305 lines
13 KiB
TypeScript
305 lines
13 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 blendMode?: 'normal' | 'additive'
|
|
readonly width: number
|
|
readonly height: number
|
|
readonly directions: number
|
|
readonly clips?: Record<string, ClipMeta>
|
|
readonly glowClips?: 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][][]
|
|
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()
|
|
}
|