diff --git a/docs/dev/findings.md b/docs/dev/findings.md index 9777f308..68e6210b 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -10307,12 +10307,6 @@ 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+ (a) — 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. @@ -10343,12 +10337,6 @@ D152 addendum (M-31): static audit of every compiled-audio `osSetIntMask(OS_IM_N **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+ (b) — 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). @@ -10631,18 +10619,6 @@ D152 addendum (M-31): static audit of every compiled-audio `osSetIntMask(OS_IM_N **Status (from §F table):** **OPEN — static-analysis pass done (M-108), not runtime-confirmed.** Repro: Control (elevator + regular track, throughout), Cavern (elevator, throughout), Runway (~5–7s in). Traced the suspect neighborhood to the software ADPCM decoder in `port/src/mixer.c` — the PC replacement for the RSP's `aLoadADPCM`/`aADPCMdec` ucode ops (`csplayer.c`/`synthesizer.c` only emit ucode command lists; they don't decode samples themselves). Found two hardcoded assumptions that don't hold for the general ADPCM-book format: (1) `sAdpcmTable` (`mixer.c:174`) is declared `[8][2][8]`, hardcoding predictor `order=2`, but `order`/`npredictors` are per-instrument authored fields (`include/PR/libaudio.h` `ALADPCMBook.order`; `src/libultra/audio/load.c:428-429` computes `bookSize` from them) that the bank-relayout code (`port/src/romdata.c:718-736`) already handles generically — if this bass instrument's authored book uses `order != 2`, the 2-tap recursion at `mixer.c:223-240` would decode it wrong; (2) even at `order=2`, `aLoadADPCMImpl` (`mixer.c:177-192`) silently truncates any book over 256 bytes (`mixer.c:183`, `npredictors > 8`), while the decode loop's 4-bit predictor-select nibble (`mixer.c:219`) can legitimately address up to 16 rows — an instrument with `npredictors > 8` would read truncated/garbage rows for some frames. **Caveat weakening confidence:** N64 SDK ADPCM in practice is conventionally always `order=2` (the RSP ucode's own decode algorithm is essentially fixed to a 2-tap recursion industry-wide), so candidate (1) may not apply to a real N64 title's data; candidate (2) (`npredictors > 8`) is the more plausible of the two absent contrary evidence. Not confirmed against GE's actual ROM-authored book data (no live capture taken this pass). Next: extend the existing `GE_MIXERTRACE=1` trace's `[LOADADPCM]` line (`mixer.c:187`, already logs `count` — `count/16 = order*npredictors`) to flag any book where `count > 256` (proves truncation) during a Control run, and correlate the anomalous book to its channel/instrument via `seqplayer.c:1120`'s program-change. If no anomaly shows, this whole neighborhood is likely a dead end and the next suspect is the resample (`mixer.c:295-341`) or envelope/gain (`mixer.c:379+`) paths for bass-range-specific behavior. **M-120 (local-Qwen triage dispatch): a third hypothesis proposed and CHECKED, RULED OUT.** The dispatched read-only pass proposed that `afFixupBook`'s `port/src/romdata.c:733-737` conversion of the ADPCM book coefficients (BE ROM → LE host, via `afRd16`/`afWr16`) is backwards and should stay big-endian. Verified against the actual code and disproven: (1) that conversion is the identical BE→LE pattern used for every other field in the same function (`order`/`npredictors` two lines above) and in the sibling `afFixupAdpcmLoop`'s ADPCM state array a few lines down — not something special-cased for this one book; (2) `port/src/mixer.c`'s `aLoadADPCMImpl` does a raw `memcpy` of the already-converted book straight into a host-native `s16[8][2][8]` table with no further byte-swap, consistent with the coefficients being intentionally pre-converted to host order; (3) decisively, `afFixupBook` is one shared code path applied uniformly to every ADPCM book in the game — if this conversion were actually backwards, every ADPCM-encoded sound would decode wrong, not one bass instrument on three tracks, and the vast majority of the game's audio is confirmed correct. Do not re-propose this specific byte-order theory without new evidence; the standing M-108 leads above (predictor-count truncation / `order` mismatch) remain the live ones. **FIXED (M-121, v0.2.0 finalization) — root cause was a plain byte-order bug in a corner neither M-108 nor M-120 looked at: RAW16 wavetables.** Offline decode of the local NTSC ROM's instrument bank found exactly two `AL_RAW16_WAVE` (type 1) wavetables — instruments 2 and 51, both sharing base 0x2118 / len 790 in the `_instrumentstbl` segment — which decode as a clean ~110 Hz soft-attack bass synth sample when read as **big-endian** s16. That is the missing bass instrument on Control/Cavern/Runway. On N64 the CPU reads those bytes BE, so it plays fine there; on PC the mixer's resampler (`port/src/mixer.c` `alRaw16Pull` path) consumes host-native (little-endian) s16, so every sample arrives byte-swapped — a deterministic per-instrument corruption, exactly matching the M-106 repro (and explaining why every ADPCM-based instrument in the game sounds right: nibble data is byte-order agnostic). Both M-108 candidates are moot — every ADPCM book in both bank trees (scanned offline from the ROM) is `order=2, npredictors∈{1,4}`, so neither the `[8][2][8]` shape nor the 256-byte truncation can ever bite GE's data. **Fix (port-only, D55 in-place-fixup idiom):** `romdataFinishCartMap()` (`port/src/romdata.c`) now walks both bank trees (SFX + instruments; same ALBankFile layout as `romdataFixupAudioBank`) and bswap16s each RAW16 wavetable's `[tbl+base, +len)` range in place in the cart map — where the sample data lives for the mixer (`alBnkfNew()` points at the tbl segments directly; only the bank structs are re-laid out, D37). Shared ranges are deduped (swapping twice would undo the swap); every read is bounds-checked against the ROM size. Startup log: `romdataInit: D230 byte-swapped 1 RAW16 wavetable range(s) in place` (one range: the two instruments share the sample data). Byte-level verification done (BE decode of the ROM range = clean tone; post-swap LE decode identical by construction); **owes a by-ear pass** on Control (elevator + level track), Cavern elevator, and Runway ~5–7 s in. -## D233 (a) — 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… - -**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 that space still draw (M-106, user QA report; screenshots `docs/img/bugs/20260912-cullingbug-bugged.png` vs `-notbugged.png`).** Root-caused M-107 (session-016TaKy6, port/fast3d only): **fast3d's CPU-side "trivial reject" AND-test in `gfx_sp_tri1` (`port/fast3d/gfx_pc.cpp:1455-1460`) has a latent w-sign bug independent of D222/D106.** The per-vertex outcode bits computed in `gfx_sp_vertex` (`x<-w`/`x>w`/`y<-w`/`y>w`/`z>w`, `gfx_pc.cpp:1372-1386`) are only valid half-space tests when `w>0`; for a vertex that has crossed behind the camera plane (`w<0` — common when the camera is close to large room-structure polygons, e.g. walls/ceiling trusses near a doorway) the comparison sense is flipped, so its outcode can come out wrong. If that spurious bit happens to match the other two (genuinely correct) vertices' bits, `v1->clip_rej & v2->clip_rej & v3->clip_rej` fires and the whole triangle is dropped before it ever reaches OpenGL — even though GL's own homogeneous clipper (which *does* handle `w<0` correctly) would have rendered the visible part of it. Confirmed against the two screenshots: same room/camera/ammo-HUD, one frame is missing the room's ceiling trusses/walls (replaced by flat sky) while the console/terminal props in the same room still draw fully — exactly the "big background polys vanish, small prop models don't" split you'd expect, since large near-camera polygons are far more likely to have a vertex cross behind the eye than small self-contained prop meshes. The backface-cull code 6 lines below (`gfx_pc.cpp:1462-1495`) already special-cases this exact `w<0` hazard for its own cross-product sign (`(v1->w<0)^(v2->w<0)^(v3->w<0)`) — the trivial-reject block above it never got the same treatment. **Independent of D222** (D222 is a game-logic frustum-cull bug in `src/game/fr.c`/`bondview2.c` that only manifests at non-default `Video.FovScale`; this is a GPU-pipeline clip-rejection bug in `port/fast3d/` that fires at any FovScale, including the default 100, whenever the camera gets close to a large polygon — matching the report's "all levels, near doors" framing with no FovScale mentioned). Also independent of D106 (D106 fixed the *game-side* portal-visibility bbox computation for a different z==0 edge case in `bg.c`; this is the *renderer-side* per-triangle clip decision, a different stage of the pipeline entirely) — same neighbourhood/symptom vocabulary ("things vanish near a portal-adjacent boundary"), different bug. - -**Status (from §F table):** **FIXED (port/fast3d/gfx_pc.cpp, `gfx_sp_tri1`) — high confidence on mechanism, UNVERIFIED visually.** Fix: skip the trivial-reject AND-test entirely whenever any of the triangle's 3 vertices has `w<0`, deferring to GL's clipper instead (mirrors the cull code's existing w-sign handling one block below). Change only ever *adds* triangles that were previously dropped — cannot make anything newly invisible, so it's a safe, narrowly-targeted fix with no game-logic touch (fast3d is PC-only code, no N64 build parity to preserve, no `#ifdef PORT` needed). **Build verified green** (`./build-pc.sh` equivalent — MSYS2 mingw64 cmake+ninja, full `ge007.x86_64.exe` link succeeds, only one pre-existing unrelated `-Wformat` warning in the same file). **VISUALLY VERIFIED (M-108):** user played `-level_26` (Frigate — corrected level id, the old `-level_02` note above predates the `bondconstants.h` numbering check) and confirmed the doorway/ship-corridor geometry no longer flickers or vanishes near room boundaries. Closed. - -## D234 (a) — Dam: guard towers along the dam structure invisible except the first one (M-106, user QA report). - -**Dam: guard towers along the dam structure invisible except the first one (M-106, user QA report).** Investigated M-107 (session-016TaKy6, port/fast3d scope): **NOT root-caused — no fast3d-side mechanism found that plausibly explains "only the first of N repeated instances renders."** Ruled out: fast3d has no matrix/model instance cache that could explain a first-instance-only bug (the D217-family "keyed by address, not content" cache-bug class doesn't recur here — the *only* content-addressed cache in fast3d is the texture cache, already excluded from this investigation per the M-106 triage note); the modelview matrix stack (`gfx_pc.cpp:105`, depth 11, push properly gated `< 11` at `gfx_pc.cpp:1220`) is correctly bounded and would affect every level's nested-DL geometry, not just Dam's towers specifically; the D233 w<0 clip-rejection bug (above) doesn't fit either — towers are viewed from a distance along the dam, not close enough for a camera-behind-vertex condition, and D233's mechanism is per-triangle/per-frame flicker, not a stable "always the same instances missing" pattern. **Leading hypothesis, NOT verified:** GE's road/structure-instancing for repeated architecture typically goes through the room-graph's bytecode "vis program" (`VISOP_*` opcodes interpreted in `bg.c` ~L4450-4620, e.g. `VISOP_PUSH_IF_ROOM_IN_RANGE`, `VISOP_ADD_VISIBLE_ROOM`) or through per-instance room/portal definitions in the level's compiled BG data — both are level *data*, not code reachable via static analysis without the ROM (this worktree has none extracted). Could equally be a per-instance object/prop transform or cull-volume bug in `src/game/propobj.c`/`chrprop.c` (out of this task's `port/fast3d/` scope, and out of the `src/game/*` model/anim exclusion in the brief regardless). **No fix attempted — do not have enough evidence to touch code.** - -**Status (from §F table):** **VISUALLY VERIFIED FIXED (M-108) — mechanism still unexplained.** User confirmed on a live Dam playthrough (post-D233 fix, correct `-level_26`/Frigate session's build) that all guard towers along the dam now render, not just the first. No code change was made specifically for D234, and the M-107 investigation's own reasoning argued D233's mechanism doesn't fit this symptom (towers viewed from a distance, not a close-camera w<0 case) — so either that reasoning was wrong, D233's fix had a broader effect than analyzed, or the original symptom was intermittent/state-dependent rather than deterministic. Not closed with high confidence in *why* it's fixed, just closed on live confirmation that it *is*. If it resurfaces, re-open with a fresh `GE_PCDUMP` capture rather than assuming this write-up's ruled-out list still holds. - ## D218 — Wide FOV exposes the per-level draw-distance / fog boundary — "blue artifacting" down long peripheral sightlines (M-84, user QA on the D211 FOV slider). **Wide FOV exposes the per-level draw-distance / fog boundary — "blue artifacting" down long peripheral sightlines (M-84, user QA on the D211 FOV slider).** BUNKER→**Dam main tunnel** at `FovScale` ~150: geometry / backdrop past the level's fog-end distance shows at the widened frustum edges (blue = Dam's fog tint). Not a render bug — `fogLoadCurrentEnvironment` (`bgfog.c:305`) does `viSetZRange(Visibility.BlendMultiplier, Visibility.FarFog)`, so **`Visibility.FarFog` is both the far clip plane and the fog-saturation distance**; levels tune it for the stock ~60° FOV. @@ -10655,18 +10631,6 @@ D152 addendum (M-31): static audit of every compiled-audio `osSetIntMask(OS_IM_N **Status (from §F table):** **ANALYZED (M-91) — the suspected unscaled-viewmodel root does NOT hold; this is correct perspective behavior, likely cosmetic/QoL.** The 1P hand + flash are model nodes in ONE gun DL (`gunfire.c` builds `rwmtx[1..4]` for hammer/flash/billboard nodes) drawn through the single per-frame projection from `viSetupCurrentPlayerView` — which IS FovScale-scaled on PC. So the flash and the muzzle it attaches to share projection and matrix chain: their relative position is geometrically preserved. What moves is the whole camera-anchored viewmodel: under perspective, screen offset from center ∝ 1/tan(fovy/2), so widening the FOV migrates every camera-space point (gun body included) toward screen center — a muzzle sitting slightly below/right of center visibly drifts up-left at FovScale 150. Same family as the M-85 accepted "viewmodel scales down, stays bottom-right anchored". `c_perspfovy`/`currentPlayerSetCameraScale` do stay unscaled (the D211 note) but that feeds frustum-cull/screen↔world math (→ D222), not the flash's render path. **Disposition: human call** — if it bothers at high FovScale, the fix is a QoL viewmodel re-anchor/scale-at-FOV (VIEWMODEL-RESEARCH.md family), not a fidelity bug; no game-logic change either way. Optional headless confirm: Jungle M16 fire at FovScale 100 vs 150, measure flash centroid vs muzzle in PPM pairs (expected: both shift toward center together). -## D233 (b) — 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… - -**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`).** - -**Status (from §F table):** **FIXED + VISUALLY VERIFIED (M-107 fix, M-108 verify).** See the other D233 row for full detail. - -## D234 (b) — Dam: guard towers along the dam structure invisible except the first one (M-106, user QA report). - -**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.** - -**Status (from §F table):** **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). **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`. @@ -10943,3 +10907,27 @@ D152 addendum (M-31): static audit of every compiled-audio `osSetIntMask(OS_IM_N **Facility firefight SIGSEGV on Steam Deck (v0.2.0-pre CI bundle, D253 fix confirmed working — user got past the opening crouch tile).** Crash during gunplay; abort-unwind backtrace points at `sub_GAME_7F09BAC4` (`vtxstore.c:158`) ← `sub_GAME_7F09BBBC` (`:220`) ← `lvlManageMpGame` (`lv.c:1302`) ← `bossMainloop` (`boss.c:567`). Disassembling the exact CI binary at the fault PC (`0x200db135`) gives `mov 0x14(%rax),%edi; mov 0x10(%rdi),%rax`: the function walks the active PropRecord list keeping `type == 1` — which is **PROP_TYPE_OBJ, not CHR** (the decompiler cast to the union's first member and misnamed the field `ChrRecord.chrflags`; it is actually `ObjectRecord.model` at `0x14`) — then dereferences `model->obj`. Two port-relevant wrinkles: (a) the load is **32-bit zero-extended** because the decomp types the field through the `CHRFLAG` enum, so a model pointer ≥ 4 GB would truncate to garbage; (b) the loop skips records whose `model->obj` is NULL but *dereferences* `model` unconditionally, so an active object with a NULL/freed `model` faults — plausible in a firefight where destructibles (crates etc.) die. Not reproduced on WSL after equivalent play. **Register capture (D254 format, 2026-09-13):** two more Deck crashes — Bunker mid-firefight, then Frigate with ~3 NPCs alive; log: `SIGNAL: 11 / FAULT ADDR: 0x10 / Rax=0x701e5f54 Rdi=(nil) / PC 0x200db135`. `Rdi` = the record's model value = **exactly NULL** → case (b) confirmed: an active PROP_TYPE_OBJ record with a NULL `model`, dereferenced by the unconditional `mov 0x10(%rdi),%rax`; truncation (a) and stale/garbage pointers are ruled out. Faulting PC matches the recorded disassembly (`sub_GAME_7F09BAC4` = `vtxstore_fix_refs`). Grep-verified: nothing in game code ever writes NULL into `ObjectRecord.model` — `clear_model_obj()` nulls `model->obj` only, and `objInit`/spawn paths only activate a record once model ≠ NULL — so the NULL comes from either a fresh/uninitialized record slot being activated or a destroy-path ordering race (model freed/nulled while the record is still active this tick). **Fix constraint:** any guard lands in game code (`vtxstore.c`) → needs rule-#2 sign-off; `port/` cannot intercept this dereference. Next: (1) decision on whether a NULL-model skip in `vtxstore_fix_refs` qualifies as an ABI-class exception (semantics-preserving defensive guard, PD analogue check); (2) if yes, add it plus a `#ifdef PORT` trace at the deactivation/model-free sites to name the path that leaves the record model-less. **Corrected, deterministic repro (v0.2.1 user testing, 2026-09-15, superseding the initial report): the trigger is Facility's computer terminals — the ones you ACTIVATE (interact with, not shoot/destroy) to open a door for level progression.** User confirms this is consistent/reliably reproducible on those specific terminals, on Steam Deck **and** Linux generally (not Deck-hardware-specific). This is a much stronger lead than the original "sometimes during firefights" — a terminal activation deactivating/freeing its associated object model while the record briefly stays active this tick is fully consistent with the destroy-path-ordering theory above, just via a scripted trigger instead of combat destruction, and gives a deterministic repro instead of a probabilistic one. **Next session should reproduce via terminal activation specifically**, not generic combat — much cheaper to instrument. **M-140: added a diagnostic probe (`GE_D255`, off by default, `#ifdef PORT`) — `vtxstore.c`'s `sub_GAME_7F09BAC4` now skips-and-logs instead of dereferencing when it finds a NULL model on an active OBJ record (identity + state fields), and `propobj.c`'s `objFree` logs every free call for correlation. Attempted a live Windows repro this session (5 terminal activations across 3 Facility launches, one direct `-level_34` boot each time): zero probe hits, zero crashes — Windows does not reproduce this, consistent with the existing WSL-negative result. Two negatives (WSL, native Windows) against one positive (Linux/Steam Deck) points at something genuinely platform-specific (allocator/timing), not a Windows-reachable path — don't spend further Windows session time chasing a live repro.** The probes are harmless (env-gated, zero behavior change when unset) and committed so a Linux/Deck session can set `GE_D255=1` and get the trace directly. **User report, 2026-09-15 (same day, later, real Steam Deck hardware on the published v0.2.1 release — unverified by this session, logging as reported, not re-asserting the activation theory as wrong):** Facility crashes **consistently on terminals being destroyed** (shot), not (only) on activation. Frigate crashed once during this same testing pass, specifics not recorded. Shooting terminals elsewhere/other attempts did not reliably reproduce. This is closer to the *original* pre-M-139 framing (combat/destructibles triggering the NULL-model race) than the M-139 "corrected, activation-only" theory — both are plausible triggers of the same underlying destroy-path-ordering race (any code path that frees/nulls an active OBJ record's model mid-tick), not necessarily contradictory. **Not yet reconciled or verified by a session with hands on the repo** — next Linux/Deck session should test both triggers (activation AND destruction-by-gunfire) with `GE_D255=1` set before revising the "reliable repro" framing again. **Reconciled, same day, further real-Deck testing (v0.2.1 published release): destruction is the trigger, not activation — confirmed across four levels.** Facility: **every** terminal crashes consistently when destroyed (not merely activated). Caverns: terminals near the radio objective crash when they explode — same mechanism, different level, rules out a Facility-specific asset/state bug. Archives: a **similar** crash when a guard's grenade detonates — same destroy-via-explosion mechanism, no terminal involved at all, which narrows the real trigger to "an object dying via explosion damage" in general, not anything terminal-specific. **Negative control:** shooting an entire room of terminals on Frigate did not reproduce it — so it isn't simply "any destructible OBJ record exploding" either; something about the specific object/scene state still gates it. Release notes updated to match (`.github/release-notes.md`, D255 bullet: "consistently crash the game when destroyed"). **Static lead for next session (untested, from this session's code read):** `objFree`'s destroy path (`propobj.c:1017-1039`, entered via `RUNTIMEBITFLAG_REMOVE` from explosion damage, `sub_GAME_7F04424C`/`sub_GAME_7F0442DC`) never explicitly nulls `ObjectRecord.model` — `clear_model_obj()` only nulls `model->obj`. The NULL therefore can't come from that assignment; it must come from either (a) `chrpropFree`'s freed slot being reused by `chrpropAllocate` for a *different*, not-yet-`objInit`'d record before `vtxstore_fix_refs`'s later same-frame pass reaches it (a stale-pointer/reuse race, not a field write), or (b) the active-list unlink (`chrpropDelist`) happening while some other code is mid-iteration over the same list (a modify-during-iterate bug) — explosion damage plausibly spawns new effect props (sparks/debris/smoke) in the same tick that immediately reuse the just-freed slot, which would explain why it's tied to *explosions specifically* and not simple destruction. **Next, concrete:** with `GE_D255=1` set (probes already in tree from this session, ship in any build off `release/v0.3.0` including the local Linux package built this session), reproduce on Facility from the Deck itself (Desktop Mode, launch from a terminal, redirect stdout to a file) and send the log back — the probe already logs the dying record's identity plus every `objFree` call, which should show whether the crashing record's `prop`/`obj` pointer address was recently freed-and-reused this same session. > The §F row had no status cell; the table row's status is derived from this entry's verdict text (ctx-opt Bucket B, 2026-09-15). + +## 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. + +> Consolidated 2026-09-15 (ctx-opt Bucket B): two §F rows carried this label — an earlier condensed pass and this full writeup. The condensed pass's content (audit verdict, the three fixes, steal-lock backstop) is fully covered above; its status line "fade-out repro playtest-gated, not headless-verified" survives in the body. Nothing deleted — earlier text is in git history and `notes/snapshot-pre-ctx-opt/`. + +## 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… + +**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 that space still draw (M-106, user QA report; screenshots `docs/img/bugs/20260912-cullingbug-bugged.png` vs `-notbugged.png`).** Root-caused M-107 (session-016TaKy6, port/fast3d only): **fast3d's CPU-side "trivial reject" AND-test in `gfx_sp_tri1` (`port/fast3d/gfx_pc.cpp:1455-1460`) has a latent w-sign bug independent of D222/D106.** The per-vertex outcode bits computed in `gfx_sp_vertex` (`x<-w`/`x>w`/`y<-w`/`y>w`/`z>w`, `gfx_pc.cpp:1372-1386`) are only valid half-space tests when `w>0`; for a vertex that has crossed behind the camera plane (`w<0` — common when the camera is close to large room-structure polygons, e.g. walls/ceiling trusses near a doorway) the comparison sense is flipped, so its outcode can come out wrong. If that spurious bit happens to match the other two (genuinely correct) vertices' bits, `v1->clip_rej & v2->clip_rej & v3->clip_rej` fires and the whole triangle is dropped before it ever reaches OpenGL — even though GL's own homogeneous clipper (which *does* handle `w<0` correctly) would have rendered the visible part of it. Confirmed against the two screenshots: same room/camera/ammo-HUD, one frame is missing the room's ceiling trusses/walls (replaced by flat sky) while the console/terminal props in the same room still draw fully — exactly the "big background polys vanish, small prop models don't" split you'd expect, since large near-camera polygons are far more likely to have a vertex cross behind the eye than small self-contained prop meshes. The backface-cull code 6 lines below (`gfx_pc.cpp:1462-1495`) already special-cases this exact `w<0` hazard for its own cross-product sign (`(v1->w<0)^(v2->w<0)^(v3->w<0)`) — the trivial-reject block above it never got the same treatment. **Independent of D222** (D222 is a game-logic frustum-cull bug in `src/game/fr.c`/`bondview2.c` that only manifests at non-default `Video.FovScale`; this is a GPU-pipeline clip-rejection bug in `port/fast3d/` that fires at any FovScale, including the default 100, whenever the camera gets close to a large polygon — matching the report's "all levels, near doors" framing with no FovScale mentioned). Also independent of D106 (D106 fixed the *game-side* portal-visibility bbox computation for a different z==0 edge case in `bg.c`; this is the *renderer-side* per-triangle clip decision, a different stage of the pipeline entirely) — same neighbourhood/symptom vocabulary ("things vanish near a portal-adjacent boundary"), different bug. + +**Status (from §F table):** **FIXED (port/fast3d/gfx_pc.cpp, `gfx_sp_tri1`) — high confidence on mechanism, UNVERIFIED visually.** Fix: skip the trivial-reject AND-test entirely whenever any of the triangle's 3 vertices has `w<0`, deferring to GL's clipper instead (mirrors the cull code's existing w-sign handling one block below). Change only ever *adds* triangles that were previously dropped — cannot make anything newly invisible, so it's a safe, narrowly-targeted fix with no game-logic touch (fast3d is PC-only code, no N64 build parity to preserve, no `#ifdef PORT` needed). **Build verified green** (`./build-pc.sh` equivalent — MSYS2 mingw64 cmake+ninja, full `ge007.x86_64.exe` link succeeds, only one pre-existing unrelated `-Wformat` warning in the same file). **VISUALLY VERIFIED (M-108):** user played `-level_26` (Frigate — corrected level id, the old `-level_02` note above predates the `bondconstants.h` numbering check) and confirmed the doorway/ship-corridor geometry no longer flickers or vanishes near room boundaries. Closed. + +> Earlier triage pass, merged 2026-09-15 (ctx-opt Bucket B): "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`." — status then: FIXED + VISUALLY VERIFIED (M-107 fix, M-108 verify). + +## D234 — Dam: guard towers along the dam structure invisible except the first one (M-106, user QA report). + +**Dam: guard towers along the dam structure invisible except the first one (M-106, user QA report).** Investigated M-107 (session-016TaKy6, port/fast3d scope): **NOT root-caused — no fast3d-side mechanism found that plausibly explains "only the first of N repeated instances renders."** Ruled out: fast3d has no matrix/model instance cache that could explain a first-instance-only bug (the D217-family "keyed by address, not content" cache-bug class doesn't recur here — the *only* content-addressed cache in fast3d is the texture cache, already excluded from this investigation per the M-106 triage note); the modelview matrix stack (`gfx_pc.cpp:105`, depth 11, push properly gated `< 11` at `gfx_pc.cpp:1220`) is correctly bounded and would affect every level's nested-DL geometry, not just Dam's towers specifically; the D233 w<0 clip-rejection bug (above) doesn't fit either — towers are viewed from a distance along the dam, not close enough for a camera-behind-vertex condition, and D233's mechanism is per-triangle/per-frame flicker, not a stable "always the same instances missing" pattern. **Leading hypothesis, NOT verified:** GE's road/structure-instancing for repeated architecture typically goes through the room-graph's bytecode "vis program" (`VISOP_*` opcodes interpreted in `bg.c` ~L4450-4620, e.g. `VISOP_PUSH_IF_ROOM_IN_RANGE`, `VISOP_ADD_VISIBLE_ROOM`) or through per-instance room/portal definitions in the level's compiled BG data — both are level *data*, not code reachable via static analysis without the ROM (this worktree has none extracted). Could equally be a per-instance object/prop transform or cull-volume bug in `src/game/propobj.c`/`chrprop.c` (out of this task's `port/fast3d/` scope, and out of the `src/game/*` model/anim exclusion in the brief regardless). **No fix attempted — do not have enough evidence to touch code.** + +**Status (from §F table):** **VISUALLY VERIFIED FIXED (M-108) — mechanism still unexplained.** User confirmed on a live Dam playthrough (post-D233 fix, correct `-level_26`/Frigate session's build) that all guard towers along the dam now render, not just the first. No code change was made specifically for D234, and the M-107 investigation's own reasoning argued D233's mechanism doesn't fit this symptom (towers viewed from a distance, not a close-camera w<0 case) — so either that reasoning was wrong, D233's fix had a broader effect than analyzed, or the original symptom was intermittent/state-dependent rather than deterministic. Not closed with high confidence in *why* it's fixed, just closed on live confirmation that it *is*. If it resurfaces, re-open with a fresh `GE_PCDUMP` capture rather than assuming this write-up's ruled-out list still holds. + +> Earlier triage pass, merged 2026-09-15 (ctx-opt Bucket B): "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`." — status then: VISUALLY VERIFIED FIXED (M-108), mechanism unexplained. diff --git a/docs/dev/notes/ctx-opt/consolidate_ab.py b/docs/dev/notes/ctx-opt/consolidate_ab.py new file mode 100644 index 00000000..05f528ad --- /dev/null +++ b/docs/dev/notes/ctx-opt/consolidate_ab.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""One-off: consolidate the (a)/(b) duplicate-label sections (D152+, D233, D234). + +For each label: keep the full writeup, append the short pass's genuinely-unique +content verbatim under a merge note, drop the (a)/(b) markers. Table rows are +untouched (cell 1 frozen; both rows point at the merged section). Dry-run by +default. +""" +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[4] +SRC = ROOT / "docs/dev/findings.md" + +# label -> merge note appended after the kept (full) section's content +NOTES = { + "D152+": ( + "> Consolidated 2026-09-15 (ctx-opt Bucket B): two §F rows carried this " + "label — an earlier condensed pass and this full writeup. The condensed " + "pass's content (audit verdict, the three fixes, steal-lock backstop) is " + "fully covered above; its status line \"fade-out repro playtest-gated, " + "not headless-verified\" survives in the body. Nothing deleted — earlier " + "text is in git history and `notes/snapshot-pre-ctx-opt/`." + ), + "D233": ( + "> Earlier triage pass, merged 2026-09-15 (ctx-opt Bucket B): \"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`.\" — status then: FIXED + VISUALLY VERIFIED " + "(M-107 fix, M-108 verify)." + ), + "D234": ( + "> Earlier triage pass, merged 2026-09-15 (ctx-opt Bucket B): \"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`.\" — status " + "then: VISUALLY VERIFIED FIXED (M-108), mechanism unexplained." + ), +} + + +def main() -> int: + apply = "--apply" in sys.argv + t = SRC.read_text(encoding="utf-8") + heads = [m.start() for m in __import__("re").finditer(r"^## ", t, __import__("re").M)] + + def section_at(h): + e = min(x for x in heads if x > h) + return h, e, t[h:e] + + out_parts, cursor = [], 0 + plan = {} + for lab, note in NOTES.items(): + pref = f"## {lab} " + ss = [h for h in heads if t[h:h + len(pref)] == pref and t[h + len(pref) - 1] != "("] + # collect both (a) and (b) spans + spans = [] + for h in [x for x in heads if t[x:x + len(f"## {lab}")] == f"## {lab}"]: + spans.append(section_at(h)) + assert len(spans) == 2, (lab, len(spans)) + # full writeup = the longer span + full, short = sorted(spans, key=lambda s: len(s[2]), reverse=True) + plan[lab] = (full, short, note) + + # remove all six spans (sorted desc so offsets stay valid) + all_spans = [s for v in plan.values() for s in (v[0], v[1])] + removed = [] + for h, e, _ in sorted(all_spans, key=lambda s: s[0], reverse=True): + t2 = t[:h] + t[e:] + removed.append((h, e)) + t = t2 + # append consolidated sections at EOF + for lab, (full, short, note) in plan.items(): + h, e, body = full + # strip the "(a)"/"(b)" marker from the kept heading + body = body.replace(f"## {lab} (a) — ", f"## {lab} — ", 1) + body = body.replace(f"## {lab} (b) — ", f"## {lab} — ", 1) + t += "\n" + body.rstrip() + "\n\n" + note + "\n" + + print("removed spans:", [(h, e - h) for h, e in removed]) + if not apply: + print("dry-run (pass --apply)") + return 0 + SRC.write_text(t, encoding="utf-8", newline="") + print("applied") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())