/** * 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 objectPages?: readonly { readonly file: string; readonly width: number; readonly height: 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 member?: string | null readonly frame?: { readonly page: number readonly x: number readonly y: number readonly width: number readonly height: number readonly offsetX: number readonly offsetY: number } | null }[] 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; kind?: 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) { if (entry.kind !== undefined && entry.kind !== 'preset') continue 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 { // Omitting the monsters tables intentionally restores the legacy drop-everything-non-object behaviour. 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 // Object frames: every object with a frame references a valid page and rect, // and no object with an art member is left pending a frame. const objectPages = packed.objectPages ?? [] let invalidFrames = 0 let pendingArt = 0 for (const obj of packed.objects) { if (obj.frame) { if (obj.frame.page < 0 || obj.frame.page >= objectPages.length || obj.frame.width <= 0 || obj.frame.height <= 0) { invalidFrames += 1 } } else if (obj.member !== null && obj.member !== undefined) { pendingArt += 1 } } check(invalidFrames === 0, `${entry.path}: object frame placement within bounds (${String(invalidFrames)} invalid)`) check(pendingArt === 0, `${entry.path}: zero objects with art pending frame (${String(pendingArt)} pending)`) 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 ? '一致' : '不一致'}`, ) } /* --------------------------------------------------------------------------- * * World connectivity * * The pixel comparison above can only run on preset levels, because a generated * level has no DS1 in the archives to rebuild it from. Links, though, are baked * for every level, and they are the part that breaks silently: an opening whose * destination is not a graph edge is a door to the void, and a graph edge with * no opening is a level you can never leave. Both are checked here, for presets * and generated levels alike. * --------------------------------------------------------------------------- */ /** The solved graph the packer wrote alongside the levels. */ interface PackedWorldGraph { readonly version: number readonly layoutSeed: number readonly levels: readonly { readonly id: number; readonly name: string; readonly waypoint: number }[] readonly edges: readonly { readonly from: number; readonly to: number; readonly kind: string }[] readonly waypoints: readonly { readonly id: number; readonly levelId: number }[] } /** The link half of a baked scene. */ interface PackedLinks { readonly levelId: number readonly entrances?: readonly { readonly toLevelId: number; readonly label: string }[] readonly warps?: readonly { readonly toLevelId: number readonly source?: 'tile' | 'room' | 'fallback' }[] readonly waypoints?: readonly { readonly waypointId: number }[] readonly stats?: { readonly unplacedEdges?: readonly { readonly toLevelId: number; readonly reason: string }[] } } const graph = JSON.parse(await readFile(join(packDir, 'world-graph.json'), 'utf8')) as PackedWorldGraph const graphLevelName = new Map(graph.levels.map(level => [level.id, level.name])) /** `from -> to` pairs, for asking "is this a real edge?" in constant time. */ const graphEdges = new Set(graph.edges.map(edge => `${String(edge.from)}>${String(edge.to)}`)) /** * Every edge the bake is expected to open somewhere; emptied as they are found. * * Portal edges are left out. A town portal, the Tristram cairn portal, the act * transitions and the Arcane Sanctuary entrance are all conjured at run time by * the thing that opens them — a quest, a scroll, a red portal — so there is no * staircase in the artwork to find and demanding one would bury the real holes * under forty-six false ones. */ const unmaterialised = new Set( graph.edges.filter(edge => edge.kind !== 'portal') .map(edge => `${String(edge.from)}>${String(edge.to)}`), ) /** Levels that own a waypoint pedestal, by the graph. */ const waypointLevels = new Set(graph.waypoints.map(entry => entry.levelId)) /** Waypoint levels seen with a baked pedestal, so variants do not each have to have one. */ const waypointsFound = new Set() let unplacedTotal = 0 /** How each baked warp's position was arrived at; see `SceneWarp.source`. */ const warpSources = { tile: 0, room: 0, fallback: 0 } /** Levels with at least one invented staircase, for the summary. */ const approximateLevels = new Set() for (const entry of index.levels) { const scene = JSON.parse(await readFile(join(packDir, entry.path, 'scene.json'), 'utf8')) as PackedLinks const levelId = scene.levelId const name = graphLevelName.get(levelId) ?? entry.label for (const warp of scene.warps ?? []) { const source = warp.source ?? 'tile' warpSources[source] += 1 if (source === 'fallback') approximateLevels.add(levelId) } // Every opening must lead somewhere the graph agrees with. let strayOpenings = 0 for (const link of [...(scene.entrances ?? []), ...(scene.warps ?? [])]) { const key = `${String(levelId)}>${String(link.toLevelId)}` if (graphEdges.has(key)) unmaterialised.delete(key) else { strayOpenings += 1 if (strayOpenings <= 3) { console.log(` FAIL ${entry.path}: 开口通向 ${String(link.toLevelId)},但世界图里没有 ${name} → 该关卡的边`) } } } check(strayOpenings === 0, `${entry.path}: openings match graph edges (${String(strayOpenings)} stray)`) // A level the graph says has a waypoint must bake a pedestal somewhere. Only // one variant has to have it for the level to be reachable by waypoint, so // this is recorded and judged after the loop. if ((scene.waypoints ?? []).length > 0) waypointsFound.add(levelId) const unplaced = scene.stats?.unplacedEdges ?? [] unplacedTotal += unplaced.length for (const hole of unplaced) { const target = graphLevelName.get(hole.toLevelId) ?? String(hole.toLevelId) console.log(` HOLE ${entry.path}: 无法放置通往 ${target} 的开口(${hole.reason})`) } } check(unplacedTotal === 0, `world: every graph edge got an opening (${String(unplacedTotal)} unplaced)`) // Packs are filtered during development (`FILTER_LEVEL`), so every judgement // below is scoped to levels this pack actually contains. const bakedLevels = new Set(index.levels.map(entry => entry.levelId)) const missingWaypoints = [...waypointLevels] .filter(levelId => bakedLevels.has(levelId) && !waypointsFound.has(levelId)) for (const levelId of missingWaypoints) { console.log(` FAIL 关卡 ${graphLevelName.get(levelId) ?? String(levelId)} 应有传送点,但所有变体都没有烘焙出来`) } check(missingWaypoints.length === 0, `world: waypoint levels have a pedestal (${String(missingWaypoints.length)} missing)`) // An edge nobody opened is a level you can walk to on paper and never reach in // play. const orphanEdges = [...unmaterialised].filter(key => bakedLevels.has(Number(key.split('>')[0]))) for (const key of orphanEdges.slice(0, 20)) { const [from, to] = key.split('>').map(Number) console.log(` FAIL 世界图有 ${graphLevelName.get(from!) ?? String(from!)} → ${graphLevelName.get(to!) ?? String(to!)} 的边,但没有任何变体开了这个口`) } check(orphanEdges.length === 0, `world: no graph edge left without an opening (${String(orphanEdges.length)} orphans)`) // Not an assertion: an approximate staircase is traversable, just not authentic. // The number is printed every run so it cannot creep up unnoticed, and it only // falls when a level type's DRLG staircase pass gets transcribed. console.log( `\n楼梯来源:美术瓦片 ${String(warpSources.tile)},迷宫楼梯间 ${String(warpSources.room)},` + `兜底 ${String(warpSources.fallback)}(涉及 ${String(approximateLevels.size)} 个关卡)`, ) console.log(`\n${String(checks - failures)}/${String(checks)} 项断言通过(逐像素比对了 ${(comparedPixels / 1048576).toFixed(1)} MB 的索引数据)`) if (failures > 0) process.exit(1)