diff --git a/package.json b/package.json index 5c7c9dc..cd3897a 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,10 @@ "build:game": "vite build --base=/diablo2/ --outDir dist-game", "pack:data": "tsx scripts/pack-act-assets.ts", "pack:ui": "tsx scripts/pack-ui.ts", + "pack:animdata": "tsx scripts/pack-animdata.ts", "verify:packs": "tsx scripts/verify-packs.ts", + "verify:bundle-no-mpq": "tsx scripts/verify-bundle-no-mpq.ts", + "verify:animation": "tsx scripts/verify-animation-browser.ts", "verify:deploy": "tsx scripts/verify-deploy.ts", "verify:listfile": "tsx scripts/verify-listfile.ts", "verify:object-lookup": "tsx scripts/verify-object-lookup.ts", diff --git a/scripts/pack-animdata.ts b/scripts/pack-animdata.ts new file mode 100644 index 0000000..a5928a9 --- /dev/null +++ b/scripts/pack-animdata.ts @@ -0,0 +1,638 @@ +/** + * Bake `AnimData.D2` and the animation columns of `MonStats2.txt` into static JSON. + * + * The runtime is not allowed to touch an MPQ, so every animation's frame count, + * speed and frame-event list has to be extracted here, on a developer machine, + * and written to `samples/d2-packs/anim/`. This script is the only place those + * numbers come from; nothing downstream is permitted to invent one. + * + * Outputs: + * - `anim/animdata.json` — every clip by upper-case COF name, plus the + * integrity facts (byte count, record count, duplicate and conflict lists) so + * a verifier can re-assert them without re-reading the archive. + * - `anim/monstats2.json` — per-monster animation columns: the `m*` mode + * presence bits, the `d*` direction counts of the modes that are present, the + * in-animation movement flags, the hit box, and the art-token join. + * + * Three policies are enforced here rather than left to the caller: + * + * 1. **`AnimData.D2` only.** `EAnimData.D2` decodes with the same decoder but + * its name set is a strict subset — zero names are unique to it — so merging + * it would add no clips while reintroducing ambiguity. It is read purely as a + * cross-check and the comparison is written into the artefact. + * 2. **A speed-0 clip inside the bake scope is a hard failure.** Four shipped + * records store `animationSpeed === 0`; an 8.8 accumulator fed zero never + * advances, so such a clip would ship as an actor frozen mid-animation with + * no error anywhere. If one is ever pulled into scope the bake stops and + * names it. Out-of-scope ones are reported, not tolerated silently. + * 3. **Monster modes are filtered on `m*` and never on `d*`.** `skeleton1` + * stores `dSC=8` and `dRN=8` while `mSC`/`mRN` are empty: the direction + * columns are populated for modes the monster does not have. Filtering on + * `d*` would bake skeleton run and cast animations that do not exist. + * + * Every table is read with `archives.read()`, never `listFiles()`: + * `MonStats2.txt` is one of nine excel tables that are present in the archive + * but absent from its `(listfile)`, so a listing-based lookup reports it + * missing. + * + * Usage: + * npm run pack:animdata [-- ] + */ +import { mkdir, writeFile } 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 { decodeAnimDataFile, ANIMDATA_FLAG_COUNT, ANIM_EVENT_ATTACK, ANIM_EVENT_MISSILE } from '../src/formats/animdata.ts' +import type { AnimDataFile, AnimDataRecord } from '../src/formats/animdata.ts' +import { decodeCof } from '../src/formats/cof.ts' +import { cell, parseTable } from '../src/game/acts.ts' +import type { D2Table } from '../src/game/acts.ts' +import { ACT_MONSTER_SPECS, resolveMonsterArtSpec } from '../src/game/monster-mapping.ts' +import { + CANONICAL_SUPER_UNIQUE_LANDMARKS, + CANONICAL_SUPERUNIQUES_TABLE, + readSuperUniques, +} from '../src/game/monsters.ts' + +/** Archives to mount, in load order; the last one to hold a member wins. */ +const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const +/** The timing table. Served by `d2exp.mpq`, not `d2data.mpq`. */ +const ANIMDATA_MEMBER = 'data\\global\\AnimData.D2' +/** The expansion table, read only as a cross-check. */ +const EANIMDATA_MEMBER = 'data\\global\\EAnimData.D2' +/** Monster animation columns. Present in the archive, absent from `(listfile)`. */ +const MONSTATS2_MEMBER = 'data\\global\\excel\\MonStats2.txt' +/** Monster records; carries the `Code` art token and the `MonStatsEx` join key. */ +const MONSTATS_MEMBER = 'data\\global\\excel\\MonStats.txt' + +/** + * Class art tokens, in `CharStats.txt` row order. + * + * `CharStats.txt` names the classes but stores no two-letter art token — the + * token lives in the executable and in the `data\global\chars\` layout — + * so the mapping is spelled out here and then *verified against the archive*: + * `assertPlayerTokens` requires `nuhth.cof` to exist for each one, so a + * wrong token fails the bake instead of silently baking nothing. + */ +const PLAYER_TOKENS: readonly { readonly token: string; readonly charStatsClass: string }[] = [ + { token: 'am', charStatsClass: 'Amazon' }, + { token: 'so', charStatsClass: 'Sorceress' }, + { token: 'ne', charStatsClass: 'Necromancer' }, + { token: 'pa', charStatsClass: 'Paladin' }, + { token: 'ba', charStatsClass: 'Barbarian' }, + { token: 'dz', charStatsClass: 'Druid' }, + { token: 'ai', charStatsClass: 'Assassin' }, +] + +/** + * Player modes in this bake's scope (`PROJECT.md` R2; Issue #143 owns the rest). + */ +const PLAYER_MODES: readonly string[] = ['NU', 'WL', 'RN', 'A1', 'A2', 'SC', 'GH', 'DT', 'DD', 'TN', 'TW'] + +/** + * Monster modes in this bake's scope, before the per-monster `m*` filter. + */ +const MONSTER_MODES: readonly string[] = ['NU', 'WL', 'RN', 'A1', 'A2', 'SC', 'GH', 'DT', 'DD'] + +/** Mode columns of `MonStats2.txt`, in table order. */ +const MONSTATS2_MODES: readonly string[] = [ + 'DT', 'NU', 'WL', 'GH', 'A1', 'A2', 'BL', 'SC', 'S1', 'S2', 'S3', 'S4', 'DD', 'KB', 'SQ', 'RN', +] + +/** In-animation movement flag columns of `MonStats2.txt`. */ +const MONSTATS2_MOVE_MODES: readonly string[] = ['A1', 'A2', 'SC', 'S1', 'S2', 'S3', 'S4'] + +/** A clip as written to `animdata.json`. */ +interface PackedClip { + /** Frames in one direction. */ + readonly fpd: number + /** 8.8 fixed-point speed as stored; 256 = 1x. */ + readonly speed: number + /** Frame events as `[frame, code]` pairs, ascending. */ + readonly events: readonly (readonly [number, number])[] + /** Present and true only when `speed` is 0, so the runtime cannot miss it. */ + readonly noSpeed?: true +} + +/** + * Convert a decoded record into its baked form. + * + * @param record - the decoded record. + * @returns the JSON-shaped clip. + */ +function toPackedClip(record: AnimDataRecord): PackedClip { + const events = record.events.map(event => [event.frame, event.code] as const) + const base = { fpd: record.framesPerDirection, speed: record.animationSpeed, events } + return record.hasSpeed ? base : { ...base, noSpeed: true } +} + +/** + * Mount the game archives, failing on the first one that cannot be opened. + * + * A missing archive is not survivable here: `AnimData.D2` lives in `d2exp.mpq` + * and the character COFs used to verify the class tokens live in `d2char.mpq`, + * so skipping a mount would turn a setup mistake into a half-empty artefact. + * + * @param archiveDir - directory holding the `.mpq` files. + * @returns the mounted stack. + */ +async function mountArchives(archiveDir: string): Promise { + const archives = new MountedArchives() + for (const name of MOUNTS) { + const path = join(archiveDir, name) + try { + archives.add(name, await MpqArchive.open(await fileSource(path))) + } catch (error) { + throw new Error(`pack-animdata: cannot mount ${path}: ${(error as Error).message}`) + } + } + return archives +} + +/** + * Confirm every class token names a real character COF directory. + * + * @param archives - the mounted stack. + * @throws when a token has no `nuhth.cof`, which would mean the token + * list has drifted from the archive. + */ +function assertPlayerTokens(archives: MountedArchives): void { + for (const entry of PLAYER_TOKENS) { + const member = `data\\global\\chars\\${entry.token}\\cof\\${entry.token}nuhth.cof` + if (!archives.has(member)) { + throw new Error(`pack-animdata: class token '${entry.token}' (${entry.charStatsClass}) has no ${member}`) + } + } +} + +/** + * Whether a `MonStats2.txt` mode presence cell counts as set. + * + * The cells hold `1` or nothing. An empty cell means the monster does not have + * the mode at all, which is why this must never be read with a non-zero + * fallback. + * + * @param value - the raw cell. + * @returns true when the mode is present. + */ +function modePresent(value: string): boolean { + const trimmed = value.trim() + return trimmed.length > 0 && trimmed !== '0' +} + +/** + * Parse an integer cell, treating an empty cell as 0. + * + * @param value - the raw cell. + * @returns the number, or 0 when the cell is empty or not numeric. + */ +function numberCell(value: string): number { + const parsed = Number.parseInt(value.trim(), 10) + return Number.isFinite(parsed) ? parsed : 0 +} + +/** The animation-relevant part of one `MonStats2.txt` row. */ +interface PackedMonStats2Row { + readonly baseW: string + readonly sizeX: number + readonly sizeY: number + readonly pixHeight: number + readonly meleeRng: number + /** Only the modes whose `m*` cell is set. */ + readonly modes: Readonly> + /** Direction counts, emitted only for modes present in `modes`. */ + readonly directions: Readonly> + /** `A1mv`-style flags, only for modes present in `modes`. */ + readonly moveInAnim: Readonly> + readonly hitBox: { + readonly noGfxHitTest: number + readonly top: number + readonly left: number + readonly width: number + readonly height: number + } + readonly compositeDeath: number +} + +/** + * Extract the animation columns of one `MonStats2.txt` row. + * + * `directions` and `moveInAnim` are deliberately emitted only for modes the + * `m*` bits declare: the direction columns are populated for absent modes too, + * so carrying them forward would let a consumer resurrect a mode that does not + * exist. + * + * @param table - the parsed table. + * @param row - the row. + * @returns the packed row. + */ +function packMonStats2Row(table: D2Table, row: readonly string[]): PackedMonStats2Row { + const modes: Record = {} + const directions: Record = {} + const moveInAnim: Record = {} + for (const mode of MONSTATS2_MODES) { + const present = cell(table, row, `m${mode}`) + if (!modePresent(present)) continue + modes[mode] = numberCell(present) || 1 + directions[mode] = numberCell(cell(table, row, `d${mode}`)) + if (MONSTATS2_MOVE_MODES.includes(mode)) { + moveInAnim[mode] = numberCell(cell(table, row, `${mode}mv`)) + } + } + return { + baseW: cell(table, row, 'BaseW').trim(), + sizeX: numberCell(cell(table, row, 'SizeX')), + sizeY: numberCell(cell(table, row, 'SizeY')), + pixHeight: numberCell(cell(table, row, 'pixHeight')), + meleeRng: numberCell(cell(table, row, 'MeleeRng')), + modes, + directions, + moveInAnim, + hitBox: { + noGfxHitTest: numberCell(cell(table, row, 'noGfxHitTest')), + top: numberCell(cell(table, row, 'htTop')), + left: numberCell(cell(table, row, 'htLeft')), + width: numberCell(cell(table, row, 'htWidth')), + height: numberCell(cell(table, row, 'htHeight')), + }, + compositeDeath: numberCell(cell(table, row, 'compositeDeath')), + } +} + +/** + * Art token to `MonStats2` row ids, via `MonStats.Code` → `MonStats.MonStatsEx`. + * + * The join is many-to-one in both directions: token `SK` covers `skeleton1..5`, + * and several monsters can share a `MonStatsEx` row. Every contributing id is + * kept so a consumer can union the `m*` bits and show its work. + * + * @param monstats - parsed `MonStats.txt`. + * @param rows - the packed `MonStats2` rows, keyed by id. + * @returns token (upper case) to the ids of the rows it covers. + */ +function buildTokenJoin( + monstats: D2Table, + rows: ReadonlyMap, +): Map { + const join = new Map() + for (const row of monstats.rows) { + const token = cell(monstats, row, 'Code').trim().toUpperCase() + const ex = cell(monstats, row, 'MonStatsEx').trim() + if (token.length === 0 || token === 'XX') continue + if (ex.length === 0 || !rows.has(ex)) continue + const ids = join.get(token) ?? [] + if (!ids.includes(ex)) ids.push(ex) + join.set(token, ids) + } + return join +} + +/** + * The monster art tokens this project actually bakes art for. + * + * Mirrors `scripts/pack-entity-assets.ts`: the Act 1..5 spawn tables plus every + * SuperUnique and landmark minion. Scope has to match the entity bake, because + * the speed-0 check below is only meaningful for clips that will really ship. + * + * @returns upper-case tokens. + */ +function bakedMonsterTokens(): Set { + const tokens = new Set() + for (let act = 1; act <= 5; act += 1) { + for (const spec of ACT_MONSTER_SPECS[act] ?? []) { + if (!spec.token || spec.token.toLowerCase() === 'xx') continue + tokens.add(spec.token.toUpperCase()) + } + } + const monsterIds = new Set() + for (const superUnique of readSuperUniques(CANONICAL_SUPERUNIQUES_TABLE)) { + monsterIds.add(superUnique.monsterId) + } + for (const landmark of CANONICAL_SUPER_UNIQUE_LANDMARKS) { + if (landmark.minionMonsterId) monsterIds.add(landmark.minionMonsterId) + } + for (const id of monsterIds) { + const spec = resolveMonsterArtSpec(id) + if (spec && spec.token && spec.token.toLowerCase() !== 'xx') tokens.add(spec.token.toUpperCase()) + } + return tokens +} + +/** The set of clips this CL's entity bake will actually need. */ +interface BakeScope { + readonly playerTokens: readonly string[] + readonly playerModes: readonly string[] + readonly monsterTokens: readonly string[] + readonly monsterModes: readonly string[] + /** Clip names in scope, sorted. Derived from the tables, never hand-listed. */ + readonly clips: readonly string[] + /** Monster tokens with no `m*` bits found, i.e. no `MonStats2` join. */ + readonly monsterTokensWithoutRows: readonly string[] +} + +/** + * Work out which clips the R2 bake will need. + * + * Player clips are every weapon-class variant the table actually stores for an + * in-scope mode. Monster clips are the same, but a mode only counts when at + * least one `MonStats2` row behind that token has the mode's `m*` bit set — the + * union across the rows, so a token covering several monsters never loses a + * clip one of them needs. + * + * @param animdata - the decoded timing table. + * @param rows - packed `MonStats2` rows by id. + * @param tokenJoin - token to row ids. + * @returns the scope. + */ +function computeBakeScope( + animdata: AnimDataFile, + rows: ReadonlyMap, + tokenJoin: ReadonlyMap, +): BakeScope { + const playerTokens = PLAYER_TOKENS.map(entry => entry.token.toUpperCase()) + const monsterTokens = [...bakedMonsterTokens()].sort() + const clips = new Set() + const withoutRows: string[] = [] + + for (const record of animdata.all) { + const token = record.name.slice(0, 2) + const mode = record.name.slice(2, 4) + if (playerTokens.includes(token)) { + if (PLAYER_MODES.includes(mode)) clips.add(record.name) + continue + } + if (!monsterTokens.includes(token)) continue + if (!MONSTER_MODES.includes(mode)) continue + const ids = tokenJoin.get(token) ?? [] + if (ids.length === 0) continue + const present = ids.some(id => rows.get(id)?.modes[mode] !== undefined) + if (present) clips.add(record.name) + } + + for (const token of monsterTokens) { + if ((tokenJoin.get(token) ?? []).length === 0) withoutRows.push(token) + } + + return { + playerTokens, + playerModes: PLAYER_MODES, + monsterTokens, + monsterModes: MONSTER_MODES, + clips: [...clips].sort(), + monsterTokensWithoutRows: withoutRows, + } +} + +/** Where a COF carrying the same tag as an undocumented AnimData code was found. */ +interface CofProvenance { + readonly member: string + readonly directions: number + readonly framesPerDirection: number + readonly speed: number + readonly tags: readonly (readonly [number, number])[] +} + +/** + * Locate the COF that belongs to a clip and read its frame tags back. + * + * Used to give the undocumented event codes a provenance: if the same code sits + * at the same frame in the COF's own tag array, the byte is shipped data and not + * a decode artefact. Nothing is inferred about what the code *means*. + * + * @param archives - the mounted stack. + * @param name - upper-case clip name. + * @returns the COF facts, or undefined when no COF carries this name. + */ +async function findCof(archives: MountedArchives, name: string): Promise { + const token = name.slice(0, 2).toLowerCase() + const lower = name.toLowerCase() + const candidates = [ + `data\\global\\monsters\\${token}\\cof\\${lower}.cof`, + `data\\global\\chars\\${token}\\cof\\${lower}.cof`, + `data\\global\\objects\\${token}\\cof\\${lower}.cof`, + ] + for (const member of candidates) { + if (!archives.has(member)) continue + const cof = decodeCof(await archives.read(member)) + const tags: (readonly [number, number])[] = [] + for (let frame = 0; frame < cof.animationFrames.length; frame += 1) { + const tag = cof.animationFrames[frame]! + if (tag !== 0) tags.push([frame, tag] as const) + } + return { + member, + directions: cof.numberOfDirections, + framesPerDirection: cof.framesPerDirection, + speed: cof.speed, + tags, + } + } + return undefined +} + +/** + * Bake the animation tables. + * + * @param archiveDir - directory holding the `.mpq` files. + * @param outDir - pack root; files land in `/anim/`. + */ +export async function bakeAnimData(archiveDir = 'samples/d2', outDir = 'samples/d2-packs'): Promise { + const archives = await mountArchives(archiveDir) + assertPlayerTokens(archives) + + const raw = await archives.read(ANIMDATA_MEMBER) + const animdata = decodeAnimDataFile(raw) + if (animdata.bytesConsumed !== raw.length) { + throw new Error(`pack-animdata: consumed ${String(animdata.bytesConsumed)} of ${String(raw.length)} bytes`) + } + console.log( + `AnimData.D2: ${String(raw.length)} bytes, ${String(animdata.all.length)} records, ` + + `${String(animdata.records.size)} unique, ${String(animdata.duplicates.length)} duplicate names, ` + + `${String(animdata.conflicts.length)} conflicting`, + ) + + // EAnimData is a cross-check, not a source: measured against 1.13c it collides + // on all 3,520 of its names and differs on none of them, so merge precedence + // cannot change a value. That is only true while it stays a value-identical + // subset, so the property is asserted here instead of assumed. + const expansion = archives.has(EANIMDATA_MEMBER) ? decodeAnimDataFile(await archives.read(EANIMDATA_MEMBER)) : undefined + let expansionSummary: Record | undefined + if (expansion !== undefined) { + const onlyInExpansion = [...expansion.records.keys()].filter(name => !animdata.records.has(name)) + const onlyInBase = [...animdata.records.keys()].filter(name => !expansion.records.has(name)) + const collisions: string[] = [] + const disagreeing: string[] = [] + for (const [name, record] of expansion.records) { + const base = animdata.records.get(name) + if (base === undefined) continue + collisions.push(name) + if ( + base.framesPerDirection !== record.framesPerDirection || + base.animationSpeed !== record.animationSpeed || + JSON.stringify(base.events) !== JSON.stringify(record.events) + ) { + disagreeing.push(name) + } + } + if (onlyInExpansion.length > 0 || disagreeing.length > 0) { + throw new Error( + `pack-animdata: EAnimData.D2 is no longer a value-identical subset of AnimData.D2 — ` + + `${String(onlyInExpansion.length)} name(s) only it has (${onlyInExpansion.slice(0, 10).join(', ')}), ` + + `${String(disagreeing.length)} shared name(s) disagree (${disagreeing.slice(0, 10).join(', ')}). ` + + `Baking AnimData.D2 alone would drop or contradict data; the merge policy needs re-deciding.`, + ) + } + expansionSummary = { + member: EANIMDATA_MEMBER, + records: expansion.all.length, + uniqueNames: expansion.records.size, + collidingNames: collisions.length, + namesOnlyInExpansion: onlyInExpansion, + namesOnlyInBase: onlyInBase, + sharedNamesDisagreeing: disagreeing, + merged: false, + mergePolicy: + 'AnimData.D2 only. EAnimData.D2 collides on every name it has and differs on none, ' + + 'so cross-file precedence cannot change a value; asserted at bake time.', + } + console.log( + `EAnimData.D2: ${String(expansion.all.length)} records, ${String(collisions.length)} colliding names, ` + + `${String(disagreeing.length)} of them disagree, ${String(onlyInExpansion.length)} names it alone has, ` + + `${String(onlyInBase.length)} names only in AnimData.D2`, + ) + } + + const monstats2Table = parseTable(await archives.read(MONSTATS2_MEMBER)) + const monstatsTable = parseTable(await archives.read(MONSTATS_MEMBER)) + const rows = new Map() + for (const row of monstats2Table.rows) { + const id = cell(monstats2Table, row, 'Id').trim() + if (id.length === 0) continue + rows.set(id, packMonStats2Row(monstats2Table, row)) + } + const tokenJoin = buildTokenJoin(monstatsTable, rows) + console.log(`MonStats2.txt: ${String(rows.size)} rows, ${String(tokenJoin.size)} art tokens joined`) + + const scope = computeBakeScope(animdata, rows, tokenJoin) + console.log( + `bake scope: ${String(scope.playerTokens.length)} class tokens x ${String(PLAYER_MODES.length)} modes, ` + + `${String(scope.monsterTokens.length)} monster tokens x <= ${String(MONSTER_MODES.length)} modes ` + + `-> ${String(scope.clips.length)} clips`, + ) + if (scope.monsterTokensWithoutRows.length > 0) { + console.log(` monster tokens with no MonStats2 join: ${scope.monsterTokensWithoutRows.join(', ')}`) + } + + // Policy 2: a speed-0 clip inside the scope is a hard failure, named. + const speedless = animdata.all.filter(record => !record.hasSpeed) + const inScopeSpeedless = speedless.filter(record => scope.clips.includes(record.name)) + if (inScopeSpeedless.length > 0) { + const detail = inScopeSpeedless + .map(record => `${record.name} (fpd=${String(record.framesPerDirection)}, speed=0)`) + .join(', ') + throw new Error( + `pack-animdata: ${String(inScopeSpeedless.length)} in-scope clip(s) have animationSpeed 0 and would never ` + + `advance in the 8.8 accumulator: ${detail}`, + ) + } + console.log( + `animationSpeed 0: ${String(speedless.length)} record(s) file-wide (${speedless.map(r => r.name).join(', ')}), ` + + `none in scope`, + ) + + for (const record of animdata.recordsOverFlagCeiling) { + console.log( + ` WARNING ${record.name} declares ${String(record.framesPerDirection)} frames but the flag array is ` + + `${String(ANIMDATA_FLAG_COUNT)} bytes; frames ${String(ANIMDATA_FLAG_COUNT)}..${String(record.framesPerDirection - 1)} cannot carry an event`, + ) + } + for (const stray of animdata.strayFlags) { + console.log(` WARNING ${stray.name} stores code ${String(stray.code)} at frame ${String(stray.frame)}, past its own frame count`) + } + + const histogram = new Map() + for (const record of animdata.all) { + for (const event of record.events) histogram.set(event.code, (histogram.get(event.code) ?? 0) + 1) + } + const undocumented: Record[] = [] + for (const record of animdata.all) { + const codes = record.events.filter(event => event.code !== ANIM_EVENT_ATTACK && event.code !== ANIM_EVENT_MISSILE) + if (codes.length === 0) continue + const cof = await findCof(archives, record.name) + undocumented.push({ + name: record.name, + framesPerDirection: record.framesPerDirection, + animationSpeed: record.animationSpeed, + events: record.events.map(event => [event.frame, event.code]), + cof: cof ?? null, + cofAgrees: cof === undefined + ? null + : JSON.stringify(cof.tags) === JSON.stringify(record.events.map(event => [event.frame, event.code])), + }) + } + console.log(`event codes: ${[...histogram].sort((a, b) => a[0] - b[0]).map(([code, count]) => `${String(code)}x${String(count)}`).join(' ')}`) + for (const entry of undocumented) { + console.log(` undocumented code on ${String(entry['name'])}: ${JSON.stringify(entry['events'])} cofAgrees=${String(entry['cofAgrees'])}`) + } + + const clips: Record = {} + for (const [name, record] of animdata.records) clips[name] = toPackedClip(record) + + const animJson = { + schema: 1, + source: { + member: ANIMDATA_MEMBER, + bytes: raw.length, + records: animdata.all.length, + uniqueNames: animdata.records.size, + duplicateNames: animdata.duplicates.length, + conflictingNames: animdata.conflicts.length, + dedupPolicy: 'first-wins: the engine scans a hash block in order and returns the first match', + flagCeiling: ANIMDATA_FLAG_COUNT, + ...(expansionSummary === undefined ? {} : { expansion: expansionSummary }), + }, + clips, + conflicts: animdata.conflicts.map(conflict => ({ + name: conflict.name, + chosen: toPackedClip(conflict.records[0]!), + candidates: conflict.records.map(toPackedClip), + })), + overFlagCeiling: animdata.recordsOverFlagCeiling.map(record => ({ + name: record.name, + framesPerDirection: record.framesPerDirection, + flagCeiling: ANIMDATA_FLAG_COUNT, + })), + strayFlags: animdata.strayFlags, + speedZero: speedless.map(record => record.name), + eventCodeHistogram: Object.fromEntries([...histogram].sort((a, b) => a[0] - b[0]).map(([code, count]) => [String(code), count])), + undocumentedEventCodes: undocumented, + bakeScope: scope, + } + + const monstatsJson = { + schema: 1, + source: { + member: MONSTATS2_MEMBER, + rows: rows.size, + columns: monstats2Table.header.length, + modeFilter: 'm* presence bits only; d* direction counts are populated for absent modes and must not be used', + }, + rows: Object.fromEntries([...rows].sort((a, b) => a[0].localeCompare(b[0]))), + tokenToRows: Object.fromEntries([...tokenJoin].sort((a, b) => a[0].localeCompare(b[0]))), + } + + const animDir = join(outDir, 'anim') + await mkdir(animDir, { recursive: true }) + const animPath = join(animDir, 'animdata.json') + const monstatsPath = join(animDir, 'monstats2.json') + await writeFile(animPath, JSON.stringify(animJson)) + await writeFile(monstatsPath, JSON.stringify(monstatsJson)) + console.log(`wrote ${animPath} (${String(Object.keys(clips).length)} clips)`) + console.log(`wrote ${monstatsPath} (${String(rows.size)} rows)`) +} + +if (import.meta.url === `file://${process.argv[1] ?? ''}`) { + const [, , archiveDir, outDir] = process.argv + await bakeAnimData(archiveDir ?? 'samples/d2', outDir ?? 'samples/d2-packs') +} diff --git a/scripts/spike-chrome-net.mjs b/scripts/spike-chrome-net.mjs new file mode 100644 index 0000000..ff87fa9 --- /dev/null +++ b/scripts/spike-chrome-net.mjs @@ -0,0 +1,166 @@ +/** + * THROWAWAY SPIKE (milestone MV): can headless Chrome reach a local HTTP server + * in this environment? + * + * scripts/browser/README.md documents a sandbox mode where headless Chrome has + * no network at all, including 127.0.0.1, and the symptom is indistinguishable + * from a page boot failure. This probe answers the question in isolation before + * any harness is built on the assumption. + * + * Diagnostic per the README: if `location.href === 'about:blank'` while the CDP + * target list shows the right URL, it is the environment, not the page. + * + * Delete after the answer is recorded. + */ +import { spawn } from 'node:child_process' +import { createServer } from 'node:http' + +const sleep = ms => new Promise(r => setTimeout(r, ms)) + +const MARKER = 'SPIKE_MARKER_8FA31C' + +async function main() { + // 1. Plain node HTTP server, no vite, no build step. + const server = createServer((req, res) => { + console.log(`[server] hit: ${req.method} ${req.url}`) + if (req.url === '/probe.json') { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ marker: MARKER })) + return + } + res.writeHead(200, { 'content-type': 'text/html' }) + res.end(`${MARKER} +
${MARKER}
+`) + }) + await new Promise(r => server.listen(0, '127.0.0.1', r)) + const port = server.address().port + const baseUrl = `http://127.0.0.1:${port}` + console.log(`[spike] server at ${baseUrl}`) + + // 2. Sanity: the server is reachable from node itself. + const nodeResp = await fetch(`${baseUrl}/probe.json`) + console.log(`[spike] node fetch -> ${nodeResp.status} ${JSON.stringify(await nodeResp.json())}`) + + // 3. Headless Chrome, same flags the existing harnesses use. + const debugPort = 9333 + const chrome = spawn('/usr/bin/google-chrome', [ + '--headless=new', + `--remote-debugging-port=${debugPort}`, + '--no-sandbox', + '--disable-dev-shm-usage', + '--enable-webgl', + '--ignore-gpu-blocklist', + '--use-gl=angle', + '--use-angle=swiftshader', + '--window-size=800,600', + 'about:blank', + ]) + chrome.stderr.on('data', d => { + const s = String(d).trim() + if (s) console.log(`[chrome stderr] ${s.slice(0, 300)}`) + }) + + let verdict = 'UNKNOWN' + try { + let wsUrl = null + for (let i = 0; i < 60; i++) { + await sleep(200) + try { + const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`) + const pages = await res.json() + const page = pages.find(p => p.type === 'page') + if (page?.webSocketDebuggerUrl) { + wsUrl = page.webSocketDebuggerUrl + break + } + } catch { + // CDP endpoint not up yet; this retry loop is the only tolerated swallow + // and it is bounded, after which we throw below. + } + } + if (!wsUrl) throw new Error('could not reach Chrome CDP endpoint at all') + console.log(`[spike] CDP ws: ${wsUrl}`) + + const ws = new WebSocket(wsUrl) + await new Promise(r => { ws.onopen = () => r() }) + + let id = 1 + const send = (method, params = {}, timeoutMs = 15000) => + new Promise((resolve, reject) => { + const myId = id++ + const timer = setTimeout(() => { + ws.removeEventListener('message', handler) + reject(new Error(`CDP ${method} timed out after ${timeoutMs}ms`)) + }, timeoutMs) + const handler = ev => { + const msg = JSON.parse(String(ev.data)) + if (msg.id === myId) { + clearTimeout(timer) + ws.removeEventListener('message', handler) + if (msg.error) reject(new Error(`CDP ${method}: ${JSON.stringify(msg.error)}`)) + else resolve(msg.result) + } + } + ws.addEventListener('message', handler) + ws.send(JSON.stringify({ id: myId, method, params })) + }) + + const evalJs = async expression => { + const res = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }) + if (res.exceptionDetails) throw new Error(`JS error: ${JSON.stringify(res.exceptionDetails)}`) + return res.result.value + } + + await send('Page.enable') + await send('Runtime.enable') + await send('Network.enable') + + console.log('[spike] Page.navigate ...') + let navErr = null + try { + await send('Page.navigate', { url: baseUrl }, 20000) + console.log('[spike] Page.navigate returned') + } catch (err) { + navErr = err + console.log(`[spike] Page.navigate FAILED: ${err.message}`) + } + + await sleep(2000) + + const href = await evalJs('location.href') + const title = await evalJs('document.title') + const body = await evalJs('document.getElementById("m") ? document.getElementById("m").textContent : "(no #m)"') + const fetchState = await evalJs('String(window.__spikeFetch)') + + console.log(`[spike] location.href = ${href}`) + console.log(`[spike] document.title = ${title}`) + console.log(`[spike] #m textContent = ${body}`) + console.log(`[spike] in-page fetch = ${fetchState}`) + + const navOk = navErr === null && href.startsWith(baseUrl) && body === MARKER + const fetchOk = fetchState === `ok:${MARKER}` + verdict = navOk && fetchOk ? 'YES' : 'NO' + if (!navOk && href === 'about:blank') { + console.log('[spike] DIAGNOSIS: href is about:blank -> sandbox network block (README §已知限制)') + } + } finally { + chrome.kill('SIGKILL') + server.close() + } + + console.log('======================================================================') + console.log(`SPIKE VERDICT: headless Chrome can reach a local HTTP server = ${verdict}`) + console.log('======================================================================') + process.exit(verdict === 'YES' ? 0 : 1) +} + +main().catch(err => { + console.error(`[spike] FATAL: ${err.stack}`) + console.log('SPIKE VERDICT: headless Chrome can reach a local HTTP server = NO (fatal)') + process.exit(1) +}) diff --git a/scripts/verify-animation-browser.ts b/scripts/verify-animation-browser.ts new file mode 100644 index 0000000..5f96f8a --- /dev/null +++ b/scripts/verify-animation-browser.ts @@ -0,0 +1,1047 @@ +/** + * Headless audit of the SHIPPED GAME BUNDLE (`dist-game/`). + * + * WHAT THIS AUDITS, AND WHY IT IS `dist-game/` AND NOT A DEV SERVER + * ----------------------------------------------------------------- + * The Zero-Runtime-MPQ invariant (Iron Law #2) constrains the artifact that + * actually ships. Auditing a dev server would prove "no MPQ requests in + * development form" while the production bundle could still contain the + * `httpRangeSource('/d2char.mpq')` path — a fully green report over a violated + * invariant, which is the worst available false negative. So this harness + * serves the real built output over a plain `node:http` static server and + * never transforms a source file. + * + * The static server is deliberately dependency-free (`node:http`, not vite): + * the dependency tree is part of what is under test, and if it were also the + * test carrier then "dependencies broke" would masquerade as "no requests were + * made". + * + * This pairs with `verify-bundle-no-mpq.ts`: + * + * bundle grep -> the bundle *cannot* download an archive (structural) + * this harness -> the bundle *did not* download one while being played + * + * Neither alone is sufficient. The grep cannot see a runtime fetch assembled + * from fragments; the runtime count cannot see a path that this particular run + * did not reach. + * + * ANTI-VACUITY: THE POINT OF THE `precondition` FIELD + * --------------------------------------------------- + * Most of these assertions are of the form "this counter is zero". Such an + * assertion passes trivially when the thing being counted never had a chance to + * happen: `missingMonsterArt.length === 0` is equally true on a level that + * rendered no monsters at all. A harness that reports success in that state is + * worse than no harness, because it manufactures confidence. + * + * So every assertion carries preconditions that establish the measurement was + * live — the scene rendered, the sim ticked, monsters were planned, the + * character draw path ran. An assertion whose preconditions are unmet is + * reported as **VACUOUS** and counts as a harness FAILURE, never as a pass. + * + * `act-scene.ts:254` is the cautionary tale: `state.characterGroup` is declared + * and published but never written, so any audit asserting on it would have been + * asserting on a constant. This harness checks that trap explicitly. + * + * NEGATIVE CONTROLS + * ----------------- + * An assertion never observed failing is not evidence. `--negative=` injects + * a specific, real fault and the run is then expected to go red on exactly the + * targeted assertion. See NEGATIVE_CONTROLS below. + * + * npm run verify:animation # audit current dist-game/ + * npm run verify:animation -- --negative=list # show the controls + * npm run verify:animation -- --negative=mpq-request + * + * OUTPUT PATH SAFETY + * ------------------ + * `scripts/verify-challenger-m3.ts` L28-29 hardcodes an output directory into a + * different worktree and writes screenshots there (L155/L159). That worktree is + * off-limits and is actively being worked in by another team. Every path this + * script writes is therefore passed through `assertWritablePath`, which throws + * unless the resolved path is inside this worktree. It is a guard rather than a + * convention so it cannot regress silently. + */ + +import { spawn } from 'node:child_process' +import { createServer } from 'node:http' +import type { IncomingMessage, ServerResponse } from 'node:http' +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { extname, join, relative, resolve } from 'node:path' + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +/** Repo root. This script lives in `/scripts`. */ +const ROOT = resolve(process.cwd()) +const DIST_DIR = join(ROOT, 'dist-game') +const PACKS_DIR = join(ROOT, 'samples', 'd2-packs') +const SRC_DIR = join(ROOT, 'src') + +/** + * Refuse to write anywhere outside this worktree. + * + * The forked-from script wrote screenshots into a sibling worktree that is + * explicitly off-limits and currently has another team's processes running in + * it. Enforcing this in code, on every write, is the only version of that rule + * that cannot rot. + */ +function assertWritablePath(candidate: string): string { + const full = resolve(candidate) + const rel = relative(ROOT, full) + if (rel.startsWith('..') || resolve(rel) === rel) { + throw new Error( + `verify-animation-browser: refusing to write outside the worktree.\n` + + ` requested: ${full}\n worktree : ${ROOT}`, + ) + } + return full +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +interface Options { + readonly negative: string + readonly outDir: string + readonly keepOpenMs: number +} + +function parseArgs(argv: readonly string[]): Options { + let negative = '' + let outDir = join(ROOT, '.agents', 'worker_mv', 'evidence') + let keepOpenMs = 0 + for (const arg of argv) { + if (arg.startsWith('--negative=')) negative = arg.slice('--negative='.length) + else if (arg.startsWith('--out=')) outDir = resolve(arg.slice('--out='.length)) + else if (arg.startsWith('--keep-open-ms=')) keepOpenMs = Number(arg.slice('--keep-open-ms='.length)) + } + return { negative, outDir, keepOpenMs } +} + +/** + * The deliberate faults. Each names the assertion it must turn red. + * + * `page-*` controls run inside the page; `artifact-*` controls would require + * mutating `dist-game/`, which is done by the separate bundle-gate controls + * rather than here, so that this harness never writes to the build output. + */ +const NEGATIVE_CONTROLS: Readonly> = { + 'mpq-request': 'page fetches a *.mpq URL -> must turn ZERO_MPQ_DLL_REQUESTS red', + 'dll-request': 'page fetches a *.dll URL -> must turn ZERO_MPQ_DLL_REQUESTS red', + 'console-error': 'page emits console.error -> must turn ZERO_CONSOLE_ERRORS red', + 'missing-art': 'push a fake id into missingMonsterArt -> must turn NO_RED_PLACEHOLDER_SQUARES red', + 'monster-art-error': 'bump monsterArtErrors -> must turn NO_RED_PLACEHOLDER_SQUARES red', + 'freeze-facing': 'ignore facing writes -> must turn EIGHT_DIRECTIONS_EXERCISED red', + 'broken-page': 'navigate to a URL that does not exist -> must turn SCENE_BOOTED red', +} + +// --------------------------------------------------------------------------- +// Static server for the built artifact +// --------------------------------------------------------------------------- + +const MIME: Readonly> = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.wasm': 'application/wasm', + '.map': 'application/json; charset=utf-8', + '.r8': 'application/octet-stream', + '.bin': 'application/octet-stream', + '.dat': 'application/octet-stream', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.ogg': 'audio/ogg', +} + +interface ServedRequest { + readonly url: string + readonly status: number +} + +/** + * Serve the production layout. + * + * The game is built with `--base=/diablo2/`, so the bundle must live under + * `/diablo2/`. `DEFAULT_PACKS` (`act-scene.ts`) resolves its second entry to + * `/diablo2/packs`, so the baked packs are mounted there — this reproduces the + * deployed shape rather than inventing one. + * + * Note what is deliberately NOT mounted: nothing serves `samples/d2`, so no + * `*.mpq` is reachable. That does not weaken the audit — a forbidden request is + * counted when it is *issued*, regardless of the response — and it matches a + * real deployment, where the archives are not published. + */ +function createStaticServer(served: ServedRequest[]): ReturnType { + const mounts: readonly { readonly prefix: string; readonly dir: string }[] = [ + { prefix: '/diablo2/packs/', dir: PACKS_DIR }, + { prefix: '/diablo2/', dir: DIST_DIR }, + ] + + return createServer((req: IncomingMessage, res: ServerResponse) => { + const rawUrl = req.url ?? '/' + const path = decodeURIComponent(rawUrl.split('?')[0] ?? '/') + + const finish = (status: number, body: Buffer | string, type: string): void => { + served.push({ url: rawUrl, status }) + res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store' }) + res.end(body) + } + + if (path === '/' || path === '/index.html') { + finish(302, '', 'text/plain') + return + } + + for (const mount of mounts) { + if (!path.startsWith(mount.prefix)) continue + const rest = path.slice(mount.prefix.length) + // Path traversal guard: the resolved file must stay inside the mount. + const target = resolve(mount.dir, rest) + if (!target.startsWith(mount.dir)) { + finish(403, 'forbidden', 'text/plain') + return + } + if (!existsSync(target) || !statSync(target).isFile()) continue + const body = readFileSync(target) + finish(200, body, MIME[extname(target).toLowerCase()] ?? 'application/octet-stream') + return + } + + finish(404, 'not found', 'text/plain') + }) +} + +// --------------------------------------------------------------------------- +// Assertion framework +// --------------------------------------------------------------------------- + +type Verdict = 'PASS' | 'FAIL' | 'VACUOUS' + +interface Assertion { + readonly id: string + /** What acceptance-criterion text this maps to. */ + readonly criterion: string + readonly verdict: Verdict + readonly detail: string + /** Precondition results, so a reader can see the measurement was live. */ + readonly preconditions: readonly { readonly name: string; readonly met: boolean; readonly value: string }[] + /** + * True when the assertion is expected to be red on current `main` because it + * documents a pre-existing defect another milestone owns. Reported loudly and + * excluded from the exit code, so a genuine regression is not buried under a + * known baseline. Turning green is reported as "baseline improved". + */ + readonly knownRedBaseline?: string +} + +class AssertionLog { + readonly items: Assertion[] = [] + + add(a: Assertion): void { + this.items.push(a) + } + + /** + * Build an assertion, evaluating preconditions first. + * + * If any precondition is unmet the verdict is VACUOUS regardless of the + * predicate, because a green from an unexercised code path is not evidence. + */ + check(args: { + id: string + criterion: string + preconditions: readonly { name: string; met: boolean; value: string }[] + pass: boolean + detail: string + knownRedBaseline?: string + }): void { + const unmet = args.preconditions.filter(p => !p.met) + const verdict: Verdict = unmet.length > 0 ? 'VACUOUS' : args.pass ? 'PASS' : 'FAIL' + const detail = + unmet.length > 0 + ? `${args.detail} | VACUOUS: preconditions unmet -> ${unmet.map(p => `${p.name}=${p.value}`).join(', ')}` + : args.detail + this.add({ + id: args.id, + criterion: args.criterion, + verdict, + detail, + preconditions: args.preconditions, + ...(args.knownRedBaseline === undefined ? {} : { knownRedBaseline: args.knownRedBaseline }), + }) + } +} + +// --------------------------------------------------------------------------- +// CDP plumbing +// --------------------------------------------------------------------------- + +const sleep = (ms: number): Promise => new Promise(r => setTimeout(r, ms)) + +interface NetworkRequestLog { + url: string + method: string + resourceType: string +} + +interface ConsoleErrorLog { + source: string + text: string +} + +interface CdpSession { + send: (method: string, params?: Record, timeoutMs?: number) => Promise + evalJs: (expression: string) => Promise + close: () => void +} + +async function connectCdp(debugPort: number, onEvent: (msg: Record) => void): Promise { + let wsUrl: string | null = null + let lastErr = 'none' + for (let i = 0; i < 80; i++) { + await sleep(200) + try { + const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`) + const pages = (await res.json()) as { type: string; webSocketDebuggerUrl?: string }[] + const page = pages.find(p => p.type === 'page') + if (page?.webSocketDebuggerUrl !== undefined) { + wsUrl = page.webSocketDebuggerUrl + break + } + lastErr = 'no page target yet' + } catch (err) { + // Bounded retry while Chrome boots. This is not a silent swallow: the + // last error is retained and thrown if the loop never succeeds. + lastErr = err instanceof Error ? err.message : String(err) + } + } + if (wsUrl === null) throw new Error(`could not reach Chrome CDP endpoint (last error: ${lastErr})`) + + const ws = new WebSocket(wsUrl) + await new Promise((res, rej) => { + ws.onopen = () => res() + ws.onerror = () => rej(new Error('CDP WebSocket failed to open')) + }) + + let idCounter = 1 + const send = ( + method: string, + params: Record = {}, + timeoutMs = 20000, + ): Promise => + new Promise((res, rej) => { + const id = idCounter++ + const timer = setTimeout(() => { + ws.removeEventListener('message', handler) + rej(new Error(`CDP ${method} timed out after ${timeoutMs}ms`)) + }, timeoutMs) + const handler = (event: MessageEvent): void => { + const msg = JSON.parse(String(event.data)) as Record + if (msg['id'] === id) { + clearTimeout(timer) + ws.removeEventListener('message', handler) + if (msg['error'] !== undefined) rej(new Error(`CDP ${method}: ${JSON.stringify(msg['error'])}`)) + else res(msg['result'] as T) + } + } + ws.addEventListener('message', handler) + ws.send(JSON.stringify({ id, method, params })) + }) + + ws.addEventListener('message', (event: MessageEvent) => { + onEvent(JSON.parse(String(event.data)) as Record) + }) + + const evalJs = async (expression: string): Promise => { + const res = await send<{ result: { value: T }; exceptionDetails?: unknown }>('Runtime.evaluate', { + expression, + awaitPromise: true, + returnByValue: true, + }) + if (res.exceptionDetails !== undefined) { + throw new Error(`in-page JS threw: ${JSON.stringify(res.exceptionDetails)}\n expression: ${expression}`) + } + return res.result.value + } + + return { send, evalJs, close: () => ws.close() } +} + +// --------------------------------------------------------------------------- +// Scene state shape (the subset this audit reads) +// --------------------------------------------------------------------------- + +interface SceneSnapshot { + present: boolean + ready: boolean + error: string | null + frames: number + tick: number + character: boolean + characterFrames: number + characterGroup: number + missingMonsterArt: string[] + monsterArtErrors: number + monsterArtLayerFailures: number + monstersPlanned: number + facing: number + x: number + y: number + npcs: number +} + +const SNAPSHOT_EXPR = `(() => { + const s = window.__d2webAct + if (!s) return { present: false } + return { + present: true, + ready: !!s.ready, + error: s.error ?? null, + frames: s.frames ?? -1, + tick: s.tick ?? -1, + character: !!s.character, + characterFrames: s.characterFrames ?? -1, + characterGroup: s.characterGroup ?? -999, + missingMonsterArt: Array.isArray(s.missingMonsterArt) ? s.missingMonsterArt.slice() : null, + monsterArtErrors: s.monsterArtErrors ?? -1, + monsterArtLayerFailures: s.monsterArtLayerFailures ?? -1, + monstersPlanned: s.monstersPlanned ?? -1, + facing: s.facing ?? -1, + x: s.x ?? -1, + y: s.y ?? -1, + npcs: s.npcs ?? -1, + } +})()` + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main(): Promise { + const opts = parseArgs(process.argv.slice(2)) + + if (opts.negative === 'list') { + console.log('Negative controls:') + for (const [id, what] of Object.entries(NEGATIVE_CONTROLS)) console.log(` --negative=${id.padEnd(20)} ${what}`) + process.exit(0) + } + if (opts.negative !== '' && NEGATIVE_CONTROLS[opts.negative] === undefined) { + console.error(`Unknown negative control: ${opts.negative}`) + console.error(`Known: ${Object.keys(NEGATIVE_CONTROLS).join(', ')}`) + process.exit(2) + } + + console.log('======================================================================') + console.log('HEADLESS AUDIT of the SHIPPED BUNDLE (dist-game/)') + if (opts.negative !== '') console.log(`NEGATIVE CONTROL ACTIVE: ${opts.negative} — ${NEGATIVE_CONTROLS[opts.negative]}`) + console.log('======================================================================') + + // --- Precondition: a real, fresh build exists ----------------------------- + // Auditing a missing or stale artifact is the silent-nothing failure mode. + assertBuildPresentAndFresh() + + const outDir = assertWritablePath(opts.negative === '' ? opts.outDir : join(opts.outDir, `negative-${opts.negative}`)) + mkdirSync(outDir, { recursive: true }) + console.log(`Evidence directory: ${outDir}`) + + const log = new AssertionLog() + const served: ServedRequest[] = [] + const networkRequests: NetworkRequestLog[] = [] + const consoleErrors: ConsoleErrorLog[] = [] + + const server = createStaticServer(served) + await new Promise(r => server.listen(0, '127.0.0.1', r)) + const addr = server.address() + const port = typeof addr === 'object' && addr !== null ? addr.port : 0 + const baseUrl = `http://127.0.0.1:${port}` + console.log(`Static server (node:http, serving dist-game/): ${baseUrl}/diablo2/`) + + const debugPort = 9246 + const chrome = spawn('/usr/bin/google-chrome', [ + '--headless=new', + `--remote-debugging-port=${debugPort}`, + '--no-sandbox', + '--disable-dev-shm-usage', + '--enable-webgl', + '--ignore-gpu-blocklist', + '--use-gl=angle', + '--use-angle=swiftshader', + '--window-size=1280,840', + 'about:blank', + ]) + + let exitCode = 0 + try { + const cdp = await connectCdp(debugPort, msg => { + const method = msg['method'] as string | undefined + if (method === 'Network.requestWillBeSent') { + const req = msg['params'].request as { url: string; method: string } + networkRequests.push({ url: req.url, method: req.method, resourceType: String(msg['params'].type) }) + } else if (method === 'Runtime.consoleAPICalled') { + // This capture does not exist anywhere else in the repo; it is the + // whole mechanism behind the "console.error count is 0" criterion. + if (msg['params'].type === 'error') { + const args = (msg['params'].args ?? []) as { value?: unknown; description?: string }[] + const text = args.map(a => String(a.value ?? a.description ?? '')).join(' ') + consoleErrors.push({ source: 'console.error', text }) + } + } else if (method === 'Runtime.exceptionThrown') { + const d = msg['params'].exceptionDetails as { text?: string; exception?: { description?: string } } + consoleErrors.push({ source: 'uncaught', text: d.exception?.description ?? d.text ?? 'unknown exception' }) + } else if (method === 'Log.entryAdded') { + // Browser-level errors (failed subresource loads, CORS, WebGL) never + // reach `consoleAPICalled`. Without this the count would miss exactly + // the failures that matter most for an asset-loading invariant. + const e = msg['params'].entry as { level: string; text: string; source: string } + if (e.level === 'error') consoleErrors.push({ source: `log:${e.source}`, text: e.text }) + } + }) + + await cdp.send('Page.enable') + await cdp.send('Runtime.enable') + await cdp.send('Network.enable') + await cdp.send('Log.enable') + + const saveScreenshot = async (filename: string): Promise => { + const shot = await cdp.send<{ data: string }>('Page.captureScreenshot', { format: 'png' }) + const filePath = assertWritablePath(join(outDir, filename)) + const buf = Buffer.from(shot.data, 'base64') + writeFileSync(filePath, buf) + console.log(` screenshot: ${filename} (${buf.length} bytes)`) + return filePath + } + + // --- Navigate ----------------------------------------------------------- + const targetUrl = + opts.negative === 'broken-page' + ? `${baseUrl}/diablo2/this-page-does-not-exist.html` + : `${baseUrl}/diablo2/acts.html?act=1` + console.log(`\n--- Navigating to ${targetUrl}`) + await cdp.send('Page.navigate', { url: targetUrl }) + + // The freeze-facing control must be installed before the scene can be + // driven, so it goes in as soon as the document exists. + if (opts.negative === 'freeze-facing') { + await sleep(1500) + await cdp.evalJs(`(() => { window.__mvFreezeFacing = true; return 1 })()`) + } + + let snap: SceneSnapshot = { present: false } as SceneSnapshot + for (let i = 0; i < 150; i++) { + snap = await cdp.evalJs(SNAPSHOT_EXPR) + if (snap.present && (snap.ready || snap.error !== null)) break + await sleep(400) + } + + console.log(` scene present=${snap.present} ready=${snap.ready} error=${String(snap.error)}`) + + log.check({ + id: 'SCENE_BOOTED', + criterion: 'audit precondition: the scene under test actually loaded and rendered', + preconditions: [{ name: '__d2webAct published', met: snap.present === true, value: String(snap.present) }], + pass: snap.ready === true && snap.error === null, + detail: `ready=${snap.ready} error=${String(snap.error)} frames=${snap.frames} tick=${snap.tick}`, + }) + + if (snap.present && snap.ready) { + // --- Drive gameplay --------------------------------------------------- + // Walking and swinging is what makes the audit cover "during play" rather + // than "at load": it exercises the walk clip, the attack path, and the + // monster draw path that produces the red placeholder square. + console.log('\n--- Driving gameplay (walk + attack)') + await cdp.evalJs(`(() => { + const sleep = ms => new Promise(r => setTimeout(r, ms)) + const press = (code, down) => + window.dispatchEvent(new KeyboardEvent(down ? 'keydown' : 'keyup', { code, bubbles: true })) + window.__mvDrive = (async () => { + const dirs = ['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp'] + for (let round = 0; round < 4; round += 1) { + const dir = dirs[round % dirs.length] + press(dir, true) + await sleep(700) + press(dir, false) + for (let i = 0; i < 3; i += 1) { + press('Space', true); await sleep(160); press('Space', false); await sleep(80) + } + } + return true + })() + return true + })()`) + await cdp.evalJs(`window.__mvDrive`) + await sleep(500) + + const played = await cdp.evalJs(SNAPSHOT_EXPR) + console.log( + ` after play: frames=${played.frames} tick=${played.tick} characterFrames=${played.characterFrames} ` + + `monstersPlanned=${played.monstersPlanned} missingMonsterArt=${JSON.stringify(played.missingMonsterArt)}`, + ) + + // --- Inject the requested fault -------------------------------------- + await injectNegativeControl(cdp, opts.negative) + await sleep(600) + + // --- Assertions ------------------------------------------------------- + const live = await cdp.evalJs(SNAPSHOT_EXPR) + const rendered = { name: 'frames rendered', met: live.frames > 0, value: String(live.frames) } + const ticked = { name: 'sim ticked', met: live.tick > 0, value: String(live.tick) } + + // 1. Zero runtime MPQ / DLL requests. + const forbidden = networkRequests.filter(r => { + const u = r.url.toLowerCase().split('?')[0] ?? '' + return u.endsWith('.mpq') || u.endsWith('.dll') || u.includes('/mpq/') || u.includes('/dll/') + }) + log.check({ + id: 'ZERO_MPQ_DLL_REQUESTS', + criterion: 'AC: 加载并游玩场景期间,对 *.mpq / *.dll 的网络请求数为 0', + preconditions: [ + rendered, + ticked, + // Without this, "0 forbidden requests" could simply mean the network + // layer was never observed at all. + { name: 'network capture live', met: networkRequests.length > 0, value: String(networkRequests.length) }, + ], + pass: forbidden.length === 0, + detail: + `${forbidden.length} forbidden of ${networkRequests.length} total requests` + + (forbidden.length > 0 ? ` -> ${forbidden.map(f => f.url).join(', ')}` : ''), + }) + + // 2. Zero console.error. + log.check({ + id: 'ZERO_CONSOLE_ERRORS', + criterion: 'AC: 审计期间 console.error 计数为 0', + preconditions: [rendered, ticked], + pass: consoleErrors.length === 0, + detail: + `${consoleErrors.length} error-level messages` + + (consoleErrors.length > 0 + ? ` -> ${consoleErrors.slice(0, 8).map(e => `[${e.source}] ${e.text.slice(0, 200)}`).join(' || ')}` + : ''), + }) + + // 3. No red placeholder squares. + // + // Read from published state, not pixels: `act-scene.ts:3352` already + // records every monster id drawn as a red box. The precondition that + // monsters were actually planned is what stops this being vacuous on a + // town level, where zero red squares is true but meaningless. + const missing = live.missingMonsterArt ?? [] + log.check({ + id: 'NO_RED_PLACEHOLDER_SQUARES', + criterion: 'AC: 无红色占位方块', + preconditions: [ + rendered, + ticked, + { name: 'monsters planned on this level', met: live.monstersPlanned > 0, value: String(live.monstersPlanned) }, + { name: 'missingMonsterArt field exists', met: live.missingMonsterArt !== null, value: String(live.missingMonsterArt !== null) }, + ], + pass: missing.length === 0 && live.monsterArtErrors === 0 && live.monsterArtLayerFailures === 0, + detail: + `missingMonsterArt=${JSON.stringify(missing)} monsterArtErrors=${live.monsterArtErrors} ` + + `monsterArtLayerFailures=${live.monsterArtLayerFailures}`, + }) + + // 3b. The published-state trap. + // + // `state.characterGroup` is declared (L254) and initialised to -1 (L328) + // and never written. It is proof that a published field can be a + // constant, so any audit reading state must verify the fields it trusts + // are actually written. This assertion documents the trap rather than + // depending on it. + log.add({ + id: 'PUBLISHED_STATE_TRAP_CHARACTERGROUP', + criterion: 'harness self-check: a published field can lie (act-scene.ts:254 characterGroup)', + verdict: live.characterGroup === -1 ? 'PASS' : 'PASS', + detail: + `characterGroup=${live.characterGroup} (init -1 at act-scene.ts:328, never written). ` + + `${live.characterGroup === -1 ? 'Confirmed still dead — no assertion may depend on it.' : 'It is now written; it became usable.'}`, + preconditions: [rendered], + }) + + // 4. Eight-direction foot-anchor evidence. + await captureEightDirections(cdp, log, saveScreenshot, live, opts) + + // 5. Weapon-swap evidence. + await captureWeaponSwap(cdp, log, saveScreenshot, outDir) + } + + // --- Report ------------------------------------------------------------- + exitCode = report(log, opts, outDir, { networkRequests, consoleErrors, served }) + + if (opts.keepOpenMs > 0) await sleep(opts.keepOpenMs) + cdp.close() + } finally { + chrome.kill('SIGKILL') + await new Promise(r => server.close(() => r())) + } + + process.exit(exitCode) +} + +/** + * Fail loudly when there is nothing real to audit. + * + * A missing `dist-game/` must not degrade into "audited nothing, all green", + * and a stale one silently audits code that no longer exists. + */ +function assertBuildPresentAndFresh(): void { + if (!existsSync(DIST_DIR) || !existsSync(join(DIST_DIR, 'acts.html'))) { + console.error(`\nFAIL: no built artifact at ${DIST_DIR} (acts.html missing).`) + console.error(' This harness audits the SHIPPED bundle. Build it first:') + console.error(' npm run build:game') + process.exit(1) + } + const newest = (dir: string): number => { + let best = 0 + const walk = (d: string): void => { + for (const e of readdirSync(d)) { + const f = join(d, e) + const st = statSync(f) + if (st.isDirectory()) walk(f) + else best = Math.max(best, st.mtimeMs) + } + } + walk(dir) + return best + } + const distTime = newest(DIST_DIR) + const srcTime = newest(SRC_DIR) + if (srcTime > distTime) { + console.error('\nFAIL: dist-game/ is STALE — a file under src/ is newer than the build.') + console.error(` newest src/ : ${new Date(srcTime).toISOString()}`) + console.error(` newest dist-game/ : ${new Date(distTime).toISOString()}`) + console.error(' Auditing a stale bundle proves nothing about the current code.') + console.error(' Rebuild: npm run build:game') + process.exit(1) + } + console.log(`Artifact OK: dist-game/ built ${new Date(distTime).toISOString()} (newer than src/)`) +} + +async function injectNegativeControl(cdp: CdpSession, negative: string): Promise { + if (negative === '') return + console.log(`\n--- Injecting negative control: ${negative}`) + switch (negative) { + case 'mpq-request': + // A genuine network request for an archive, issued by the page. It is + // expected to 404 — the invariant is about the request being made. + await cdp.evalJs(`fetch('/diablo2/data/d2char.mpq').then(()=>1).catch(()=>1)`) + break + case 'dll-request': + await cdp.evalJs(`fetch('/diablo2/data/D2Common.dll').then(()=>1).catch(()=>1)`) + break + case 'console-error': + await cdp.evalJs(`(() => { console.error('MV negative control: synthetic console.error'); return 1 })()`) + break + case 'missing-art': + await cdp.evalJs( + `(() => { window.__d2webAct.missingMonsterArt.push('mv-synthetic-missing-monster'); return 1 })()`, + ) + break + case 'monster-art-error': + await cdp.evalJs(`(() => { window.__d2webAct.monsterArtErrors += 1; return 1 })()`) + break + case 'freeze-facing': + case 'broken-page': + // Handled at their own point in the flow. + break + default: + throw new Error(`unhandled negative control ${negative}`) + } +} + +/** + * Drive the player through all eight facings and capture one screenshot each. + * + * WHY THE MACHINE ASSERTION IS NOT "the screenshots differ". + * The background has animated tiles and independently moving monsters, so any + * two full-frame captures differ even if the player never turned. An assertion + * on image difference would therefore pass unconditionally — vacuous in the + * most dangerous way, since it *looks* like pixel evidence. + * + * What is actually checkable is the mechanism: that all eight distinct facings + * were really reached, and that the character draw path was live at each one + * (`characterFrames` strictly increasing). The screenshots are then human + * review material for anchor drift, which is a judgement a script cannot make + * without a per-frame draw rect the runtime does not yet publish. + */ +async function captureEightDirections( + cdp: CdpSession, + log: AssertionLog, + saveScreenshot: (name: string) => Promise, + before: SceneSnapshot, + opts: Options, +): Promise { + console.log('\n--- Capturing 8 facings') + const observed: number[] = [] + const charFrames: number[] = [] + + for (let dir = 0; dir < 8; dir++) { + await cdp.evalJs(`(() => { + if (window.__mvFreezeFacing) return 1 + const e = window.__d2webEngine + if (!e) throw new Error('__d2webEngine is not published; cannot set facing') + e.world.player.facing = ${dir} + return 1 + })()`) + // Two sim ticks at 25 Hz plus a render. + await sleep(160) + const s = await cdp.evalJs(SNAPSHOT_EXPR) + observed.push(s.facing) + charFrames.push(s.characterFrames) + await saveScreenshot(`anchor_facing_${dir}.png`) + } + + const uniqueFacings = new Set(observed) + const charPathLive = charFrames.every((v, i) => i === 0 || v > (charFrames[i - 1] ?? -1)) + + console.log(` facings observed: ${JSON.stringify(observed)} (unique=${uniqueFacings.size})`) + console.log(` characterFrames : ${JSON.stringify(charFrames)} (strictly increasing=${charPathLive})`) + + log.check({ + id: 'EIGHT_DIRECTIONS_EXERCISED', + criterion: 'AC: 8 个方向下角色脚底位置稳定(锚点正确),有截图证据 — mechanism half', + preconditions: [ + { name: '__d2webEngine published', met: true, value: 'act-scene.ts:2677' }, + { name: 'character art loaded (not the marker box)', met: before.character === true, value: String(before.character) }, + ], + pass: uniqueFacings.size === 8 && charPathLive, + detail: + `observed facings=${JSON.stringify(observed)} unique=${uniqueFacings.size}/8; ` + + `characterFrames=${JSON.stringify(charFrames)} strictlyIncreasing=${charPathLive}; ` + + `8 screenshots written (anchor_facing_0..7.png)`, + }) + + // The numeric half of the criterion is not yet evidenceable — say so rather + // than quietly shipping screenshots as if they were a regression test. + log.add({ + id: 'FOOT_ANCHOR_NUMERIC_INVARIANT', + criterion: 'AC: 脚底位置稳定(无漂移)— numeric half', + verdict: 'VACUOUS', + detail: + 'NOT EVIDENCEABLE on current main. The draw rect is computed inline at act-scene.ts:3321 ' + + '(`player.x - frame.width/2, player.y - frame.height + FEET_HEIGHT/2`) from closure-local ' + + '`character`/`frame`, neither of which is published, so no in-page read can recover it. ' + + 'REQUIRED OF M4: publish `state.playerDrawRect = {x,y,w,h}` each frame; this harness will ' + + 'then assert |(y+h) - player.y| <= 1 across all 8 facings, which is the actual anti-drift test. ' + + 'Screenshots alone are human-review evidence, not a regression gate.', + preconditions: [{ name: 'state.playerDrawRect published', met: false, value: 'absent' }], + }) + + if (opts.negative === 'freeze-facing') { + console.log(' (freeze-facing control active: facing writes were suppressed in-page)') + } +} + +/** + * Capture evidence for "swapping weapon class changes the attack animation". + * + * On current `main` the player COF is hardcoded to `hth` (tier-3 char load + * passes the literal `'hth'`, `act-scene.ts:2388-2389`), and no weapon class + * reaches the clip choice at all, so this is a known-red baseline that M3 owns. + * The harness still captures the before/after so the change is reviewable, and + * records the equipped codes structurally rather than claiming a screenshot + * proves something it does not. + */ +async function captureWeaponSwap( + cdp: CdpSession, + log: AssertionLog, + saveScreenshot: (name: string) => Promise, + outDir: string, +): Promise { + console.log('\n--- Weapon-swap evidence') + + const readWeapon = `(() => { + const hud = window.__d2webHudInstance + const inv = hud && hud.inventory + const eq = inv && inv.equipped + const w1 = eq && eq.weapon1 + return { + hudPresent: !!hud, + inventoryPresent: !!inv, + weapon1: w1 ? { code: w1.code, name: w1.name } : null, + // The runtime does not publish a resolved weapon class or the COF clip in + // use. Recorded as null so the gap is visible in the artifact rather than + // implied by omission. + resolvedWeaponClass: (window.__d2webAct && window.__d2webAct.weaponClass) ?? null, + activeClip: (window.__d2webAct && window.__d2webAct.playerClip) ?? null, + } + })()` + + interface WeaponEvidence { + hudPresent: boolean + inventoryPresent: boolean + weapon1: { code: string; name: string } | null + resolvedWeaponClass: string | null + activeClip: string | null + } + + const before = await cdp.evalJs(readWeapon) + await saveScreenshot('weapon_before.png') + + // Swap to a two-handed weapon so the weapon class genuinely differs. `2hs` + // vs `hth` is the coarsest possible distinction, which is the right one for a + // first regression signal. + const swapped = await cdp.evalJs(`(() => { + const inv = window.__d2webHudInstance && window.__d2webHudInstance.inventory + if (!inv) return false + inv.equipped.weapon1 = { + id: 'mv-swap-2hs', code: '7wa', invFile: 'invgpa', name: 'Colossus Blade', + quality: 'normal', invWidth: 2, invHeight: 4, allowedSlots: ['weapon1'], + } + return true + })()`) + await sleep(600) + const after = await cdp.evalJs(readWeapon) + await saveScreenshot('weapon_after.png') + + const evidencePath = assertWritablePath(join(outDir, 'weapon-swap-evidence.json')) + writeFileSync(evidencePath, JSON.stringify({ swapped, before, after }, null, 2)) + console.log(` weapon evidence: ${relative(ROOT, evidencePath)}`) + console.log(` before=${JSON.stringify(before)}`) + console.log(` after =${JSON.stringify(after)}`) + + log.check({ + id: 'WEAPON_SWAP_EVIDENCE_CAPTURED', + criterion: 'AC: 换装不同武器类后…有截图或日志证据 — capture half', + preconditions: [{ name: 'HUD inventory reachable', met: before.inventoryPresent, value: String(before.inventoryPresent) }], + pass: swapped && before.weapon1 !== null && after.weapon1 !== null && before.weapon1.code !== after.weapon1.code, + detail: `equipped weapon1 ${before.weapon1?.code ?? 'none'} -> ${after.weapon1?.code ?? 'none'}; screenshots + JSON written`, + }) + + log.check({ + id: 'WEAPON_SWAP_CHANGES_ANIMATION', + criterion: 'AC: 换装不同武器类后,攻击动作随之变化', + preconditions: [ + { + name: 'runtime publishes the resolved weapon class / active clip', + met: after.resolvedWeaponClass !== null || after.activeClip !== null, + value: `weaponClass=${String(after.resolvedWeaponClass)} activeClip=${String(after.activeClip)}`, + }, + ], + pass: before.activeClip !== after.activeClip, + detail: + 'Player COF weapon class is hardcoded to `hth` on current main ' + + '(act-scene.ts:2388-2389 passes the literal), and neither the resolved weapon class nor the ' + + 'active clip is published, so there is nothing to compare. REQUIRED OF M3: publish ' + + '`state.weaponClass` and `state.playerClip`; this assertion then becomes real.', + knownRedBaseline: 'M3 owns weapon-class-aware COF selection (R3). Expected red until M3 lands.', + }) +} + +function report( + log: AssertionLog, + opts: Options, + outDir: string, + raw: { networkRequests: NetworkRequestLog[]; consoleErrors: ConsoleErrorLog[]; served: ServedRequest[] }, +): number { + console.log('\n======================================================================') + console.log('RESULTS') + console.log('======================================================================') + + let hardFail = 0 + let vacuous = 0 + let knownRed = 0 + let baselineImproved = 0 + + for (const a of log.items) { + const isKnownRed = a.knownRedBaseline !== undefined + let tag: string + if (a.verdict === 'PASS') { + if (isKnownRed) { + tag = 'BASELINE-IMPROVED' + baselineImproved++ + } else tag = 'PASS' + } else if (a.verdict === 'VACUOUS') { + if (isKnownRed) { + tag = 'KNOWN-RED' + knownRed++ + } else { + tag = 'VACUOUS(=FAIL)' + vacuous++ + } + } else { + if (isKnownRed) { + tag = 'KNOWN-RED' + knownRed++ + } else { + tag = 'FAIL' + hardFail++ + } + } + console.log(`\n[${tag}] ${a.id}`) + console.log(` criterion: ${a.criterion}`) + console.log(` detail : ${a.detail}`) + for (const p of a.preconditions) { + console.log(` precond : ${p.met ? 'met' : 'UNMET'} — ${p.name} = ${p.value}`) + } + if (a.knownRedBaseline !== undefined) console.log(` baseline : ${a.knownRedBaseline}`) + } + + const summaryPath = assertWritablePath(join(outDir, 'audit-report.json')) + writeFileSync( + summaryPath, + JSON.stringify( + { + negativeControl: opts.negative === '' ? null : opts.negative, + generated: new Date().toISOString(), + assertions: log.items, + totals: { hardFail, vacuous, knownRed, baselineImproved }, + networkRequestCount: raw.networkRequests.length, + forbiddenRequests: raw.networkRequests + .filter(r => { + const u = r.url.toLowerCase().split('?')[0] ?? '' + return u.endsWith('.mpq') || u.endsWith('.dll') + }) + .map(r => r.url), + consoleErrors: raw.consoleErrors, + serverRequestCount: raw.served.length, + server404s: raw.served.filter(s => s.status === 404).map(s => s.url), + }, + null, + 2, + ), + ) + + console.log('\n----------------------------------------------------------------------') + console.log( + `SUMMARY: ${log.items.length} assertions | FAIL=${hardFail} VACUOUS=${vacuous} ` + + `KNOWN-RED=${knownRed} BASELINE-IMPROVED=${baselineImproved}`, + ) + console.log(`Report: ${relative(ROOT, summaryPath)}`) + + if (baselineImproved > 0) { + console.log('\nNOTE: a known-red baseline turned green. Update the baseline annotation in this script.') + } + + if (opts.negative !== '') { + // Under a negative control the run is SUPPOSED to be red. Report the + // inversion explicitly so a control that failed to bite is not mistaken + // for a clean run. + const red = hardFail + vacuous + console.log('\n----------------------------------------------------------------------') + if (red > 0) { + console.log(`NEGATIVE CONTROL '${opts.negative}': harness went RED as required (${red} failing assertion(s)).`) + return 0 + } + console.log(`NEGATIVE CONTROL '${opts.negative}': ***HARNESS STAYED GREEN — THE CONTROL DID NOT BITE***`) + console.log('This means the assertion it targets cannot detect its own failure mode.') + return 1 + } + + return hardFail + vacuous > 0 ? 1 : 0 +} + +main().catch((err: unknown) => { + console.error('\nHARNESS ERROR:', err) + process.exit(1) +}) diff --git a/scripts/verify-bundle-no-mpq.ts b/scripts/verify-bundle-no-mpq.ts new file mode 100644 index 0000000..e3b59d5 --- /dev/null +++ b/scripts/verify-bundle-no-mpq.ts @@ -0,0 +1,561 @@ +/** + * CI HARD GATE — the shipped bundle must not be *able* to fetch a game archive. + * + * WHY A STRING GREP IS NOT ENOUGH (and what this does instead) + * ------------------------------------------------------------ + * The obvious gate is "grep `dist-game/` for `d2char.mpq`". That gate is a trap. + * It proves only that *one particular spelling* is absent, and every one of + * these evades it while leaving the capability fully intact: + * + * const a = 'd2char' + '.mpq' + * const b = `${name}.mpq` + * const c = ['d2','char','.mpq'].join('') + * + * So the moment someone removes the literal — by an innocent refactor, by a + * minifier transformation, or by deliberately routing around the gate — it goes + * green, convincingly, while the bundle can still stream an archive. A false + * green is worse than no gate, because it terminates scrutiny. + * + * This gate therefore asserts a **capability**, not a spelling: + * + * 1. CAPABILITY_NOT_REACHABLE (primary) + * Reconstruct the chunk dependency graph from the emitted artifact + * itself — follow `import("./x.js")` and `from"./x.js"` out of every + * HTML entry — and assert that no reachable chunk contains the + * *behavioural* fingerprints of the MPQ machinery: the HTTP-Range + * archive reader and the MPQ header decoder. Those fingerprints are + * error-message string literals inside the functions themselves, which + * survive minification and have nothing to do with any archive filename. + * Renaming or concatenating `d2char.mpq` does not move them. + * + * 2. NO_ARCHIVE_LITERALS (secondary, retained) + * The original literal scan. Still useful: it catches a new archive name + * that no fingerprint covers. + * + * 3. FRAGMENT_SCAN (informational) + * Fragmented forms — a bare `.mpq`, a standalone `mpq`/`dll` token — + * listed for human review, since a concatenation gate cannot be made + * reliable automatically. + * + * Both `*.mpq` AND `*.dll` are covered; the acceptance criterion names both. + * + * KNOWN-RED ON `main` (2026-09-21) — the expected, desired result + * --------------------------------------------------------------- + * `src/scene/act-scene.ts` has an unguarded production path: + * + * loadCharacterArt L2384 + * -> getMountedCharArchives L2013 + * -> getCachedMpqArchive L1968 + * -> httpRangeSource(base + '/d2char.mpq') L1974 + * + * Measured consequence in the artifact: `acts-*.js` dynamically imports + * `source-*.js` (the HTTP-Range reader) and `archive-*.js` (the MPQ decoder), + * and carries `DATA_ARCHIVES` / `CHARACTER_ARCHIVE` through minification. + * + * ⚠ BINDING NOTE FOR THE MILESTONE THAT FIXES THIS (M4): + * the fix must be **STRUCTURAL** — remove the live-MPQ path from the production + * entry graph, or isolate it behind a dev-only entry point, so the chunks are + * not emitted/reachable at all. It must NOT be renaming the constants, and it + * must NOT be splitting them into concatenation to evade the literal scan. + * Assertion 1 exists precisely so that evasion cannot produce a green gate. + * + * SOURCE MAPS + * ----------- + * A `.map` embeds `sourcesContent`, i.e. the entire original source text, so a + * literal survives in the map even after the code using it is perfectly + * dead-code-eliminated. Hard-failing on maps would make the gate unsatisfiable + * without deleting the string from the source tree — exactly the pressure that + * gets a gate weakened instead of a bug fixed. Maps are therefore + * INFORMATIONAL, and are classified **by content** (parsed as a source map with + * `version` + `sources`), not by filename, so renaming an executable asset to + * `*.map` cannot launder it into the exempt bucket. + * + * VACUITY GUARD + * ------------- + * A gate that passes because it examined nothing is the same failure class it + * is meant to prevent. The run fails unless it actually scanned at least one + * emitted `.js` asset and resolved at least one HTML entry into a chunk graph. + * + * Usage: + * npm run verify:bundle-no-mpq + * npm run verify:bundle-no-mpq -- --dir=dist-game --report= + */ + +import { readdirSync, readFileSync, statSync, mkdirSync, writeFileSync } from 'node:fs' +import { dirname, extname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(process.cwd()) + +// --------------------------------------------------------------------------- +// Patterns +// --------------------------------------------------------------------------- + +/** + * Whole archive/library filenames. + * + * The named entries mirror `src/scene/act-scene.ts` L80-82 (`DATA_ARCHIVES`, + * `CHARACTER_ARCHIVE`); the two catch-alls mean a newly introduced archive name + * cannot slip past just because this list was not updated. + */ +const FORBIDDEN_PATTERNS: readonly { readonly label: string; readonly re: RegExp }[] = [ + { label: 'd2char.mpq', re: /d2char\.mpq/gi }, + { label: 'd2data.mpq', re: /d2data\.mpq/gi }, + { label: 'd2exp.mpq', re: /d2exp\.mpq/gi }, + { label: 'Patch_D2.mpq', re: /patch_d2\.mpq/gi }, + { label: '.mpq', re: /[\w-]+\.mpq/gi }, + { label: '.dll', re: /[\w-]+\.dll/gi }, +] + +/** + * Fragmented spellings a literal scan cannot reason about. + * + * Reported, never auto-failed: `'mpq'` appears in plenty of innocent contexts + * (a directory name, a comment that survived, a decoder's own identity string). + * Automatic failure here would produce noise that trains people to ignore the + * gate. Assertion 1 is what actually closes the concatenation hole; this exists + * so a human can see the fragments while reviewing. + */ +const FRAGMENT_PATTERNS: readonly { readonly label: string; readonly re: RegExp }[] = [ + { label: "bare '.mpq'", re: /["'`]\.mpq["'`]/gi }, + { label: "bare '.dll'", re: /["'`]\.dll["'`]/gi }, + { label: "standalone 'mpq' string", re: /["'`]mpq["'`]/gi }, + { label: "standalone 'dll' string", re: /["'`]dll["'`]/gi }, +] + +/** + * Behavioural fingerprints of the MPQ machinery. + * + * These are error-message literals from inside the functions that implement the + * capability, so they identify *the code being present*, independent of any + * archive filename. Verified to survive Vite/esbuild minification in the + * current build (they live in `source-*.js` and `archive-*.js`). + */ +const CAPABILITY_FINGERPRINTS: readonly { + readonly id: string + readonly needle: string + readonly origin: string + readonly why: string +}[] = [ + { + id: 'HTTP_RANGE_ARCHIVE_READER', + needle: 'range requests are required to read an archive this large', + origin: 'src/mpq/source.ts httpRangeSource() L107-114', + why: 'the function that streams an archive over HTTP Range — the actual download capability', + }, + { + id: 'MPQ_HEADER_DECODER', + needle: 'not an MPQ archive (magic 0x', + origin: 'src/mpq/archive.ts MpqArchive.open() L136', + why: 'the MPQ container decoder; present only if archive parsing ships', + }, + { + id: 'MPQ_HASH_TABLE_DECODER', + needle: 'is not a non-zero power of two', + origin: 'src/mpq/archive.ts L156', + why: 'MPQ hash-table validation; corroborates the decoder fingerprint', + }, +] + +/** Extensions considered emitted/executed output. */ +const EXECUTABLE_EXT = new Set(['.js', '.mjs', '.cjs', '.html', '.htm', '.css', '.json']) + +// --------------------------------------------------------------------------- +// Model +// --------------------------------------------------------------------------- + +interface Occurrence { + readonly file: string + readonly pattern: string + readonly count: number + readonly sample: string +} + +interface CapabilityHit { + readonly fingerprintId: string + readonly file: string + readonly reachableFrom: readonly string[] + readonly origin: string + readonly why: string +} + +export interface BundleScanResult { + readonly root: string + readonly filesTotal: number + readonly emittedJsScanned: number + readonly entries: readonly string[] + /** entry html -> emitted chunk files reachable from it. */ + readonly reachable: Readonly> + readonly orphanChunks: readonly string[] + readonly capabilityHits: readonly CapabilityHit[] + readonly literalHard: readonly Occurrence[] + readonly literalSourceMapOnly: readonly Occurrence[] + readonly fragments: readonly Occurrence[] +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry) + if (statSync(full).isDirectory()) walk(full, out) + else out.push(full) + } + return out +} + +/** + * Decide whether a file is a genuine source map, by parsing it. + * + * Extension alone is not enough: a rename must not be able to move an + * executable asset into the informational bucket. + */ +function isRealSourceMap(text: string): boolean { + if (text.length === 0 || !text.trimStart().startsWith('{')) return false + try { + const parsed = JSON.parse(text) as Record + return ( + typeof parsed['version'] === 'number' && + Array.isArray(parsed['sources']) && + (parsed['mappings'] !== undefined || parsed['sourcesContent'] !== undefined) + ) + } catch { + // Not parseable as JSON, therefore not a source map. This is a + // classification answer, not a swallowed error: the caller treats the file + // as emitted output, which is the conservative direction. + return false + } +} + +function sampleAround(text: string, re: RegExp): string { + const probe = new RegExp(re.source, re.flags.replace('g', '')) + const m = probe.exec(text) + if (m === null) return '(no sample)' + const from = Math.max(0, m.index - 90) + const to = Math.min(text.length, m.index + 90) + return `...${text.slice(from, to).replace(/\s+/g, ' ')}...` +} + +/** + * Rebuild the chunk dependency graph from the artifact. + * + * Reading the artifact rather than a build-time manifest matters: it is the + * thing that actually ships, and it needs no change to `vite.config.ts` (which + * this milestone does not own). Static `from"./x.js"` / `import"./x.js"` and + * dynamic `import("./x.js")` are all followed, so a lazily-imported chunk — the + * exact shape the MPQ fallback uses — is still counted as reachable. + */ +function buildChunkGraph(root: string, files: readonly string[]): { + entries: string[] + reachable: Record + orphans: string[] +} { + const rel = (f: string): string => relative(root, f).split('\\').join('/') + const byRel = new Map() + for (const f of files) byRel.set(rel(f), f) + + const htmlEntries = files.filter(f => f.toLowerCase().endsWith('.html')).map(rel) + + const refsOf = (relPath: string): string[] => { + const abs = byRel.get(relPath) + if (abs === undefined) return [] + let text: string + try { + text = readFileSync(abs, 'utf8') + } catch { + return [] + } + const out = new Set() + const dir = dirname(relPath) + const add = (spec: string): void => { + const joined = spec.startsWith('/') + ? spec.replace(/^\/diablo2\//, '').replace(/^\//, '') + : join(dir, spec).split('\\').join('/') + if (byRel.has(joined)) out.add(joined) + } + // HTML: