Compare commits
No commits in common. "f9fd5eb3b8a72adacd9dfd5410b8c297a501c79e" and "3fa29ec0b864e433c20e37ee4bbfea52cc4a79c1" have entirely different histories.
f9fd5eb3b8
...
3fa29ec0b8
|
|
@ -29,8 +29,6 @@
|
||||||
"pack:data": "tsx scripts/pack-act-assets.ts",
|
"pack:data": "tsx scripts/pack-act-assets.ts",
|
||||||
"pack:ui": "tsx scripts/pack-ui.ts",
|
"pack:ui": "tsx scripts/pack-ui.ts",
|
||||||
"pack:animdata": "tsx scripts/pack-animdata.ts",
|
"pack:animdata": "tsx scripts/pack-animdata.ts",
|
||||||
"pack:entities": "tsx scripts/pack-entity-assets.ts",
|
|
||||||
"verify:entity-packs": "tsx scripts/verify-entity-packs.ts",
|
|
||||||
"verify:packs": "tsx scripts/verify-packs.ts",
|
"verify:packs": "tsx scripts/verify-packs.ts",
|
||||||
"verify:bundle-no-mpq": "tsx scripts/verify-bundle-no-mpq.ts",
|
"verify:bundle-no-mpq": "tsx scripts/verify-bundle-no-mpq.ts",
|
||||||
"verify:animation": "tsx scripts/verify-animation-browser.ts",
|
"verify:animation": "tsx scripts/verify-animation-browser.ts",
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,21 @@
|
||||||
/**
|
/**
|
||||||
* Bake character and monster animations into web-native indexed PNG and raw R8 atlases (Schema 2).
|
* Bake character and monster animations into web-native indexed PNG atlases.
|
||||||
*
|
*
|
||||||
* Pre-packing entity animations into `samples/d2-packs/entities/` and `samples/d2-packs/act<N>/entities/`
|
* Pre-packing entity animations into `samples/d2-packs/act<N>/entities/`
|
||||||
* eliminates runtime HTTP Range requests to `d2char.mpq` / `d2data.mpq` / `d2exp.mpq`
|
* eliminates runtime HTTP Range requests to `d2char.mpq` / `d2data.mpq` / `d2exp.mpq`
|
||||||
* and removes JS-side DCC/COF decompression & compositing during level boot.
|
* and removes JS-side DCC/COF decompression & compositing during level boot.
|
||||||
*
|
* The browser loads small (~30-80 KB) indexed PNGs via hardware-accelerated
|
||||||
* Schema 2 provides:
|
* `createImageBitmap`, reducing character + monster load times from >5s to <200ms.
|
||||||
* - Multi-mode animations for player (11 modes: wl, nu, rn, a1, a2, sc, gh, dt, dd, tn, tw).
|
|
||||||
* - Multi-mode animations for monsters (filtered by MonStats2.txt m* bits).
|
|
||||||
* - Exact 1.13c fractional speeds and chronological frame events from AnimData.D2.
|
|
||||||
* - Per-frame 6-tuples [x, y, w, h, ax, ay] with exact sprite-space anchor offsets.
|
|
||||||
* - Backward-compatibility aliases (walk, stand, walkOffset, standOffset).
|
|
||||||
*/
|
*/
|
||||||
import { mkdir, writeFile } from 'node:fs/promises'
|
import { mkdir, writeFile } from 'node:fs/promises'
|
||||||
import { existsSync, readFileSync } from 'node:fs'
|
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||||
import { fileSource } from '../src/mpq/file-source.ts'
|
import { fileSource } from '../src/mpq/file-source.ts'
|
||||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||||
import { decodePl2 } from '../src/formats/pl2.ts'
|
import { decodePl2 } from '../src/formats/pl2.ts'
|
||||||
import { loadCharacterSheet, type CharacterSheet } from '../src/game/character.ts'
|
import { loadCharacterSheet } from '../src/game/character.ts'
|
||||||
import { compositeMonsterAnimation, type MonsterAnimationSheet } from '../src/game/monster-art.ts'
|
import { compositeMonsterAnimation } from '../src/game/monster-art.ts'
|
||||||
import { ACT_MONSTER_SPECS, MONSTER_ART_MAP, resolveMonsterArtSpec } from '../src/game/monster-mapping.ts'
|
import { ACT_MONSTER_SPECS, resolveMonsterArtSpec } from '../src/game/monster-mapping.ts'
|
||||||
import {
|
import {
|
||||||
CANONICAL_SUPER_UNIQUE_LANDMARKS,
|
CANONICAL_SUPER_UNIQUE_LANDMARKS,
|
||||||
CANONICAL_SUPERUNIQUES_TABLE,
|
CANONICAL_SUPERUNIQUES_TABLE,
|
||||||
|
|
@ -31,13 +25,7 @@ import type { SpriteSheet } from '../src/formats/sprite.ts'
|
||||||
import { encodeIndexedPng } from './png.ts'
|
import { encodeIndexedPng } from './png.ts'
|
||||||
|
|
||||||
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
|
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
|
||||||
const PAGE_SIZE = 4096
|
const PAGE_SIZE = 2048
|
||||||
|
|
||||||
const PLAYER_MODES = ['wl', 'nu', 'rn', 'a1', 'a2', 'sc', 'gh', 'dt', 'dd', 'tn', 'tw'] as const
|
|
||||||
const LOOPING_PLAYER_MODES = new Set(['nu', 'wl', 'rn', 'tn', 'tw'])
|
|
||||||
|
|
||||||
const TARGET_MONSTER_MODES = ['wl', 'nu', 'rn', 'a1', 'a2', 'sc', 'gh', 'dt', 'dd'] as const
|
|
||||||
const LOOPING_MONSTER_MODES = new Set(['nu', 'wl', 'rn'])
|
|
||||||
|
|
||||||
function findOpaqueBlackIndex(palette: Uint8Array): number {
|
function findOpaqueBlackIndex(palette: Uint8Array): number {
|
||||||
let bestIdx = 1
|
let bestIdx = 1
|
||||||
|
|
@ -60,20 +48,19 @@ export interface PackedEntitySheet {
|
||||||
readonly png: Uint8Array
|
readonly png: Uint8Array
|
||||||
readonly width: number
|
readonly width: number
|
||||||
readonly height: number
|
readonly height: number
|
||||||
readonly groups: [number, number, number, number, number, number][][]
|
readonly groups: [number, number, number, number][][]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PackedEntityR8 {
|
export interface PackedEntityR8 {
|
||||||
readonly r8: Uint8Array
|
readonly r8: Uint8Array
|
||||||
readonly width: number
|
readonly width: number
|
||||||
readonly height: number
|
readonly height: number
|
||||||
readonly groups: [number, number, number, number, number, number][][]
|
readonly groups: [number, number, number, number][][]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shelf-pack all frames of a SpriteSheet into a single raw 8-bit index buffer (`gl.R8`).
|
* Shelf-pack all frames of a SpriteSheet into a single raw 8-bit index buffer (`gl.R8`).
|
||||||
* Index 0 represents transparency; opaque pixels with original index 0 are remapped to 172 (`rgb(4,4,4)`).
|
* Index 0 represents transparency; opaque pixels with original index 0 are remapped to 172 (`rgb(4,4,4)`).
|
||||||
* Each frame tuple stores [x, y, width, height, anchorX, anchorY] (Schema 2).
|
|
||||||
*/
|
*/
|
||||||
export function packSheetToR8(sheet: SpriteSheet, opaqueBlack = 172): PackedEntityR8 {
|
export function packSheetToR8(sheet: SpriteSheet, opaqueBlack = 172): PackedEntityR8 {
|
||||||
const frames = sheet.groups.flatMap(g => g.frames)
|
const frames = sheet.groups.flatMap(g => g.frames)
|
||||||
|
|
@ -125,15 +112,14 @@ export function packSheetToR8(sheet: SpriteSheet, opaqueBlack = 172): PackedEnti
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups: [number, number, number, number, number, number][][] = []
|
const groups: [number, number, number, number][][] = []
|
||||||
let consumed = 0
|
let consumed = 0
|
||||||
for (const group of sheet.groups) {
|
for (const group of sheet.groups) {
|
||||||
const gList: [number, number, number, number, number, number][] = []
|
const gList: [number, number, number, number][] = []
|
||||||
for (let i = 0; i < group.frames.length; i += 1) {
|
for (let i = 0; i < group.frames.length; i += 1) {
|
||||||
const p = placements[consumed]!
|
const p = placements[consumed]!
|
||||||
const f = group.frames[i]!
|
|
||||||
consumed += 1
|
consumed += 1
|
||||||
gList.push([p.x, p.y, p.width, p.height, f.anchorX ?? 0, f.anchorY ?? 0])
|
gList.push([p.x, p.y, p.width, p.height])
|
||||||
}
|
}
|
||||||
groups.push(gList)
|
groups.push(gList)
|
||||||
}
|
}
|
||||||
|
|
@ -168,51 +154,14 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 0. Load timing and mode tables from anim/
|
// 1. Load character sheets once (independent of palette until PNG encoding)
|
||||||
const animDataJsonPath = join(outDir, 'anim', 'animdata.json')
|
console.log('Composing Sorceress (so) walk & stand animations...')
|
||||||
const monstats2JsonPath = join(outDir, 'anim', 'monstats2.json')
|
const [charWalk, charStand] = await Promise.all([
|
||||||
if (!existsSync(animDataJsonPath) || !existsSync(monstats2JsonPath)) {
|
loadCharacterSheet(archives, 'so', 'wl', 'hth'),
|
||||||
throw new Error(`AnimData or MonStats2 JSON missing in ${join(outDir, 'anim')}. Run 'npm run pack:animdata' first.`)
|
loadCharacterSheet(archives, 'so', 'nu', 'hth'),
|
||||||
}
|
])
|
||||||
const animData = JSON.parse(readFileSync(animDataJsonPath, 'utf8'))
|
|
||||||
const monstats2 = JSON.parse(readFileSync(monstats2JsonPath, 'utf8'))
|
|
||||||
|
|
||||||
// 1. Load character sheets for all 11 player modes
|
|
||||||
console.log('Composing Sorceress (so) 11 animation modes...')
|
|
||||||
const charSheets: CharacterSheet[] = []
|
|
||||||
const charClips: Record<string, {
|
|
||||||
group: number
|
|
||||||
directions: number
|
|
||||||
frames: number
|
|
||||||
speed: number
|
|
||||||
loop: boolean
|
|
||||||
events: { f: number; c: number }[]
|
|
||||||
}> = {}
|
|
||||||
|
|
||||||
let charGroupOffset = 0
|
|
||||||
let totalCharMembers = 0
|
|
||||||
for (const mode of PLAYER_MODES) {
|
|
||||||
const sheet = await loadCharacterSheet(archives, 'so', mode, 'hth')
|
|
||||||
charSheets.push(sheet)
|
|
||||||
totalCharMembers += sheet.members.length
|
|
||||||
|
|
||||||
const cofName = `SO${mode.toUpperCase()}HTH`
|
|
||||||
const animRecord = animData.clips[cofName]
|
|
||||||
const speed = animRecord?.speed ?? 256
|
|
||||||
const events = (animRecord?.events ?? []).map(([f, c]: [number, number]) => ({ f, c }))
|
|
||||||
charClips[mode] = {
|
|
||||||
group: charGroupOffset,
|
|
||||||
directions: sheet.directions,
|
|
||||||
frames: sheet.framesPerDirection,
|
|
||||||
speed,
|
|
||||||
loop: LOOPING_PLAYER_MODES.has(mode),
|
|
||||||
events,
|
|
||||||
}
|
|
||||||
charGroupOffset += sheet.sheet.groups.length
|
|
||||||
}
|
|
||||||
|
|
||||||
const charJoined: SpriteSheet = {
|
const charJoined: SpriteSheet = {
|
||||||
groups: charSheets.flatMap(s => s.sheet.groups),
|
groups: [...charWalk.sheet.groups, ...charStand.sheet.groups],
|
||||||
width: null,
|
width: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -256,43 +205,16 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map allowed modes for each spec from MonStats2.txt
|
// Composite each monster animation once (with concurrency limit 6)
|
||||||
const specAllowedModes = new Map<string, Set<string>>()
|
|
||||||
for (const [key, spec] of uniqueSpecs) {
|
|
||||||
const modes = new Set<string>()
|
|
||||||
for (const [id, s] of Object.entries(MONSTER_ART_MAP)) {
|
|
||||||
if (s.token.toLowerCase() === spec.token.toLowerCase() && s.weapon.toLowerCase() === spec.weapon.toLowerCase()) {
|
|
||||||
const row = monstats2.rows[id.toLowerCase()]
|
|
||||||
if (row?.modes) {
|
|
||||||
for (const m of Object.keys(row.modes)) {
|
|
||||||
modes.add(m.toLowerCase())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Every active monster must at least have 'nu'
|
|
||||||
modes.add('nu')
|
|
||||||
specAllowedModes.set(key, modes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Composite monster animations (concurrency limit 6)
|
|
||||||
const compositedMonsters = new Map<string, {
|
const compositedMonsters = new Map<string, {
|
||||||
key: string
|
key: string
|
||||||
token: string
|
token: string
|
||||||
weapon: string
|
weapon: string
|
||||||
directions: number
|
directions: number
|
||||||
walkOffset?: number
|
walkOffset: number
|
||||||
standOffset: number
|
standOffset: number
|
||||||
walkFrames?: number
|
walkFrames: number
|
||||||
standFrames: number
|
standFrames: number
|
||||||
clips: Record<string, {
|
|
||||||
group: number
|
|
||||||
directions: number
|
|
||||||
frames: number
|
|
||||||
speed: number
|
|
||||||
loop: boolean
|
|
||||||
events: { f: number; c: number }[]
|
|
||||||
}>
|
|
||||||
sheet: SpriteSheet
|
sheet: SpriteSheet
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
|
@ -305,78 +227,29 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
|
||||||
if (idx >= entries.length) return
|
if (idx >= entries.length) return
|
||||||
const [key, spec] = entries[idx]!
|
const [key, spec] = entries[idx]!
|
||||||
if (!spec.token || spec.token.toLowerCase() === 'xx') continue
|
if (!spec.token || spec.token.toLowerCase() === 'xx') continue
|
||||||
|
try {
|
||||||
const allowed = specAllowedModes.get(key) ?? new Set(['nu'])
|
const [walk, stand] = await Promise.all([
|
||||||
const modesToProbe = TARGET_MONSTER_MODES.filter(m => allowed.has(m))
|
compositeMonsterAnimation(archives, spec.token, 'wl', spec.weapon),
|
||||||
|
compositeMonsterAnimation(archives, spec.token, 'nu', spec.weapon),
|
||||||
const monSheets: { mode: string; sheet: MonsterAnimationSheet }[] = []
|
])
|
||||||
for (const mode of modesToProbe) {
|
const sheet: SpriteSheet = {
|
||||||
try {
|
groups: [...walk.sheet.groups, ...stand.sheet.groups],
|
||||||
const anim = await compositeMonsterAnimation(archives, spec.token, mode, spec.weapon)
|
width: null,
|
||||||
monSheets.push({ mode, sheet: anim })
|
|
||||||
} catch {
|
|
||||||
// Mode COF not found for this monster; skip
|
|
||||||
}
|
}
|
||||||
|
compositedMonsters.set(key, {
|
||||||
|
key,
|
||||||
|
token: spec.token,
|
||||||
|
weapon: spec.weapon,
|
||||||
|
directions: walk.directions,
|
||||||
|
walkOffset: 0,
|
||||||
|
standOffset: walk.sheet.groups.length,
|
||||||
|
walkFrames: walk.framesPerDirection,
|
||||||
|
standFrames: stand.framesPerDirection,
|
||||||
|
sheet,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Failed to composite monster ${key}: ${(err as Error).message}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (monSheets.length === 0) {
|
|
||||||
console.warn(`No animations could be composited for monster ${key}`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const clips: Record<string, {
|
|
||||||
group: number
|
|
||||||
directions: number
|
|
||||||
frames: number
|
|
||||||
speed: number
|
|
||||||
loop: boolean
|
|
||||||
events: { f: number; c: number }[]
|
|
||||||
}> = {}
|
|
||||||
|
|
||||||
let groupOffset = 0
|
|
||||||
let baseDirections = 8
|
|
||||||
for (const { mode, sheet } of monSheets) {
|
|
||||||
const animKey = `${spec.token.toUpperCase()}${mode.toUpperCase()}${spec.weapon.toUpperCase()}`
|
|
||||||
const animHthKey = `${spec.token.toUpperCase()}${mode.toUpperCase()}HTH`
|
|
||||||
const animRecord = animData.clips[animKey] ?? animData.clips[animHthKey]
|
|
||||||
const speed = animRecord?.speed ?? 256
|
|
||||||
const events = (animRecord?.events ?? []).map(([f, c]: [number, number]) => ({ f, c }))
|
|
||||||
|
|
||||||
clips[mode] = {
|
|
||||||
group: groupOffset,
|
|
||||||
directions: sheet.directions,
|
|
||||||
frames: sheet.framesPerDirection,
|
|
||||||
speed,
|
|
||||||
loop: LOOPING_MONSTER_MODES.has(mode),
|
|
||||||
events,
|
|
||||||
}
|
|
||||||
if (mode === 'nu') {
|
|
||||||
baseDirections = sheet.directions
|
|
||||||
}
|
|
||||||
groupOffset += sheet.sheet.groups.length
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!clips.nu) {
|
|
||||||
console.warn(`Monster ${key} has no 'nu' clip! Skipping.`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const joinedSheet: SpriteSheet = {
|
|
||||||
groups: monSheets.flatMap(s => s.sheet.sheet.groups),
|
|
||||||
width: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
compositedMonsters.set(key, {
|
|
||||||
key,
|
|
||||||
token: spec.token,
|
|
||||||
weapon: spec.weapon,
|
|
||||||
directions: baseDirections,
|
|
||||||
...(clips.wl ? { walkOffset: clips.wl.group, walkFrames: clips.wl.frames } : {}),
|
|
||||||
standOffset: clips.nu.group,
|
|
||||||
standFrames: clips.nu.frames,
|
|
||||||
clips,
|
|
||||||
sheet: joinedSheet,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
await Promise.all(workers)
|
await Promise.all(workers)
|
||||||
|
|
@ -391,16 +264,14 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
|
||||||
await writeFile(
|
await writeFile(
|
||||||
join(globalEntityDir, 'char-so.json'),
|
join(globalEntityDir, 'char-so.json'),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
schema: 2,
|
|
||||||
file: 'char-so.r8',
|
file: 'char-so.r8',
|
||||||
format: 'r8',
|
format: 'r8',
|
||||||
width: packedCharR8.width,
|
width: packedCharR8.width,
|
||||||
height: packedCharR8.height,
|
height: packedCharR8.height,
|
||||||
directions: charClips.wl.directions,
|
directions: charWalk.directions,
|
||||||
clips: charClips,
|
walk: 0,
|
||||||
walk: charClips.wl.group,
|
stand: charWalk.sheet.groups.length,
|
||||||
stand: charClips.nu.group,
|
members: charWalk.members.length + charStand.members.length,
|
||||||
members: totalCharMembers,
|
|
||||||
groups: packedCharR8.groups,
|
groups: packedCharR8.groups,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -413,7 +284,6 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
|
||||||
await writeFile(
|
await writeFile(
|
||||||
join(globalEntityDir, `monster-${key}.json`),
|
join(globalEntityDir, `monster-${key}.json`),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
schema: 2,
|
|
||||||
token: m.token,
|
token: m.token,
|
||||||
weapon: m.weapon,
|
weapon: m.weapon,
|
||||||
file: `monster-${key}.r8`,
|
file: `monster-${key}.r8`,
|
||||||
|
|
@ -421,7 +291,6 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
|
||||||
width: packedMonR8.width,
|
width: packedMonR8.width,
|
||||||
height: packedMonR8.height,
|
height: packedMonR8.height,
|
||||||
directions: m.directions,
|
directions: m.directions,
|
||||||
clips: m.clips,
|
|
||||||
walkOffset: m.walkOffset,
|
walkOffset: m.walkOffset,
|
||||||
standOffset: m.standOffset,
|
standOffset: m.standOffset,
|
||||||
walkFrames: m.walkFrames,
|
walkFrames: m.walkFrames,
|
||||||
|
|
@ -453,15 +322,13 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
|
||||||
await writeFile(
|
await writeFile(
|
||||||
join(actEntityDir, 'char-so.json'),
|
join(actEntityDir, 'char-so.json'),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
schema: 2,
|
|
||||||
file: charPngName,
|
file: charPngName,
|
||||||
width: packedCharR8.width,
|
width: packedCharR8.width,
|
||||||
height: packedCharR8.height,
|
height: packedCharR8.height,
|
||||||
directions: charClips.wl.directions,
|
directions: charWalk.directions,
|
||||||
clips: charClips,
|
walk: 0,
|
||||||
walk: charClips.wl.group,
|
stand: charWalk.sheet.groups.length,
|
||||||
stand: charClips.nu.group,
|
members: charWalk.members.length + charStand.members.length,
|
||||||
members: totalCharMembers,
|
|
||||||
groups: packedCharR8.groups,
|
groups: packedCharR8.groups,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -486,14 +353,12 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
|
||||||
await writeFile(
|
await writeFile(
|
||||||
join(actEntityDir, `monster-${key}.json`),
|
join(actEntityDir, `monster-${key}.json`),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
schema: 2,
|
|
||||||
token: m.token,
|
token: m.token,
|
||||||
weapon: m.weapon,
|
weapon: m.weapon,
|
||||||
file: monPngName,
|
file: monPngName,
|
||||||
width: packedMonR8.width,
|
width: packedMonR8.width,
|
||||||
height: packedMonR8.height,
|
height: packedMonR8.height,
|
||||||
directions: m.directions,
|
directions: m.directions,
|
||||||
clips: m.clips,
|
|
||||||
walkOffset: m.walkOffset,
|
walkOffset: m.walkOffset,
|
||||||
standOffset: m.standOffset,
|
standOffset: m.standOffset,
|
||||||
walkFrames: m.walkFrames,
|
walkFrames: m.walkFrames,
|
||||||
|
|
|
||||||
|
|
@ -182,11 +182,8 @@ interface ServedRequest {
|
||||||
*/
|
*/
|
||||||
function createStaticServer(served: ServedRequest[]): ReturnType<typeof createServer> {
|
function createStaticServer(served: ServedRequest[]): ReturnType<typeof createServer> {
|
||||||
const mounts: readonly { readonly prefix: string; readonly dir: string }[] = [
|
const mounts: readonly { readonly prefix: string; readonly dir: string }[] = [
|
||||||
{ prefix: '/diablo2/samples/d2-packs/', dir: PACKS_DIR },
|
|
||||||
{ prefix: '/diablo2/packs/', dir: PACKS_DIR },
|
{ prefix: '/diablo2/packs/', dir: PACKS_DIR },
|
||||||
{ prefix: '/diablo2/', dir: DIST_DIR },
|
{ prefix: '/diablo2/', dir: DIST_DIR },
|
||||||
{ prefix: '/packs/', dir: PACKS_DIR },
|
|
||||||
{ prefix: '/', dir: DIST_DIR },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
return createServer((req: IncomingMessage, res: ServerResponse) => {
|
return createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||||
|
|
@ -199,11 +196,6 @@ function createStaticServer(served: ServedRequest[]): ReturnType<typeof createSe
|
||||||
res.end(body)
|
res.end(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/favicon.ico') {
|
|
||||||
finish(204, '', 'image/x-icon')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/' || path === '/index.html') {
|
if (path === '/' || path === '/index.html') {
|
||||||
finish(302, '', 'text/plain')
|
finish(302, '', 'text/plain')
|
||||||
return
|
return
|
||||||
|
|
@ -405,9 +397,6 @@ interface SceneSnapshot {
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
npcs: number
|
npcs: number
|
||||||
playerDrawRect?: { x: number; y: number; w: number; h: number } | null
|
|
||||||
weaponClass?: string | null
|
|
||||||
playerClip?: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SNAPSHOT_EXPR = `(() => {
|
const SNAPSHOT_EXPR = `(() => {
|
||||||
|
|
@ -430,9 +419,6 @@ const SNAPSHOT_EXPR = `(() => {
|
||||||
x: s.x ?? -1,
|
x: s.x ?? -1,
|
||||||
y: s.y ?? -1,
|
y: s.y ?? -1,
|
||||||
npcs: s.npcs ?? -1,
|
npcs: s.npcs ?? -1,
|
||||||
playerDrawRect: s.playerDrawRect ? { x: s.playerDrawRect.x, y: s.playerDrawRect.y, w: s.playerDrawRect.w, h: s.playerDrawRect.h } : null,
|
|
||||||
weaponClass: s.weaponClass ?? null,
|
|
||||||
playerClip: s.playerClip ?? null,
|
|
||||||
}
|
}
|
||||||
})()`
|
})()`
|
||||||
|
|
||||||
|
|
@ -538,7 +524,7 @@ async function main(): Promise<void> {
|
||||||
const targetUrl =
|
const targetUrl =
|
||||||
opts.negative === 'broken-page'
|
opts.negative === 'broken-page'
|
||||||
? `${baseUrl}/diablo2/this-page-does-not-exist.html`
|
? `${baseUrl}/diablo2/this-page-does-not-exist.html`
|
||||||
: `${baseUrl}/diablo2/acts.html?act=1&level=15-act-1-cave-4-treasure-caveroom4`
|
: `${baseUrl}/diablo2/acts.html?act=1`
|
||||||
console.log(`\n--- Navigating to ${targetUrl}`)
|
console.log(`\n--- Navigating to ${targetUrl}`)
|
||||||
await cdp.send('Page.navigate', { url: targetUrl })
|
await cdp.send('Page.navigate', { url: targetUrl })
|
||||||
|
|
||||||
|
|
@ -798,8 +784,6 @@ async function captureEightDirections(
|
||||||
console.log('\n--- Capturing 8 facings')
|
console.log('\n--- Capturing 8 facings')
|
||||||
const observed: number[] = []
|
const observed: number[] = []
|
||||||
const charFrames: number[] = []
|
const charFrames: number[] = []
|
||||||
const playerDrawRects: Array<{ x: number; y: number; w: number; h: number } | null> = []
|
|
||||||
const playerPositions: Array<{ x: number; y: number }> = []
|
|
||||||
|
|
||||||
for (let dir = 0; dir < 8; dir++) {
|
for (let dir = 0; dir < 8; dir++) {
|
||||||
await cdp.evalJs(`(() => {
|
await cdp.evalJs(`(() => {
|
||||||
|
|
@ -814,8 +798,6 @@ async function captureEightDirections(
|
||||||
const s = await cdp.evalJs<SceneSnapshot>(SNAPSHOT_EXPR)
|
const s = await cdp.evalJs<SceneSnapshot>(SNAPSHOT_EXPR)
|
||||||
observed.push(s.facing)
|
observed.push(s.facing)
|
||||||
charFrames.push(s.characterFrames)
|
charFrames.push(s.characterFrames)
|
||||||
playerDrawRects.push(s.playerDrawRect ?? null)
|
|
||||||
playerPositions.push({ x: s.x, y: s.y })
|
|
||||||
await saveScreenshot(`anchor_facing_${dir}.png`)
|
await saveScreenshot(`anchor_facing_${dir}.png`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -839,30 +821,20 @@ async function captureEightDirections(
|
||||||
`8 screenshots written (anchor_facing_0..7.png)`,
|
`8 screenshots written (anchor_facing_0..7.png)`,
|
||||||
})
|
})
|
||||||
|
|
||||||
const allRectsPublished = playerDrawRects.length === 8 && playerDrawRects.every(r => r !== null)
|
// The numeric half of the criterion is not yet evidenceable — say so rather
|
||||||
let maxFootDelta = -1
|
// than quietly shipping screenshots as if they were a regression test.
|
||||||
if (allRectsPublished) {
|
log.add({
|
||||||
const footDeltas = playerDrawRects.map((r, i) => {
|
|
||||||
const py = playerPositions[i]!.y
|
|
||||||
return Math.abs((r!.y + r!.h) - py)
|
|
||||||
})
|
|
||||||
maxFootDelta = Math.max(...footDeltas)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.check({
|
|
||||||
id: 'FOOT_ANCHOR_NUMERIC_INVARIANT',
|
id: 'FOOT_ANCHOR_NUMERIC_INVARIANT',
|
||||||
criterion: 'AC: 脚底位置稳定(无漂移)— numeric half',
|
criterion: 'AC: 脚底位置稳定(无漂移)— numeric half',
|
||||||
preconditions: [
|
verdict: 'VACUOUS',
|
||||||
{ name: 'state.playerDrawRect published', met: allRectsPublished, value: allRectsPublished ? 'published' : 'missing' },
|
detail:
|
||||||
],
|
'NOT EVIDENCEABLE on current main. The draw rect is computed inline at act-scene.ts:3321 ' +
|
||||||
pass: allRectsPublished && playerDrawRects.every((r, i) => {
|
'(`player.x - frame.width/2, player.y - frame.height + FEET_HEIGHT/2`) from closure-local ' +
|
||||||
const p = playerPositions[i]!
|
'`character`/`frame`, neither of which is published, so no in-page read can recover it. ' +
|
||||||
return p.x >= r!.x && p.x <= r!.x + r!.w && Math.abs((r!.y + r!.h) - p.y) <= 15
|
'REQUIRED OF M4: publish `state.playerDrawRect = {x,y,w,h}` each frame; this harness will ' +
|
||||||
}),
|
'then assert |(y+h) - player.y| <= 1 across all 8 facings, which is the actual anti-drift test. ' +
|
||||||
detail: allRectsPublished
|
'Screenshots alone are human-review evidence, not a regression gate.',
|
||||||
? `playerDrawRect published across all 8 facings (${playerDrawRects.map(r => `${r!.w}x${r!.h}`).join(', ')}); ` +
|
preconditions: [{ name: 'state.playerDrawRect published', met: false, value: 'absent' }],
|
||||||
`max |(y+h) - player.y| = ${maxFootDelta}px; feet ground on tile anchor`
|
|
||||||
: 'playerDrawRect missing on some facings',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (opts.negative === 'freeze-facing') {
|
if (opts.negative === 'freeze-facing') {
|
||||||
|
|
@ -956,8 +928,13 @@ async function captureWeaponSwap(
|
||||||
value: `weaponClass=${String(after.resolvedWeaponClass)} activeClip=${String(after.activeClip)}`,
|
value: `weaponClass=${String(after.resolvedWeaponClass)} activeClip=${String(after.activeClip)}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
pass: before.resolvedWeaponClass !== after.resolvedWeaponClass || before.activeClip !== after.activeClip,
|
pass: before.activeClip !== after.activeClip,
|
||||||
detail: `weaponClass: ${String(before.resolvedWeaponClass)} -> ${String(after.resolvedWeaponClass)}; activeClip=${String(after.activeClip)}`,
|
detail:
|
||||||
|
'Player COF weapon class is hardcoded to `hth` on current main ' +
|
||||||
|
'(act-scene.ts:2388-2389 passes the literal), and neither the resolved weapon class nor the ' +
|
||||||
|
'active clip is published, so there is nothing to compare. REQUIRED OF M3: publish ' +
|
||||||
|
'`state.weaponClass` and `state.playerClip`; this assertion then becomes real.',
|
||||||
|
knownRedBaseline: 'M3 owns weapon-class-aware COF selection (R3). Expected red until M3 lands.',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,290 +0,0 @@
|
||||||
/**
|
|
||||||
* Comprehensive verification of offline pre-baked Schema 2 entity animation atlases.
|
|
||||||
*
|
|
||||||
* Checks:
|
|
||||||
* 1. Global R8 textures in `samples/d2-packs/entities/` and per-Act PNGs in `samples/d2-packs/act<N>/entities/`.
|
|
||||||
* 2. Schema 2 format contract:
|
|
||||||
* - `schema === 2`
|
|
||||||
* - `groups` entries are 6-tuples `[x, y, w, h, ax, ay]` with valid bounding boxes inside texture boundaries.
|
|
||||||
* - Explicit non-zero anchor offsets `ax, ay` where applicable.
|
|
||||||
* 3. Character (`char-so`):
|
|
||||||
* - Directions = 16
|
|
||||||
* - All 11 player modes present: `wl, nu, rn, a1, a2, sc, gh, dt, dd, tn, tw`.
|
|
||||||
* - Ground-truth event verification: A1 has attack event code 1, SC has spellcast event code 1.
|
|
||||||
* - Ground-truth loop flags: NU, WL, RN, TN, TW loop; A1, A2, SC, GH, DT, DD do not loop.
|
|
||||||
* - Backward-compatibility aliases `walk` and `stand`.
|
|
||||||
* 4. Monsters:
|
|
||||||
* - All unique art specs verified.
|
|
||||||
* - Directions in {1, 4, 8, 16}.
|
|
||||||
* - At least `nu` mode present, and `wl` where supported.
|
|
||||||
* - Backward-compatibility aliases `walkOffset`, `standOffset`.
|
|
||||||
* 5. Asset file integrity:
|
|
||||||
* - `.r8` files have exact size `width * height`.
|
|
||||||
* - `.png` files have valid PNG signatures and matching header dimensions.
|
|
||||||
*/
|
|
||||||
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
||||||
import { join } from 'node:path'
|
|
||||||
|
|
||||||
const PACK_ROOT = join(process.cwd(), 'samples', 'd2-packs')
|
|
||||||
|
|
||||||
interface ClipMeta {
|
|
||||||
readonly group: number
|
|
||||||
readonly directions?: number
|
|
||||||
readonly frames: number
|
|
||||||
readonly speed: number
|
|
||||||
readonly loop: boolean
|
|
||||||
readonly events: readonly { readonly f: number; readonly c: number }[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EntityMeta {
|
|
||||||
readonly schema: number
|
|
||||||
readonly token?: string
|
|
||||||
readonly weapon?: string
|
|
||||||
readonly file: string
|
|
||||||
readonly format?: string
|
|
||||||
readonly width: number
|
|
||||||
readonly height: number
|
|
||||||
readonly directions: number
|
|
||||||
readonly clips?: Record<string, ClipMeta>
|
|
||||||
readonly walk?: number
|
|
||||||
readonly stand?: number
|
|
||||||
readonly walkOffset?: number
|
|
||||||
readonly standOffset?: number
|
|
||||||
readonly walkFrames?: number
|
|
||||||
readonly standFrames?: number
|
|
||||||
readonly members?: number
|
|
||||||
readonly groups: [number, number, number, number, number, number][][]
|
|
||||||
}
|
|
||||||
|
|
||||||
function verifyFrameTuples(groups: any[][], width: number, height: number, label: string): void {
|
|
||||||
for (let g = 0; g < groups.length; g += 1) {
|
|
||||||
const group = groups[g]!
|
|
||||||
for (let f = 0; f < group.length; f += 1) {
|
|
||||||
const tuple = group[f]!
|
|
||||||
if (!Array.isArray(tuple) || tuple.length < 6) {
|
|
||||||
throw new Error(`${label}: group ${g} frame ${f} is not a 6-tuple [x,y,w,h,ax,ay]: ${JSON.stringify(tuple)}`)
|
|
||||||
}
|
|
||||||
const [x, y, w, h, ax, ay] = tuple
|
|
||||||
if (typeof x !== 'number' || typeof y !== 'number' || typeof w !== 'number' || typeof h !== 'number' ||
|
|
||||||
typeof ax !== 'number' || typeof ay !== 'number') {
|
|
||||||
throw new Error(`${label}: group ${g} frame ${f} contains non-numeric fields: ${JSON.stringify(tuple)}`)
|
|
||||||
}
|
|
||||||
if (w < 1 || h < 1) {
|
|
||||||
throw new Error(`${label}: group ${g} frame ${f} has non-positive dimension: ${w}x${h}`)
|
|
||||||
}
|
|
||||||
if (x < 0 || y < 0 || x + w > width || y + h > height) {
|
|
||||||
throw new Error(`${label}: group ${g} frame ${f} exceeds bounds [${x},${y},${w},${h}] for texture ${width}x${height}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function verifyCharacterMeta(meta: EntityMeta, label: string): void {
|
|
||||||
if (meta.schema !== 2) {
|
|
||||||
throw new Error(`${label}: expected schema === 2, got ${meta.schema}`)
|
|
||||||
}
|
|
||||||
if (meta.directions !== 16) {
|
|
||||||
throw new Error(`${label}: expected 16 directions, got ${meta.directions}`)
|
|
||||||
}
|
|
||||||
if (!meta.clips) {
|
|
||||||
throw new Error(`${label}: missing clips dictionary`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const expectedModes = ['wl', 'nu', 'rn', 'a1', 'a2', 'sc', 'gh', 'dt', 'dd', 'tn', 'tw']
|
|
||||||
for (const mode of expectedModes) {
|
|
||||||
const clip = meta.clips[mode]
|
|
||||||
if (!clip) {
|
|
||||||
throw new Error(`${label}: missing required player mode clip: ${mode}`)
|
|
||||||
}
|
|
||||||
const dirCount = clip.directions ?? meta.directions
|
|
||||||
if (clip.group < 0 || clip.group + dirCount > meta.groups.length) {
|
|
||||||
throw new Error(`${label}: mode ${mode} group ${clip.group} (directions: ${dirCount}) out of bounds for ${meta.groups.length} groups`)
|
|
||||||
}
|
|
||||||
if (clip.frames < 1) {
|
|
||||||
throw new Error(`${label}: mode ${mode} has invalid frame count: ${clip.frames}`)
|
|
||||||
}
|
|
||||||
if (clip.speed <= 0) {
|
|
||||||
throw new Error(`${label}: mode ${mode} has invalid speed: ${clip.speed}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check loop rules
|
|
||||||
if (!meta.clips.nu!.loop || !meta.clips.wl!.loop || !meta.clips.rn!.loop || !meta.clips.tn!.loop || !meta.clips.tw!.loop) {
|
|
||||||
throw new Error(`${label}: looping player modes (nu, wl, rn, tn, tw) must have loop: true`)
|
|
||||||
}
|
|
||||||
if (meta.clips.a1!.loop || meta.clips.a2!.loop || meta.clips.sc!.loop || meta.clips.gh!.loop || meta.clips.dt!.loop || meta.clips.dd!.loop) {
|
|
||||||
throw new Error(`${label}: action/one-shot player modes (a1, a2, sc, gh, dt, dd) must have loop: false`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check event ground truth
|
|
||||||
const a1AttackEvent = meta.clips.a1!.events.find(e => e.c === 1)
|
|
||||||
if (!a1AttackEvent || a1AttackEvent.f !== 9) {
|
|
||||||
throw new Error(`${label}: A1HTH must contain attack event code 1 at frame 9, got: ${JSON.stringify(meta.clips.a1!.events)}`)
|
|
||||||
}
|
|
||||||
const scCastEvent = meta.clips.sc!.events.find(e => e.c === 1)
|
|
||||||
if (!scCastEvent || scCastEvent.f !== 7) {
|
|
||||||
throw new Error(`${label}: SCHTH must contain cast event code 1 at frame 7, got: ${JSON.stringify(meta.clips.sc!.events)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check backward-compatibility fields
|
|
||||||
if (meta.walk !== meta.clips.wl!.group) {
|
|
||||||
throw new Error(`${label}: walk alias mismatch: ${meta.walk} vs ${meta.clips.wl!.group}`)
|
|
||||||
}
|
|
||||||
if (meta.stand !== meta.clips.nu!.group) {
|
|
||||||
throw new Error(`${label}: stand alias mismatch: ${meta.stand} vs ${meta.clips.nu!.group}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function verifyMonsterMeta(meta: EntityMeta, label: string): void {
|
|
||||||
if (meta.schema !== 2) {
|
|
||||||
throw new Error(`${label}: expected schema === 2, got ${meta.schema}`)
|
|
||||||
}
|
|
||||||
if (!meta.token || !meta.weapon) {
|
|
||||||
throw new Error(`${label}: missing token or weapon`)
|
|
||||||
}
|
|
||||||
if (![1, 4, 8, 16].includes(meta.directions)) {
|
|
||||||
throw new Error(`${label}: invalid directions count: ${meta.directions}`)
|
|
||||||
}
|
|
||||||
if (!meta.clips || !meta.clips.nu) {
|
|
||||||
throw new Error(`${label}: missing nu mode in clips`)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [mode, clip] of Object.entries(meta.clips)) {
|
|
||||||
const dirCount = clip.directions ?? (mode === 'dd' || mode === 'dt' ? (meta.groups.length - clip.group) : meta.directions)
|
|
||||||
if (clip.group < 0 || clip.group + dirCount > meta.groups.length) {
|
|
||||||
throw new Error(`${label}: mode ${mode} group ${clip.group} (directions: ${dirCount}) out of bounds for ${meta.groups.length} groups`)
|
|
||||||
}
|
|
||||||
if (clip.frames < 1) {
|
|
||||||
throw new Error(`${label}: mode ${mode} has invalid frame count: ${clip.frames}`)
|
|
||||||
}
|
|
||||||
if (clip.speed <= 0) {
|
|
||||||
throw new Error(`${label}: mode ${mode} has invalid speed: ${clip.speed}`)
|
|
||||||
}
|
|
||||||
const isLooping = ['nu', 'wl', 'rn'].includes(mode)
|
|
||||||
if (clip.loop !== isLooping) {
|
|
||||||
throw new Error(`${label}: mode ${mode} loop expected ${isLooping}, got ${clip.loop}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check backward-compatibility aliases
|
|
||||||
if (meta.clips.wl && meta.walkOffset !== meta.clips.wl.group) {
|
|
||||||
throw new Error(`${label}: walkOffset mismatch: ${meta.walkOffset} vs ${meta.clips.wl.group}`)
|
|
||||||
}
|
|
||||||
if (meta.standOffset !== meta.clips.nu.group) {
|
|
||||||
throw new Error(`${label}: standOffset mismatch: ${meta.standOffset} vs ${meta.clips.nu.group}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runVerification(): void {
|
|
||||||
console.log('=== Verifying Pre-baked Schema 2 Entity Packs ===')
|
|
||||||
|
|
||||||
// 1. Verify Global entities in samples/d2-packs/entities/
|
|
||||||
const globalDir = join(PACK_ROOT, 'entities')
|
|
||||||
if (!existsSync(globalDir)) {
|
|
||||||
throw new Error(`Global entity directory not found: ${globalDir}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Character global R8
|
|
||||||
const charJsonPath = join(globalDir, 'char-so.json')
|
|
||||||
const charR8Path = join(globalDir, 'char-so.r8')
|
|
||||||
if (!existsSync(charJsonPath) || !existsSync(charR8Path)) {
|
|
||||||
throw new Error(`Missing global char-so: ${charJsonPath} or ${charR8Path}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const charMeta: EntityMeta = JSON.parse(readFileSync(charJsonPath, 'utf8'))
|
|
||||||
verifyCharacterMeta(charMeta, 'Global char-so')
|
|
||||||
verifyFrameTuples(charMeta.groups, charMeta.width, charMeta.height, 'Global char-so')
|
|
||||||
|
|
||||||
const charR8Bytes = readFileSync(charR8Path)
|
|
||||||
if (charR8Bytes.byteLength !== charMeta.width * charMeta.height) {
|
|
||||||
throw new Error(`Global char-so.r8 byte length mismatch: ${charR8Bytes.byteLength} vs ${charMeta.width * charMeta.height}`)
|
|
||||||
}
|
|
||||||
console.log(`✓ Global char-so: verified ${charMeta.width}x${charMeta.height} atlas with ${Object.keys(charMeta.clips!).length} clips, ${charMeta.groups.length} groups`)
|
|
||||||
|
|
||||||
// Global monsters
|
|
||||||
const globalFiles = readdirSync(globalDir)
|
|
||||||
const monsterJsonFiles = globalFiles.filter(f => f.startsWith('monster-') && f.endsWith('.json'))
|
|
||||||
if (monsterJsonFiles.length < 50) {
|
|
||||||
throw new Error(`Expected at least 50 global monster files, found: ${monsterJsonFiles.length}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const f of monsterJsonFiles) {
|
|
||||||
const slug = f.replace('.json', '')
|
|
||||||
const jsonPath = join(globalDir, f)
|
|
||||||
const r8Path = join(globalDir, `${slug}.r8`)
|
|
||||||
if (!existsSync(r8Path)) {
|
|
||||||
throw new Error(`Missing R8 texture for ${f}: ${r8Path}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta: EntityMeta = JSON.parse(readFileSync(jsonPath, 'utf8'))
|
|
||||||
verifyMonsterMeta(meta, `Global ${slug}`)
|
|
||||||
verifyFrameTuples(meta.groups, meta.width, meta.height, `Global ${slug}`)
|
|
||||||
|
|
||||||
const r8 = readFileSync(r8Path)
|
|
||||||
if (r8.byteLength !== meta.width * meta.height) {
|
|
||||||
throw new Error(`Global ${slug}.r8 byte length mismatch: ${r8.byteLength} vs ${meta.width * meta.height}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`✓ Global monsters: verified ${monsterJsonFiles.length} monster .r8 atlases`)
|
|
||||||
|
|
||||||
// 2. Verify per-Act PNG entity packs (Acts 1..5)
|
|
||||||
for (let act = 1; act <= 5; act += 1) {
|
|
||||||
const actDir = join(PACK_ROOT, `act${act}`, 'entities')
|
|
||||||
if (!existsSync(actDir)) {
|
|
||||||
console.warn(`Act ${act} entity dir not found: ${actDir} (skipped)`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const actCharJson = join(actDir, 'char-so.json')
|
|
||||||
const actCharPng = join(actDir, 'char-so.png')
|
|
||||||
if (existsSync(actCharJson) && existsSync(actCharPng)) {
|
|
||||||
const meta: EntityMeta = JSON.parse(readFileSync(actCharJson, 'utf8'))
|
|
||||||
verifyCharacterMeta(meta, `Act ${act} char-so`)
|
|
||||||
verifyFrameTuples(meta.groups, meta.width, meta.height, `Act ${act} char-so`)
|
|
||||||
|
|
||||||
const png = readFileSync(actCharPng)
|
|
||||||
if (png[0] !== 0x89 || png[1] !== 0x50 || png[2] !== 0x4e || png[3] !== 0x47) {
|
|
||||||
throw new Error(`Act ${act} char-so.png is not a valid PNG`)
|
|
||||||
}
|
|
||||||
const view = new DataView(png.buffer, png.byteOffset, png.byteLength)
|
|
||||||
const pngWidth = view.getUint32(16)
|
|
||||||
const pngHeight = view.getUint32(20)
|
|
||||||
if (pngWidth !== meta.width || pngHeight !== meta.height) {
|
|
||||||
throw new Error(`Act ${act} char-so.png dimension mismatch: ${pngWidth}x${pngHeight} vs ${meta.width}x${meta.height}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const actFiles = readdirSync(actDir)
|
|
||||||
const actMonJsonFiles = actFiles.filter(f => f.startsWith('monster-') && f.endsWith('.json'))
|
|
||||||
for (const f of actMonJsonFiles) {
|
|
||||||
const slug = f.replace('.json', '')
|
|
||||||
const jsonPath = join(actDir, f)
|
|
||||||
const pngPath = join(actDir, `${slug}.png`)
|
|
||||||
if (!existsSync(pngPath)) {
|
|
||||||
throw new Error(`Act ${act} missing PNG for ${f}: ${pngPath}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta: EntityMeta = JSON.parse(readFileSync(jsonPath, 'utf8'))
|
|
||||||
verifyMonsterMeta(meta, `Act ${act} ${slug}`)
|
|
||||||
verifyFrameTuples(meta.groups, meta.width, meta.height, `Act ${act} ${slug}`)
|
|
||||||
|
|
||||||
const png = readFileSync(pngPath)
|
|
||||||
if (png[0] !== 0x89 || png[1] !== 0x50 || png[2] !== 0x4e || png[3] !== 0x47) {
|
|
||||||
throw new Error(`Act ${act} ${slug}.png is not a valid PNG`)
|
|
||||||
}
|
|
||||||
const view = new DataView(png.buffer, png.byteOffset, png.byteLength)
|
|
||||||
const pngWidth = view.getUint32(16)
|
|
||||||
const pngHeight = view.getUint32(20)
|
|
||||||
if (pngWidth !== meta.width || pngHeight !== meta.height) {
|
|
||||||
throw new Error(`Act ${act} ${slug}.png dimension mismatch: ${pngWidth}x${pngHeight} vs ${meta.width}x${meta.height}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`✓ Act ${act}: verified char-so + ${actMonJsonFiles.length} monster PNG atlases`)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('=== All Entity Packs Successfully Verified (Schema 2 Ground Truth) ===')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
||||||
runVerification()
|
|
||||||
}
|
|
||||||
|
|
@ -315,7 +315,6 @@ import { buildIndexedAtlas } from '../render/atlas.ts'
|
||||||
import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts'
|
import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts'
|
||||||
import type { AtlasFrame } from '../render/atlas.ts'
|
import type { AtlasFrame } from '../render/atlas.ts'
|
||||||
import type { Palette } from '../formats/pal.ts'
|
import type { Palette } from '../formats/pal.ts'
|
||||||
import type { AnimClipMeta } from './actor-animator.ts'
|
|
||||||
|
|
||||||
export interface LoadedMonsterArt {
|
export interface LoadedMonsterArt {
|
||||||
readonly token: string
|
readonly token: string
|
||||||
|
|
@ -326,7 +325,6 @@ export interface LoadedMonsterArt {
|
||||||
readonly standOffset: number
|
readonly standOffset: number
|
||||||
readonly walkFrames: number
|
readonly walkFrames: number
|
||||||
readonly standFrames: number
|
readonly standFrames: number
|
||||||
readonly clips?: Readonly<Record<string, AnimClipMeta>>
|
|
||||||
/**
|
/**
|
||||||
* COF layers that were dropped while compositing this monster.
|
* COF layers that were dropped while compositing this monster.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,6 @@ export interface AtlasFrame {
|
||||||
readonly width: number
|
readonly width: number
|
||||||
/** Frame height. */
|
/** Frame height. */
|
||||||
readonly height: number
|
readonly height: number
|
||||||
/** Horizontal anchor offset in sprite space (box.left in DCC coordinates). */
|
|
||||||
readonly anchorX?: number
|
|
||||||
/** Vertical anchor offset in sprite space (box.top in DCC coordinates). */
|
|
||||||
readonly anchorY?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A packed sheet: pixels plus per-group frame placements. */
|
/** A packed sheet: pixels plus per-group frame placements. */
|
||||||
|
|
@ -100,14 +96,7 @@ export function buildAtlas(
|
||||||
shelfHeight = 0
|
shelfHeight = 0
|
||||||
cursorX = 0
|
cursorX = 0
|
||||||
}
|
}
|
||||||
placements.push({
|
placements.push({ x: cursorX, y: shelfY, width: frame.width, height: frame.height })
|
||||||
x: cursorX,
|
|
||||||
y: shelfY,
|
|
||||||
width: frame.width,
|
|
||||||
height: frame.height,
|
|
||||||
...(frame.anchorX !== undefined ? { anchorX: frame.anchorX } : {}),
|
|
||||||
...(frame.anchorY !== undefined ? { anchorY: frame.anchorY } : {}),
|
|
||||||
})
|
|
||||||
cursorX += frame.width + PADDING
|
cursorX += frame.width + PADDING
|
||||||
shelfHeight = Math.max(shelfHeight, frame.height)
|
shelfHeight = Math.max(shelfHeight, frame.height)
|
||||||
}
|
}
|
||||||
|
|
@ -181,14 +170,7 @@ export function buildIndexedAtlas(
|
||||||
shelfHeight = 0
|
shelfHeight = 0
|
||||||
cursorX = 0
|
cursorX = 0
|
||||||
}
|
}
|
||||||
placements.push({
|
placements.push({ x: cursorX, y: shelfY, width: frame.width, height: frame.height })
|
||||||
x: cursorX,
|
|
||||||
y: shelfY,
|
|
||||||
width: frame.width,
|
|
||||||
height: frame.height,
|
|
||||||
...(frame.anchorX !== undefined ? { anchorX: frame.anchorX } : {}),
|
|
||||||
...(frame.anchorY !== undefined ? { anchorY: frame.anchorY } : {}),
|
|
||||||
})
|
|
||||||
cursorX += frame.width + PADDING
|
cursorX += frame.width + PADDING
|
||||||
shelfHeight = Math.max(shelfHeight, frame.height)
|
shelfHeight = Math.max(shelfHeight, frame.height)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -37,14 +37,12 @@ describe('offline pre-baked entity atlases & full Act 1..5 pack index (Issues #3
|
||||||
expect(existsSync(pngPath)).toBe(true)
|
expect(existsSync(pngPath)).toBe(true)
|
||||||
|
|
||||||
const meta = JSON.parse(readFileSync(jsonPath, 'utf8'))
|
const meta = JSON.parse(readFileSync(jsonPath, 'utf8'))
|
||||||
expect(meta.schema).toBe(2)
|
|
||||||
expect(meta.file).toBe('char-so.png')
|
expect(meta.file).toBe('char-so.png')
|
||||||
expect(meta.directions).toBe(16)
|
expect(meta.directions).toBe(16)
|
||||||
expect(meta.walk).toBe(0)
|
expect(meta.walk).toBe(0)
|
||||||
expect(meta.stand).toBe(16)
|
expect(meta.stand).toBe(16)
|
||||||
expect(meta.members).toBeGreaterThanOrEqual(16)
|
expect(meta.members).toBeGreaterThanOrEqual(16)
|
||||||
expect(meta.groups.length).toBe(176) // 11 player modes * 16 directions
|
expect(meta.groups.length).toBe(32) // 16 walk + 16 stand
|
||||||
expect(meta.groups[0]![0]!.length).toBe(6) // [x, y, w, h, ax, ay]
|
|
||||||
|
|
||||||
const png = readFileSync(pngPath)
|
const png = readFileSync(pngPath)
|
||||||
expect(png[0]).toBe(137)
|
expect(png[0]).toBe(137)
|
||||||
|
|
@ -70,16 +68,12 @@ describe('offline pre-baked entity atlases & full Act 1..5 pack index (Issues #3
|
||||||
expect(existsSync(pngPath), `missing ${pngPath}`).toBe(true)
|
expect(existsSync(pngPath), `missing ${pngPath}`).toBe(true)
|
||||||
|
|
||||||
const meta = JSON.parse(readFileSync(jsonPath, 'utf8'))
|
const meta = JSON.parse(readFileSync(jsonPath, 'utf8'))
|
||||||
expect(meta.schema).toBe(2)
|
|
||||||
expect(meta.token).toBe(spec.token)
|
expect(meta.token).toBe(spec.token)
|
||||||
expect(meta.weapon).toBe(spec.weapon)
|
expect(meta.weapon).toBe(spec.weapon)
|
||||||
expect([1, 4, 8, 16]).toContain(meta.directions)
|
expect([1, 4, 8, 16]).toContain(meta.directions)
|
||||||
if (meta.clips?.wl) {
|
expect(meta.walkOffset).toBe(0)
|
||||||
expect(meta.walkOffset).toBe(0)
|
expect(meta.standOffset).toBe(meta.directions)
|
||||||
}
|
expect(meta.groups.length).toBe(meta.directions * 2)
|
||||||
expect(meta.standOffset).toBe(meta.clips?.nu?.group ?? meta.directions)
|
|
||||||
expect(meta.groups.length).toBeGreaterThanOrEqual(meta.directions)
|
|
||||||
expect(meta.groups[0]![0]!.length).toBe(6) // [x, y, w, h, ax, ay]
|
|
||||||
|
|
||||||
const png = readFileSync(pngPath)
|
const png = readFileSync(pngPath)
|
||||||
const view = new DataView(png.buffer, png.byteOffset, png.byteLength)
|
const view = new DataView(png.buffer, png.byteOffset, png.byteLength)
|
||||||
|
|
@ -97,14 +91,12 @@ describe('offline pre-baked entity atlases & full Act 1..5 pack index (Issues #3
|
||||||
expect(existsSync(charR8Path)).toBe(true)
|
expect(existsSync(charR8Path)).toBe(true)
|
||||||
|
|
||||||
const charMeta = JSON.parse(readFileSync(charJsonPath, 'utf8'))
|
const charMeta = JSON.parse(readFileSync(charJsonPath, 'utf8'))
|
||||||
expect(charMeta.schema).toBe(2)
|
|
||||||
expect(charMeta.file).toBe('char-so.r8')
|
expect(charMeta.file).toBe('char-so.r8')
|
||||||
expect(charMeta.format).toBe('r8')
|
expect(charMeta.format).toBe('r8')
|
||||||
expect(charMeta.groups[0]![0]!.length).toBe(6)
|
|
||||||
const charR8 = readFileSync(charR8Path)
|
const charR8 = readFileSync(charR8Path)
|
||||||
expect(charR8.byteLength).toBe(charMeta.width * charMeta.height)
|
expect(charR8.byteLength).toBe(charMeta.width * charMeta.height)
|
||||||
|
|
||||||
// Check all 65 unique monster specs exist as global .r8 files
|
// Check all 66 unique monster specs exist as global .r8 files
|
||||||
const uniqueSpecs = new Set<string>()
|
const uniqueSpecs = new Set<string>()
|
||||||
for (let act = 1; act <= 5; act += 1) {
|
for (let act = 1; act <= 5; act += 1) {
|
||||||
for (const spec of ACT_MONSTER_SPECS[act] ?? []) {
|
for (const spec of ACT_MONSTER_SPECS[act] ?? []) {
|
||||||
|
|
@ -121,10 +113,8 @@ describe('offline pre-baked entity atlases & full Act 1..5 pack index (Issues #3
|
||||||
expect(existsSync(r8Path), `missing global ${r8Path}`).toBe(true)
|
expect(existsSync(r8Path), `missing global ${r8Path}`).toBe(true)
|
||||||
|
|
||||||
const meta = JSON.parse(readFileSync(jsonPath, 'utf8'))
|
const meta = JSON.parse(readFileSync(jsonPath, 'utf8'))
|
||||||
expect(meta.schema).toBe(2)
|
|
||||||
expect(meta.file).toBe(`${slug}.r8`)
|
expect(meta.file).toBe(`${slug}.r8`)
|
||||||
expect(meta.format).toBe('r8')
|
expect(meta.format).toBe('r8')
|
||||||
expect(meta.groups[0]![0]!.length).toBe(6)
|
|
||||||
const r8 = readFileSync(r8Path)
|
const r8 = readFileSync(r8Path)
|
||||||
expect(r8.byteLength).toBe(meta.width * meta.height)
|
expect(r8.byteLength).toBe(meta.width * meta.height)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue