diablo2-web/scripts/verify-objects.ts

696 lines
32 KiB
TypeScript

/**
* Verify the DS1 object → art rule against the real Diablo II archives.
*
* The rule this checks is not a guess: it is ported from ThePhrozenKeep/D2MOO
* (see the module comment in `src/game/objects.ts`), and the point of this script
* is to prove the port against bytes the user supplied rather than against the
* doc comment that describes it. Four independent things are measured:
*
* 1. **Where the object art actually is.** The project's notes said `d2char.mpq`;
* the archives say otherwise, and this prints the per-archive counts so the
* claim is checkable rather than asserted.
* 2. **The composition template.** Every object COF in the archives declares its
* layers as *composite indices*, not file names, so the sprite path has to be
* reconstructed from the COF's own name plus that index — the same trick
* `scripts/verify-dcc.ts` uses for the Sorceress. This rebuilds all 1746 layer
* references and reports exactly which ones the rule does not reach.
* 3. **Every object placed by a preset level.** `Levels.txt` rows with
* `DrlgType === 2` name fixed DS1 files, so this walks all 35 of them, decodes
* the maps and resolves each placed object's art, grouping the results by
* level type. Any object that fails to resolve fails the run — a packer that
* silently drops one is the bug this exists to catch.
* 4. **The draw anchors.** `DUNGEON_GameToClientTileDrawPositionCoords` and
* `DUNGEON_GameToClientSubtileDrawPositionCoords` (D2Common ordinals 10115 and
* 10117) are printed next to the formulas this project currently uses, and the
* DT1 `minBlockY` of every floor and wall tile a preset level actually
* references is measured, because that is the term that decides whether the
* project's wall formula and the engine's agree.
*
* Usage:
* node scripts/verify-objects.ts [archive-directory]
*
* Exits non-zero when an object placed in a preset level cannot be resolved, when
* a sampled member decodes to empty pixels, or when the ported coordinate
* arithmetic fails its own round-trips.
*/
import { readFileSync } from 'node:fs'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { cell, loadActTables, resolveLevel } from '../src/game/acts.ts'
import type { ActTables } from '../src/game/acts.ts'
import { decodeDs1 } from '../src/formats/ds1.ts'
import type { Ds1 } from '../src/formats/ds1.ts'
import { decodeDt1 } from '../src/formats/dt1.ts'
import type { Dt1, Dt1Tile } from '../src/formats/dt1.ts'
import { decodeCof } from '../src/formats/cof.ts'
import { decodeDcc } from '../src/formats/dcc.ts'
import { decodeDc6 } from '../src/formats/dc6.ts'
import {
OBJECT_COMPONENTS,
OBJECT_MODE_COUNT,
OBJECT_MODE_TOKENS,
clientSubtileDrawPositionToGameCoords,
clientTileDrawPositionToGameCoords,
gameSubtileToClientCoords,
gameTileToClientCoords,
loadObjectSheet,
loadObjectsTable,
objectAnchor,
objectCofMember,
objectDrawAnchor,
objectSpriteMember,
resolveDs1Object,
resolveObjectArt,
subtileDrawPositionCoords,
tileDrawPositionCoords,
} from '../src/game/objects.ts'
import type { ObjectsRow, ObjectsTable } from '../src/game/objects.ts'
/** Archives to mount, in load order (later overrides earlier). */
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
/**
* Community listfile that supplies the names Storm stores encrypted.
*
* `d2data.mpq` and `Patch_D2.mpq` ship no `(listfile)`, so without it "this member
* is not in the archives" would only mean "not in the archives we could enumerate".
* The file is optional: when it is absent the sweep still runs, but the member
* index is then incomplete and the run says so.
*/
const LISTFILE = 'listfile_113c.txt'
/** Prefix every object member lives under. */
const OBJECT_ROOT = 'data\\global\\objects\\'
/** `DrlgType` value for a preset level: its layout is a fixed DS1. */
const DRLG_PRESET = '2'
/** Cap on members decoded in the sample sweep, so the run stays bounded. */
const DECODE_SAMPLE_LIMIT = 60
/** Cap on individually printed failures. */
const MAX_REPORTED_FAILURES = 25
const args = process.argv.slice(2)
const archiveDir = args.find(argument => !argument.startsWith('--')) ?? 'samples/d2'
let checks = 0
let failures = 0
const failureLog: string[] = []
/**
* Record one assertion.
*
* @param ok - whether it held.
* @param what - what was asserted, printed only when it fails.
*/
function check(ok: boolean, what: string): void {
checks += 1
if (ok) return
failures += 1
if (failureLog.length < MAX_REPORTED_FAILURES) failureLog.push(what)
}
/**
* Assert a condition, returning it for reuse.
*
* @param ok - whether it held.
* @param what - description used in the failure log.
* @returns `ok`.
*/
function assert(ok: boolean, what: string): boolean {
check(ok, what)
return ok
}
/** Format a number with a leading sign, for the anchor comparison table. */
function signed(value: number): string {
return value >= 0 ? `+${String(value)}` : String(value)
}
/** Open the four archives and mount them the way the game loads them. */
async function mount(): Promise<MountedArchives> {
const archives = new MountedArchives()
let listfile: string[] | undefined
try {
listfile = readFileSync(`${archiveDir}/${LISTFILE}`, 'utf8')
.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line.length > 0)
console.log(` listfile: ${String(listfile.length)} names from ${LISTFILE}`)
} catch {
console.log(` listfile: ${LISTFILE} missing — member index is incomplete`)
listfile = undefined
}
for (const name of MOUNTS) {
const archive = await MpqArchive.open(await fileSource(`${archiveDir}/${name}`), { listfile })
archives.add(name, archive)
}
return archives
}
/** The union of every mounted archive's member list, plus a lower-case index. */
interface MemberSet {
readonly names: readonly string[]
readonly lower: ReadonlyMap<string, string>
}
/**
* Build the case-insensitive member index.
*
* Diablo II's own lookups go through Storm's case-insensitive hash and the
* archives really do mix case (`Data\Global\Objects\1Y\S1\1ys1litnuhth.DC6`
* against `data\global\objects\c5\tr\c5trlitnuhth.dcc`), so every comparison here
* is on the lower-cased name while the archive's own spelling is what gets used
* for reads.
*
* @param archives - the mounted stack.
* @returns the names and the index.
*/
async function memberSet(archives: MountedArchives): Promise<MemberSet> {
const names: string[] = []
for (const entry of archives.mounted) names.push(...await entry.archive.listFiles())
const lower = new Map<string, string>()
for (const name of names) if (!lower.has(name.toLowerCase())) lower.set(name.toLowerCase(), name)
return { names, lower }
}
/** Per-level-type tally for the preset-level walk. */
interface LevelTypeTally {
levels: number
objects: number
resolved: number
fallbacks: number
/** Objects the archives ship no sprite for at all; `null` is the correct answer. */
artless: number
/** DS1 entries that are monster spawn points (`type` 1), not objects. */
monsterSpawns: number
modeGuesses: number
undefinedRows: number
tokens: Set<string>
}
/** Measure 1: where the object art lives, and how much of it there is. */
async function reportArchiveLayout(archives: MountedArchives): Promise<void> {
console.log('== object art in the archives ==')
let dataTotal = 0
for (const entry of archives.mounted) {
const names = await entry.archive.listFiles()
const objects = names.filter(name => name.toLowerCase().startsWith(OBJECT_ROOT))
const dcc = objects.filter(name => name.toLowerCase().endsWith('.dcc')).length
const cof = objects.filter(name => name.toLowerCase().endsWith('.cof')).length
const dc6 = objects.filter(name => name.toLowerCase().endsWith('.dc6')).length
console.log(` ${entry.label.padEnd(14)} members=${String(names.length).padStart(6)} objects=${String(objects.length).padStart(5)} dcc=${String(dcc).padStart(5)} cof=${String(cof).padStart(5)} dc6=${String(dc6).padStart(3)}`)
if (entry.label === 'd2data.mpq' || entry.label === 'd2exp.mpq') dataTotal += objects.length
}
assert(dataTotal > 0, 'object art must exist in d2data.mpq/d2exp.mpq')
console.log(' (note: d2char.mpq holds character art only; object art is in d2data/d2exp)')
}
/**
* Measure 2: rebuild every COF layer's sprite path and see how far the rule goes.
*
* @param archives - the mounted stack.
* @param members - the member index.
* @param tables - the parsed `Objects.txt`.
* @returns the tally, for the summary.
*/
async function verifyCompositionTemplate(
archives: MountedArchives,
members: MemberSet,
tables: ObjectsTable,
): Promise<{ layers: number; dcc: number; dc6: number; unresolved: readonly string[]; cofs: number; prefixes: number }> {
console.log('== COF -> DCC template (D2Common_10884_COMPOSIT_unk) ==')
const cofs = members.names
.filter(name => name.toLowerCase().startsWith(OBJECT_ROOT) && name.toLowerCase().endsWith('.cof'))
.sort()
const byToken = new Map<string, ObjectsRow>()
for (const row of tables.rows) if (!byToken.has(row.token)) byToken.set(row.token, row)
let layers = 0
let dcc = 0
let dc6 = 0
let prefixes = 0
const unresolved: string[] = []
const unknownTokens = new Set<string>()
for (const cofMember of cofs) {
const parts = cofMember.split('\\')
const token = (parts[3] ?? '').toLowerCase()
const file = (parts[5] ?? '').replace(/\.cof$/i, '')
if (!byToken.has(token.toUpperCase())) unknownTokens.add(token.toUpperCase())
let cof
try {
cof = decodeCof(await archives.read(cofMember))
} catch (err) {
unresolved.push(`${cofMember}: ${(err as Error).message}`)
continue
}
// The COF's name is <TOKEN><MODE><WEAPON>; the suffix length comes from the
// layer record itself rather than from a hard-coded 3.
const weapon = cof.layers[0]?.weaponClass ?? 'hth'
const mode = file.slice(token.length, file.length - weapon.length).toLowerCase()
if (cofMember.toLowerCase().split('\\').slice(0, 3).join('\\') === OBJECT_ROOT.slice(0, -1)) prefixes += 1
for (const layer of cof.layers) {
layers += 1
const component = OBJECT_COMPONENTS[layer.type]
if (component === undefined) { unresolved.push(`${cofMember}: composite ${String(layer.type)} has no directory`); continue }
const wanted = `${OBJECT_ROOT}${token}\\${component}\\${token}${component}lit${mode}${weapon}.dcc`
if (members.lower.has(wanted)) { dcc += 1; continue }
if (members.lower.has(wanted.replace(/\.dcc$/i, '.dc6'))) { dc6 += 1; continue }
unresolved.push(wanted)
}
}
const resolved = dcc + dc6
console.log(` cofs=${String(cofs.length)} layers=${String(layers)} resolved=${String(resolved)} (.dcc ${String(dcc)} / .dc6 ${String(dc6)}) unresolved=${String(unresolved.length)}`)
console.log(` tokens with art but no Objects.txt row: ${[...unknownTokens].sort().join(' ') || 'none'}`)
for (const miss of unresolved.slice(0, 8)) console.log(` unresolved: ${miss}`)
if (unresolved.length > 8) console.log(` ... ${String(unresolved.length - 8)} more`)
// The five known duds (E1/E2/HO/TP/YQ ship a COF whose layer sprite is absent,
// or spell the armor class differently); everything else must resolve.
check(layers > 1700, `expected ~1746 object COF layer references, saw ${String(layers)}`)
check(resolved / Math.max(layers, 1) >= 0.99, `at least 99% of layers must resolve, saw ${String(resolved)}/${String(layers)}`)
return { layers, dcc, dc6, unresolved, cofs: cofs.length, prefixes }
}
/** One placed object, reduced to what the report needs. */
interface Placement {
readonly levelId: number
readonly levelName: string
readonly levelType: string
readonly row: ObjectsRow
readonly member: string | null
readonly mode: string
readonly anchor: { readonly x: number; readonly y: number }
readonly guessedMode: boolean
}
/**
* Measure 3: walk every preset level and resolve every object it places.
*
* @param archives - the mounted stack.
* @param members - the member index.
* @param tables - the parsed `Objects.txt`.
* @param levelTables - the level tables.
* @returns every placement, in walk order.
*/
async function walkPresetObjects(
archives: MountedArchives,
members: MemberSet,
tables: ObjectsTable,
levelTables: ActTables,
): Promise<{ placements: readonly Placement[]; tallies: Map<string, LevelTypeTally>; ds1Count: number; levels: number }> {
console.log('== objects placed by the 35 preset levels (Levels.txt DrlgType=2) ==')
const presetRows = levelTables.levels.rows.filter(row => cell(levelTables.levels, row, 'DrlgType') === DRLG_PRESET)
const libraryCache = new Map<string, Dt1>()
const tallies = new Map<string, LevelTypeTally>()
const placements: Placement[] = []
let ds1Count = 0
let levels = 0
for (const levelRow of presetRows) {
const levelId = Number(cell(levelTables.levels, levelRow, 'Id'))
let info
try {
info = resolveLevel(levelTables, levelId)
} catch (err) {
failures += 1
failureLog.push(`level ${String(levelId)}: ${(err as Error).message}`)
continue
}
levels += 1
const tally = tallies.get(info.levelTypeName) ?? {
levels: 0, objects: 0, resolved: 0, fallbacks: 0, artless: 0, monsterSpawns: 0,
modeGuesses: 0, undefinedRows: 0, tokens: new Set<string>(),
}
tally.levels += 1
tallies.set(info.levelTypeName, tally)
let ds1: Ds1
let found = false
for (const ds1Name of info.ds1Names) {
try {
ds1 = decodeDs1(await archives.read(ds1Name))
} catch {
continue
}
found = true
ds1Count += 1
for (const object of ds1.objects) {
tally.objects += 1
let resolved
try {
// Omitting the monsters tables intentionally restores the legacy drop-everything-non-object behaviour.
resolved = resolveDs1Object(tables, info.act, object.type, object.id)
} catch (err) {
tally.undefinedRows += 1
failures += 1
if (failureLog.length < MAX_REPORTED_FAILURES) failureLog.push(`${info.levelName} ${ds1Name}: ${(err as Error).message}`)
continue
}
// DS1 `type` 1 is a monster spawn point, not an object: it has no object art and
// this project has no monsters, so it is counted and left alone.
if (resolved.kind === 'monster') {
tally.monsterSpawns += 1
continue
}
const row = resolved.row
tally.tokens.add(resolved.token)
const art = resolveObjectArt({
object,
token: resolved.token,
row: row === null
? null
: { name: row.name, token: resolved.token, subClass: row.subClass, mode: 0, hp: 0 },
mode: resolved.mode,
members: members.names,
})
const guessedMode = art.notes.some(note => note.includes('fell back to'))
if (guessedMode) tally.modeGuesses += 1
// `null` is the correct answer only when the archives really ship no sprite for
// this token *and this mode*: `Objects.txt` has invisible helper rows (`Dummy`)
// and modes that were never drawn (a portal's closed `NU` state has a COF but no
// `NU` DCC). A token that does have art for the requested mode may not resolve to
// null — that is the case this assertion exists to catch.
const tokenRoot = `\\objects\\${resolved.token.trim().toLowerCase()}\\`
const modeTag = `lit${art.mode.toLowerCase()}hth.`
const modeHasSprite = members.names.some((name) => {
const lower = name.toLowerCase()
return lower.includes(tokenRoot) && lower.includes(modeTag)
})
if (art.member === null) {
tally.fallbacks += 1
if (!assert(!modeHasSprite,
`${info.levelName}: ${resolved.token} has ${art.mode} art in the archives but resolved to null`)) continue
tally.artless += 1
continue
}
tally.resolved += 1
const exists = members.lower.has(art.member.toLowerCase())
if (!assert(exists, `${info.levelName}: ${resolved.token} member ${String(art.member)} is not in the archives`)) continue
if (!assert(Number.isFinite(art.anchor.x) && Number.isFinite(art.anchor.y), `${info.levelName}: ${resolved.token} anchor is not finite`)) continue
placements.push({
levelId,
levelName: info.levelName,
levelType: info.levelTypeName,
// Records with no Objects.txt row still have a real token; synthesise the
// metadata row so downstream reporting has one shape to deal with.
row: row ?? {
id: object.id, name: resolved.token, token: resolved.token, subClass: 0, act: info.act,
sizeX: 0, sizeY: 0, xOffset: 0, yOffset: 0, isDoor: false, trans: 0, draw: 0,
totalPieces: 0, autoMap: 0, mode: [], selectable: [], frameCnt: [], frameDelta: [],
start: [], cycleAnim: [], lit: [], sync: 0, components: [],
},
member: art.member,
mode: art.mode,
anchor: art.anchor,
guessedMode,
})
}
break
}
if (!found) {
failures += 1
failureLog.push(`level ${String(levelId)} (${info.levelName}): none of its ${String(info.ds1Names.length)} DS1 members decoded`)
}
// Keep the DT1 libraries of the last level of each type reachable for the
// anchor measurement below.
for (const dt1Name of info.dt1Names) {
if (libraryCache.has(dt1Name)) continue
libraryCache.set(dt1Name, decodeDt1(await archives.read(dt1Name)))
}
}
console.log(' level type levels objects resolved no-art monsters mode-guessed tokens')
// `no-art` counts objects the archives genuinely ship no sprite for (invisible helper
// rows and modes that were never drawn); `resolved` counts the ones that drew something.
for (const [type, tally] of [...tallies].sort((a, b) => a[0].localeCompare(b[0]))) {
console.log(` ${type.padEnd(28)} ${String(tally.levels).padStart(6)} ${String(tally.objects).padStart(8)} ${String(tally.resolved).padStart(9)} ${String(tally.artless).padStart(7)} ${String(tally.monsterSpawns).padStart(8)} ${String(tally.modeGuesses).padStart(13)} ${String(tally.tokens.size).padStart(6)}`)
}
const tokens = new Set(placements.map(placement => placement.row.token))
console.log(` distinct tokens across all preset levels: ${String(tokens.size)} -> ${[...tokens].sort().join(' ')}`)
assert(levels === 35, `expected 35 preset levels, saw ${String(levels)}`)
assert(placements.length > 0, 'at least one object must resolve')
return { placements, tallies, ds1Count, levels }
}
/**
* Measure 4a: decode a sample of the resolved members and prove they have pixels.
*
* A name that exists is not art: this is what catches a rule that reconstructs a
* *plausible* path to a real file with the wrong contents, or a decoder that
* returns zeroed frames.
*
* @param archives - the mounted stack.
* @param members - the member index.
* @param placements - the resolved placements.
* @returns the number of members decoded and the total opaque pixels seen.
*/
async function decodeSample(
archives: MountedArchives,
members: MemberSet,
placements: readonly Placement[],
): Promise<{ decoded: number; frames: number; opaque: number }> {
console.log('== sample decode of resolved members ==')
const distinct = new Map<string, Placement>()
for (const placement of placements) if (placement.member !== null && !distinct.has(placement.member)) distinct.set(placement.member, placement)
const chosen: Placement[] = []
const seenToken = new Set<string>()
for (const placement of [...distinct.values()].sort((a, b) => a.row.token.localeCompare(b.row.token))) {
if (seenToken.has(placement.row.token)) continue
seenToken.add(placement.row.token)
chosen.push(placement)
if (chosen.length >= DECODE_SAMPLE_LIMIT) break
}
let decoded = 0
let frames = 0
let opaque = 0
for (const placement of chosen) {
const member = members.lower.get((placement.member ?? '').toLowerCase())
if (member === undefined) continue
const bytes = await archives.read(member)
const isDc6 = member.toLowerCase().endsWith('.dc6')
let frameList: { width: number; height: number; mask: Uint8Array }[] = []
try {
if (isDc6) {
const sheet = decodeDc6(bytes)
frameList = sheet.groups.flatMap(group => group.frames.map(frame => ({ width: frame.width, height: frame.height, mask: frame.mask })))
} else {
const file = decodeDcc(bytes)
frameList = file.directions.flatMap(direction => direction.frames.map(frame => ({ width: frame.frame.width, height: frame.frame.height, mask: frame.frame.mask })))
}
} catch (err) {
assert(false, `${member}: ${(err as Error).message}`)
continue
}
const dims = frameList.every(frame => frame.width >= 1 && frame.height >= 1)
if (!assert(dims && frameList.length >= 1, `${member}: ${String(frameList.length)} frames, dims ok=${String(dims)}`)) continue
const pixels = frameList.reduce((sum, frame) => sum + frame.mask.reduce((inner, value) => inner + value, 0), 0)
assert(pixels > 0, `${member}: decoded to ${String(pixels)} opaque pixels`)
decoded += 1
frames += frameList.length
opaque += pixels
}
console.log(` decoded ${String(decoded)} members (${String(frames)} frames, ${String(opaque)} opaque pixels) from ${String(chosen.length)} sampled objects`)
return { decoded, frames, opaque }
}
/**
* Measure 4b: composite one object through its real COF, the engine's own path.
*
* @param archives - the mounted stack.
* @param members - the member index, to confirm the COF is really in the archives.
* @param placements - the resolved placements.
* @returns the number of sheets composited.
*/
async function compositeSample(
archives: MountedArchives,
members: MemberSet,
placements: readonly Placement[],
): Promise<number> {
console.log('== COF composition (the engine\'s entry point) ==')
const chosen: Placement[] = []
const seen = new Set<string>()
for (const placement of placements) {
const key = `${placement.row.token}:${placement.mode}`
if (seen.has(key)) continue
seen.add(key)
chosen.push(placement)
if (chosen.length >= 12) break
}
let composited = 0
let layers = 0
let opaque = 0
for (const placement of chosen) {
const modeIndex = OBJECT_MODE_TOKENS.indexOf(placement.mode as (typeof OBJECT_MODE_TOKENS)[number])
try {
const sheet = await loadObjectSheet(archives, placement.row.token, Math.max(modeIndex, 0), placement.row)
const frames = sheet.sheet.groups[0]?.frames ?? []
const pixels = frames.reduce((sum, frame) => sum + frame.mask.reduce((inner, value) => inner + value, 0), 0)
const cofExists = members.lower.has(objectCofMember(placement.row.token, Math.max(modeIndex, 0)))
assert(cofExists, `${objectCofMember(placement.row.token, modeIndex)} must exist for ${placement.row.token}`)
assert(sheet.members.length >= 1, `${placement.row.token} ${placement.mode}: COF resolved no sprite layers (${sheet.notes.join('; ')})`)
if (!assert(pixels > 0, `${placement.row.token} ${placement.mode}: composited to ${String(pixels)} opaque pixels`)) continue
composited += 1
layers += sheet.members.length
opaque += pixels
} catch (err) {
assert(false, `${placement.row.token} ${placement.mode}: ${(err as Error).message}`)
}
}
// Every object token a preset level places must have a readable COF for its
// neutral mode: that is the file the engine actually loads.
const tokensPlaced = new Set(placements.map(placement => placement.row.token))
const withoutCof = [...tokensPlaced].filter(token => !members.lower.has(objectCofMember(token, 0)))
assert(withoutCof.length === 0, `placed tokens with no neutral COF: ${withoutCof.join(' ')}`)
console.log(` composited ${String(composited)} object animations (${String(layers)} layers, ${String(opaque)} opaque pixels) from ${String(tokensPlaced.size)} distinct tokens`)
return composited
}
/**
* Measure 4c: the anchor arithmetic, ported and compared.
*
* @param archives - the mounted stack.
* @param levelTables - the level tables.
* @param placements - the resolved placements.
*/
async function reportAnchors(
archives: MountedArchives,
levelTables: ActTables,
placements: readonly Placement[],
): Promise<void> {
console.log('== draw anchors: D2Dungeon.cpp 10115 / 10117 vs this project ==')
// Identities the port must satisfy.
let identity = true
for (let x = -6; x <= 6; x += 1) {
for (let y = -6; y <= 6; y += 1) {
const tile = tileDrawPositionCoords(x, y)
const tileCentre = gameTileToClientCoords(x, y)
if (tile.x !== tileCentre.x - 80 || tile.y !== tileCentre.y + 80) identity = false
const sub = subtileDrawPositionCoords(x, y)
const subCentre = gameSubtileToClientCoords(x, y)
if (sub.x !== subCentre.x - 16 || sub.y !== subCentre.y + 16) identity = false
const backTile = clientTileDrawPositionToGameCoords(tile.x, tile.y)
if (backTile.x !== x || backTile.y !== y) identity = false
const backSub = clientSubtileDrawPositionToGameCoords(sub.x, sub.y)
if (backSub.x !== x || backSub.y !== y) identity = false
// Negative results must floor, not truncate: the C++ idiom is `v / n - 1`.
const floored = clientTileDrawPositionToGameCoords(tile.x - 1, tile.y - 1)
if (floored.x !== x - 1 || floored.y !== y - 1) identity = false
}
}
assert(identity, 'tile/subtile draw positions must equal the centre conversion plus (-80,+80)/(-16,+16) and round-trip on 169 coordinates')
const engineObject = objectDrawAnchor(0, 0, 0, 0)
assert(engineObject.x === -16 && engineObject.y === 16, `objectDrawAnchor(0,0,0,0) must be (-16,+16), saw (${String(engineObject.x)},${String(engineObject.y)})`)
const sampleX = 12
const sampleY = 7
const engineFloor = tileDrawPositionCoords(sampleX, sampleY)
// This project's values, from src/game/d2map.ts (TILE_ANCHOR_X = -80,
// WALL_SURFACE_HEIGHT = 80) and from the packer's object placement.
const projectFloorX = (sampleX - sampleY) * 80 - 80
const projectFloorY = (sampleX + sampleY) * 40
const projectWallExtra = 80
const engineObjectAt = subtileDrawPositionCoords(sampleX * 5, sampleY * 5)
const projectObjectX = (sampleX * 5 - sampleY * 5) * 16 - 32 / 2
const projectObjectY = (sampleX * 5 + sampleY * 5) * 8 + 16
console.log(` cell (${String(sampleX)},${String(sampleY)})`)
console.log(` floor engine DUNGEON_GameToClientTileDrawPositionCoords = (${String(engineFloor.x)}, ${String(engineFloor.y)})`)
console.log(` floor this project ((cx-cy)*80-80, (cx+cy)*40) = (${String(projectFloorX)}, ${String(projectFloorY)}) delta (${signed(engineFloor.x - projectFloorX)}, ${signed(engineFloor.y - projectFloorY)})`)
console.log(` wall this project adds minBlockY + WALL_SURFACE_HEIGHT = minBlockY ${signed(projectWallExtra)}; engine adds only the block's own offset to the same tile draw position`)
console.log(` object engine DUNGEON_GameToClientSubtileDrawPositionCoords = (${String(engineObjectAt.x)}, ${String(engineObjectAt.y)})`)
console.log(` object this project (orthoX - w/2, orthoY - h + 16), w=h=32 = (${String(projectObjectX)}, ${String(projectObjectY)}) delta (${signed(engineObjectAt.x - projectObjectX)}, ${signed(engineObjectAt.y - projectObjectY)})`)
const anchorVsObject = placements[0]
if (anchorVsObject !== undefined) {
console.log(` object resolveObjectArt anchor for the first placement (${anchorVsObject.row.token}): (${String(anchorVsObject.anchor.x)}, ${String(anchorVsObject.anchor.y)})`)
}
// The term that decides whether the project's floor and wall formulas agree
// with the engine: the DT1 block offset the decoder shifts by.
console.log(' DT1 minBlockY actually referenced by preset levels:')
const presetRows = levelTables.levels.rows.filter(row => cell(levelTables.levels, row, 'DrlgType') === DRLG_PRESET)
const floorHist = new Map<number, number>()
const wallHist = new Map<number, number>()
let floorTiles = 0
let wallTiles = 0
for (const levelRow of presetRows.slice(0, 8)) {
const levelId = Number(cell(levelTables.levels, levelRow, 'Id'))
let info
try {
info = resolveLevel(levelTables, levelId)
} catch {
continue
}
const libraries: Dt1[] = []
for (const dt1Name of info.dt1Names) libraries.push(decodeDt1(await archives.read(dt1Name)))
const tiles: Dt1Tile[] = libraries.flatMap(library => [...library.tiles])
for (const ds1Name of info.ds1Names) {
let ds1: Ds1
try {
ds1 = decodeDs1(await archives.read(ds1Name))
} catch {
continue
}
for (const row of ds1.cells) {
for (const cellRow of row) {
for (const floor of cellRow.floors) {
if (floor.hidden || floor.prop1 === 0) continue
const tile = tiles.find(candidate => candidate.style === floor.style && candidate.sequence === floor.sequence && candidate.direction === 0)
if (tile === undefined) continue
floorTiles += 1
floorHist.set(tile.minBlockY, (floorHist.get(tile.minBlockY) ?? 0) + 1)
}
for (const wall of cellRow.walls) {
if (wall.hidden || wall.prop1 === 0) continue
const tile = tiles.find(candidate => candidate.style === wall.style && candidate.sequence === wall.sequence && candidate.direction === wall.type)
if (tile === undefined) continue
wallTiles += 1
wallHist.set(tile.minBlockY, (wallHist.get(tile.minBlockY) ?? 0) + 1)
}
}
}
break
}
}
const show = (label: string, hist: Map<number, number>, total: number): void => {
const entries = [...hist].sort((a, b) => a[0] - b[0]).slice(0, 8)
console.log(` ${label} tiles=${String(total)} ` + entries.map(([value, count]) => `minBlockY ${String(value)}:${String(count)}`).join(' '))
}
show('floor', floorHist, floorTiles)
show('wall ', wallHist, wallTiles)
console.log(' a floor tile in this project is drawn at (cx+cy)*40 + (blockY - minBlockY); the engine draws it at (cx+cy)*40 + 80 + blockY,')
console.log(' so the two agree only where minBlockY is -80. A wall in this project adds minBlockY back at draw time, which cancels the shift.')
}
/** Entry point. */
async function main(): Promise<void> {
const archives = await mount()
const members = await memberSet(archives)
const tables = await loadObjectsTable(archives)
const levelTables = await loadActTables(archives)
console.log(`archives: ${archives.describe().join(', ')}`)
console.log(`Objects.txt rows: ${String(tables.rows.length)} (${String(new Set(tables.rows.map(row => row.token)).size)} distinct tokens)`)
console.log(`modes: ${OBJECT_MODE_TOKENS.join(' ')} (${String(OBJECT_MODE_COUNT)}); components: ${OBJECT_COMPONENTS.join(' ')}`)
await reportArchiveLayout(archives)
const template = await verifyCompositionTemplate(archives, members, tables)
const walk = await walkPresetObjects(archives, members, tables, levelTables)
const decoded = await decodeSample(archives, members, walk.placements)
const composited = await compositeSample(archives, members, walk.placements)
await reportAnchors(archives, levelTables, walk.placements)
console.log('== summary ==')
console.log(` preset levels walked: ${String(walk.levels)}, DS1 members decoded: ${String(walk.ds1Count)}`)
console.log(` object placements resolved: ${String(walk.placements.length)}`)
console.log(` COF layer references rebuilt: ${String(template.layers)} (${String(template.dcc + template.dc6)} resolved, ${String(template.unresolved.length)} known duds)`)
console.log(` members decoded: ${String(decoded.decoded)} (${String(decoded.frames)} frames, ${String(decoded.opaque)} opaque pixels)`)
console.log(` object animations composited: ${String(composited)}`)
console.log('')
if (failures > 0) {
console.log(`FAIL: ${String(failures)}/${String(checks)} checks failed`)
for (const line of failureLog) console.log(` - ${line}`)
if (failures > failureLog.length) console.log(` ... ${String(failures - failureLog.length)} more`)
process.exitCode = 1
return
}
console.log(`PASS: ${String(checks)}/${String(checks)} checks passed`)
}
await main()