241 lines
11 KiB
TypeScript
241 lines
11 KiB
TypeScript
/**
|
|
* Prove a pack equals what the archives say.
|
|
*
|
|
* A prebaked pack is only trustworthy if it is indistinguishable from decoding
|
|
* the MPQs directly, so this script rebuilds every packed map through the live
|
|
* path — mount, tables, DS1, DT1, isometric scene — and compares it against the
|
|
* pack's JSON field by field:
|
|
*
|
|
* 1. map extent (cells, scene size, origin)
|
|
* 2. every floor and wall draw, in order, by frame rect and position
|
|
* 3. the collision grid, byte for byte
|
|
* 4. the spawn point
|
|
* 5. each frame's indexed pixels, by hash (the pack stores hashes, not pixels)
|
|
* 6. object placements (art is deliberately not baked yet)
|
|
*
|
|
* Anything that differs is a bug in the packer or in the shared code, and the
|
|
* script exits non-zero rather than letting a subtly wrong pack ship.
|
|
*
|
|
* Usage: node scripts/verify-packs.ts [archive-directory] [pack-directory]
|
|
*/
|
|
import { readFile } from 'node:fs/promises'
|
|
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 { loadActTables, resolveLevel } from '../src/game/acts.ts'
|
|
import { decodeDs1 } from '../src/formats/ds1.ts'
|
|
import { decodeDt1 } from '../src/formats/dt1.ts'
|
|
import { loadObjectsTable, resolveDs1Object } from '../src/game/objects.ts'
|
|
import { levelSeed, buildIsoMapScene, findIsoSpawn } from '../src/game/d2map.ts'
|
|
|
|
const [archiveDir = 'samples/d2', packDir = 'samples/d2-packs'] = process.argv.slice(2)
|
|
|
|
/** Packed scene fields this script reads. */
|
|
interface PackedScene {
|
|
readonly act: number
|
|
readonly levelId: number
|
|
readonly levelName: string
|
|
readonly ds1: string
|
|
readonly cellsX: number
|
|
readonly cellsY: number
|
|
readonly originX: number
|
|
readonly originY: number
|
|
readonly widthPx: number
|
|
readonly heightPx: number
|
|
readonly frames: readonly (readonly number[])[]
|
|
readonly frameHash: readonly string[]
|
|
readonly framePlacement: readonly (readonly number[])[]
|
|
readonly floors: readonly (readonly number[])[]
|
|
readonly walls: readonly (readonly number[])[]
|
|
/** Roof draws, painted last; absent in packs baked before roofs were split out. */
|
|
readonly roofs?: readonly (readonly number[])[]
|
|
readonly objects: readonly {
|
|
readonly id: number
|
|
readonly type: number
|
|
readonly name: string
|
|
/** Token the hardcoded object lookup table resolves this DS1 id to. */
|
|
readonly token: string
|
|
/** Animation mode token the engine places the object in. */
|
|
readonly mode: string
|
|
/** `Objects.txt` row the table points at, or -1 when it points at none. */
|
|
readonly objectsTxtId: number
|
|
}[]
|
|
readonly collision: { readonly width: number; readonly height: number; readonly runs: readonly (readonly number[])[] }
|
|
readonly spawn: readonly number[] | null
|
|
}
|
|
|
|
/**
|
|
* FNV-1a over a frame's indexed pixels — the same function the packer used.
|
|
*
|
|
* @param indices - palette indices.
|
|
* @returns an 8-character hex digest.
|
|
*/
|
|
function frameHash(indices: Uint8Array): string {
|
|
let hash = 0x811c9dc5
|
|
for (const byte of indices) {
|
|
hash ^= byte
|
|
hash = Math.imul(hash, 0x01000193) >>> 0
|
|
}
|
|
return hash.toString(16).padStart(8, '0')
|
|
}
|
|
|
|
const archives = new MountedArchives()
|
|
for (const name of ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
|
archives.add(name, await MpqArchive.open(await fileSource(join(archiveDir, name))))
|
|
}
|
|
const tables = await loadActTables(archives)
|
|
const index = JSON.parse(await readFile(join(packDir, 'index.json'), 'utf8')) as {
|
|
levels: readonly { act: number; levelId: number; path: string; ds1: string; label: string }[]
|
|
}
|
|
|
|
let checks = 0
|
|
let failures = 0
|
|
let comparedPixels = 0
|
|
|
|
/**
|
|
* Assert one expectation.
|
|
*
|
|
* @param ok - whether it held.
|
|
* @param message - what was checked.
|
|
*/
|
|
function check(ok: boolean, message: string): void {
|
|
checks += 1
|
|
if (!ok) { failures += 1; console.log(` FAIL ${message}`) }
|
|
}
|
|
|
|
/** DS1 object ids the community lookup table does not cover, summed over every map. */
|
|
let unknownObjectIds = 0
|
|
|
|
/** The hardcoded object lookup needs `Objects.txt` for metadata; load it once. */
|
|
const objectsTable = await loadObjectsTable(archives)
|
|
|
|
for (const entry of index.levels) {
|
|
const packed = JSON.parse(await readFile(join(packDir, entry.path, 'scene.json'), 'utf8')) as PackedScene
|
|
const info = resolveLevel(tables, packed.levelId, packed.act)
|
|
const libraries = []
|
|
for (const name of info.dt1Names) libraries.push(decodeDt1(await archives.read(name)))
|
|
const level = decodeDs1(await archives.read(packed.ds1))
|
|
const live = buildIsoMapScene(level, libraries, levelSeed(packed.ds1))
|
|
|
|
check(packed.levelName === info.levelName, `${entry.path}: level name`)
|
|
check(packed.cellsX === live.cellsX && packed.cellsY === live.cellsY, `${entry.path}: cell extent`)
|
|
check(packed.originX === live.originX && packed.originY === live.originY, `${entry.path}: scene origin`)
|
|
check(packed.widthPx === live.widthPx && packed.heightPx === live.heightPx, `${entry.path}: scene size`)
|
|
|
|
// Draws: same count, same order, same frame rect and position.
|
|
check(packed.floors.length === live.floors.length, `${entry.path}: floor count ${String(packed.floors.length)} vs ${String(live.floors.length)}`)
|
|
check(packed.walls.length === live.walls.length, `${entry.path}: wall count ${String(packed.walls.length)} vs ${String(live.walls.length)}`)
|
|
const rectOf = (frameIndex: number): string => (packed.framePlacement[frameIndex] ?? []).slice(1).join(',')
|
|
let floorMismatch = 0
|
|
for (let at = 0; at < Math.min(packed.floors.length, live.floors.length); at += 1) {
|
|
const packedRow = packed.floors[at]!
|
|
const liveDraw = live.floors[at]!
|
|
if (packedRow[1] !== liveDraw.x || packedRow[2] !== liveDraw.y || packedRow[3] !== liveDraw.cellX || packedRow[4] !== liveDraw.cellY) floorMismatch += 1
|
|
}
|
|
check(floorMismatch === 0, `${entry.path}: floor draw geometry (${String(floorMismatch)} mismatches)`)
|
|
let wallMismatch = 0
|
|
for (let at = 0; at < Math.min(packed.walls.length, live.walls.length); at += 1) {
|
|
const packedRow = packed.walls[at]!
|
|
const liveDraw = live.walls[at]!
|
|
if (packedRow[1] !== liveDraw.x || packedRow[2] !== liveDraw.y) wallMismatch += 1
|
|
}
|
|
check(wallMismatch === 0, `${entry.path}: wall draw geometry and order (${String(wallMismatch)} mismatches)`)
|
|
|
|
// Roofs are a separate pass in the engine, so they are compared as their own
|
|
// list: same count, same order, same geometry.
|
|
const packedRoofs = packed.roofs ?? []
|
|
check(packedRoofs.length === live.roofs.length,
|
|
`${entry.path}: roof count ${String(packedRoofs.length)} vs ${String(live.roofs.length)}`)
|
|
let roofMismatch = 0
|
|
for (let at = 0; at < Math.min(packedRoofs.length, live.roofs.length); at += 1) {
|
|
const packedRow = packedRoofs[at]!
|
|
const liveDraw = live.roofs[at]!
|
|
if (packedRow[1] !== liveDraw.x || packedRow[2] !== liveDraw.y) roofMismatch += 1
|
|
}
|
|
check(roofMismatch === 0, `${entry.path}: roof draw geometry and order (${String(roofMismatch)} mismatches)`)
|
|
|
|
// Collision grid: expand the runs and compare bytes.
|
|
const expanded = new Uint8Array(packed.collision.width * packed.collision.height)
|
|
let cursor = 0
|
|
for (const run of packed.collision.runs) {
|
|
const value = run[0] ?? 0
|
|
const length = run[1] ?? 0
|
|
if (value !== 0) expanded.fill(value, cursor, Math.min(cursor + length, expanded.length))
|
|
cursor += length
|
|
}
|
|
check(packed.collision.width === live.gridWidth, `${entry.path}: collision width`)
|
|
check(packed.collision.height === live.gridHeight, `${entry.path}: collision height`)
|
|
let collisionDiff = 0
|
|
for (let at = 0; at < Math.min(expanded.length, live.blocked.length); at += 1) {
|
|
if (expanded[at] !== live.blocked[at]) collisionDiff += 1
|
|
}
|
|
check(collisionDiff === 0, `${entry.path}: collision grid (${String(collisionDiff)} differing sub-tiles)`)
|
|
|
|
// Spawn.
|
|
const liveSpawn = findIsoSpawn(live)
|
|
const packedSpawn = packed.spawn
|
|
check(
|
|
(packedSpawn === null && liveSpawn === null)
|
|
|| (packedSpawn !== null && liveSpawn !== null && packedSpawn[0] === Math.round(liveSpawn.x) && packedSpawn[1] === Math.round(liveSpawn.y)),
|
|
`${entry.path}: spawn point`,
|
|
)
|
|
|
|
// Frames: same tiles, same pixels (by hash), and every frame reachable on a page.
|
|
check(packed.frames.length === live.frames.length, `${entry.path}: frame count`)
|
|
let hashMismatch = 0
|
|
let offPage = 0
|
|
for (let at = 0; at < Math.min(packed.frames.length, live.frames.length); at += 1) {
|
|
const liveFrame = live.frames[at]!
|
|
if (frameHash(liveFrame.indices) !== packed.frameHash[at]) hashMismatch += 1
|
|
const place = packed.framePlacement[at]
|
|
if (place === undefined || (place[3] ?? 0) !== liveFrame.width || (place[4] ?? 0) !== liveFrame.height) offPage += 1
|
|
comparedPixels += liveFrame.indices.byteLength
|
|
}
|
|
check(hashMismatch === 0, `${entry.path}: frame pixel hashes (${String(hashMismatch)} differ)`)
|
|
check(offPage === 0, `${entry.path}: frame sizes match their page placement (${String(offPage)} differ)`)
|
|
void rectOf
|
|
|
|
// Objects: same placements, same count (art is not baked yet, by design).
|
|
// The packer skips DS1 monster spawn points (`type` 1) and any id the hardcoded
|
|
// object table does not know, so the expected count is "entries that resolve to a
|
|
// real object", not the raw DS1 count. Token/mode are compared too: they come from
|
|
// the lookup table, which is the part a wrong mapping would silently corrupt.
|
|
// A few DS1 ids are simply absent from the community table; the packer records
|
|
// them as unresolved instead of guessing, so they are excluded here the same way.
|
|
let unknownIds = 0
|
|
const expected: { object: (typeof level.objects)[number]; token: string; mode: string }[] = []
|
|
for (const object of level.objects) {
|
|
try {
|
|
const resolved = resolveDs1Object(objectsTable, packed.act, object.type, object.id)
|
|
if (resolved.kind === 'object') expected.push({ object, token: resolved.token, mode: resolved.mode })
|
|
} catch {
|
|
unknownIds += 1
|
|
}
|
|
}
|
|
check(packed.objects.length === expected.length,
|
|
`${entry.path}: object count ${String(packed.objects.length)} vs ${String(expected.length)}`)
|
|
let tokenMismatch = 0
|
|
for (let index = 0; index < Math.min(packed.objects.length, expected.length); index += 1) {
|
|
const baked = packed.objects[index]!
|
|
const want = expected[index]!
|
|
if (baked.token !== want.token || baked.mode !== (want.mode === '' ? 'NU' : want.mode)) {
|
|
tokenMismatch += 1
|
|
if (tokenMismatch <= 3) {
|
|
console.log(` FAIL ${entry.path}: object ${String(index)} ${baked.token}/${baked.mode} != ${want.token}/${want.mode}`)
|
|
}
|
|
}
|
|
}
|
|
check(tokenMismatch === 0, `${entry.path}: object tokens/modes match the lookup (${String(tokenMismatch)} mismatches)`)
|
|
unknownObjectIds += unknownIds
|
|
|
|
console.log(
|
|
`${entry.path.padEnd(26)} ${String(live.floors.length).padStart(5)} 地面 ${String(live.walls.length).padStart(4)} 墙 ${String(live.roofs.length).padStart(4)} 顶 `
|
|
+ `${String(live.frames.length).padStart(3)} 帧 ${String(packed.collision.width)}x${String(packed.collision.height)} 碰撞 `
|
|
+ `${String(expected.length).padStart(4)} 对象 ${collisionDiff === 0 && hashMismatch === 0 ? '一致' : '不一致'}`,
|
|
)
|
|
}
|
|
|
|
console.log(`\n${String(checks - failures)}/${String(checks)} 项断言通过(逐像素比对了 ${(comparedPixels / 1048576).toFixed(1)} MB 的索引数据)`)
|
|
if (failures > 0) process.exit(1)
|