diablo2-web/scripts/drlg-diff.ts

196 lines
8.6 KiB
TypeScript

/**
* Differential gate of the DRLG port: runs the native D2MOO oracle and the TypeScript port on the
* same game seeds and compares their schema-2 dumps stage by stage (drlg -> act -> per level
* levelGrid -> rooms -> maps -> warp -> activation). On a mismatch both sides are re-run with the RNG
* trace enabled and the first diverging seed operation is printed.
*
* Seeds are drawn at run time (crypto) unless given explicitly, so the gate cannot be satisfied by
* special-casing known seeds.
*
* Usage:
* npx tsx scripts/drlg-diff.ts [--levels 2-7,17,39] [--seeds 32 | --seed 0x12345678,0x5eed0100]
* [--difficulty 0] [--isolated] [--data tools/d2moo-oracle/data]
* [--stop-on-first] [--report out.json] [--no-activation] [--trace-check]
* Without --levels only the act stage (DRLG_AllocDrlg: layout, links, town) is compared.
* --no-activation compares every stage except room activation (the port does not activate rooms).
* --trace-check always traces both sides and also requires the complete RNG traces to be identical,
* which covers seed streams that are not dumped (e.g. the maze levels behind warps that room
* activation initialises through sub_6FD77BB0 -> DRLG_InitLevel).
*/
import { webcrypto } from 'node:crypto'
import { writeFileSync } from 'node:fs'
import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts'
import { loadDrlgTables } from '../src/game/drlg/drlg-tables.ts'
import { DrlgTraceWriter, dumpAct1, type DrlgDump } from '../src/game/drlg/drlg-dump.ts'
import { firstDiff, firstTraceDivergence, formatDiff, fsDrlgSource, ORACLE_DATA, runOracle } from './lib/drlg-oracle.ts'
interface Args {
levels: number[]
seeds: number[]
difficulty: number
isolated: boolean
dataDir: string
stopOnFirst: boolean
report: string | null
/** Compare every stage except `activation` (the port then skips room activation). */
noActivation: boolean
/** Trace both sides on every seed and require identical RNG traces. */
traceCheck: boolean
}
function parseLevels(spec: string): number[] {
const out: number[] = []
for (const part of spec.split(',')) {
if (!part) continue
const m = /^(\d+)(?:-(\d+))?$/.exec(part.trim())
if (!m) throw new Error(`bad --levels item "${part}"`)
const a = Number(m[1])
const b = m[2] !== undefined ? Number(m[2]) : a
if (b < a) throw new Error(`bad --levels range "${part}"`)
for (let i = a; i <= b; i += 1) out.push(i)
}
return out
}
function parseArgs(argv: string[]): Args {
const args: Args = { levels: [], seeds: [], difficulty: 0, isolated: false, dataDir: ORACLE_DATA, stopOnFirst: false, report: null, noActivation: false, traceCheck: false }
let nRandom = 0
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i]!
const next = (): string => {
const v = argv[++i]
if (v === undefined) throw new Error(`missing value for ${a}`)
return v
}
if (a === '--levels') args.levels = parseLevels(next())
else if (a === '--seeds') nRandom = Number(next())
else if (a === '--seed') args.seeds.push(...next().split(',').filter(Boolean).map(s => Number(s) >>> 0))
else if (a === '--difficulty') args.difficulty = Number(next())
else if (a === '--isolated') args.isolated = true
else if (a === '--data') args.dataDir = next()
else if (a === '--stop-on-first') args.stopOnFirst = true
else if (a === '--report') args.report = next()
else if (a === '--no-activation') args.noActivation = true
else if (a === '--trace-check') args.traceCheck = true
else throw new Error(`unknown argument ${a}`)
}
if (!Number.isInteger(nRandom) || nRandom < 0) throw new Error('--seeds must be a non-negative integer')
if (nRandom > 0) args.seeds.push(...webcrypto.getRandomValues(new Uint32Array(nRandom)))
if (args.seeds.length === 0) throw new Error('give --seeds N or --seed X')
if (![0, 1, 2].includes(args.difficulty)) throw new Error('--difficulty must be 0..2')
return args
}
interface SeedResult {
seed: string
ok: boolean
stage?: string
diff?: string
trace?: string | null
error?: string
}
/** Stage-ordered comparison: the first differing stage is the one to fix first. */
function compareDumps(expected: DrlgDump, actual: DrlgDump, noActivation: boolean): { stage: string; diff: string } | null {
const stages: [string, unknown, unknown][] = [
['drlg', expected.drlg, actual.drlg],
['act', expected.act, actual.act],
]
const keys = ['id', 'levelGrid', 'nRooms', 'rooms', 'maps', 'levelSeedAfterRooms', 'warp', 'activation'] as const
const n = Math.max(expected.levels.length, actual.levels.length)
for (let i = 0; i < n; i += 1) {
const e = expected.levels[i]
const a = actual.levels[i]
const id = e?.id ?? a?.id
for (const key of keys) {
if (noActivation && key === 'activation') continue
stages.push([`L${id}.${key}`, e?.[key], a?.[key]])
}
}
for (const [stage, e, a] of stages) {
const d = firstDiff(e, a, `$.${stage}`)
if (d) return { stage, diff: formatDiff(d) }
}
return null
}
function main(): void {
const args = parseArgs(process.argv.slice(2))
const source = fsDrlgSource(args.dataDir)
const tables = loadDrlgTables(source)
const results: SeedResult[] = []
const t0 = Date.now()
console.log(`drlg-diff: ${args.seeds.length} seed(s), levels [${args.levels.join(',') || 'act only'}], difficulty ${args.difficulty}${args.isolated ? ', isolated' : ''}${args.noActivation ? ', no activation' : ''}${args.traceCheck ? ', trace check' : ''}`)
const runPort = (seed: number, trace: DrlgTraceWriter | undefined): DrlgDump =>
dumpAct1(createDrlgEnv(source, tables), seed, args.levels, {
difficulty: args.difficulty,
isolated: args.isolated,
actOnly: args.levels.length === 0,
skipActivation: args.noActivation,
...(trace ? { trace } : {}),
})
for (const seed of args.seeds) {
const seedHex = `0x${seed.toString(16).padStart(8, '0')}`
const runOpts = { seed, levels: args.levels, difficulty: args.difficulty, isolated: args.isolated, dataDir: args.dataDir }
let result: SeedResult
try {
const oracleRun = runOracle({ ...runOpts, trace: args.traceCheck })
const expected = oracleRun.doc
const writer = args.traceCheck ? new DrlgTraceWriter() : undefined
let actual: DrlgDump | null = null
let portError: unknown = null
try {
actual = runPort(seed, writer)
} catch (e) {
portError = e
}
let cmp = actual ? compareDumps(expected, actual, args.noActivation) : { stage: 'port', diff: `port threw: ${String(portError instanceof Error ? portError.stack : portError)}` }
if (!cmp && args.traceCheck) {
const divergence = firstTraceDivergence(oracleRun.trace!, writer!.lines)
if (divergence) cmp = { stage: 'trace', diff: 'dumps are identical but the RNG traces differ' }
}
if (!cmp) {
result = { seed: seedHex, ok: true }
} else if (args.traceCheck) {
result = { seed: seedHex, ok: false, stage: cmp.stage, diff: cmp.diff, trace: firstTraceDivergence(oracleRun.trace!, writer!.lines) }
} else {
// Re-run both sides with the RNG trace to locate the first diverging seed operation.
const oracleTrace = runOracle({ ...runOpts, trace: true }).trace!
const traceWriter = new DrlgTraceWriter()
try {
runPort(seed, traceWriter)
} catch {
// The trace up to the exception is still useful.
}
result = { seed: seedHex, ok: false, stage: cmp.stage, diff: cmp.diff, trace: firstTraceDivergence(oracleTrace, traceWriter.lines) }
}
} catch (e) {
result = { seed: seedHex, ok: false, stage: 'oracle', error: String(e instanceof Error ? e.message : e) }
}
results.push(result)
if (result.ok) {
process.stdout.write('.')
} else {
process.stdout.write('\n')
console.log(`seed ${seedHex}: MISMATCH in ${result.stage}`)
if (result.diff) console.log(` ${result.diff}`)
if (result.error) console.log(` ${result.error}`)
if (result.trace) console.log(result.trace.replace(/^/gm, ' '))
else if (result.trace === null) console.log(' (RNG traces are identical: the divergence is not in a seed operation)')
if (args.stopOnFirst) break
}
}
const failed = results.filter(r => !r.ok)
console.log(`\n${results.length - failed.length}/${results.length} seeds identical (${((Date.now() - t0) / 1000).toFixed(1)}s)`)
if (args.report) {
writeFileSync(args.report, JSON.stringify({ levels: args.levels, difficulty: args.difficulty, isolated: args.isolated, results }, null, 1))
}
process.exit(failed.length === 0 ? 0 : 1)
}
main()