/** * 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 blendMode?: 'normal' | 'additive' readonly width: number readonly height: number readonly directions: number readonly clips?: Record readonly glowClips?: 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][][] readonly glowGroups?: [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}`) } // Check blendMode & glowGroups if present (Schema 2 refactor) if (meta.blendMode && !['normal', 'additive'].includes(meta.blendMode)) { throw new Error(`${label}: invalid blendMode: ${meta.blendMode}`) } if (meta.glowGroups) { if (meta.glowGroups.length !== meta.groups.length) { throw new Error(`${label}: glowGroups length ${meta.glowGroups.length} does not match groups length ${meta.groups.length}`) } verifyFrameTuples(meta.glowGroups, meta.width, meta.height, `${label} (glowGroups)`) } } 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() }