Documents the four hotfix-batch changes since v0.2.1 (D255, D285, D282,
D293) in README Status and .github/release-notes.md, and folds in
2026-09-16 real-Deck confirmation that D285's audio-thread lock holds
under sustained overlapping-explosion combat (Archives, dual grenade
launchers + rocket-launcher-cheat guards).
No discoverable in-UI way to quit (Deck/controller-only setups had only
window-close/Alt+F4). Adds a ROW_ACTION row kind (not config-backed, same
pattern as the existing __Resolution row) that triggers the identical
configSave()+exit(0) path SDL_QUIT already uses. Build-verified on Windows
and Linux; verify.sh dam PASS, no regression.
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
sndHandleEvent's pool-exhaustion preempt-scan read/walked the shared
D_800243E4 active-ALSoundState list with no osSetIntMask lock, while
sndSetupSound (the list's insert path, reached from sndPlaySfx calls
across 15 game files) already locks it -- an asymmetric-locking gap,
the same D4/D147/D152 "N64 interrupts-off isn't a real lock on PC"
class. Wraps the scan in the identical lock/unlock pair sndSetupSound
already uses, unconditionally (no #ifdef PORT, matching that function's
own style -- osSetIntMask is genuine N64 API and the port's shim is a
process-wide recursive lock, safe to nest). Audited the rest of the
function: the scan is the only D_800243E4 touch point in it.
Build green; -level_09/-level_20 unregressed (1400+ frames, no crash,
no D152 lock-stuck warnings). Not yet gdb/live-confirmed against a
real reproduced crash -- a GE_INPUTSCRIPT-driven stress attempt this
session didn't visibly activate the scripted-input harness for a
reason not root-caused; the honest bar met here is "correct by D4/D152
precedent, build/sweep-clean," not "reproduced-then-fixed."
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
Menus read the same stick_x/stick_y channel as in-level gameplay, and
naturalPitchMode (SOLITARE-style, default-on) routes that channel to
the right stick for continuous look. Since front.c's menu nav shares
the channel, it silently inherited the right-stick routing, which is
unintuitive on a modern pad/Deck outside a running stage. The gamepad
poll already computed an unused padMenuMode flag for shoulder-button
gating; reused it to always source stick_x/stick_y from the left
stick outside RUN_STAGE, regardless of naturalPitchMode. Port-layer
only, inert during gameplay -- verified via -level_09/-level_20
unregressed; in-menu feel needs a human controller playtest.
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
Root-caused via a live Steam Deck session over SSH: sub_GAME_7F09BAC4
(vtxstore_fix_refs) read an active OBJ record's Model* through
((Model*)((ChrRecord*)prop->chr)->chrflags) - a union pun onto
ChrRecord.chrflags, which is typed CHRFLAG, a 4-byte enum. On 32-bit
N64 this is bit-exact (the enum and a pointer are both 4 bytes, so
casting the enum's value to Model* reproduces the real stored bytes).
On 64-bit PC, Model* is 8 bytes but the enum read is still only 4 -
the cast reads just the pointer's low 32 bits as an int and
zero-extends it back to a 64-bit address, discarding the real high
bits. This is the D3x pointer-width ABI class from AGENTS.md - a
struct field misread across the 32-to-64-bit pointer-width transition.
This was actually flagged as suspect (a) in the very first D255
investigation, then wrongly deprioritized in favor of a "destroy-path
race" theory once a register capture happened to show Rdi=(nil) and
looked like it confirmed a different, unrelated NULL-model theory. It
didn't: (nil) is exactly what a 32-bit-truncated read produces
whenever the real pointer's low 32 bits happen to be zero - a
coincidence that pointed two sessions' worth of investigation at the
wrong field. Proof: reading the same record through the correctly
64-bit-typed ObjectRecord.model field (this session's diagnostic
probe) was always a valid, non-NULL pointer - the object's model was
never actually missing.
Fix: read the pointer through ObjectRecord.model (the same union
memory, correctly typed) instead of through the mistyped
ChrRecord.chrflags alias, at both use sites in the function. Identical
bytes and identical result on N64; correct on 64-bit PC.
Verified live on the user's own Steam Deck hardware over SSH, running
their own prepared ROM assets: held through the entire Facility level
including repeated terminal destruction (the most consistent prior
repro) and through Caverns' radio-objective terminals (the second
confirmed level), zero crashes. Windows rebuild + full 21-level
verify.sh sweep also clean.
The separate audio-thread crash (D285, caught during this same live
session) is unrelated and unaffected by this fix.
2026-09-15 23:48:17 -04:00
8 changed files with 209 additions and 55 deletions
@@ -12,7 +12,7 @@ in a dedicated `port/` layer, following the architecture of the
[Perfect Dark PC port](https://github.com/fgsfdsfgs/perfect_dark), the same
Rare "Indy" engine family, one hardware generation apart.
**v0.2.1** is out for Windows and Linux (including Steam Deck, where the
**v0.2.2** is out for Windows and Linux (including Steam Deck, where the
Linux bundle sideloads as-is) and runs the full campaign at a steady 60 fps
with known rough edges ([Status](#status)). Free to download, build on and
modify (you bring the ROM).
@@ -53,7 +53,7 @@ account of what worked and what didn't.
Both bundles contain **no ROM and no game assets**: you supply your own
(see [Requirements](#requirements)), which keeps the release legal to
distribute. Earlier builds: v0.2.0 and v0.1.0 alpha, same page. You can also build it
distribute. Earlier builds: v0.2.1, v0.2.0 and v0.1.0 alpha, same page. You can also build it
yourself; see [Building](#building).
### Quick start
@@ -67,24 +67,26 @@ included or distributed. Then:
2. Make a `data/` folder next to the executable and drop the ROM in as `ge007.ntsc-final.z64`.
3. Launch the executable from that folder. The first run takes a few extra seconds: it detects the ROM and generates the derived asset folders once (no Python or other tooling needed).
Read the [Status](#status) caveats first: v0.2.1 has known rough edges,
Read the [Status](#status) caveats first: v0.2.2 has known rough edges,
listed plainly there.
## Status
**v0.2.1 - playable, with known rough edges.** The full single-player
**v0.2.2 - playable, with known rough edges.** The full single-player
campaign is completable end to end (all 21 missions, Agent difficulty,
playtested), at a steady 60 fps; all 21 solo missions load, render and run
crash-free, verified on Windows, Linux and real Steam Deck hardware. Feedback
is very welcome.
**Working:** boot sequence and front end (menu → mission select → briefing →
start); all 21 solo missions load, render and are crash-free (full campaign
start), front-end menu navigation on the left stick to match the F10 overlay
(D282); all 21 solo missions load, render and are crash-free (full campaign
playtested end to end at Agent difficulty); steady 60 fps
(software RSP off the presentation critical path); full audio: in-level music and SFX; keyboard + mouse (click-to-lock, proportional aim mode) and a modern
dual-stick controller layout; file-backed saves; faithful N64 progression by default
(F10 → *All unlocked* opens every level, 007 mode and the full cheat menu); F10 in-game
- No macOS or ARM support; no controller rebinding UI.
@@ -230,7 +231,7 @@ code with this one.
| **How** | Decompilation-based source port: human-reconstructed C, compiled for the host; game logic runs as written | Static binary recompilation: the shipped machine code is auto-translated to C; no source-level understanding |
| **Lineage** | [GoldenEye 007 decompilation](https://github.com/n64decomp/007) + [Perfect Dark PC port](https://github.com/fgsfdsfgs/perfect_dark) engine family | Xbox 360 "…Recompiled" static-recompilation family |
| **Status** | v0.2.1 public release; full campaign playable at 60 fps (see [Status](#status)) | Playable full game with online multiplayer |
| **Status** | v0.2.2 public release; full campaign playable at 60 fps (see [Status](#status)) | Playable full game with online multiplayer |
| **Why it exists** | To run the *original* N64 game from source, and as a [case study in AI-agent collaboration](#background) on a hard low-level codebase | To get a playable PC release of the remaster |
They answer a different question: how to get the *remaster* onto PC by
D276,OPEN,"D236 wall, eighth pass —","REFRAMED — oml=0xc81049d8/fmt=3 is the primary suspect now, not D269's CI8 pair; 0x0c184b50 unattributed; wall still open, see D236"
D279,?,"Backfilled — this label was consumed by an undocumented partial session (2026-09-14 ~23:00) that left artifacts in scratch/ (d279_run.log, d279_imgmap_run.log,…",undocumented — artifacts only
D281,OPEN,"Game.AllUnlocked (D257) still breaks audio, and can also break right-mouse aim, when enabled before any save has ever been written (v0.2.1 user testing, 2026-0…","OPEN — user-reported regression, not root-caused; mitigation is user-facing guidance only (release notes:"
D282,OPEN,"QoL ask (v0.2.1 user testing, 2026-09-15):","OPEN — not designed; QoL, port-only."
D282,FIXED,"QoL ask (v0.2.1 user testing, 2026-09-15):","FIXED (port/src/input.c, #ifdef not needed —"
D283,OPEN,"Steam Deck first-run preset (D-unnumbered in port/src/video.c/main.c, videoApplySteamOSDefaults()) did not apply for a user who added the game via Steam in Des…","OPEN — mechanism understood, root cause (Desktop-vs-Game-Mode STEAMOS env var difference) unverified on real hardware; user-facing workaround documented in README/release notes."
D280,?,"D236 wall, ninth pass —","REFRAMED again — no fix; next steps in detail section, see D236"
D275,?,"D236 wall, eighth pass —","done — probes live, uncommitted; see D276 for what they found"
D252,?,"Particle effects cycle through a rainbow palette (M-124, user playtest of the v0.2.0-pre candidate).",
D253,FIXED,Facility (and any level with a FORCECROUCH/LADDER linked special tile) SIGSEGV on Linux/Steam Deck —,FIXED — Deck-verified:
D254,FIXED,Linux crash handler destroyed its own logs:,FIXED — field-verified.
D255,OPEN,"Facility firefight SIGSEGV on Steam Deck (v0.2.0-pre CI bundle, D253 fix confirmed working —",OPEN — case identified (NULL model on an active OBJ record; destroy-path ordering); reliable deterministic repro is Facility's door-opening computer terminals on activation (Linux/Steam Deck); earlie…
D255,?,"Facility firefight SIGSEGV on Steam Deck (v0.2.0-pre CI bundle, D253 fix confirmed working —",
D256,FIXED,Saves (and config) silently failed to persist on Linux whenever the game was not launched from the directory containing data/ —,FIXED (path half) — save-flow detail still owed from the user.
D257,FIXED,New Game.AllUnlocked option (F10; default flipped OFF for v0.2.0 —,FIXED — verified by user playtest (PR #79 merged); default flipped OFF for v0.2.0 (D259).
D259,FIXED,Game.AllUnlocked (D257) breaks audio on a fresh save:,FIXED in v0.2.1 — the deferred fix was applied:
D259,FIXED,Game.AllUnlocked (D257) breaks audio on a fresh save:,FIXED in v0.2.1 —…
D263,RESOLVED,v0.2.1 (user report):,"RESOLVED (not a bug), but refined (v0.2.1 further user testing, 2026-09-15) —"
D263,RESOLVED,v0.2.1 (user report):,"RESOLVED (not a bug), but refined (v0.2.1 further user testing, 2026-09-15) —…"
D265,?,"D236 investigation (port-layer, port/fast3d/gfx_pc.cpp):",PARKED — reverted to baseline; evidence + suspect list above.
D258,OPEN,PAL/JP asset conversion is broken at the source-data level —,"OPEN — repair path verified, not applied (out of v0.2.0 scope)."
D285,FIXED,Steam Deck SIGSEGV during D255 live-testing = SEPARATE bug:,"FIXED (M-141, src/snd.c) —"
D293,FIXED,"QoL ask: no discoverable way to quit the game from the UI (M-141, Steam Deck user playtest —","FIXED (M-141) — ""Quit to desktop"" row added to the F10 overlay; build-verified Win+Linux, not yet live-confirmed.…"
1
# GENERATED by tools_pc/gen_findings_index.py -- do not hand-edit.
@@ -485,7 +485,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
| D276 | **D236 wall, eighth pass — the true painter of the visible weave, at confirmed weave pixels, is a fully-opaque IA8 blocky-noise texture sampled with a fixed, identical 2×-wrapped UV window and low fog — NOT either of D269's named CI8 classes.** Multi-point pixel attribution + per-vertex fog (D275's `GE_D270PIX` list extension) at 4 pixels picked directly off the reproduced weave screenshot found the D269 OPA/XLU classes absent at 2 of them; the dominant front-most class there (`oml` low bits coincide with the XLU class but `fmt=3`/IA, not CI8) decodes to blocky gray static, matches D267's already-dumped-but-dismissed "seq 113" texture, and reproduces D265's original "all cards sample u:0..64/v:0..16" observation to the exact texel — that lead was reframed away from prematurely in D269's second pass. A third class (`0x0c184b50`, CI8) is present everywhere with erratic z/w (`-0.04..0.99`) and high fog when it does land normally — flagged as the possible real "tree" layer, with its own likely occlusion/billboard bug, meaning D236 may have two separate root causes, not one. | REFRAMED — `oml=0xc81049d8`/`fmt=3` is the primary suspect now, not D269's CI8 pair; `0x0c184b50` unattributed; wall still open, see D236 |
| D279 | **Backfilled — this label was consumed by an undocumented partial session (2026-09-14 ~23:00) that left artifacts in `scratch/` (`d279_run.log`, `d279_imgmap_run.log`, `d279_g110.png`, `d279_g220*.png`, `d279_gray_1500.png`) but no written conclusion; the logs carry no D279-tagged lines, so its exact probe/finding cannot be reconstructed from the tree.** Next available label after it: D280. | undocumented — artifacts only |
| D281 | **`Game.AllUnlocked` (D257) still breaks audio, and can also break right-mouse aim, when enabled before any save has ever been written (v0.2.1 user testing, 2026-09-15) — a distinct regression from D259, which only fixed the all-zero-slot volume case.** User's empirical workaround: complete a level normally first (e.g. Dam on Agent, which writes a real save), THEN enable `All unlocked`; enabling it on a completely fresh install (no save yet) still reproduces both symptoms. Not root-caused this session — D259's `geEepromPatchAllCheats` all-zero-slot seeding was verified against exactly the "fresh eep" case, so either that fix doesn't cover every code path a truly-first-launch toggle can hit (e.g. a race between the F10 toggle taking effect and the very first save-slot creation/read), or there's a second, independent mechanism. The right-mouse-aim symptom is new information with no prior lead — cross-ref D211/D194 (aim-mode sensitivity/mode family) for where a save-state-dependent aim regression could originate. | OPEN — user-reported regression, not root-caused; mitigation is user-facing guidance only (release notes: don't enable until a save exists, treat as experimental). |
| D282 | **QoL ask (v0.2.1 user testing, 2026-09-15): front-end menu navigation (main menu, file select, mission-select map) currently requires the RIGHT analog stick — matches N64 default control schemes but is unintuitive on a modern controller/Steam Deck, where the left stick is the expected primary-navigation input.** Distinct from the F10 options overlay, which is already left-stick/D-pad navigable (`port/src/optionsoverlay.c`). Scope: front-end menu input in `src/game/front.c` (or wherever N64 stick reads are gated by the active `controldef`) — likely wants a port-layer input remap for these menu contexts specifically, not a general control-scheme change. | OPEN — not designed; QoL, port-only. |
| D282 | **QoL ask (v0.2.1 user testing, 2026-09-15): front-end menu navigation (main menu, file select, mission-select map) currently requires the RIGHT analog stick — matches N64 default control schemes but is unintuitive on a modern controller/Steam Deck, where the left stick is the expected primary-navigation input.** Distinct from the F10 options overlay, which is already left-stick/D-pad navigable (`port/src/optionsoverlay.c`). Scope: front-end menu input in `src/game/front.c` (or wherever N64 stick reads are gated by the active `controldef`) — likely wants a port-layer input remap for these menu contexts specifically, not a general control-scheme change. **M-141: root cause found + fixed, port-only.**`front.c`'s menu-nav code reads `joyGetStickX/Y` — the same emulated N64 "Control Stick" channel (`stick_x`/`stick_y`) that in-level gameplay uses. `port/src/input.c`'s gamepad poll routes that channel to the LEFT physical stick by default, but `naturalPitchMode` (D194/D238's SOLITARE-style scheme, **default-on**) reroutes it to the RIGHT stick for continuous look — and since menus share the channel, they silently inherited that reroute too. The poll already computed an unused `padMenuMode` flag (`current_menu != GE_MENU_RUN_STAGE/GE_MENU_INVALID`, previously only gating shoulder-button weapon-cycle edges); moved it earlier and added a `padMenuMode` branch that always sources `stick_x`/`stick_y` from the left stick outside a running stage, regardless of `naturalPitchMode` — matching the F10 overlay's existing left-stick convention (`optionsoverlay.c`). Zero effect on in-level control feel (the branch is inert whenever `current_menu == GE_MENU_RUN_STAGE`). Verified: build green; `-level_09`/`-level_20` unregressed (1100+ frames each, no crash) — bare `-level_XX` launches skip the front end entirely, so this only proves the gameplay path (unaffected) stays clean. **In-menu feel not interactively verified this session** (no physical gamepad available in this environment) — needs a human playtest with a controller to confirm the menu now navigates on the left stick as expected. | FIXED (`port/src/input.c`, `#ifdef` not needed — port-layer-only file), feel unconfirmed. |
| D283 | **Steam Deck first-run preset (D-unnumbered in `port/src/video.c`/`main.c`, `videoApplySteamOSDefaults()`) did not apply for a user who added the game via Steam in Desktop Mode and switched to Game Mode afterward — resolution stayed at some non-native value instead of the Deck's native 1280×800 (v0.2.1 user testing, 2026-09-15).** Root mechanism, confirmed by code inspection: the preset only applies when `getenv("STEAMOS")` is truthy AND no `ge007.ini` exists yet (`main.c:126-129`, before `configLoad()`); once any ini exists, it always wins over the preset on every future launch (by design, so user F10 changes persist). **Leading hypothesis (not verified on real hardware in this environment):** SteamOS's Desktop Mode (a normal Plasma/X11 session) likely does not export the same `STEAMOS` env var that the Game Mode/gamescope session does, so a Desktop Mode test-launch (a natural step when adding a non-Steam game via its file browser) creates `ge007.ini` with ordinary Linux defaults *before* the user ever reaches Game Mode — permanently skipping the preset for that install, since the ini already exists on the first real Game Mode launch. This exact caveat was already known internally (`docs/HANDOFF.md`: "a Deck that already has an ini needs `-fresh` or F10 to pick the preset up") but was never surfaced in user-facing docs (README/release notes) — same class of gap as the stale D230 release-notes reference. **Workaround (lighter than `-fresh`, which also wipes the save):** F10 → Resolution → 1280×800 manually; takes seconds, no data lost. | OPEN — mechanism understood, root cause (Desktop-vs-Game-Mode `STEAMOS` env var difference) unverified on real hardware; user-facing workaround documented in README/release notes. |
| D280 | **D236 wall, ninth pass — fog math VERIFIED N64-faithful (±2 truncation over all 2505 verts of `0xc81049d8`; D276 owed step #1 closed), the IA8 noise class VISUALLY confirmed as the weave's painter (`GE_D278C` collapse: band HF 14.8→2.9, no trees appear without it — first unconfounded test), full census at both confirmed weave points (only 3 real classes; two log entries are probe artifacts), D276's depth-race read INVERTED (`0x0c184b50` tree cards are in FRONT of the noise quad, z/w smaller=closer), `0xc8102078` ruled out as trees (terrain, 1.4M tris), and `0x0c184b50` shown to barely instantiate under deterministic `-level_36` boot (~4 tris vs 720 free-run) with CPU-baked bimodal fog 25/255. **No quick win for trees** — remaining work is CPU-side instantiation/room-streaming, not a port fix. | REFRAMED again — no fix; next steps in detail section, see D236 |
| D275 | **D236 wall, eighth pass — probe upgrades:**`GE_D270PIX` extended from a single point to a semicolon-separated list (each line now tagged `pix=(x,y)` + D274's reliable `addr=`); `GE_D270KEEPOML` gained a "kept N tris" counter so a black A/B capture is diagnosable (tick-misaligned vs. a genuine render-interaction bug). Mechanical groundwork for D276; the class-isolation A/B itself is still owed. | done — probes live, uncommitted; see D276 for what they found |
@@ -546,17 +546,21 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
| D252 | **Particle effects cycle through a rainbow palette (M-124, user playtest of the v0.2.0-pre candidate).** Shooting repeatedly makes the bullet-impact "sparks" slowly progress through the full hue wheel (~10 shots to sweep it); other lingering particle effects — smoke / explosion residue — show the same rainbow colouration instead of their intended grey/orange palette. **Mechanism context (verified this session):** particle tris carry no per-primitive material; their colour comes entirely from the ROM-resident state records `globalDL_0x078 … globalDL_0xa50` (`assets/oddtextures.c`, 15 of them) replayed per particle batch (`explosion.c:894`). Each record binds a **distinct** texture pair — `IMAGE_SMOKE_N` (IA, tile 0 @ TMEM 0) ×`IMAGE_FIRE_N` (RGBA16, tile 1 @ TMEM 0x188) under `G_CC_INTERFERENCE` (TEXEL0·TEXEL1), and `explosionRenderPart` uses a fixed all-white default vertex (`explosion.c:215`) — so the intended per-stage colour is whatever those 15 fire/smoke pairs encode, and particles **age through the records** (`var_s2` 14→0 at `frame/flareanimspeed`), so a per-texture decode error surfaces exactly as a hue sweep over time / across shots. **Ruled out this session:** (a) the M-82/D172 tile-offset fix is correctly configured for this path — `gsDPSetTextureLOD(G_TL_TILE)` does NOT set the `G_TL_LOD` bit, so `rdp.tex_lod` stays false (`gfx_pc.cpp:2860`) and `gfx_lod_tile_offset` returns `i`, i.e. TEXEL1 genuinely samples tile 1; (b) the RGBA16/5551 texel decode in `import_texture_rgba16` is correct (big-endian, R=bits15-11 G=10-6 B=5-1 A=bit0). **Prime suspect: the width/height derivation in `import_texture_rgba16`** — `width = rdp.texture_tile[tile].line_size_bytes / 2`, `height = size_bytes / line_size_bytes`. The fire tiles are 16 px wide with a MASK_16 TMEM line (64 bytes), so `width` computes 32 (true: 16) and, with `size_bytes` = the LOADBLOCK's 16×14×2 = 448, `height` computes 7 (true: 14) — the GL texture would be built from the right pixels at the wrong dimensions, scrambling each of the 15 fire sprites into a different wrong-hued smear; that fits "slow rainbow progression" precisely (each aging stage = a different scrambled sprite). **Not yet confirmed** — needs one imported fire tile dumped and compared against the raw `IMAGE_FIRE_N` bytes (`GE_TEXDUMP`/`GE_PCDUMP`, or a targeted probe at the `upload_texture` call). **Per user instruction: documented only, NOT fixed for v0.2.0-pre** — ships as a known issue; pick up afterwards (cheap if the suspect holds: derive width/height from the LOADBLOCK's dtx/dlh rather than the tile line size, or special-case non-32-texel-wide RGBA16 tiles). D172/M-82 lineage. |
| D253 | **Facility (and any level with a FORCECROUCH/LADDER linked special tile) SIGSEGV on Linux/Steam Deck — `stanCheckLinkedSpecialTile` stores to a garbage `outFlags` pointer (D203 root cause).** The B-callback typedef `standTileLocusCallback_B_t` (`bondtypes.h:573`) declares the middle three params as `f32 f32 f32`, so on x86-64 SysV the caller (`sub_GAME_7F0B1DDC`, `stan.c:2228`) passes the 6th arg (`record`) in **`%rdx`** (3rd integer slot; the floats take xmm0–2). But the actual implementation `stanCheckLinkedSpecialTile` was decompiled with an all-`s32` prototype, so it reads `outFlags` from **`%r9`** (6th integer slot) — a stale scratch value left by earlier callees. On the N64 (MIPS o32) both prototypes agree: everything flows through GPRs a0–a5 in order, 6th arg = a5 either way, so the original is fine and the decompilation looked consistent. The mismatch stays dormant until a tile actually hits the FORCECROUCH or LADDER branch and *stores* through `outFlags` → SEGV; that's why it was level-specific (Facility's opening crouch tile) and input-dependent, and why idle repros were negative. The sibling B-callback `stanLocusEdgeIsAboveY` already had the correct float prototype, which is why only this path was booby-trapped. **Fix (ABI-class, semantics-preserving):** declare the three middle params of `stanCheckLinkedSpecialTile` as `f32` in both the forward decl and definition (`stan.c:152`, `stan.c:2407`) to match the typedef; the body never uses them, and on N64 o32 f32 args occupy the same GPRs, so N64 codegen is unaffected. **Verified:** gdb run on WSL (gcc 15) — pre-fix, SIGSEGV at `stan.c:2441` with `r9=0x3` and valid `record=0x409fea00` in the caller frame; post-fix, the same walk route completes with no crash. Side benefit: the FORCECROUCH/LADDER branches were effectively dead-on-arrival on PC (any hit = crash); they now function. **Also observed post-fix (separate, WSLg-only):** a segfault *inside libgallium* during `gfx_opengl_upload_texture` (32×48 RGBA + mipmaps) while walking further — GL-driver-side under WSLg's Mesa; not reproducible on Windows; park as a new finding if it recurs on real hardware. | FIXED — Deck-verified: user got past the crouch tile with the re-cut bundle (a *new*, later crash is D255). |
| D254 | **Linux crash handler destroyed its own logs: `ge007.crash.log` only ever contained the SIGABRT block, never the original SEGV.** The POSIX `crashHandler` (`port/src/crash.c`) finished by calling `sysFatalError()` → `abort()` → SIGABRT, which re-entered the *same* handler (SIGABRT was one of the installed signals); the second pass reopened the log with `"wb"` and overwrote the first pass's SEGV block — faulting PC, signal, everything. The Deck firefight crash (D255) came back as a bare `SIGNAL: 6` abort record for exactly this reason, forcing offline disassembly of the CI binary to recover what the log should have said. **Fix (port-only):** re-entrancy guard (`inCrashHandler`, second pass `_exit(128+sig)` immediately) + terminate directly with `_exit()` instead of `sysFatalError()` (the Windows path already terminated directly for the same reason) + the Linux block now dumps the faulting context: `FAULT ADDR` (`si_addr`) and the full general register set (Rax/Rcx/Rdx/Rsi/Rdi/R8–R11, Rsp/Rbp) from the `ucontext`, so a field crash now names the bad pointer on its face. **Field-verified 2026-09-13:** the D255 Deck crash came back as `SIGNAL: 11` + `FAULT ADDR: 0x10` + full register set — exactly the format designed here; it directly identified the NULL-model case (see D255). | FIXED — field-verified. |
| D255 | **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. | OPEN — case identified (NULL model on an active OBJ record; destroy-path ordering); reliable deterministic repro is Facility's door-opening computer terminals on activation (Linux/Steam Deck); earlier combat-related reports (Bunker, Frigate) may be the same mechanism via a different trigger; fix pending rule-#2 sign-off. |
| D256 | **Saves (and config) silently failed to persist on Linux whenever the game was not launched from the directory containing `data/` — reported as "saves do not work at all" on Steam Deck.**`romdataInit` finds the ROM in `$S/` (CWD `data/`) *or*`$E/` (exe dir) *or*`./`, but `sysResolvePath`'s POSIX `$S` branch (used by `ge007.eep` and `ge007.ini`) only ever produced a CWD-relative path — its "fallback" was `./data/`, identical to the primary. Launch from any other CWD (Steam non-steam-game shortcut, terminal in `~`): ROM loads via the exe-dir candidate, game runs, but every EEPROM/config write hits `fopen(..., "wb")` on a nonexistent `./data/` and fails with a stderr-only "cannot write" — saves never persist, and **every launch also starts with default settings** (F10 changes don't stick), which presents as "draw distance/LOD is low" on levels like Dam where the user had raised `Video.DrawDistance` on another machine. The Windows branch already did CWD-else-exedir; the POSIX branch now mirrors it (`readlink("/proc/self/exe")`) and creates the directory best-effort so first-run writes don't fail. **Verified on WSL:** with exe+`data/` in `/tmp/fb2` and CWD=`/`, config/eeprom/ROM all resolve into `/tmp/fb2/data/` and a 25 s run completes clean; the pre-fix binary in the same layout fails to find the ROM at all. **Caveat:** if the user's Deck launch *was* from the bundle dir (CWD had `data/`), this fix does not explain their save failure — need the save-flow detail (does the memory-card screen appear? slot confirm? "saved" message?) before closing the report. Side note found while testing: a `data/` without the generated sidecars (`pccg-*/`, `pcmodels-*/` from `tools_pc/d69_emit.py`/`d43_emit.py`) starts fine but faults in `load_bg_file` (`bg.c:865`) on level load — the reserve functions warn "stage loads will fault" and return 0; expected degraded mode, not a new bug. | FIXED (path half) — save-flow detail still owed from the user. |
| D257 | **New `Game.AllUnlocked` option (F10; default flipped OFF for v0.2.0 — see D259): skip N64 progression entirely — all 21 solo levels selectable at every difficulty, 007 mode available, and the cheat menu fully populated. Port-layer only; no game-code edits.** Three mechanisms, all in `port/`: (1) `main.c` seeds the two live `LEFTOVERDEBUG` RAM flags after `configLoad()` — `debug_enable_all_levels_flag`(all levels) and `debug_007_unlock_flag` (007 mode; both in `src/game/debugmenu_handler.c`, same class as the existing `GE_UNLOCK_ALL` getenv hook in the getter); (2) the eep shim (`libultra.c geEepromPatchAllCheats`) patches the save block at read time — special-casing `fileValidateSaves`'s exact pattern (block 4, 5×96 bytes) — setting all 20 progression-gated cheat-unlock bits per slot (`unlocked_cheats_1/2/3`; the cheat menu row on mode-select only renders when ≥1 is set, `front.c interface_menu06_modesel`) **and** filling every *empty* stage/difficulty completion-time field with the max time 0x3FF (existing player times preserved); both are then CRC'd per slot with the game's own `fileGenerateCRC` or `fileValidateSaves` would wipe the slots. (3) `optionsoverlay.c` row so users can toggle OFF for faithful progression. **Why the completion-time half is required (found by user repro):** when ANY single-player cheat is active, `g_AppendCheatSinglePlayer` makes mission-select (`get_highest_unlocked_difficulty_for_level`, front.c) accept only levels with status `STAGESTATUS_COMPLETED (3)`, not merely `UNLOCKED (1)` — AllUnlocked's debug-flag path returns 1, so enabling a cheat locked every level ("could only do Silo"). Reading the save as a fully-completed campaign satisfies both branches. Time bit-math mirrors `fileGet/SetSaveStageDifficultyTime` verbatim (10-bit fields, difficulties 0–2; 007 times are virtual). **Pitfall hit during implementation:** the first mirror of `save_data` used `times[80]` instead of `(SP_LEVEL_MAX-1)*4 = 76`, making `sizeof` 100≠96 so the block-read size check silently never matched — now enforced by a compile-time `ge_save_slot_size_check`. **Semantics:** unlocked cheats/completions persist to `ge007.eep` on any subsequent save write (self-consistent; mirrors N64 where completing a level permanently unlocks its cheat). No *active* cheats are enabled (invincibility/all-guns stay OFF); weapons remain per-mission pickups. **Verified:** fresh eep — all 5 slots validate `stored==calc` under `GE_SAVELOG=1`; user playtest: all levels selectable at each difficulty, 007 mode present, cheat menu populated, cheats toggle and work (time-modifying cheats untested by user), mission select intact with all cheats ON. | FIXED — verified by user playtest (PR #79 merged); default flipped OFF for v0.2.0 (D259). |
| D259 | **`Game.AllUnlocked` (D257) breaks audio on a fresh save: with it ON, music and SFX load at volume 0 (silence). v0.2.0 ships with `AllUnlocked` default OFF.** User-observed during v0.2.0 artifact testing ("all unlocked seems to break sound"). **Mechanism:** a fresh `ge007.eep` is zero-filled, so every save slot is all-zero. On N64 such a slot fails `fileValidateSaves`' CRC check and is rewritten as `BLANKSAVEDATA` (`src/bondconstants.h:2148`), whose `music_vol`/`sfx_vol` are `0xFF` (max). With AllUnlocked ON, `geEepromPatchAllCheats` (`port/src/libultra.c`) patches the zeroed slots (cheat bits + completion times) and recomputes a **valid** per-slot CRC, so `fileValidateSaves` accepts them as-is and never calls `fileResetSave`. The volume bytes therefore stay 0, and `fileLoadSettingsForFolder` (`src/game/file2.c:1289-1290`) loads them verbatim: `set_mTrack2Vol((save->music_vol << 7) | (save->music_vol >> 1))` = 0 → silence. With AllUnlocked OFF the zeroed slots fail CRC and reset to `BLANKSAVEDATA`, which is why sound works in the default configuration. **Disposition for v0.2.0:** flip `portAllUnlocked` default to 0 (`port/src/video.c`; `SkipIntro` was already 0) — faithful N64 progression out of the box, F10 *All unlocked* still enables the goodie. The quirk then only affects users who deliberately toggle it ON with a fresh save (recoverable: raising the in-game volume writes a nonzero slot, after which the patch leaves volumes alone). **Deferred fix candidate (port-only, ~6 lines):** in `geEepromPatchAllCheats`, detect all-zero slots and seed `music_vol`/`sfx_vol = 0xFF` before the CRC recompute, emulating what `fileResetSave` would have done; real (nonzero) saves incl. a user-muted one stay untouched. | **FIXED in v0.2.1** — the deferred fix was applied: `geEepromPatchAllCheats` now detects all-zero slots and seeds `music_vol`/`sfx_vol = 0xFF` before the CRC recompute, emulating `fileResetSave`; real saves (incl. a deliberately muted one) stay untouched. **By-ear confirmed by the user** (v0.2.1 test pass: fresh save, AllUnlocked ON — audio plays correctly). |
| D260 | **v0.2.1 (user request): mouse-wheel weapon-select direction swapped.** Wheel-up now selects the *previous* weapon and wheel-down the *next*, matching the in-game wheel menu's on-screen layout (the port had them inverted vs the user's expectation). Port-only: `inputPostWheel()` (`port/src/input.c`) — the notches>0 / notches<0 branches were swapped so scroll-up emits the back-notch and scroll-down the forward-notch. N64 build untouched; no game-code edits. | FIXED — build green; one-line branch swap, trivially reviewable. |
| D261 | **v0.2.1 (user request: "fix watch menu sensitivity for wasd/controller buttons"): holding W/S (or a fully-pressed stick axis) on the watch inventory page crawled one item per tap — no repeat.** Root cause is the interaction of two existing behaviours: keyboard W/S map to `stickY = ±STICK_MAX (0x50)` and SDL pads peg at 0x80, both *outside* the smooth-scroll band (0x1f..0x45) that D118d intentionally left for the port — so a held press only ever hit the latched single-step path (`watch_stick_y_pressed_*`, one step per press edge, no repeat). On N64 the same stick level was the *fast-scroll* band (one step per frame), which D118d removed because digital sources peg there and it became runaway scroll. **Fix (port-only, `#ifdef PORT` in `src/game/options.c`):** a bounded auto-repeat helper `geWatchStickFastStep()` — while the stick is held past the smooth band (≥ 0x46 up / ≤ −0x45 down), step one extra item every `GE_WATCH_NAV_RATE` (6) frames after an initial `GE_WATCH_NAV_DELAY` (15)-frame hold; the first press still goes through the untouched latched path, so a tap behaves exactly as before, and the smooth band + D-pad/C-button edge-repeat are unchanged. Called from `game_options_inventory_navigation()` only. **Verified:** build green; headless smoke (`GE_INPUTSCRIPT` long-W-hold on the inventory page) runs clean. Feel-check (repeat rate) owed to the user — both constants are at the top of the helper for a one-number tune. **User feel-check (v0.2.1 test pass): repeat "feels fine for now", but W/S is still too sensitive overall — desired behaviour is tactile one-press-one-item with no risk of scrolling the whole list on an over-held key; candidate tweaks (not yet done): longer `GE_WATCH_NAV_DELAY`, slower `GE_WATCH_NAV_RATE`, or gating the auto-repeat off for keyboard taps entirely (repeat only for analogue stick). Logged, no code change. | FIXED — feel-check received; W/S sensitivity follow-up logged above. |
| D262 | **v0.2.1 (user report): "3D models missing on watch menu screen item select" — the selected-item 3D preview does not appear on the watch inventory page.** User visually confirmed this session: no 3D model on the item-select page where one should be (default selection is *unarmed*, which has no model — a weapon must be scrolled to with S/DOWN). Headless repro (`GE_INPUTSCRIPT` pulse train + `GE_PCDUMP`, Dam `-level_33`): watch can't open during the intro freeze (`bondviewFrozenMoveBond` zeroes input while `g_CameraMode == CAMERAMODE_INTRO`; freeze ends ~frame 1400–1500 on Dam, and controller reads run faster than sim ticks, so scripts are timed by trial); once open + paged to INVENTORY (R_JPAD), the watch UI renders but the centre of the face is empty for every item. **Root cause found and fixed as D264** (`renderdata.flags == 0` in the watch subdraw pass — the cross-global ModelRenderData template read zeroes on PC; `subdraw()` then gated *every* geometry node, emitting exactly 1 word instead of the model's commands). Residual: with flags restored, the preview emits matrix/light-setup words (71 for item 5) but dump frames still show no visible centre geometry — a possible second layer (LOD/distance gating or fast3d rasterisation of the sub-DLs) remains unverified; user visual re-check pending. | FIXED (primary cause, D264) — pixel-level confirmation pending user run. |
| D264 | **D262's root cause (ABI/layout class, cf. D140/D52): the watch item preview's `ModelRenderData` template is read across two adjacent globals that no longer sit adjacent on PC.**`set_enviro_fog_for_items_in_solo_watch_menu()` (`src/game/gunfire.c`) starts with `ModelRenderData renderdata = *((ModelRenderData *) (&D_80035D00));` — a 64-byte copy that on N64 spans `D_80035D00` (a zeroed u32, .bss) *and*`D_80035D04[] = {1, 3, 0…}` (.data): the effective template is the one documented in the comment above `D_80035D00` in `gun.c` — `basemtx=NULL, zbufferenabled=TRUE, flags=3, rest zero` (`CULLMODE_BOTH` is 0). The PC link places .bss and .data in separate sections, so the read is all zeros: **`flags==0`**, and every geometry emitter in the subdraw node dispatch gates on `renderdata->flags & 1/2` (e.g. `modelRenderNodeDl`) — so `subdraw()` emitted exactly one word (`gSPSegment`) per preview, no matter the item. Probes this session: entry gates all pass (hand has item, model header non-NULL), node tree present (~400 nodes, isLoaded=1), world-path geometry nodes carry valid built DLs — but in the watch pass `flags=0` on every node. **Fix (`#ifdef PORT`, values identical to the N64 ROM bytes):** explicit template — `ModelRenderData renderdata = {0}; renderdata.zbufferenabled = TRUE; renderdata.flags = 3;`. Verified at Gfx level: preview emission went 1 → 71 words (item 5) / 81 (mid-scroll) / 9 (item 47, knife-class). Note for follow-up: the 71-word dump is a repeating {matrix/segment/light} unit with no `gSPDisplayList` in the first 24 words — if the user's re-check still shows no visible model, next suspects are LOD/distance gating of the local watch-space matrices (`modelUpdateDistanceRelations`, cf. 7c48f664) or fast3d handling of the preview sub-DLs (segment 3 / render modes). | FIXED — build green, probes removed; visual confirmation pending. |
| D263 | **v0.2.1 (user report): Facility — "ouromov not shooting Trevelyan"; the scripted opening beat never triggers. RESOLVED: not a port bug — the beat is proximity/LOS-triggered exactly as on N64, and the full chain (incl. the execution shot) was verified firing end-to-end on PC.** The triage suspects (tick pacing, camera-mode gate, silent anim lookup) were all ruled out at runtime. **The beat, fully mapped** (static decode of `UsetuparkZ.c` — remember every DBYTE/TBYTE in the dump is bswap16/32'd relative to game interpretation): Trevelyan = guard chr 67 (body 9 = Trevelyan_006, pad 131) on ai_18: walks to pad 124, wanders, then loops a sees-Bond check; **only when Bond is in his vision** (range <10k, facing ±110°, fog, random gate ≈1/49/tick, LOS + *same stand tile* via `chrCanSeeBond`) does he jump to ai_19: runs at Bond, alarm + dialogue timers, sets an objective bit; BG list ai_41 (ID 0x1000, auto-started) polls that bit and spawns **Ourumov (body 7) at pad 88 with ai_23** plus a 6-soldier squad; ai_23 renumbers him to chrnum 78 and arms him → ai_22: runs to pad 123, waits for Trevelyan within 0x1400 of it, ~95 s dialogue cascade, then the **execution shot** (`HitChrWithItem` from 78 on 67). It is a multi-minute sequence that *starts* only when the player walks Bond west and down a floor into Trevelyan's room — Bond spawns 8900 units away on the upper floor with LOS blocked (probed directly: `stanTestLineUnobstructed` = obstructed, different stan tiles), identical geometry to N64. **Runtime verification** (`-level_34`, `#ifdef PORT` probes in the AI executor/vision path, all since removed): all 15 BG lists + guards start; Trevelyan alive on ai_18; every vision gate passes except the geometric ones at spawn distance; player movement confirmed working (note for future headless tests: under the port's default SOLITARE/natural-pitch scheme, walking is C-button/D-pad fed — W→C-up — and the analog stick carries pitch, so `GE_INPUTSCRIPT` SUP/SDOWN tokens do *not* walk Bond there; use UP/DOWN pulses). **Decisive test:** a temporary PORT-only teleport probe dropped Bond 65 units from Trevelyan at t≈12 s → seconds later: vision fires (chr 67 → ai_19), objective bit set, `SpawnChr body=7 pad=88` + squad, chrnum→78 on ai_23; ~2½ min later **`HitChrWithItem from_chr=78 target=67 part=8 item=6`** — Ourumov executes Trevelyan. Most likely explanation of the report: the tester never walked Bond into Trevelyan's sightline (or pre-D253 crash masked it). No game-code or port-code change required. **Kept from this session (test-harness only):**`port/src/input.c` — `GE_INPUTSCRIPT` analog-stick tokens were applied *after* the stick was already written out, so SUP/SDOWN/SLEFT/SRIGHT were silently discarded; the script override now runs before the clamp/write. | RESOLVED (not a bug), but refined (v0.2.1 further user testing, 2026-09-15) — beat verified firing end-to-end on PC incl. execution shot; **user playtest confirmed the sequence plays normally.****Refinement: the exact trigger behavior is significantly sensitive to Bond's precise location and does not fire 1:1 identically to the N64 original across different approach paths/positions** — consistent with the beat's own documented sensitivity (range/facing/LOS/same-stand-tile + a ≈1/49-per-tick random gate, see the mapped mechanism above), so this is likely the same finicky-trigger behavior rather than a new bug, but not confirmed byte-identical to N64 for every approach. **User confirms this does NOT block level progression** either way — logged for awareness, not tracked as an open defect. Probes removed; input-script stick fix kept. |
| D265 | **D236 investigation (port-layer, `port/fast3d/gfx_pc.cpp`): the tree cards are IA8/IA4, not IA16 — and `import_texture_ia4` decoded the RDP's I2:A2 texel as I3:A1, inverting the alpha ramp. Fixed; awaiting user visual re-test.** The earlier sessions misidentified the format: a live tri-time tile census (probe in `gfx_sp_tri1`, since removed) shows level_36's tree-card classes are (a) **IA8, line=64B → 64×16** (texdump r107), drawn OPA/bilerp/1-cycle/AC_NONE — *genuinely fully opaque* (A nibble F on 1023/1024 texels; opaque on N64 too) — and (b) **IA4, line=64B → 128×16** (texdump r117), drawn ZMODE_XLU/point/2-cycle with m2a=CLR_MEM/m2b=1MA, i.e. texel-alpha blended. There are no 32-wide IA tiles at tri time; the prior "32×32 tile" reading was wrong, and the whole earlier IA16 byte-pair/I11:A5 debate (r107) was analyzing an IA8 image as byte-pairs. **The wall's root cause:** r117 decoded as I2:A2 is a strip of ~13 discrete conifer silhouettes (trunk+canopy shapes visible in the ASCII render, `scratch/r117_ia4_alpha.png`) with a 3-level alpha ramp {0: 95% of texels, 85, 170} — faint dithered distant trees on N64. But `import_texture_ia4` read each nibble as intensity=`part>>1` (3-bit, SCALE_3_8) + alpha=`part&1`: A2=1 canopy pixels became **fully opaque**, A2=2 edge dithering became **invisible** → opaque repeating tree silhouettes = the "wall". The bug is inherited verbatim from the PD port (`pd_port/port/fast3d/gfx_pc.cpp` has the identical I3:A1 read — latent there, fatal for GE's tree strip). **Fix (applied):**`intensity = part>>2`, `alpha = part&3`, both via new `SCALE_2_8`; this is the hardware-correct RDP IA4 layout. Also cleaned up: the ia16 importer comment now states plain I8:A8 (the byte-pair read *is* correct IA16; the old "r107 proof" was misidentified data), and the earlier part-1 dimension fix stays (harmless/correct for real IA16 tiles). **Caveats/owed:** (1) user visual re-test of Surface 1 trees — headless A/B was in flight when work halted; a cross-run region diff was inconclusive (camera drift between captures); (2) the decode change affects *every* IA4 tile game-wide — regression-sweep other levels for dithered/alpha IA4 art that now renders with correct 3-level alpha; (3) if a hard repeating band remains after the fix, it is the opaque IA8 strip (a), which matches hardware and D236 needs re-scoping. **OUTCOME (user re-test): NEGATIVE — trees still render as the wall with the I2:A2 decode applied; the change was fully reverted (`git checkout` of `port/fast3d/gfx_pc.cpp`, back to the original PD-derived importers) and work is parked.** The census/RM/texture data above stands as evidence for the next attempt: the decode was *necessary* (I2:A2 is the hardware layout and r117 is unambiguously an authored tree strip under it) but *not sufficient* — remaining suspects, in order: (i) fast3d's blending of these XLU/2-cycle cards (texel alpha reaching the GL blend at all — the `texture_edge`/`SHADER_OPT_TEXTURE_EDGE` path forces `a>0.19 ? 1 : discard`, which for a 3-level ramp quantizes to opaque/discard, i.e. still hard-edged), (ii) per-card UV windowing/placement (all captured cards sampled the same u:0..64/v:0..16 window — confirm against N64), (iii) fog/distance. Note for the next session: verify what the N64 actually shows in that region before more port-side changes (the opaque IA8 strip is faithful to hardware either way). | PARKED — reverted to baseline; evidence + suspect list above. |
| D258 | **PAL/JP asset conversion is broken at the source-data level — `scripts/filelist.e.csv` and `filelist.j.csv` carry naming defects, so `gen_romassets.py e/j` (and the d43/d69/d88 emitters' `find_row`) cannot resolve PAL/JP builds. v0.2.0's drop-in ROM feature therefore ships NTSC-U only.** Discovered while scoping issue #6 (drop-in ROM). **PAL:** 31 of the 512 model rows in `filelist.e.csv` have truncated/renamed basenames (an extraction artifact — e.g. `Pbridge_console`×6 where the resource table says `Pbridge_console1aZ…3bZ`, `Pchrgrenadelauncher` for `PchrgrenadelaunchZ`). **JP:** one font row is mislabeled — `fontBankGothic_fontchartable.bin`@3044276 is named `fontZurichBold_fontchartable.bin` (a duplicate label). Both were proven without a PAL/JP ROM: (a) greedy two-pointer sequence alignment of the 512-row `file_resource_table.inc.c` model order against each CSV's model rows gives an exact 1:1 match (481 by-name + 31 positional for PAL), offsets strictly non-decreasing, and zero size anomalies vs US (all within [0.5×, 2×]); (b) JP font row proven by the contiguity invariant — Gothic kerning @3043600+676 = 3044276 exactly hits the mislabeled row, whose size (8716) equals the US Gothic chartable, while Zurich's own chain is contiguous one row later. **Consequence:** no PAL or JP PC exe has ever been buildable from this tree (latent — only NTSC was ever exercised); the CSV `file` column is a label only (emitters look up by basename, never read the path), so the repair is a pure data fix: rename the 31 PAL model rows + 1 JP font row to the exact table symbols. **Scope decision (v0.2.0):** ship drop-in ROM support for NTSC-U only — `prepare-assets.py`/the frozen `ge007-convert` gate non-US ROMs with a clear message after SHA-1 region detection; the CSV repair + multi-region matrix + per-region exes are deferred to the Part B follow-up. | OPEN — repair path verified, not applied (out of v0.2.0 scope). |
| D255 | **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, acLine truncated
**ROOT-CAUSED AND FIXED, same day, live Deck session over SSH (M-140).** The `objFree`/destroy-path-race theory above was a red herring — set up a real Deck test (SSH access, key-based auth, WSL-cross-compiled local Linux build deployed and run live against the user's own prepared ROM assets), reproduced the crash directly, and symbolized it on-device against the build's own debug info. **First capture with the `GE_D255` probe active:** the probe's `ObjectRecord.model == NULL` guard did NOT fire, yet the game crashed anyway at `vtxstore.c:199` (`var_v1->RootNode`), `FAULT ADDR: 0x10`, `Rdi=(nil)` — a *different* pointer than the one the probe was checking. **Actual bug:**`sub_GAME_7F09BAC4` (`vtxstore_fix_refs`) reads the record's `Model*` through `((Model*)((ChrRecord*)prop->chr)->chrflags)`— a union pun onto `ChrRecord.chrflags`, which is typed `CHRFLAG`, a **4-byte enum**. On 32-bit N64 this is bit-exact (both the enum and a pointer are 4 bytes, so casting the enum's value to `Model*` reproduces the real stored bytes). On 64-bit PC, `Model*` is 8 bytes but the enum read is still only 4 — the cast reads just the pointer's low 32 bits as an `int` and zero-extends it back to a 64-bit address, discarding the real high bits entirely. This is the **exact D3x pointer-width ABI class** documented in AGENTS.md/porting-notes — a struct field misread across the 32→64-bit pointer-width transition — and it was *already flagged* as suspect (a) in this very row's first paragraph, back in the original v0.2.0-pre investigation, then wrongly deprioritized in favor of suspect (b) (the NULL-model theory) once a live register capture happened to show `Rdi=(nil)` and looked like it confirmed (b). It didn't: `(nil)` is exactly what a 32-bit-truncated read produces whenever the real pointer's low 32 bits happen to be zero — a coincidence that pointed the whole investigation at the wrong field for two sessions. **Proof it was the field, not the object:** this session's `GE_D255`probe read the SAME record through the *correctly*-typed `ObjectRecord.model` field (`Model*`, 8 bytes) and it was **always a valid, non-NULL pointer** — the object's model was never actually missing. **Fix (ABI-class, semantics-preserving, `#ifdef PORT`; `src/game/vtxstore.c`, `sub_GAME_7F09BAC4`):** read the pointer through `ObjectRecord.model` (the same union memory, correctly typed as `Model*`) instead of through the mistyped `ChrRecord.chrflags` alias, at both use sites in the function (the `RootNode` walk and the `modelGetNodeRwData` call). Identical bytes and identical result on N64 (both fields are 4 bytes there); correct on 64-bit PC. The earlier `GE_D255` diagnostic skip-guard (which checked the wrong field and would never have actually fired) is removed — it's no longer needed, the record's model was never really missing. **Verified live, in order:** (1) diagnostic build with the guard checking BOTH fields caught the exact moment of divergence — `ObjectRecord.model` valid, `ChrRecord.chrflags`-cast pointer producing the crash — nailing the mechanism beyond doubt; (2) the real fix (no guard, just the corrected field) held through the *entire* Facility level including multiple terminal destructions (the most consistent prior repro) and through Caverns' radio-objective terminals (the second confirmed level) with zero crashes, live on the user's own Steam Deck hardware over the ROM/assets they already had prepared. Windows rebuild + full 21-level `verify.sh sweep` also run this session as the standard regression gate. | **FIXED** — root-caused (D3x-class 32-bit pointer truncation via a mistyped enum-to-pointer cast, not a destroy-path race) and verified live on real Steam Deck hardware across both confirmed-repro levels (Facility, Caverns), zero crashes; Windows sweep clean. The separate audio-thread crash (D285) is untouched by this fix. |
| D256 | **Saves (and config) silently failed to persist on Linux whenever the game was not launched from the directory containing `data/` — reported as "saves do not…** — full `## D256` entry at file tail | FIXED (path half) — save-flow detail still owed from the user. |
| D257 | **New `Game.AllUnlocked` option (F10; default flipped OFF for v0.2.0 — see D259): skip N64 progression entirely — all 21 solo levels selectable at every…** — full `## D257` entry at file tail | FIXED — verified by user playtest (PR #79 merged); default flipped OFF for v0.2.0 (D259). |
| D259 | **`Game.AllUnlocked` (D257) breaks audio on a fresh save: with it ON, music and SFX load at volume 0 (silence). v0.2.0 ships with `AllUnlocked` default OFF.** — full `## D259` entry at file tail | **FIXED in v0.2.1** —… |
| D260 | **v0.2.1 (user request): mouse-wheel weapon-select direction swapped.** — full `## D260` entry at file tail | FIXED — build green; one-line branch swap, trivially reviewable. |
| D261 | **v0.2.1 (user request: "fix watchmenu sensitivity for wasd/controller buttons"): holding W/S (or a fully-pressed stick axis) on the watch inventory page…** — full `## D261` entry at file tail | FIXED — feel-check received; W/S sensitivity follow-up logged above. |
| D262 | **v0.2.1 (user report): "3D models missing on watch menu screen item select" — the selected-item 3D preview does not appear on the watch inventory page.** — full `## D262` entry at file tail | FIXED (primary cause, D264) — pixel-level confirmation pending user run. |
| D264 | **D262's root cause (ABI/layout class, cf. D140/D52): the watch item preview's `ModelRenderData` template is read across two adjacent globals that no longer sit…** — full `## D264` entry at file tail | FIXED — build green, probes removed; visual confirmation pending. |
| D263 | **v0.2.1 (user report): Facility — "Ourumov not shooting Trevelyan"; the scripted opening beat never triggers. RESOLVED: not a port bug — the beat is…** — full `## D263` entry at file tail | RESOLVED (not a bug), but refined (v0.2.1 further user testing, 2026-09-15) —… |
| D265 | **D236 investigation (port-layer, `port/fast3d/gfx_pc.cpp`): the tree cards are IA8/IA4, not IA16 — and `import_texture_ia4` decoded the RDP's I2:A2 texel as…** — full `## D265` entry at file tail | PARKED — reverted to baseline; evidence + suspect list above. |
| D258 | **PAL/JP asset conversion is broken at the source-data level — `scripts/filelist.e.csv` and `filelist.j.csv` carry naming defects, so `gen_romassets.py e/j` (and…** — full `## D258` entry at file tail | OPEN — repair path verified, not applied (out of v0.2.0 scope). |
| D285 | **Steam Deck SIGSEGV during D255 live-testing = SEPARATE bug: the `sndHandleEvent` preempt-scan walks the active-sound list unlocked (M-140; fixed M-141).** — full `## D285` entry at file tail | **FIXED (M-141, `src/snd.c`)** — `osSetIntMask` lock added around the preempt-scan, matching `sndSetupSound`'s existing unconditional (no `#ifdef PORT`) pattern in the same file; build green, `-level_09`/`-level_20` unregressed (1400+ frames, no crash, no D152 lock-stuck warnings). Not yet gdb/live-confirmed against a real reproduced crash — see M-141 body. Do not conflate with D255 — different thread, different subsystem. |
| D293 | **QoL ask: no discoverable way to quit the game from the UI (M-141, Steam Deck user playtest — "couldn't see a way to exit the game. Maybe build this into the…** — full `## D293` entry at file tail | FIXED (M-141) — "Quit to desktop" row added to the F10 overlay; build-verified Win+Linux, not yet live-confirmed.… |
Phase 2 replaced the Phase-1 demo loop with the real `mainproc()` on real OS
threads, compiled GE's real `src/sched.c`, and brought in PD's fast3d software
1. Single instrumented run: `-level_36` + `GE_D270PIX` (both weave points) + `GE_TEXRAW` + `GE_PCDUMP=1400-1600:1`; correlate per tick — does a fog=25 `0x0c184b50` tri cover the point AND is the pixel dark?
2. Log `curRoom`/`roomsDrawn` (D271 infra) alongside `GE_D270PIX` to correlate `0x0c184b50` presence with room-streaming state.
3. Decode `0x0c184b50`'s CI8 texture offline — needs a run in which it actually uploads (TEXRAW has not captured it yet).
## D285 — M-140: a real Steam Deck SIGSEGV during D255 live-testing turned out to be a SEPARATE, previously-undocumented bug — an unprotected shared-list read in `sndHandleEvent`'s preempt-scan (`src/snd.c:366`), classic D4-class main-thread-vs-audio-thread race, case identified but NOT fixed
**Context.** While setting up a live Deck test session for D255 (SSH access, `GE_D255=1`), found a fresh `ge007.crash.log` on the user's Deck (mtime inside the user's active testing window, confirmed by the user as "any crashes from the last hour or two on that build is my testing") from the **published v0.2.1 binary**, which still had its own debug symbols (`with debug_info, not stripped`). Symbolized directly on-device with `addr2line` against the exact crashing binary — not a rebuild, not a guess:
**This is a different crash from D255.** D255's known signature is `FAULT ADDR: 0x10`, PC inside `vtxstore_fix_refs` (render/game-logic thread, via `bossMainloop`). This one is `FAULT ADDR: 0x62`, entirely inside the **audio thread** (`portThreadWrapper` → `amMain`), a different subsystem and a different thread. The user's testing session (explosions/terminal-destroy repro) apparently hit *this* bug at least once, not necessarily D255 — the two may have been conflated in earlier reports because both surface as a SIGSEGV during the same kind of combat/explosion testing.
**Root cause (static, code-read only — not yet gdb-confirmed on a live crash).** `src/snd.c:366` is `iterState->unk3e` inside `sndHandleEvent`'s pool-exhaustion preempt-scan:
`D_800243E4` (`struct D_800243E4_s D_800243E4 = { {NULL, NULL}, NULL };`) is the sentinel head of the active-`ALSoundState` linked list; `node.prev` is genuinely `NULL` when the list is empty (verified: `sndDisposeSound`, `snd.c:868-875`, resets `node.next`/`node.prev` to `NULL`-adjacent state as the list empties; `sndSetupSound`, `snd.c:802-824`, is the only inserter, always under a lock — see below). This do-while dereferences `iterState`**before** its own `!= NULL` check ever runs (do-while always executes the body once) — safe *only* if the caller can guarantee the list is non-empty. The scan is gated on `limitReached` (the 8-voice pool is full), which *should* imply at least 8 live nodes — so a `NULL` here means the list state and the pool-full accounting have desynced.
**Prime suspect: unprotected concurrent access, the established D4/D147/D152 race class** (`docs/porting-notes.md` §D4 — "N64 `osSetIntMask(OS_IM_NONE)` is their only mutual-exclusion primitive; on PC the 'audio interrupt' is a real preemptible thread, so a no-op shim = no mutual exclusion = concurrent linked-list mutation" — already fixed generally via a process-wide recursive mutex, D4/D152). Checked directly this session: `sndSetupSound` (the `D_800243E4` list-insert path, reached via `sndPlaySfx`, which **15 different `src/game/*.c` files call directly** including `explosion.c` — i.e. from whatever thread the caller runs on, not queued through the audio thread) wraps its list mutation in `osSetIntMask(OS_IM_NONE)`/restore (`snd.c:807,827`). **`sndHandleEvent`'s entire body (`snd.c:263-668`) — including the preempt-scan that reads/walks/mutates the same `D_800243E4` list — never calls `osSetIntMask` at all.** A writer (`sndSetupSound`, potentially running on the main game thread via a direct `sndPlaySfx` call) is lock-protected; this reader (`sndHandleEvent`, running on the audio thread) is not — a classic asymmetric-locking gap, exactly the shape that lets one thread observe another's list mutation mid-update.
**Fits the reported repro pattern.** Explosions synthesize a burst of near-simultaneous SFX triggers (explosion + debris/glass + metal), i.e. a burst of `sndPlaySfx` calls in a tight window — the highest-contention case for a race that needs the audio thread's preempt-scan (only entered when the 8-voice pool is *exhausted*, itself a busy/high-SFX-load state) to overlap a main-thread insert/remove. This matches the user's report of explosions/terminal-destruction reproducing it and Frigate (presumably lower simultaneous SFX load in that test) not reproducing it — a timing race, not a deterministic trigger, fits both observations.
**NOT fixed — two open questions before it should be.**
1. **Is the missing lock a genuine N64-decomp-fidelity gap, or ground-truth-faithful?** Unlike D4's already-fixed cases, this would be a *new*`osSetIntMask` call site in decompiled game code (`src/snd.c`), not a shim-implementation change. If the real N64 assembly at this function's address (comment header cites `9630 70008A30`-style N64 addresses elsewhere in this file — check `sndHandleEvent`'s own) already has an interrupt-mask pair around the equivalent scan that the decompiler dropped, restoring it is a decomp-fidelity fix (expected, not a rule-#2 case). If N64's real code genuinely never masks interrupts there (plausible if the real audio ISR's preempt-scan only ever runs from within the ISR itself, inherently serialized against the main CPU by hardware, with no real race possible on N64), then adding a new lock here is the same class of exception D4 already established (a PC-only concurrency fix, not a behavior change) — but that determination needs an MIPS-disassembly check against the real ROM, not a code read of the decomp alone.
2. **Not yet reproduced/confirmed via gdb on a live crash** — this is a strong static lead from one real crash's symbolized backtrace plus a code read, not a confirmed root cause. The next Deck session should `gdb attach` (per D253's established recipe) while reproducing via a burst of simultaneous SFX (an explosion is the natural trigger) and confirm `D_800243E4.node.prev == NULL` (or a torn/partially-updated node) at the fault, and check the exact other thread's state at that moment if possible.
**Do NOT conflate with D255** — different fault address, different thread, different subsystem, different code path. Both may be independently real; testing one does not rule out the other, and a fix for one will not affect the other.
**Next, concrete:** (1) settle question 1 above (N64 disassembly check or accept the D4-class port-layer exception); (2) if a lock is warranted, wrap `sndHandleEvent`'s preempt-scan (and audit the rest of the function for the same gap against every other `D_800243E4` touch point in the file) in `osSetIntMask(OS_IM_NONE)`/restore, matching `sndSetupSound`'s existing pattern; (3) live-verify via gdb on a real reproduced crash, not just the static read.
**M-141: FIXED, per the accepted D4-class port-layer exception; not yet live-verified.** Question 1 is settled by precedent rather than a fresh N64 disassembly read: `sndSetupSound` (the paired writer) already takes `osSetIntMask(OS_IM_NONE)`/restore completely unconditionally — **no `#ifdef PORT` at all** — around the identical `D_800243E4` list mutation, in this same file, with no N64-behavior caveat noted anywhere near it. Since `osSetIntMask` is genuine N64 API (not a port shim call added out of nowhere) and the port's own `osSetIntMask` implementation (`port/src/libultra.c`, "Interrupt mask -> global recursive lock", D147/D152) is a **process-wide recursive** critical section — safe to nest inside `alEvtqPostEvent`/`alSynSetVol` calls without deadlock risk — adding the identical lock/unlock pair around `sndHandleEvent`'s preempt-scan follows the exact same already-accepted shape as its sibling writer, unconditionally, no `#ifdef PORT` needed (matches the surrounding code's own style). Audited the rest of `sndHandleEvent` (`snd.c:263-680`) for other `D_800243E4` touch points per the open item above: **the preempt-scan is the only one** — nothing else in the function reads or writes that list. Fix: `src/snd.c`, wraps `ALSoundState *iterState = ...` through the closing `} while (limitReached && iterState != NULL);` in `OSIntMask d285Mask = osSetIntMask(OS_IM_NONE);` / `osSetIntMask(d285Mask);`. **Verified:** build green; `-level_09`/`-level_20` boot 1400+ frames each with zero crashes and zero `D152: osSetIntMask lock stuck` warnings (which would fire if this new lock ever wedged). **Not verified:** a `GE_INPUTSCRIPT`-driven rapid-fire stress test (modelled on D219/M-90's scripted-Z-pulse approach) was attempted to force the 8-voice pool exhaustion this scan requires, but the script's own parse-confirmation log line never appeared in this run's output — the scripted-input harness did not visibly activate for a reason not root-caused this session (worth a quick look next time `GE_INPUTSCRIPT` is used: confirm it still fires on current `main`/`release/v0.3.0` before trusting a "no crash" result from it as a real stress test). No live Deck/gdb reproduction attempted this session (no Deck SSH session active). Given the D4/D152 precedent's exact-shape match, confidence in the fix is HIGH; confidence that it's been exercised under real load is LOW — the honest bar met here is "correct by precedent, build/sweep-clean," not "reproduced-then-fixed." Next: an interactive session (ideally the same live Steam Deck setup as D255/M-140, explosion-heavy combat, e.g. Facility/Frigate) should confirm the crash no longer reproduces.
**Live-verified, 2026-09-16, real Steam Deck hardware, v0.2.2 hotfix build.** User ran Archives with dual grenade launchers plus guards spawned with the rocket-launcher cheat — sustained, dense overlapping-explosion combat, exactly the 8-voice-pool-exhaustion / SFX-burst scenario this fix targets — with no crash. This is user-reported (not gdb-attached or `D_800243E4`-state-captured at the moment of stress), so it does not pin the exact race window the way a live-fault capture would, but it is a real, on-hardware, high-load stress test of the fix's actual trigger condition, not just the build/sweep-clean bar noted above. Confidence upgraded from "correct by precedent, unexercised under real load" to "correct by precedent, exercised under real load with no failure."
## D286 — M-140: user-confirmed, real (not system-side) ~8s frame freeze on Caverns, Steam Deck, same live session as D255/D285 — self-recovered, not a crash; case not yet root-caused
**Context.** Caught live during the same Deck SSH session used to fix D255: the game's own hang watchdog (`portHeartbeatCheck`, `port/src/libultra.c`) fired once, logging `kernel heartbeat: no frame rendered for 8008866 us (frames=46404); state:` (unit bug in the log line, see below — the raw value is *microseconds*, printed with a `ms` suffix and no `/1000`; fixed this session, `port/src/libultra.c`) followed by a per-thread/per-queue state dump and a `crashDumpThreads` backtrace of the watchdog thread itself (`portTickThread` → `portHeartbeatCheck` → `crashDumpThreads`, `port/src/libultra.c:266/315`, `port/src/crash.c:458` — this is the watchdog reporting on *itself*, a diagnostic dump, not a fault). **User confirmed by direct observation: this was a real, visible freeze** (not a display/system hiccup — the Deck's own WiFi had one unrelated sleep/wake event earlier in the session, so system-side causes were a live alternative hypothesis, now ruled out by the user's own eyes on the screen), happening "pretty early" in a Caverns playthrough, not tied to an explosion or terminal (ruling out D255/D285 as the cause — this is a third, distinct issue).
**Outcome: self-recovered.** VI-post/frame-render logging resumed normally within the same log stream immediately after the watchdog fired, and the session continued (the user later closed the game normally, no crash log, all 5 game threads still `started=1`/`exited=0` at the time of the dump — nothing was permanently deadlocked). This is a genuine ~8s *stall*, not a hang or a crash.
**Not root-caused.** No distinguishing log line immediately precedes the stall (just routine `D51 vi post` lines) — nothing points at a specific subsystem yet. Caverns is one of the larger/more complex levels (cross-ref the D250 "FPS inconsistent" lineage, though D250's own symptom was sustained sub-60fps, not a multi-second total stall, so likely a different mechanism — a one-time heavy room/asset stream-in stall is the leading guess, unconfirmed). A single occurrence with no repro trigger identified; **do not spend session time trying to force a repro** without a better lead. If it recurs, the next capture should note (a) exact in-level location/trigger if any pattern emerges, (b) whether `Rooms`/`roomsDrawn` or a texture/model load was in flight via existing debug logging, and (c) whether it's Caverns-specific or level-general (test other large/open levels — Streets, Egyptian — the D249/D250 neighbourhood).
**Also fixed in passing (cosmetic, no behavior change):** the heartbeat log's unit label — `now - g_lastFrameUs` is computed in microseconds but was printed as `%llu ms` without converting; now divided by 1000 so the log actually reads correct milliseconds. | OPEN — one user-confirmed real freeze on Caverns (~8s, self-recovered, no crash), not root-caused, no repro trigger identified; distinct from D255/D285. Log-unit cosmetic fix landed alongside.
## D293 — QoL ask (M-141, Steam Deck user playtest): no discoverable way to quit the game from the UI on a controller-only setup
**Context.** Deck/controller-only setups had only window-close/Alt+F4 to quit; no in-UI affordance.
**FIXED.** Added a `ROW_ACTION` row kind (not config-backed, same pattern as the existing `__Resolution` row) to the F10 options overlay ("Quit to desktop") that triggers the identical `configSave()`+`exit(0)` path `SDL_QUIT` already uses. Build-verified on Windows and Linux; `verify.sh dam` PASS, no regression.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.