diablo2-web/scripts/verify-entity-packs.ts

291 lines
12 KiB
TypeScript

/**
* 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()
}