From c7898d1620fc171cf90414dfc1348485afa47091 Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:28:25 -0400 Subject: [PATCH] D254/D255: stop the Linux crash handler eating its own logs; document the Facility firefight crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The POSIX handler re-entered on SIGABRT (sysFatalError -> abort) and truncated ge007.crash.log, so field crashes came back as bare abort records with no faulting PC or registers. Guard against re-entry, terminate directly with _exit(), and dump si_addr + the general register set from the ucontext — a Deck crash now names the bad pointer on its face. Also documents D255 (new SIGSEGV in vtxstore's active-object walk during a Facility firefight on the Deck; disassembly shows the fault is a 32-bit-zero-extended read of ObjectRecord.model, misnamed ChrRecord. chrflags by the decompiler) and updates the release notes' Deck status. --- .github/release-notes.md | 9 +++++--- docs/dev/findings-index.csv | 4 +++- docs/dev/findings.md | 4 +++- port/src/crash.c | 45 ++++++++++++++++++++++++++++++++----- 4 files changed, 52 insertions(+), 10 deletions(-) diff --git a/.github/release-notes.md b/.github/release-notes.md index a3c02767..3306b526 100644 --- a/.github/release-notes.md +++ b/.github/release-notes.md @@ -51,9 +51,12 @@ selected** (e.g. the MSAA value appears both on its own row and again at the panel bottom); earlier builds showed it as an intermittent 4K- fullscreen ghost of the top row. -- **Not yet verified on real Steam Deck hardware** — the bundle is built for - it and its prime crash suspect from v0.1.0 was fixed, but please report any - Deck-specific faults (a `ge007.crash.log` next to the exe helps). +- **Steam Deck: one open crash remains** — the v0.1.0-era Facility crash is + confirmed fixed on Deck hardware, but a *new* SIGSEGV can occur during a + firefight in Facility (D255); diagnosis is in progress. Other levels are + untested there so far — please report any Deck-specific faults (a + `ge007.crash.log` next to the exe helps; recent builds now capture full + faulting registers in it). ### Downloads diff --git a/docs/dev/findings-index.csv b/docs/dev/findings-index.csv index b5dedd78..08850747 100644 --- a/docs/dev/findings-index.csv +++ b/docs/dev/findings-index.csv @@ -134,4 +134,6 @@ D249,OPEN,"LOD/culling breaks down on large open levels (Streets, Egyptian named D250,?,FPS is inconsistent/low on many levels since the port started actually running at 60fps —, D251,FIXED,F10 options overlay QoL pass:,"FIXED (port-only, port/src/optionsoverlay.c)." 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 — pending Deck re-verification with the re-cut bundle. +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 — needs one Deck crash to prove the new format in the field. +D255,OPEN,"Facility firefight SIGSEGV on Steam Deck (v0.2.0-pre CI bundle, D253 fix confirmed working —",OPEN — awaiting register capture from the Deck. diff --git a/docs/dev/findings.md b/docs/dev/findings.md index 60a71bd8..9c50c00e 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -530,7 +530,9 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D250 | **FPS is inconsistent/low on many levels since the port started actually running at 60fps — drops to 20–40fps intermittently, described as worse than before (M-119, user QA report, follow-on from D248).** Very likely a direct consequence of **D248** shipping: the port's render+tick loop had been silently running at half rate (30Hz) for the entire life of the project (D248, M-118) — the *real* per-level CPU/GPU cost of rendering and simulating this port was always this high, it was simply invisible while D248 halved the work being asked of the hardware every second. D248's own write-up flagged this as an open risk ("any level-specific perf regression now that render work happens twice as often per second") and explicitly left a live playtest / perf pass as un-done. Not yet investigated which side (CPU sim, CPU fast3d command generation, or GPU draw) is the bottleneck, or whether it's uniform or level-specific (the user's report implies specific/intermittent, which fits a per-scene cost — draw-call count, overdraw, or a specific expensive subsystem — rather than a flat doubled baseline cost). **M-120: partial progress, real bottleneck not yet caught.** User report sharpened: ~45fps at 720p on a self-described high-end PC, present even outside of dedicated stress levels — worse than the coarse "many levels, intermittent" framing suggested. Two things ruled out this session: (1) **build flags are not the cause** — `build-pc/CMakeCache.txt` confirms `CMAKE_BUILD_TYPE=RelWithDebInfo` (`-O2 -DNDEBUG`) is the actual default (`CMakeLists.txt:75-76`, unconditional unless a cache already exists), matching what `./build-pc.sh` produces with no extra flags — a debug/`-O0` build is not the explanation. (2) **idle-load is not the bottleneck, at least not in this dev box:** `GE_D193=1` on `-level_09` (BUNKER1, small level) idles at a rock-steady `render=60 sim=60`, frame-render time ~2.5 µs·10^3 (2.5 ms); `-level_29` (STREETS, one of D249's flagged large/open levels) idles at `render=55-60 sim=60-62`, frame-render time 6-7 ms — both comfortably under the 16.7 ms/60fps budget with no catch-up clamp hits. **This does not reproduce the user's 45fps** — either the real cost only shows up under actual gameplay load (AI/combat/particles active, not an empty idle scene — matches D193's own "player fine, AI-driven cost is different" pattern) or it's resolution/GPU-fill-rate-bound in a way this box's small idle test window doesn't exercise (this session could not drive real keyboard/mouse input into the game to generate a loaded scene — no interactive input tooling available headlessly). Also spotted and ruled out in passing: the `D51 vi post` debug log firing every render frame in `port/src/libultra.c:781` is throttled to ~once/sec (`% 60 == 1`), not a per-frame cost — not the culprit, though it's a `TEMP D51` instrument that's been live since early porting and could be removed as cleanup. **M-120 continued, live session with the user driving the game directly — the sim/render split is now confirmed live, and several strong candidates are ruled out, but the actual cost is still unlocated.** Reproduced directly on Dam (`-level_33`): `sim` holds a healthy 59-67/s throughout, `render` sags to 45-50/s, and the CPU-side `frame rendered in Nus` cost (`gfx_run`'s software-RSP interpretation, `port/src/libultra.c:1252`) stays only 6-10ms the whole time — comfortably under the 16.7ms/60fps budget. That leaves a genuine, unexplained ~10-15ms/frame, and a live A/B pass ruled out every obvious GPU-side suspect one at a time, each via the F10 in-game overlay while watching `GE_D193`: **MSAA 2→1 and Anisotropy 16→1 — no change.** **FovScale 125→100 — no change.** **VSync 1→0 (true uncapped rendering) — render still pinned at ~60 max, never higher, and the bad-scene numbers were unchanged** (also tells us the ~60fps ceiling itself is not a vsync artifact — something else already caps the loop at 60, vsync or not). **Resolution 1280×960 fullscreen → 640×480 windowed — no change** (rules out GPU fill-rate/pixel-count entirely; the cost does not scale with pixels at all). **Found and fixed a real, separate bug in passing (did not turn out to be the active cause here):** `sync_framerate_with_timer()` (`port/fast3d/gfx_sdl2.cpp`) computes the remaining frame budget in 100ns units (`qpc_to_100ns`) and passes it straight to `sysSleep()`, which takes **microseconds** — a genuine 10x oversleep whenever that branch fires. Fixed (`left / 10`). But this path only runs when `target_fps` is nonzero (tied to `Video.FpsCap`), and the user's test config had `FpsCap=0`/`VSync=0` — confirmed live that the bug fix made no difference to the reported drops, because the buggy code path was never executing in that config. Still worth landing as a correctness fix for whenever `FpsCap`/`VSync` *is* active. **Added `GE_D250`** (`src/boss.c`, wraps `lvlRender()`, the DL-building/culling/scene-traversal step that runs *before* `gfx_run()` and is separate from it) to check whether DL-building was hiding the missing time — live-tested on Dam: **`lvlRender` averages only 0.5-1.5ms, also not the bottleneck.** So: known costs (`gfx_run` ~6-10ms + `lvlRender` ~1ms) sum to ~7-11ms, real frame period is ~20-22ms (45-50fps) — the remaining ~10-15ms/frame is resolution-independent, GPU-setting-independent, and not in either of the two obvious CPU call sites. Traced the dataflow one level further: the game's main loop (`src/boss.c` `bossMainloop`) blocks on `osRecvMesg(&gfxFrameMsgQ, ..., OS_MESG_BLOCK)` waiting for `OS_SC_RETRACE_MSG`, posted by a dedicated pacemaker thread (`portTickThread`, `port/src/libultra.c`) at a fixed cadence independent of how fast the consumer drains it — meaning the observed render rate is whatever the consumer (game+render loop, one OS thread hop away from the pacemaker) can actually sustain, not a directly-configured cap. **Leading remaining suspect: the emulated N64 inter-"thread" message-passing/scheduler round-trip itself** (`port/src/gesched.c`/`sched.c`, real OS threads + mutex/condvar standing in for the N64's cooperative SP/DP/main threads) — each hop (game thread → scheduler → SP "thread" → done-message back) is a real OS context-switch-and-wake, and Windows condvar/thread wake latency can easily run several ms per hop, unlike the free N64 hardware handoff this code was ported from. **Not yet confirmed** — this needs either a targeted timer bracketing the full scheduler round-trip (same technique as `GE_D250`, just at a different call site) or a real sampling profiler (Visual Studio's own, or Windows Performance Recorder/WPR) attached to `ge007.x86_64.exe` during a live bad-fps moment, which would show this directly without more printf-probe guessing. Ad-hoc per-call timers have now ruled out the two most obvious candidates without finding the cost; a real profiler is the better next tool. **M-120 — ROOT-CAUSED AND FIXED: `getenv()`.** Set up a real profiler as recommended (`tools_pc/perf.ps1`, new sister script to `debug.ps1`, wraps `xperf` CPU-sampling + context-switch tracing). A live WPR/xperf capture on Dam, with Microsoft's public symbols resolved (`-symbols`, downloads system-DLL PDBs), showed the dominant render thread spending only **5.2% of its CPU time in the game's own code** — **43% was in `msvcrt.dll`**, and within that, `getenv()`/`_getenv_helper_nolock` alone accounted for **~50% of the thread's entire CPU time**. Root cause: `port/fast3d/gfx_pc.cpp`'s `import_texture()` (runs on every texture bind) and several per-triangle/per-`G_SETCOMBINE` combine-mode probe checks (`GE_DTEX`, `GE_TEXDUMP`, `GE_TEXRAW`, `GE_D229` ×2, `GE_D172` ×2, plus `GE_TEXDUMP`/`GE_PCDUMP` in `gfx_opengl.cpp`) called `getenv()` **uncached, every single call** — unlike the safe `static int x = -1; if (x < 0) x = getenv(...) != NULL;` pattern already used everywhere else in this codebase (`ft_d193`, `ge_d116`, `dvm`, etc.). On a texture-heavy scene this is tens of thousands of Windows CRT environment-lookup calls per second (each a locked, locale-aware linear scan per Microsoft's msvcrt implementation), completely invisible to both of M-120's earlier timers (`gfx_run`'s "frame rendered in Nus" and the new `GE_D250` `lvlRender` probe) because neither one specifically isolated CRT/library time from the game's own code. **Fix:** cached every uncached `getenv()` site in `gfx_pc.cpp`/`gfx_opengl.cpp` with the established pattern — narrow, mechanical, zero logic change. **Verified:** the exact idle-Dam headless test that was pinned at 45-50fps before the fix now holds a rock-steady 60-61fps, zero dips, with no other change. This also retroactively explains why M-120's earlier resolution/MSAA/vsync A/B pass (all "no change") was so consistent — the `getenv()` cost was a large, *per-call* (not per-pixel) constant that swamped any smaller resolution-dependent GPU cost at every setting tried, so the A/B genuinely couldn't have detected a fill-rate difference underneath it. **User re-tested 1440p/4K after the fix and confirmed it's fine too** — the initial "still runs badly at 1440p+" report was from before this fix landed (a stale-build mixup, same class as the DLL-load-error false alarm earlier in this session); once actually re-tested on the fixed build, high resolutions hold up fine. **D250 fully resolved, all resolutions tested clean.** | | D251 | **F10 options overlay QoL pass: (a) bottom rows drawn off-screen in the in-game 2D space — the layout assumed a ~320×240 field and hard-fit all rows, but `viSetXY` gives 440×330 on front-end screens (`src/game/front.c:8618`) vs 320×240 in-game (`front.c:8628`), so with 17 rows × 15u pitch + 2 header rows the last rows land at y≈282, off-screen whenever the panel is opened in-level; (b) MSAA row only cycled forward — `rowAdjust()`'s `ROW_MSAA` case clamped its index to [0,3] instead of wrapping, so left-click stuck it at 8x with no way down via clicks; (c) manual % rows (`Video.DrawDistance`/`Video.LodDistance`) showed unconditionally even while their `*AutoFov` toggles were on, where the percent is inert/misleading. | **FIXED (port-only, `port/src/optionsoverlay.c`).** (a) The panel now computes how many rows fit the *actual* current 2D height (`maxVisibleRows()` from `viGetY()`) and scrolls: a visible-row window (`s_scroll`) follows the selection, driven by wheel (`optionsOverlayScroll`) and arrow/D-pad at the list edges; layout, mouse hit-testing (`overlayRowAtY` now returns a visible position) and keyboard nav all index the visible list (`s_visIdx`/`s_visN`, `s_sel` is now a visible-list index), so it fits both 240- and 330-tall spaces with no per-space tuning. (b) `ROW_MSAA` wraps `(idx + dir + 4) % 4` — OFF→2x→4x→8x→OFF in both directions, matching the already-wrapping `ROW_ENUM`/`ROW_RES`; sliders keep clamping (standard for drag-able values). (c) New `Row.hiddenIfOn`/`hidePtr` fields: a row is hidden while the named option is nonzero — `Draw distance %` hides under `Video.DrawDistanceAutoFov`, `LOD distance %` under `Video.LodDistanceAutoFov`; rebuilt every frame in `overlayUpdateVisible()` (selection clamped into range when its row vanishes). Hint line updated to "click / drag / arrows / wheel". **Verified:** compiles clean in the full 241-target build; final link of this session was blocked only by a running game instance holding the exe (re-link pending). Not yet eyeballed live: scroll feel at 320×240, hover/scroll interaction, and the auto/% row appear-vanish transitions. Cross-ref D213 (FPS readout in the same file), D218/D249 (the auto-coupling these rows expose). **Follow-up after live test:** (d) list navigation (wheel + arrows) wrapped at both ends, so scrolling past the bottom made the highlight "repeat" from the top — now clamped at both edges like a normal PC settings list; (e) new `Input.SensLink` toggle (default ON, registered in `port/src/input.c`) links the aim/turn sensitivity knobs: moving either rescales the other to hold the stock 38 (`Input.AimModeSens`) : 50 (`Input.MouseTurnSpeed`) ratio, via a re-entrancy-guarded pair-write in `rowSet()`; (f) the "Mouse aim speed" row now edits `Input.AimModeSens` (the knob the default GEPD aim path actually uses — it previously edited `Input.MouseAimSpeed`, which only feeds the legacy velocity-stick fallback and was inert under default settings). **Second follow-up:** hover-to-highlight removed entirely -- moving the mouse over the panel no longer moves the selection or scrolls; near a list edge the hover->select->scroll-shift->new-row-under-cursor feedback loop made the bottom of the list twitch/echo (the persistent "duplicate row" report). Selection now moves only by explicit click (which also selects the clicked row), wheel, or arrows. **Reassessment of the 4K-fullscreen ghost-row bug:** the presentation-UV-wrap theory is DEAD — `gfxFramebuffer` has zero readers (the textured-quad present never happens); presentation is a full-frame `glBlitFramebuffer` from the same-size MSAA FBO to the default framebuffer (fb index 0 is reserved for the window, `gfx_opengl_init`), which has no UVs and cannot wrap. The overlay DL draws each visible row exactly once, in-bounds, with its own scissor. Local repro (maximized 16:9 window + MSAA=4 + `GE_OPTIONSOVERLAY=1` + `GE_PCDUMP`) shows NO ghost — the FBO/MSAA path alone is not the trigger; the remaining variable is true fullscreen on the user's 3072×1728 display (driver/display-level suspect). Next: user bisection — Fullscreen ON + MSAA OFF (restart) → does the ghost persist? **User follow-up (logged, not fixed):** the 4K-fullscreen ghost is *intermittent* across launches, and the user suspects the `hiddenIfOn` appear/vanish transitions of the Draw/LOD distance % rows (visible-list resize while the panel is open) — unverified; note `overlayUpdateVisible()`+`overlayUpdateScroll()` are re-run together in both emit and input each frame, so a stale-state window would have to come from elsewhere (e.g. the one frame a row count changes). MSAA-off bisection still pending. User: in the sessions seen so far the ghost is present on *first open* of F10 (not only after an auto-toggle flip) — weakens but does not kill the appear/vanish theory (toggles had flipped in earlier sessions before a restart). **v0.2.0-pre playtest (user, latest build):** the symptom has changed — the panel's *bottom row now deterministically duplicates whatever item is currently selected* (e.g. MSAA appears both on its own row and again at the bottom), not just an intermittent 4K/MSAA ghost of the top row; suggests the visible-row window can contain the selected row twice (off-by-one in `overlayUpdateScroll`/`maxVisibleRows` bookkeeping) — investigate post-release. **QoL follow-up:** make the auto-linked % rows visually subordinate to their toggle (indent and/or dimmer value colour) so the pairing is obvious in the panel; hint line also reworded to "select: scroll/click, change: mouse/arrows". | | 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 — pending Deck re-verification with the re-cut bundle. | +| 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. | FIXED — needs one Deck crash to prove the new format in the field. | +| 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. **Next:** the D254 handler now captures registers at fault time (`rax` = the ObjectRecord, `rdi` = its model value) — one more Deck crash identifies which case: tiny/zero `rdi` → destroy-path ordering; plausible-but-wrong `rdi` → stale/reused record or truncation. | OPEN — awaiting register capture from the Deck. | 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 diff --git a/port/src/crash.c b/port/src/crash.c index aced06e4..9cfab783 100644 --- a/port/src/crash.c +++ b/port/src/crash.c @@ -324,7 +324,7 @@ static void *crashGetModuleBase(const void *addr) return NULL; } -static void crashStackTrace(char *msg, int sig, void *pc) +static void crashStackTrace(char *msg, int sig, void *pc, ucontext_t *ucontext, siginfo_t *siginfo) { unsigned msglen = 0; void *frames[CRASH_MAX_FRAMES] = { NULL }; @@ -338,6 +338,26 @@ static void crashStackTrace(char *msg, int sig, void *pc) char **strings = backtrace_symbols(frames, nframes); CRASH_MSG("SIGNAL: %d\n", sig); + if (siginfo && (sig == SIGSEGV || sig == SIGBUS)) { + CRASH_MSG("FAULT ADDR: %p\n", (void *)siginfo->si_addr); + } +#if defined(PLATFORM_X86_64) + if (ucontext) { + CRASH_MSG("REGS: Rax=%p Rcx=%p Rdx=%p Rsi=%p Rdi=%p R8=%p R9=%p R10=%p R11=%p\n", + (void *)ucontext->uc_mcontext.gregs[REG_RAX], + (void *)ucontext->uc_mcontext.gregs[REG_RCX], + (void *)ucontext->uc_mcontext.gregs[REG_RDX], + (void *)ucontext->uc_mcontext.gregs[REG_RSI], + (void *)ucontext->uc_mcontext.gregs[REG_RDI], + (void *)ucontext->uc_mcontext.gregs[REG_R8], + (void *)ucontext->uc_mcontext.gregs[REG_R9], + (void *)ucontext->uc_mcontext.gregs[REG_R10], + (void *)ucontext->uc_mcontext.gregs[REG_R11]); + CRASH_MSG("Rsp=%p Rbp=%p\n", + (void *)ucontext->uc_mcontext.gregs[REG_RSP], + (void *)ucontext->uc_mcontext.gregs[REG_RBP]); + } +#endif CRASH_MSG("PC: "); if (pc) { CRASH_MSG("%p\n", pc); @@ -370,13 +390,26 @@ static void crashStackTrace(char *msg, int sig, void *pc) free(strings); } +/* D254: re-entrancy guard. The handler used to call sysFatalError() -> + * abort() -> SIGABRT, which re-entered this handler and TRUNCATED the log + * ("wb"), destroying the original SEGV block — the one with the faulting + * PC/registers. Field crash logs then only showed the abort. Guard against + * re-entry and terminate directly instead (the Windows path already did). + */ +static sig_atomic_t inCrashHandler = 0; + static void crashHandler(int sig, siginfo_t *siginfo, void *ctx) { + if (inCrashHandler) { + _exit(128 + sig); + } + inCrashHandler = 1; + char msg[CRASH_MAX_MSG + 1] = { 0 }; + ucontext_t *ucontext = ctx ? (ucontext_t *)ctx : NULL; void *pc = NULL; - if (ctx) { - ucontext_t *ucontext = (ucontext_t *)ctx; + if (ucontext) { #ifdef PLATFORM_X86 pc = (void *)ucontext->uc_mcontext.gregs[REG_EIP]; #elif defined(PLATFORM_X86_64) @@ -389,7 +422,7 @@ static void crashHandler(int sig, siginfo_t *siginfo, void *ctx) fflush(stderr); fflush(stdout); - crashStackTrace(msg, sig, pc); + crashStackTrace(msg, sig, pc, ucontext, siginfo); { FILE *f = fopen(CRASH_LOG_FNAME, "wb"); @@ -399,7 +432,9 @@ static void crashHandler(int sig, siginfo_t *siginfo, void *ctx) } } - sysFatalError("Crash!\n\n%s", msg); + /* D254: terminate directly — sysFatalError() raises SIGABRT which used + * to re-enter this handler and truncate the log we just wrote. */ + _exit(128 + sig); } /* D38: called by the kernel heartbeat watchdog (port/src/libultra.c) when a