From 75eba8d9ffb497306d9a399e2d8776f6d6cfc125 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Mon, 21 Sep 2026 14:03:17 +0000 Subject: [PATCH] =?UTF-8?q?feat(anim):=20=E5=AE=9E=E7=8E=B0=20Schema=202?= =?UTF-8?q?=20=E5=AE=9E=E4=BD=93=E5=9B=BE=E9=9B=86=E6=89=93=E5=8C=85?= =?UTF-8?q?=E4=B8=8E=E5=85=A8=E9=87=8F=E9=AA=8C=E8=AF=81=20(M1=20=E5=AE=8C?= =?UTF-8?q?=E6=88=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 + scripts/pack-entity-assets.ts | 240 ++++++++++++++++++++------ scripts/verify-entity-packs.ts | 290 ++++++++++++++++++++++++++++++++ tests/packed-entity-art.test.ts | 20 ++- 4 files changed, 495 insertions(+), 57 deletions(-) create mode 100644 scripts/verify-entity-packs.ts diff --git a/package.json b/package.json index cd3897a..e1ffabe 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "pack:data": "tsx scripts/pack-act-assets.ts", "pack:ui": "tsx scripts/pack-ui.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:bundle-no-mpq": "tsx scripts/verify-bundle-no-mpq.ts", "verify:animation": "tsx scripts/verify-animation-browser.ts", diff --git a/scripts/pack-entity-assets.ts b/scripts/pack-entity-assets.ts index a68125d..f7f775d 100644 --- a/scripts/pack-entity-assets.ts +++ b/scripts/pack-entity-assets.ts @@ -1,21 +1,27 @@ /** - * Bake character and monster animations into web-native indexed PNG atlases. + * Bake character and monster animations into web-native indexed PNG and raw R8 atlases (Schema 2). * - * Pre-packing entity animations into `samples/d2-packs/act/entities/` + * Pre-packing entity animations into `samples/d2-packs/entities/` and `samples/d2-packs/act/entities/` * eliminates runtime HTTP Range requests to `d2char.mpq` / `d2data.mpq` / `d2exp.mpq` * and removes JS-side DCC/COF decompression & compositing during level boot. - * The browser loads small (~30-80 KB) indexed PNGs via hardware-accelerated - * `createImageBitmap`, reducing character + monster load times from >5s to <200ms. + * + * Schema 2 provides: + * - 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 { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { MpqArchive } from '../src/mpq/archive.ts' import { fileSource } from '../src/mpq/file-source.ts' import { MountedArchives } from '../src/mpq/mount.ts' import { decodePl2 } from '../src/formats/pl2.ts' -import { loadCharacterSheet } from '../src/game/character.ts' -import { compositeMonsterAnimation } from '../src/game/monster-art.ts' -import { ACT_MONSTER_SPECS, resolveMonsterArtSpec } from '../src/game/monster-mapping.ts' +import { loadCharacterSheet, type CharacterSheet } from '../src/game/character.ts' +import { compositeMonsterAnimation, type MonsterAnimationSheet } from '../src/game/monster-art.ts' +import { ACT_MONSTER_SPECS, MONSTER_ART_MAP, resolveMonsterArtSpec } from '../src/game/monster-mapping.ts' import { CANONICAL_SUPER_UNIQUE_LANDMARKS, CANONICAL_SUPERUNIQUES_TABLE, @@ -25,7 +31,13 @@ import type { SpriteSheet } from '../src/formats/sprite.ts' import { encodeIndexedPng } from './png.ts' const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const -const PAGE_SIZE = 2048 +const PAGE_SIZE = 4096 + +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 { let bestIdx = 1 @@ -48,19 +60,20 @@ export interface PackedEntitySheet { readonly png: Uint8Array readonly width: number readonly height: number - readonly groups: [number, number, number, number][][] + readonly groups: [number, number, number, number, number, number][][] } export interface PackedEntityR8 { readonly r8: Uint8Array readonly width: number readonly height: number - readonly groups: [number, number, number, number][][] + readonly groups: [number, number, number, number, number, number][][] } /** * 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)`). + * Each frame tuple stores [x, y, width, height, anchorX, anchorY] (Schema 2). */ export function packSheetToR8(sheet: SpriteSheet, opaqueBlack = 172): PackedEntityR8 { const frames = sheet.groups.flatMap(g => g.frames) @@ -112,14 +125,15 @@ export function packSheetToR8(sheet: SpriteSheet, opaqueBlack = 172): PackedEnti } } - const groups: [number, number, number, number][][] = [] + const groups: [number, number, number, number, number, number][][] = [] let consumed = 0 for (const group of sheet.groups) { - const gList: [number, number, number, number][] = [] + const gList: [number, number, number, number, number, number][] = [] for (let i = 0; i < group.frames.length; i += 1) { const p = placements[consumed]! + const f = group.frames[i]! consumed += 1 - gList.push([p.x, p.y, p.width, p.height]) + gList.push([p.x, p.y, p.width, p.height, f.anchorX ?? 0, f.anchorY ?? 0]) } groups.push(gList) } @@ -154,14 +168,51 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/ } } - // 1. Load character sheets once (independent of palette until PNG encoding) - console.log('Composing Sorceress (so) walk & stand animations...') - const [charWalk, charStand] = await Promise.all([ - loadCharacterSheet(archives, 'so', 'wl', 'hth'), - loadCharacterSheet(archives, 'so', 'nu', 'hth'), - ]) + // 0. Load timing and mode tables from anim/ + const animDataJsonPath = join(outDir, 'anim', 'animdata.json') + const monstats2JsonPath = join(outDir, 'anim', 'monstats2.json') + if (!existsSync(animDataJsonPath) || !existsSync(monstats2JsonPath)) { + throw new Error(`AnimData or MonStats2 JSON missing in ${join(outDir, 'anim')}. Run 'npm run pack:animdata' first.`) + } + 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 = {} + + 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 = { - groups: [...charWalk.sheet.groups, ...charStand.sheet.groups], + groups: charSheets.flatMap(s => s.sheet.groups), width: null, } @@ -205,16 +256,43 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/ } } - // Composite each monster animation once (with concurrency limit 6) + // Map allowed modes for each spec from MonStats2.txt + const specAllowedModes = new Map>() + for (const [key, spec] of uniqueSpecs) { + const modes = new Set() + 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 sheet: SpriteSheet }>() @@ -227,29 +305,79 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/ if (idx >= entries.length) return const [key, spec] = entries[idx]! if (!spec.token || spec.token.toLowerCase() === 'xx') continue - try { - const [walk, stand] = await Promise.all([ - compositeMonsterAnimation(archives, spec.token, 'wl', spec.weapon), - compositeMonsterAnimation(archives, spec.token, 'nu', spec.weapon), - ]) - const sheet: SpriteSheet = { - groups: [...walk.sheet.groups, ...stand.sheet.groups], - width: null, + + const allowed = specAllowedModes.get(key) ?? new Set(['nu']) + const modesToProbe = TARGET_MONSTER_MODES.filter(m => allowed.has(m)) + + const monSheets: { mode: string; sheet: MonsterAnimationSheet }[] = [] + for (const mode of modesToProbe) { + try { + const anim = await compositeMonsterAnimation(archives, spec.token, mode, spec.weapon) + 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 = {} + + 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, + walkOffset: clips.wl ? clips.wl.group : undefined, + standOffset: clips.nu.group, + walkFrames: clips.wl ? clips.wl.frames : undefined, + standFrames: clips.nu.frames, + clips, + sheet: joinedSheet, + }) } }) await Promise.all(workers) @@ -264,14 +392,16 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/ await writeFile( join(globalEntityDir, 'char-so.json'), JSON.stringify({ + schema: 2, file: 'char-so.r8', format: 'r8', width: packedCharR8.width, height: packedCharR8.height, - directions: charWalk.directions, - walk: 0, - stand: charWalk.sheet.groups.length, - members: charWalk.members.length + charStand.members.length, + directions: charClips.wl.directions, + clips: charClips, + walk: charClips.wl.group, + stand: charClips.nu.group, + members: totalCharMembers, groups: packedCharR8.groups, }), ) @@ -284,6 +414,7 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/ await writeFile( join(globalEntityDir, `monster-${key}.json`), JSON.stringify({ + schema: 2, token: m.token, weapon: m.weapon, file: `monster-${key}.r8`, @@ -291,6 +422,7 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/ width: packedMonR8.width, height: packedMonR8.height, directions: m.directions, + clips: m.clips, walkOffset: m.walkOffset, standOffset: m.standOffset, walkFrames: m.walkFrames, @@ -322,13 +454,15 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/ await writeFile( join(actEntityDir, 'char-so.json'), JSON.stringify({ + schema: 2, file: charPngName, width: packedCharR8.width, height: packedCharR8.height, - directions: charWalk.directions, - walk: 0, - stand: charWalk.sheet.groups.length, - members: charWalk.members.length + charStand.members.length, + directions: charClips.wl.directions, + clips: charClips, + walk: charClips.wl.group, + stand: charClips.nu.group, + members: totalCharMembers, groups: packedCharR8.groups, }), ) @@ -353,12 +487,14 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/ await writeFile( join(actEntityDir, `monster-${key}.json`), JSON.stringify({ + schema: 2, token: m.token, weapon: m.weapon, file: monPngName, width: packedMonR8.width, height: packedMonR8.height, directions: m.directions, + clips: m.clips, walkOffset: m.walkOffset, standOffset: m.standOffset, walkFrames: m.walkFrames, diff --git a/scripts/verify-entity-packs.ts b/scripts/verify-entity-packs.ts new file mode 100644 index 0000000..a093edd --- /dev/null +++ b/scripts/verify-entity-packs.ts @@ -0,0 +1,290 @@ +/** + * 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/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 + 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() +} diff --git a/tests/packed-entity-art.test.ts b/tests/packed-entity-art.test.ts index 52b8e78..e423c79 100644 --- a/tests/packed-entity-art.test.ts +++ b/tests/packed-entity-art.test.ts @@ -37,12 +37,14 @@ describe('offline pre-baked entity atlases & full Act 1..5 pack index (Issues #3 expect(existsSync(pngPath)).toBe(true) const meta = JSON.parse(readFileSync(jsonPath, 'utf8')) + expect(meta.schema).toBe(2) expect(meta.file).toBe('char-so.png') expect(meta.directions).toBe(16) expect(meta.walk).toBe(0) expect(meta.stand).toBe(16) expect(meta.members).toBeGreaterThanOrEqual(16) - expect(meta.groups.length).toBe(32) // 16 walk + 16 stand + expect(meta.groups.length).toBe(176) // 11 player modes * 16 directions + expect(meta.groups[0]![0]!.length).toBe(6) // [x, y, w, h, ax, ay] const png = readFileSync(pngPath) expect(png[0]).toBe(137) @@ -68,12 +70,16 @@ describe('offline pre-baked entity atlases & full Act 1..5 pack index (Issues #3 expect(existsSync(pngPath), `missing ${pngPath}`).toBe(true) const meta = JSON.parse(readFileSync(jsonPath, 'utf8')) + expect(meta.schema).toBe(2) expect(meta.token).toBe(spec.token) expect(meta.weapon).toBe(spec.weapon) expect([1, 4, 8, 16]).toContain(meta.directions) - expect(meta.walkOffset).toBe(0) - expect(meta.standOffset).toBe(meta.directions) - expect(meta.groups.length).toBe(meta.directions * 2) + if (meta.clips?.wl) { + expect(meta.walkOffset).toBe(0) + } + 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 view = new DataView(png.buffer, png.byteOffset, png.byteLength) @@ -91,12 +97,14 @@ describe('offline pre-baked entity atlases & full Act 1..5 pack index (Issues #3 expect(existsSync(charR8Path)).toBe(true) const charMeta = JSON.parse(readFileSync(charJsonPath, 'utf8')) + expect(charMeta.schema).toBe(2) expect(charMeta.file).toBe('char-so.r8') expect(charMeta.format).toBe('r8') + expect(charMeta.groups[0]![0]!.length).toBe(6) const charR8 = readFileSync(charR8Path) expect(charR8.byteLength).toBe(charMeta.width * charMeta.height) - // Check all 66 unique monster specs exist as global .r8 files + // Check all 65 unique monster specs exist as global .r8 files const uniqueSpecs = new Set() for (let act = 1; act <= 5; act += 1) { for (const spec of ACT_MONSTER_SPECS[act] ?? []) { @@ -113,8 +121,10 @@ describe('offline pre-baked entity atlases & full Act 1..5 pack index (Issues #3 expect(existsSync(r8Path), `missing global ${r8Path}`).toBe(true) const meta = JSON.parse(readFileSync(jsonPath, 'utf8')) + expect(meta.schema).toBe(2) expect(meta.file).toBe(`${slug}.r8`) expect(meta.format).toBe('r8') + expect(meta.groups[0]![0]!.length).toBe(6) const r8 = readFileSync(r8Path) expect(r8.byteLength).toBe(meta.width * meta.height)