From 290e85c50d2db404307ca39f5559373f55409bd1 Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:42:37 -0400 Subject: [PATCH] M-29 docs/tools: SMALL-FIXES notes, LEVEL-OBJECTIVES decode, animgen dump helpers --- docs/LEVEL-OBJECTIVES.md | 210 ++++++++++++++++++ docs/SMALL-FIXES.md | 430 ++++++++++++++++++++++++++++++++++++ tools_pc/attach_animgen.sh | 12 + tools_pc/dump_animgen.cmd | 58 +++++ tools_pc/dump_objectives.py | 210 ++++++++++++++++++ 5 files changed, 920 insertions(+) create mode 100644 docs/LEVEL-OBJECTIVES.md create mode 100644 docs/SMALL-FIXES.md create mode 100644 tools_pc/attach_animgen.sh create mode 100644 tools_pc/dump_animgen.cmd create mode 100644 tools_pc/dump_objectives.py diff --git a/docs/LEVEL-OBJECTIVES.md b/docs/LEVEL-OBJECTIVES.md new file mode 100644 index 00000000..a294905d --- /dev/null +++ b/docs/LEVEL-OBJECTIVES.md @@ -0,0 +1,210 @@ + +=== level_09 (level 9, UsetupsevbunkerZ) === +Objective 0 [briefing text id 30749] min difficulty: Agent + - DESTROY_OBJECT tag 0 -> CCTV + - DESTROY_OBJECT tag 1 -> CCTV + - DESTROY_OBJECT tag 2 -> CCTV + - DESTROY_OBJECT tag 3 -> CCTV +Objective 1 [briefing text id 30750] min difficulty: Agent + - COPY_ITEM (key-analyzer/datavac copy flag) +Objective 2 [briefing text id 30751] min difficulty: Agent + - COMPLETE_CONDITION stage flag mask 0x100 (bit(s) [8]) + - FAIL_CONDITION stage flag mask 0x200 (bit(s) [9]) -- mission FAILS when set +Objective 3 [briefing text id 30752] min difficulty: Agent + - COMPLETE_CONDITION stage flag mask 0x400 (bit(s) [10]) + - FAIL_CONDITION stage flag mask 0x800 (bit(s) [11]) -- mission FAILS when set + - FAIL_CONDITION stage flag mask 0x200 (bit(s) [9]) -- mission FAILS when set +Objective 4 [briefing text id 30753] min difficulty: Agent + - PHOTOGRAPH tag 5 -> MULTI_MONITOR; photo-flag word @8=0x0 + +=== level_20 (level 20, UsetupsiloZ) === +Objective 0 [briefing text id 34834] min difficulty: Agent + - DEPOSIT_IN_ROOM ref @4=34 ref @8=4; runtime word @c=0x0 + - DEPOSIT_IN_ROOM ref @4=34 ref @8=12; runtime word @c=0x0 + - DEPOSIT_IN_ROOM ref @4=34 ref @8=88; runtime word @c=0x0 + - DEPOSIT_IN_ROOM ref @4=34 ref @8=95; runtime word @c=0x0 +Objective 1 [briefing text id 34835] min difficulty: Agent + - PHOTOGRAPH tag 0 -> PROP; photo-flag word @8=0x0 +Objective 2 [briefing text id 34836] min difficulty: Agent + - COLLECT_OBJECT tag 1 -> PROP +Objective 3 [briefing text id 34837] min difficulty: Agent + - COLLECT_OBJECT tag 2 -> PROP + - COLLECT_OBJECT tag 3 -> PROP + - COLLECT_OBJECT tag 4 -> PROP + - COLLECT_OBJECT tag 5 -> PROP + - FAIL_CONDITION stage flag mask 0x100 (bit(s) [8]) -- mission FAILS when set + - FAIL_CONDITION stage flag mask 0x200 (bit(s) [9]) -- mission FAILS when set + - FAIL_CONDITION stage flag mask 0x400 (bit(s) [10]) -- mission FAILS when set + - FAIL_CONDITION stage flag mask 0x800 (bit(s) [11]) -- mission FAILS when set +Objective 4 [briefing text id 34838] min difficulty: Agent + - COMPLETE_CONDITION stage flag mask 0x8000 (bit(s) [15]) + - FAIL_CONDITION stage flag mask 0x4000 (bit(s) [14]) -- mission FAILS when set + +=== level_21 (level 21, UsetupsevbunkerZ) === +Objective 0 [briefing text id 30749] min difficulty: Agent + - DESTROY_OBJECT tag 0 -> CCTV + - DESTROY_OBJECT tag 1 -> CCTV + - DESTROY_OBJECT tag 2 -> CCTV + - DESTROY_OBJECT tag 3 -> CCTV +Objective 1 [briefing text id 30750] min difficulty: Agent + - COPY_ITEM (key-analyzer/datavac copy flag) +Objective 2 [briefing text id 30751] min difficulty: Agent + - COMPLETE_CONDITION stage flag mask 0x100 (bit(s) [8]) + - FAIL_CONDITION stage flag mask 0x200 (bit(s) [9]) -- mission FAILS when set +Objective 3 [briefing text id 30752] min difficulty: Agent + - COMPLETE_CONDITION stage flag mask 0x400 (bit(s) [10]) + - FAIL_CONDITION stage flag mask 0x800 (bit(s) [11]) -- mission FAILS when set + - FAIL_CONDITION stage flag mask 0x200 (bit(s) [9]) -- mission FAILS when set +Objective 4 [briefing text id 30753] min difficulty: Agent + - PHOTOGRAPH tag 5 -> MULTI_MONITOR; photo-flag word @8=0x0 + +=== level_22 (level 22, UsetupstatueZ) === +Objective 0 [briefing text id 35897] min difficulty: Agent + - FAIL_CONDITION stage flag mask 0x200000 (bit(s) [21]) -- mission FAILS when set + - COMPLETE_CONDITION stage flag mask 0x100 (bit(s) [8]) +Objective 1 [briefing text id 35898] min difficulty: Agent + - FAIL_CONDITION stage flag mask 0x20000 (bit(s) [17]) -- mission FAILS when set + - FAIL_CONDITION stage flag mask 0x200000 (bit(s) [21]) -- mission FAILS when set + - COMPLETE_CONDITION stage flag mask 0x400 (bit(s) [10]) +Objective 2 [briefing text id 35899] min difficulty: Agent + - COMPLETE_CONDITION stage flag mask 0x200 (bit(s) [9]) + - FAIL_CONDITION stage flag mask 0x200000 (bit(s) [21]) -- mission FAILS when set +Objective 3 [briefing text id 35900] min difficulty: Agent + - FAIL_CONDITION stage flag mask 0x400000 (bit(s) [22]) -- mission FAILS when set + - FAIL_CONDITION stage flag mask 0x200000 (bit(s) [21]) -- mission FAILS when set + - COMPLETE_CONDITION stage flag mask 0x800 (bit(s) [11]) +Objective 4 [briefing text id 35901] min difficulty: Agent + - COLLECT_OBJECT tag 1 -> PROP + - FAIL_CONDITION stage flag mask 0x200000 (bit(s) [21]) -- mission FAILS when set + +=== level_23 (level 23, UsetupcontrolZ) === +Objective 0 [briefing text id 8233] min difficulty: Agent + - COMPLETE_CONDITION stage flag mask 0x1000 (bit(s) [12]) + - FAIL_CONDITION stage flag mask 0x2000 (bit(s) [13]) -- mission FAILS when set +Objective 1 [briefing text id 8234] min difficulty: Agent + - COMPLETE_CONDITION stage flag mask 0x4000 (bit(s) [14]) + - FAIL_CONDITION stage flag mask 0x10000 (bit(s) [16]) -- mission FAILS when set +Objective 2 [briefing text id 8235] min difficulty: Agent + - DESTROY_OBJECT tag 21 -> PROP + - DESTROY_OBJECT tag 22 -> PROP + - DESTROY_OBJECT tag 23 -> PROP + - DESTROY_OBJECT tag 24 -> PROP + - DESTROY_OBJECT tag 25 -> PROP + - DESTROY_OBJECT tag 26 -> PROP + +=== level_24 (level 24, UsetuparchZ) === +Objective 0 [briefing text id 2089] min difficulty: Agent + - ENTER_ROOM ref @4=152 (room); runtime word @8=0x0 +Objective 1 [briefing text id 2090] min difficulty: Agent + - COMPLETE_CONDITION stage flag mask 0x100 (bit(s) [8]) +Objective 2 [briefing text id 2091] min difficulty: Agent + - COLLECT_OBJECT tag 2 -> PROP + - FAIL_CONDITION stage flag mask 0x1000 (bit(s) [12]) -- mission FAILS when set +Objective 3 [briefing text id 2092] min difficulty: Agent + - ENTER_ROOM ref @4=208 (room); runtime word @8=0x0 + - COMPLETE_CONDITION stage flag mask 0x400 (bit(s) [10]) + - FAIL_CONDITION stage flag mask 0x200 (bit(s) [9]) -- mission FAILS when set + +=== level_25 (level 25, UsetuptraZ) === +Objective 0 [briefing text id 36898] min difficulty: Agent + - DESTROY_OBJECT tag 8 -> PROP + - DESTROY_OBJECT tag 9 -> PROP + - DESTROY_OBJECT tag 10 -> PROP + - DESTROY_OBJECT tag 11 -> PROP + - DESTROY_OBJECT tag 12 -> PROP + - DESTROY_OBJECT tag 13 -> PROP +Objective 1 [briefing text id 36899] min difficulty: Agent + - COMPLETE_CONDITION stage flag mask 0x8000 (bit(s) [15]) + - FAIL_CONDITION stage flag mask 0x4000 (bit(s) [14]) -- mission FAILS when set +Objective 2 [briefing text id 36900] min difficulty: Agent + - COMPLETE_CONDITION stage flag mask 0x10000 (bit(s) [16]) +Objective 3 [briefing text id 36901] min difficulty: Agent + - COMPLETE_CONDITION stage flag mask 0x20000 (bit(s) [17]) +Objective 4 [briefing text id 36902] min difficulty: Agent + - COMPLETE_CONDITION stage flag mask 0x100000 (bit(s) [20]) + +=== level_26 (level 26, UsetupdestZ) === +Objective 0 [briefing text id 13332] min difficulty: Agent + - COMPLETE_CONDITION stage flag mask 0x100 (bit(s) [8]) + - COMPLETE_CONDITION stage flag mask 0x200 (bit(s) [9]) + - COMPLETE_CONDITION stage flag mask 0x400 (bit(s) [10]) + - COMPLETE_CONDITION stage flag mask 0x800 (bit(s) [11]) + - COMPLETE_CONDITION stage flag mask 0x1000 (bit(s) [12]) + - FAIL_CONDITION stage flag mask 0x100000 (bit(s) [20]) -- mission FAILS when set +Objective 1 [briefing text id 13333] min difficulty: Agent + - COMPLETE_CONDITION stage flag mask 0x10000 (bit(s) [16]) + - FAIL_CONDITION stage flag mask 0x40000 (bit(s) [18]) -- mission FAILS when set +Objective 2 [briefing text id 13334] min difficulty: Agent + - COMPLETE_CONDITION stage flag mask 0x20000 (bit(s) [17]) + - FAIL_CONDITION stage flag mask 0x80000 (bit(s) [19]) -- mission FAILS when set +Objective 3 [briefing text id 13335] min difficulty: Agent + - COMPLETE_CONDITION stage flag mask 0x4000 (bit(s) [14]) + - FAIL_CONDITION stage flag mask 0x8000 (bit(s) [15]) -- mission FAILS when set + +=== level_27 (level 27, UsetupsevbZ) === +Objective 0 [briefing text id 29743] min difficulty: Agent + - COMPLETE_CONDITION stage flag mask 0x200 (bit(s) [9]) +Objective 1 [briefing text id 29744] min difficulty: Agent + - FAIL_CONDITION stage flag mask 0x1000 (bit(s) [12]) -- mission FAILS when set + - COLLECT_OBJECT tag 2 -> PROP +Objective 2 [briefing text id 29745] min difficulty: Agent + - DESTROY_OBJECT tag 28 -> CCTV + - DESTROY_OBJECT tag 29 -> CCTV + - DESTROY_OBJECT tag 30 -> CCTV + - DESTROY_OBJECT tag 31 -> CCTV + - DESTROY_OBJECT tag 32 -> CCTV + - DESTROY_OBJECT tag 33 -> CCTV +Objective 3 [briefing text id 29746] min difficulty: Agent + - COLLECT_OBJECT tag 34 -> PROP +Objective 4 [briefing text id 29747] min difficulty: Agent + - FAIL_CONDITION stage flag mask 0x100 (bit(s) [8]) -- mission FAILS when set + - COMPLETE_CONDITION stage flag mask 0x800 (bit(s) [11]) + - ENTER_ROOM ref @4=51 (room); runtime word @8=0x0 + +=== level_28 (level 28, UsetupaztZ) === +Objective 0 [briefing text id 5141] min difficulty: Agent + - COMPLETE_CONDITION stage flag mask 0x1000 (bit(s) [12]) +Objective 1 [briefing text id 5142] min difficulty: Agent + - COMPLETE_CONDITION stage flag mask 0x8000 (bit(s) [15]) + +=== level_29 (level 29, UsetuppeteZ) === +Objective 0 [briefing text id 25627] min difficulty: Agent + - COMPLETE_CONDITION stage flag mask 0x20000 (bit(s) [17]) + - FAIL_CONDITION stage flag mask 0x40000 (bit(s) [18]) -- mission FAILS when set +Objective 1 [briefing text id 25625] min difficulty: Agent + - COMPLETE_CONDITION stage flag mask 0x4000 (bit(s) [14]) + - FAIL_CONDITION stage flag mask 0x8000 (bit(s) [15]) -- mission FAILS when set +Objective 2 [briefing text id 25626] min difficulty: Agent + - COMPLETE_CONDITION stage flag mask 0x4000 (bit(s) [14]) + - FAIL_CONDITION stage flag mask 0x1000 (bit(s) [12]) -- mission FAILS when set + +=== level_30 (level 30, UsetupdepoZ) === +Objective 0 [briefing text id 12303] min difficulty: Agent + - DESTROY_OBJECT tag 10 -> PROP + - DESTROY_OBJECT tag 11 -> PROP + - DESTROY_OBJECT tag 12 -> PROP + - DESTROY_OBJECT tag 13 -> PROP + - DESTROY_OBJECT tag 14 -> PROP + - DESTROY_OBJECT tag 15 -> PROP + - DESTROY_OBJECT tag 16 -> PROP + - DESTROY_OBJECT tag 17 -> PROP + - DESTROY_OBJECT tag 18 -> PROP + - DESTROY_OBJECT tag 19 -> PROP + - DESTROY_OBJECT tag 20 -> PROP + - DESTROY_OBJECT tag 21 -> PROP + - DESTROY_OBJECT tag 22 -> PROP + - DESTROY_OBJECT tag 23 -> PROP + - DESTROY_OBJECT tag 24 -> PROP + - DESTROY_OBJECT tag 25 -> PROP + - DESTROY_OBJECT tag 26 -> PROP +Objective 1 [briefing text id 12304] min difficulty: Agent + - DESTROY_OBJECT tag 1 -> MULTI_MONITOR + - DESTROY_OBJECT tag 2 -> PROP + - DESTROY_OBJECT tag 3 -> PROP +Objective 2 [briefing text id 12305] min difficulty: Agent + - COLLECT_OBJECT tag 0 -> KEY +Objective 3 [briefing text id 12306] min difficulty: Agent + - COLLECT_OBJECT tag 5 -> PROP +Objective 4 [briefing text id 12307] min difficulty: Agent + - COMPLETE_CONDITION stage flag mask 0x100 (bit(s) [8]) +UsetuprefZ not in filelist diff --git a/docs/SMALL-FIXES.md b/docs/SMALL-FIXES.md new file mode 100644 index 00000000..8c88fea5 --- /dev/null +++ b/docs/SMALL-FIXES.md @@ -0,0 +1,430 @@ +# SMALL-FIXES — ready-to-execute fix notes for small / low-level open items + +**Read-only document.** This file records *proposed fixes and verification +procedures* for the small, low-level bugs and noted issues that are parked in +`BACKLOG.md`, `GRAPHICS-BACKLOG.md`, `HANDOFF.md`, and the §F/§H finding log. +No code was changed when writing it (M-28). Each item is self-contained enough +to be picked up cold; items marked **VERIFY-ONLY** already have their fix in +the tree and only need confirmation + commit hygiene. + +Standing rules for every item below: + +- Game-code edits must stay inside the narrow `#ifdef PORT` ABI/layout + exception (AGENTS.md #2); port-layer work has no such limit. Anything + bigger than described here → stop, write it up with a confidence rating. +- New findings get the next free Dxx in `docs/PCPortResearch.md` §F/§H + an + index row; recurring bug classes go to `docs/PORT-LEARNINGS.md`. +- After any converter change: full regen chain + `python tools_pc/d43_emit.py ntsc-final && python tools_pc/d69_emit.py + ntsc-final && python tools_pc/d88_emit.py ntsc-final --regen`. +- Verification anchors (quiet machine only — D153): target level boots to ≥1 + non-degenerate frame (`tools_pc/pixcount.py`), `-level_09` + `-level_20` + unregressed (`tools_pc/framediff.py`, use `--mask` for the HUD). +- Crashes: `tools_pc/debug.ps1` (gdb + FATAL line + backtrace → `gdb.txt`). + +--- + +## A. VERIFY-ONLY — fix is in the tree, needs confirmation + +### A1. D154 — `bg.c` room-geometry hit-test GBI parser (committed WIP `072b5c44`) + +**Status:** written + builds green; **never exercised** (a no-input +`GE_PCDUMP` capture fires no bullets, and the M-28 box was thrashed). + +**What it is:** `bgTestRayIntersectionInRoom` (`src/game/bg.c:~3331`, called +from `bgTestBulletHitBackground` on every shot that resolves against +background geometry) was the D135 sibling: raw 8-byte-N64-`Gfx` byte/word +indexing into the 16-byte PC room DL → desync → OOB `vtxbase[idx]` / walk-off +AV the first time a wall or floor is shot. The `#ifdef PORT` port reads +`gdl->words.w0/.w1` (G_TRI1 from `(w1>>16/8/0)&0xff/10`; G_TRI4 nibbles from +`(u32)words.w0/.w1` with the byte-mapping table in the code comment); both +`texturenum` recoveries return `-1` (generic impact decal/sound, parked w/ D77). + +**Procedure:** +1. Quiet machine. `./tools_pc/debug.ps1 -level_09`. +2. Get into a firefight and **shoot walls/floor deliberately** (not just + guards) for ~60 s — this is the only trigger. +3. Pass = no AV in `bgTestRayIntersectionInRoom` / `bgTestBulletHitBackground`, + bullets still resolve hits (decal/sound may be generic). +4. `-level_09` + `-level_20` framediff unregressed. +5. Then fix up the commit (`072b5c44` is labelled "WIP, unverified") and close + the §F D154 row. + +**Risk:** low — `#ifdef PORT` only, N64 path verbatim under `#else`. If a new +fault appears in this function, the nibble-mapping table is the thing to +re-derive against `rsp/graphics/gmain.s` (ground truth for GBI decoding). + +### A2. D139 — stage-unload `cleanupObjects` type byte (fix committed M-23) + +**Status:** fix in (`src/game/cleanup_objects.c`, `#ifdef PORT` +`CLEANUP_PDTYPE(o)` = `((PropDefHeaderRecord*)o)->type` instead of `(u8)obj[0]`, +which on LE reads `extrascale` and walks off the propDef blob). **Never +verified** — the fast teardown test hit D140 first (since fixed), and M-27's +level-exit loop was playtested through the *real* front-end flow, which does +exercise `lvlUnloadStageTextData` → `cleanupObjects` on level exit. + +**Procedure:** +1. Re-check whether M-27's Dam→Facility playthrough already exercised it: + if a real level exit completed without a teardown crash after the D139 + commit, this is done — just record "verified via M-27 exit loop" on the + §F row and stop. +2. Otherwise: `debug.ps1 -level_09`, complete/abort a real level exit (not + pause-abort) and confirm no AV in `objFree` / `objFreeEmbedmentOrProjectile`. + +**Risk:** low — one-line read-source change, every other propDef consumer +already uses the struct member. + +--- + +## B. Root-caused small fixes (not yet applied) + +### B1. D74 — dead + OOB texture-wrap block in fast3d (`port/fast3d/gfx_pc.cpp:~1570`) + +**Status:** latent. Harmless for CLAMP glyphs today; could corrupt wrapped +textures on triangles (room panels, model skins). + +**Root cause (confirmed against the tree):** two independent defects in the +D74 pre-wrap block inside the per-vertex UV loop: +1. **Always-false guard.** The codebase's `gbi.h` (`include/PR/gbi.h`, used by + both game and fast3d via `port/shim/PR/gbi.h`) encodes wrap mode as a + 2-bit field: `G_TX_NOMIRROR|G_TX_WRAP = 0x0`, `G_TX_MIRROR = 0x1`, + `G_TX_CLAMP = 0x2`. So `cms & G_TX_WRAP` is `& 0` — **always false**. The + "is this tile in wrap mode" test must be **"clamp bit not set"**: + `(cms & G_TX_CLAMP) == 0`. (Compare the correct switch at + `gfx_opengl.cpp:706-712`, which keys on the four combinations.) +2. **OOB index.** `tex_width2[i]` / `tex_height2[i]` are **per-texunit `[2]` + arrays** (set up at `gfx_pc.cpp:~1471`), but the block is inside the + **per-vertex** loop where `i` is 0..2 → read of slot `[2]` if the guard + ever fires. + +**Fix:** +1. Hoist the tile-window computation out of the vertex loop: for each used + texunit `t`, compute once — `tw = tex_width2[t]`, `th = tex_height2[t]`, + `wrapS = (cms & G_TX_CLAMP) == 0 && tw < tex_width[t]`, same for T. +2. In the vertex loop, apply `u = fmodf(u, tw); if (u<0) u += tw; u += uls/4` + (and V) only when the precomputed flag is set. +3. Delete nothing else — leave the surrounding D74 comment, extend it with a + line noting the `G_TX_WRAP == 0` encoding trap (PORT-LEARNINGS §D class: + "N64 hardware idioms fast3d does not emulate" — add the bit-encoding note). + +**Verify:** build; `-level_09` + `-level_20` framediff; then a level with +visible wrapped triangle textures — **Depot (`-level_30`)** is the known +worst case (overlaps B2, but D74's block only fires for WRAP tiles). Check +`tools_pc/golden/` dumps for no new garbage. + +**Risk:** low-medium. The block has never run, so this is *new* behavior — +if wrapped textures suddenly look wrong, the fmod window size (`tw` from +`lrs-uls+4`/4) is the thing to validate against `rsp/graphics/gmain.s`'s +tile-window handling before "fixing" it further. + +### B2. D152 — find the leaking `osSetIntMask` site + proper narrow fix + +**Status:** mitigated (self-healing lock in `port/src/libultra.c`, M-28) — a +leaked section now costs ~2 s + a log line instead of a permanent black +screen. The **root leak is still unidentified**. + +**Procedure to root it:** +1. Replay the trigger: Facility (`-level_34`), kill Trevelyan → mission-fail + fade-out, under `tools_pc/debug.ps1`. +2. When the log shows `D152: ... stealing from owner=...`, take the logged + stale-owner address **and** the stealing caller's return address; + `addr2line -e build-pc/ge007.x86_64.exe -f -C ` (image base + `0x140000000`). The *owner* is the leak site. +3. Audit target: libaudio's `alEvtqPostEvent` / `alEvtqNextEvent` / + `sndSetSfxSlotVolume` paths (`src/libultra/audio/event.c`, `src/snd.c`) — + look for early returns between an `osSetIntMask(OS_IM_NONE)` and its + matching `OS_IM_ALL` (the D147 assumption that "the decomp never blocks / + never early-returns while masked" is falsified on this path). Also check + transient threads that acquire-and-exit (`gdb.txt` showed New Thread / + exited churn every few k frames). + +**Proper narrow fix (once the site is known), pick one:** +- **(a) Preferred:** a dedicated `ALEventQueue` lock (mutex per queue or one + global in the port layer) taken by `alEvtqPostEvent`/`alEvtqNextEvent`, so + the audio event paths stop using the process-wide `osSetIntMask` section at + all; then the self-healing steal becomes dead weight that can be reverted to + the plain D147 recursive mutex. +- **(b) Cheaper:** make the leak site balanced (the actual bug), keep the + self-healing lock as belt-and-braces. + +**Verify:** replay the mission-fail 3×: no `D152:` steal log lines, fade-out +completes, screen returns to the mission-failed UI. `-level_09` framediff. + +**Risk:** (a) is port-layer only if the lock lives in the shimmed +`alEvtq*` wrappers — but note `event.c` is *compiled game-side libultra*, so +the lock likely has to go inside `event.c` under `#ifdef PORT` (ABI-exception +class, document as Dxx). Keep the self-healing lock either way until audio +(Phase 3) lands. + +### B3. D120 — blood-stain hang: extend `d43_emit.py` for opcode-0x18 + +**Status:** guarded, not fixed (`src/game/chr.c:~3322` caps the +`PointUsage[]` / `CollisionVertices` walk at `numVertices+8` + bounds-checks +`index`). Blood decals may be missing/wrong; the game survives. + +**Root cause:** `tools_pc/d43_emit.py`'s opcode-0x18 +`ModelRoData_DisplayList_CollisionRecord` conversion is incomplete — 6 pointer +fields widen the struct on PC, and the `PointUsage` / `CollisionVertices` +sub-arrays' endianness + stride are not handled (only `CollisionRelatedNode` +got the D43/D45 u32-vma treatment). + +**Procedure:** +1. Byte-spec the record: dump one converted guard model's opcode-0x18 region + from a sidecar (`data/pccg-*`) and diff against the raw ROM bytes; write + out the N64 layout (fields, sub-array strides, terminators) in the + converter comment — same method as the D69/D88 audits. +2. Extend `d43_emit.py`: emit the widened struct with the 6 pointers via + `put_ptr`, and convert both sub-arrays (endianness + PC stride). +3. Keep the `chr.c` guard until verification passes, then decide whether to + keep it as a safety net (recommended — same philosophy as D85's bounds + check) or revert. +4. Regen full chain; verify: BUNKER1 (`-level_09`) guard firefight — blood + decals now appear and no hang; framediff anchors. + +**Risk:** medium — converter work with a runtime guard underneath, so a bad +conversion degrades to today's behavior rather than crashing. Good subagent +brief on its own (per HANDOFF-ARCHIVE M-9). + +### B4. B3 — RMB aim too sensitive (`port/src/input.c`) + +**Status:** user-reported (M-28 playtest). Port-only. + +**Current math (aim mode, `input.c:~471`):** +`m = 61 + |dx| * (MouseAimSpeed/100) * AIM_GAIN(4.0)`, clamped to +`60 + AIM_BAND(20)` = 80. At the default `MouseAimSpeed=50`, **25 px/poll +saturates the band** — a fast flick is "max stick" for many polls, and GE's +aim curve `(stick-60)/10` then runs at full rate the whole time. + +**Fix (in order of cheapness):** +1. Lower the default: `mouseAimSpeed = 50 → 25` (`input.c:~128`). One line; + users can still dial it back up in `ge007.ini`. +2. Make the band itself tunable: register `Input.AimBand` (int, 5..40, + default 20) and replace the `AIM_BAND` constant in the two clamp sites — + a smaller band = finer control near the gate, a larger one = more headroom. +3. Optional curve change if it still feels bad: make the mapping into the + band sub-linear (`m = 61 + AIM_BAND * sqrt(gx / saturating_px)`) so small + movements get proportionally less stick — but only after 1+2 are + playtested; don't stack changes. + +**Verify:** playtest-only (no headless signal): aim at a guard in BUNKER1, +flick the mouse, confirm no overshoot orbiting the target; hipfire turn +(`MouseTurnSpeed`) is untouched by this. + +**Risk:** trivial — port-only, config-backed, default-reversible. + +### B5. B4 — menu screens need an absolute pointer mode (`port/src/input.c`) + +**Status:** user-reported (M-28). Port-only design item; **no code yet**. + +**Problem:** front-end / watch menus with the crosshair cursor behave like +an analog-stick game, not a PC pointer. Today all mouse input is *delta* +based (turn/look), and menu navigation goes through `joyGetStickX/Y` level +checks in `front.c` / `options.c` — synthetic stick deltas make menu cursors +crawl or slam. + +**Design (port-only, no `src/` change):** +1. Add a **menu-pointer mode** to the input layer: when active, emit + *absolute* stick values derived from the cursor position — + `sx = (mouseX / windowW) * 2 - 1 → [-80..80]`, same for Y. GE's front-end + maps stick deflection to on-screen cursor position, so absolute→absolute + gives true pointer behavior with zero game-code changes. +2. **Mode detection is the open question** — the port doesn't know when the + game is in a menu. Options, cheapest first: + - **(a)** Heuristic: mouse is *ungrabbed* (cursor visible) ⇒ menu mode. + Pair with a rule that front-end screens release the grab and gameplay + grabs it (the grab state already flips on focus loss; extend it to be + driven by a key, e.g. hold-to-release, or auto-detect via the same + signals D145 used for ESC-as-B). Simple, no game-state peeking. + - **(b)** Peek a game-side "am I in the front end" flag through an existing + exported symbol (check `front.c` / `lv.c` globals) and branch on it in + `inputPoll`. Read-only access to a global is arguably inside the + documented exception class, but **record a Dxx** if used. + - **(c)** Explicit user toggle (config key + hotkey). Ugly but zero risk. +3. While in menu mode: suppress aim/turn delta emission entirely; wheel and + clicks map to A/B as today. + +**Verify:** drive the mission-select / SELECT FILE screens with the mouse — +cursor should track 1:1, click selects; in-game aim unaffected. + +**Risk:** low (port-only) under option (a)/(c); (b) needs a Dxx write-up. +Sequence *after* B4 so the gameplay feel is settled first. + +### B6. D118d latent sibling — `front.c` menu-nav stick level checks + +**Status:** latent, noted M-27. Same bug class as the fixed watch-menu +over-scroll (`options.c`, `GE_WATCH_STICK_FAST{UP,DOWN}` → 0 under PORT). + +**What to do:** find the `joyGetStick*InRange` / raw `joyGetStickY < -0x46` +style *level* checks in `front.c` menu navigation (main menu, mode-select, +mission-select paging) and apply the same treatment: under `#ifdef PORT` +drop the stick fast-scroll term so keyboard/digital input goes through the +latched single-step path only. Keep N64 verbatim under `#else`. + +**Verify:** keyboard W/S (and D-pad) paging on every front-end menu = one +step per press, no multi-skip; controller sticks still fast-scroll. + +**Risk:** low — mechanical copy of an already-landed fix. Do it together with +B5 so the menu-input area is touched once. + +### B7. D118a residual — hipfire pitch is digital, yaw is analog + +**Status:** parked minor feel issue. The *full* fix needs a game-side hook; +the deferred design is: + +An `#ifdef PORT` analog-aim hook in `bondview.c`/`bondview2.c` where GE reads +digital C-up/C-down for hipfire pitch — replace the digital read with the +analog stick-Y value (which our input layer can emit even in hipfire, since +stick-Y is "move" only *when the game consumes it as such*; validate that +emitting a small stick-Y does not also make Bond walk). This is the first +*behavioral* `src/` edit of the aim path — it exceeds pure ABI/layout; **write +it up with a confidence rating before implementing** (AGENTS.md #2: "stop and +write it up"), and gate it behind a config key defaulting to *current* +behavior. + +**Cheaper alternative worth trying first:** raise `MOUSE_PITCH_THRESH` +granularity by emitting the hipfire pitch as *repeated C-button pulses with +dwell proportional to speed* — pure port-layer, approximates analog. Try this +before touching `src/`. + +--- + +## C. Cosmetic / graphics — investigation plans (root cause not fully known) + +These are parked below crash/level work (`GRAPHICS-BACKLOG.md`). Each entry is +the *next diagnostic step*, because the root cause is not yet pinned. Do not +treat them as ready-to-apply fixes. + +### C1. D114/D116 — HUD text + ammo digits X-mirrored per glyph + +**Hard rule (PORT-LEARNINGS §D2): do NOT re-run the static trace** (four +sessions did; every stage verified non-mirrored at the GL boundary) and do NOT +apply a global texrect S-swap (mirrors the whole screen). + +**Next attempt — pick one, in order:** +1. **RenderDoc (or apitrace) capture of exactly one glyph's texrect draw.** + The contradiction is "VBO verified non-mirrored but pixels mirrored" — a + GPU-side capture resolves it in one shot: inspect the bound texture's + upload bytes vs the `gDPLoadTextureBlock` source, and the final quad's UVs. +2. **Asymmetric-1-texel experiment:** temporarily force one HUD glyph's image + to a 2×1 or 3×1 *asymmetric* pattern (e.g. left half white) in the port + layer; if it renders right-half-white, the flip is between upload and + sample (texture origin / `glPixelStorei(GL_UNPACK_ROW_LENGTH)` / Y-flip); + if it renders correctly, the mirror is applied *per-glyph placement* + (texrect S-range inversion in `textRenderGlyph`'s GBI — check the + `gDPTile`/`gDPSetTile` uls/lrs signs for the HUD tiles specifically). + +Suspects to check with the capture in hand: 4-bit CI tile upload stride vs +`G_IM_SIZ_4b` sampling, TLUT index endianness (wrong-but-symmetric TLUT would +not mirror — deprioritize), and the `uls/lrs` sign convention for *rect* +draws (D116 is a rect path; triangles were never suspected). + +### C2. D75 — front-end 3D models misplaced / absent (logo, gun-barrel Bond, cast) + +Triage per §F D75 — **distinguish (a) vs (b) before any fix:** +1. For the Nintendo logo and one cast model, determine which matrix path + builds their transforms: `guRotate`/`guLookAt` (→ candidate (a), D73 + scope gap in `guint.h` `DVAL()`) or the `animInit` + embedded raw-offsets + into `struct player` path (→ candidate (b), independent model/RW-pool bug, + cf. D56/D100/D102 — all now-fixed struct-pun classes; a *new* instance of + the class is the leading hypothesis). +2. Capture `GE_PCDUMP` frames across the logo transition and gun-barrel/cast + segments to localize which frame first diverges. +3. Files: `src/libultra/gu/guint.h`, `src/game/model.c`, `bondview2.c` + (animInit / modelSetScale), `port/fast3d/gfx_pc.cpp`. + +**Note:** D148/D149 below are the same family — fixing D75's root cause will +likely take them with it. The D149 garbled geometry is also worth one check on +the *converter* side: the malformed sub-DL (`seg5+0x9ee4`, unresolved matrix +ptr + garbage opcodes) may be a `d43_emit.py` conversion defect rather than a +runtime transform bug — dump that sub-DL's raw ROM bytes vs the sidecar before +assuming it's runtime. + +### C3. D76 — disclaimer screen only partially drawn + +Likely the D68 image-table class, incomplete: some `sImageTableEntry`s the +disclaimer references are missed by `port/src/gimgfixup.c`'s bswap/shadow-sync. + +**Procedure:** +1. Enumerate the image entries the disclaimer screen references (trace + `textRender`/`display_image_at_position` calls in the legal-screen code, or + log every `gDPLoadTextureBlock` source during PPM frames f20–f100). +2. For each, confirm it is covered by the D68 fixup (bswapped IMAGESEG w1 + + synced compiled globalDL shadow). The "only the classification line + one + line below draw" symptom = exactly the covered subset rendering. +3. Files: `port/src/gimgfixup.c`, `src/game/tex.c`, image-table consumers. + +### C4. D148 / D149 — Dam exit cutscene absent; MISSION COMPLETE models garbled + +Both are D75 family (see C2). Additional notes: +- **D148:** the missing piece is a *scripted* sequence — cinematic camera + + Bond rappel anim. Once D75's model path is fixed, verify the cutscene + trigger still fires (check whether the exit trigger even dispatches the + cinematic on PC — it may be gated on a front-end state flag our flow skips; + `GE_STARTMENU` tooling can crash-test the screen in isolation). +- **D149:** see the converter-side check in C2. D144/D146 already keep the + game alive on the corrupt DL; the fix should make the model *correct*, then + D146's "unknown opcode → end DL" guard can stay as a safety net. + +### C5. D85 (residual) — room DL `texLoadFromGdl` decode + +**Status:** OPEN but safety-netted; geometry renders, so this is below level +work. Remaining: decode the room-specific opcodes / CC-RM-LUT markers that +`texLoadFromGdl` still handles generically, and verify `csize_*_DL_binary` +sizing. Only worth touching if a level shows placeholder/garbage textures on +room geometry that can't be explained by B2 (Depot wrong-colors). + +--- + +## D. Housekeeping / cleanup (no gameplay effect) + +### D1. Stale §F index row — D132 says "proposed fix (not applied)" + +The D132 fix **is applied** in the tree: `tools_pc/d88_propdefs.py` +(`PROPDEF_PC_BYTES[14/19]=32, [44]=40`; the `(14,19,38,44)` handler emits each +`Index{k}` into the low 4 B of the 8-aligned slot at PC `8+8*k`) and +`src/game/loadobjectmodel.c sizepropdef()` (LINK/SWITCH/LOCK_DOOR → 8 words, +SAFE_ITEM → 10). The §F index row still reads "proposed fix (not applied) — +see below" while the subsection says "APPLIED (M-20, commit pending)". +**Action:** update the index row to `resolved (M-20)` and confirm the M-20 +commit landed (git-blame both files). Docs-only. + +### D2. Stale BACKLOG technical-cleanup items + +`docs/BACKLOG.md` "Technical cleanup" lists: +- ~~"Add a D24-implications bug class to PORT-LEARNINGS"~~ — **done M-19** + (PORT-LEARNINGS §E, lines ~374). +- ~~"Watch item: `osYieldThread` = `Sleep(0)`"~~ — **done M-19** (§E, line + ~407). Keep the watch item itself; it's still accurate (the one place we + fake cooperative scheduling — first suspect for level-sweep hot-loop + misbehavior under host load). +- "Remove TEMP D63 debug scaffolding" — **partially done M-19** (`libultra.c` + + game-side probe blocks removed). Remaining: `port/fast3d/gfx_pc.cpp` + trail code — the `/* TEMP D63 (strip before commit) */` block at ~2462, the + `GE_D63`-gated dram-branch trace at ~2516-2531, and the unknown-opcode dump + at ~2759-2765. All env-gated / inert; strip in one pass (keep the + `cached = getenv("GE_D63")` pattern only if a probe is retained — recommend + removing all of it). + +**Action:** one docs+code cleanup commit: mark the two done items, strip the +gfx_pc.cpp D63 trail, rebuild + `-level_09` framediff. + +### D3. `osYieldThread = Sleep(0)` watch item (no action — recorded) + +When a flaky timing bug appears (especially Phase 3 audio underruns): reach +first for host thread priorities (`SetThreadPriority`: scheduler thread +time-critical, tick normal) and the deferred `GE_DETERM` mode (D117) — not +for kernel changes. See PORT-LEARNINGS §E. + +--- + +## Suggested order if working through this list + +1. **A1 + A2** (verify-only; closes two open rows, ~an hour on a quiet box). +2. **B4** (one-line default + optional knob; immediate user-facing win). +3. **D2** (cleanup commit; keeps the tree honest). +4. **B1** (D74 — small, self-contained, un-lats a real OOB). +5. **B6 + B5** (menu input, one pass over the area). +6. **B2** (needs the user's Facility replay to get the log line). +7. **B3** (converter work; good standalone subagent brief). +8. **C1–C4** only after crashes/levels are done (parked by priority decision); + C1 first — it's the most visible remaining cosmetic defect. diff --git a/tools_pc/attach_animgen.sh b/tools_pc/attach_animgen.sh new file mode 100644 index 00000000..80474a8c --- /dev/null +++ b/tools_pc/attach_animgen.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# Attach to a RUNNING ge007 (not owned by another gdb) and dump the +# Facility-outro-hang animation state (modelSetAnimFrame2WithChrStuff). +# The game keeps running after detach. +export PATH="/c/msys64/mingw64/bin:$PATH" +PID=$(ps -W | grep ge007.x86_64.exe | awk '{print $4}' | head -1) +if [ -z "$PID" ]; then + echo "no ge007.x86_64.exe process found" + exit 1 +fi +echo "attaching to winpid $PID" +gdb -batch -p "$PID" -x "$(dirname "$0")/dump_animgen.cmd" 2>&1 | grep -v "DBGHELP\|warning:" diff --git a/tools_pc/dump_animgen.cmd b/tools_pc/dump_animgen.cmd new file mode 100644 index 00000000..e0599223 --- /dev/null +++ b/tools_pc/dump_animgen.cmd @@ -0,0 +1,58 @@ +# gdb command file — dump animation state for the Facility outro hang. +# Usage: tools_pc/attach_animgen.sh (game must be running, NOT under another gdb) +set pagination off +python +import gdb + +TARGET_FN = "modelSetAnimFrame2WithChrStuff" +found = False +for t in gdb.selected_inferior().threads(): + fr = t.frame + depth = 0 + while fr is not None and depth < 40: + if fr.name() == TARGET_FN: + found = True + print("== thread %d at %s" % (t.num, TARGET_FN)) + for v in ["framea", "frameb", "curframe", "endframe", "forward", + "framenum", "flag", "jointnum", "scale"]: + try: + print(" %s = %s" % (v, fr.read_var(v))) + except Exception as e: + print(" %s = " % (v, e)) + for e in [ + "modelptr->animframe1", "modelptr->animframe2", "modelptr->animframe", + "modelptr->speed", "modelptr->playspeed", "modelptr->newspeed", + "modelptr->oldspeed", "modelptr->timespeed", "modelptr->elapsespeed", + "modelptr->unk84", "modelptr->unk88", "modelptr->endframe", + "modelptr->scale", "modelptr->anim_translation_scale", + "modelptr->anim", "(int)(modelptr->anim->unk04)", + "(int)(modelptr->anim->unk07)", "modelptr->anim2", + "header->unk00", "header->unk01", "header->unk18", + ]: + try: + print(" %s = %s" % (e, fr.read_var(e))) + except Exception as ex: + print(" %s = " % (e, ex)) + cf = fr.older() + if cf is not None: + print("== caller: %s" % cf.name()) + for v in ["frame", "frame2", "numticks", "update_chrstuff"]: + try: + print(" %s = %s" % (v, cf.read_var(v))) + except Exception as e2: + print(" %s = " % (v, e2)) + break + fr = fr.older() + depth += 1 +if not found: + print("modelSetAnimFrame2WithChrStuff not on any stack — dumping all bt's instead") + for t in gdb.selected_inferior().threads(): + print("== thread %d" % t.num) + fr = t.frame + depth = 0 + while fr is not None and depth < 12: + print(" #%d %s" % (depth, fr.name())) + fr = fr.older() + depth += 1 +print("dump done") +end diff --git a/tools_pc/dump_objectives.py b/tools_pc/dump_objectives.py new file mode 100644 index 00000000..1c088e7d --- /dev/null +++ b/tools_pc/dump_objectives.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""dump_objectives.py — dump per-level mission objectives + win/fail criteria +from the retail ROM, for human playtest validation (WS6). + +Ground truth: + * levelID -> stage file: `setup_text_pointers[]` in src/game/chraidata.c + (parsed live from the source, index == levelID). + * stage file = RZ-compressed `struct stagesetup`; header field [3] + ("propDefs") is a plain byte offset into the decompressed file. + * propDefs record walk: PROPDEF_N64_WORDS (tools_pc/d88_propdefs.py), + same strides the runtime `sizepropdef()` uses. + * criteria semantics: src/game/objective_status.c get_status_of_objective(): + 25 DESTROY_OBJECT tag id -> incomplete while tagged object alive + 26 COMPLETE_CONDITION flag id -> complete once stage flag set + 27 FAIL_CONDITION flag id -> FAILED once stage flag set + 28 COLLECT_OBJECT tag id -> must be in Bond's inventory + 29 DEPOSIT_OBJECT tag id -> incomplete while in inventory + 30 PHOTOGRAPH tag id -> camera objective + 31 NULL (spacer) + 32 ENTER_ROOM room ref @4 (runtime status word @8) + 33 DEPOSIT_IN_ROOM refs @4/@8 (runtime status word @c) + 34 COPY_ITEM key-analyzer / datavac copy objective + Objective blocks run from record 23 (OBJECTIVE_START: obj index @4, + briefing text id @8, min difficulty @c) to record 24 (OBJECTIVE_END). + Tags: record 22 TagObjectRecord [u16 tagid @4][s16 offset-to-target @6]; + the target is another propDef record in the same stream. + +Usage: + python tools_pc/dump_objectives.py facility # one level (name or -level_XX number) + python tools_pc/dump_objectives.py all # every solo level +""" +import csv +import os +import re +import struct +import sys +import zlib + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from d88_propdefs import PROPDEF_N64_WORDS # noqa: E402 + +REGION = "ntsc-final" +ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") +ROM_PATH = os.path.join(ROOT, "data", f"ge007.{REGION}.z64") +FILELIST = os.path.join(ROOT, "scripts", "filelist.u.csv") +CHRAIDATA = os.path.join(ROOT, "src", "game", "chraidata.c") + +DIFF_NAMES = {0: "Agent", 1: "Secret Agent", 2: "No One Lives Forever", 3: "GoldenEye"} +CRIT_NAMES = { + 25: "DESTROY_OBJECT", 26: "COMPLETE_CONDITION", 27: "FAIL_CONDITION", + 28: "COLLECT_OBJECT", 29: "DEPOSIT_OBJECT", 30: "PHOTOGRAPH", + 31: "NULL", 32: "ENTER_ROOM", 33: "DEPOSIT_IN_ROOM", 34: "COPY_ITEM", +} +TYPE_NAMES = {1: "DOOR", 2: "DOOR_SCALE", 3: "PROP", 4: "KEY", 5: "ALARM", + 6: "CCTV", 7: "MAGAZINE", 8: "COLLECTABLE", 9: "GUARD", + 10: "MONITOR", 11: "MULTI_MONITOR", 12: "RACK", 13: "AUTOGUN", + 14: "LINK", 17: "HAT", 18: "GUARD_ATTR", 19: "SWITCH", + 20: "AMMO", 21: "ARMOUR", 22: "TAG", 23: "OBJ_START", + 24: "OBJ_END", 25: "OBJ_DESTROY", 26: "OBJ_COMPLETE_COND", + 27: "OBJ_FAIL_COND", 28: "OBJ_COLLECT", 29: "OBJ_DEPOSIT", + 30: "OBJ_PHOTOGRAPH", 31: "OBJ_NULL", 32: "OBJ_ENTER_ROOM", + 33: "OBJ_DEPOSIT_IN_ROOM", 34: "OBJ_COPY_ITEM", + 35: "WATCH_OBJ_TEXT", 36: "GAS", 37: "RENAME", 38: "LOCK_DOOR", + 39: "VEHICLE", 40: "AIRCRAFT", 42: "GLASS", 43: "SAFE", + 44: "SAFE_ITEM", 45: "TANK", 46: "CAMERAPOS", 47: "TINTED_GLASS", + 48: "END"} + + +def be16(b, o): return struct.unpack_from(">H", b, o)[0] +def be32(b, o): return struct.unpack_from(">I", b, o)[0] +def bs32(b, o): return struct.unpack_from(">i", b, o)[0] + + +def load_level_table(): + """levelID -> Usetup file name, from setup_text_pointers[] in chraidata.c.""" + src = open(CHRAIDATA).read() + m = re.search(r"setup_text_pointers\[\]\s*=\s*\{(.*?)\};", src, re.S) + if not m: + sys.exit("setup_text_pointers not found in chraidata.c") + names = re.findall(r'"(Usetup\w+Z)"|NULL', m.group(1)) + return {i: n for i, n in enumerate(names) if n} + + +def load_rom_file(name): + rom = open(ROM_PATH, "rb").read() + fl = {} + for r in csv.reader(open(FILELIST)): + if len(r) >= 3 and r[2]: + base = r[2].rsplit("/", 1)[-1] + if base.endswith(".bin"): + base = base[:-4] + fl[base] = (int(r[0]), int(r[1])) + if name not in fl: + sys.exit(f"{name} not in filelist") + addr, size = fl[name] + comp = rom[addr:addr + size] + if comp[:2] != b"\x11\x72": + sys.exit(f"{name}: bad RZ magic {comp[:2].hex()}") + return zlib.decompress(comp[2:], -15) + + +def walk_propdefs(buf, off): + """Yield (offset, type, word0..n) for every record until PROPDEF_END.""" + recs = [] + while True: + if off + 4 > len(buf): + break + w0 = be32(buf, off) + rtype = w0 & 0xFF + words = PROPDEF_N64_WORDS.get(rtype) + if words is None: + print(f" !! unknown propdef type {rtype} at +{off:#x}", file=sys.stderr) + break + recs.append((off, rtype)) + off += words * 4 + if rtype == 48: + break + return recs + + +def dump_level(levelid, lav_name): + fname = load_level_table().get(levelid) + if not fname: + print(f"[{lav_name}] level {levelid}: no stage file") + return + buf = load_rom_file(fname) + poff = be32(buf, 12) # header field [3] = propDefs offset + recs = walk_propdefs(buf, poff) + + # tags: id -> (type of tagged record). OffsetToObj is a RECORD-INDEX + # delta from the TAG record within the propDefs stream (verified: + # values are tiny +/-2, never byte offsets). + tags = {} + for k, (off, t) in enumerate(recs): + if t == 22: + tid = be16(buf, off + 4) + toff = struct.unpack_from(">h", buf, off + 6)[0] + tt = recs[k + toff][1] if 0 <= k + toff < len(recs) else None + tags[tid] = tt + + def tagdesc(tid): + if tid in tags and tags[tid] is not None: + tt = tags[tid] + return f"tag {tid} -> {TYPE_NAMES.get(tt, 'type %s' % tt)}" + return f"tag {tid} (unresolved)" + + print(f"\n=== {lav_name} (level {levelid}, {fname}) ===") + # objective blocks: 23 ... 24 + i = 0 + nblocks = 0 + while i < len(recs): + off, t = recs[i] + if t != 23: + i += 1 + continue + nblocks += 1 + idx = bs32(buf, off + 4) + textid = be32(buf, off + 8) + diff = buf[off + 12] + print(f"Objective {idx} [briefing text id {textid}] min difficulty: " + f"{DIFF_NAMES.get(diff, diff)}") + j = i + 1 + while j < len(recs) and recs[j][1] != 24: + coff, ct = recs[j] + name = CRIT_NAMES.get(ct, f"TYPE_{ct}") + a4 = bs32(buf, coff + 4) + if ct in (25, 28, 29): + extra = tagdesc(a4) + elif ct in (26, 27): + bits = [b for b in range(32) if a4 >> b & 1] + extra = f"stage flag mask {a4:#x} (bit(s) {bits})" + if ct == 27: + extra += " -- mission FAILS when set" + elif ct == 30: + extra = f"{tagdesc(a4)}; photo-flag word @8={be32(buf, coff + 8):#x}" + elif ct == 32: + extra = f"ref @4={a4} (room); runtime word @8={be32(buf, coff + 8):#x}" + elif ct == 33: + extra = (f"ref @4={a4} ref @8={bs32(buf, coff + 8)}; " + f"runtime word @c={be32(buf, coff + 12):#x}") + elif ct == 34: + extra = "(key-analyzer/datavac copy flag)" + else: + extra = "" + print(f" - {name:<20} {extra}") + j += 1 + i = j + 1 if j < len(recs) else j + if nblocks == 0: + print(" (no objective records)") + + +NAMES = {"dam": 33, "facility": 34, "runway": 35, "surface1": 36, "bunker1": 9, + "silo": 20, "frigate": 26, "surface2": 43, "bunker2": 27, "statue": 22, + "archives": 24, "streets": 29, "depot": 30, "train": 25, "jungle": 37, + "control": 23, "caverns": 39, "cradle": 41, "aztec": 28, "egypt": 32, + "cuba": 54} + +if __name__ == "__main__": + arg = (sys.argv[1] if len(sys.argv) > 1 else "facility").lower() + table = load_level_table() + if arg in ("all", "*"): + for lid in sorted(table): + dump_level(lid, f"level_{lid:02d}") + elif re.fullmatch(r"\d+", arg): + dump_level(int(arg), f"level_{int(arg):02d}") + else: + key = arg.replace(" ", "").replace("_", "") + if key not in NAMES: + sys.exit(f"unknown level '{arg}' (try: {', '.join(NAMES)} or all)") + dump_level(NAMES[key], key)