docs(findings): migrate fat §F rows D152-D168 to tail sections (ctx-opt P1, batch 2/8)

15 rows: topic/status cells moved verbatim to new '## Dxx' sections at file
tail; table rows now carry a one-line summary + pointer. CSV regenerated, label
set unchanged (171). Nothing deleted.
This commit is contained in:
James Dansereau committed 2026-09-15 21:22:47 -04:00
1 parent cd6f0b5e83
commit 415abb4438
2 files changed
+113 -23

No files matched your search

+8 -8
View File
@@ -80,8 +80,8 @@ D85,OPEN,room primary/secondary DL → texLoadFromGdl garbage,"OPEN (safety-nett
D86 · D87,RESOLVED,modelInitRwData truncated ptr · attract-demo BE ramromfilestructure,resolved
D88.4,RESOLVED,propDefs polymorphic record stream not byteswapped → setupDoor crash,resolved (d88_propdefs.py); layout audit M-20 → D132
D132,PROPOSED,D88 propDefs layout audit (M-20):,proposed fix (not applied) —
D157,FIXED,"Campaign never unlocks the next level (M-30, user bug —","FIXED (src/bondtypes.h, #ifdef PORT —"
D160,PARTIAL,Dam level-end cutscene (Bond off the dam / bungee) never plays —,"DIAGNOSTIC SHIPPED, not root-caused (c95713f5, #ifdef PORT + GE_D160=1):"
D157,FIXED,"Campaign never unlocks the next level (M-30, user bug —","FIXED (src/bondtypes.h, #ifdef PORT —…"
D160,PARTIAL,Dam level-end cutscene (Bond off the dam / bungee) never plays —,"DIAGNOSTIC SHIPPED, not root-caused (c95713f5, #ifdef PORT + GE_D160=1)…"
D89-D92,RESOLVED,"stage-load→frame: stan zero-fill overrun, portal address trunc, chr/AI spawn ptrs",resolved
D93-D102,RESOLVED,struct-pun / hardcoded-size pass:,resolved (D95 pool bump partial)
D103-D107,RESOLVED,"BUNKER1 viewport height, depth-buffer clear (G_CLEAR_DEPTH_EXT), portal near-plane, LOD mip tile",resolved
@@ -121,16 +121,16 @@ D136,RESOLVED,"Stage-unload crash — objFree obj->prop reads float garbage (M-2
D139,FIXED,Stage-unload crash root cause + fix (M-23).,"FIXED (src/game/cleanup_objects.c, #ifdef PORT —…"
D140,FIXED,Pause-menu crash (M-23 open → M-27 fixed).,"FIXED (src/game/bondview.h, src/game/bondview2.c; #ifdef PORT)."
D141,FIXED,"Watch-menu item-model crash (M-27), exposed by D140.","FIXED (src/game/gunfire.c, #ifdef PORT)."
D152,OPEN,"Mission-failed / objective-failed → permanent black screen (M-28, user bug report —","MITIGATED (port/src/libultra.c, M-28, #ifdef PORT/port-only)."
D154,FIXED,"bg.c room-geometry ray/hit-test GBI parser port (M-28, WRITTEN / UNVERIFIED / UNCOMMITTED).",M-30 re-audit + fixes (still playtest-gated for final verify).
D156,PARTIAL,"Facility outro-cutscene ""hang"" —","GUARDED (src/game/model.c, #ifdef PORT):"
D155,FIXED,"Facility outro-cutscene end → ""hang"" = unclamped deltaFrames spiral (M-29, user bug report).","FIXED (src/game/frametiming.c, #ifdef PORT —"
D152,?,"Mission-failed / objective-failed → permanent black screen (M-28, user bug report —","MITIGATED (port/src/libultra.c, M-28, #ifdef PORT/port-only).…"
D154,?,"bg.c room-geometry ray/hit-test GBI parser port (M-28, WRITTEN / UNVERIFIED / UNCOMMITTED).",M-30 re-audit + fixes (still playtest-gated for final verify).
D156,PARTIAL,"Facility outro-cutscene ""hang"" —","GUARDED (src/game/model.c, #ifdef PORT)…"
D155,FIXED,"Facility outro-cutscene end → ""hang"" = unclamped deltaFrames spiral (M-29, user bug report).","FIXED (src/game/frametiming.c, #ifdef PORT —…"
D153,RESOLVED,"-level_09 frame ~900–1400 0xc0000005 = D117/D134 load flakiness, NOT a bug (M-28).",NOT A BUG — closed. Run anchors on a quiet machine.
D151,FIXED,Watch data all blank —,"FIXED (src/bondtypes.h, #ifdef PORT —…"
D130,RESOLVED,"crash class C2 (Facility -level_34, Runway -level_35):",resolved — stage all 6 N64 fields in a local f[6] before writing any.
D164,PROPOSED,"Disclaimer / legal screen only draws the first line (D76 root cause, M-31).","FIX PROPOSED, NOT APPLIED (front.c is owned by another agent this session)."
D164,PROPOSED,"Disclaimer / legal screen only draws the first line (D76 root cause, M-31).","FIX PROPOSED, NOT APPLIED (front.c is owned by another agent this session).…"
D165,FIXED,"Front-end menu cursor doesn't feel like a mouse pointer (M-31, user bug B5 follow-up).","FIXED (port/src/input.c; Input.MenuPointerMode, Input.HipfirePitchSpeed added)."
D166,FIXED,Hipfire mouse pitch is digital while yaw is analog —,FIXED (port/src/input.c).
D166,FIXED,Hipfire mouse pitch is digital while yaw is analog —,FIXED (port/src/input.c).…
D235,OPEN,Wrong textures load after dying/restarting a level —,OPEN — needs: does it repro on a *fresh* level boot (rules out reload-state) vs only after death/restart (confirms it); which surfaces; a screenshot.
D236,OPEN,"Surface 1: 2D billboard trees render as a wall/sheet of tree texture instead of discrete camera-facing tree sprites (M-106, user QA report).",OPEN — ninth pass closed with no fix; see D276/D280
D237,OPEN,"QoL ask (M-106, user QA):",OPEN — not designed; port-only
1 # GENERATED by tools_pc/gen_findings_index.py -- do not hand-edit.
80 D86 · D87
81 D88.4
82 D132
83 D157
84 D160
85 D89-D92
86 D93-D102
87 D103-D107
121 D139
122 D140
123 D141
124 D152
125 D154
126 D156
127 D155
128 D153
129 D151
130 D130
131 D164
132 D165
133 D166
134 D235
135 D236
136 D237
+105 -15
View File
@@ -424,9 +424,9 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
| D209 | **Every AI character in the game is locked to a *walk* animation regardless of the speed the ailist commands — `act_ubytes.padding[45]`, a raw-byte alias into the action union for `act_gopos.unk59` (the SPEED tier), shifts from offset 45 to 81 at 64-bit (M-81, ROOT CAUSE of D193, D3x pointer-width class).** GE character *travel* is animation-root-motion driven, so the bound locomotion animation sets the speed. `get_sound_at_range()` (`chraction.c:3573`, the locomotion-anim selector — misleading name) picks `ANIM_DATA_sprinting`/`running`/`walking` (and the one-handed/female variants) from its `arg1` tier. Its **only** caller, `play_hit_soundeffect_and_proper_volume()` (`chraction.c:3665`), passes the tier as `self->act_ubytes.padding[45]` — a raw byte read out of the `ChrRecord` action union rather than the named field. On N64 that alias is exact: `struct act_gopos` = `{coord3d targetpos@0, StandTile *target@12, waypoint *target_path@16, waypoint *waypoints[6]@20, u8 curindex@44, u8 unk59@45}`, so `padding[45]` **is** `unk59`. On x86-64 the three pointer members widen 4→8 B (`target@16, target_path@24, waypoints@32`), pushing `curindex`→80 and `unk59`→**81**, while the literal `45` now lands inside `waypoints[1]` (spanning 40..47) — specifically **byte 5 of a heap pointer**. The port's arena is low-4GB (`0x00000000_70xxxxxx`), so byte 5 is **always `0x00`** → `arg1 = 0` → `ANIM_DATA_walking` / `ANIM_DATA_walking_unarmed`, unconditionally, for every AI chr on every level since the 64-bit transition. The named-field reads (`chrlvApplySpeed(self, ..., (s32) self->act_gopos.unk59, ...)` at `:9104`, and the `unk59 == 2` / `== 1` `modelSetAnimSpeed` tiers at `:9106-9130`) resolve correctly, which is why the tier *looked* right everywhere it was inspected — only the alias was broken. **Runtime evidence (Cradle `-level_41`, `GE_D193A` extended with `tier`/`aidx`):** all six chrs report `act=15 ACT_GOPOS tier=1 SPEED_RUN` on 420/440 samples, yet `aidx` (live `model->anim` reverse-resolved against `animation_table_ptrs1`) is **40 = `PTR_ANIM_walking`** for Trevelyan (chr 0) and **107 = `PTR_ANIM_walking_unarmed`** for the guards on every single sample — `aidx 42 (running)` / `41 (sprinting)` never appear. Travel speed sat in a flat 140–155 u/s band for all of them, independent of animation, which is the walk-cycle stride. `act_ubytes` has exactly one use in the whole tree, so this is a single site. Both callers of `play_hit_soundeffect_and_proper_volume` reach it with `actiontype == ACT_GOPOS` (`:3723`, immediately after `actiontype = ACT_GOPOS; act_gopos.unk59 = speed;`; and `:9087`, inside the explicit `else` of `actiontype == ACT_PATROL`, mirroring the `chrlvApplySpeed` split directly below it), so reading the named field is exactly the byte N64 reads — no behavioural change. | **FIXED + user-playtest-verified (M-81).** `chraction.c:3665`, `#ifdef PORT` → `get_sound_at_range(self, self->act_gopos.unk59, ...)`; the N64 line kept verbatim under `#else`. ABI/pointer-width only, no game-logic change (rule-2 narrow exception, D3x class). User ran Cradle on the fix build: **Trevelyan and the guards run.** Also corrected the `// guess: room` comment on `unk59` (`src/bondtypes.h:2191`) — it is the SPEED tier. |
| D210 | **`chrToPatrol()` leaves `act_patrol.lastvisible60` uninitialised on PC — a raw-byte union alias (`act_init.padding[0x13]`) that shifts when `act_patrol` widens (M-82, A1 class, sibling of D209).** `chrToPatrol` (`chraction.c:3872`, sets `ACT_PATROL`) does `self->act_init.padding[0x13] = -1;` — union-relative byte `0x4c`. On N64 `struct act_patrol` = `{patrol_path *path@0, s32 nextstep@4, bool forward@8, waydata@0xc (0x40 B), s32 lastvisible60@0x4c, f32 speed@0x50}`, so byte `0x4c` **is** `lastvisible60`, initialised to `-1`. On x86-64 the leading `path` pointer widens 4→8 B, so everything after shifts +4: `lastvisible60` is now at `0x50`, and the literal `0x13`/byte-`0x4c` write lands in the last 4 bytes of `waydata` (`segdisttotal`). Net PC effect: `lastvisible60` keeps whatever garbage the previous union arm left, and the "chr hasn't seen the player recently → break off / resume patrol" gate at `chraction.c:~9347` (`(act_patrol.lastvisible60 + CHRLV_DEFAULT_TIMER) < g_GlobalTimer`) reads that garbage on the first tick(s) after entering PATROL — until `chr.c:2569` / `chraction.c:3482` / `:9359` overwrite it (only when the chr actually sees the player). Same root class as D209 (raw index into a union arm whose pointer members widened); found by the post-D209 A1 sweep. Only new instance in the sweep — `act_init.padding[0..3]` in `chrlvTickAnim`/`chrlvTickDead` alias `act_anim`/`act_dead`, both pointer-free, so those are layout-stable. | **FIXED (`chraction.c:3872`, `#ifdef PORT` → `self->act_patrol.lastvisible60 = -1;`, N64 line under `#else`). ABI/pointer-width only (D3x/A1 class), no game-logic change.** Build clean; bunker1/facility/archives/dam verify PASS, no crash. **Owed:** a playtest eyeball of patrol-guard behaviour on entry to patrol (do they over/under-react to a player they haven't seen). Low individual impact but exactly the class the D170/D193 "AI behaviour looks off" reports keep landing in. |
| D205 | **PC plays wrong/extra SFX for gameplay events the user can A/B against N64: general gunfire has a slap/glass layer audible on PC but silent on N64; an explosion plays a soldier-scream sample; armor pickup plays a wrong sample; unarmed melee (slap) plays Klobb's shooting sound idx 106 instead of the slap effect (M-71/M-72, user by-ear + N64 A/B).** **M-74 WITHDRAWS M-73's root cause.** M-73 read the user's combat capture as guards stuck re-triggering fire sound idx 109 ("256x/29.7 s, no bullets") and pinned it on `stanTestLineUnobstructed` LOS over converted collision geometry. Re-counting the same file: idx 109 is requested **128x** not 256x (the double-log artifact D204 already corrected for M-63 on this very index), = 4.31/s across ~11 guards = ~0.4/s each, **~6x UNDER** the AK47's own `SoundTriggerRate` (`RATE_AK47`=4 ticks => 15/s ceiling) — so the `field_178` gate never binds; and impacts are plentiful in the same window (~30 ricochet/wall-hit 19-41, 69 x2 flesh, 12 body-falls 123-132, 14 yelps 134-147). The eye-catching monotone sweeps 134->147 / 123->132 are the ground-truth round-robin `male_guard_yelp_counter` (`chraction.c:2454-2470`), faithful, not a broken selector. **The 109 traffic is normal in-spec guard combat**; do NOT open the pccg-stan/LOS geometry investigation on this evidence. Everything upstream of presentation is proven: bank 261/261 identical (M-71), sample+pitch exact (M-68/M-70), and every index in the capture correct (182 explosion x1, 81 armor x1, 46/47/48/49/105). **Remaining untested layer = spatial presentation (per-voice volume, pan, concurrency)** — it explains all four complaints at once (a wrongly-placed sound is by ear a sound at your own position) and is the youngest code in the stack (`sndCreatePostEvent` was fully stubbed by D138 until M-65). **M-78: CLOSED.** All four symptoms were D206 (the +1 bank-slot alias). (3) armour + (4) melee->Klobb + (1) slap/glass over gunfire: user by-ear A/B vs N64 = PASS (M-77c). (2) explosion->scream was thought NOT explained by D206 (169-183 -> 168-182, all still explosions) but the user re-tested on Bunker post-merge and the explosion SFX is now correct by ear — the earlier "scream" was a coincident guard death-yelp (GET_HIT_MALE 134-147 seen +/-1s of the blast in M-73's capture), faithful N64 behaviour, not a wrong sample. No presentation-layer bug: pan audit (M-74) already showed GE never spatially pans SFX (252/261 bank sounds `samplePan==64`, no `AL_SNDP_PAN_EVT` poster). | CLOSED (M-78) — root cause was D206; all four symptoms resolved + user-confirmed by ear vs N64. No separate presentation-layer fix needed. |
| D152+ | D152 addendum (M-31): static audit of every compiled-audio `osSetIntMask(OS_IM_NONE)` — **all balanced**, no unbalanced early-return. Real fixes: `sndSetSfxSlotVolume` now holds the mask across its list walk (matches its twin `sndDeactivateAllSfxByFlag`) + `sndApplyVolumeAllSfxSlot` batches the slot loop under one recursive hold (kills the fade-out lock-acquire storm); `portThreadWrapper` calls `imThreadExitRelease()` on thread exit (kills the "transient thread acquired `OS_IM_NONE` and died" leak + the pthread-id-reuse re-wedge). Steal-lock kept as backstop. | FIXED (`src/snd.c`, `port/src/libultra.c`, `#ifdef PORT`) — fade-out repro playtest-gated, not headless-verified |
| D152+ | **D152 addendum (M-31):** — full `## D152+` entry at file tail | FIXED (`src/snd.c`, `port/src/libultra.c`, `#ifdef PORT`) — fade-out repro playtest-gated, not headless-verified |
| RC3 · D167 | **Non-power-of-two texture wrap period (`docs/dev/TEXTURE-GLITCH-ANALYSIS.md` §6 RC3 — "textures repeat oddly", residual Depot-ceiling noise after D161).** The N64 RDP masks the texel coordinate of a wrapping render tile at `1<<mask`, and GE sets `mask = texDimensionToMask(dim) = ceil(log2(dim))` (`src/game/tex.c:361`), so a non-PoT tile (Depot's 65×65 / 96×48 / 56×56 room surfaces) repeats at the **next power of two**, not at its image size the way GL `GL_REPEAT` does → the pattern is squashed/stretched and the seam lands in the wrong place. fast3d never stored `masks`/`maskt` at all (`gfx_dp_set_tile` dropped them) and wrapped purely at the uploaded image dimension. **Fix (`port/fast3d/gfx_pc.cpp`, behind the existing `Video.WrapFix` knob, default OFF):** store `masks`/`maskt` on the tile; in the hoisted per-texunit pre-wrap block in `gfx_sp_tri1` (D74 block — already lifted out of the vertex loop, indexed by texunit `t` not vertex `i`), when the tile is WRAP (no CLAMP/MIRROR bit) and `1<<mask != tex_width`, fold the UV at the N64 period `1<<mask` and clamp the `[dim, 1<<mask)` overflow band (which is a TMEM smear on console, no real texels) to the last texel so it reads as an edge streak instead of a bogus early image restart. `GE_WRAPFIX=0/1` env override added (env wins over the ini, matching `Debug.FrameDump`). **Per-level captures (`-level_09`/`-30`/`-34`/`-20`, WrapFix OFF vs ON, `GE_PCDUMP` 6-frame windows):** no crashes, 6/6 frames each; on settled/comparable frames Silo is ~pixel-identical (phash 0–11), Facility 180–260 pixel-identical, Depot shows small localized texel changes on ceiling/wall cells (dmean 5–9, no structural break); the large per-run deltas are all the D117 intro-camera-pan nondeterminism, not the fix. Default kept **OFF** — no regression, but a headless structural diff can't confirm the Depot ceiling actually looks *better*; needs a human eyeball with `Video.WrapFix=1`. Default-off is byte-identical to golden (all new behaviour is inside `if (g_wrap_fix)`). D74's dead in-vertex-loop wrap block was already reworked/hoisted at M-30; this only adds the mask-period trigger. Confidence: **medium** (mechanism correct; overflow-band handling is an approximation, not exact TMEM-smear emulation; visual win unconfirmed). porting-notes.md §D. | KNOB ADDED, default OFF (`port/fast3d/gfx_pc.cpp`). Needs user visual check on Depot. |
| D168 | **`GE_PCDUMP` / F12 PPM captures were vertically flipped — the entire source of the bogus D114/D116 "HUD/text X-mirror" (M-33, developer-confirmed).** `gfx_opengl_dump_bound_fbo` (`port/fast3d/gfx_opengl.cpp`) wrote `glReadPixels` output straight to a P6 PPM. GL framebuffer origin is bottom-left; PPM P6 is top-row-first — so every capture (and the `tools_pc/golden/` set, and every screenshot pasted into the finding log since M-6) was upside-down. On real hardware / an actual screen the game renders correctly (developer confirmed). The successive D114→D116 "shared fast3d mirror" investigations — each of which found *every probed stage clean yet the output "mirrored"* — were reading an inverted capture and pattern-matching upside-down asymmetric content (text, guards, the Nintendo logo) as "mirrored". Fix: emit PPM rows bottom-to-top. `tools_pc/golden/*.png` flipped in place to match (see `tools_pc/golden/README.md`); regenerate from a real run when convenient. **Not runtime-verified this session** (no ROM / toolchain in the migrated tree — see the migration note) — needs a fresh capture to confirm text reads normally. Confidence: **high** (mechanism is unambiguous; developer has hardware confirmation the screen is correct). porting-notes.md §D2. | FIXED (`port/fast3d/gfx_opengl.cpp`); D114 / D116 reclassified as capture-orientation artifacts (below). |
| D168 | **`GE_PCDUMP` / F12 PPM captures were vertically flipped — the entire source of the bogus D114/D116 "HUD/text X-mirror" (M-33, developer-confirmed).** — full `## D168` entry at file tail | FIXED (`port/fast3d/gfx_opengl.cpp`); D114 / D116 reclassified as capture-orientation artifacts (below). |
| D169 | **Front-end mouse pointer can't reach the outer cells of the mission/level-select grid — only an inner ~3×3 selectable (M-33, developer bug report).** The D165 pointer P-controller in `port/src/input.c` is hard-coded to a **320×240** virtual field (`MENU_CURSOR_HI_H`/`_HI_V` = 300/220, `MENU_CURSOR_MID_H`/`_MID_V` = 160/120, `input.c:145-149`), but GE's front end runs at **440×330** (`front.c:8570` `viSetViewSize(440,330)`; default cursor home 220/165 `front.c:285`). `frontUpdateControlStickPosition` clamps the real cursor to `[20,420]×[20,310]` (`front.c:1195-1217`); the mission-select hit-test grid spans x 73…352 / y 62…270 with outer split points 317 / 235.5 (`front.c:516,519,3180,3194`). The port clamps its pointer *target* and *estimate* to 300/220 (`input.c:553-564`) → the stick zeroes out once both saturate → the game's `cursor_h_pos`/`cursor_v_pos` park at ≈300/220, short of the two outer columns and the bottom row. File-select / mode-select / main-menu are unaffected because their hit targets (centred folder boxes, the `x=126` mode list, the `x=106` difficulty list) all sit inside the 320×240 sub-box. Not the D118d `joyGetStickY`-threshold class — this is a virtual-resolution constant mismatch, closer to D164 (a front-end layout constant wrong on the PC path). Also note: the "re-syncs whenever the target is held at a screen edge" comment at `input.c:141-143` describes behaviour the code doesn't implement — the only estimator reset is the activation re-home (`input.c:545-549`), so once pinned at the clamp the estimate never recovers. Confidence: **medium-high** (constants + clamp math unambiguous in source; static-only, no build/run this session; exact reachable block "~4×3" vs the reported "3×3" within tolerance). | **FIXED (M-33, `port/src/input.c`, port-only, no `#ifdef PORT`).** The `menuMode` pointer branch now derives its clamp bounds from the live virtual screen — `[screenleft+20, screenleft+screenwidth-20] × [screentop+20, screentop+screenheight-20]` via `getPlayer_c_screenwidth/height/left/top()` (`src/game/bondview.c:880-895`) — falling back to the old 320×240 constants when the front-end screen isn't set (`sw` outside 200…2000). The estimator seed on activation is now the real `cursor_h_pos`/`cursor_v_pos` (`front.c:285`, externed in `front.h`) instead of the 160/120 centre guess. `MENU_CURSOR_HI_H/_V` / `MID_H/_V` kept only as the fallback. **Verified:** builds + links clean (`getPlayer_c_screen*` and `cursor_[hv]_pos` all resolve — non-static engine symbols, as expected); `GE_STARTMENU=7` mission-select boots crash-free 600+ frames; `-level_09` unregressed (framediff 3/3 within threshold, 91.6% nonclear). The in-level path is untouched (menu-only branch). **Interactive feel-check still owed** — headless input can't drive the mouse pointer, so "every grid tile is now reachable" is inferred from the corrected clamp math, not observed. `Input.MenuPointerMode=0` (legacy velocity) and the constant fallback remain as escape hatches. |
| D178 | **Pre-mission briefing screen: objectives blank / missing, briefing pages blank (also the D143 "briefing text blank" side effect) — FIXED (M-36).** Root cause: the briefing segment (`Ubrief*Z`, source `assets/obseg/brief/*.c`) is a raw ROM image of `struct BriefStruct` = `{ u16 brief[4]; struct { u16 textid; u16 enabled_difficulty; } objective[10]; }` (48 bytes), loaded by `front.c load_briefing_text_for_stage()` via `_fileNameLoadToAddr()` — **no converter and no BE→LE fixup anywhere in the chain**. On the LE host all 24 `u16` read byte-swapped. Measured on Dam (`GE_D178=1`): raw `brief=002c,012c,022c,032c obj0=042c/0100 obj3=072c/0000` vs correct `2c00,2c01,2c02,2c03 / 2c04/0001 / 2c07/0000`. Two independent symptoms follow: (a) `textid` `0x2C04` (= `getStringID(LDAM,4)`, bank 11 slot 4) reads as `0x042C` → bank 1 slot 44, never loaded → `langGet()` NULL → **blank text** (this is exactly the D143 residual, `front.c:6732` and `brief[0..3]` at `front.c:6855-6867`); (b) `enabled_difficulty` `0x0001` (Secret Agent) reads as `0x0100 = 256`, so `selected_difficulty >= enabled_difficulty` (`front.c:6729`) is false for every difficulty-gated objective — on Agent only the one `DIFFICULTY_AGENT`(0) objective survived the filter, which is why a single bare "a." bullet printed. **Fix:** `romdataFixupBriefing()` in `port/src/romdata.c` (+ `port/include/romdata.h`), called from a `#ifdef PORT` block in `load_briefing_text_for_stage()` right after the load — the same shape as `langFixupLoadedBank()` in `language.c` (BE-serialized-struct reconciliation, semantics-preserving, no game logic touched). `GE_D178=1` prints the raw and fixed words. **Verified:** Dam briefing on Agent now shows "a. Bungee jump from platform"; on `GE_STARTMENU_DIFF=3` (00 Agent) all four (Neutralize all alarms / Install covert modem / Intercept data backup / Bungee jump from platform), correct difficulty gating. `-level_09` framediff 3/3; `GE_STARTMENU=7`/`=13` crash-free. | **FIXED (M-36)** — high confidence |
| D175 | **In-game stutter / brief hang during normal play** (user QA report): opening a door on Runway (`-level_35`, mission 3) and also observed on Surface. Self-recovered; no backtrace captured. Likely one of the known transient-hang classes (D155 catch-up spiral / D156 anim NaN loop / D134 task-done event / D147-D152 audio-lock steal) or a benign new-room texture-import frame spike on door open. See `GRAPHICS-BACKLOG.md` D175 for the gdb pattern-match sheet. | OPEN — observed, not investigated |
@@ -467,9 +467,9 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
| D88.1–D88.3 · D88.5–D88.6 | `Usetup*Z` header + sub-table width/endian conversion (`d88_emit.py`) | resolved |
| D88.4 | `propDefs` polymorphic record stream not byteswapped → `setupDoor` crash | resolved (`d88_propdefs.py`); layout audit M-20 → **D132** |
| D132 | D88 propDefs layout audit (M-20): converter cursor confirmed vs real PC struct layout for all types the 21 levels emit. DOOR/OBJECT-prefix/VEHICHLE/AIRCRAFT/TANK/AUTOGUN/AMMO/TINTED_GLASS/objective sub-records all MATCH. **Divergence found:** types 14 LINK / 19 SWITCH / 38 LOCK_DOOR / 44 SAFE_ITEM — each has `s32 IndexN` fields sharing a union with a pointer (`LinkRecord.first`/`Index1`), so on PC the field sits in an 8-byte, 8-aligned union slot, but the converter emits it at N64 tight-4-byte-word offsets → `pdef->Index1` reads `Index2`'s value, `Index2` reads 0 → switch-doors / dual-weapons / locked-doors / safes silently fail their validity guard (non-crash; guard failure also prevents the under-sized-record `->next` overflow). | proposed fix (not applied) — see below |
| D157 | **Campaign never unlocks the next level (M-30, user bug — "complete Dam on Agent, Facility stays locked, no save").** The type-23 objective record's `MinDificulty` is a big-endian `s32` at word 3; on N64 the 0–3 value sits in its low byte == offset `0xF`, and `struct objective_entry.difficulty` (`bondtypes.h`) reads `s8` @0xF. `d88_propdefs.py` `_bswap32`'s that word (correct — it IS a 32-bit field), moving the value to offset `0xC` on LE → the `s8`@0xF read yields **0 for every objective**. `get_difficulty_for_objective()` → Agent(0) for all → `objectiveIsAllComplete()` on Agent evaluates objectives that should be difficulty-gated out (Dam "Neutralize all alarms" = Secret Agent, "Install covert modem" = 00 Agent) → they're never complete → `bossReturnTitleStage` sees `objectiveIsAllComplete=0` → `end_of_mission_briefing()` (the `fileUnlockStageInFolderAtDifficulty` → EEPROM write) never runs. The D151 sibling — same struct, the field D151 said it "left unchanged". Confirmed via GE_SAVELOG playtest + a direct ROM dump of Dam's records (word 3 = 1/2/2/0). | FIXED (`src/bondtypes.h`, `#ifdef PORT` — reorder `objective_entry`'s post-swap tail so `difficulty` reads @0xC, `unkD` @0xE; N64 layout under `#else`; sizeof unchanged 16). Also fixed `tools_pc/dump_objectives.py` (same byte-offset bug — read `buf[off+12]` = BE high byte = always 0 → showed "Agent" for every objective) + regenerated `docs/dev/LEVEL-OBJECTIVES.md`. **User-confirmed:** Dam Agent → `objdiff=1/2/2/0` → `objectiveIsAllComplete=1` → `fileWriteSave` with the completion time; Agent checkmark shows in mission-select and persists across kill+restart. New `GE_SAVELOG=1` / `GE_UNLOCK_ALL=1` `#ifdef PORT` env diagnostics added along the way. 21/21 sweep PASS. porting-notes.md §C. |
| D160 | **Dam level-end cutscene (Bond off the dam / bungee) never plays — cuts straight to the MISSION COMPLETE report (M-30/M-31, user bug D148).** Progression is unaffected: `bossReturnTitleStage` fires, the report + next briefing load, EEPROM save lands (D157). So the *transition* works; only the scripted cutscene step is skipped or silently fails. Static trace: Dam's end sequence is `UsetupdamZ` ailist `ai_24` (id 0x1004, gated `if_bond_in_room_with_pad(0x4a01)` — Bond reaches the exit) → lock controls, `bond_set_locked_velocity` (auto-walk off edge), wait `if_bond_y_pos_less_than` (Bond falling) → fade → `if_objective_all_completed` branch → `camera_switch`/`jump_to_ai_list` into the abseil ailist `ai_17` (id 0x0412: `screen_fade_from_black`, `music_xtrack_play`, `guard_play_animation(0xb100…)` = rappel anim on a puppet chr, three `camera_switch` w/ fades, `exit_level`). `camera_switch(TAG,…)` resolves a `CutsceneRecord` (propDef type 46, records 322/324/326 paired with Tags 10/11/12) via `tagGetCommandIndex` + `setupGetPtrToCommandByIndex` (a **command-index count walk over the propDef stream** using `sizepropdef()`). CutsceneRecord itself is all-scalar 28 B, correctly converted (`d88_propdefs.py` 46→28). **Leading hypotheses, unverified:** (a) the index-count walk desyncs because some earlier propDef type Dam emits (Vehicle 321 — D122 "medium confidence" tail; TintedGlass) has a wrong PC `sizepropdef` stride → `camera_switch` gets a bogus `cdef`/`TagIndex<0` → cutscene silently skipped, `ai_17` falls through to `exit_level`; (b) the trigger chr for `ai_24`/`ai_25` isn't spawned / `if_bond_in_room_with_pad` never true and the level ends via a different path; (c) D75/anim-model family — the puppet `guard_play_animation` rappel anim doesn't render (third-person Bond model absent, like the gun-barrel/cast models) so the cutscene "plays" invisibly but the user perceives it as skipped (less likely — user reports an instant cut, not a blank pause). | DIAGNOSTIC SHIPPED, not root-caused (`c95713f5`, `#ifdef PORT` + `GE_D160=1`): trace points in `bossReturnTitleStage` (entry + caller), `bondviewSetCameraMode` (mode/stage/`g_IntroSwirl`/intro-anim-idx/caller), and AI cmds `EndLevel`/`exit_level`, `CameraLookAtBondFromPad`, `CameraSwitch` (prints `cdef` ptr / `TagIndex<0` / tag `NOT FOUND`). **Next step:** user plays Dam to the exit with `GE_D160=1` (via `debug.ps1`); the trace answers (1) does `CameraSwitch`/`CameraLookAtBondFromPad` execute at all → trigger fires? (2) if yes, is `cdef` a sane pointer → propDef-walk / converter bug (a), same family as D122/D132/D157; (3) if the cam cmds never run → ai_24 trigger / chr-spawn bug (b). If it's (c), `bondviewSetCameraMode` shows POSEND being set + control returns normally. This is likely a WRITE-UP-then-fix (propDef stride) rather than a hack. Confidence in hypothesis (a): medium. **M-106 static re-audit (no build/runtime access this session — see note below): hypothesis (a) independently RE-CONFIRMED RULED OUT.** `CutsceneRecord` (`src/bondtypes.h:3785-3792`, propDef type 46) is **all-scalar** — `PropDefHeaderRecord` (4B header) + `coord3d pos` (12B, 3×f32) + `theta`/`verta`/`pad` (4B each) = 28B exactly, zero pointer/union fields, so it cannot exhibit the D122/D126/D132/D209 pointer-width-growth pattern at all (nothing to widen); this matches `porting-notes.md`'s already-recorded M-31 conclusion (`§ "D160 — a propDef command-index walk desync..."`) that the stride cross-check is clean for all 48 types. Traced the `AI_CameraSwitch` path end-to-end (`chrai.c:3990-4013`): `tagGetCommandIndex`/`setupGetPtrToCommandByIndex` resolve `cdef`, then `gBondViewCutscene = cdef` and `bondviewSetCameraMode(CAMERAMODE_POSEND)`; the render side (`bondview2.c:1558-1590`) reads `gBondViewCutscene->pad`/`pos`/`theta`/`verta` directly off that pointer with no intermediate copy/struct-punning step that could introduce a *new* aliasing bug — this leg of the pipeline is clean. **Did not get far on hypotheses (b)/(c)** (AI trigger-fire vs invisible-puppet-render) — no interactive session / no build toolchain available in this worktree (`gcc`/`cmake` not on `PATH`; MSYS2 MINGW64 environment not active), so the `GE_D160=1` Dam-to-exit playthrough this row has needed since M-31 still has not been driven. **Confidence update: hypothesis (a) — HIGH (ruled out, independently re-derived, not just cited); (b)/(c) — unchanged, still need the live trace.** No code changes made (nothing to fix once (a) is ruled out); this is a documentation-only confirmation pass. See also D173 below — if the Cradle "Bond floating/spinning" symptom filed under this label is the puppet-render family (D75/D156), closing D173 first may explain the residual `#4 GOTO` here too. **M-107 — the live trace finally happened; (b)/(c) both REFUTED, real root cause moved to D243.** `GE_D160=1` on `-level_33` (Dam) plus a fresh-save normal playthrough all show the AI triLine truncated
| D161 | **Depot (`-level_30`) ceiling renders as bright-blue speckle + radial "light-ray" streaks converging to a point instead of a dark corrugated roof (M-30/M-31, user bug B2 — `docs/dev/TEXTURE-GLITCH-ANALYSIS.md`).** NOT filtering (3-point+trilinear+aniso didn't touch it), NOT RC2 mip-contamination, NOT the RGBA5551 decode. Root-caused with a `GE_TEXDUMP` PPM+param dump (`gfx_opengl.cpp` / `gfx_pc.cpp`): the offending surface is **one texture, `fmt=CI siz=8b, 16×16`**, uploaded with **`rdp.palette_fmt == G_TT_NONE` (0)** while every other CI texture that frame has `palfmt=0x8000` (`G_TT_RGBA16`). GE draws the Depot roof with the TLUT **disabled** (`gsDPSetTextureLUT(G_TT_NONE)` — a legit N64 idiom: a CI-siz tile with no LUT feeds the raw 8-bit TMEM texel straight into the colour pipe, i.e. it behaves as a plain **I8** texture). fast3d's `import_texture()` ignored `palette_fmt` and always did the palette lookup for `fmt==CI`, indexing a **stale `rdp.palette`** left over from a previous texture → the 16×16 image came out as pure blue/cyan/magenta noise; the "radial rays" were that high-frequency noise aliasing on the steeply-receding ceiling plane (which is also why filtering couldn't help — the data was garbage). Fix: in `import_texture()`, `fmt_eff = (fmt==G_IM_FMT_CI && rdp.palette_fmt==G_TT_NONE) ? G_IM_FMT_I : fmt`, and dispatch on `fmt_eff` (CI4→I4, CI8→I8). Narrow — only changes surfaces that are currently 100% garbage. Verified: Depot corridor + control-room ceilings render as clean dark/grey industrial roofs; `-level_09`/`-level_30`/`-level_34` sweep PASS, unregressed. `GE_TEXDUMP` (env-gated PPM dump of every uploaded texture + a `fmt/siz/palfmt/palidx` log line) kept as an inert diagnostic. Confidence: **high** (before/after screenshots + the palfmt=0 evidence). | FIXED (`port/fast3d/gfx_pc.cpp` `import_texture`, `port/fast3d/gfx_opengl.cpp` diag). porting-notes.md §D. |
| D157 | **Campaign never unlocks the next level (M-30, user bug — "complete Dam on Agent, Facility stays locked, no save").** — full `## D157` entry at file tail | FIXED (`src/bondtypes.h`, `#ifdef PORT` —… |
| D160 | **Dam level-end cutscene (Bond off the dam / bungee) never plays — cuts straight to the MISSION COMPLETE report (M-30/M-31, user bug D148).** — full `## D160` entry at file tail | DIAGNOSTIC SHIPPED, not root-caused (`c95713f5`, `#ifdef PORT` + `GE_D160=1`)… |
| D161 | **Depot (`-level_30`) ceiling renders as bright-blue speckle + radial "light-ray" streaks converging to a point instead of a dark corrugated roof (M-30/M-31…** — full `## D161` entry at file tail | FIXED (`port/fast3d/gfx_pc.cpp` `import_texture`, `port/fast3d/gfx_opengl.cpp` diag). porting-notes.md §D. |
| D89–D92 | stage-load→frame: stan zero-fill overrun, portal address trunc, chr/AI spawn ptrs | resolved |
| D93–D102 | struct-pun / hardcoded-size pass: player alloc, player.model inline Model, weapon Model pun, master-DL buffer | resolved (D95 pool bump partial) |
| D103–D107 | BUNKER1 viewport height, depth-buffer clear (`G_CLEAR_DEPTH_EXT`), portal near-plane, LOD mip tile | resolved |
@@ -511,20 +511,20 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
| D118d | **Watch-menu list over-scrolls with keyboard (M-27, user bug report).** `game_options_inventory_navigation` / `sub_GAME_7F0A611C` (`options.c`) have a "slam the stick" fast-scroll: a raw *level* check `joyGetStickY(PLAYER_1) < -0x46` / `>= 0x47` that steps `watch_inventory_cursor_pos` by 1 **every frame** it's held. An N64 stick rarely sustains past ~0x46 on-axis so a human taps it; keyboard W/S (and SDL pads pegged at 0x80) sit there every frame → one keypress skips several items (breaks item selection needed to finish the game). Fix: `#ifdef PORT` macro `GE_WATCH_STICK_FAST{UP,DOWN}` → `0` for the port — stick list-nav then goes only through the latched single-step path (`watch_stick_y_pressed_*`, one step per press, existing game mechanism) and the -0x1e..-0x45 smooth-scroll band; D-pad/C-buttons keep edge-repeat. N64 `#else` = verbatim. | FIXED (`src/game/options.c`, `#ifdef PORT`). Build green, `-level_09` unregressed. **Feel to be confirmed in playtest** (probe couldn't reach the INVENTORY watch page headlessly). D118 input family. Latent sibling: `front.c` menu nav uses `joyGetStick*InRange` level checks the same way. |
| D141 | **Watch-menu item-model crash (M-27), exposed by D140.** — full `## D141` entry at file tail | FIXED (`src/game/gunfire.c`, `#ifdef PORT`). Watch page now renders (weapon page verified — mirrored text D114/D116, dark weapon model D75, both parked cosmetic). `-level_09`/`-level_20` unregressed. |
| D150 | **Watch objective/briefing page crash (M-28).** — full `## D150` entry at file tail | FIXED (`src/str.c`, `#ifdef PORT`). Build green (`ntsc-final`); `-level_09` 600+ frames @ 91.67% unregressed. Watch-page repro is interactive — user to re-verify. porting-notes.md §C. |
| D152 | **Mission-failed / objective-failed → permanent black screen (M-28, user bug report — LIVE process inspected, not yet fixed).** User killed Trevelyan in Facility (Ctrl = fire, no crouch bind) → failed the objective → screen faded to black and never came back; process stayed alive under `debug.ps1`. `gdb.txt` (kept running): rendering stops dead at frame 12600 (`frames=12657` frozen across 206 heartbeat dumps / ~7 real min), VI pacemaker still posts. **Deadlock on `s_imLock`** — the D147 recursive-mutex that backs `osSetIntMask`. Both consumer threads are blocked in `pthread_mutex_lock(&s_imLock)` and neither owns it: `mainThread` = `sndSetScalerApplyVolumeAllSfxSlot` → `sndApplyVolumeAllSfxSlot` → `sndSetSfxSlotVolume` → `alEvtqPostEvent` (`event.c:82`) → `osSetIntMask(OS_IM_NONE)` (`libultra.c:1232`); `amMain`/audioThread = `amHandleFrameMessage` → `alAudioFrame` → `sndPlayerVoiceHandler` → `alEvtqNextEvent` (`event.c:47`) → `osSetIntMask(OS_IM_NONE)`. `sndSetScalerApplyVolumeAllSfxSlot` is the **mission-failed audio fade-out** (ramps `g_sndSfxVolumeScale` → 0 each frame, `AL_SNDP_RELEASE_EVT` posted per active `ALSoundState` per frame) — so the fade-to-black *is* the intended fail transition, and it hammers the event queue hard enough that the `osSetIntMask` lock is left owned by a party not in the 3-thread dump (a leaked unbalanced `OS_IM_NONE` — libaudio has early-return mask paths — or a transient thread that acquired it and exited; `gdb.txt` shows `New Thread`/`exited` churn every few k frames). D147's "the decomp never blocks while holding OS_IM_NONE → cannot deadlock" assumption is **falsified** by this path. Same family as D147 / porting-notes.md §D4. | MITIGATED (`port/src/libultra.c`, M-28, `#ifdef PORT`/port-only). `osSetIntMask` is now a **self-healing** logical lock: `OS_IM_NONE`/`OS_IM_ALL` acquire/release a process-wide recursive critical section tracked by `s_imHeld`/`s_imOwner`/`s_imDepth` under a short bookkeeping mutex `s_imMx` + condvar. Normal (balanced, microsecond) audio critical sections behave exactly as the D147 recursive mutex did. A waiter that blocks **> 2 s** (`OS_IM_STUCK_NS`) concludes the holder leaked it, logs `LOG_ERROR` with the stale `s_imOwner` + the stealing caller's return address, and **steals** the section — so a leaked/unbalanced `OS_IM_NONE` (or an acquire-and-exit transient thread) can no longer wedge the game forever; worst case is a ~2 s audio hiccup + a log line naming the leak. Builds clean. Still OPEN: the exact leaking call site (find it from the `D152: ... stealing from owner=` log next time it fires) and the proper narrow fix (dedicated `ALEventQueue` lock, or restore-on-thread-exit). Regression check deferred — `-level_09`/`-level_20` both flaky under the current machine load (D117/D134), and clean `master` itself has a separate nondeterministic `-level_09` frame ~900-1400 `0xc0000005` (see below). |
| D152+ | **D152 addendum — static audit + narrow fix (M-31).** Audited every `osSetIntMask(OS_IM_NONE)` in the *compiled* audio code (`event.c` `alEvtqNextEvent`/`alEvtqPostEvent`/`alEvtqFlush`/`alEvtqFlushType`, `csplayer.c` `__CSPRepostEvent`, `synaddplayer.c` `alSynAddPlayer`, `snd.c` `sndRemoveEvents`/`sndSetupSound`/`sndDeactivateAllSfxByFlag`): **all balanced on every path** — `alEvtqPostEvent`'s lone early `return` (`event.c:86`, freeList empty) restores the mask first. The §F D152 guess "libaudio has unbalanced early-return mask paths" is **not** borne out. Two real defects found + fixed `#ifdef PORT` (N64 verbatim `#else`): **(1)** `sndSetSfxSlotVolume` (`snd.c`) walks the live `ALSoundState` list and posts `AL_SNDP_RELEASE_EVT` to the shared `ALEventQueue` **without holding `OS_IM_NONE`** — unlike its structural twin `sndDeactivateAllSfxByFlag`, which wraps its identical walk. On PC that's an unguarded walk racing `amMain` *and* one lock acquire/release per matching sound; the mission-failed fade (`sndSetScalerApplyVolumeAllSfxSlot`→`sndApplyVolumeAllSfxSlot`→ this, every frame, every active sound) turns it into a per-frame lock-acquire storm on `mainThread` = exactly the §F dump's "hammers the event queue hard enough that the lock is left owned." Fix: hold `OS_IM_NONE` once across the whole walk (nested `alEvtqPostEvent` hits the recursive depth++ fast path, no new contention window); also wrap `sndApplyVolumeAllSfxSlot`'s slot loop so the whole volume update is one recursive hold (1 real acquire/frame instead of `SFX_SLOT_COUNT × active-sounds`). **(2)** The actual leak matches the §F dump's *other* hypothesis — "a transient thread acquired `OS_IM_NONE` and exited" (`gdb.txt` shows `New Thread`/`exited` churn). `port/src/libultra.c` `portThreadWrapper` now calls new `imThreadExitRelease()` after the thread entry returns: if that thread still owns `s_imHeld`, release the orphan immediately (+ `LOG_ERROR` naming it). This removes the wedge *and* the ~2 s steal hitch, and stops the **re-wedge** that occurs when a new host thread reuses the dead thread's pthread id and `imAcquire` mis-detects recursion (`s_imOwner == self`). The M-28 steal-lock stays as the last-resort backstop for a genuinely unbalanced *same-thread* path (none found, but cheap insurance). Build green (240/240); `-level_09` frame 300 renders 91.7% non-clear, crash-free; `-level_20` boots crash-free (frame-count flaky under load per D117/D134, no `D152:` log). Fade-out repro is **playtest-gated** — not reproducible headless, so not end-to-end verified; user should replay the mission-fail and confirm no black screen + no `D152:` steal/orphan log lines. | FIXED (`src/snd.c`, `port/src/libultra.c`, `#ifdef PORT`). porting-notes.md §D4. |
| D154 | **`bg.c` room-geometry ray/hit-test GBI parser port (M-28, WRITTEN / UNVERIFIED / UNCOMMITTED).** `bgTestRayIntersectionInRoom` (`bg.c:3331`, called by `bgTestBulletHitBackground` on every shot that resolves against background geometry) is the D135 sibling flagged since M-23: raw `((u8*)gdl)[k]` / `((u32*)gdl)[i]` byte/word indexing into what is now the 16-byte PC `Gfx` room DL (D85 `bgWidenRoomGdl` + `texLoadFromGdl`) → desync → OOB `vtxbase[idx]` / walk-off AV the first time you shoot a wall or floor. Ported `#ifdef PORT` (N64 verbatim under `#else`): `vtxoff`/`vtxbase`/`op` via the `.dma` view (matches the already-ported `bgBuildRoomVtxBounds`); G_TRI1 indices from `(w1>>16/8/0)&0xff /10`; the 12 G_TRI4 nibble extractions from `(u32)gdl->words.w0/.w1` (each mapped from the N64 byte/halfword layout — table in the code comment, cross-checked twice); both `texturenum` recoveries → `-1` (the KSEG0 `*(u16*)(w1-8)` deref is invalid for the port's converted GDLs, same as D135; texnum only picks the impact decal/sound, parked D77). Builds clean. **Verification blocked** — a no-input `GE_PCDUMP` capture never fires a bullet so it never calls this function; needs a real firefight into a wall on an idle machine (this session's box was thrashed → D153/driver crash on every run). | **M-30 re-audit + fixes (still playtest-gated for final verify).** Cross-checked the ported G_TRI1 + all 12 G_TRI4 nibble recoveries twice against the N64 `#else` and the raw BE-word bit derivation — **G_TRI1 and G_TRI4 are correct**. **Two bugs fixed:** (1) `vtxoff` — the port read `gdl->dma.par & 0xf`, but the PC `Gdma_le` shim (`port/shim/PR/gbi.h`) maps `.par` to bits 0-23 of word0 (the packed *length*, always 16-aligned) not the N64 byte-1 params field at bits 16-23, so vtxoff was silently forced to 0 (breaks any G_VTX batch with `v0 != 0`, i.e. multi-batch rooms → wrong `vtxbase[idx]` → OOB). Now `((u32)gdl->words.w0 >> 16) & 0xf`. (2) **Sibling ported** — `bgTestBulletHitBackground` tail (`bg.c:~3841`, the post-hit G_SETTILE back-scan for `tileformat`/`tilesize`) was itself an unported raw `((u8*)gdl)[0]`/`[1]` + 64-bit `words.w0 << 11 >> 30` parser over the same widened DL; wrapped `#ifdef PORT` (opcode from bits 24-31, byte1 from bits 16-23, `(u32)`-forced 32-bit width for the tilesize shift). **Diagnostic:** `GE_D154=1` → `osSyncPrintf` of the parsed cmd/vtx-index stream (call header + per-TRI1/TRI4 `idx`) for the first 64 invocations, for the user/integrator to eyeball. Build green (`ntsc-final`, 242/242). Runtime unverifiable in a worktree (no `data/`); still needs a firefight-into-wall on an idle machine + `-level_09` framediff. Confidence: HIGH on the mechanical correctness (bit-for-bit vs N64), MEDIUM that nothing else in the walk still bites. porting-notes.md §B (D135 corollary). |
| D156 | **Facility outro-cutscene "hang" — the actual fix (M-29, user bug report, 2nd occurrence after D155).** After D155 shipped the user re-ran the playthrough and it hung again at the end of Facility — same stack (`modelSetAnimFrame2WithChrStuff` ← `modelTickAnim` ← `chrUpdateAnim` ← `chrTick` ← `playerTick` ← `propsTick` ← `lvlRender` ← `bossMainloop`) but this time **`frames=12962` frozen CONSTANT across ~200 heartbeat dumps / minutes** — a true infinite loop, not the D155 slow-catch-up spiral. Root: the `while (1)` at `model.c:3131` in `modelSetAnimFrame2WithChrStuff` steps the root-motion accumulation one anim frame at a time from `framea` (`model->animframe1`) to `frameb` (`frame`, accumulated in `modelTickAnim` as `frame += playspeed * speed` per tick). If `frameb` arrives **NaN or blown up** — a cutscene anim whose blend/transition math produces a huge/non-finite `model->speed`/`playspeed`/`timespeed` (`model.c:3436` `/ model->unkb0`, `:3477` `/ model->timespeed`, both guarded `> 0` but not against tiny values) — then `floorFloatToInt(frameb)` yields garbage, `endframe - curframe` ≈ 2^31, and the loop (each iteration: `sub_GAME_7F06D3F4` joint transform + `cosf`/`sinf` + `modelConstrainOrWrapAnimFrame`) runs effectively forever. **Likely deeper cause (unconfirmed, needs the repro):** these speed/blend fields are `Model` struct members; if this model is punned/inline (D100/D140 family) and `sizeof(Model)` growth misaligned them, they read garbage exactly when a cutscene sets an unusual anim transition. | GUARDED (`src/game/model.c`, `#ifdef PORT`): (1) in `modelSetAnimFrame2WithChrStuff`, if `frameb` is non-finite or `|frameb| >= 1e6` snap it to `framea` (loop then does ~0 iterations; the `modelConstrainOrWrapAnimFrame` at :3211 still clamps `model->frameb` to a valid frame); (2) in `modelTickAnim` after the tick loop, if `frame`/`frame2` went non-finite/absurd fall back to the pre-loop `model->animframe1`/`animframe2` so the NaN can't poison model state every frame. `!(x > -1e6f && x < 1e6f)` catches NaN too. Anim frames are 0..few-hundred so 1e6 is a safe ceiling. Verified: build green; `-level_09` frame 200 unregressed (guards never fire in normal play). **User to re-verify the Facility outro.** If it still hangs, the NaN source is upstream — dump `model->speed/playspeed/timespeed/unkb0` in `modelTickAnim` during the cutscene. porting-notes.md §E. |
| D155 | **Facility outro-cutscene end → "hang" = unclamped `deltaFrames` spiral (M-29, user bug report).** User watched the Facility outro cutscene; at the end the game froze (process alive, kernel-heartbeat dumps). Symbolicated `mainThread` stack from the port's own hang dump: `modelConstrainOrWrapAnimFrame` ← `modelTickAnim` ← `chrUpdateAnim` ← `chrTick` ← `playerTick` ← `propsTick` ← `lvlRender`, sampled there across *every* heartbeat dump. Root cause: `waitForNextFrame()` (`src/game/frametiming.c`) sets `nextFrameTime = (osGetCount() − prev + 387937) / 775875` and passes it **unclamped** to `updateFrameCounters` as `deltaFrames`. `osGetCount()` is wall-clock on the port (D117), not VI-locked, so a real-time stall — the asset load at the cutscene→debrief boundary, made far worse by the user running a local LLM on the same box — makes `nextFrameTime` balloon to hundreds/thousands. That becomes `speedgraphframes` → `g_ClockTimer`, which drives `modelTickAnim(model, g_ClockTimer, 1)`'s `while (numticks-- > 0)` loop once per character per render, plus dozens of `for (i = 0; i < g_ClockTimer; i++)` sim loops (`bondhead.c`, `bondview2.c`, `explosion.c`, `options.c`…). One render then does thousands of anim ticks × N characters → multi-second frame → heartbeat trips, and the slow frame feeds an even bigger delta next time → unrecoverable spiral. The N64 was physically VI-bound and never produced `deltaFrames > ~2`. Timing-compensation class, cf. D117/D134. | FIXED (`src/game/frametiming.c`, `#ifdef PORT` — clamp `nextFrameTime` to `FRAMETIMING_PORT_MAX_CATCHUP` = 6). After a hitch the sim just resumes at real-time-ish pace instead of spiralling, exactly as the console did when it dropped frames under load. N64 path verbatim. Confidence: high (stack + arithmetic both point here). porting-notes.md §E. |
| D153 | **`-level_09` frame ~900–1400 `0xc0000005` = D117/D134 load flakiness, NOT a bug (M-28).** Reproduced 6/6 while the machine was under heavy concurrent build+run load; on a **quiet** machine `-level_09` ran past frame 2400 with 0 heartbeats, no crash. Same false-positive class the docs warn about every session (M-13..M-22, M-25 Silo ~frame 300 freeze). The crash PC was in a system DLL (a `memcpy` off a buffer) and the frame varied run-to-run — both consistent with the port's host-scheduling jitter under load (`osGetCount` is wall-clock, variable-timestep sim), not a deterministic defect. `romdataFixupMusicSeqTable: seqCount 63 exceeds blob capacity 1` is an EXPECTED benign warning on the header-only first `romdataFixupMusicSeqTable` call (music.c:736; the full-size second call at :742 is silent) — not related. **Lesson: don't regression-test while builds/other runs are in flight.** | NOT A BUG — closed. Run anchors on a quiet machine. |
| D152 | **Mission-failed / objective-failed → permanent black screen (M-28, user bug report — LIVE process inspected, not yet fixed).** — full `## D152` entry at file tail | MITIGATED (`port/src/libultra.c`, M-28, `#ifdef PORT`/port-only).… |
| D152+ | **D152 addendum — static audit + narrow fix (M-31).** — full `## D152+` entry at file tail | FIXED (`src/snd.c`, `port/src/libultra.c`, `#ifdef PORT`). porting-notes.md §D4. |
| D154 | **`bg.c` room-geometry ray/hit-test GBI parser port (M-28, WRITTEN / UNVERIFIED / UNCOMMITTED).** — full `## D154` entry at file tail | **M-30 re-audit + fixes (still playtest-gated for final verify).** Cross-checked the ported G_TRI1 + all 12 G_TRI4 nibble recoveries twice against the N64 `#else` and the raw BE-word bit derivation —… |
| D156 | **Facility outro-cutscene "hang" — the actual fix (M-29, user bug report, 2nd occurrence after D155).** — full `## D156` entry at file tail | GUARDED (`src/game/model.c`, `#ifdef PORT`)… |
| D155 | **Facility outro-cutscene end → "hang" = unclamped `deltaFrames` spiral (M-29, user bug report).** — full `## D155` entry at file tail | FIXED (`src/game/frametiming.c`, `#ifdef PORT` —… |
| D153 | **`-level_09` frame ~900–1400 `0xc0000005` = D117/D134 load flakiness, NOT a bug (M-28).** — full `## D153` entry at file tail | NOT A BUG — closed. Run anchors on a quiet machine. |
| D151 | **Watch data all blank — objectives / mission background / M / Q / Moneypenny (M-28, user bug report).** — full `## D151` entry at file tail | FIXED (`src/bondtypes.h`, `#ifdef PORT` —… |
| D130 | **crash class C2 (Facility `-level_34`, Runway `-level_35`):** — full `## D130` entry at file tail | resolved — stage all 6 N64 fields in a local `f[6]` before writing any. Facility + Runway now PASS (16→18/21). Probes reverted. |
| D164 | **Disclaimer / legal screen only draws the first line (D76 root cause, M-31).** `constructor_menu00_legalscreen` (`src/game/front.c:1523`) renders the 12-entry `legalpage_text_array[]` with `legal_text_end = (struct legal_screen_text *)&legalscreen_MRD;` then `do { render; ptr++; } while (ptr < legal_text_end);`. This is a **linker-adjacency assumption**: on N64 `.data` the two file-scope globals are emitted in source order so `&legalscreen_MRD == legalpage_text_array + 12` and the loop count is exactly the array length. mingw/GCC reorders them — in `build-pc/ge007.x86_64.exe` `legalscreen_MRD` links at `0x1401305e0`, **0x60 bytes before** `legalpage_text_array` at `0x140130640` (verified via `nm`). So `legal_text_end < legal_text_ptr` from the start; the `do/while` runs the body **once** (entry 0, "TWYCROSS BOARD OF GAME CLASSIFICATION" — long + CENTER_ALIGN so it wraps to ~2 visual lines = the "classification line + one below" the user saw), then exits. The other 11 legal lines never render. NOT a D68 image-table bug — the legal screen references **zero** `sImageTableEntry`s; it is pure `langGet()` font text via `display_aligned_white_text_to_screen`→`textRender`, plus the 3D `logoinst` model (`subdraw`, absent — D75 family, parked). The D68 fixup and the `GE_IMGT` avenue in the brief are both dead ends here. | **FIX PROPOSED, NOT APPLIED** (`front.c` is owned by another agent this session). One-line `#ifdef PORT` in `constructor_menu00_legalscreen`: `legal_text_end = legalpage_text_array + ARRAY_COUNT(legalpage_text_array);` (N64 `&legalscreen_MRD` cast under `#else`). Same linker-layout class as the D75 loop-bound / D142 enum family. Confidence: **high** — mechanism confirmed by symbol addresses. |
| D164 | **Disclaimer / legal screen only draws the first line (D76 root cause, M-31).** — full `## D164` entry at file tail | **FIX PROPOSED, NOT APPLIED** (`front.c` is owned by another agent this session).… |
| D159 | **Front-end wallet-Bond photo (and other large front-end I4/I8/IA8 textures) render "interlaced" / combed (M-31, user bug RC1 / D149).** The user's menu showed a garbled venetian-blind Bond photo on the folder / mode-select screen; earlier the same model DL walked into garbage (`D146` opcode spam) — that crash is already contained by the D144/D146 guards, so what's left is purely the texture. Root-caused with `GE_DTEX`: the photo is a **65×65 I4** (also seen: 65×65 I8, 95×32 IA8) with a full mip chain; `import_texture_i4` uploads it `line_size_bytes*2` (= padded stride) wide and `size_bytes/line` tall — geometry fine. The comb is **`texSwapAltRowBytes` (`src/game/image.c:2191`)**: it pre-swaps every **odd** texture row in 8-byte (one-`u32`) groups to compensate for the N64 RDP's odd-line TMEM address XOR (bit 2) during 4-byte-word `gDPLoadBlock` loads. fast3d does **not** emulate that XOR (no odd-row handling anywhere in `gfx_pc.cpp`), so the pre-swapped odd rows stay scrambled in the uploaded GL image. It's called on *every* level of *every* texture on *every* path (§4), but only bites where the texture is large enough and viewed 1:1 to see the 8-texel comb — glaring on the ~big front-end photos, ~invisible on small HUD glyphs / distant in-level surfaces (which is why it was written off as "front-end model" work for many sessions). Same class as the K0-fold / interrupt-mask hardware-quirk shims — belongs in `port/`, not game logic. | **FIXED** (`src/game/image.c`, `#ifdef PORT` early-`return` in `texSwapAltRowBytes` — fast3d wants a plain linear image; N64 body kept verbatim under it). Verified: wallet-Bond photo renders as a clean recognisable grayscale portrait (still 180°-rotated — D75 model-transform family, parked); `-level_09` frame 200 coverage 91.62 vs 91.66 golden (D117 noise) and a visual frame is clean (tiled floor / monitors / world-map screen all correct). Confidence: **high** (GE_DTEX + before/after screenshots). The residual over-tall-mip (RC2) and non-PoT wrap (RC3) issues are separate and still parked; this only removes the row-swap. porting-notes.md §C. |
| D159 | **Front-end wallet-Bond photo (and other large front-end I4/I8/IA8 textures) render "interlaced" / combed (M-31, user bug RC1 / D149).** — full `## D159` entry at file tail | **FIXED** (`src/game/image.c`, `#ifdef PORT` early-`return` in `texSwapAltRowBytes` —… |
| D165 | **Front-end menu cursor doesn't feel like a mouse pointer (M-31, user bug B5 follow-up).** M-30 added a "menu pointer mode" that fed mouse *velocity* into the stick axes, but `front.c frontUpdateControlStickPosition` *integrates* the stick as a velocity into a screen-pixel cursor position (`cursor_h_pos += (stickx*0.075 ± 0.5) * g_GlobalTimerDelta`, ±5 deadzone, ±70 clamp, cursor clamped into the ~320×240 virtual rect − 20px margin). Velocity-in → velocity² cursor. **Fix (port-only, no `front.c` edit):** `input.c` now runs a P-controller — it keeps its own estimate `menuEst{H,V}` of where the game cursor is, integrated each poll with the *exact same recurrence* as `frontUpdateControlStickPosition` (`menuCursorStep()`), accumulates a `menuTgt{H,V}` target from raw mouse motion (clamped to the same rect), and emits `stick = clamp(MENU_P_GAIN·(target−est), ±70)`. `MENU_P_GAIN = 6.0` → per-poll error decay ≈ 0.45 (`0.075·6`), so no overshoot and ~8-poll settle; stick falls under the game's ±5 deadzone within ~1px of target so the cursor parks with zero jitter/drift. The estimate re-syncs to the true cursor whenever the target is held at a screen edge (both clamp). `Input.MenuPointerSpeed` scales the mouse→target mapping (bump toward 150–200 if the sweep feels short — `est` steps once per controller poll ≈ 2×/rendered-frame vs `front.c`'s 1×/frame, a rate mismatch absorbed by this knob). `Input.MenuPointerMode` (0 = old velocity, 1 = pointer, **default 1**) is the escape hatch. `GE_INPUTLOG` prints an `menuptr est=/tgt=/eff=/stick=` trace line for tuning. With no mouse motion `target==est==centre` → `eff=0` → emitted stick unchanged → prior behaviour exactly. | FIXED (`port/src/input.c`; `Input.MenuPointerMode`, `Input.HipfirePitchSpeed` added). Build green, `-level_09` + `GE_STARTMENU=6` smoke crash-free. Feel-check owed (headless can't exercise the mouse). |
| D166 | **Hipfire mouse pitch is digital while yaw is analog — inconsistent feel (user bug B7 / D118a, "cheaper alternative").** In hipfire GE takes yaw from the analog stick-X but pitch only from digital C-up/C-down, so `input.c` emitted one C-button per poll once `|mouseΔY|` crossed a fixed threshold — an on/off step. **Fix (port-only):** emit the C-button as a *duty-cycled pulse* whose firing frequency scales with mouse-Y speed — `duty = |dyLook|·(HipfirePitchSpeed/100)/HIP_PITCH_FULL` (clamped ≤1), phase-accumulate, fire when the phase wraps. Fast mouse → `duty≈1` → C-button every poll (solid hold, as before); slow mouse → sparse taps proportional to speed. `HIP_PITCH_FULL = 6.0` px/poll for a solid hold; `Input.HipfirePitchSpeed` (10–500, default 100) tunes it. Aim-mode (RMB, already fully analog) untouched. No mouse motion → no pulse → prior behaviour. | FIXED (`port/src/input.c`). SMALL-FIXES B7. **M-87: likely SUPERSEDABLE — see the "SOLITAIRE natural-pitch" note below.** This pulse hack was necessary because the port forces a control scheme (HONEY, the 1.1 default) whose hipfire pitch is digital-only. GE's own **SOLITAIRE (1.2) / GOODNIGHT (1.4)** schemes don't have that constraint at all (see D194) — under those, the *entire* primary analog stick is free for continuous, unclamped pitch+yaw ("natural pitch/turn"), because movement is redirected onto digital C-buttons instead. If the port switches its forced `controldef` to SOLITAIRE/GOODNIGHT (also the right move for D223's wheel-cycling design and D194's mouse-feel ask), this whole digital-pulse emulation becomes unnecessary — mouse-Y would drive the stick's Y axis directly and continuously, same as X already does, with **zero digital-button emulation for pitch at all**. Don't remove D166's code without first confirming the scheme switch; keep as a fallback for whichever legacy path still needs it. |
| D165 | **Front-end menu cursor doesn't feel like a mouse pointer (M-31, user bug B5 follow-up).** — full `## D165` entry at file tail | FIXED (`port/src/input.c`; `Input.MenuPointerMode`, `Input.HipfirePitchSpeed` added). Build green, `-level_09` + `GE_STARTMENU=6` smoke crash-free. Feel-check owed (headless can't exercise the mouse). |
| D166 | **Hipfire mouse pitch is digital while yaw is analog — inconsistent feel (user bug B7 / D118a, "cheaper alternative").** — full `## D166` entry at file tail | FIXED (`port/src/input.c`).… |
| D233 | **All levels (intermittent): indoor level geometry invisible near doors — you can see through walls/floors into rooms you can't see into, while world objects (terminals, furniture) in the room still draw (M-106, user QA report; screenshots `docs/img/bugs/20260912-cullingbug-bugged.png` vs `-notbugged.png`).** The geometry-vs-object split localises it to room/portal background visibility or frustum culling in fast3d, not textures/models. Logged in `GRAPHICS-BACKLOG.md`. **Duplicate row — see the full root-cause writeup above (`gfx_sp_tri1` w<0 trivial-reject bug, `port/fast3d/gfx_pc.cpp:1455-1460`).** | **FIXED + VISUALLY VERIFIED (M-107 fix, M-108 verify).** See the other D233 row for full detail. |
| D234 | **Dam: guard towers along the dam structure invisible except the first one (M-106, user QA report).** Repeated instances → per-instance transform decode, shared model/texture resource bound only by the first instance, or cull-volume issue. Same neighbourhood as D233. Logged in `GRAPHICS-BACKLOG.md`. **Duplicate row — see the full entry above for the M-107 investigation + M-108 verification.** | **VISUALLY VERIFIED FIXED (M-108), mechanism unexplained.** See the other D234 row for detail. |
| D235 | **Wrong textures load after dying/restarting a level — not Facility-specific, seen on multiple levels (M-106, user QA report; clarified same session).** Original report ("Facility: wrong textures now") read as a build regression; user clarified the actual trigger is **death/restart** (or a similar level-reload path), and it has recurred on other levels too — this is a state-leak on level re-entry, not a one-level texture-decode bug and likely not caused by #47/#49 at all. Prime suspect: the texture pool / CI-cache (D217/D228 neighbourhood) not being fully reset between a level teardown and its reload — a stale arena base, a leftover palette-cache entry keyed by an address that gets reused with different content, or a texpool index not rewound — surfacing as "wrong texture" only after the reload path runs, never on a fresh level boot. Logged in `GRAPHICS-BACKLOG.md`. | OPEN — needs: does it repro on a *fresh* level boot (rules out reload-state) vs only after death/restart (confirms it); which surfaces; a screenshot. **M-108 static-analysis pass: the leading "stale CI-cache not reset on restart" hypothesis is likely REFUTED, don't re-propose it as-is.** The theory was that `g_TexCacheItems`/`g_TexCacheCount` (`src/game/image.c:38-40`, cleared only in `set_mt_tex_alloc()`, `src/game/initmttex.c:8`) survives a death/restart because the restart path skips `set_mt_tex_alloc`. But tracing the actual call graph: `set_mt_tex_alloc()` runs inside `lvlStageLoad()` (`src/game/lv.c:369`), which has exactly **one** call site (`src/boss.c:485`) inside `bossMainloop`'s outer `while (!done)` loop; that loop re-runs on *every* stage transition, driven by `g_StageNum = g_MainStageNum` (`boss.c:693`) which is set by `bossSetLoadedStage()` (`boss.c:722`) — the same function the front-end's level-select/continue path calls (`front.c:6947`). Nothing in this call graph is level-select-specific vs. restart-specific; both look like they go through the identical full reload (including the `set_mt_tex_alloc` cache reset). Have not yet found the actual death/restart trigger's call site to be 100% certain it doesn't have its own bypass, so this isn't fully closed out — but the single-reload-path structure makes the "cache survives restart" mechanism unlikely as stated. Next: find the actual death-handler call site (search for where a life/continue/retry decision is made, likely `chr.c`/a game-state machine, not yet located) and confirm whether it calls `bossSetLoadedStage`/goes through `bossMainloop`'s reload path same as normal transitions, or has a distinct lighter-weight path — if it's the same path, the real D235 mechanism is still unknown and this CI-cache theory should be dropped. |
@@ -10296,3 +10296,93 @@ crash class C2 (Facility `-level_34`, Runway `-level_35`): `import_texture_i8` A
**Status (from §F table):** resolved — stage all 6 N64 fields in a local `f[6]` before writing any. Facility + Runway now PASS (16→18/21). Probes reverted.
## D152+ — D152 addendum (M-31):
D152 addendum (M-31): static audit of every compiled-audio `osSetIntMask(OS_IM_NONE)` — **all balanced**, no unbalanced early-return. Real fixes: `sndSetSfxSlotVolume` now holds the mask across its list walk (matches its twin `sndDeactivateAllSfxByFlag`) + `sndApplyVolumeAllSfxSlot` batches the slot loop under one recursive hold (kills the fade-out lock-acquire storm); `portThreadWrapper` calls `imThreadExitRelease()` on thread exit (kills the "transient thread acquired `OS_IM_NONE` and died" leak + the pthread-id-reuse re-wedge). Steal-lock kept as backstop.
**Status (from §F table):** FIXED (`src/snd.c`, `port/src/libultra.c`, `#ifdef PORT`) — fade-out repro playtest-gated, not headless-verified
## D168 — `GE_PCDUMP` / F12 PPM captures were vertically flipped — the entire source of the bogus D114/D116 "HUD/text X-mirror" (M-33, developer-confirmed).
**`GE_PCDUMP` / F12 PPM captures were vertically flipped — the entire source of the bogus D114/D116 "HUD/text X-mirror" (M-33, developer-confirmed).** `gfx_opengl_dump_bound_fbo` (`port/fast3d/gfx_opengl.cpp`) wrote `glReadPixels` output straight to a P6 PPM. GL framebuffer origin is bottom-left; PPM P6 is top-row-first — so every capture (and the `tools_pc/golden/` set, and every screenshot pasted into the finding log since M-6) was upside-down. On real hardware / an actual screen the game renders correctly (developer confirmed). The successive D114→D116 "shared fast3d mirror" investigations — each of which found *every probed stage clean yet the output "mirrored"* — were reading an inverted capture and pattern-matching upside-down asymmetric content (text, guards, the Nintendo logo) as "mirrored". Fix: emit PPM rows bottom-to-top. `tools_pc/golden/*.png` flipped in place to match (see `tools_pc/golden/README.md`); regenerate from a real run when convenient. **Not runtime-verified this session** (no ROM / toolchain in the migrated tree — see the migration note) — needs a fresh capture to confirm text reads normally. Confidence: **high** (mechanism is unambiguous; developer has hardware confirmation the screen is correct). porting-notes.md §D2.
**Status (from §F table):** FIXED (`port/fast3d/gfx_opengl.cpp`); D114 / D116 reclassified as capture-orientation artifacts (below).
## D157 — Campaign never unlocks the next level (M-30, user bug — "complete Dam on Agent, Facility stays locked, no save").
**Campaign never unlocks the next level (M-30, user bug — "complete Dam on Agent, Facility stays locked, no save").** The type-23 objective record's `MinDificulty` is a big-endian `s32` at word 3; on N64 the 0–3 value sits in its low byte == offset `0xF`, and `struct objective_entry.difficulty` (`bondtypes.h`) reads `s8` @0xF. `d88_propdefs.py` `_bswap32`'s that word (correct — it IS a 32-bit field), moving the value to offset `0xC` on LE → the `s8`@0xF read yields **0 for every objective**. `get_difficulty_for_objective()` → Agent(0) for all → `objectiveIsAllComplete()` on Agent evaluates objectives that should be difficulty-gated out (Dam "Neutralize all alarms" = Secret Agent, "Install covert modem" = 00 Agent) → they're never complete → `bossReturnTitleStage` sees `objectiveIsAllComplete=0` → `end_of_mission_briefing()` (the `fileUnlockStageInFolderAtDifficulty` → EEPROM write) never runs. The D151 sibling — same struct, the field D151 said it "left unchanged". Confirmed via GE_SAVELOG playtest + a direct ROM dump of Dam's records (word 3 = 1/2/2/0).
**Status (from §F table):** FIXED (`src/bondtypes.h`, `#ifdef PORT` — reorder `objective_entry`'s post-swap tail so `difficulty` reads @0xC, `unkD` @0xE; N64 layout under `#else`; sizeof unchanged 16). Also fixed `tools_pc/dump_objectives.py` (same byte-offset bug — read `buf[off+12]` = BE high byte = always 0 → showed "Agent" for every objective) + regenerated `docs/dev/LEVEL-OBJECTIVES.md`. **User-confirmed:** Dam Agent → `objdiff=1/2/2/0` → `objectiveIsAllComplete=1` → `fileWriteSave` with the completion time; Agent checkmark shows in mission-select and persists across kill+restart. New `GE_SAVELOG=1` / `GE_UNLOCK_ALL=1` `#ifdef PORT` env diagnostics added along the way. 21/21 sweep PASS. porting-notes.md §C.
## D160 — Dam level-end cutscene (Bond off the dam / bungee) never plays — cuts straight to the MISSION COMPLETE report (M-30/M-31, user bug D148).
**Dam level-end cutscene (Bond off the dam / bungee) never plays — cuts straight to the MISSION COMPLETE report (M-30/M-31, user bug D148).** Progression is unaffected: `bossReturnTitleStage` fires, the report + next briefing load, EEPROM save lands (D157). So the *transition* works; only the scripted cutscene step is skipped or silently fails. Static trace: Dam's end sequence is `UsetupdamZ` ailist `ai_24` (id 0x1004, gated `if_bond_in_room_with_pad(0x4a01)` — Bond reaches the exit) → lock controls, `bond_set_locked_velocity` (auto-walk off edge), wait `if_bond_y_pos_less_than` (Bond falling) → fade → `if_objective_all_completed` branch → `camera_switch`/`jump_to_ai_list` into the abseil ailist `ai_17` (id 0x0412: `screen_fade_from_black`, `music_xtrack_play`, `guard_play_animation(0xb100…)` = rappel anim on a puppet chr, three `camera_switch` w/ fades, `exit_level`). `camera_switch(TAG,…)` resolves a `CutsceneRecord` (propDef type 46, records 322/324/326 paired with Tags 10/11/12) via `tagGetCommandIndex` + `setupGetPtrToCommandByIndex` (a **command-index count walk over the propDef stream** using `sizepropdef()`). CutsceneRecord itself is all-scalar 28 B, correctly converted (`d88_propdefs.py` 46→28). **Leading hypotheses, unverified:** (a) the index-count walk desyncs because some earlier propDef type Dam emits (Vehicle 321 — D122 "medium confidence" tail; TintedGlass) has a wrong PC `sizepropdef` stride → `camera_switch` gets a bogus `cdef`/`TagIndex<0` → cutscene silently skipped, `ai_17` falls through to `exit_level`; (b) the trigger chr for `ai_24`/`ai_25` isn't spawned / `if_bond_in_room_with_pad` never true and the level ends via a different path; (c) D75/anim-model family — the puppet `guard_play_animation` rappel anim doesn't render (third-person Bond model absent, like the gun-barrel/cast models) so the cutscene "plays" invisibly but the user perceives it as skipped (less likely — user reports an instant cut, not a blank pause).
**Status (from §F table):** DIAGNOSTIC SHIPPED, not root-caused (`c95713f5`, `#ifdef PORT` + `GE_D160=1`): trace points in `bossReturnTitleStage` (entry + caller), `bondviewSetCameraMode` (mode/stage/`g_IntroSwirl`/intro-anim-idx/caller), and AI cmds `EndLevel`/`exit_level`, `CameraLookAtBondFromPad`, `CameraSwitch` (prints `cdef` ptr / `TagIndex<0` / tag `NOT FOUND`). **Next step:** user plays Dam to the exit with `GE_D160=1` (via `debug.ps1`); the trace answers (1) does `CameraSwitch`/`CameraLookAtBondFromPad` execute at all → trigger fires? (2) if yes, is `cdef` a sane pointer → propDef-walk / converter bug (a), same family as D122/D132/D157; (3) if the cam cmds never run → ai_24 trigger / chr-spawn bug (b). If it's (c), `bondviewSetCameraMode` shows POSEND being set + control returns normally. This is likely a WRITE-UP-then-fix (propDef stride) rather than a hack. Confidence in hypothesis (a): medium. **M-106 static re-audit (no build/runtime access this session — see note below): hypothesis (a) independently RE-CONFIRMED RULED OUT.** `CutsceneRecord` (`src/bondtypes.h:3785-3792`, propDef type 46) is **all-scalar** — `PropDefHeaderRecord` (4B header) + `coord3d pos` (12B, 3×f32) + `theta`/`verta`/`pad` (4B each) = 28B exactly, zero pointer/union fields, so it cannot exhibit the D122/D126/D132/D209 pointer-width-growth pattern at all (nothing to widen); this matches `porting-notes.md`'s already-recorded M-31 conclusion (`§ "D160 — a propDef command-index walk desync..."`) that the stride cross-check is clean for all 48 types. Traced the `AI_CameraSwitch` path end-to-end (`chrai.c:3990-4013`): `tagGetCommandIndex`/`setupGetPtrToCommandByIndex` resolve `cdef`, then `gBondViewCutscene = cdef` and `bondviewSetCameraMode(CAMERAMODE_POSEND)`; the render side (`bondview2.c:1558-1590`) reads `gBondViewCutscene->pad`/`pos`/`theta`/`verta` directly off that pointer with no intermediate copy/struct-punning step that could introduce a *new* aliasing bug — this leg of the pipeline is clean. **Did not get far on hypotheses (b)/(c)** (AI trigger-fire vs invisible-puppet-render) — no interactive session / no build toolchain available in this worktree (`gcc`/`cmake` not on `PATH`; MSYS2 MINGW64 environment not active), so the `GE_D160=1` Dam-to-exit playthrough this row has needed since M-31 still has not been driven. **Confidence update: hypothesis (a) — HIGH (ruled out, independently re-derived, not just cited); (b)/(c) — unchanged, still need the live trace.** No code changes made (nothing to fix once (a) is ruled out); this is a documentation-only confirmation pass. See also D173 below — if the Cradle "Bond floating/spinning" symptom filed under this label is the puppet-render family (D75/D156), closing D173 first may explain the residual `#4 GOTO` here too. **M-107 — the live trace finally happened; (b)/(c) both REFUTED, real root cause moved to D243.** `GE_D160=1` on `-level_33` (Dam) plus a fresh-save normal playthrough all show the AI trigger firing and all three `camera_switch` commands executing (`bondviewSetCameraMode(7)` × 2-3, then `bossReturnTitleStage`) — the cutscene is NOT skipped and the puppet is NOT invisible; it renders, but corrupted (violent camera-shake artifact during each angle cut). Root-caused no further than "stale/freed display-list memory read, likely a render-pipeline race" — full writeup and evidence under **D243**, which now owns this bug going forward. This row's own title ("cutscene never plays") is superseded; kept for history.
## D161 — Depot (`-level_30`) ceiling renders as bright-blue speckle + radial "light-ray" streaks converging to a point instead of a dark corrugated roof (M-30/M-31…
**Depot (`-level_30`) ceiling renders as bright-blue speckle + radial "light-ray" streaks converging to a point instead of a dark corrugated roof (M-30/M-31, user bug B2 — `docs/dev/TEXTURE-GLITCH-ANALYSIS.md`).** NOT filtering (3-point+trilinear+aniso didn't touch it), NOT RC2 mip-contamination, NOT the RGBA5551 decode. Root-caused with a `GE_TEXDUMP` PPM+param dump (`gfx_opengl.cpp` / `gfx_pc.cpp`): the offending surface is **one texture, `fmt=CI siz=8b, 16×16`**, uploaded with **`rdp.palette_fmt == G_TT_NONE` (0)** while every other CI texture that frame has `palfmt=0x8000` (`G_TT_RGBA16`). GE draws the Depot roof with the TLUT **disabled** (`gsDPSetTextureLUT(G_TT_NONE)` — a legit N64 idiom: a CI-siz tile with no LUT feeds the raw 8-bit TMEM texel straight into the colour pipe, i.e. it behaves as a plain **I8** texture). fast3d's `import_texture()` ignored `palette_fmt` and always did the palette lookup for `fmt==CI`, indexing a **stale `rdp.palette`** left over from a previous texture → the 16×16 image came out as pure blue/cyan/magenta noise; the "radial rays" were that high-frequency noise aliasing on the steeply-receding ceiling plane (which is also why filtering couldn't help — the data was garbage). Fix: in `import_texture()`, `fmt_eff = (fmt==G_IM_FMT_CI && rdp.palette_fmt==G_TT_NONE) ? G_IM_FMT_I : fmt`, and dispatch on `fmt_eff` (CI4→I4, CI8→I8). Narrow — only changes surfaces that are currently 100% garbage. Verified: Depot corridor + control-room ceilings render as clean dark/grey industrial roofs; `-level_09`/`-level_30`/`-level_34` sweep PASS, unregressed. `GE_TEXDUMP` (env-gated PPM dump of every uploaded texture + a `fmt/siz/palfmt/palidx` log line) kept as an inert diagnostic. Confidence: **high** (before/after screenshots + the palfmt=0 evidence).
**Status (from §F table):** FIXED (`port/fast3d/gfx_pc.cpp` `import_texture`, `port/fast3d/gfx_opengl.cpp` diag). porting-notes.md §D.
## D152 — Mission-failed / objective-failed → permanent black screen (M-28, user bug report — LIVE process inspected, not yet fixed).
**Mission-failed / objective-failed → permanent black screen (M-28, user bug report — LIVE process inspected, not yet fixed).** User killed Trevelyan in Facility (Ctrl = fire, no crouch bind) → failed the objective → screen faded to black and never came back; process stayed alive under `debug.ps1`. `gdb.txt` (kept running): rendering stops dead at frame 12600 (`frames=12657` frozen across 206 heartbeat dumps / ~7 real min), VI pacemaker still posts. **Deadlock on `s_imLock`** — the D147 recursive-mutex that backs `osSetIntMask`. Both consumer threads are blocked in `pthread_mutex_lock(&s_imLock)` and neither owns it: `mainThread` = `sndSetScalerApplyVolumeAllSfxSlot` → `sndApplyVolumeAllSfxSlot` → `sndSetSfxSlotVolume` → `alEvtqPostEvent` (`event.c:82`) → `osSetIntMask(OS_IM_NONE)` (`libultra.c:1232`); `amMain`/audioThread = `amHandleFrameMessage` → `alAudioFrame` → `sndPlayerVoiceHandler` → `alEvtqNextEvent` (`event.c:47`) → `osSetIntMask(OS_IM_NONE)`. `sndSetScalerApplyVolumeAllSfxSlot` is the **mission-failed audio fade-out** (ramps `g_sndSfxVolumeScale` → 0 each frame, `AL_SNDP_RELEASE_EVT` posted per active `ALSoundState` per frame) — so the fade-to-black *is* the intended fail transition, and it hammers the event queue hard enough that the `osSetIntMask` lock is left owned by a party not in the 3-thread dump (a leaked unbalanced `OS_IM_NONE` — libaudio has early-return mask paths — or a transient thread that acquired it and exited; `gdb.txt` shows `New Thread`/`exited` churn every few k frames). D147's "the decomp never blocks while holding OS_IM_NONE → cannot deadlock" assumption is **falsified** by this path. Same family as D147 / porting-notes.md §D4.
**Status (from §F table):** MITIGATED (`port/src/libultra.c`, M-28, `#ifdef PORT`/port-only). `osSetIntMask` is now a **self-healing** logical lock: `OS_IM_NONE`/`OS_IM_ALL` acquire/release a process-wide recursive critical section tracked by `s_imHeld`/`s_imOwner`/`s_imDepth` under a short bookkeeping mutex `s_imMx` + condvar. Normal (balanced, microsecond) audio critical sections behave exactly as the D147 recursive mutex did. A waiter that blocks **> 2 s** (`OS_IM_STUCK_NS`) concludes the holder leaked it, logs `LOG_ERROR` with the stale `s_imOwner` + the stealing caller's return address, and **steals** the section — so a leaked/unbalanced `OS_IM_NONE` (or an acquire-and-exit transient thread) can no longer wedge the game forever; worst case is a ~2 s audio hiccup + a log line naming the leak. Builds clean. Still OPEN: the exact leaking call site (find it from the `D152: ... stealing from owner=` log next time it fires) and the proper narrow fix (dedicated `ALEventQueue` lock, or restore-on-thread-exit). Regression check deferred — `-level_09`/`-level_20` both flaky under the current machine load (D117/D134), and clean `master` itself has a separate nondeterministic `-level_09` frame ~900-1400 `0xc0000005` (see below).
## D152+ — D152 addendum — static audit + narrow fix (M-31).
**D152 addendum — static audit + narrow fix (M-31).** Audited every `osSetIntMask(OS_IM_NONE)` in the *compiled* audio code (`event.c` `alEvtqNextEvent`/`alEvtqPostEvent`/`alEvtqFlush`/`alEvtqFlushType`, `csplayer.c` `__CSPRepostEvent`, `synaddplayer.c` `alSynAddPlayer`, `snd.c` `sndRemoveEvents`/`sndSetupSound`/`sndDeactivateAllSfxByFlag`): **all balanced on every path** — `alEvtqPostEvent`'s lone early `return` (`event.c:86`, freeList empty) restores the mask first. The §F D152 guess "libaudio has unbalanced early-return mask paths" is **not** borne out. Two real defects found + fixed `#ifdef PORT` (N64 verbatim `#else`): **(1)** `sndSetSfxSlotVolume` (`snd.c`) walks the live `ALSoundState` list and posts `AL_SNDP_RELEASE_EVT` to the shared `ALEventQueue` **without holding `OS_IM_NONE`** — unlike its structural twin `sndDeactivateAllSfxByFlag`, which wraps its identical walk. On PC that's an unguarded walk racing `amMain` *and* one lock acquire/release per matching sound; the mission-failed fade (`sndSetScalerApplyVolumeAllSfxSlot`→`sndApplyVolumeAllSfxSlot`→ this, every frame, every active sound) turns it into a per-frame lock-acquire storm on `mainThread` = exactly the §F dump's "hammers the event queue hard enough that the lock is left owned." Fix: hold `OS_IM_NONE` once across the whole walk (nested `alEvtqPostEvent` hits the recursive depth++ fast path, no new contention window); also wrap `sndApplyVolumeAllSfxSlot`'s slot loop so the whole volume update is one recursive hold (1 real acquire/frame instead of `SFX_SLOT_COUNT × active-sounds`). **(2)** The actual leak matches the §F dump's *other* hypothesis — "a transient thread acquired `OS_IM_NONE` and exited" (`gdb.txt` shows `New Thread`/`exited` churn). `port/src/libultra.c` `portThreadWrapper` now calls new `imThreadExitRelease()` after the thread entry returns: if that thread still owns `s_imHeld`, release the orphan immediately (+ `LOG_ERROR` naming it). This removes the wedge *and* the ~2 s steal hitch, and stops the **re-wedge** that occurs when a new host thread reuses the dead thread's pthread id and `imAcquire` mis-detects recursion (`s_imOwner == self`). The M-28 steal-lock stays as the last-resort backstop for a genuinely unbalanced *same-thread* path (none found, but cheap insurance). Build green (240/240); `-level_09` frame 300 renders 91.7% non-clear, crash-free; `-level_20` boots crash-free (frame-count flaky under load per D117/D134, no `D152:` log). Fade-out repro is **playtest-gated** — not reproducible headless, so not end-to-end verified; user should replay the mission-fail and confirm no black screen + no `D152:` steal/orphan log lines.
**Status (from §F table):** FIXED (`src/snd.c`, `port/src/libultra.c`, `#ifdef PORT`). porting-notes.md §D4.
## D154 — `bg.c` room-geometry ray/hit-test GBI parser port (M-28, WRITTEN / UNVERIFIED / UNCOMMITTED).
**`bg.c` room-geometry ray/hit-test GBI parser port (M-28, WRITTEN / UNVERIFIED / UNCOMMITTED).** `bgTestRayIntersectionInRoom` (`bg.c:3331`, called by `bgTestBulletHitBackground` on every shot that resolves against background geometry) is the D135 sibling flagged since M-23: raw `((u8*)gdl)[k]` / `((u32*)gdl)[i]` byte/word indexing into what is now the 16-byte PC `Gfx` room DL (D85 `bgWidenRoomGdl` + `texLoadFromGdl`) → desync → OOB `vtxbase[idx]` / walk-off AV the first time you shoot a wall or floor. Ported `#ifdef PORT` (N64 verbatim under `#else`): `vtxoff`/`vtxbase`/`op` via the `.dma` view (matches the already-ported `bgBuildRoomVtxBounds`); G_TRI1 indices from `(w1>>16/8/0)&0xff /10`; the 12 G_TRI4 nibble extractions from `(u32)gdl->words.w0/.w1` (each mapped from the N64 byte/halfword layout — table in the code comment, cross-checked twice); both `texturenum` recoveries → `-1` (the KSEG0 `*(u16*)(w1-8)` deref is invalid for the port's converted GDLs, same as D135; texnum only picks the impact decal/sound, parked D77). Builds clean. **Verification blocked** — a no-input `GE_PCDUMP` capture never fires a bullet so it never calls this function; needs a real firefight into a wall on an idle machine (this session's box was thrashed → D153/driver crash on every run).
**Status (from §F table):** **M-30 re-audit + fixes (still playtest-gated for final verify).** Cross-checked the ported G_TRI1 + all 12 G_TRI4 nibble recoveries twice against the N64 `#else` and the raw BE-word bit derivation — **G_TRI1 and G_TRI4 are correct**. **Two bugs fixed:** (1) `vtxoff` — the port read `gdl->dma.par & 0xf`, but the PC `Gdma_le` shim (`port/shim/PR/gbi.h`) maps `.par` to bits 0-23 of word0 (the packed *length*, always 16-aligned) not the N64 byte-1 params field at bits 16-23, so vtxoff was silently forced to 0 (breaks any G_VTX batch with `v0 != 0`, i.e. multi-batch rooms → wrong `vtxbase[idx]` → OOB). Now `((u32)gdl->words.w0 >> 16) & 0xf`. (2) **Sibling ported** — `bgTestBulletHitBackground` tail (`bg.c:~3841`, the post-hit G_SETTILE back-scan for `tileformat`/`tilesize`) was itself an unported raw `((u8*)gdl)[0]`/`[1]` + 64-bit `words.w0 << 11 >> 30` parser over the same widened DL; wrapped `#ifdef PORT` (opcode from bits 24-31, byte1 from bits 16-23, `(u32)`-forced 32-bit width for the tilesize shift). **Diagnostic:** `GE_D154=1` → `osSyncPrintf` of the parsed cmd/vtx-index stream (call header + per-TRI1/TRI4 `idx`) for the first 64 invocations, for the user/integrator to eyeball. Build green (`ntsc-final`, 242/242). Runtime unverifiable in a worktree (no `data/`); still needs a firefight-into-wall on an idle machine + `-level_09` framediff. Confidence: HIGH on the mechanical correctness (bit-for-bit vs N64), MEDIUM that nothing else in the walk still bites. porting-notes.md §B (D135 corollary).
## D156 — Facility outro-cutscene "hang" — the actual fix (M-29, user bug report, 2nd occurrence after D155).
**Facility outro-cutscene "hang" — the actual fix (M-29, user bug report, 2nd occurrence after D155).** After D155 shipped the user re-ran the playthrough and it hung again at the end of Facility — same stack (`modelSetAnimFrame2WithChrStuff` ← `modelTickAnim` ← `chrUpdateAnim` ← `chrTick` ← `playerTick` ← `propsTick` ← `lvlRender` ← `bossMainloop`) but this time **`frames=12962` frozen CONSTANT across ~200 heartbeat dumps / minutes** — a true infinite loop, not the D155 slow-catch-up spiral. Root: the `while (1)` at `model.c:3131` in `modelSetAnimFrame2WithChrStuff` steps the root-motion accumulation one anim frame at a time from `framea` (`model->animframe1`) to `frameb` (`frame`, accumulated in `modelTickAnim` as `frame += playspeed * speed` per tick). If `frameb` arrives **NaN or blown up** — a cutscene anim whose blend/transition math produces a huge/non-finite `model->speed`/`playspeed`/`timespeed` (`model.c:3436` `/ model->unkb0`, `:3477` `/ model->timespeed`, both guarded `> 0` but not against tiny values) — then `floorFloatToInt(frameb)` yields garbage, `endframe - curframe` ≈ 2^31, and the loop (each iteration: `sub_GAME_7F06D3F4` joint transform + `cosf`/`sinf` + `modelConstrainOrWrapAnimFrame`) runs effectively forever. **Likely deeper cause (unconfirmed, needs the repro):** these speed/blend fields are `Model` struct members; if this model is punned/inline (D100/D140 family) and `sizeof(Model)` growth misaligned them, they read garbage exactly when a cutscene sets an unusual anim transition.
**Status (from §F table):** GUARDED (`src/game/model.c`, `#ifdef PORT`): (1) in `modelSetAnimFrame2WithChrStuff`, if `frameb` is non-finite or `|frameb| >= 1e6` snap it to `framea` (loop then does ~0 iterations; the `modelConstrainOrWrapAnimFrame` at :3211 still clamps `model->frameb` to a valid frame); (2) in `modelTickAnim` after the tick loop, if `frame`/`frame2` went non-finite/absurd fall back to the pre-loop `model->animframe1`/`animframe2` so the NaN can't poison model state every frame. `!(x > -1e6f && x < 1e6f)` catches NaN too. Anim frames are 0..few-hundred so 1e6 is a safe ceiling. Verified: build green; `-level_09` frame 200 unregressed (guards never fire in normal play). **User to re-verify the Facility outro.** If it still hangs, the NaN source is upstream — dump `model->speed/playspeed/timespeed/unkb0` in `modelTickAnim` during the cutscene. porting-notes.md §E.
## D155 — Facility outro-cutscene end → "hang" = unclamped `deltaFrames` spiral (M-29, user bug report).
**Facility outro-cutscene end → "hang" = unclamped `deltaFrames` spiral (M-29, user bug report).** User watched the Facility outro cutscene; at the end the game froze (process alive, kernel-heartbeat dumps). Symbolicated `mainThread` stack from the port's own hang dump: `modelConstrainOrWrapAnimFrame` ← `modelTickAnim` ← `chrUpdateAnim` ← `chrTick` ← `playerTick` ← `propsTick` ← `lvlRender`, sampled there across *every* heartbeat dump. Root cause: `waitForNextFrame()` (`src/game/frametiming.c`) sets `nextFrameTime = (osGetCount() − prev + 387937) / 775875` and passes it **unclamped** to `updateFrameCounters` as `deltaFrames`. `osGetCount()` is wall-clock on the port (D117), not VI-locked, so a real-time stall — the asset load at the cutscene→debrief boundary, made far worse by the user running a local LLM on the same box — makes `nextFrameTime` balloon to hundreds/thousands. That becomes `speedgraphframes` → `g_ClockTimer`, which drives `modelTickAnim(model, g_ClockTimer, 1)`'s `while (numticks-- > 0)` loop once per character per render, plus dozens of `for (i = 0; i < g_ClockTimer; i++)` sim loops (`bondhead.c`, `bondview2.c`, `explosion.c`, `options.c`…). One render then does thousands of anim ticks × N characters → multi-second frame → heartbeat trips, and the slow frame feeds an even bigger delta next time → unrecoverable spiral. The N64 was physically VI-bound and never produced `deltaFrames > ~2`. Timing-compensation class, cf. D117/D134.
**Status (from §F table):** FIXED (`src/game/frametiming.c`, `#ifdef PORT` — clamp `nextFrameTime` to `FRAMETIMING_PORT_MAX_CATCHUP` = 6). After a hitch the sim just resumes at real-time-ish pace instead of spiralling, exactly as the console did when it dropped frames under load. N64 path verbatim. Confidence: high (stack + arithmetic both point here). porting-notes.md §E.
## D153 — `-level_09` frame ~900–1400 `0xc0000005` = D117/D134 load flakiness, NOT a bug (M-28).
**`-level_09` frame ~900–1400 `0xc0000005` = D117/D134 load flakiness, NOT a bug (M-28).** Reproduced 6/6 while the machine was under heavy concurrent build+run load; on a **quiet** machine `-level_09` ran past frame 2400 with 0 heartbeats, no crash. Same false-positive class the docs warn about every session (M-13..M-22, M-25 Silo ~frame 300 freeze). The crash PC was in a system DLL (a `memcpy` off a buffer) and the frame varied run-to-run — both consistent with the port's host-scheduling jitter under load (`osGetCount` is wall-clock, variable-timestep sim), not a deterministic defect. `romdataFixupMusicSeqTable: seqCount 63 exceeds blob capacity 1` is an EXPECTED benign warning on the header-only first `romdataFixupMusicSeqTable` call (music.c:736; the full-size second call at :742 is silent) — not related. **Lesson: don't regression-test while builds/other runs are in flight.**
**Status (from §F table):** NOT A BUG — closed. Run anchors on a quiet machine.
## D164 — Disclaimer / legal screen only draws the first line (D76 root cause, M-31).
**Disclaimer / legal screen only draws the first line (D76 root cause, M-31).** `constructor_menu00_legalscreen` (`src/game/front.c:1523`) renders the 12-entry `legalpage_text_array[]` with `legal_text_end = (struct legal_screen_text *)&legalscreen_MRD;` then `do { render; ptr++; } while (ptr < legal_text_end);`. This is a **linker-adjacency assumption**: on N64 `.data` the two file-scope globals are emitted in source order so `&legalscreen_MRD == legalpage_text_array + 12` and the loop count is exactly the array length. mingw/GCC reorders them — in `build-pc/ge007.x86_64.exe` `legalscreen_MRD` links at `0x1401305e0`, **0x60 bytes before** `legalpage_text_array` at `0x140130640` (verified via `nm`). So `legal_text_end < legal_text_ptr` from the start; the `do/while` runs the body **once** (entry 0, "TWYCROSS BOARD OF GAME CLASSIFICATION" — long + CENTER_ALIGN so it wraps to ~2 visual lines = the "classification line + one below" the user saw), then exits. The other 11 legal lines never render. NOT a D68 image-table bug — the legal screen references **zero** `sImageTableEntry`s; it is pure `langGet()` font text via `display_aligned_white_text_to_screen`→`textRender`, plus the 3D `logoinst` model (`subdraw`, absent — D75 family, parked). The D68 fixup and the `GE_IMGT` avenue in the brief are both dead ends here.
**Status (from §F table):** **FIX PROPOSED, NOT APPLIED** (`front.c` is owned by another agent this session). One-line `#ifdef PORT` in `constructor_menu00_legalscreen`: `legal_text_end = legalpage_text_array + ARRAY_COUNT(legalpage_text_array);` (N64 `&legalscreen_MRD` cast under `#else`). Same linker-layout class as the D75 loop-bound / D142 enum family. Confidence: **high** — mechanism confirmed by symbol addresses.
## D159 — Front-end wallet-Bond photo (and other large front-end I4/I8/IA8 textures) render "interlaced" / combed (M-31, user bug RC1 / D149).
**Front-end wallet-Bond photo (and other large front-end I4/I8/IA8 textures) render "interlaced" / combed (M-31, user bug RC1 / D149).** The user's menu showed a garbled venetian-blind Bond photo on the folder / mode-select screen; earlier the same model DL walked into garbage (`D146` opcode spam) — that crash is already contained by the D144/D146 guards, so what's left is purely the texture. Root-caused with `GE_DTEX`: the photo is a **65×65 I4** (also seen: 65×65 I8, 95×32 IA8) with a full mip chain; `import_texture_i4` uploads it `line_size_bytes*2` (= padded stride) wide and `size_bytes/line` tall — geometry fine. The comb is **`texSwapAltRowBytes` (`src/game/image.c:2191`)**: it pre-swaps every **odd** texture row in 8-byte (one-`u32`) groups to compensate for the N64 RDP's odd-line TMEM address XOR (bit 2) during 4-byte-word `gDPLoadBlock` loads. fast3d does **not** emulate that XOR (no odd-row handling anywhere in `gfx_pc.cpp`), so the pre-swapped odd rows stay scrambled in the uploaded GL image. It's called on *every* level of *every* texture on *every* path (§4), but only bites where the texture is large enough and viewed 1:1 to see the 8-texel comb — glaring on the ~big front-end photos, ~invisible on small HUD glyphs / distant in-level surfaces (which is why it was written off as "front-end model" work for many sessions). Same class as the K0-fold / interrupt-mask hardware-quirk shims — belongs in `port/`, not game logic.
**Status (from §F table):** **FIXED** (`src/game/image.c`, `#ifdef PORT` early-`return` in `texSwapAltRowBytes` — fast3d wants a plain linear image; N64 body kept verbatim under it). Verified: wallet-Bond photo renders as a clean recognisable grayscale portrait (still 180°-rotated — D75 model-transform family, parked); `-level_09` frame 200 coverage 91.62 vs 91.66 golden (D117 noise) and a visual frame is clean (tiled floor / monitors / world-map screen all correct). Confidence: **high** (GE_DTEX + before/after screenshots). The residual over-tall-mip (RC2) and non-PoT wrap (RC3) issues are separate and still parked; this only removes the row-swap. porting-notes.md §C.
## D165 — Front-end menu cursor doesn't feel like a mouse pointer (M-31, user bug B5 follow-up).
**Front-end menu cursor doesn't feel like a mouse pointer (M-31, user bug B5 follow-up).** M-30 added a "menu pointer mode" that fed mouse *velocity* into the stick axes, but `front.c frontUpdateControlStickPosition` *integrates* the stick as a velocity into a screen-pixel cursor position (`cursor_h_pos += (stickx*0.075 ± 0.5) * g_GlobalTimerDelta`, ±5 deadzone, ±70 clamp, cursor clamped into the ~320×240 virtual rect − 20px margin). Velocity-in → velocity² cursor. **Fix (port-only, no `front.c` edit):** `input.c` now runs a P-controller — it keeps its own estimate `menuEst{H,V}` of where the game cursor is, integrated each poll with the *exact same recurrence* as `frontUpdateControlStickPosition` (`menuCursorStep()`), accumulates a `menuTgt{H,V}` target from raw mouse motion (clamped to the same rect), and emits `stick = clamp(MENU_P_GAIN·(target−est), ±70)`. `MENU_P_GAIN = 6.0` → per-poll error decay ≈ 0.45 (`0.075·6`), so no overshoot and ~8-poll settle; stick falls under the game's ±5 deadzone within ~1px of target so the cursor parks with zero jitter/drift. The estimate re-syncs to the true cursor whenever the target is held at a screen edge (both clamp). `Input.MenuPointerSpeed` scales the mouse→target mapping (bump toward 150–200 if the sweep feels short — `est` steps once per controller poll ≈ 2×/rendered-frame vs `front.c`'s 1×/frame, a rate mismatch absorbed by this knob). `Input.MenuPointerMode` (0 = old velocity, 1 = pointer, **default 1**) is the escape hatch. `GE_INPUTLOG` prints an `menuptr est=/tgt=/eff=/stick=` trace line for tuning. With no mouse motion `target==est==centre` → `eff=0` → emitted stick unchanged → prior behaviour exactly.
**Status (from §F table):** FIXED (`port/src/input.c`; `Input.MenuPointerMode`, `Input.HipfirePitchSpeed` added). Build green, `-level_09` + `GE_STARTMENU=6` smoke crash-free. Feel-check owed (headless can't exercise the mouse).
## D166 — Hipfire mouse pitch is digital while yaw is analog — inconsistent feel (user bug B7 / D118a, "cheaper alternative").
**Hipfire mouse pitch is digital while yaw is analog — inconsistent feel (user bug B7 / D118a, "cheaper alternative").** In hipfire GE takes yaw from the analog stick-X but pitch only from digital C-up/C-down, so `input.c` emitted one C-button per poll once `|mouseΔY|` crossed a fixed threshold — an on/off step. **Fix (port-only):** emit the C-button as a *duty-cycled pulse* whose firing frequency scales with mouse-Y speed — `duty = |dyLook|·(HipfirePitchSpeed/100)/HIP_PITCH_FULL` (clamped ≤1), phase-accumulate, fire when the phase wraps. Fast mouse → `duty≈1` → C-button every poll (solid hold, as before); slow mouse → sparse taps proportional to speed. `HIP_PITCH_FULL = 6.0` px/poll for a solid hold; `Input.HipfirePitchSpeed` (10–500, default 100) tunes it. Aim-mode (RMB, already fully analog) untouched. No mouse motion → no pulse → prior behaviour.
**Status (from §F table):** FIXED (`port/src/input.c`). SMALL-FIXES B7. **M-87: likely SUPERSEDABLE — see the "SOLITAIRE natural-pitch" note below.** This pulse hack was necessary because the port forces a control scheme (HONEY, the 1.1 default) whose hipfire pitch is digital-only. GE's own **SOLITAIRE (1.2) / GOODNIGHT (1.4)** schemes don't have that constraint at all (see D194) — under those, the *entire* primary analog stick is free for continuous, unclamped pitch+yaw ("natural pitch/turn"), because movement is redirected onto digital C-buttons instead. If the port switches its forced `controldef` to SOLITAIRE/GOODNIGHT (also the right move for D223's wheel-cycling design and D194's mouse-feel ask), this whole digital-pulse emulation becomes unnecessary — mouse-Y would drive the stick's Y axis directly and continuously, same as X already does, with **zero digital-button emulation for pitch at all**. Don't remove D166's code without first confirming the scheme switch; keep as a fallback for whichever legacy path still needs it.