diablo2-web/scripts/verify-acts.ts

122 lines
6.1 KiB
TypeScript

/**
* Resolve and build all five act towns from the real archives.
*
* This runs the exact data chain the browser page uses — mount, tables, DS1,
* DT1, palette, isometric scene — and reports numbers instead of impressions:
* which DS1 quadrants each town has, whether every tile reference resolved,
* how much of the map is walkable, and whether a spawn point exists. It is the
* check that says the geometry is self-consistent before anything is drawn.
*
* Usage:
* node scripts/verify-acts.ts [directory]
*/
import { MountedArchives } from '../src/mpq/mount.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { loadActTables, loadActTown } from '../src/game/acts.ts'
import { levelSeed, buildIsoMapScene, findIsoSpawn, isBlockedAt } from '../src/game/d2map.ts'
import { SUB_TILES_PER_TILE } from '../src/game/map.ts'
const dir = process.argv[2] ?? 'samples/d2'
/** Mount order: later archives override earlier ones, exactly as the game loads them. */
const MOUNTS = ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']
const CHARACTER_ARCHIVE = 'd2char.mpq'
let checks = 0
let failures = 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(` ${ok ? 'ok ' : 'FAIL'} ${message}`)
}
const archives = new MountedArchives()
for (const name of [...MOUNTS, CHARACTER_ARCHIVE]) {
try {
archives.add(name, await MpqArchive.open(await fileSource(`${dir}/${name}`)))
} catch (err) {
console.log(`skip ${name}: ${String(err)}`)
}
}
if (archives.size === 0) {
console.log(`no archives found in ${dir}`)
process.exit(2)
}
console.log(`== mounted ${String(archives.size)} archives ==`)
for (const line of archives.describe()) console.log(` ${line}`)
const tables = await loadActTables(archives)
console.log(`\n== tables ==`)
console.log(` levels.txt ${String(tables.levels.rows.length)} rows, ${String(tables.levels.header.length)} columns`)
console.log(` lvltypes.txt ${String(tables.lvltypes.rows.length)} rows`)
console.log(` lvlprest.txt ${String(tables.lvlprest.rows.length)} rows`)
for (let act = 1; act <= 5; act += 1) {
console.log(`\n-- Act ${String(act)}`)
const loaded = await loadActTown(archives, tables, act)
const { town } = loaded
console.log(` level ${town.levelName} (Id ${String(town.levelId)}, ${String(town.sizeX)}x${String(town.sizeY)} cells, level type "${town.levelTypeName}")`)
console.log(` palette ${town.paletteName} (${String(loaded.palette.rgb.length / 3)} colours, ${String(loaded.palette.lightLevels.length)} light tables)`)
console.log(` ds1 ${town.ds1Names.map(name => name.split('\\').pop()).join(', ')}`)
console.log(` dt1 ${String(town.dt1Names.length)} libraries, mask 0x${town.dt1Mask.toString(16)}`)
check(town.ds1Names.length > 0, `Act ${String(act)}: has at least one DS1`)
check(town.dt1Names.length > 0, `Act ${String(act)}: has at least one DT1 library`)
check(loaded.levels.every(level => level.version >= 8), `Act ${String(act)}: DS1 versions carry the act field`)
let scene = null as ReturnType<typeof buildIsoMapScene> | null
let quadrant = ''
for (const level of loaded.levels) {
// The largest quadrant is the one the page opens; smaller ones are corridors.
const built = buildIsoMapScene(level, loaded.libraries)
if (scene === null || built.frames.length > scene.frames.length) {
scene = built
quadrant = `${String(level.width)}x${String(level.height)}`
}
}
if (scene === null) { check(false, `Act ${String(act)}: built a scene`); continue }
let blocked = 0
for (const value of scene.blocked) if (value === 1) blocked += 1
const walkable = 1 - blocked / scene.blocked.length
const spawn = findIsoSpawn(scene)
console.log(` scene ${String(scene.widthPx)}x${String(scene.heightPx)} px, ${String(scene.floors.length)} floors, ${String(scene.walls.length)} walls, ${String(scene.frames.length)} distinct frames`)
console.log(` collision ${String(scene.gridWidth)}x${String(scene.gridHeight)} sub-tiles, walkable ${(walkable * 100).toFixed(1)}%`)
console.log(` warnings missing=${String(scene.missingTiles)} clipped=${String(scene.clippedTiles)} duplicate-refs=${String(scene.duplicateRefs)}`)
// A handful of unresolved references is a quirk of the shipped DS1 data (the
// game draws nothing there); a broken chain produces hundreds, so the bound is
// tight in relative terms and the offenders are always printed.
const references = scene.floors.length + scene.walls.length + scene.missingTiles
const missingShare = scene.missingTiles / Math.max(1, references)
check(
missingShare <= 0.002,
`Act ${String(act)}: ${String(scene.missingTiles)}/${String(references)} references unresolved (${(missingShare * 100).toFixed(2)}%)`,
)
for (const ref of scene.missingRefs.slice(0, 3)) console.log(` note unresolved: ${ref}`)
// A town is mostly open ground; Act 4's fortress courtyard is 92% walkable, so
// the bound only rejects maps that are entirely one or the other.
check(walkable > 0.1 && walkable < 0.99, `Act ${String(act)}: walkable share ${(walkable * 100).toFixed(1)}% is a map, not a wall`)
check(spawn !== null, `Act ${String(act)}: a walkable spawn exists`)
if (spawn !== null) {
check(!isBlockedAt(scene, spawn.x, spawn.y), `Act ${String(act)}: spawn is on a walkable sub-tile`)
// Walk a short straight line and confirm the collision grid answers.
let reached = 0
for (let step = 0; step < 40; step += 1) {
if (!isBlockedAt(scene, spawn.x + step * 4, spawn.y)) reached += 1
}
console.log(` probe ${String(reached)}/40 four-pixel steps east of spawn are walkable`)
}
check(scene.frames.length > 0, `Act ${String(act)}: scene references at least one frame`)
console.log(` note ${String(scene.clippedTiles)} tiles needed a bitmap taller than their declared height`)
void SUB_TILES_PER_TILE
}
console.log(`\n${String(checks - failures)}/${String(checks)} checks passed`)
if (failures > 0) process.exit(1)