196 lines
8.6 KiB
TypeScript
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()
|