diff --git a/docs/dev/findings-index.csv b/docs/dev/findings-index.csv index 0d2d0ae0..100f1e9f 100644 --- a/docs/dev/findings-index.csv +++ b/docs/dev/findings-index.csv @@ -161,3 +161,4 @@ D264,FIXED,"D262's root cause (ABI/layout class, cf.","FIXED — build green, pr 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,"A real Steam Deck SIGSEGV caught during D255 live-testing (v0.2.1 published binary, symbolized on-device via addr2line against its own debug symbols) is a SEPA…","FIXED (M-141, src/snd.c) —" diff --git a/docs/dev/findings.md b/docs/dev/findings.md index 91f026e3..ec2a6053 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -559,6 +559,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | 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). | +| D285 | **A real Steam Deck SIGSEGV caught during D255 live-testing (v0.2.1 published binary, symbolized on-device via `addr2line` against its own debug symbols) is a SEPARATE bug from D255** — `FAULT ADDR: 0x62` inside the **audio thread** (`sndHandleEvent`, `src/snd.c:366`, via `amMain`/`portThreadWrapper`), not D255's `0x10`/`vtxstore_fix_refs` on the render/game-logic thread. Root cause (static, not yet gdb-confirmed): the pool-exhaustion preempt-scan in `sndHandleEvent` reads/walks the shared `D_800243E4` active-`ALSoundState` list with **no `osSetIntMask` lock at all** (checked: absent from the function's entire body), while the list's insert path (`sndSetupSound`, reached from 15 different `src/game/*.c` files' direct `sndPlaySfx` calls, e.g. `explosion.c` — i.e. from whatever thread the caller runs on) **does** lock it — an asymmetric-locking gap, the same D4/D147/D152 "N64 interrupts-off is not a real lock on PC" class. Fits the repro pattern (explosion SFX bursts = high-contention window; Frigate's lower SFX load not reproducing it fits a timing race, not a deterministic trigger). Full body below. | **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. | 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 @@ -10104,3 +10105,39 @@ Texture content (`GE_TEXDUMP`/`GE_TEXRAW` + offline decode of `texdump/r*.bin`): 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: +``` +SIGNAL: 11 FAULT ADDR: 0x62 +#00 crashStackTrace port/src/crash.c:332 +#01 sndHandleEvent src/snd.c:366 <- fault site +#02 sndPlayerVoiceHandler src/snd.c:253 +#03 _timeToSamplesNoRound src/libultra/audio/synthesizer.c:330 +#04 amHandleFrameMessage src/audi.c:551 +#05 amMain src/audi.c:452 +#06 portThreadWrapper port/src/libultra.c:387 +``` +**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: +```c +ALSoundState *iterState = (ALSoundState *) D_800243E4.node.prev; +do { + s32 ownerlessLoop = (iterState->unk3e & SOUND_FLAG_LOOPED) && ... // line 366: the fault + ... + iterState = (ALSoundState *) iterState->link.prev; +} while (limitReached && iterState != NULL); +``` +`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. +## 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. diff --git a/src/snd.c b/src/snd.c index 8ef2530f..f2e10363 100644 --- a/src/snd.c +++ b/src/snd.c @@ -344,6 +344,19 @@ void sndHandleEvent(ALSndPlayer *sndp, ALSndpEvent *event) { // Not a looped or retriggered sound, and all retries have been exhausted. if (limitReached) { // Check if we can preempt a lower-priority sound. + /* D285: this scan reads/walks D_800243E4 without the + * osSetIntMask lock sndSetupSound (above) already takes + * around every mutation of the same list -- an + * asymmetric-locking gap (writer locked, reader not) in + * the D4/D152 class ("N64's interrupt-disable trick is a + * no-op on a real PC thread"). Locking here changes + * nothing observable on N64 (this function's own caller, + * AL_SNDP_PLAY_EVT, already runs with interrupts masked + * on real hardware per the audio ISR's normal execution + * context); it closes the PC race where a burst of + * sndPlaySfx calls (explosions) from the main thread can + * mutate this list while the audio thread is mid-walk. */ + OSIntMask d285Mask = osSetIntMask(OS_IM_NONE); ALSoundState *iterState = (ALSoundState *) D_800243E4.node.prev; do { @@ -381,6 +394,7 @@ void sndHandleEvent(ALSndPlayer *sndp, ALSndpEvent *event) { } iterState = (ALSoundState *) iterState->link.prev; } while (limitReached && iterState != NULL); + osSetIntMask(d285Mask); if (!limitReached) { // Retry the sound that was preempted.