diff --git a/.gitignore b/.gitignore index 9f14970a..998ea0ef 100644 --- a/.gitignore +++ b/.gitignore @@ -144,12 +144,20 @@ ppm/ screenshots/ # local Mupen64 Plus checkout (N64 reference footage); binaries, do not commit tools_pc/mupen64/ +# local N64 emulator checkout (D205 A/B reference) +mupen64/ # PC port run/scratch logs run_*.log d6*.log ge007.crash.log +# agent scratch space + audio probe artifacts (regenerable dumps/traces) +scratchpad/ +/audiodump.raw +/audiotrace.log +/mixertrace.log + # Claude Code local state / agent worktrees .claude/ diff --git a/AGENTS.md b/AGENTS.md index 9729eb3d..5c30d70b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,8 @@ Run `/linkcheck` for this sweep. Record new findings in `docs/dev/findings.md` parked in `docs/dev/GRAPHICS-BACKLOG.md`. - **Phase 3 (audio + input):** input layer done (`port/src/input.c`); polish bugs open (D118* mouse-look residuals; interactive feel-checks owed). Audio - not started — libaudio → SDL, copy-and-adapt from the PD port's `mixer.c`. + mixer done (libaudio → SDL software mixer, D198–D201); D204 tempo drift + fixed + measured; D202 stuck door loop root-caused with a port-side + expiration (M-66b) awaiting by-ear verification. - **Phase 4 (saves + polish):** file-backed EEPROM done; widescreen, config, rebinding UI outstanding. diff --git a/README.md b/README.md index 1cfaec53..22b5e91f 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,9 @@ original N64 game running from reconstructed source, not the Xbox 360 remaster.* The first alpha, **[v0.1.0](../../releases)**, is out for Windows and Linux: it boots, renders the full intro and front end, and runs all 21 solo missions — in a full-campaign playtest 19 of the 21 were completable start to finish (two -levels still crash). **There is no audio yet** and rough edges remain; this is -Phase 2 of 4. See [Status](#status) and [Background](#background). +levels still crash). **It shipped without audio** — a working software mixer +landed in the tree afterwards (Phase 3, in progress) and will ship in the next +release. Rough edges remain. See [Status](#status) and [Background](#background). It is also a **research project on AI-agent collaboration in a large, unfamiliar, low-level codebase** — how far two coding agents (a local @@ -166,13 +167,14 @@ that work was driven by AI agents. > [!WARNING] > **Alpha — playable, not polished.** This is a work-in-progress research port. -> It runs the full single-player campaign, but there is **no audio**, some -> front-end 3D models and cutscenes are broken, AI characters move too slowly, -> input has known rough edges, and two levels (Bunker ii, Statue) still crash. -> Treat it as an early alpha of the porting work, not a finished way to play -> GoldenEye. +> It runs the full single-player campaign and has **working audio** (SFX + +> music, Phase 3 in progress), but some front-end 3D models and cutscenes are +> broken, AI characters move too slowly, input has known rough edges, and two +> levels (Bunker ii, Statue) still crash. Treat it as an early alpha of the +> porting work, not a finished way to play GoldenEye. -**Phase 2 of 4 (rendering). First alpha: [v0.1.0](../../releases).** The port +**Phase 3 of 4 (audio + input) in progress; Phase 2 (rendering) essentially +done. First alpha: [v0.1.0](../../releases) (shipped before audio landed).** The port boots, renders, and plays through the front end and the campaign. It is not finished and it is not fully stable. @@ -189,13 +191,18 @@ finished and it is not fully stable. - Input: keyboard + mouse (with mode-aware mouse-look) and SDL game controllers, mapped onto the N64 pad. Tunable via `ge007.ini`. - File-backed EEPROM saves. +- **Audio** — software mixer (libultra audio layer → SDL, adapted from the PD + port): SFX and music play in-level. Recent fixes: a permanent ~2 % tempo + drift (D204) and a stuck infinite door loop (D202). - **Windows and Linux** (`x86_64`). Windows is the primary development and playtest path; the Linux build boots, renders, and passes the level sweep, with far less human playtime. **Not yet working** -- **Audio** — not implemented (Phase 3). The game runs silent. +- **Audio polish** — the mixer works, but Phase 3 is not done: by-ear + verification of the recent D202/D204 fixes is still owed, and reverb / mix + balance have not been playtest-tuned. - **AI pacing** — scripted and combat AI characters travel to their destinations noticeably slower than on N64. This breaks Cradle (the final level) via Trevelyan's scripted progression. Top post-alpha fix (D193). @@ -209,8 +216,8 @@ finished and it is not fully stable. [`docs/dev/GRAPHICS-BACKLOG.md`](docs/dev/GRAPHICS-BACKLOG.md). - No macOS or ARM support; no controller rebinding UI; no widescreen. -Next up: audio (Phase 3), the AI-pacing fix, and the two remaining level -crashes. Cosmetic defects are tracked in +Next up: audio polish (Phase 3), the AI-pacing fix, and the two remaining +level crashes. Cosmetic defects are tracked in [`docs/dev/GRAPHICS-BACKLOG.md`](docs/dev/GRAPHICS-BACKLOG.md). ## Download diff --git a/docs/dev/AUDIO-PLAN.md b/docs/dev/AUDIO-PLAN.md index 8f12da69..8b6d8853 100644 --- a/docs/dev/AUDIO-PLAN.md +++ b/docs/dev/AUDIO-PLAN.md @@ -1,375 +1,202 @@ -# Audio Plan — Phase 3 (libaudio → SDL) +# AUDIO-PLAN — Phase 3 / ROADMAP B3 (libaudio → SDL) -Status: **draft for evaluation** (written read-only; no code changes made). -Supersedes the audio paragraphs of §6 in `docs/internals.md` as the plan of record -for closing **D77** (no audible music/sound on PC). Companion docs: `docs/HANDOFF.md`, -`docs/porting-notes.md`. +Status: plan of record for the audio track, 2026-09-05. Companion to +`ROADMAP-1.0.md` §B3 ("audio (Phase 3)", "audio track parallel from M-50 → +v0.4.0"). Independent of B1/B2 — can start immediately. + +**Status update (M-69):** Phases 0–2 below are DONE — the mixer landed as +D199 (`port/src/mixer.c` macro-swap), D200/D201 fixed the two pointer-width +landmines it exposed, and D204 fixed a permanent ~2 % tempo drift. Phase 3 +(verification) is now the active track: D202's sample-correctness question is +closed negative through M-69 (full-corpus per-voice exact match — 286/320 +requests CC ≥ 0.85, every allocated voice plays the exact ROM sample at the +exact ROM pitch, no corruption). Remaining: user by-ear pass + N64 A/B on the +door-loop cap (M-66b), then D202 probe removal + close; reverb/mix-balance +tuning is still owed. See findings.md D199–D204. --- -## Contents +## Where things stand (verified 2026-09-05) -1. [Goal and scope](#1-goal-and-scope) -2. [Current state](#2-current-state-verified-by-reading-not-assumption) -3. [The GE pipeline in one paragraph](#3-the-ge-pipeline-in-one-paragraph) -4. [How the PD port does it](#4-how-the-pd-port-does-it-ground-truth-comparison) -5. [Architecture options](#5-architecture-options-evaluate-all-recommendation-marked) — options A/B/C + sub-decisions A1–A3 -6. [DMEM layout](#6-dmem-layout-ido-from-srclibultraaudiosynthinternalsh5771) -7. [Shims and ABI fixes required](#7-shims-and-abi-fixes-required) — incl. [7.1 `K0_TO_PHYS` bug](#71-bug-found-k0_to_phys-is-unshimmed-and-corrupts-pointers-on-pc) -8. [Phase 0 — recon](#8-phase-0--recon-do-first-informs-everything) -9. [Open questions](#9-open-questions-decide-before-phase-1) -10. [Phases and milestones](#10-phases-and-milestones) -11. [Verification ritual (audio-specific)](#11-verification-ritual-audio-specific-on-top-of-the-standard-one-in-agentsmd) -12. [Risks / unknowns](#12-risks--unknowns) -13. [Key references](#13-key-references) +More is already in place than "audio not started" suggests: ---- +- **The whole CPU-side audio engine already compiles and runs.** + `src/audi.c`, `src/snd.c`, `src/music.c` are in `SRC_ENGINE`; IDO libaudio + (`src/libultra/audio/*.c`) + Rare's naudio "New" driver + (`src/libultrare/audio/*.c`: drvrNew/env/reverb) are in the link. The + `amMain` audio thread runs per VI-retrace; D54/D54b already killed its + SIGSEGVs — so synthesis bookkeeping is live (D77: "music runs in code but + no audible output"). +- **The scheduler side needs zero changes.** `osSpTaskStartGo` + (`port/src/libultra.c:1237`) already has an M_AUDTASK branch that completes + the task and posts `OS_EVENT_SP`, so `amMain`'s reply-message handshake + works. The branch is currently a no-op — exactly where the audio ucode's + job used to go. +- **The two TODO stubs are the actual gap:** `osAiSetNextBuffer()` / + `osAiGetLength()` in `port/src/libultra.c:852-862` discard everything + (`osAiGetLength` returns 0), and `port/src/audio.c` never opens an SDL + device. `main.c:141-142` already calls `audioInit()` / `mixerInit()`. +- **The PD reference is local** at `C:\Users\james\Source\Repos\pd_port`, + with the full audio stack: `port/src/audio.c` (75 lines, SDL device + + queue), `port/src/mixer.c` (~720 lines, software RSP-audio-ucode), and the + wiring trick in `include/PR/abi.h`. -## 1. Goal and scope +## Architecture — PD's macro-swap trick, adapted -Make GoldenEye's audio audible on the PC port with 1:1 fidelity to the N64 pipeline: -music (3 cseq tracks) and SFX through the real libaudio synth, out of SDL2. +On N64, `alAudioFrame()` only *builds* an acmd list; the RSP audio ucode +(`aspMain`) executes it. PD's PC port never runs that ucode: under +`#ifndef PLATFORM_N64`, `include/PR/abi.h` swaps each acmd-building macro +`aXxx(pkt, ...)` for a call to a CPU implementation (`aXxxImpl`) operating on +a software DMEM array. libaudio/naudio compile unmodified; every "command" +executes inline at build time; the M_AUDTASK that reaches the scheduler is an +empty formality (PD's `pdsched.c` audio branch is literally empty). -In scope: -- Executing the per-frame **Acmd stream** that stock IDO libaudio emits (the part that - ran on the RSP under GE's `aspMain` microcode). -- SDL audio device + queue plumbing (`port/src/audio.c`, AI shims in `libultra.c`). -- One latent ABI fix in the port shim headers (`K0_TO_PHYS`, §7.1). +**Critical catch — GE and PD use different acmd ABIs** (the AGENTS.md +"same family ≠ identical format" case): -Out of scope: -- MP3 playback (naudio-only, PD-specific; GE never emits it). -- Input (done — D118), saves (Phase 4). -- Any change to game logic. All work lives in `port/` except the narrow, documented - shim-header exception in §7.1 (same class as D78). - -## 2. Current state (verified by reading, not assumption) - -Working already: -- **The whole CPU side of libaudio runs.** `src/libultra/audio/*` + - `src/libultrare/audio/{drvrNew,env,reverb}.c` compile and execute; the audio thread - (`amMain`, `src/audi.c`) gets retrace messages from the real scheduler (`src/sched.c`, - compiled — Phase 2 decision), calls `alAudioFrame()`, which fills a 3000-word Acmd - list and posts an `M_AUDTASK` to the scheduler. Bank trees, cseq fixups (D54, D36/D37) - are done. Sample DMA from cart works via `amDmaCallback` → `osPiStartDma` - (synchronous ROM-image shim in `libultra.c`). - -Dead (the three gaps): -1. **Acmd list never executed.** `port/src/libultra.c:1108` `osSpTaskStartGo()` has an - explicit Phase-3 no-op branch for `M_AUDTASK`. The output buffer (`info->data`) is - never filled. -2. **AI shims are stubs.** `libultra.c:794–810`: `osAiSetNextBuffer` discards the - buffer, `osAiGetLength` returns 0 (so the per-frame sample-count feedback loop in - `amHandleFrameMessage`, `audi.c:527`, runs blind). -3. **No SDL device.** `port/src/audio.c` `audioInit()` is a TODO stub; and nothing - calls `audioEndFrame()` (zero callers repo-wide). - -Existing PORT guards to revisit when audio lands: -- **C7 / D127** (`src/snd.c:859`): skips SFX whose resolved `ALSound*` pointer is out of - range — added because the converted bank tree (`romdata.c afFixupInst`) yields fewer/ - rearranged `soundArray` slots than the N64 image on some levels (Surface1). May be - masking a real bank-tree defect. See **Q1**. -- `src/audi.c:365`: `#if defined(PORT)` memcpy of `CUSTOM_FX_PARAMS_N` (C++-style - initializer workaround) — harmless, keep. - -## 3. The GE pipeline in one paragraph - -Audio thread on each retrace: `amHandleFrameMessage()` (`src/audi.c:497`) hands the -*previous* frame's output to AI via `osAiSetNextBuffer(lastInfo->data, …)`, computes -this frame's sample count from `g_FrameSize - osAiGetLength()>>2` (clamped to -`g_MinFrameSize`), calls `alAudioFrame()` which walks the filter chains (decoder → -resampler → envmixer → [FX: GE's custom chorus/reverb] → aux bus → main bus → save) -emitting **Acmd words** into a 3000-word list, then posts an `M_AUDTASK` (ucode = -`aspMain`). The real scheduler (`sched.c:439,485`) queues it in the audio list and runs -it through `osSpTaskLoad/osSpTaskStartGo`. On the N64 the RSP executes the Acmd stream -against 4 KB of DMEM, writing interleaved s16 stereo to `info->data`; AI DMA then plays -it at 22050 Hz. On PC we must replace "RSP executes Acmd stream" — that is the entire -problem. - -**Opcode inventory** (grep-verified across `src/libultra/audio/*.c` + -`src/libultrare/audio/*.c` — 15 ops): - -| Op | Emitted by | PD reference code? | +| | GE (`include/PR/abi.h`) | PD (naudio "New") | |---|---|---| -| `aSetBuffer` (×20) | load, mainbus, resample, save, reverb | yes (`a*Impl` pattern; different arg ABI) | -| `aMix` (×7) | mainbus, reverb | yes — `aMixImpl` | -| `aLoadBuffer` (×7) | load, reverb | yes — `aLoadBufferImpl` | -| `aClearBuffer` (×7) | auxbus, mainbus, load, reverb | yes — `aClearBufferImpl` | -| `aSetVolume` (×5) | env.c (Rare envelope) | yes — `aSetVolumeImpl` | -| `aSaveBuffer` (×4) | save, reverb | yes — `aSaveBufferImpl` | -| `aDMEMMove` (×4) | load, resample, reverb | yes — `aDMEMMoveImpl` | -| `aResample` (×2) | resample.c, reverb.c | yes — `aResampleImpl` (SSE4.1/NEON/scalar) | -| `aLoadADPCM` (×2) | load.c, reverb.c | yes — `aLoadADPCMImpl` | -| `aSetLoop` | load.c:479 | yes — `aSetLoopImpl` | -| `aSegment` | synthesizer.c:231 | n/a (no-op on CPU) | -| `aPoleFilter` | **reverb.c:402 (GE custom FX)** | **NO — PD's stub is empty ("never gets called?")** | -| `aInterleave` | save.c | yes — `aInterleaveImpl` (naudio variant takes no args) | -| `aEnvMixer` | env.c:410/414 | yes — `aEnvMixerImpl` | -| `aADPCMdec` | load.c:483 | yes — `aADPCMdecImpl` (SSE4.1/NEON/scalar) | +| `aADPCMdec` | `(pkt, flags, statePtr)` — params from an `aSetBuffer` context + `ADPCM_STATE[16]` | `(pkt, state, flags, count, inofs, outofs)` — inline args | +| `aLoadBuffer` / `aSaveBuffer` | `(pkt, dramAddr)` only — dest/count from the `aSetBuffer` context | explicit dest + count args | +| `aInterleave` | `(pkt, lDmem, rDmem)` | no args | +| Opcodes | 16: adds `aSetBuffer`, `aPan`, `aSegment`; no `aDisable` / `aPlayMP3` | 15 incl. MP3 (PD-specific) | -PD reference = the Perfect Dark port `port/src/mixer.c` + -`port/include/mixer.h`. Reusable: the DSP *math* (ADPCM 8-coefficient loop filter, -resample table + interpolation, envelope ramping, clamps). **Not** reusable as-is: -signatures and argument conventions (naudio ABI ≠ IDO ABI, §5.3). +GE's context model (from the call sites in `src/libultra/audio/`): +`aSetBuffer(flags, dmemin, dmemout, count)` sets a working-buffer context; +subsequent `aLoadBuffer(dramAddr)` / `aADPCMdec(flags, statePtr)` / +`aResample(flags, pitch, statePtr)` operate within it; the tail is +`aInterleave(AL_MAIN_L_OUT, AL_MAIN_R_OUT)` → `aSaveBuffer(f->dramout)`. -## 4. How the PD port does it (ground-truth comparison) +So this is **not** "copy `mixer.c`": port PD's mixer as the *DSP algorithm +reference* (ADPCM decode, linear resample, mix math, env-mixer / pole-filter +are the same Rare/Nintendo DSP blocks) and write GE-ABI wrappers that +maintain the `aSetBuffer` context and read params from the state structs. -1. naudio is Rare's own library; its `n_abi.h` has a `#ifndef PLATFORM_N64` branch that - redefines every `n_a*` macro as a **direct C call** into `port/src/mixer.c` - (`mixer.h` `#undef`s the macros and redirects to `a*Impl`). All DSP therefore runs - **inline on the audio thread at task-build time**, inside `amgrFrame()`. -2. The `M_AUDTASK` is an empty shell: `pdsched.c:233` receives it with the comment - `// send this into the mixer` and an empty body. -3. PD **replaced the scheduler** (`pdsched.c`) and drives audio from retrace: - `schedAudioFrame()` → `amgrFrame(); audioEndFrame();` (×`diffframe60`). Same thread - for build and flush ⇒ no locking in their SDL handoff. -4. SDL device layer: queue-based, exactly what our scaffold already copied — - `osAiGetLength` = queued **bytes** (`audioGetBytesBuffered()`), - `osAiSetNextBuffer` = stash pointer, `audioEndFrame` = `SDL_QueueAudio` with a - `queueLimit` backpressure drop. Device opened at 22020 Hz (their TODO admits this - "might cause trouble" — it is a silent −9-cent detune vs their 22050 synth rate). -5. DMEM: ~3 KB static buffer, **naudio-specific** offsets (main L/R @0x4E0/0x650, - aux @0x7C0/0x930) — different from IDO's layout (§6). -6. PD-only extras: `aPlayMP3Impl` (minimp3; GE never emits), `aPoleFilterImpl` empty - stub (GE *does* emit it). +**GE's full emittable opcode set** (static audit of every call site in +`src/libultra/audio/*.c` + `src/libultrare/audio/*.c`): ClearBuffer, +LoadADPCM, DMEMMove, SetBuffer, LoadBuffer, SetLoop, ADPCMdec, Resample, +Interleave, SaveBuffer, Mix, SetVolume, EnvMixer, PoleFilter, Segment — plus +`aPan`, defined in abi.h but apparently uncalled. PD's mixer covers the DSP +core for all of them except the trivial bookkeeping ones: SetBuffer / +LoadBuffer / SaveBuffer are context tracking + memcpys; Segment is a no-op on +PC (`amDmaCallback` already resolves ROM addresses against the mapped image — +`osVirtualToPhysical` is identity, `libultra.c:1295`). -**Why we can't copy it wholesale:** GE compiles the real `src/sched.c` and stock IDO -libaudio, whose macros only *write words* into the Acmd list — there is no build-time -hook unless we shadow `PR/abi.h`. Something must execute the stream. Two further ABI -facts: +## Phases -- **IDO's implicit "current buffer":** e.g. `aADPCMdec(pkt, f, s)` carries only flags + - a state pointer; in/out DMEM offsets and sample count come from the preceding - `aSetBuffer` (`load.c:482–483`, `resample.c:90–91`). naudio spells everything out per - call. Whatever option we pick, the implementation must model current-buffer state. -- **Addresses in the stream are RAM pointers** (`K0_TO_PHYS(f->state)`, - `osVirtualToPhysical(e->state)`, `f->dramout`) — resolvable on PC *except* for the - `K0_TO_PHYS` bug in §7.1. +### Phase 0 — Ground truth (research, ~half day, delegable) -## 5. Architecture options (evaluate all; recommendation marked) +1. **Disassemble GE's `aspMain` microcode from the ROM** and document each + opcode's exact semantics in findings.md §F (new Dxx): flag-bit meanings + (`A_FIRST`, `A_CONTINUE`, …), count units (bytes vs samples), where the + interleaved output lands before `aSaveBuffer`, how `aSetLoop` state gets + restored, `ENVMIX_STATE[40]` layout. This is the audio analogue of using + `rsp/graphics/gmain.s` as GBI ground truth — we don't run the ucode, but + it is the spec. (`port/src/ucode.c`'s header already flags this "DECISION + NEEDED in Phase 3"; CPU-bypass means the dummy `aspMainTextStart` / + `aspMainDataStart` markers stay forever — document that decision there.) +2. **Opcode inventory probe** (port-only, zero game-code change): a + `GE_AUDIOTRACE` env var in the existing M_AUDTASK branch of + `osSpTaskStartGo` that walks `t->t.data_ptr` for `data_size/8` words and + histograms `w0 >> 24`. Confirms the static call-site audit and catches + anything emitted dynamically. -### Option A — CPU-side Acmd interpreter at task time 【RECOMMENDED】 +### Phase 1 — Device plumbing (small, lead work) -Decode the 64-bit command words from `t->t.data_ptr` inside the existing `M_AUDTASK` -branch of `osSpTaskStartGo()` (`libultra.c:1108`), dispatching each op to an -implementation in `port/src/mixer.c` operating on a static 4 KB "DMEM". +- Implement `port/src/audio.c::audioInit`: SDL device at **22050 Hz** (GE's + `OUTPUT_RATE = 0x5622`, not PD's 22020), `AUDIO_S16SYS`, stereo. Keep the + existing `Audio.BufferSize` / `Audio.QueueLimit` config registrations. +- Wire `osAiSetNextBuffer` → `SDL_QueueAudio` guarded by `queueLimit`; + `osAiGetLength` → `SDL_GetQueuedAudioSize()`. Faithful mapping: N64's + `AI_LEN_REG` ("bytes synthesized but not yet played") ≙ SDL queued-bytes, + and the game's self-tuning `frameSamples = g_FrameSize − + osAiGetLength()>>2` math (`audi.c:527`) keeps the virtual FIFO at ~736 + samples (NTSC) with no game-code changes. Queue inline in + `osAiSetNextBuffer` (same thread, once per frame) rather than PD's deferred + `audioEndFrame`. Overflow safety is already self-limiting: a full queue + makes `frameSamples` clamp to `g_MinFrameSize`, and the `queueLimit` check + drops rather than crashes. +- Add a `GE_AUDIODEBUG` probe logging queue depth + synthesized samples every + 60 frames; catalog it in `docs/GE-ENV-PROBES.md`. +- **Exit:** links 243/243, `/linkcheck` clean; headless `-level_09` 60 s + crash-free; debug probe shows the queue filling while intro music "plays" + (still silent — acmds don't execute yet). -- Pros: zero game/header/build changes; treats the stream as data exactly like fast3d - does with GBI (project-consistent); natural logging hook (`GE_DAUDIO=1` dump of op + - args per frame) for debugging and for validating against the microcode disassembly; - execution point matches N64 timing (DSP runs when the RSP would have run it). -- Cons: must write the word decoder (~50 lines, trivial — op is bits 24–31 of w0 per - `include/PR/abi.h`) and the current-buffer state machine; one scheduler tick of extra - latency vs build-time execution (inaudible). +### Phase 2 — The mixer (core work, ~1–3 days) -### Option B — Shadow `PR/abi.h`, redirect macros to direct C calls (the PD trick) +- Port PD's DSP cores into `port/src/mixer.c`: ADPCM decode (coefficient-book + handling via `aLoadADPCM`), linear resampler, stereo mix with `0x8000` + scaling, env-mixer + pole-filter (reverb/chorus driven by + `CUSTOM_FX_PARAMS_N`, `audi.c:169`), set-volume ramps. Add the GE-ABI + context layer: a static `{dmemin, dmemout, count}` updated by + `aSetBuffer`, consumed by LoadBuffer / SaveBuffer / ADPCMdec / Resample; a + shared software DMEM array across Impls. Replace the current 31-line stub + interface (`port/include/mixer.h`) with PD-style `aXxxImpl` declarations. +- Edit `include/PR/abi.h` with an `#ifdef PORT` split (N64 path + byte-identical): PC includes a port `mixer.h` whose macros match **GE's** + arg arities. This is the one non-`port/` edit — same documented + ABI/layout-exception class as D78 / D135, logged in §F with its own label. +- Unknown / unused opcodes (`aPan`, `aSegment`): log-and-no-op under + `GE_AUDIOTRACE`; implement only if Phase 0 shows them live. +- All DSP runs on the existing `amMain` thread inside `alAudioFrame` — no new + locks, no scheduler changes. +- **Exit:** opcode histogram fully covered; headless run clean; queue probe + non-zero. -Add `port/shim/PR/abi.h`: `#include "include/PR/abi.h"` then `#undef` the 15 `a*` -macros and `#define` them as direct calls into `mixer.c` (PD's `mixer.h` does exactly -this for naudio). DSP then runs inline during `alAudioFrame()` on the audio thread; the -`M_AUDTASK` branch stays a no-op, exactly like PD. +### Phase 3 — Verification & tuning (user-gated listen tests) -- Pros: battle-tested pattern (shipped in PD); no decoder; slightly less latency. -- Cons: shadows a core PR header for every TU that includes it (only libaudio uses these - macros on PC, so blast radius is small but nonzero — must verify include-path - ordering in `CMakeLists.txt`); stream becomes invisible at runtime (no logging hook — - you'd instrument inside each Impl); the shim must exactly mirror IDO arg order, which - differs from PD's naudio redirects line-by-line. +- Listen checklist: menu music → briefing → in-level music transitions → + gunfire / explosions → **positional SFX** (walk around a source) → weapon + one-shots → level changes → **mission-fail fade** (the D152 `s_imLock` + scenario — retest killing Trevelyan in Facility with live audio, since the + fade hammers the event queue; the D152 self-healing lock mitigation is + already in place but must be re-proven under load). +- Compare reverb / chorus character against N64 reference footage (that's + what `CUSTOM_FX_PARAMS_N` is for). +- Regression: `GE_PCDUMP` framediff vs goldens unchanged (audio must not + touch rendering) + 60 s `-level_09` soak. +- Docs: findings §F entry, porting-notes quirk ("classic-libultra vs + naudio-New acmd ABI delta"), ROADMAP B3 status, HANDOFF update. -### Option C — Run the real `aspMain` microcode 【NOT RECOMMENDED】 +## Risks -Point `aspMainTextStart/DataStart` (`port/src/ucode.c`) at the ROM microcode and execute -it with an RSP emulator. Maximum fidelity by construction, but requires a 16-bit-MIPS -RSP core + DMEM + interrupt model for one component whose entire job is the 15 ops we -can implement directly. Keep the `ucode.c` dummies permanently (their header comment -anticipates this decision). The microcode is still *used* — as a **reference to -disassemble**, not to run (§8, Phase 0). +1. **Ucode-semantics mismatch** (count units, flag bits) → garbled or + wrong-tinted audio, not crashes; mitigated by Phase 0 disassembly + + iterative listen checks. Main schedule risk. +2. **Timing coupling with B1** (D193 family): low — audio frame size is + feedback-driven off `osAiGetLength`, robust to scheduler jitter; re-verify + after B1 lands. +3. **Performance:** ~736 samples/frame of CPU DSP is trivial on x86-64 (PD + shipped exactly this). +4. **D152 lock storm under live audio** — covered by the explicit Phase 3 + retest. -### Sub-decision A1 — where samples hit the SDL queue 【RECOMMENDED: immediate】 +## Delegation shape & effort -- **Immediate:** `osAiSetNextBuffer()` shim calls into `audio.c` and `SDL_QueueAudio`s - right there (on the audio thread), with the existing `queueLimit` backpressure drop. - Lock-free (single writer); matches the moment N64 AI DMA starts; `audioEndFrame()` - disappears entirely. Safe because `NUMBER_OUTPUT_BUFFERS=3` rotation guarantees the - handed-off buffer isn't rewritten before it is queued. -- **Deferred (PD-style):** stash in `osAiSetNextBuffer`, flush from a frame-end hook. - In GE this crosses threads (audio thread sets; scheduler/gfx path would flush) ⇒ needs - a mutex or accepts PD's unlocked pattern, and needs a new `audioEndFrame()` call site - that doesn't exist today. Only worth it if immediate queueing shows a problem. +Per `docs/dev-process.md`: -### Sub-decision A2 — device sample rate 【RECOMMENDED: 22050】 +- Phase 0 disassembly → Claude research subagent (judgment-heavy, read-only + + findings write-up). +- Phase 1 → lead or mechanical worker (build-verifiable). +- Phase 2 DSP-core port → candidate for the local Qwen once the semantics doc + exists (mechanical, diff-checkable against PD), with the ABI mapping and + review kept by the lead. +- Phase 3 listen tests → user. -Request exactly `OUTPUT_RATE` (0x5622 = 22050 Hz), stereo s16, as PD's `audio.c` -scaffold already comments. Do **not** copy PD's shipped 22020 (detune). If a platform -rejects 22050, let SDL convert (`SDL_OpenAudioDevice` with allowed-conversion) rather -than changing the requested rate. +Total ~3–6 focused sessions. Milestone: ROADMAP "audio track (parallel from +M-50) → v0.4.0". -### Sub-decision A3 — timing and thread priorities 【RECOMMENDED: inline + host priorities; no worker thread】 +## Key file map -Execution stays on the scheduler thread (Option A's execution point). If Phase 2/3 -milestones show underruns (`audio: ai out of samples` spam — verification step 3) or -frame-time spikes attributable to synthesis cost, the **first lever is host thread -priorities**: `SetThreadPriority` / `pthread_attr_setschedparam` with the scheduler -thread time-critical (it now owns both fast3d and the mixer) and the tick thread -normal — a ~10-line fix in `libultra.c`. A dedicated audio worker thread consuming -`M_AUDTASK`s is explicitly **not** the next step: it adds a cross-thread handoff for a -problem priorities may fully solve, and the kernel's priority semantics are already -fake (D24 — see the backlogged bug-class entry in `docs/BACKLOG.md`), so any deeper -redesign starts there. Do **not** consider rewriting toward PD's single-threaded -inline-`amgrFrame()` model: it works for them only because they adapted game-side lib -code (direct `schedSubmitTask` calls, gutted `pdsched.c`); for GE it means editing -`src/audi.c` / scheduler submission points — a non-negotiable #2 violation. Rationale -recorded in `docs/BACKLOG.md` (Architecture decision). - -## 6. DMEM layout (IDO, from `src/libultra/audio/synthInternals.h:57–71`) - -``` -AL_MAX_RSP_SAMPLES = 160 (per-chunk; alAudioFrame builds the list in ≤160-sample chunks) -offset 0 AL_DECODER_IN / AL_RESAMPLER_OUT / AL_TEMP_0 (320 B) -offset 320 AL_DECODER_OUT / AL_TEMP_1 (320 B) -offset 640 AL_TEMP_2 (320 B) -offset 1088 AL_MAIN_L_OUT (320 B) -offset 1408 AL_MAIN_R_OUT (320 B) -offset 1728 AL_AUX_L_OUT (320 B) -offset 2048 AL_AUX_R_OUT (320 B) → max extent 2368 B -``` - -Static 4 KB buffer in `mixer.c` (round number, headroom). **Not** PD's naudio offsets. -GE's custom FX (`reverb.c`) uses `AL_TEMP_0/1/2` as scratch plus RAM-backed delay lines -via `aLoadBuffer`/`aSaveBuffer` to `r->base` — all plain host pointers on PC. - -## 7. Shims and ABI fixes required - -### 7.1 【BUG FOUND】 `K0_TO_PHYS` is unshimmed and corrupts pointers on PC - -`port/shim/PR/R4300.h` redefines `PHYS_TO_K0` (identity) but **not** `K0_TO_PHYS`. -Stock: `#define K0_TO_PHYS(x) ((x)&0x1FFFFFFF)` (`include/PR/R4300.h:65`). Game RAM -lives at the V1 view `0x70xxxxxx` (`port/src/dram.c`); masking gives `0x10xxxxxx` — an -invalid host pointer. Hit sites: `load.c:483` (`aADPCMdec(..., K0_TO_PHYS(f->state))`) -and `load.c:479` (`aSetLoop(ptr++, K0_TO_PHYS(f->lstate))`) — the mixer would receive -garbage state addresses for every ADPCM voice. Fix: extend the existing shim with -`#undef K0_TO_PHYS / #define K0_TO_PHYS(x) ((u32)(x))` (identity; V1 addresses are live -host pointers). Inert in the N64 build (no `-DPORT`) — same class as the D78 precedent. -Record as a new Dxx finding when implemented (next free label after D127). - -### 7.2 AI shims (`libultra.c:794–810`) - -- `osAiSetFrequency(hz)` → return `hz` (already does; 22050 flows into - `alconf->outputRate` and the frame-size math — correct). -- `osAiSetNextBuffer(buf, size_bytes)` → queue to SDL per A1. -- `osAiGetLength()` → **bytes** currently queued (`SDL_GetQueuedAudioSize`); the game - does `>> 2` for frames (`audi.c:527`). PD returns bytes too. This closes the - feedback loop that sizes each frame's synthesis. -- `osAiSetConvert` — not called by GE; leave stub. - -### 7.3 Misc - -- `port/src/audio.c`: fill in `audioInit()` (SDL_InitSubSystem(AUDIO), open device per - A2, unpause); add the queue function per A1; keep `Audio.BufferSize`/`Audio.QueueLimit` - config entries; add an `Audio.Enabled` toggle (PD's `g_SndDisabled`) so the level - sweep can run silent. -- `port/src/mixer.c`: rewrite as the op implementations + (for Option A) the decoder / - current-buffer state machine. `mixerInit/Destroy` reset DMEM + op state. -- Threading note: mixer runs on the scheduler thread (`sched.c __scMain` → - `osSpTaskStartGo` inline); with A1 there is exactly one shared object (the SDL queue, - internally locked) — no port-side locks needed. - -## 8. Phase 0 — recon (do first; informs everything) - -1. **Disassemble `bin/aspboot.text.bin`** (+ `.data.bin`; markers in `src/aspboot.s`) — - GE's real ASP microcode, 16-bit MIPS RSP. This is the ground truth for: - - exact `aSetBuffer` current-buffer semantics (which subsequent ops consume it, and - how), - - `aADPCMdec` state layout (what the pointer in w1 points at — IDO `ADPCM_STATE` + - data addressing), - - **`aPoleFilter`** — the one op with no PD reference code; implement from this, - - any DMEM convention not derivable from libaudio usage. - Methodology mirrors how `rsp/graphics/gmain.s` is used for graphics. A small - 16-bit-MIPS disassembler script (`tools_pc/`) is sufficient — we decode, not execute. -2. **Acmd stream logger** (`GE_DAUDIO=1`, in the Option-A decoder or a temporary probe - in the `M_AUDTASK` branch): dump op + decoded args per frame for the first N frames. - Confirms the 15-op inventory at runtime, catches surprises (e.g. ops we didn't grep), - and gives before/after artifacts for validating the implementation. - -## 9. Open questions (decide before Phase 1) - -- **Q1 — C7/D127 bank-tree guard (`src/snd.c:859`):** root-cause the bogus - `ALSound*` resolutions now (likely an `afFixupInst` layout/stride defect in - `port/src/romdata.c`, same family as D37's bank-tree re-layout), or keep the guard as - a documented stopgap and file a separate finding? Keeping it means some SFX stay - silently missing on affected levels (Surface1) even after audio lands. -- **Q2 — Option A vs B** (§5): A is recommended (no header shadowing, stream logging, - project-consistent with fast3d); B is the PD-proven fallback if current-buffer - semantics prove fiddly at task time. Note for the evaluator: this is a **low-stakes, - cheap-to-revisit decision** — both options share 100% of the DSP implementation code - (§6 layout, all op bodies); only the ~50-line front end (word decoder vs macro - redirects) and the execution point differ. If Phase 0's disassembly surfaces anything - that makes B look safer, switching costs an afternoon, not a re-architecture. -- **Q3 — Pole-filter fidelity bar:** implement `aPoleFilter` strictly from the - microcode disassembly (recommended), or accept a devkit-docs approximation and verify - by ear? The chorus is audible in most music; a wrong filter coefficient shows as tonal - difference, not crash. - -## 10. Phases and milestones - -- **Phase 0 — Recon** (§8): microcode disassembly notes + Acmd log artifacts committed - to `docs/` (or `tools_pc/`). Exit: current-buffer semantics + pole filter documented; - op inventory confirmed at runtime. -- **Phase 1 — Plumbing.** SDL device opens @22050 stereo s16 (A2); A1 queue path live; - real AI shims (§7.2); `K0_TO_PHYS` shim fix (§7.1); `Audio.Enabled` toggle. Mixer still - a no-op is fine. **Milestone: device drains, `osAiGetLength` feedback loop visible in - logs, clean start/stop, silence (not crash).** -- **Phase 2 — Core ops.** Implement `aSetBuffer/aLoadBuffer/aClearBuffer/aDMEMMove/ - aADPCMdec/aResample/aEnvMixer/aMix/aSetVolume/aLoadADPCM/aSetLoop/aInterleave/ - aSaveBuffer` (+`aSegment` no-op) in `mixer.c`, reusing PD's DSP math where the ABI - maps. **Milestone: SFX audible (gunshots, doors); dry music plays.** Expect at least - one Dxx-class finding here (state-layout or endianness surprise is the usual bug - class — see `docs/porting-notes.md`). -- **Phase 3 — FX.** `aPoleFilter` + validate the full `reverb.c` chorus path against - the disassembly. **Milestone: music matches N64 character (chorus/reverb present, no - pumping); close D77.** -- **Phase 4 — Polish & bookkeeping.** Resolve Q1; remove/adjust temporary probes and - `GE_DAUDIO` defaults; latency/buffer tuning (`Audio.BufferSize`, `QueueLimit`); - host thread-priority tuning per A3 *only if* underruns were observed in Phases 2–3; - record findings in `docs/internals.md` §F/§H (next Dxx after D127) + update the - §F index, `AGENTS.md` Phase 3 status, `docs/HANDOFF.md`; append recurring bug classes - to `docs/porting-notes.md`. - -## 11. Verification ritual (audio-specific, on top of the standard one in AGENTS.md) - -1. Boot → title: menu music audible within a couple of seconds; no click at start. -2. Enter BUNKER1 (`-level_09`): gunfire/door SFX present and roughly level-correct; - level music plays. -3. No `audio: ai out of samples` spam from `amHandleDoneMessage` (`audi.c:596`) — that - print is the built-in underrun detector. -4. Run 2–3 levels incl. one with heavy SFX overlap; watch `SDL_GetQueuedAudioSize` - (log it) staying below `queueLimit` in steady state. -5. PAL build (`pal-final`) spot-check: 50 Hz retrace drives the same path; frame-size - math uses `MAYBE_FRAME_RATE` — confirm no underruns there too. -6. `Audio.Enabled=0` → fully silent, zero audio CPU cost, nothing else affected. - -## 12. Risks / unknowns - -| Risk | Mitigation | +| File | Role in this plan | |---|---| -| IDO current-buffer semantics misread → subtle wrong output (no crash) | Phase 0 disassembly is the arbiter; Acmd logger + by-ear checks per phase | -| `aPoleFilter` has no PD code | Disassembly (Q3); it's one small IIR — bounded effort | -| ADPCM state layout differs from assumption → garbage/cratchy samples | Phase 0 documents the struct from `load.c` usage + microcode; scalar fallback path makes debugging easy | -| C7/D127 hides a bank-tree bug that audio makes *audible* (wrong SFX, not just missing) | Q1; keep the guard until root-caused | -| SDL queue backpressure drops under load (slow machines) | `queueLimit` drop is graceful (silence gap, no crash); A3: raise scheduler-thread host priority before buffer tuning; PD shipped the same design | -| Synthesis cost on the scheduler thread starves gfx frame time / causes underruns | A3: host thread priorities first; a dedicated audio worker thread only if priorities provably insufficient (and revisit D24's fake-priority semantics before designing one) | -| Cross-region: PAL sample-count math differs | Verification step 5 | - -## 13. Key references - -| File | What it gives us | -|---|---| -| `src/audi.c` | audio thread, frame-size feedback loop, task construction (`OUTPUT_RATE` @22) | -| `src/sched.c:439,465,485` | real scheduler's M_AUDTASK path → `osSpTaskStartGo` interception point | -| `include/PR/abi.h:260–407` | IDO Acmd word formats (the decoder's spec) + A_* opcodes | -| `src/libultra/audio/synthInternals.h:57–71` | DMEM layout constants | -| `src/libultrare/audio/reverb.c`, `env.c`, `drvrNew.c` | GE custom FX (`alFxNew`), pole filter + chorus, envelope ops | -| `bin/aspboot.text.bin` / `.data.bin` (+ `src/aspboot.s`) | **ground-truth ASP microcode to disassemble** | -| `pd_port/port/src/mixer.c`, `port/include/mixer.h` | DSP math reference + the Option-B pattern | -| `pd_port/port/src/audio.c`, `pdsched.c:233–260`, `libultra.c:150–175` | PD wiring (queue design, empty AUDTASK branch, AI shims) | -| `port/src/libultra.c:794–810, 1108` | the three dead seams this plan fills | -| `port/src/dram.c`, `port/shim/PR/R4300.h` | address model (V1/V2 views) + the K0_TO_PHYS gap | +| `src/audi.c` | Game audio manager — compiles unmodified; source of `OUTPUT_RATE`, frame-size math, `CUSTOM_FX_PARAMS_N` | +| `include/PR/abi.h` | GE's acmd accessors (16 opcodes, context ABI) — the one non-`port/` edit (`#ifdef PORT` split) | +| `src/libultra/audio/*.c`, `src/libultrare/audio/*.c` | CPU synth + naudio New driver — compile unmodified; call sites define the opcode set | +| `port/src/audio.c` | SDL device + queue (implement Phase 1) | +| `port/src/mixer.c` | Software acmd interpreter + software DMEM (implement Phase 2) | +| `port/include/mixer.h` | Stub interface → replace with `aXxxImpl` declarations | +| `port/src/libultra.c:848-863` | `osAi*` shims — wire to audio.c (Phase 1) | +| `port/src/libultra.c:1237` | M_AUDTASK branch — stays a completing no-op; hosts `GE_AUDIOTRACE` | +| `port/src/ucode.c` | Dummy `aspMain*` markers stay forever under CPU-bypass — document decision | +| `C:\Users\james\Source\Repos\pd_port\port\src\{audio,mixer}.c`, `include\PR\abi.h` | PD ground truth: device glue + DSP cores + the macro-swap pattern | diff --git a/docs/dev/D172-PARTICLE-COLOR-RESEARCH.md b/docs/dev/D172-PARTICLE-COLOR-RESEARCH.md new file mode 100644 index 00000000..3df88f4f --- /dev/null +++ b/docs/dev/D172-PARTICLE-COLOR-RESEARCH.md @@ -0,0 +1,100 @@ +# D172 — particle/flares wrong color (magenta/cyan blood): static research + root-cause candidate + +Symptom (user-reported, GRAPHICS-BACKLOG line 31): bullet impacts / flares / blood spatter +render magenta/cyan instead of dark red. Static audit done; one strong root-cause candidate +with a cheap in-game verification. **No code changed.** + +## 1. How particles get their color state (mechanism) + +Particle triangles (`explosion.c`, `gSPVertex` + `G_TRI_FILL`/`G_TRI_SHADE_TXTR`) carry +**no per-primitive material command**. Their CC/tile state comes from replaying ROM-resident +state records via `gSPDisplayList(gdl++, g_ExplosionDisplayLists[var_s2])` +(`explosion.c:894`, 15-record loop, one record + its particles per iteration). + +- Records: `globalDL_0x078 … globalDL_0x9a8`, a 17-entry table at image-bank offset + 0x28 (`g_pc_gimg_off_globalDL_*`), bank base cart 0x1029D160 (see `image_bank.c:322`). +- Each record is also scanned at load by `texLoadFromDisplayList` (`src/game/image.c:475`) + for its `G_SETTIMG` + ABCD marker word — that part works (textures upload fine). + +## 2. Record layout (decoded from ROM) + +``` ++0x00 E7 C3 FF FE G_RDPPIPESYNC ++0x08 B9 00 03 1D ??? <-- UNHANDLED by fast3d (see §4) ++0x10 second word of the ??? command ++0x18 FC ... G_SETCOMBINE w0 (rgb c0 | alpha c0 <<16) ++0x20 FF ... G_SETCOMBINE w1 (rgb c1 | alpha c1 <<16) ++0x28 FD ... G_SETTIMG w0 (timg pointer) ++0x30 .. 50 50 50 50 G_SETTIMG w1 (ABCD marker) +``` + +Dominant particle combine mode (14 of 17 records), decoded with the standard libultra +bit layout: + +- cycle 0 RGB: `TEXEL0, 0, TEXEL1, COMB_A` +- cycle 0 ALP: `TEXEL0A, COMB_A, SHADE, COMB_A` +- cycle 1 RGB: `COMBINED, 0, SHADE, COMB_A` ← **2-cycle** modulate +- cycle 1 ALP: same as cycle 0 + +## 3. fast3d audit (what is right) + +- `G_SETCOMBINE` bit extraction (`gfx_pc.cpp:2786`) matches the standard libultra layout + exactly (verified field-by-field against the ROM words). Not the bug. +- Store/decode of `rdp.combine_mode` (`gfx_dp_set_combine_mode` vs `gfx_generate_cc` + key shifts) are consistent. Not the bug. +- The generated GLSL combiner implements the RDP formula with the usual special cases; + `SHADER_COMBINED` correctly resolves to the previous cycle's running value. + +## 4. Root-cause candidate: the 0xB9 word aborts the sub-DL (D146 path) + +fast3d's SP walker has **no case for opcode 0xB9** (full case list audited: MTX/MOVEMEM/ +VTX/DL/COL/syncs/all DP immediates/EXT commands — nothing in the 0x80–0xBF group). Its +`default:` (`gfx_pc.cpp:2920`, the D146 guard) **ends the whole (sub-)display list** on an +unknown opcode. + +Consequence for every particle record replay: + +``` +RDPPIPESYNC -> ok (no-op) +0xB9 -> D146 default -> RETURN (sub-DL dead) +G_SETCOMBINE -> never applied +G_SETTIMG -> never applied + -> drawn with STALE combine mode + stale tile/timg state +``` + +Stale CC/tile state = arbitrary colors = the magenta/cyan symptom. On N64, gmain.s +obviously handles 0xB9 (the game works there), so this is a fast3d gap, not a ROM quirk. + +Secondary (only matters if 0xB9 turns out to be a no-op): particle 2-cycle rendering +requires `G_CYC_2CYCLE` in other_mode_h (`gfx_pc.cpp:1523`); the records never set +other-modes, so particles inherit ambient state — same on N64, but worth confirming if +the primary fix doesn't resolve it. + +## 5. Verification (cheap, in-game) + +The D146 guard logs `D146: unknown GBI opcode 0x%02x ...` rate-limited to **20** messages. +Run any level, shoot things / trigger flares, watch console output at startup for +`unknown GBI opcode 0xb9`. If present → root cause confirmed. (The budget fills in the +first frame or two of particles, so check early output.) + +## 6. Fix path (next session) + +1. **Decode 0xB9's semantics.** `ginit.s` (dispatch-table init) is *not in this repo* + (`gmain.s:10` includes it; file absent). Options: (a) locate ginit.s in the original + source / another checkout; (b) trace gmain.s's 0x80–0xBF group handler by hand; + (c) infer from data — w0 is constant `B900031D` across all records, w1 varies + (`0C184B50`, `00504B50`, …); if it's an other-modes/cycle-type setter, w1 bits should + match G_MDSFT_* fields. +2. Implement the handler in fast3d (port-only). Re-run: blood/flares should go dark red; + D146 spam for 0xb9 stops. +3. If colors still wrong after that: log `use_2cyc` + combine key on particle triangles + (env-gated) and check ambient G_CYC_2CYCLE (§4 secondary). + +## Files + +- `src/game/explosion.c` — record table (165), replay loop (894) +- `src/game/image_bank.c:322`, `src/game/image.c:475` — record offsets / load-time scan +- `port/fast3d/gfx_pc.cpp` — SP walker cases (~2600–2930), D146 default (2920), + SETCOMBINE decode (2786), combine store (2270), cc generation (312+), 2CYCLE flag (1523) +- `rsp/graphics/gmain.s` — N64 ucode ground truth for 0xB9 (dispatch table at dmem 0xbc; + ginit.s missing from repo) diff --git a/docs/dev/D176a-SKY-NOTES.md b/docs/dev/D176a-SKY-NOTES.md new file mode 100644 index 00000000..0f506ab4 --- /dev/null +++ b/docs/dev/D176a-SKY-NOTES.md @@ -0,0 +1,85 @@ +# D176(a) Sky — consolidated research notes (read-only) + +Companion to `GRAPHICS-BACKLOG.md` D176(a) and findings.md M-37/M-47. +Purpose: make the next implementation session mechanical. No code changed by +this doc. + +## State of play + +- **Root cause (M-37):** `skyRenderTri`/`skyRenderFull` emit geometry only as + `G_RDPHALF_*` immediates; `gfx_pc.cpp:2901-2903` deliberately no-ops them + (same in the PD port) → solid black sky on every cloud-sky level. Clean + headless repro: `-level_22` (Statue), frame ~360. +- **M-47 draft (Path B)** — `scratchpad/sky.patch`: `#ifdef PORT` hooks in + both functions re-emit the sky as ordinary screen-space GBI via + `skyPortRenderPoly` (ortho pixel projection + `gSPVertex`/triangles). + Geometry reaches GL and textures (top-half luma 0.1 → ~174 on `-level_22`). + Two open defects (below). Draft, not shippable. +- **Path A** (decode RDPHALF in fast3d) is the backlog's original suggestion; + the wire format is now fully decoded (next section), so it is a viable + fallback if Path B's units can't be pinned. + +## RDPHALF wire format (decoded from `src/game/sky.c` + `include/PR/gbi.h`) + +- Opcodes (`gbi.h:102-164,216-223`): `G_RDPHALF_1 = 0xe1`, + `G_RDPHALF_2 = 0xf1` (final word of a stream), `G_RDPHALF_CONT` + (= `G_IMMFIRST-13`). Tri ops: `G_TRI_FILL 0xc8`, `G_TRI_FILL_ZBUFF 0xc9`, + `G_TRI_SHADE_TXTR 0xce`, `G_TRI_SHADE_TXTR_ZBUFF 0xcf`. +- **All payload values are S15.1 fixed point** — converter is + `sub_GAME_7F094298` (`sky.c:218`): `clamp(x, ±32767.9) × 65536`, u32. +- **Packing:** each `(G_RDPHALF_1, G_RDPHALF_CONT)` pair carries four + halfwords = **two S15.1 values**: `valueA = (w1.hi16, w2.lo16)`, + `valueB = (w1.lo16, w2.hi16)`. Multi-value groups are streamed in two + passes — all high halves first, then all low halves (see the explicit + `(x & 0xffff0000) | (y & 0xffff0000) >> 16` packing at `sky.c:1854-1870`). +- **`skyRenderTri` header** (`sky.c:1725-1728`): first word = + `(op << 24) | (winding/sign flag bit23) | lo16(first value)`; the stream + then carries screen-space positions (`unk28×0.25` = pixel X, `unk2c` = Y×4 + family) plus per-vertex attributes. The RSP ucode reassembles and DMAs a + real edge-walked RDP tri command (per the M-46 comment in sky.patch). +- **Vertex attribute vectors are 8 components** (`sky.c:1759-1776`): + `(r, g, b, a, S·w′, T·w′, 32767·w′, z)` with perspective weight + `w′ = unk34 × min(unk0c)/2` — i.e. color + perspective-corrected S/T + + depth. `SkyRelated38` fields: `unk28 = screenX×4`, `unk2c = screenY×4`, + `unk30 = z (0..0x7fff)`, `unk34 = 1/w`, `unk20/unk24 = S/T`. +- GE emits RDPHALF **nowhere except sky.c** — the command surface is closed + and small. + +## Defect 1 — texcoord shear (clouds smeared into streaks) + +`skyPortRenderPoly` uses `tc = unk20 × 32.0f` — a magic number. GBI vertex +`tc` is **S8.8 texel** space; the real RSP path applies the sky image's tile +shifts to the streamed S/T. Fix spec: + +1. Read the base-tile descriptor of the cloud image (`skywaterimages[0]`, + GIMG offset `0xFB4` per M-36) from the extracted assets/sidecar — get W, H + and shifts/shiftt. +2. Cross-check against how `gfx_pc.cpp` addresses texels for a known-good + textured DL (the working reference). +3. Replace the magic: if streamed S/T are image fractions f ∈ [0,1), then + `tc = f × (W << 8)` / `f × (H << 8)`. Verify on `-level_22`. + +## Defect 2 — vertical coverage (band fills only top ~25%) + +Leading hypothesis, from reading the N64 path: **the quad is not a plain +corner quad.** The N64 stream's four vertices are *perspective-interpolated* +points computed in the big interpolation block (`sky.c:1797-1813`, the +`sp210/sp290/sp2b0/sp230` vectors), not the raw TL/TR/BL/BR corners that +Path B's hook passes through. If those interpolated y-values differ from the +corner ys (they will, wherever w varies across the quad), the re-emitted +quad lands short. + +Next steps (one focused session): +1. Per-call probe on `skyPortRenderPoly`: log nverts + each vertex's + (x, y) + the tri op-header word; A/B against N64 reference footage of + `-level_22`/`-level_36`. +2. If confirmed, either (a) replicate the interpolation block under PORT and + emit the 4 computed vertices, or (b) drop to Path A with the decoder spec + above (the ucode's own reassembly is then irrelevant — we decode the same + values directly). + +## Recommendation + +Finish **Path B** first — geometry already works; both defects are unit/ +vertex-set questions answerable in one render-iterate session. Keep Path A +as fallback; it is now fully specified by this doc if B stalls. diff --git a/docs/dev/GE-ENV-PROBES.md b/docs/dev/GE-ENV-PROBES.md index ff5edcbb..718695ef 100644 --- a/docs/dev/GE-ENV-PROBES.md +++ b/docs/dev/GE-ENV-PROBES.md @@ -25,6 +25,18 @@ Two classes: | `GE_STARTMENU` (+ `GE_STARTMENU_PAGE`, `GE_STARTMENU_DIFF`) | `src/game/lv.c:398-402` (`#ifdef PORT`) | Boot straight into a front-end menu id (13=MISSION_COMPLETE, 10=BRIEFING, 7=MISSION_SELECT, 12=MISSION_FAILED, 6=MODE_SELECT); `_PAGE`=folder row (def 1=Dam), `_DIFF`=0..3. Crash-test a screen fast. | **live** | | `GE_UNLOCK_ALL` | `src/game/debugmenu_handler.c:1098` (`get_debug_enable_all_levels_flag`, `#ifdef PORT`) | Mission-select shows every solo level (playtest jump-to-any-level aid). Cached in a `static int c`. | **live** | | `GE_OPTIONSOVERLAY` | `port/src/optionsoverlay.c:231` | Force-enable the F10 port-layer options overlay (D184 / PR #10) regardless of ini. `atoi != 0`. | **live** | +| `GE_D204` | `port/src/audio.c` (`audioSetNextBuffer`) | **Audio-health monitor (D204).** One line per 5 s of wall clock: `rt=` produced-audio/wall ratio (1.000 healthy; below 1 = the 30 Hz audio retrace is starving and the device is padding with silence), `q=` SDL queue depth / `queueLimit` (pinning at the limit with `drop=` climbing = overproduction, the "eventually no audio" state), `drop=` blocks discarded, `max=` largest block vs its `g_MaxFrameSize*4` allocation. **Deliberately cheap (~30 clock reads/s) — safe to leave on for a full playtest, unlike `GE_MIXERTRACE`.** The instrument of record for D202. | **live** | +| `GE_D204_OLD` | `port/src/audio.c` (`d204OldMode`) | Restores pre-D204 behaviour (`osAiGetLength()` reports the whole SDL queue depth; `queueLimit` back to 8192) so before/after can be A/B'd in **one binary**, without build-to-build variance. Temporary — remove when D204 closes. | **live** (D204 A/B) | +| `GE_AUDIOTRACE` | `src/snd.c` (`sndPlaySfx`, `sndDeactivate`), `src/game/propobj.c` (door sounds), `src/libultra/audio/load.c` (`alLoadParam`) | Per-`sndPlaySfx` resolution trace to `audiotrace.log`: `soundIndex` → `ALSound*`/keyMap/wavetable/base/len/book, the returned `ALSoundState*`, every `sndDeactivate`, and wavetable bind events. M-65 additions: `[VOICE+]`/`[VOICE-]` pairing every voice acquire/release with sound/state/flags (NB: written from two threads to one unbuffered FILE* — occasional torn lines, parse defensively), `[SLOTWRITE]` door slot ownership, `[RETRIGGER-POST]`, `[WAVELOOP]` (per-sound `ALADPCMloop` presence), `[DOORSND]` every door sound state change incl. an `ORPHAN-both-slots-busy` verdict. M-66b addition: `[EXPIRE]` — posted whenever the port-side ownerless-infinite-loop expiration is scheduled (sound/state/delay/fade); this line is **permanent** (part of the D202 option-C fix), unlike the rest of the probe set which is removable on close. M-67/M-69 additions: `[WIRE] t=µs filter=… <- table=…` (`src/libultra/audio/load.c`, every wavetable→decoder bind, µs-timestamped) and `[VOICEMAP]` (`src/libultra/audio/synthesizer.c`, full PVoice→decoder/resampler/env-state address map at synth init — this is how offline voicedump slot addresses tie to WIRE filter addresses). M-68 additions: `[EVT] t=µs type=N state=P` + `[STOP-EVT] … deltaUs=N` (event types per `src/snd.h`: 1=PLAY, 2=STOP, 8=VOL, 64=DECAY, 128=END). M-71 additions: `[VOL] t=µs state=… rawVol=… playing=…` (`src/snd.c`, per-VOL-event value before the slot-volume law) and `[DISTVOL] pos=(…) player=(…) dist=… vol=…` (`src/game/propobj.c`, the distance-attenuation funnel `sub_GAME_7F053894` — this is how an SFX's final volume is computed from hit position). NB: lines from the multiple file handles interleave — occasional torn/corrupt lines, parse defensively. `dumppos=` gives the exact byte offset into `audiodump.raw` (quantised to the 2880-byte audio block). Low cost. | **live** (D202/D205) | +| `GE_PULLTRACE` | `src/libultra/audio/load.c` (`alAdpcmPull`) | `[PASTEND]` assertion: fires if a voice's `f->sample` exceeds its wave's total samples while `nOver == 0` (i.e. an unstopped voice keeps decoding past the end instead of zero-filling). Rate-limited to 40 lines. M-65: 0 hits — exhausted voices go silent correctly. | **live** (D202) | +| `GE_DMEMWIPE` | `port/src/mixer.c` (`aClearBufferImpl`) | Zeroes the whole software DMEM at the frame boundary (detected as `alMainBusPull`'s clear of `AL_MAIN_L_OUT`, addr 1088). Tests whether audio state survives across frames in DMEM. M-65: no-op on output — ruled out. | **live** (D202) | +| `GE_NOWET` | `port/src/mixer.c` (`aSetVolumeImpl`) | Mutes the reverb send (`wetamt = 0`), isolating the reverb delay lines as a possible source of persistent signal. M-65: drone unchanged — ruled out (NB `aPoleFilterImpl` is still an unimplemented no-op, D199). | **live** (D202) | +| `GE_BANKDUMP` | `port/src/romdata.c` | `BANKSRC` lines: the source ROM-layout bank tree per `soundIndex` (instrument → sound → envelope pointers + `decayTime`). Used to prove sound 203's `decay=-1` is genuine ROM data, not a conversion bug. One-shot at load. | **live** (D202) | +| `GE_FORCEALARM` | `src/game/propobj.c` (`handle_alarm_gas_timer_calldamage`, `#ifdef PORT`) | **D207 diag (temporary).** Forces the in-game alarm klaxon (`alarmActivate`) on a frame counter — on ~2–10 s, off, on again — and emits a per-frame `[D207] f=… active=… ptr_alarm_sfx=… playstate=… locked=…` line to `audiotrace.log`. Repro harness for the "alarm SFX overrides other audio" report when a level's own alarm can't be triggered headlessly. Remove when D207 closes. | **live** (D207) — temporary | +| `GE_KEYMAPDUMP` | `port/src/romdata.c` | `KEYMAP` + `ENVSRC` lines: raw source bytes of every `ALKeyMap` / `ALEnvelope` in the ROM-layout blob, for byte-wise comparison against runtime values. One-shot at load. | **live** (D202) | +| `GE_AUDIODUMP` | `port/src/audio.c` (`audioSetNextBuffer`) | Raw-dumps every mixed s16/stereo/22050 Hz buffer to `audiodump.raw` in the CWD for offline waveform inspection. Length ÷ 88200 = seconds of audio produced. | **live** (D202) | +| `GE_VOICEDUMP` | `port/src/mixer.c` (`aEnvMixerImpl`) | Dumps each voice's resampled mono stream (post-resample/pre-envelope, i.e. the env-mixer input) to `voicedump.raw` as records of `[u32 stateAddr][u32 nSamples][u64 us][s16 × n]`. **The µs stamp is when the block was MIXED — records are rendered ahead, so a record's samples END at its stamp** (M-69: treating stamps as start offsets misplaces content by up to one ~33 ms block). `stateAddr` = the env-mixer state address from `[VOICEMAP]`. Used with `scratchpad/exact_match.py` for full-corpus per-voice sample verification. | **live** (D202) | +| `GE_MIXERTRACE` | `port/src/mixer.c`, `src/audi.c` (`amDmaCallback`), `src/libultra/audio/load.c` (`_decodeChunk`, `alLoadParam`) | Per-opcode mixer trace to `mixertrace.log` (DMEM addresses, ADPCM books, decoded frames, `[DMAREQ]`/`[DMAHIT]`/`[DMAMISS]`/`[BINDTABLE]`). **WARNING: unbuffered per-opcode `fprintf`, ~25 MB/min. It slows the process enough to starve the audio thread — M-63 measured "audio at 13 % of real time" that was entirely this probe's own artifact. NEVER use it for timing-sensitive measurement; use `GE_D204`.** Grep it, never read it whole. | **live** (D202) — timing-unsafe | | `GE_TEXPITCH` | `port/fast3d/gfx_pc.cpp:924` (`gfx_tex_pitch_fix`, cached) | `=0` disables the D183 defensive de-stride in `import_texture` (default on). RC2/RC3 texture-shear A/B knob. | **live** (RC3 not finished) | | `GE_TEXRAW` | `port/fast3d/gfx_pc.cpp:1079` | With `GE_TEXDUMP`, also dumps the raw pre-import source bytes (`texdump/rNNN_f…_s…_WxH.bin`) so a decode bug can be told from a source-data bug offline (D183). | **live** (RC3 not finished) | | `GE_WRAPFIX` | `port/fast3d/gfx_pc.cpp:3166` (`gfx_set_wrap_fix`) | RC3 test override for the `Video.WrapFix` D74/D167 wrap-block path (`atoi != 0` wins over the ini). | **live** (RC3 not finished) | diff --git a/docs/dev/GRAPHICS-BACKLOG.md b/docs/dev/GRAPHICS-BACKLOG.md index 1f189614..4bbf5861 100644 --- a/docs/dev/GRAPHICS-BACKLOG.md +++ b/docs/dev/GRAPHICS-BACKLOG.md @@ -18,16 +18,16 @@ once the game is playable start-to-finish without crashes. | **D149** | Front-end MISSION COMPLETE / mode-select 3D models (dossier, wallets) render as garbled full-screen geometry or not at all — one of the compiled sub-DLs (seg5+0x9ee4) is malformed, D146 now skips it instead of aborting. | D75 family. §F D146. | | **D170** | Named-NPC "flee" scripted behavior looks wrong on PC: on Silo, Ourumov breaks into a full run the instant he alerts ("Ourumov: Kill him!") rather than the N64 walk/retreat; developer reports Trevelyan behaves the same way on Cradle. **M-49 full playthrough: escalated — Cradle (`-level_51`) is "bugged out completely" from Trevelyan's behavior; the final level's scripted progression breaks. Now folded into / superseded by §F D193 (NPC AI locomotion too slow).** | **Observed, not investigated** (M-34, from a Silo playtest capture; user bug report — no fix wanted yet). Could be a genuine ailist / `chrTick` divergence, OR the D155/D156 wall-clock `deltaFrames` locomotion-anim speed-up (a catch-up spike playing the walk cycle at 2–3×). **Verify first:** capture the same beat against N64 reference footage before assuming it diverges — Ourumov may be scripted to flee. If it's real, suspect the frametiming clamp (§F D155/D156) or the Cradle-family AI path (§F D123). | | **D171** | Silo missile-shaft: a large translucent grey triangle sweeps across the lower frame while panning down the shaft (~3 s of the capture). Looks like a stray poly / near-plane clip fan, same family as D106 (portal near-plane min>max). | **Observed, not investigated** (M-34, Silo capture). Cosmetic, intermittent, tied to camera angle in the open vertical shaft. | -| **D172** | Bullet-impact / blood particle spray renders magenta or cyan instead of dark red (visible on guards hit at close range on Silo). | **Observed, not investigated** (M-34, Silo capture). A channel-order / CC-mode error in the particle-sprite path (magenta = R+B, cyan = G+B) — not the D64/D120 blood-decal family (those are crash/hang + missing decals, not color). Likely a texture-format or env-color decode in `blood_animation.c` / the fast3d sprite path. | +| **D172** | Bullet-impact / blood particle spray renders magenta or cyan instead of dark red (visible on guards hit at close range on Silo). | **Root-cause candidate found, static research done** (`docs/dev/D172-PARTICLE-COLOR-RESEARCH.md`). Particles get CC/tile state from 15 ROM records replayed via `gSPDisplayList` (`explosion.c:894`); each record contains an opcode **0xB9** that fast3d does not handle → the D146 unknown-opcode guard kills the sub-DL before the record's `G_SETCOMBINE`+`G_SETTIMG` apply → particles draw with stale state = magenta/cyan. SETCOMBINE decode itself verified correct. Verify in-game: watch console for `D146: unknown GBI opcode 0xb9` while shooting (rate-limited to 20). Fix: decode 0xB9 semantics (ginit.s missing from repo — trace gmain.s 0x80–0xBF group or infer from data) + implement in fast3d. | | **D178** | Objectives not listed correctly on the pre-mission briefing screen (the mission overview shown before starting a level). | **M-36 triage: the objective lines are BLANK, not wrong.** `GE_STARTMENU=10` Dam briefing renders the header fine ("OHMSS / Agent: James Bond / Mission 1: Arkangelsk / Part i: Dam / PRIMARY OBJECTIVES:") then just the bullet "a." with no text and no b/c/d. → **D143/D151 blank-text family** (`langGet()` → NULL → silent blank), *not* a propDef walk / difficulty decode. **Family B** in `docs/dev/M-36-TRIAGE.md` — find which lang bank the front-end briefing flow fails to load. Re-confirm the exact shape in a *real* briefing (the `GE_STARTMENU` boot skips real save state). Close relatives: D151 (watch objective text blank — type-35/23 `text` slot u16→u32 fix, M-28; interactive re-verify was still pending), D143 (briefing/objective text blank via `langGet` NULL), D157 (objective difficulty byte offset — could make difficulty-gated objectives appear/disappear wrongly). The pre-start briefing is a *different surface* from the in-level watch, so D151's fix may not cover it. **Next:** ask user which level + what exactly shows vs expected; compare against `docs/dev/LEVEL-OBJECTIVES.md` (regenerated post-D157); if lines are blank → D143/D151 family (`langGet` NULL guard = silent blank), if wrong objectives listed → propDef type-23 record walk / difficulty decode. | | **D175** | In-game stutter / brief hang during normal play — observed opening a door on Runway (mission 3, `-level_35`) and also on Surface. No backtrace captured (gdb attach missed the window; it self-recovered). | **Observed, not investigated** (user QA report). Matches known transient-hang classes rather than looking new: D155 (post-slow-load catch-up spiral — clamped), D156 (anim NaN loop — guarded, prints one-shot stderr diag if it fires), D134 (SP/DP task-done event swallowed — fixed M-22, Runway was an original victim), D147/D152 (audio `s_imLock` steal = ~2 s hiccup). Door-open specifically = new room first visible → one-off texture-import/frame spike (likely benign). **Next:** reproduce under `tools_pc/debug.ps1` with the box idle; check console for the D156 stderr diag; if it recurs, attach gdb mid-hang and pattern-match `bt` (import_texture = benign slow frame; model.c:3131 spin = D156; pthread_mutex_lock on s_imLock = D147/D152). | -| **D176** | Surface exterior renders wrong (two co-located defects). **(a)** Sky is solid **black** — the skybox / sky gradient does not draw at all (reference: warm sunset cloud gradient). **(b)** The perimeter **tree-line backdrop renders as grey TV-static / comb interlacing** instead of snow-laden pines — the geometry is present and roughly the right silhouette but every tree texture is scrambled noise. Ground, tunnel and the dish/bunker building draw fine. | **Observed, screenshots committed** (M-35, user QA; `docs/img/bugs/d176-surface-exterior-our-build.png` vs `-reference.png`). Level is the shared Surface exterior — user reached it Dam→Surface on Agent so this is **Surface (i) `-level_36`** (confirm it also affects `-level_43`). **(a)** black sky: **M-36 partial (findings.md "D176(a)").** Env data is correct (`Sky.Clouds=1`, `CloudRGB=240,120,30`, `SkyImageId=0`); `skyRender` runs the cloud path and classifies corners correctly (switch case 12), but `skyRenderTri`/`skyRenderFull` emit nothing — not even the `env->RGB` fill shows. Not root-caused: next is probing `sub_GAME_7F097388` projected coords, verifying `skywaterimages[0]` resolves on PC (`image_bank.c` GIMG offset `0xFB4`), and a fast3d sky-tri trace. `GE_D176=1` probe shipped. **M-37: ROOT-CAUSED** — `skyRenderTri`/`skyRenderFull` build geometry *exclusively* as `G_RDPHALF_*` immediates, which `gfx_pc.cpp` deliberately no-ops (identical no-op in the PD port); fix = decode the RDPHALF tri-raster stream in fast3d (~1 session, genuine new code). **M-42: `-level_22` (Statue) confirmed a clean headless repro** — bare boot, frame ~360, upper half pure black; use it to verify the decoder. `-level_36`/`-43` intro cams face down (not a sky test without `GE_INPUTSCRIPT`). **(b)** tree/rock static: **M-36 triage — NOT the D159 family.** `texSwapAltRowBytes` is a full `#ifdef PORT` no-op (`image.c:2199`), it cannot cause this. Real cause: `port/fast3d/gfx_pc.cpp` `import_texture_*` assume `line_size_bytes == full_image_line_size_bytes` (`SUPPORT_CHECK`; rgba16's is commented out *"trips ... with a garbage size"* `:658`) → non-PoT / sub-rect tiles get a flat linear read → diagonal row shear = grey static. **Family A** in `docs/dev/M-36-TRIAGE.md`. Fix: honour `line_size_bytes` as the real source pitch. Same defect as D182(2). | +| **D176** | Surface exterior renders wrong (two co-located defects). **(a)** Sky is solid **black** — the skybox / sky gradient does not draw at all (reference: warm sunset cloud gradient). **(b)** The perimeter **tree-line backdrop renders as grey TV-static / comb interlacing** instead of snow-laden pines — the geometry is present and roughly the right silhouette but every tree texture is scrambled noise. Ground, tunnel and the dish/bunker building draw fine. | **Observed, screenshots committed** (M-35, user QA; `docs/img/bugs/d176-surface-exterior-our-build.png` vs `-reference.png`). Level is the shared Surface exterior — user reached it Dam→Surface on Agent so this is **Surface (i) `-level_36`** (confirm it also affects `-level_43`). **(a)** black sky: **M-36 partial (findings.md "D176(a)").** Env data is correct (`Sky.Clouds=1`, `CloudRGB=240,120,30`, `SkyImageId=0`); `skyRender` runs the cloud path and classifies corners correctly (switch case 12), but `skyRenderTri`/`skyRenderFull` emit nothing — not even the `env->RGB` fill shows. Not root-caused: next is probing `sub_GAME_7F097388` projected coords, verifying `skywaterimages[0]` resolves on PC (`image_bank.c` GIMG offset `0xFB4`), and a fast3d sky-tri trace. `GE_D176=1` probe shipped. **M-37: ROOT-CAUSED** — `skyRenderTri`/`skyRenderFull` build geometry *exclusively* as `G_RDPHALF_*` immediates, which `gfx_pc.cpp` deliberately no-ops (identical no-op in the PD port); fix = decode the RDPHALF tri-raster stream in fast3d (~1 session, genuine new code). **M-42: `-level_22` (Statue) confirmed a clean headless repro** — bare boot, frame ~360, upper half pure black; use it to verify the decoder. **M-47 + read-only consolidation:** Path B draft exists (`scratchpad/sky.patch`, `skyPortRenderPoly` PORT hook — geometry+texture reach GL, two unit defects open) and the full RDPHALF wire format is now decoded (S15.1 half-packing, 8-component perspective-interpolated vertex vectors); next session is mechanical per `docs/dev/D176a-SKY-NOTES.md`. `-level_36`/`-43` intro cams face down (not a sky test without `GE_INPUTSCRIPT`). **(b)** tree/rock static: **M-36 triage — NOT the D159 family.** `texSwapAltRowBytes` is a full `#ifdef PORT` no-op (`image.c:2199`), it cannot cause this. Real cause: `port/fast3d/gfx_pc.cpp` `import_texture_*` assume `line_size_bytes == full_image_line_size_bytes` (`SUPPORT_CHECK`; rgba16's is commented out *"trips ... with a garbage size"* `:658`) → non-PoT / sub-rect tiles get a flat linear read → diagonal row shear = grey static. **Family A** in `docs/dev/M-36-TRIAGE.md`. Fix: honour `line_size_bytes` as the real source pitch. Same defect as D182(2). | | **D174** | "No blood effect" — user reports no visible blood when shooting guards / into surfaces. | **User QA report; likely D120, unverified.** Decals: the D120 `PointUsage[]` converter fix (M-30) was never interactively verified and only takes effect after a full sidecar regen (`d43 && d69 && d88 --regen`) — check whether the playtest build had regenerated sidecars before concluding the fix failed. Spray: D172 observed the particle spray *drawing* (wrong color, magenta/cyan) on Silo — if the user sees no spray either, that's new info (different weapon/range, or the D172 decode is worse than "wrong color"). First step: BUNKER1 firefight with regenerated sidecars; if decals still absent, instrument the `chr.c:3328` decal walk. | | **D173** | Third-person Bond model (the player-representing figure shown at level start) spawns too high — floats well above the ground "a lot of the time" while the actual player spawn is correct. Same symptom on the Cuba end credits: JB stands ~6 ft in the air above Natalya. | **Confirmed still present across the full 21-level playthrough (M-49).** **Observed, not investigated** (user QA report). D75 animated-model family but a *position* defect, not absence — M-33 confirmed the gun-barrel Bond renders fine upright. Suspects: (a) intro/credits puppet chr spawn position vs the model's base transform; (b) anim root-motion accumulation (`modelSetAnimFrame2WithChrStuff`, D156 area) offsetting the rendered joints above the body origin; (c) a `coord3d`/packed-float decode in the intro camera/model setup. "Player spawning is OK" localises it to the render-side model, not `g_CurrentPlayer` state. | | **D182** | SELECT FILE screen degrades after navigating away and back (main menu → file select, or backing out of file select and re-entering). On the fresh screen the 007 gun-barrel spiral / Bond silhouette renders behind the file boxes; after a round-trip **the Bond image stops drawing entirely** and the whole background gains the **comb / interlacing** artifact (horizontal line scramble). File boxes, text and the Copy/Erase icons still draw. | **Observed, screenshot committed** (M-35, user QA; `docs/img/bugs/d182-file-select-backout-20260902.png`). Two symptoms, likely linked: **(1)** background 3D image stops rendering after re-entry → a front-end model/DL whose `render_pos` / segment binding is only valid on first construction (D149 / D75 `constructor_menuXX` family — the dossier/wallet models have the same "renders once" behavior). **(2)** interlacing = **M-36 triage: Family A**, the `import_texture_*` `line` vs `full_image_line` shear (see D176(b) / `docs/dev/M-36-TRIAGE.md`) — **not** the D159 `texSwapAltRowBytes` path (that is a full PORT no-op). Open question: why only *after* a round-trip — first import may land `line == width`, re-import may not, or the tile descriptor is stale on re-entry. Repro: `GE_STARTMENU` to file-select, back out with ESC/B, re-enter; `GE_DTEX` on the spiral texture across both visits. Shares defect (2) with D176(b). | | **D197** | Character face/head textures wrap wrong — the face texture repeats around the side of the head "and continues weird" instead of clamping to the front. Seen on **Silo** (M-49 playtest), intermittent ("sometimes"). Reads as **`G_TX_CLAMP` not honoured** for the character-skin tile in fast3d (→ `GL_REPEAT`, so the face UV region tiles around the skull), or wrong `shifts`/`shiftt` / LOD-tile selection on the head texture, or the D167 non-PoT wrap-period issue (head textures are often non-PoT) hitting skinned models. | **Observed, not investigated** (M-49, user bug report — needs a capture + which chr/level). Distinct from D176(b)/D182(2) (those are *bg wall* pitch shear). Related: RC3/D167 (`Video.WrapFix`, non-PoT wrap period) + the `texWriteTileLods` `shifts=shiftt=lod` note in findings §F "D176(b) → 4. candidate 2". First checks: does `gfx_pc.cpp` map N64 `cms/cmt` `G_TX_CLAMP` → `GL_CLAMP_TO_EDGE` for character DLs; is the head tile getting the base tile vs an LOD tile (D107); try `Video.WrapFix=1` on a repro. | | **D195** | Transparency / alpha-blended surfaces render wrong — reported on **Control** (Severnaya bunker basement — Natalya's console room; heavy on glass partitions and the translucent projected/holographic wall displays). Exact failure mode not yet pinned by the reporter (M-49 playtest): candidates are alpha-blended geometry drawing fully opaque, translucent panels drawing invisible, or draw-order/sort so translucent surfaces occlude what's behind. | **Observed, not investigated** (M-49, user bug report — needs a `GE_PCDUMP` capture + which surfaces). fast3d render-mode / combiner path. Same neighborhood as D161 (CI8 tile + `G_TT_NONE` → garbage), D172 (particle-sprite channel order), D176(b) (texture pitch shear) — but this is a *blend/alpha* defect, not a texture-decode one. Note D128 already touched Control's `specialportalarray` (portal struct stride) — check whether the affected surfaces are portal-adjacent. First checks: does `gfx_pc.cpp` honour `G_RM_*` XLU render modes + `G_AC_THRESHOLD`/`G_CC` alpha for these DLs; is `gDPSetRenderMode` `w1` being parsed (cf. findings §F "zbuf-clear w1 scribble"); is Z-write-vs-blend ordering right for XLU surfaces. Get the reporter to say: opaque-where-should-be-clear, invisible, or wrong edges? | -| **1P weapon viewmodel** | In-level first-person weapon models don't draw at all (no gun visible while playing). | Already documented — **AUDIT-M6 #5** (`docs/dev/AUDIT-M6-player-offsets.md`, "prime suspect") + §F D115 "still open (MED)"; M-8 handoff listed it NOT STARTED with a plan: instrument `gunfire.c:600-661` setup + `field_87F` gating first, then check `weaponModel.render_pos` arena lifetime and `weaponRwPool[192]` sizing. Note M-32b's "render_pos/dynAllocate RULED OUT" probe was the **front-end gun-barrel** path (`title.c`), not this in-level path — candidate #5 still stands. Distinct from the watch-page "dark/faint weapon model" (D75, D141 note). | +| **1P weapon viewmodel** | In-level first-person weapon models don't draw at all (no gun visible while playing). | **Static research done — `docs/dev/VIEWMODEL-RESEARCH.md`.** Ruled out: arena lifetime (setup + render same frame; `dynSwapBuffers` only at frame end), word-index math (D52 `u32*` pool), segment/render_pos mechanism (bodies + watch-menu weapon models render through it). Ranked suspects: **#1 `field_87F` gate** — the `(Gun_hand_without_item(h) == 0)` clear-term (`gun.c:812`) reads as "hide when holding an item"; a PC init divergence in `hand_item`/`field_2A44`/`hand_invisible` yields exactly this symptom (render loop `continue`s at `gunfire.c:1537`). **#2** `weaponRwPool[192]` capacity vs PC-wide rw records (no bounds check; DEBUG print's 32-word threshold is stale N64). **#3** stale `copy_of_body_obj_header` → NULL `RootNode` → `subdraw` silent no-op (`model.c:5326`). Next: one `GE_DVM=1` probe in the setup block triages all three in a single BUNKER1 run. | ## Resolved (for reference) diff --git a/docs/dev/QOL-INVENTORY.md b/docs/dev/QOL-INVENTORY.md new file mode 100644 index 00000000..5b2a5ae4 --- /dev/null +++ b/docs/dev/QOL-INVENTORY.md @@ -0,0 +1,59 @@ +# QoL Inventory — PD-vs-GE options diff + +Status: **read-only research, parked** (input to project Phase 4). No code +changed. Companion to `WIDESCREEN-FOV-PLAN.md` and `UNLOCKED-FPS-PLAN.md`. + +Method: diffed the config surface of both ports — every `configRegister*` +key in `pd_port/port/src/*.c` vs `port/src/config.c` + the F10 overlay +(`port/src/optionsoverlay.c`) + PD's `optionsmenu.c`. Principle applied: +**where PD has a QoL feature that is achievable here, it is the standard; +GE keeps its own extras.** + +Classification key — **P** = port-only (no rules issues), **G** = needs a +game-code exception (rule #2 sign-off, per `WIDESCREEN-FOV-PLAN.md` framing). + +## GE already has (baseline — keep) + +Input: mouse enable/capture/aim+turn speed/invert Y/Y scale/smoothing/raw +input, aim band, hipfire pitch speed, menu pointer speed + mode (GE's menu +cursor is injected entirely in `port/src/input.c` — no game code), pad +deadzone/trigger %. Video: VSync, FpsCap, MSAA, TextureFilter, +FixMipTextures, WrapFix, Fullscreen, Window geometry. Game: +ScreenShakeIntensity. Debug: FrameDump, InputLog. + +## PD has, GE lacks (candidates) + +| Feature | PD location | Class | Effort | Notes / priority | +|---|---|---|---|---| +| **Key rebinding** (config-string binds per controller/action) | `input.c:69,627-675,1548` (`bindStrs`, `inputParseBindString`) | P | M | **High.** Explicit Phase 4 debt ("rebinding UI" — note PD has *no* UI either; config-file driven). Port-only in `port/src/input.c`. | +| **SkipIntro** | `main.c:139` (STAGE_TITLE → STAGE_CITRAINING) | P | S | **High.** GE analogue: stage-override path already exists (`boss.c:385-393`, `g_StageNum = LEVELID_TITLE` at :98); wire a flag + arg. 10-min check on what feeds `tokenFindLevel`. | +| **DisplayFPS** (+ interval) | config `Video.DisplayFPS(Interval)` | P | S | Cheap; useful for FPS-plan verification too. | +| **Anisotropic filtering** (≤16×), MipmapFilter, TextureFilter2D | `gfx_opengl` texparam path | P | S–M | **High at 4K.** GE's `gfx_opengl.cpp` has mipmap nY but no aniso (`GL_TEXTURE_MAX_ANISOTROPY_EXT`). Port-only. | +| Per-pad tuning: per-stick deadzones, stick scale, rumble scale, device index, swap sticks, C-button mapping | `input.c` padsCfg block | P | M | Medium. Matters for real-controller users; PD pattern is a config section per pad. | +| FakeGamepads / FirstGamepadNum / UseHIDAPI | `input.c` | P | S–M | Niche (local MP on PC); defer until MP is actually played. | +| Window polish: DefaultFullscreen/Maximize, CenterWindow, AllowHiDpi, ExclusiveFullscreen | `video.c` vid* block | P | S | HiDpi + center are the useful ones on modern Windows. | +| VSync adaptive (−1..10) | `Video.VSync` range | P | S | Note for FPS plan — GE's 0/1 toggle is fine until then. | +| **CenterHUD** (0/1/2 = left/center/right) | `Game.CenterHUD` → `g_HudAlignModeL/R` | G* | — | Already scoped in `WIDESCREEN-FOV-PLAN.md` Option B (Phase 1 decision). Not a separate item. | +| Per-player FovY + FovAffectsZoom | `Game.Player%d.FovY`, `g_PlayerExtCfg` | G | M | PD does this in game code. GE's port-only RSP FOV slider (widescreen plan Phase 4) covers the global case; per-player is a stretch goal needing the exception class. | +| MemorySize (4–2048 MB emulated RAM) | `Game.MemorySize` → `g_OsMemSizeMb` | P? | S | Check how GE sizes its OS memory heap; if fixed, make it a config knob (stability lever for heavy levels). | +| MaxExplosions / GEMuzzleFlashes / DisableMpDeathMusic | PD decomp edits | G | S | Low priority; rule-#2 cost > benefit. Skip unless asked. | +| GlareBrightness / OverexposureScale / FramebufferEffects | PD post-FX | — | — | Not applicable: GE's fast3d has no framebuffer-effect path. Excluded. | + +## GE has, PD lacks (keep as-is) + +`Video.FixMipTextures`, `Video.WrapFix`, `Input.AimBand`, +`Input.HipfirePitchSpeed`, `Input.MouseSmoothing`, `Debug.FrameDump`, +`Debug.InputLog`. No action. + +## Suggested Phase 4 order (after audio; ordering vs widescreen/FPS TBD) + +1. **Quick wins (all P, small):** SkipIntro, DisplayFPS, aniso + mipmap + filters, window HiDpi/center. +2. **Rebinding** (P, medium) — the headline Phase 4 item; PD pattern is + config-string driven, no UI required for parity. +3. **Per-pad tuning** (P, medium). +4. Niche: fake gamepads / HIDAPI (when MP gets played); MemorySize knob + (after the heap check). + +Everything here is parked behind audio like the other plans; nothing in this +inventory implies an implementation decision yet. diff --git a/docs/dev/UNLOCKED-FPS-PLAN.md b/docs/dev/UNLOCKED-FPS-PLAN.md new file mode 100644 index 00000000..2fab7622 --- /dev/null +++ b/docs/dev/UNLOCKED-FPS-PLAN.md @@ -0,0 +1,176 @@ +# Unlocked FPS Plan + +Status: **proposal, parked** — documented while audio work (project Phase 3) is +active. Do not start Phase 0 until audio is done; first action when resumed is +the Phase 0 measurement pass. +Related: `docs/dev/findings.md` §D117 (GE_DETERM), §D155 (catch-up clamp), +`port/src/libultra.c` (pacemaker), `src/game/frametiming.c`, `src/sched.c`. + +## Decisions (agreed) + +1. **Default preset = the original pace** — whatever the game currently does + (measured ~30 fps; Phase 0 pins the exact rate). All testing, level + sweeps, determinism runs (`GE_DETERM`), and playtest baselines stay on the + default preset. Its code path must remain byte-for-byte what it is today. +2. **Unlocked FPS (60 / 120 / uncapped) is EXPERIMENTAL.** It ships behind a + clearly-labelled option and may be less polished than the default. +3. **Hard requirement:** raising the presentation rate must never change game + speed. Sim rate is invariant across presets. This is "the main speed-up + issue" and it gates everything else. +4. **Follow the PD PC port where feasible** (`pd_port` checkout): wall-clock + accumulator pacing with remainder carry, vsync + framerate-limit options, + safety cap when vsync is off. + +## Non-goals (for now) + +- Motion interpolation between sim ticks (the only thing that would make 120 + fps *look* like 120 fps). Requires per-entity prev/cur state in the + DL-building game code — rule #2 territory, parked as a separate proposal. +- Changing the sim rate itself (running logic at 120 Hz with halved constants + is explicitly out — game logic). + +## Background: why raising FPS today speeds up the game + +One clock drives everything: + +- `portTickThread` (`port/src/libultra.c`) posts a VI-retrace message on a + free-running grid (60 Hz NTSC / 50 Hz PAL, `g_tickIntervalUs`). +- `src/sched.c` `__scMain` blocks on that retrace; `__scHandleRetrace` + notifies clients → **exactly one sim tick + one render per retrace**. +- `osGetCount()` is wall-clock scaled to 46.5 MHz. `waitForNextFrame()` + (`src/game/frametiming.c`) converts elapsed time to `deltaFrames`, which + becomes `g_ClockTimer` (`src/game/lv.c:1008`) driving the per-tick sim loops. + +The quantization is the trap: `nextFrameTime = (elapsed + Q/2) / Q` (Q = one +frame quantum). The `+Q/2` bias **rounds every interval up to at least 1 +tick**, and `waitForNextFrame` spins until ≥1 tick is due. Consequence: +**sim speed tracks the retrace rate almost 1:1** — 30 Hz retrace → 30 ticks/s, +120 Hz retrace → ~120 ticks/s (each 8.3 ms interval rounds up to a full +tick). That is exactly the observed "anything faster speeds up the game". + +Two useful existing properties: + +- GE's game code already handles `g_ClockTimer == 0` and multi-tick deltas + everywhere (`for (i = 0; i < g_ClockTimer; i++)`, explicit `== 0` guards in + bondhead/bondview2/propobj/explosion). The N64 used this for dropped frames. +- D155 already clamps catch-up to 6 ticks after a real-time hitch. + +## What the PD port does (reference) + +- `src/game/timing.c` `frametimeCalculate()`: wall-clock accumulator with + remainder carry (`lostframetime60t`) → `diffframe60` / `diffframe240` ticks + due **per rendered frame**; render loop runs at display rate. +- Vsync + framerate limit options (`VIDEO_MAX_FPS` 200/240); vsync-off with + no cap forces the safety cap. +- Feasibility mapping to GE: + - *Pacing math (accumulator + carry)* → adoptable **port-only**. ✓ + - *Render frames carrying zero sim ticks* → blocked in GE by + `waitForNextFrame`'s ≥1-tick enforcement (`frameDelay`, only settable + under `LEFTOVERDEBUG`). A crafted `osGetCount()` cannot defeat it (the + spin + `+Q/2` bias always yield ≥1). Needs a game-code change → **parked** + (see "Parked" below), and note it buys little without interpolation. + +## Phases + +### Phase 0 — Baseline & ground truth (port-only) + +Goal: know exactly what the default preset does before touching anything. + +- [ ] Measure actual sim rate and present rate of the current default build + (speedgraph + a `video.c` counter; add a periodic log line + `sim=..Hz render=..Hz`). Confirm NTSC vs PAL build. +- [ ] Find why the default is ~30 rather than the console's 60 Hz. Candidates: + - tick-thread 4 ms chunked-sleep drift under host load; + - `__scTaskReady` double-buffer check (`sched.c`) stalling a task every + other retrace; + - `__scHandleRetrace`'s client flag read `*((s32*)client + 2)` is + **out-of-bounds of `OSScClient`** (struct is only `next`+`msgQ`; the + decomp even says "im wrong size"). For `gfxClient[0]` it happens to + read `gfxClient[1].next` (zero) — but this is layout luck, worth a + documented finding either way. +- [ ] Record a Dxxx finding with the measured rates and root cause. +- [ ] **Decision gate:** if 30 fps turns out to be a port artifact rather than + original behavior, document it and decide with data whether the default + preset keeps the current pace (test continuity) or is fixed. Default + assumption: keep current behavior as the "original" preset either way; + a fix becomes its own item. + +### Phase 1 — PD-style pacing hardening (port-only) + +Makes the default preset stable and drift-free; no game-code changes. + +- [ ] Replace the free-running tick grid in `portTickThread` with a + wall-clock accumulator + remainder carry (PD `frametimeCalculate` math): + post ticks when real time says they're due, carry the remainder, bounded + catch-up after hitches (D155 philosophy). No drift under load, no burst + of back-to-back ticks. +- [ ] `video.c`: proper frame limiter for fractional caps (spin+sleep hybrid), + triple-buffered swap chain, and the PD safety rule (vsync off + no cap → + force a max cap so "unlocked" can't spin). +- [ ] Config: keep `Video.VSync` / `Video.FpsCap`; add whatever Phase 2 needs. + +### Phase 2 — Experimental presentation rates (port-only) + +The user-visible "60 / 120 / unlocked" feature. Game speed is invariant **by +construction**: sim + render stay on the original tick clock; only *present* +runs faster by showing each rendered frame more than once. + +- [ ] Frame duplication at the present stage (`video.c` / fast3d SDL swap + path, near `gfx_pre_swap_hook`): after a fresh frame is presented, + re-present the last finished frame on extra vsyncs until the next sim + tick's frame is ready. Target rates: 60, 120, uncapped (safety-capped à + la PD `VIDEO_MAX_FPS`). +- [ ] Input at present rate: sample SDL input continuously in + `port/src/input.c`; each sim tick consumes the latest state (compare + against PD `joy.c`). This is the real tangible win of the feature: up + to ~half a frame of latency removed even without interpolation. +- [ ] Options menu entries, clearly labelled **experimental** + (`port/src/optionsoverlay.c`), default preset untouched. +- [ ] Audio check: `src/audi.c` uses real-time `osGetTime()` and its own + thread; libaudio buffer sends ride the sim/retrace clock. Verify nothing + divides by an assumed frame rate; expect no changes. + +### Phase 3 — Verification & sign-off + +- [ ] `GE_DETERM` regression: default preset simulation path byte-identical to + pre-change (existing determinism diffs keep passing). +- [ ] Preset-invariance playtest: a timed in-game event (e.g. a fixed-length + animation or timer) takes the same real time on default / 60 / 120 / + unlocked. This is the acceptance test for decision #3. +- [ ] Hitch recovery: force a multi-second stall (debugger pause / asset load), + confirm D155 clamp holds and no speed spiral on any preset. +- [ ] `./build-pc.sh` + `/linkcheck` ritual; new findings in §F/§H with index + labels. + +## Timing-consumer audit (each gets a line in the finding) + +| Consumer | Clock class | Action | +|---|---|---| +| `src/game/frametiming.c` (`waitForNextFrame`, `updateFrameCounters`) | sim-time | unchanged; stays tick-locked | +| `src/boss.c:403,517` (main-loop gate, `MAIN_LOOP_TICK_INTERVAL`) | sim-time | unchanged; re-verify vs new pacemaker | +| `src/usb.c:387,405` (LibDragon flash timeouts) | real-time | fine as-is; document | +| `src/speed_graph.c` | measurement | fine; source of the rate log line | +| `libultrare/audio/env.c`, `reverb.c` `lastCnt` | perf counters only | fine; document | +| `src/audi.c` `g_DeltaTime` etc. (`osGetTime`) | real-time | fine; verify in Phase 2 | + +## Parked (needs explicit sign-off, separate proposals) + +1. **True decoupled sim/render (PD's zero-tick render frames).** Minimal + change class: relax `waitForNextFrame`'s ≥1-tick enforcement so rendered + frames can carry `deltaFrames = 0` (GE already guards `g_ClockTimer == 0` + everywhere). This is a *behavior-affecting* game-code edit gated off by + default — outside the ABI-only D3x exception, needs its own rules decision. + Perceptual benefit over Phase 2 duplication is small **without** + interpolation, so do this only as a prerequisite for #2. +2. **Motion interpolation** (prev/cur entity state, lerp at render time). + The actual "120 fps looks like 120 fps" feature. Large, invasive, in + DL-building game code. Separate proposal; not implied by this plan. + +## Open questions + +- Phase 0 outcome: is the ~30 fps default a port artifact? (fix vs keep — see + decision gate) +- PAL builds: default stays 50 Hz pace; experimental present rates identical + option set. Confirm no PAL-specific pacing assumptions in Phase 1 math. +- Naming/caps for "unlocked": adopt PD's safety-cap value (200/240) or expose + the cap as a slider? diff --git a/docs/dev/VIEWMODEL-RESEARCH.md b/docs/dev/VIEWMODEL-RESEARCH.md new file mode 100644 index 00000000..ec96e00d --- /dev/null +++ b/docs/dev/VIEWMODEL-RESEARCH.md @@ -0,0 +1,88 @@ +# 1P weapon viewmodel — static research (read-only) + +Companion to `GRAPHICS-BACKLOG.md` ("1P weapon viewmodel") and findings D115 +item #5 / AUDIT-M6 #5. Goal: narrow the five audit suspects to a ranked, +probe-ready list. No code changed by this doc. + +## Path map (verified in source) + +- **Setup (per frame):** `bondview2.c:9040` → `gunUpdateAndFireBothHands()` + (`gunfire.c:1018`) → `gunUpdateAndFire(handnum)` (`gunfire.c:327`). Inside, + the weapon-model block at `gunfire.c:599-662`: computes `field_87F` (the + show/hide gate), then if set: `dynAllocate(numMatrices × sizeof Mtxf)` → + identity-fill → `modelCalculateRwDataLen` → `modelInit(HAND_WEAPON_MODEL, + ©_of_body_obj_header[handnum], HAND_WEAPON_RWPOOL)` → + `hand->mtxlist = rwmtx` and (PORT/D102) + `weaponModel.render_pos = (RenderPosView*)rwmtx`. +- **Render (same frame, later):** `gunRenderFirstPersonGunModels` loop at + `gunfire.c:1532-1540`: **`if (handptr->field_87F == 0) continue;`** then + `subdraw(&renderdata, HAND_WEAPON_MODEL(handptr))` (`model.c:5326`) which + does `gSPSegment(gdl, 3, osVirtualToPhysical(mdl->render_pos))` and walks + `mdl->obj->RootNode` — **a NULL RootNode returns silently, drawing + nothing**. + +## Statically ruled out (with evidence) + +1. **Transient-arena lifetime** (the audit's prime suspect). `dynAllocate` + is a bump allocator on the vtx pool (`src/game/dyn.c:123`), reset only by + `dynSwapBuffers()` at frame end. Setup and render both happen inside one + frame, so `render_pos`/`mtxlist` are valid when consumed — in solo play. + (Split-screen mid-frame swaps would be a separate question; out of scope.) +2. **Word-index / record-size mismatch** (`flashvisptr = RWPOOL + + RwDataIndex`, `gunfire.c:650`). D52 kept `Model.datas` as `u32*` on PC + precisely so N64 word math holds, and `RwDataIndex` values are computed + at runtime by `modelCalculateRwDataIndexes` from the *PC* record sizes — + self-consistent. (Self-consistent ≠ in-bounds; see suspect 2.) +3. **Segment/render_pos mechanism itself.** Every working model in the game + (bodies, props) uses the same `gSPSegment(3, render_pos)` + + `bondviewTransformManyPosToViewMatrix` machinery — and it renders. The + **watch menu draws the same weapon models** via a different path + (`set_enviro_fog_for_items_in_solo_watch_menu`, `gunfire.c:1630+`, local + matrices, no 87F gate, no hand rw-pool) and they appear (albeit faint — + D75/D141 note). So the model assets and headers are very likely fine; the + defect is in-level-path-specific. + +## Ranked suspects + probe spec + +**#1 — `field_87F` gating (`gunfire.c:599-610`).** The clear-condition +disjunction contains `(Gun_hand_without_item(handnum) == 0)`. That function +(`gun.c:812-816`) returns `hand_invisible[h] > 0 || (hand_item[h] == 0 && +field_2A44[h] < 0)` — so the term is TRUE (→ gate cleared → gun hidden) +whenever the hand *has* an item and isn't invisible. Read naively, that +hides the gun exactly when you're holding one; since the N64 shows the gun, +the runtime values of `hand_item`/`field_2A44`/`hand_invisible` must differ +from their names suggest — but this is the single most likely place for a +PC-only divergence (e.g. a field the port initializes differently). If 87F +is 0, the render loop `continue`s and the symptom is exactly "no gun, no +crash". + +**#2 — rw-pool capacity.** PC records are wider than N64's (pointer fields); +`weaponRwPool[192]` (768 B) was D102's scaled-up budget, but nothing bounds- +checks it. The `#ifdef DEBUG` print at `gunfire.c:620` still tests the *N64* +32-word threshold — stale on PC. If `numRecords` words exceed 192, +`modelInitRwData` overruns the pool into whatever follows it in `struct +hand` → corruption that could kill the draw (or worse, silently). + +**#3 — stale `copy_of_body_obj_header`.** Populated only at item load +(`gun.c:924` shallow copy / `gun.c:931-951` `load_object_fill_header`). If a +PC asset-load quirk zeroed it for the in-level path, `RootNode == NULL` and +`subdraw` no-ops silently. Lower priority given the watch menu renders the +same models — but cheap to confirm in the same probe. + +**Probe (one session, PORT-only, env-gated `GE_DVM=1`, zero behavior +change):** in the `field_87F != 0` block at `gunfire.c:611+`, print once per +frame while a pistol is held in BUNKER1: `handnum`, `item`, each clear-term +value + final `field_87F`, `©_of_body_obj_header[h]`, `numMatrices`, +`numRecords` (words) vs 192, `RootNode`, `rwmtx`. One run triages all three +suspects: 87F==0 → suspect #1 (log which term); numRecords>192 → #2; +RootNode NULL/garbage → #3. + +## Fix-shape notes (for when the probe lands) + +- If #1: the fix is a `#ifdef PORT` initialization/correction of whichever + hand field diverges — **not** an edit to the gate logic itself (rule #2); + document as Dxxx with the N64-vs-PC value table. +- If #2: enlarge `weaponRwPool` to the measured max + a PORT bounds guard + that prints (mirrors the existing DEBUG print, correct threshold). +- If #3: trace the `load_object_fill_header` call for the in-level weapon + load; likely an asset-offset issue in the port's loader path. diff --git a/docs/dev/WIDESCREEN-FOV-PLAN.md b/docs/dev/WIDESCREEN-FOV-PLAN.md new file mode 100644 index 00000000..ea71e384 --- /dev/null +++ b/docs/dev/WIDESCREEN-FOV-PLAN.md @@ -0,0 +1,241 @@ +# Widescreen & FOV Plan + +Status: **proposal, parked** — like `UNLOCKED-FPS-PLAN.md`, resume after audio +work (project Phase 3). Research current as of this writing. + +Scope decision (agreed): **target exactly two display aspects — 4:3 and 16:9.** +Arbitrary aspect ratios are TBD and out of scope; the window manager should +snap/letterbox to one of the two rather than trying to serve anything else. + +Standard (agreed): **Option B (native 16:9 render, PD-parity) is the goal.** +The PD PC port's widescreen architecture is the reference standard for this +engine family. Option A (4:3-internal + stretch) remains an acceptable +**case-by-case workaround** only where B proves impractical for a specific +surface — each such fallback documented individually. + +## Problem + +At high resolutions (e.g. 4K) in a 16:9 window the game is disorienting. Root +cause, confirmed in code: the port renders in *window-resolution space*. All +320×240 logical screen coordinates are scaled per-axis by +`RATIO_X = win_w/320`, `RATIO_Y = win_h/240` (`port/fast3d/gfx_pc.cpp:53-54`). +In a 16:9 window everything — world geometry **and** HUD — is stretched 4/3 +horizontally: circles become ellipses, and the horizontal FOV balloons from +~78° to ~91° while vertical stays 60°. That stretch is the disorientation. + +## Research findings + +### 1. The FOV model (where FOV lives) + +- Vertical FOV is a fixed 60° base: `FOV_Y_F` (`src/fr.h:137`); per-player + `fovy`/`aspect` fields default to 60 / `DEFAULT_ASPECT` + (`src/game/player.c:454-455`). +- Setters: `viSetFovY` / `viSetAspect` / `viSetFov(fovx, fovy)` + (`src/fr.c:911-934`) → `guPerspectiveF(g_viProjectionMatrixF, …, fovy, + aspect, znear, zfar, 1.0f)` (`src/fr.c:718`). +- **FOV is dynamic**: aim zoom rewrites fovy every frame via + `viSetFovY(g_CurrentPlayer->zoominfovy)` (`src/game/bondview2.c:3123`), and + the camera path resets fovy/aspect per frame + (`bondviewMovePlayerUpdateViewport`, `src/game/bondview2.c:7967-8034`). + ⇒ Any FOV option must be a *multiplicative correction composable with zoom*, + not an override of a constant. + +### 2. The game ships with a (stretch-based) 16:9 path + +This decomp contains a player-facing widescreen path from the original: +options-menu **"Ratio": Normal / 16:9** (`GAME_OPTIONS_INDEX_RATIO`, string +`OPTION_STR_23_169_LF`, `src/game/options.c:129`), save bit +`OPTION_SCREENRATIO` (`src/game/file2.c:1266-1270, 1323`), +`get/set_screen_ratio()` (`src/game/options.c:553-558`). When active, the +camera sets `aspect = (vp_w/vp_h) × 0.75 × 16/9` (= viewport ratio × 4/3, +`src/game/bondview2.c:8007-8034`) — the pre-distortion for a **4:3 render +stretched to 16:9** (N64-out-4:3, TV stretches). HUD compensation exists for +crosshair (`gunfire.c:6316-6318`, half-width ×0.75) and rocket aim +(`gunfire.c:501`); EU-only extras need an NTSC audit. + +⇒ This path is exactly **Option A's** game-side machinery. It is *not* the +PD standard (which renders native 16:9), so under Option B it stays dormant +except as fallback. + +### 3. Current port rendering pipeline (what to build on) + +- Render happens at window size; an FBO path already exists and activates when + internal dims ≠ window dims or MSAA > 1 (`gfx_pc.cpp:3041-3115+`). +- Native-res bookkeeping: `GE_NATIVE_W/H` = 640×480 (NTSC) / 640×400 (PAL) + (`port/src/video.c:40-44`); VI size requests clamp to ≤640 + (`port/src/libultra.c:816`). Auto window defaults to 4:3 + (`port/fast3d/gfx_sdl2.cpp:107`). + +### 4. fast3d EXT aspect commands — already present in GE's RSP + +GE's software RSP (adapted from PD's) implements Rare's PC-port extension +geometry-mode commands `G_ASPECT_{LEFT,RIGHT,WIDE,CENTER}_EXT` +(`gfx_pc.cpp:1954-1979`, decoder at `gfx_sp_extra_geometry_mode`), and +`port/fast3d/gbiex.h` carries the `gSP…EXT` command macros. **The RSP side of +the PD standard already works in this codebase**; only the game-code emission +sites are missing. + +### 5. The PD standard (reference architecture — what Option B replicates) + +From the `pd_port` checkout (N64 PD had no widescreen; the port added it): + +1. **Projection aspect comes from the port**: `videoGetAspect()` returns the + real window aspect (`gfx_current_dimensions.aspect_ratio`, + `port/src/video.c:232-235`). Game code calls `viSetAspect(videoGetAspect())` + (e.g. `title.c:2495`; even `#define TITLE_ASPECT (videoGetAspect())`). + Native 16:9 projection ⇒ correct perspective, vertical FOV preserved. +2. **Per-element screen-space corrections** where an element is positioned in + screen coords: `sightGetAdjustedX(x)`, `×(SCREEN_ASPECT / videoGetAspect())` + scalings (`sight.c:77,921`, `sky.c:2728+`, `menu.c:2332,4771`, + `player.c:3108`). +3. **HUD alignment via the EXT commands**: screen-space draw segments wrapped + in `gSPSetExtraGeometryModeEXT(gdl, G_ASPECT_CENTER_EXT)` … + `gSPClearExtraGeometryModeEXT(…)`, all under `#ifndef PLATFORM_N64`. With + the mode active, fast3d scales 4:3-logical screen coords by the *native* + aspect and centers (or left/right-aligns) them in the wider window — no + distortion. PD scale: **52 emission sites across 10 game files** (sight, + hudmsg, bondgun, menu, radar, title, propobj, credits, activemenu, + game_1531a0). +4. User-facing: vsync/framerate options + HUD-centering option + (`g_HudCenter` → `g_HudAlignModeL/R`, `port/src/main.c:77-83`). + +## Option B — the standard (native 16:9, PD-parity) + +### Architecture + +Render directly at the 16:9 window resolution. World: projection aspect = +window aspect (vertical FOV preserved, no post-stretch). HUD/screen-space: +4:3-logical coordinates mapped undistorted via the EXT aspect modes +(centered by default; left/right alignment available à la PD). 4:3 windows: +byte-identical to today. + +### Change inventory + +**Port layer (no rules issues):** +- [ ] `videoGetAspect()` equivalent in `port/src/video.c` (return window + aspect; GE already tracks `gfx_current_dimensions.aspect_ratio`). +- [ ] Window/aspect policy: `Video.Aspect` {4:3, 16:9}, snap on create/resize; + out-of-scope aspects → nearest + note (TBD). +- [ ] Make `port/fast3d/gbiex.h` reachable from game TUs (include path or a + shim header) — build-system only. +- [ ] HUD alignment mode plumbing if we adopt PD's center/wide option + (`g_HudAlignModeL/R` pattern) — decide in Phase 1; default = centered. + +**Game code (exception class — see below):** +- **(a) Projection aspect path.** `bondviewMovePlayerUpdateViewport` + (`bondview2.c:8007-8034`): under PORT + 16:9, use window aspect instead + of the ×4/3 stretch formula. Plus the menu/title/briefing call sites + `viSetFovY(FOV_Y_F); viSetAspect(ASPECT_RATIO_SD)` (`front.c` — ~7+ + sites; enumerate in Phase 1) → PD pattern `viSetAspect(videoGetAspect())`. +- **(b) HUD / screen-space draw sites.** Audit + wrap with EXT aspect modes + and/or per-element corrections, per surface: gameplay HUD (crosshair — + replaces the existing ×0.75 compensation; ammo/timer/text via + `textrelated.c`), menus (`front.c`), title/briefing/credits, letterbox + bars (`fr.c` gDPFillRectangle calls). GE's known screen-space emitters: + `front.c`, `textrelated.c`, `gunfire.c`, `bondwalk2.c` (initial grep; + Phase 1 completes the audit). Expect PD-comparable scale (~10 files), + possibly less. +- **(c) Anything the audit surfaces** (e.g. mouse→screen mapping for menus, + cutscene camera framing) — each item logged individually. + +### Rule #2 exception framing (needs explicit sign-off to start Phase 2) + +This is a behavior-affecting game-code edit class, outside the ABI-only D3x +exception. The case for granting it: + +1. **Direct sister-engine precedent**: PD's port made exactly these edits on + the same Rare engine/fast3d; the project designates PD as standing + reference for port patterns. We replicate a proven, bounded change class, + not inventing one. +2. **Bounded and gated**: every edit is `#ifdef PORT` (and where sensible + runtime aspect-gated) so the N64 build and the 4:3 default path stay + byte-identical; golden/determinism tests unaffected at 4:3. +3. **The decomp already contains** both a shipped 16:9 branch and `#ifdef + PORT` precedents in game files — the pattern is not foreign to this tree. + +Conditions: explicit user sign-off before Phase 2; each change (or tight +cluster) gets a findings.md entry with §F index label citing the PD analogue; +4:3 byte-identity verified per phase; any edit that would alter *sim* behavior +(not just presentation) stops the work and comes back for review. + +### Fallback (case-by-case, documented) + +If the Phase 1 audit shows a specific surface is disproportionately costly or +can't be made clean under B (candidate: complex cutscene framing), that +surface may fall back to **Option A** — 4:3-internal render + edge-to-edge +stretch using the game's shipped `SCREEN_RATIO_16_9` path (`set_screen_ratio` +is an existing API; FBO+blit infra exists) — recorded as a finding with the +reason. Whole-feature fallback to A only if B fails verification overall. + +## FOV adjustment design (port-only, applies to both options) + +- **Hook: software RSP projection post-scale.** Wherever the RSP applies the + uploaded projection matrix, scale its vertical row by + `tan(fov_orig/2) / tan(fov_adj/2)`. Zoom rewrites the matrix every frame, so + post-scaling whatever arrives composes with aim zoom for free. No game code. +- Rejected: editing `viSetFovY` call sites (game code); writing + `g_ViBackData` from the port (racy). +- UI: `Video.FovScale` (percent of original vertical FOV, e.g. 50–150), + default 100 = byte-identical to today. + +## Phases + +### Phase 0 — Verification research (port-only, read-mostly) + +- [ ] Playtest the in-game Ratio option as-is on PC; document + screenshot + what the shipped stretch path does under the current pipeline. +- [ ] Measure baseline FOVs (4:3 and current 16:9-stretch) from the projection + matrix or a calibration render; record numbers. +- [ ] Confirm `gbiex.h` EXT commands round-trip through GE's RSP with a test + DL (they should — decoder exists). +- [ ] Audit EU-only aspect constants (`g_GunSightAspectRatio`, + `EU_CAMERA_8003642C_ASPECT`) for NTSC/PAL effect. +- [ ] Record a Dxxx finding consolidating baselines. + +### Phase 1 — Port foundation + full change audit (port-only) + +- [ ] `videoGetAspect()`, window/aspect policy, gbiex.h include path. +- [ ] Complete the screen-space draw-site audit for inventory item (b): every + emitter, its coordinate assumptions, proposed wrap/correction, PD + analogue cited. Output = the signed change list for Phase 2/3. +- [ ] Decide HUD alignment default (centered vs PD's center/wide option). + +### Phase 2 — Projection aspect (game-code exception class, small wave) + +- [ ] Item (a): camera path + front.c call sites, `#ifdef PORT`-gated. +- [ ] Verify: world perspective correct at 16:9 (FOV measurement vs Phase 0), + aim zoom composes, 4:3 byte-identical. + +### Phase 3 — HUD alignment wave (game-code exception class) + +- [ ] Item (b), per surface in order: gameplay HUD → menus → + title/briefing/credits → letterbox/misc. Each surface: change, visual + verification at 4:3 + 16:9, finding entry. Fallback-to-A decisions + happen here, per surface, with documented reason. + +### Phase 4 — FOV slider (port-only) + +- [ ] RSP projection post-scale; `Video.FovScale` config + F10 overlay; + verify composition with aim zoom at non-100 scale. + +### Phase 5 — Verification & sign-off + +- [ ] FOV measurements per mode/scale vs Phase 0 baselines. +- [ ] Full HUD pass at 4:3 + 16:9; cosmetics → `GRAPHICS-BACKLOG.md`. +- [ ] 4K performance check; MSAA interaction; config persistence; screenshots. +- [ ] `./build-pc.sh` + `/linkcheck`; findings complete with §F index labels. + +## Open questions + +- HUD alignment UX: always-centered (simple) vs PD's center/wide option + (more faithful, more surface area). Decide Phase 1. +- In-game Ratio menu entry under Option B: the shipped option means "stretch + mode" — keep it as the *fallback* trigger, hide it, or repurpose? Decide + Phase 1 (leaning: leave dormant; port config owns display mode). +- Mouse/menu coordinate mapping at 16:9 (PD had `videoGetAspect()`-scaled + mouse math in menu.c) — confirm GE's menu input path needs the same. +- Ordering vs `UNLOCKED-FPS-PLAN.md`: both parked behind audio; B renders + native so the FPS plan's present-stage duplication slots in unchanged. + Decide order when audio lands. +- PAL: `GE_NATIVE` 640×400 bookkeeping — confirm aspect math per region. +- Split-screen viewports under 16:9 — likely out of scope for PC; note and close. diff --git a/docs/dev/findings-index.csv b/docs/dev/findings-index.csv index 5197501c..8fbefe3d 100644 --- a/docs/dev/findings-index.csv +++ b/docs/dev/findings-index.csv @@ -21,8 +21,20 @@ D190,OPEN,"Linux -level_45 SIGSEGV at load in add_ptr_to_objective (objective.c: D191,OPEN,Windows -O2 release-bundle crash:,OPEN — not investigated (M-49). D193,OPEN,NPC AI locomotion is too slow —,"OPEN — root-cause hypothesis (timing family), not investigated (M-49)." D194,OPEN,Mouse input needs another tuning pass —,"OPEN — root-caused, not fixed (M-49)" +D195,OPEN,"Transparency / alpha surfaces broken on Control (M-49, user playtest) —","OPEN — not investigated (M-49), needs a capture" +D197,OPEN,"Character face/head textures wrap around the head, seen on Silo (M-49, user playtest).","OPEN — not investigated (M-49), needs a capture" +D198,FIXED,alAdpcmPull/_decodeChunk used K0_TO_PHYS (an unconditional & 0x1FFFFFFF mask) on 64-bit heap pointers (src/libultra/audio/load.c).,FIXED (src/libultra/audio/load.c) — +D199,PARTIAL,Phase-3 software audio mixer:,"LANDED (port/src/mixer.c, port/include/mixer.h, include/PR/abi.h) —" +D200,FIXED,-level_09 segfault: reverb's ALDelay.input/output are u32 —,FIXED — 5-line cast in reverb.c (this session) D196,OPEN,"OS mouse cursor stays visible after closing the F10 options overlay in click-to-lock mode (M-49, user playtest).","OPEN — root-caused, 1-line fix identified, not applied (M-49)" D192,OPEN,Front-end mission/level-select grid pointer still can't reach the outer cells —,"OPEN — root-caused, not fixed (M-49)" +D204,FIXED,PC audio ran ~2 % below real time permanently:,"FIXED (port/src/audio.c, port/src/libultra.c, port/include/audio.h) —" +D203,OPEN,"Steam Deck (SteamOS, x86_64 Linux) v0.1.0 release bundle:",OPEN — negative idle repros only; not yet reproduced with input. +D201,FIXED,"src/libultra/audio/bnkf.c bank/instrument/sound/wavetable relocation offsets were s32, truncating the real 64-bit base pointer.",FIXED (src/libultra/audio/bnkf.c) — +D202,RESOLVED,"Silenced PPK plays a ""slap"" instead of a gunshot, PLUS user reports broader real-time audio corruption (M-52 → M-59 → REOPENED M-60 → M-61/M-62 closed the data…",ROOT CAUSE ESTABLISHED (M-66); DISPOSITION C IMPLEMENTED + MEASURED (M-66b) — +D206,FIXED,PC played every SFX one bank slot too high —,"FIXED (M-76, src/snd.c:1001, #ifdef PORT → soundArray[soundIndex - 1], N64 line verbatim under #else)." +D207,?,Alarm klaxon starves combat SFX and (per user) doesn't recover after it stops —,MONITORING (M-77c: user reports the alarm is acceptable in play after D206; no fix shipped) — +D205,OPEN,PC plays wrong/extra SFX for gameplay events the user can A/B against N64:,OPEN — bank + playback + requested indices all exonerated; M-73 LOS/geometry root cause WITHDRAWN (M-74). D152+,FIXED,D152 addendum (M-31):,"FIXED (src/snd.c, port/src/libultra.c, #ifdef PORT) —" D168,FIXED,GE_PCDUMP / F12 PPM captures were vertically flipped —,FIXED (port/fast3d/gfx_opengl.cpp); D114 / D116 reclassified as capture-orientation artifacts (below). D169,FIXED,Front-end mouse pointer can't reach the outer cells of the mission/level-select grid —,"FIXED (M-33, port/src/input.c, port-only, no #ifdef PORT)." diff --git a/docs/dev/findings.md b/docs/dev/findings.md index 47f148dc..ef3c95c8 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -394,8 +394,20 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D191 | **Windows `-O2` release-bundle crash: SIGSEGV in `modelGetNodeRwData` (`src/game/model.c:478`) — the `root->Parent` node-tree walk (M-49, user playtests of the v0.1.0 win64 bundle).** **Two instances, identical fault PC `0x1400801b1`:** (1) fresh save, killed the first guard right after escaping the cell in Bunker ii; (2) Statue (`-level_49`?), right after the Trevelyan meeting cutscene. `ge007.crash.log` (auto-dump; symbols intact in the shipped exe, `addr2line` resolves): PC = `modelGetNodeRwData` at `model.c:478` — the `switch (root->Opcode & 0xff)`, i.e. `root` (a `ModelNode *`) is bad; `addr2line` puts it in the inlined self-recursion at `:532`, so the `while (root->Parent) { root = root->Parent; … }` walk (`model.c:527-540`) handed a garbage node into the recursive call. Instance 1: `Rdx = 0x70267bc000000000` — a `~0x70266xxx` heap address in the **high 32 bits, zero low half** = 32-bit pointer field read as 64-bit (or a byte-swapped `u32` pair). Instance 2: `Rdx = 0`, FAULT ADDR `0x0` — `root` (or `root->Parent`) resolved to NULL. Both: `Rcx=Rdi` = the `~0x7008xxxx`/`~0x7007xxxx` object ptr, `R8 = 0x1d` (29). So the `Parent` chain in some model contains a **truncated or NULL link** — pointer-width / byte-swap family (D3x, D119 `weapons_held[]->chr` type-pun, D122/D126/D132 propDef-stride). NOT weapon-fire-specific — common factor is `modelGetNodeRwData` running on a character/cutscene model whose node tree has a bad `Parent`. Crash logs saved: `scratchpad/crashlogs/`. **Repro aid:** shipped exe has full symbols; `debug-crash.ps1` in the bundle dir runs it under gdb, `GE_D51=1` → `d52rw.log` rwdata-pool trace. The crash.c EBP backtrace breaks immediately (`#01: 0x1d`) — need a real `bt` + `p *root` + `p *Objinst` at the fault. Not yet reproduced under a debugger; no fix. | OPEN — not investigated (M-49). 2 field instances (Bunker, Statue). Awaiting a gdb backtrace + `d52rw.log`. | | D193 | **NPC AI locomotion is too slow — characters don't move to their destinations at the original game's rate (M-49, user playtest; clarified after the FULL 21-level playthrough — "the biggest overall flaw").** Not (primarily) an animation-playback bug — the *travel speed* of scripted/AI movement is wrong: guards, named NPCs (Trevelyan, Ourumov) and friendlies (Natalya) all take noticeably longer to run/walk to their goal positions than on N64. General, not chr-specific. **Leading hypothesis — the D117/D134/D155/D156 wall-clock-timing family:** `chr.c` / `chrai.c` integrate movement as `pos += speed * g_GlobalTimerDelta` and step sim loops `while (i < g_ClockTimer)` / `-= g_ClockTimer` (`chr.c:1437,1446,1491-1502,1862,2359,2569`; `chrai.c:76`). On the port `osGetCount()` is free-running wall-clock (D117), `g_GlobalTimerDelta`/`g_ClockTimer` are derived from it, and D155's `FRAMETIMING_PORT_MAX_CATCHUP = 6` clamps `nextFrameTime`. If the effective per-second sum of `g_GlobalTimerDelta` (or the `g_ClockTimer` tick count) runs low — quantization, the catch-up clamp biting during normal play, or a units mismatch (`g_JP_GlobalTimerDelta` vs `g_GlobalTimerDelta`, `chr.c:1435/1437`) — every delta-scaled AI motion integrates slow while **player** movement (input-driven per frame) still feels right, which is exactly the reported split. Related: **D170** (Ourumov/Trevelyan "flee" looks wrong — flagged there as possibly the same locomotion-speed issue rather than an AI divergence). Also possible: `chrGoToBond/Chr(SPEED_RUN/WALK/SPRINT)` speed enum → path-follow step, or pathfinding advancing ≤1 waypoint per frame-quantized tick. **Instances seen (M-49 full playthrough):** (a) Facility — the post-scripted-scene beat with Ourumov + the soldier squad: the soldiers **walk in super slowly and take a long time to path into firing positions** before they start shooting (this is regular squad combat AI, not a named-NPC script — good evidence the slowdown is general delta-scaled `chr` locomotion / path-following, not one ailist); (b) the named-NPC flee behaviour on Silo (Ourumov) and Cradle (Trevelyan) — see D170; (c) **Natalya (the escort NPC) always walks really slow** — on every escort level (Bunker ii, Statue, Control, Archives) she trails far behind, forcing the player to wait. Escort follow-AI is the same `chrGoToChr`/path-follow path as (a). **WORST CASE — Cradle (`-level_51`) is "bugged out completely" due to Trevelyan's behaviour (M-49).** The final level is Trevelyan-fleeing-and-scripted end to end (he runs the antenna cradle, climbs, drops to the cradle floor, the helicopter beat); if his locomotion timing / path-following is off he never hits the trigger points and the level's scripted progression stalls or goes haywire. This makes D193 effectively a **can't-properly-finish-the-game** bug, not just cosmetic sluggishness. **Next:** log `g_GlobalTimerDelta` + `g_ClockTimer` per frame during normal play and check the 1-second sum vs the N64's ~60; time a guard's run across a known distance vs reference footage; then watch Trevelyan's chr state / current AI command on Cradle. | OPEN — root-cause hypothesis (timing family), not investigated (M-49). **Highest-impact playtest finding — breaks Cradle.** | | D194 | **Mouse input needs another tuning pass — RMB aim over-sensitive + sensitivity couples to frame/sim rate (M-49, user playtest).** Two parts. **(a) Aim mode (RMB) is near bang-bang, not proportional** (`port/src/input.c:697-707`): any per-poll delta past `AIM_MOVE_THRESH` emits stick `m = clamp(61 + |Δ|·(MouseAimSpeed/100)·AIM_GAIN, 61, 60+AimBand)` = `[61,80]` — with the M-29 defaults (`MouseAimSpeed=16`, `AIM_GAIN=4.0`, `AimBand=20`) `m` is already 61 (fast) at ~1.5 px/poll and saturates at 80 by ~30 px/poll, so the usable proportional band is tiny and sits near the top of GE's aim range → "way too sensitive," turn rate barely tracks how fast you actually move the mouse. Needs a real response curve (low floor, wider proportional band, maybe a gamma), not a threshold+clamp. **(b) Overall sensitivity correlates with movement/frame rate** (user's words: "correlated with movement speed, does not feel good"): `input.c` emits raw stick counts **per controller poll** with no `g_GlobalTimerDelta` / polls-per-frame normalization (hipfire yaw `sx += edx·(MouseTurnSpeed/100)·6.0` at `:709`; aim as above). GE integrates the stick scaled by its sim delta, and the port's poll cadence vs render/sim cadence drifts with scene load — so effective sensitivity shifts when the framerate does (which reads as "when I'm moving"). Fix direction: accumulate mouse delta and convert to stick using a fixed reference dt (or feed the game a dt-consistent value), so a given hand motion = a given view rotation regardless of fps. Lineage: D118a (hipfire pitch digital vs analog), D165 (menu pointer), D166 (hipfire pitch pulses), D180-B3 (`MouseAimSpeed` 25→16). Config knobs exist (`Input.MouseAimSpeed/AimBand/MouseTurnSpeed/HipfirePitchSpeed`) but the *shape* of the mapping is the problem. | OPEN — root-caused, not fixed (M-49) | +| D195 | **Transparency / alpha surfaces broken on Control (M-49, user playtest)** — Natalya's console room, the glass partitions + translucent projected wall displays. Failure mode not yet specified by the reporter (no `GE_PCDUMP` capture taken). Likely a fast3d render-mode / alpha-blend path defect, not a decode one — neighbours D161 (CI8/LUT decode), D172, D176(b) (both blend-adjacent) but those are texture-format bugs, this reads as a blend-state one; check D128 portal adjacency in case the glass geometry is portal-culled wrong instead. Logged in `GRAPHICS-BACKLOG.md`. **Next:** `GE_PCDUMP` capture on Control near the console room, compare RDP render-mode words against N64 `gmain.s` for the glass/display materials. | OPEN — not investigated (M-49), needs a capture | +| D197 | **Character face/head textures wrap around the head, seen on Silo (M-49, user playtest).** Reads as `G_TX_CLAMP` not honoured (or a non-power-of-two wrap period) on a head/face texture tile — the texture repeats around the mesh instead of clamping at the UV edge. Not yet isolated to a specific character or texture; no `GE_PCDUMP`/`GE_TEXDUMP` capture taken. Likely fast3d texture-tile setup (clamp/wrap mode bits from the tile descriptor not round-tripped) rather than a data bug, given it's cosmetic and geometry-independent. **Next:** `GE_TEXDUMP` the offending head texture's tile descriptor on Silo, diff clamp/mirror bits against `gmain.s`'s expectations for that material. | OPEN — not investigated (M-49), needs a capture | +| D198 | **`alAdpcmPull`/`_decodeChunk` used `K0_TO_PHYS` (an unconditional `& 0x1FFFFFFF` mask) on 64-bit heap pointers (`src/libultra/audio/load.c`).** `K0_TO_PHYS` is the N64 kseg0→physical mask, valid only because N64 pointers fit in 32 bits; on PC's 64-bit heap it truncates any pointer whose value exceeds 512MB, corrupting the `aLoadADPCM`/`aSetLoop`/`aADPCMdec` DRAM addresses at 3 call sites (`load.c:71`, `:468`, `:474`). Every sibling call site in `env.c`/`resample.c`/`reverb.c` already resolves state pointers via `osVirtualToPhysical` (the port shim, `port/src/libultra.c`), which is bit-identical to `K0_TO_PHYS` for real N64 kseg0 addresses but correctly PC-shimmed. **Fix (ABI-layer, `#ifdef`-free — same call resolves correctly on both platforms):** replace the 3 `K0_TO_PHYS(...)` calls with `osVirtualToPhysical(...)`. No behavior change on N64 (identical result for kseg0 pointers); fixes PC. Surfaced while building the Phase-3 software mixer (D199) — the acmd list these opcodes feed now actually executes on PC instead of being ignored. | FIXED (`src/libultra/audio/load.c`) — part of the Phase-3 mixer landing, not yet build/runtime-verified in isolation | +| D199 | **Phase-3 software audio mixer: macro-swap `aXxx` opcode execution + reverse-engineered `aSetBuffer` persistent-context semantics (`port/src/mixer.c`, `port/include/mixer.h`, `include/PR/abi.h`).** The RSP audio ucode (`aspMain`) that would normally execute GE's acmd list never runs on PC (`port/src/ucode.c`); adapting the Perfect Dark PC port's macro-swap trick (`docs/dev/AUDIO-PLAN.md`), `include/PR/abi.h` now `#include`s `port/include/mixer.h` under `#ifdef PORT`, which redefines every `aXxx` macro to call an `Impl` function in `mixer.c` immediately against a small software "DMEM" scratch buffer, instead of packing RSP command words for later execution. GE uses the classic IDO libaudio ABI (verified against every call site in `src/libultra/audio/*.c` + `src/libultrare/audio/{env,reverb}.c`), not PD's differently-shaped "naudio New" ABI — DSP math (ADPCM decode, linear resample, linear envelope mix) is the same Nintendo/SGI ucode PD's `mixer.c` already ported; only the ABI/addressing layer is GE-specific and new here. Several opcodes (`aADPCMdec`, `aResample`, `aEnvMixer`, `aLoadBuffer`, `aSaveBuffer`) take only a state/DRAM pointer — their DMEM source/dest addresses and byte counts come from the most recent `aSetBuffer` call(s), which there is no RSP disassembly to check against; the persistent-context shape (`sCtx.{in,out,count,dryR,wetL,wetR}`) was reconstructed purely from reading every `aSetBuffer`+opcode call-site pair across `resample.c`/`load.c`/`reverb.c`/`mainbus.c`/`save.c`/`env.c` (see the field comments on `sCtx` in `mixer.c:52-82`). `aSegmentImpl` is a no-op — GE's audio DRAM addresses are already resolved via `osVirtualToPhysical` before reaching the mixer (see D198), so segment/base never factor into DMEM addressing here. **Risk:** the `aSetBuffer` context reconstruction is an inference, not a verified spec — if a call site this session didn't audit sets the context differently, audio for that path will mix from the wrong DMEM offsets. **Verified:** `-level_09` now runs full music + SFX 120s+ crash-free (post-D200 fix); no cross-level or opcode-level audio-correctness verification done yet (no golden-audio comparison tooling exists). | LANDED (`port/src/mixer.c`, `port/include/mixer.h`, `include/PR/abi.h`) — functional on `-level_09`, context-inference risk noted, needs a wider level sweep | +| D200 | **`-level_09` segfault: reverb's `ALDelay.input/output` are u32 — `-d->output` zero-extends to a +4GB pointer offset on 64-bit (D3x class, FIXED).** `src/libultrare/audio/reverb.c` back-references the delay ring as `&r->input[-d->output]`; on N64 (s32 `ptrdiff_t`) the u32 negation wraps to a small negative index, on PC x86-64 it zero-extends to +4294967136 samples → `_loadBuffer`/`_saveBuffer` write 13–52KB wild, trampling the delay array / `r->base` / adjacent heap in a self-propagating loop until unmapped memory. Fixed with `(s32)` negation casts at 5 sites (PD ground truth `n_reverb.c` does exactly this); ramalign line gets `(s64)`. Verified: `-level_09` runs 120 s+ crash-free, DRAM guard window over the FX region sees zero OOB writes. | FIXED — 5-line cast in reverb.c (this session) | | D196 | **OS mouse cursor stays visible after closing the F10 options overlay in click-to-lock mode (M-49, user playtest).** `Input.MouseCaptureMode=1`. Open F10 overlay → `optionsOverlayToggle()` (`optionsoverlay.c:391`) calls `inputSuspendForOverlay()` which forces `SDL_ShowCursor(SDL_ENABLE)` + drops the grab. While open, `inputComputePad(0)` early-returns at `input.c:512` (overlay owns controller 0) **before** `reconcileGrab()` / any cursor-visibility call. On close: the next poll runs `reconcileGrab(menuMode)` (`input.c:522`) which re-grabs + hides the cursor **only if `captureArmed && !menuMode`** — i.e. only if you had already clicked-to-lock in a stage before opening F10. If you opened the overlay from a menu, or in a stage you hadn't clicked-locked yet, `want` computes 0, `mouseGrabbed` stays 0, and **nothing calls `applyCursorVisibility()`** — so the cursor `inputSuspendForOverlay()` made visible never gets re-hidden (correct free-but-focused state per `applyCursorVisibility()` `input.c:842-846` is *hidden*). **Fix (port-only, ~1 line):** call `applyCursorVisibility()` right after the `reconcileGrab(menuMode)` at `input.c:522` (self-heals every poll once the overlay-open early-return stops firing), or add an `inputResumeFromOverlay()` that runs `reconcileGrab` + `applyCursorVisibility` and call it from `optionsOverlayToggle()` on close. D180/D184/D165 input lineage. Minor. | OPEN — root-caused, 1-line fix identified, not applied (M-49) | | D192 | **Front-end mission/level-select grid pointer still can't reach the outer cells — but only under `Input.MouseCaptureMode=0` (legacy always-grab) (M-49, user playtest).** D169 was marked FIXED (M-33) by replacing the hard-coded 320×240 pointer clamp with the live `getPlayer_c_screenwidth/height/left/top()` rect; the feel-check was flagged "owed" on both D169 and D180 and never done. It now reproduces: with `MouseCaptureMode=0` the menu pointer runs the relative-delta P-controller path (`port/src/input.c:659-690`), which clamps target+estimate to `getPlayer_c_screen*` — but that accessor returns `g_CurrentPlayer->c_screenwidth` (`src/game/bondview.c:882`), which in the front end is the ~320×240 stage viewport, **not** the front end's actual 440×330 (`front.c:8570` `viSetViewSize(440,330)`). So the effective clamp is still ~300/220 while the mission-select grid's outer split points sit at 317 / 235.5 (`front.c:516/519/3180/3194`) — outer columns + bottom row unreachable, P-controller winds up and never settles. `MouseCaptureMode=1` (the shipped default, click-to-lock) writes `cursor_h_pos`/`cursor_v_pos` directly from the absolute OS cursor (`input.c:629-657`) and *feels* fine, though it shares the bad `hiH` so the last few px at the right/bottom edge may still be short. **Not a v0.1.0 blocker** — default mode is acceptable, always-grab is opt-in via INI, front-end roughness is already in the release notes. **Fix (port-only, small):** in the `menuMode` branch, when `current_menu` is a front-end menu use the real front-end rect (hard-code `front.c`'s cursor clamp `[20,420]×[20,310]`, or read the front-end viewport global) instead of routing through `g_CurrentPlayer`. Confirm with one `GE_INPUTLOG=1` capture in mission-select — sweep the mouse to each corner, watch where `cursor=(x,y)` plateaus. D165 · D169 · D180 lineage. | OPEN — root-caused, not fixed (M-49) | +| D204 | **PC audio ran ~2 % below real time permanently: GE's AI feedback loop (`src/audi.c:531`) has a ~3 ms setpoint that PC scheduling jitter clears, so the SDL queue starves and the device pads playback with silence (FIXED, measured).** `amMain` wakes at 30 Hz (`sched.c:334` forwards every 2nd retrace to the audio client) and asks for `g_MinFrameSize`=720 samples per block = 21600/s against a 22050 Hz device — structurally 2 % short, relying on 784-sample top-up blocks that `audi.c` only requests once the reported AI length falls under ~69 frames (3 ms). Fine on N64 (double-buffered AI, exact VI interrupt); on PC the queue empties before the loop reacts, and the padded silence is unrecoverable time. **Fix (F5, port-only):** `audioGetAiLengthBytes()` subtracts a 1024-frame (~46 ms) cushion before reporting, so the loop tops up while slack remains; `audi.c`'s control law untouched (rule #2 clean). Measured A/B in one binary via `GE_D204_OLD`: **rt 0.980 → 1.000, q min 0 → 368, drop 0.** Also: F1 correct AI_LEN_REG single-buffer semantics (robustness, measures as a no-op), F3 `Audio.QueueLimit` 8192→2880 + non-silent drops (NB: an existing `ge007.ini` pins it), F4 oversize invariant guard, and `GE_D204=1` cheap audio-health monitor (`rt`/`q`/`drop`/`max`, safe to leave on for a full playtest). **Falsified in-session, do not re-open:** the u32 wrap at `audi.c:531` is real (high-water 2064 frames > the 789 threshold) but harmless — `frameSamples` is `s16` (`audi.c:145`), so it lands negative and audi.c's own lower clamp catches it; max block stays 3136 B vs the 3156 B allocation, no heap overrun. **Corrects two M-63 claims:** `soundIndex=109` is guards' return fire (count was double-logged; the "32.6 ms cadence" is just the 2880-byte block quantum every `dumppos` is rounded to) — closed negative; and M-63's "13 % of real time" was `GE_MIXERTRACE`'s own 25 MB unbuffered log starving the audio thread (same repro without it: 58.0 s / 60 s). Does NOT explain D202. | FIXED (`port/src/audio.c`, `port/src/libultra.c`, `port/include/audio.h`) — measured; a by-ear pass on a real playthrough still owed | +| D203 | **Steam Deck (SteamOS, x86_64 Linux) v0.1.0 release bundle: Facility (`-level_34`) crashes "after loading in as James Bond" (user bug report).** Idle repro attempts on WSL Ubuntu with the actual `dist/goldeneye-pc-port-0.1.0-linux-x86_64.tar.gz` are **negative**: 120 s idle → frame 3300+ clean exit (the intro auto-advances, so "sitting as Bond" is covered), and a ~4-min stick-forward-only run → frame 6900+ also survived. So the trigger needs real gameplay input (fire/action) or is Deck-environment-specific. **Leading suspect: the D191 family** — both known v0.1.0 field crashes are SIGSEGVs in `modelGetNodeRwData` (`model.c:478`) over a truncated/NULL `root->Parent` node link, and both were triggered by *gameplay state* (first guard kill on Bunker ii; post-cutscene on Statue), never by idling; Facility opens into the Ourumov + soldier-squad scripted beat (D193a instance), which fits the trigger profile. Alternatives: Deck GL driver path (RADV/zink vs llvmpipe) or 40/50/60 Hz timing (D193 family). **Next:** (1) WSL run under gdb with a walk+fire `GE_INPUTSCRIPT` — generate the script into a file first (earlier attempts were blocked by a `bash -lc` quoting quirk that emptied the loop var, so no fire buttons ever went out); on SIGSEGV compare the fault PC against `modelGetNodeRwData`; (2) if not reproducible on WSL, get `ge007.crash.log` from the Deck (the Linux build writes it — proven by a stale one in the smoke tree) + ask what Bond was doing at crash time; optionally a gdb run from the SteamOS terminal. | OPEN — negative idle repros only; not yet reproduced with input. | +| D201 | **`src/libultra/audio/bnkf.c` bank/instrument/sound/wavetable relocation offsets were `s32`, truncating the real 64-bit base pointer.** `alSeqFileNew`/`alBnkfNew` compute `offset = (s32) base` from a heap pointer and thread it through `_bnkfPatchBank/Inst/Sound/WaveTable` as `s32 offset, table` parameters, used to rebase every embedded pointer field in a loaded bank/inst/sound/wavetable/book/loop record. On N64 (32-bit pointers) the truncation is a no-op; on PC, sign-extension of the truncated `s32` during the rebase arithmetic reconstructs the wrong 64-bit address whenever the base allocation's low 32 bits have the top bit set (~50% of allocations, allocator-dependent) — corrupting every relocated pointer in the bank file, a plausible contributor to bad model/audio pointers surfacing downstream (D191-family symptoms are a node-tree walk, not this, but the class is the same). **Fix (ABI-layer, no logic change):** widen `offset`/`table`/`woffset` and all four `_bnkfPatch*` parameters from `s32` to `uintptr_t` (``) — already this codebase's idiom for pointer-safe integers in `src/game/*.c`. No behavior change on the 32-bit N64 build. | FIXED (`src/libultra/audio/bnkf.c`) — part of the Phase-3 mixer landing, not yet build/runtime-verified in isolation | +| D202 | **Silenced PPK plays a "slap" instead of a gunshot, PLUS user reports broader real-time audio corruption (M-52 → M-59 → REOPENED M-60 → M-61/M-62 closed the data chain, recommended close → REOPENED M-63 on a fuller symptom report).** M-56–M-62: exhaustively verified the index/bank/pointer/decode/ROM-offset/source-data chain — soundIndex 46 is correctly requested and the data is ground truth (byte-match checksum corroboration, M-62). **M-63: user's actual complaint is broader — wrong sound always plays, correct one SOMETIMES plays too (both heard together), and sounds "pile up and spirally glitch out" over a session until only a stuck looping sound (e.g. Bunker's door) remains.** Got a live build+run repro this session (`-level_09`, 60s, `GE_MIXERTRACE`+`GE_AUDIOTRACE`+`GE_AUDIODUMP`, 58 scripted PPK fires). **Ruled out voice-pool exhaustion/leak** — only 16 distinct voice slots used, properly recycled (`sndDeactivate` count tracks `sndPlaySfx` count 1:1). **Found an unexplained anomaly**: `soundIndex=109` (AK47 bolt-action SFX) fires 94 times in 60s despite no AK47 in the repro, some retriggering the same voice slot at a suspiciously uniform ~32.6ms cadence (4-in-a-row) — far faster than plausible weapon fire; looks like something re-issuing `sndPlaySfx` every audio tick instead of once per game event. Not yet confirmed as the cause of the user's symptom, but the top concrete lead. Traces preserved: `scratchpad/d202-m63/`. (Side note, not a real bug: chased a `load_bg_file` crash this session that turned out to be an incomplete test-data setup on the investigator's part — see M-63 write-up.) **M-65 PARTIALLY resolved:** the "eventual silence" cascade is root-caused and mitigated (ownerless `SOUND_FLAG_LOOPED` SFX — sound 203, `METAL_SLIDE_CLOSE_SFX`, `decayTime=-1`, played by `doorPlayCloseSound0/1` with `NULL` owner — leaks 7 of 8 voices; a 4-line guard in the preemption scan reclaims only provably-ownerless loops, measured 171→629 acquisitions), and a second independent cause is fixed (`sndCreatePostEvent` was stubbed out per D138, removing ALL distance attenuation). The audible stuck door loop is still not reproduced — every candidate mechanism ruled out by measurement (see M-65); needs a user capture at the failing door with `GE_AUDIOTRACE=1 GE_AUDIODUMP=1`. | ROOT CAUSE ESTABLISHED (M-66); DISPOSITION C IMPLEMENTED + MEASURED (M-66b) — the audible stuck loop is sound 203 (`METAL_SLIDE_CLOSE_SFX`): ROM infinite ADPCM loop (`count=-1`) + `decayTime=-1`, played fire-and-forget by `doorPlayCloseSound0/1` with NULL owner, never stored in any slot, exempt from preemption (flag 0x12) and from decay-stop scheduling, priority 0x41 makes it unstealable — **faithful N64 behaviour / original quirk, not a port bug**. 327 ms period = -550-cent pitch (keyBase 54 + detune 50 − shift 6000 → ratio 0.7278; 5248 samples @ 22050×0.7278 = 327.007 ms). M-65's guard fires only under pool pressure (twice in the user capture) and cannot silence the idle loop. **Disposition C implemented + measured (M-66b):** PC-only `AL_SNDP_PORT_EXPIRE_EVT` fades provably-ownerless infinite-loop SFX out ~2.5 s after start (2 s delay + 0.5 s fade); per-second spectral check: pre-fix drone flat at max t≈39→361 s, post-fix transient bursts with full silence between; run ends allocated=0/8. **M-67: static analysis exhausted — full-bank data scan clean (all 261), DSP audit complete vs PD, A_LOOP branch dead code in GE, reference clip B00I00S2D.wav REFUTED as an in-game capture (best waveform CC 0.19 ≈ noise; M-61's +0.999 was envelope-only correlation); new `GE_VOICEDUMP` per-voice probe + timestamped WIRE/AUDIOTRACE for the decisive runtime A/B; self-test found the offline resample direction had been inverted all along (y[k]=x[ratio·k]) and that GE "music" is an SFX-sequence system with its own ~8-sample set sharing the 8-voice pool; parked anomaly: sound 232 requested at level start produces no audible audio. **M-68 (user captures ×3): all three complaint classes verified per-voice at runtime — doors CC 0.91–0.99, PPK 46 CC 0.90–0.97, armor 81 full 833 ms vs ROM-predicted 837 ms @ ratio 0.5612; the earlier "instant death" metric was a line-count artifact (µs-timestamped `[EVT]` probe shows every STOP lands on schedule). No port bug: the heard "wrong" sounds are the original design (overlapping SFX, short decays, quick-stop truncation).** **M-69 (full-corpus exact match, 320 scripted-run requests): 286/320 CC ≥ 0.85 — every allocated voice plays the exact ROM sample at the exact ROM pitch; the initial 44 "BAD" were tooling artifacts (voicedump stamps are block-END times; coarse-slide missed sharp onsets; refs not clipped at voice death). Remaining failures = dropped allocations (no `[VOICE+]` — suspected 8-voice pool exhaustion, unverified) + shared-wavetable wire misattribution. No corruption.** **M-70 (decisive clean re-capture, 209 requests, matcher v3): every voiced request plays the exact ROM sample — 200/209 auto CC ≥ 0.85; the 4 MID are idx=109 verified at CC 0.946 by manual ratio sweep (residual = wall-clock-bursty dump stamps + short live windows); the 5 NO-WIRE are 1 matcher window artifact + 4 never-voiced requests, NOT pool exhaustion (alloc ≤ 5/8 at each drop). Zero wrong-sample playback — the D202 wrong-sample/mixer-corruption hypothesis is refuted.** **M-71: user by-ear pass on M-66b = PASS ("the metal door sound does not loop forever now") — Disposition C validated; D202 ready for probe removal + close. The user's remaining audio complaints (slap/glass over gunfire, explosion→scream, armor pickup wrong sample — N64 A/B confirms non-fidelity) are a DIFFERENT bug: PC requests different/extra sound indices than N64; bank converter + playback chain exonerated → split out to D205.** **M-76/M-77c: D202 CLOSED.** The silenced-PPK "slap" was D206 (bank index +1) — `ALInstrumentAlt_s.soundArray` sits at struct offset 12 on N64 but 16 on PC (pointer width), so `sndPlaySfx` resolved `GUN_SILPPK_A`=46 to on-disk slot 46 (`PUNCH1`, a slap) instead of slot 45; M-61's rejected "index 45, +0.999" was right. Fix `snd.c:1001` `#ifdef PORT` `-1`, committed `378386d1`; **user by-ear A/B vs N64 = PASS (M-77c)**. Mixer/pool/decode were correctly exonerated by M-56–M-70. The M-65/M-66b ownerless-infinite-loop mitigations stay (independent, validated M-71). | +| D206 | **PC played every SFX one bank slot too high — a pointer-width layout shift in `struct ALInstrumentAlt_s`, not a game-logic bug (M-76 FIXED).** Root cause of D205 (3) armour + (4) melee→Klobb and of D202's silenced-PPK "slap". `sndPlaySfx` resolves a sound via `soundBank->instArray[0]->soundArray[soundIndex]` (`snd.c:1001`), casting the on-disk standard `ALInstrument` (12-byte u8 header, `s16 bendRange`@12, `s16 soundCount`@14, `ALSound* soundArray[]`@16) through GE's `struct ALInstrumentAlt_s { s32 unk0, unk4, unk8; ALSound *soundArray[1]; }` (`src/snd.h:165`). On N64 (4-byte pointers) that struct's `soundArray` sits at **offset 12** — it deliberately aliases the on-disk `bendRange`/`soundCount` words, so `soundArray[N]` == on-disk table entry **[N-1]**, i.e. GE's `SFX_ID` values are **1-based** into the sound table (`SFX_ID 0 = NOTHING_SFX`, short-circuited at `snd.c:992`, never dereferenced). On PC the 8-byte pointer + 8-byte alignment pushes `soundArray` to **offset 16**, and the converted bank (`port/src/romdata.c` `afFixupInst`, which faithfully reproduces the standard layout) is packed to match — so `soundArray[N]` landed on entry **[N]**, one slot high, on every SFX. Verified by static struct/converter arithmetic **and** at runtime: a BUNKER1 `GE_AUDIOTRACE` capture post-fix shows all ~90 distinct requested indices resolve to `rom_sfx_decode.py` slot **N-1** (0/90 at N). e.g. `ARMOUR_COLLECT_SFX`=81 → slot 80 (= rip `S50`, the user's "correct" armour); `skorpion_stats.Sound`=0x6A=106 → slot 105 (= rip `S69`, "the actual Klobb sound"); `wppksil_stats.Sound`=0x2E=46 → slot 45 (= rip `S2D`, M-61's rejected "+0.999" clip). The earlier "262 enum vs 261 bank ⇒ needs a -1" arithmetic was right about the *symptom*; the *mechanism* is the alias, and `BIG_CLANK_SFX`=261 → slot 260 is in-bounds (never an OOB). Upstream `n64decomp/007` `snd.c` byte-matches with no `-1` and no `NON_MATCHING` guard — correct, because the N64 struct offset supplies the -1. **NOT D205 (2)** explosion→scream (169-183 → slots 168-182, all still explosions) — stays with D205. | **FIXED (M-76, `src/snd.c:1001`, `#ifdef PORT` → `soundArray[soundIndex - 1]`, N64 line verbatim under `#else`).** ABI/layout-only, D3x class (pointer-width struct-layout reconciliation); no game-logic change. Build clean; BUNKER1 60s crash-free; `-level_09` golden framediff 3/3. **User by-ear A/B vs N64 = PASS (M-77c)** — armour / melee / silenced PP7 all correct now → D202 closed, D205 down to symptom (2). Committed `378386d1`. Scratchpad `rom_sfx_decode.py` / `exact_match.py` / `diff_bank.py` still walk slots 0-based — correct for "what is at bank slot i", but a check against a `SFX_ID` must compare to slot `id-1`. | +| D207 | **Alarm klaxon starves combat SFX and (per user) doesn't recover after it stops — surfaced by D206 (M-77, ROOT-CAUSED, fix designed not applied).** Before D206, `ALARM3_SFX` (id 163) resolved to the wrong bank slot 163 — a **one-shot** sample: played once, freed its voice. After D206 it correctly resolves to slot 162 = the **real infinite-loop klaxon** (`decayTime=-1` -> `SOUND_FLAG_LOOPED`; ADPCM `loop.count=-1`). Played by `handle_alarm_gas_timer_calldamage()` (`src/game/propobj.c:14457`) with owner `&ptr_alarm_sfx` while `alarmIsActive()`. It now **permanently holds 1 of only 8 SFX voices** (`MUSIC_SFX_SEQ_MAYBE_MAX_SOUNDS=8`, `src/music.c`), at priority `0x41` (looped sounds get `decayTimeFlag + 0x40`, `sndSetupSound` `snd.c:820`) — and is **exempt from the port's preemption scan** (`snd.c` ~369: `!(unk3e & 0x12)` refuses to steal any LOOPED/RETRIGGER voice; the M-65 carve-out only covers *ownerless* loops, and the alarm is owned). **M-77b:** the voice-starvation hypothesis did NOT hold up — a BUNKER1 `GE_FORCEALARM` + scripted-fire soak showed **0 true drops** (`sndDisposeSound` no-voice sites instrumented), the designed last-resort preemption pass **never fired**, and `g_sndAllocatedVoicesCount` recovered to 0 after every alarm-off (no leak). The earlier "~20% drop" was a log-latency artifact. The alarm klaxon (`ALARM3` slot 162) is a *continuous, gapless* infinite loop — a real GE alarm pulses — so the leading remaining explanation is **perceptual** (the drone masking gunfire, and/or slot-162 mixed too loud vs N64), or genuine 8/8 starvation only on a heavy level not yet tested. Needs a user repro. | MONITORING (M-77c: user reports the alarm is acceptable in play after D206; no fix shipped) — **the voice-starvation hypothesis did NOT hold up (M-77b).** The designed last-resort LOOPED-preemption pass was built, and instrumented drop counters (`[D207-DROP]` at both `sndDisposeSound` no-voice sites) + a `[D207-YIELD]` trace were added. Result on BUNKER1 (`GE_FORCEALARM` + scripted sustained fire, ~85 s): **zero true drops**, the last-resort pass **never fired** (pass-1 always found a non-looped victim), and `g_sndAllocatedVoicesCount` sat at 1 during quiet alarm and recovered to **0** after every alarm-off — no leak, no permanent starvation. The earlier "~20% dropped" figure was a measurement artifact (log-line latency, not real drops; every requested sound eventually got a voice via the `unk38` retries). So the fix was **reverted** (kept: nothing in `snd.c`; D206 `-1` only). BUNKER1 combat is too light to peg the 8-voice pool. **Next session needs a real repro from the user:** which level + weapon, and a `GE_AUDIOTRACE=1` capture at the failing alarm — is it (a) genuine 8/8 starvation on a heavy level (Facility/Silo/Statue firefight), (b) a leak that only triggers on a specific alarm-stop path, or (c) perceptual — the continuous klaxon drone masking gunfire / the alarm mixed too loud (check `ALARM3` slot 162 volume vs N64). Temp `GE_FORCEALARM` probe kept in `src/game/propobj.c` for that work. | +| D205 | **PC plays wrong/extra SFX for gameplay events the user can A/B against N64: general gunfire has a slap/glass layer audible on PC but silent on N64; an explosion plays a soldier-scream sample; armor pickup plays a wrong sample; unarmed melee (slap) plays Klobb's shooting sound idx 106 instead of the slap effect (M-71/M-72, user by-ear + N64 A/B).** **M-74 WITHDRAWS M-73's root cause.** M-73 read the user's combat capture as guards stuck re-triggering fire sound idx 109 ("256x/29.7 s, no bullets") and pinned it on `stanTestLineUnobstructed` LOS over converted collision geometry. Re-counting the same file: idx 109 is requested **128x** not 256x (the double-log artifact D204 already corrected for M-63 on this very index), = 4.31/s across ~11 guards = ~0.4/s each, **~6x UNDER** the AK47's own `SoundTriggerRate` (`RATE_AK47`=4 ticks => 15/s ceiling) — so the `field_178` gate never binds; and impacts are plentiful in the same window (~30 ricochet/wall-hit 19-41, 69 x2 flesh, 12 body-falls 123-132, 14 yelps 134-147). The eye-catching monotone sweeps 134->147 / 123->132 are the ground-truth round-robin `male_guard_yelp_counter` (`chraction.c:2454-2470`), faithful, not a broken selector. **The 109 traffic is normal in-spec guard combat**; do NOT open the pccg-stan/LOS geometry investigation on this evidence. Everything upstream of presentation is proven: bank 261/261 identical (M-71), sample+pitch exact (M-68/M-70), and every index in the capture correct (182 explosion x1, 81 armor x1, 46/47/48/49/105). **Remaining untested layer = spatial presentation (per-voice volume, pan, concurrency)** — it explains all four complaints at once (a wrongly-placed sound is by ear a sound at your own position) and is the youngest code in the stack (`sndCreatePostEvent` was fully stubbed by D138 until M-65). | OPEN — bank + playback + requested indices all exonerated; M-73 LOS/geometry root cause WITHDRAWN (M-74). Next: static audit of the pan/volume path (`src/snd.c` :460/:527 -> `alSynSetPan` -> `port/src/mixer.c` ENVMIXER -> SDL stereo), then a `[DISTVOL]`+pan probe run; user N64 A/B + ROM 106-vs-109 WAVs held until that reports | | D152+ | D152 addendum (M-31): static audit of every compiled-audio `osSetIntMask(OS_IM_NONE)` — **all balanced**, no unbalanced early-return. Real fixes: `sndSetSfxSlotVolume` now holds the mask across its list walk (matches its twin `sndDeactivateAllSfxByFlag`) + `sndApplyVolumeAllSfxSlot` batches the slot loop under one recursive hold (kills the fade-out lock-acquire storm); `portThreadWrapper` calls `imThreadExitRelease()` on thread exit (kills the "transient thread acquired `OS_IM_NONE` and died" leak + the pthread-id-reuse re-wedge). Steal-lock kept as backstop. | FIXED (`src/snd.c`, `port/src/libultra.c`, `#ifdef PORT`) — fade-out repro playtest-gated, not headless-verified | | RC3 · D167 | **Non-power-of-two texture wrap period (`docs/dev/TEXTURE-GLITCH-ANALYSIS.md` §6 RC3 — "textures repeat oddly", residual Depot-ceiling noise after D161).** The N64 RDP masks the texel coordinate of a wrapping render tile at `1<lp` (a pointer into the AFX +delay array) resolved to garbage like `0x664d59be`. + +**Root cause.** `ALDelay.input` / `ALDelay.output` are `u32`, and reverb.c computes +back-references into the delay ring as `&r->input[-d->output]` (and variants). On +N64, `ptrdiff_t` is s32, so `-160` (the u32 constant `0xFFFFFF60`) wraps to −160 and +the pointer arithmetic is correct. On PC x86-64, `ptrdiff_t` is s64: the u32 negation +**zero-extends** to +4294967136 samples, so `out_ptr` flies forward ~8GB — verified +exactly: `0x706d30a0 (r->input) + 0xFFFFFF60·2 = 0x2_706D2F60`, the observed wild +address. The subsequent `aSaveBuffer`/`aLoadBuffer` then write 13–52KB of wild data, +corrupting the delay array, `r->base`, and adjacent heap blocks — a self-propagating +loop that grows each frame until it hits unmapped memory. + +**Fix (ABI/layout only, D3x class).** Five sites in `src/libultrare/audio/reverb.c` +cast to `(s32)` before negation so the offset sign-extends: `alFxPull` (`in_ptr`, +`out_ptr`), `_loadOutputBuffer` ×2 (`out_ptr`), plus the ramalign line, which uses +`(s64)out_ptr & 0x7`. Perfect Dark ground truth (`src/lib/naudio/n_reverb.c`) does +exactly this: `(s32)-d->input`, `(s32)-d->output`, `(s32)-(d->output - d->rsdelta)`, +`(intptr_t)out_ptr & 0x7`. No game logic changed; behaviour on N64 is identical. + +**Verification.** `-level_09` now runs 120 s+ crash-free (previously segfaulted in +seconds); the delay pointers advance correctly (`out_ptr = input − 320B`, +0x140/frame). +A temporary DRAM guard window over the FX region, registered with the software mixer +(`port/src/mixer.c`), confirmed zero out-of-bounds writes after the fix. All +instrumentation removed; only the five casts remain in the diff. + +**Sibling-risk audit.** Grep of `src/libultra/audio/*.c` + `src/libultrare/audio/*.c` +for negative pointer indexing: reverb.c is the only file with the pattern (its one +positive index, `&r->input[d->output]`, is safe — positive u32 zero-extension is +correct). + +**Lineage.** D3x pointer-width reconciliation; same family as D177 (widened-pointer +high half) and D191 (32-bit field read as 64-bit). + +## D202 — Phase-3 audio: silenced PPK plays "slap" not gunshot, PLUS runtime mixer corruption (piling up / glitching / eventual silence) (M-52; reclassified M-59; REOPENED M-60 on reference clip; M-61/M-62 closed the data/index/decode chain and recommended close; M-63 REOPENED again; M-65 PARTIALLY resolved — voice-leak cascade root-caused + guarded, `sndCreatePostEvent` un-stubbed; **M-66 ROOT CAUSE ESTABLISHED for the audible stuck door loop — sound 203 behaving exactly as ROM + ground-truth code specify: faithful N64 behaviour / original quirk; M-66b DISPOSITION C IMPLEMENTED + MEASURED — port-side expiration fades ownerless infinite loops out ~2.5 s after start, awaiting user by-ear pass**) + +**Symptom (M-52 playtest, first listening pass on the Phase-3 software mixer, +post-D200).** In-level (Bunker1, Dam — not menu/intro, which is music-only and +correct): (a) weapon fire consistently plays the WRONG but always-the-same +clip per weapon (e.g. Bond's silenced PPK plays what sounds like a melee/punch +hit, every single shot — fully deterministic, not intermittent garble); (b) +level music never plays — a looping SFX-like sound plays in its place from +level start. + +**Ruled out, with hard evidence (do not re-investigate these):** +- `soundIndex -> ALSound*` resolution (`snd.c sndPlaySfx`, `soundBank->instArray[0]->soundArray[soundIndex]`): + traced live with a `GE_AUDIOTRACE` probe against real gameplay (`-level_09`, + repeated PPK fire). `soundIndex=46` (confirmed via `bondconstants.h`'s SFX + name table = `46_GUN_SILPPK_A_SFX`) resolves to the same `ALSound*`/`ALWaveTable*` + every time; 13+ distinct indices captured across one session, all distinct, + all valid. +- D37's bank re-layout (`port/src/romdata.c romdataFixupAudioBank`): preserves + `soundArray`/`instArray` order; not a re-ordering bug. +- The `ALWaveTable.base`/`.book` pointer chain: traced all the way to + `alLoadParam(AL_FILTER_SET_WAVETABLE, ...)` (`src/libultra/audio/load.c`) — + the exact same `table`/`base`/`book` addresses `sndPlaySfx` resolved are the + ones wired into the physical decode filter. No swap between resolution and + voice setup. +- `ALStartParamAlt` (40B on x86-64) vs the generic `ALParam` pool slot (32B): + already fixed pre-M-52 (D54, `synthesizer.c:127-140`, `#ifdef PORT` sizes + each slot for the largest variant). Re-confirmed still in place, not a + regression. +- Per-voice synth processing (`synthesizer.c`'s client-list handler loop) is + strictly sequential — one client's full setup+decode completes before the + next starts. Ruled out global-mixer-scratch (`sCtx`/`sAdpcmTable`/`sVol` in + `port/src/mixer.c`) cross-talk between concurrently active voices. +- `ALSndpEvent` (snd.c-local union, force-cast to `ALEvent*` through + `alEvtqPostEvent`/`alEvtqNextEvent`, which copy `sizeof(ALEvent)` bytes) + vs `ALEvent` (libaudio.h): measured directly with a throwaway compile — + both are exactly 32 bytes on x86-64. Not a truncating-copy bug. +- `sndCountAllocList`'s `(ALEventQueue *)&D_800243E4` cast (a real, compiler-flagged + `-Warray-bounds` OOB read — `D_800243E4_s` is 24B, aliased as 40B `ALEventQueue`): + confirmed genuine but functionally inert — its output (`numFree`/`numAlloc`) + is computed and immediately discarded in `sndHandleEvent`, never used. + +**Not yet ruled out — pick up here:** +1. **`aADPCMdecImpl`'s decode math** (`port/src/mixer.c`, this session's new + Phase-3 code, never verified against real N64 audio output). Structurally + matches the standard VADPCM order-2 predictor algorithm on inspection + (9 input bytes -> 16 output samples, intra-frame reverb-of-already-decoded- + samples loop) but no reference trace/audio diff exists yet. +2. **The raw PCM bytes embedded in the exe at the resolved ROM offset being + wrong at the source** (asset-embedding/segment-placement issue, unrelated + to anything traced above) — would need a byte-level compare of the + PC-embedded `_sfxtblSegmentRomStart` segment against the source `.z64`. + +**Key diagnostic fact:** the wrong sample is 100% deterministic per weapon +(same wrong clip every shot, confirmed by user) — this argues against a +stateful/interleaving bug (already the least-likely explanation given the +sequential-processing proof above) and toward either (1) or (2) above, both +of which would consistently mis-decode/mis-fetch the same bytes every time +for the same `ALWaveTable`. + +**Diagnostic tooling added this session (still in the tree, `#ifdef`/env-gated, +harmless if left in, should be stripped once root-caused):** +- `src/snd.c` `sndPlaySfx`: `GE_AUDIOTRACE=1` env var → appends resolved + `soundIndex`/`sound`/`wavetable`/`base`/`len`/`type`/`flags`/`book` to + `audiotrace.log` (cwd-relative, unbuffered). +- `src/libultra/audio/load.c` `alLoadParam` `AL_FILTER_SET_WAVETABLE`: + same env var → appends the filter/table/base/len/book actually wired into + a voice to `audiotrace_wire.log`. + +**Also noticed, not yet a confirmed bug:** `alLoadParam`'s +`a->memin = (s32) a->table->base;` (`load.c:382,438`) truncates a real 64-bit +`u8 *` to `s32` — currently harmless only because the ROM is deliberately +mapped at a low, sub-2GB virtual address (`0x10000000`, 12MB window per the +boot log), so no real address in range overflows 32 bits. Fragile; worth a +`(s64)`/`uintptr_t` widening pass regardless of whether it's D202's cause. + +**M-53 update — both of the two remaining suspects now independently checked, +both look clean (static analysis only, no listening pass possible this +session):** + +1. **`aADPCMdecImpl` decode math (`port/src/mixer.c:148-192`) — compared + line-by-line against the local Perfect Dark PC port's scalar reference + (`pd_port/port/src/mixer.c:333-352`, the `#else` non-SIMD path).** + Identical: same nibble unpack (`(((*in >> 4) << 28) >> 28) << shift` / + same for the low nibble), same `prev1`/`prev2` history read from `out[-1]`/ + `out[-2]`, same accumulator (`tbl[0][j]*prev2 + tbl[1][j]*prev1 + + (ins[j]<<11)` plus the `k>11` + scale-down + clamp, same 16-sample/32-byte state carry via + `memcpy(state, out-16, ...)`. No discrepancy found. This was PD's + ground-truth non-vectorized fallback, i.e. the reference the SIMD paths + are themselves checked against — a strong match. +2. **`_sfxtblSegmentRomStart`'s ROM offset (`0x102F19A0`, + `port/src/romassets_u.s:2948`) — cross-validated three independent ways, + all agree:** + - `scripts/filelist.u.csv` row 28: `3086752,797360,assets/music/sfx.tbl` → + `0x10000000 + 3086752 = 0x102F19A0` exactly. + - Row 27 (`sfx.ctl`, `3063264,23488`) is contiguous with row 28: + `3063264 + 23488 = 3086752` — matches `music.c:686`'s assumption + (`size = &_sfxtblSegmentRomStart - &_sfxctlSegmentRomStart`, i.e. ctl + immediately precedes tbl in ROM with no gap). + - `ge007.ld:164-165` (N64 ground truth, untouched) places + `sfx.ctl.o (.data)` immediately followed by `sfx.tbl.o (.data)` in the + `musicfiles` segment — same adjacency, independently, from the linker + script rather than the CSV scanner. + - Read the real bytes from `data/ge007.ntsc-final.z64` at both offsets + (this session, ad hoc Python): `sfx.ctl` decodes as a plausible + `ALBankFile` (`bankCount=1`, one bank offset `0x5ba0` inside the 23488B + ctl blob); no structural red flag. Byte-level content correctness + beyond that needs a decode+listen pass, not available in this session. + + Net: the segment-offset math is corroborated by 3 independent sources + (scanner CSV, N64 linker script, raw ROM read) and is very unlikely to be + a simple wrong-base bug. Downgrading this suspect's priority. + +**Also re-confirmed clean, not previously called out explicitly:** the ADPCM +book coefficients (`ALADPCMBook`, referenced off `ALWaveTable.waveInfo. +adpcmWave.book`) go through `afFixupBook()` (`port/src/romdata.c:704-735`), +which correctly `afRd16`/`afWr16` byte-swaps every predictor coefficient +during the D37 bank re-layout — not just the offset/pointer fields. And +`_bnkfPatchWaveTable()`'s `w->base += table;` (`bnkf.c:140`) is `uintptr_t` +end-to-end post-D201, so no truncation there either. + +**Net effect: the M-52 write-up's two recommended suspects are now the +LEAST likely explanations, not the most likely.** The bug is probably +further downstream, in a stage neither this nor the M-52 session traced: +**the resample/pitch pipeline** (`aResampleImpl` / whatever computes a +voice's playback rate from `ALKeyMap`/`unityPitch` — `synallocvoice.c`, +`seqplayer.c`, `synport.c`/equivalent — not yet audited this session). A +wrong pitch/rate calculation would (a) make a correctly-decoded SFX sample +sound like a completely different sound if pitch-shifted far enough to be +mistaken for one, matching "PPK sounds like a melee hit" better than a +literal wrong-sample-content theory would, since sample *content* tracing +(D202 original session) already came back clean; and (b) explain "music +never plays, an SFX-like loop plays instead" if the same mis-pitched/ +mis-selected-voice bug applies to sequence-player note events too, not just +raw `sndPlaySfx()` calls. **Recommended next step:** trace one PPK-fire note +event's computed pitch/rate value (another `GE_AUDIOTRACE`-style probe, this +time in the resample-rate / `ALKeyMap` lookup path) against what the N64 +build would compute for the same key/velocity, rather than re-auditing the +decode or offset math further. Not yet attempted — no build/run done this +session (static-analysis-only pass); still needs the real listening-pass +sign-off per the standing M-52 note. + +**M-53 cont. (build+run, same session) — user rebuilt off the M-53 doc-only +commit and confirmed no behavior change (expected — no code changed yet); +then supplied two new concrete data points that reframe symptom (b) and +partially (a):** + +1. **"the looping sound [that plays instead of music] is actually a door + sound in the attract screen getting looped and continuing to play."** + This is not a wrong-sample or wrong-pitch bug — it's confirmed to + literally be a real, correctly-identified SFX (a door slide-loop) that + fails to stop. Traced the mechanism live with `GE_AUDIOTRACE` + (`-level_09`, scripted door-interact input): a single player action + fires a **chained** `sndPlaySfx` sequence — soundIndex 202 + (`METAL_SLIDE_OPEN_SFX`) → 204 (`METAL_SLIDE_LOOP_SFX`) → 203 + (`METAL_SLIDE_CLOSE_SFX`) — via the `do…while` chain in `sndPlaySfx` + (`snd.c:927-928`, each `ALSound`'s `keyMap->velocityMin`/`keyMin` fields + double as a "next soundIndex" link; this chain mechanic is stock N64 + design, not a port bug). + - The game-code side that's supposed to **stop** a stuck door loop — + `door7F053B10()` (`propobj.c:12835`, marked `//#MATCH`, i.e. decompiled + byte-exact to the N64 binary, so its logic is ground truth and NOT a + candidate for the bug itself) — calls `sndDeactivate(door-> + openSoundState)` whenever the door's *open* or *close* sound is still + playing. `door->openSoundState` is already a real `ALSoundState *` + in `bondtypes.h:3042` (not a narrowed/truncated field — checked, this + is NOT another D3x pointer-width bug). + - So the bug is downstream of this game-logic call: either (a) + `sndDeactivate`'s posted `AL_SNDP_DEACTIVATE_EVT` (delta 0) isn't + being serviced/dequeued reliably by the PC event-queue/mixer tick, or + (b) (more likely given the user specifically saw this originate **in + the attract screen** and persist **past it, into gameplay**) whatever + N64 does to reset/clear all playing SFX at an attract-demo → real-game + mode transition isn't happening equivalently on PC, orphaning the + loop's voice with no `DoorRecord` left alive to ever call + `sndDeactivate` on it again. **Not yet located** — haven't found the + mode-transition audio-reset call (candidate: `sndDeactivateAllSfxByFlag` + family, `snd.c:996`, or an equivalent full-stop at level-load). +2. **"ammo pick up is one of the knife sounds."** Checked against + `bondconstants.h`: `PICKUP_AMMO_SFX`=234, `PICKUP_KNIFE_SFX`=233 — adjacent + indices. Combined with the M-53 PPK report (`GUN_SILPPK_A_SFX`=46 sounding + like `PUNCH1_SFX`=47, also adjacent but in the OPPOSITE direction), this + does **not** fit a simple constant index-shift theory (would need to be + the same sign both times) — leaves either (a) two unrelated single-entry + bugs, or (b) the "wrong sound" being a badly-mispitched *correct* sample + that a listener reasonably mistakes for a neighboring, timbrally-similar + entry (a hard door clank at the wrong pitch could pass for a knife sound; + a gunshot pitched down for a punch). Not resolved this session — would + need an actual decoded-audio comparison (dump the decoded PCM for a + known-bad play and listen/compare band energy against the two + candidates), which the environment can produce (`GE_AUDIOTRACE` gives the + exact `base`/`len`/`book` addresses) but wasn't attempted here. + +**Still OPEN. Build/run environment confirmed available this session** +(`./build-pc.sh ntsc-final` after `export PATH=/c/msys64/mingw64/bin:$PATH`, +`GE_INPUTSCRIPT` with `"A"`=`GE_CONT_A`, `"Z"`=`GE_CONT_G` per +`port/src/input.c:311` — note **"Z" triggers the door/action interact in +this control scheme in `-level_09`, not weapon fire**; hadn't yet found the +right button/level combo to headless-repro an actual gunshot before running +out of session time). **Next step:** locate and instrument the attract→game +(or menu→level) audio-reset call to confirm/deny the orphaned-voice theory +for symptom (b); separately, dump+listen-compare decoded PCM for one +mis-sounding SFX to settle symptom (a)/(2)'s index-vs-pitch question. + +**Follow-up (same session) — checked the level-transition "deactivate all" +sweep, found it's flag-filtered, which narrows the orphaned-voice theory +to a specific, already-catalogued bug family.** `lv.c:1443/1473` calls +`sndDeactivateAllSfxByFlag_1()` at level transitions → `flag=1` +(`SOUND_FLAG_FINAL_IN_SEQUENCE` only) → `sndDeactivateAllSfxByFlag` +(`snd.c:996`) only touches sounds whose `unk3e` has **every** bit in `flag` +set (`(item->unk3e & flag) == flag`). A door's loop-tail sound is very +unlikely to carry `FINAL_IN_SEQUENCE` (it's a middle link in the +open→loop→close chain, not the end), so this sweep would **not** catch an +orphaned door loop even on real N64 — meaning N64 must instead rely on the +attract-mode demo's scripted input naturally executing its "close door" +press before the demo ends, letting `door7F053B10()` clean up the loop +normally. **This reframes the theory:** if the attract-mode demo's +canned input is driven by a fixed *frame count* but the PC build's +sim-time-per-frame differs from N64's (the same `g_GlobalTimerDelta`/ +`g_ClockTimer` wall-clock-vs-sim-time family already root-cause-pending +for **D193**, the AI-locomotion-too-slow bug), the demo could get cut off +before its scripted door-close input fires on PC even though it always +does on N64 — orphaning the loop's voice with no code path left to stop +it. **If true, D202(b) (the stuck-loop symptom) may not be a distinct +audio bug at all — it may be a second visible symptom of D193's root +cause once that's found**, not something to fix in the audio code. Worth +confirming/ruling out before spending more time in `snd.c`/`propobj.c`. + +**M-54 update — checked the D193-linkage theory's timing mechanics +directly; it's weaker than M-53 framed it, and got two direct +experimental data points instead.** + +1. Read `frametiming.c`/`libultra.c`'s timing chain end to end. + **The ramrom-demo path (`iterate_ramrom_entries_handle_camera_out`, + `ramromreplay.c:355`) does NOT go through `waitForNextFrame()`** — it + calls `updateFrameCounters(ramrom_blkbuf_2->speedframes)` directly + (`ramromreplay.c:400`), feeding the **recorded** N64 speedframes value, + not a value derived from PC wall-clock elapsed time. The D155 + catch-up clamp (`FRAMETIMING_PORT_MAX_CATCHUP`) lives inside + `waitForNextFrame()` and is irrelevant here. The call site + (`boss.c:502-533`) gates *when* this fires on real elapsed ticks + (`mainTickElapsed >= MAIN_LOOP_TICK_INTERVAL`) but that gate + self-throttles correctly even if `gfxFrameMsgQ`'s retrace messages + back up during a stall (checked: `osCreateMesgQueue(&gfxFrameMsgQ, + ..., 32)`, `init.c:236` — 32-deep, so a stall *can* queue up dozens + of retrace messages) — the very first drained message after a stall + recomputes a big `mainTickElapsed` and fires exactly once, which + immediately refreshes `copy_of_osgetcount_value_1`, so every + subsequent backlogged message in the same burst sees a near-zero + elapsed and does nothing. **No runaway/duplicate advance mechanism + found.** This weakens (doesn't fully rule out) the D193-linkage + theory: the demo's sim-time progression looks insulated from PC + real-time hiccups by design, not just by luck. The D193 tie-in is + still plausible in principle (if the *count* of real retrace ticks + over the whole demo differs from N64's, e.g. via `osGetCount()`'s + scaling formula, D117/D134) but the specific "backlog causes runaway + catch-up" mechanism I'd hoped to point to isn't there. Downgrading + this from "leading theory" to "one of several still-open". +2. Extended the existing `GE_AUDIOTRACE` probes: `sndPlaySfx` now also + logs the `newState` pointer per `soundIndex` link (not just the + `ALSound*`), and `sndDeactivate` now logs every call with its `state` + arg (`snd.c`, both `#ifdef PORT`/`getenv("GE_AUDIOTRACE")`-gated, + still uncommitted). Ran two headless captures + (`GE_AUDIOTRACE=1 GE_D87=1 ./build-pc/ge007.x86_64.exe`, no level arg, + no input, 100s then 240s, idling at the front end so the >=30s idle + timer — `front.c:2493`, `MENU_TIMER >= 1801` at NTSC 60fps — kicks + attract mode on). Findings: + - **`sndDeactivate` DOES fire and DOES work** when called: trace + shows `sndPlaySfx: soundIndex=260 -> newState=...706f4e18` followed + later by `sndDeactivate: state=...706f4e18 (non-null)`, and that + same `ALSoundState*` slot gets legitimately reused for a new + `soundIndex` right after. This rules out "the event-queue doesn't + service `AL_SNDP_DEACTIVATE_EVT` reliably" (branch (a) from the + M-53 writeup) as a *general* mechanism — deactivation is not + structurally broken. + - Both captures only ever logged front-end/menu-click `soundIndex` + values (258, 260, 111, 232, 109) — despite `GE_D87` confirming + `iterate_ramrom_entries_handle_camera_out`/`ramrom_replay_handler` + WERE firing repeatedly (159 D87 log lines in the 240s run, i.e. the + attract-mode camera flythrough demo was genuinely playing), **no + door-chain soundIndex (202/203/204) appeared in either capture.** + Root cause of the miss, found by reading `ramromreplay.c:631-634`: + which of the 14 `ramrom_table[]` demo files plays is + `randomGetNext() % i` — **randomly selected per attract cycle**, not + a fixed sequence. A short capture has no guarantee of landing on a + demo that triggers a door open/loop at all (most of the 14 clips + — Dam/Facility/Runway/Silo/Frigate/Train camera paths — may not + pass a metal sliding door in-frame). Neither capture is long enough + to be conclusive either way for symptom (b); this needs either (a) + a much longer unattended capture (several attract cycles, each + clip's `totaltime_ms` likely 30-90s, so plausibly 10+ min for good + odds of hitting a door-bearing clip), or (b) a temporary + `PORT`-gated env-var override pinning `ramrom_table` selection to a + specific known door-bearing index for fast, deterministic repro + (not yet added). + - Separately confirmed the `romdataFixupMusicSeqTable: seqCount 63 + exceeds blob capacity 1` `[ERROR]` log line seen at boot is a + **known, benign, single-fire false alarm** — it's the deliberate + D35 header-only peek (`music.c:731-736`, buffer intentionally sized + 16 bytes to decode just the `seqCount` field before the real, + correctly-sized allocation+decode at `music.c:738-742`) — confirmed + it fires exactly once per run and the real table load that follows + doesn't. Not a new lead; noting it here only so a future session + doesn't re-flag it as one. + +**M-54 cont. — got the live repro, and it's NOT a door.** Re-ran headless +with a `GE_INPUTSCRIPT` pressing A every second for the first ~20s (to +clear the Rare/Nintendo/legal/cast-intro screens fast) then idling, 280s +total. Landed on the `ramrom_Train` attract demo (confirmed: `GE_D87` +shows `iterate_ramrom_entries_handle_camera_out`/`ramrom_replay_handler` +actively firing — real attract-mode canned-input playback, not the boot +cinematic, which is a separate unrelated state machine in `front.c`). +`audiotrace.log` (full, unedited): + +``` +soundIndex=64 -> newState=706f4db0 flags=2 (TRAIN_GO_SFX, first voice) +soundIndex=64 -> newState=706f4f50 flags=2 (TRAIN_GO_SFX, second voice) +... +sndDeactivate: state=706f4e80 (non-null) +sndDeactivate: state=706f4ee8 (non-null) +``` + +`soundIndex=64` = `TRAIN_GO_SFX` (`bondconstants.h`), `flags=2` = +`SOUND_FLAG_LOOPED` — the **only** looped sound requested in the entire +run. **Neither `706f4db0` nor `706f4f50` ever appears in a +`sndDeactivate` line, anywhere in the log** — confirmed by grepping the +full file for both pointers. Meanwhile two *other*, non-looped states +(`706f4e80`, `706f4ee8`) DO get swept and deactivated, at exactly the +point the demo cycle ends and the sequence rolls back to the boot logos +(`RARELOGO_SFX`/`RARELOGO_FAINT_SFX`, indices 258/260, reappear +immediately after). **This is a direct trace-level confirmation of the +M-53 "Follow-up" theory**: whatever cleanup sweep runs at the +demo-cycle/mode-transition boundary deactivates some voices but +specifically skips the looped one(s) — consistent with +`sndDeactivateAllSfxByFlag`'s `FINAL_IN_SEQUENCE`-only flag filter +(`snd.c:996`, see M-53 above) never matching a genuinely-looped ambience +voice. Reframes the bug from "door-specific" to **"any `SOUND_FLAG_ +LOOPED` voice started during a `ramrom` attract demo has no code path +that ever stops it once the demo cycle moves on"** — the door report and +this train report are the same bug, different SFX. + +**New candidate mechanism for why N64 doesn't audibly show this**: +`src/libultra/audio/synallocvoice.c`'s `_allocatePVoice`/`alSynAllocVoice` +implement real N64 hardware **voice-stealing** — a small fixed pool of +physical `PVoice`s (`ALSynConfig.maxPVoices`, set from +`MUSIC_SYN_CONFIG_MAX_P_VOICES` in `music.c:772`, unchanged decompiled +value) where a new voice request silently steals and ramps out the +lowest-priority already-playing physical voice once the pool is full +(lines 100-131). On real hardware this would eventually silence an +orphaned looped voice anyway, once enough *other* sounds compete for +the same small voice pool — even though its `ALSoundState` bookkeeping +never gets told to stop. **Not yet checked**: whether the PC port's +mixer (`port/src/mixer.c`) and the synth's free/alloc/lame-list +machinery actually enforce the same `maxPVoices` cap end-to-end, or +whether something in the Phase-3 PC mixer path effectively gives every +voice a free physical channel (never triggers stealing), which would +make an orphaned loop audible forever on PC even in scenes where N64 +would eventually have silently stolen it away. This is the most +promising next-step lead — cheaper to check (audit `mixer.c`'s voice +dispatch / count) than adding more ramrom-timing probes. + +**Still OPEN.** Next session, in priority order: (1) audit whether +`port/src/mixer.c` honors `maxPVoices`/voice-stealing identically to +`synallocvoice.c`'s original algorithm — if it doesn't, that's the fix, +and it would explain both the door and train loop reports without +touching `snd.c`/`propobj.c`/`front.c` game logic at all; (2) if voice +stealing checks out fine, look at whether N64 has some OTHER explicit +stop call for these ambience loops that isn't in the `Dxx`-traced code +paths yet (e.g. a `lvlManageMpGame`-adjacent full-audio-reset at +attract-cycle boundaries not yet located). Symptom (a)/(2) +(wrong-sample-per-weapon) still untouched — still needs the +decoded-PCM listen-compare. The `GE_AUDIOTRACE` trace additions to +`snd.c` (now covering `sndPlaySfx`'s `newState=`/`flags=` and every +`sndDeactivate` call) are left in place, uncommitted, for reuse. + +**M-55 — priority-(1) voice-stealing audit done; result is negative, and +narrows the bug further.** Static/read-only session (local Qwen `triage` +dispatch for the grep/citation legwork, spot-checked line-for-line +against the real files before trusting it). + +- **`port/src/mixer.c` doesn't need to honor `maxPVoices` — it never + participates in voice allocation at all.** Grepped it for + `maxPVoices`/`pAllocList`/`pFreeList`/`pLameList`/`numPVoices`: zero + hits. `mixer.c` (~436 lines) only implements the low-level `aXxx` + RSP-acmd DSP commands (envmixer/resample/etc `Impl` functions) that + `synallocvoice.c`'s already-allocated `PVoice`s get pointed at — the + lame-list/free-list/steal-lowest-priority bookkeeping (D202 M-54's + suspect) lives entirely in **unmodified ground-truth** + `src/libultra/audio/synallocvoice.c` (`alSynAllocVoice`/ + `_allocatePVoice`, lines 27-131, byte-identical to decomp) plus + `src/libultra/audio/synthesizer.c`'s `alSynNew` (`numPVoices = + c->maxPVoices`, `music.c:772`'s `MUSIC_SYN_CONFIG_MAX_P_VOICES`, + unchanged). **The M-54 "maybe the PC mixer gives every voice a free + channel" theory is ruled out — there's no PC-specific voice-pool code + to have that bug in.** Since this pool logic is unmodified decomp + code, its steal behaviour on PC is provably identical to N64's. +- **Priority (2) has an answer too, and it's negative in a useful way.** + Traced whether the `ALSynth` driver (or anything else audio-wide) gets + reset/reinitialized at the attract-demo → boot-logo transition: + `alInit` (`src/libultra/audio/sl.c:28-33`) is a **hard singleton** + (`if (!alGlobals) { alSynNew(...); }`) called exactly once, transitively + from `bossEntry()` → `musicSeqPlayerInit()` (`src/boss.c:312`, + `src/music.c:648`) → `amCreateAudioManager()` (`src/music.c:781`) → + `alInit()` (`src/audi.c:374`/`378`) → `alSynNew()`. The only teardown, + `alClose()` (`src/audi.c:481`), fires solely on audio-thread + `MAIN_QUIT_MESSAGE` (process exit), not at a demo boundary. The actual + attract-demo stop path, `stop_demo_playback()` + (`src/game/ramromreplay.c:604-616`), touches only joystick-playback + bookkeeping (`joySetPlaybackFunc`/`joySetContDataIndex`/ + `ramrom_demo_related_3`/`is_ramrom_flag`) — **no audio call of any + kind.** All of `sl.c`, `audi.c`, `boss.c` here are unmodified + ground-truth, so **N64 has exactly the same absence of a + demo-boundary audio reset** — confirming there is no missing-on-PC + system-reset bug to find in this direction either. +- **Net effect: both of M-54's priority leads are now closed, and + neither explains the bug.** This pushes the M-53 "Follow-up" theory + back to the front — a genuinely `SOUND_FLAG_LOOPED` voice has *no* + code path that stops it at a `ramrom` attract-cycle boundary on + **either** platform; whatever the real fix is, it is a **pre-existing + N64 behavior being newly exposed**, not a PC regression, and per rule + #2 (game logic is ground truth) is very unlikely to be something we + should "fix" by adding a stop call the original game never had. The + live-repro data (M-54 cont.) already showed this exact voice audibly + looping forever in the PC build; whether real N64 hardware's `PVoice` + stealing (now confirmed byte-identical logic) actually does silence it + in practice depends on whether some *other* sound eventually claims + that priority slot during/after the demo cycle — untested acoustically + either way. + +**Still OPEN. Next session:** stop auditing the voice-pool/reset code — +both candidate mechanisms are now cross-validated clean/absent on both +platforms. The two live options are (a) accept this as a real, harmless- +on-N64-in-practice edge case (the orphaned voice gets stolen once enough +other SFX compete for the small `PVoice` pool during normal gameplay, +just not during an idle unattended attract-mode capture) and downgrade +D202(b) priority accordingly, or (b) do the acoustic check directly — +run a longer capture (10+ min, several attract cycles) or a temporary +`PORT`-gated `ramrom_table` index override to force a door/train demo +reliably, then listen past the demo boundary to hear whether the loop +in fact does eventually cut out once other SFX start competing for +voices (would confirm (a) and close D202(b) as WONTFIX/expected). +Symptom (a)/(2) (wrong-sample-per-weapon, PPK→PUNCH1) is still fully +untouched and is now the higher-value remaining D202 thread — needs the +decoded-PCM listen-compare, not more voice-pool tracing. + +**M-56 — D202(a) got the decoded-PCM listen-compare, live user confirmation, +and the index/pointer/ROM-data chain is now independently proven byte-exact +against the real ROM (not just "ruled out" by code audit). Root cause is +narrowed to the actual decode/mix runtime.** + +- Added a temporary `GE_AUDIODUMP=1` probe (`port/src/audio.c`, + `audioSetNextBuffer`) that raw-dumps every mixed s16 stereo buffer + reaching the SDL device to `audiodump.raw` — the true final output, same + path as what plays in-game. Ran a headless repro (`-level_33` Dam, + `GE_INPUTSCRIPT` holding aim+fire for 8 trigger-pulls starting ~10s in), + converted the dump to WAV, located the transients via a 50ms-window RMS + scan, and sent the clip to the user. **Exact repro command** (run from + repo root, `export PATH=/c/msys64/mingw64/bin:$PATH` first): + ``` + rm -f audiotrace.log audiodump.raw + GE_AUDIODUMP=1 GE_AUDIOTRACE=1 GE_INPUTSCRIPT="600:R,Z;601:R,Z;650:R,Z;651:R,Z;700:R,Z;701:R,Z;750:R,Z;751:R,Z;800:R,Z;801:R,Z;850:R,Z;851:R,Z;900:R,Z;901:R,Z;950:R,Z;951:R,Z" \ + timeout 30 ./build-pc/ge007.x86_64.exe -level_33 + ``` + `audiotrace.log` (per-call soundIndex/pointer trace) and `audiodump.raw` + (raw s16le stereo @ 22050Hz, no header — wrap with Python's `wave` module + or `ffmpeg -f s16le -ar 22050 -ac 2 -i audiodump.raw out.wav`) land in the + repo root. **User confirmation: "it's playing + the ricochet sound effect and the slapping melee attack sound effect on + each gunshot"** — i.e. two distinct wrong-but-real game sounds, not + silence/noise/a single mislabeled clip. +- The `GE_AUDIOTRACE` probe (already in tree) on that same run showed the + fire event genuinely requests **soundIndex=46 (`GUN_SILPPK_A_SFX`)** + paired with **soundIndex=122 (`CART_SPENT_SFX`, the shell-casing sound — + `gunfire.c:5582`)** — both legitimate, correct call sites for this + weapon/action, resolving to non-null, structurally-valid + sound/keyMap/wavetable/book pointers. This is a **live, real-gameplay + confirmation** of what M-52 had only checked structurally. +- **Independently re-parsed the raw ROM bytes from scratch in Python** + (no reuse of any project code — a from-first-principles walk of + `ALBankFile → ALBank → ALInstrument → ALSound → ALWaveTable` against + `data/ge007.ntsc-final.z64` at the `_sfxctlSegmentRomStart`/ + `_sfxtblSegmentRomStart` addresses) to check the PC port's custom + `port/src/romdata.c` re-layout tool (`romdataAudioBankPcSize`/ + `romdataFixupAudioBank`, the `afFixup*` family — hand-written PORT code, + not decomp, and the one part of this chain not previously scrutinized + this closely). **Result: exact match.** Index 46's wavetable resolves to + `base=0x103128A0 len=2242`, index 122's to `base=0x10351DF8 len=2196` — + **both bit-for-bit identical to the live runtime trace.** Also pulled + each sound's ADPCM book (order/npredictors/coefficients) and `ALKeyMap` + directly from ROM: indices 46/47/122 all have distinct, non-aliased book + coefficients (not a copy-paste/dedup collision), though 46 and 47 + (`PUNCH1_SFX`) do share an identical `keyBase=54/detune=50` keymap — + coincidental (both are attack-transient sounds tuned the same), not by + itself an index confusion. +- **This closes out the index/pointer/ROM-offset chain for good** — it is + now proven correct by independent reconstruction, not just "audited and + believed clean" (M-52's original phrasing). Since each sound's raw + ROM-embedded ADPCM data is objectively distinct (different books), the + wrong-sounding output can only be happening in the **decode or mixing + runtime itself** — i.e. the actually executed `port/src/mixer.c` path, + not the data feeding it. +- **New leading suspect, not yet tested**: **D199's flagged risk** — the + Phase-3 mixer's `aSetBuffer` **persistent-DMEM-context inference** + (macro-swap `aXxx`→`aXxxImpl` execution with no RSP disassembly to + verify against, `findings.md` D199) — if two voices active in the same + audio frame (e.g. the continuous Dam wave-ambience loop already seen + looping throughout this capture, soundIndex 65/67, plus the fire SFX) + end up sharing/clobbering the same inferred DMEM decode-context instead + of each getting its own, one voice's ADPCM decoder state could bleed + into another's output — producing exactly this "correct data in, wrong + but real-sounding-like-something-else data out" symptom, deterministically + per concurrent-voice pairing. This is a hypothesis, not yet verified. + +**Still OPEN. Next session:** audit `port/src/mixer.c`'s `aSetBuffer`/DMEM +context handling for whether concurrently-active voices (not just +back-to-back `Impl` calls for the *same* voice) get isolated decode state — +trace which physical DMEM buffer/context each of the fire SFX and the +continuous ambience loop actually uses per frame during the same repro +(`GE_AUDIODUMP`+`GE_AUDIOTRACE`, `-level_33`, aim+fire script above already +gives a clean repro). Do NOT re-check the ROM offset/pointer/index chain +again — independently re-derived byte-exact this session, closed for good. + +**M-57 — the M-56 concurrent-voice-DMEM hypothesis is FALSIFIED by a live +opcode-level trace, not just re-audited. A new (currently dormant) +pointer-truncation bug was found in the same family as D198/D201, and a +full sibling audit was produced, but NEITHER explains D202 yet — still +OPEN.** Interactive session, build+run environment confirmed working +(`export PATH=/c/msys64/mingw64/bin:$PATH` from repo root). + +- Added a temporary `GE_MIXERTRACE=1` probe (`port/src/mixer.c`) that logs + every `aSetBuffer`/`aLoadADPCM`/`aADPCMdec`/`aResample`/`aEnvMixer` call + with its DMEM addresses and state pointer, in call order, to + `mixertrace.log`. Reran the exact M-56 repro + (`GE_AUDIODUMP=1 GE_AUDIOTRACE=1 GE_MIXERTRACE=1 GE_INPUTSCRIPT=... -level_33`) + and read the trace around the soundIndex 46/122 fire event (cross-referenced + via `audiotrace.log` and the `alLoadParam` wire probe's `audiotrace_wire.log`, + both already in tree from M-52/M-56). +- **Result: every voice's full chain — `aLoadADPCM` → `aSetBuffer`(decode) → + `aADPCMdec` → `aSetBuffer`(resample) → `aResample` → `aSetBuffer`(main) → + `aSetBuffer`(aux) → `aEnvMixer` — runs to completion, strictly sequentially, + before the next voice's `aLoadADPCM` starts.** No interleaving between + voices was observed anywhere in ~62k logged opcodes spanning the fire + event. The single shared `sDmem`/`sAdpcmTable`/`sCtx` globals are safe + under this call pattern for exactly the same reason the real RSP's single + physical DMEM is safe: one voice's full pipeline finishes and its result is + accumulated into the persistent `AL_MAIN_L_OUT`/`AL_AUX_*` buffers before + the scratch region (`AL_DECODER_IN`=0) is reused by the next voice. **D199's + "concurrent-voice DMEM clobber" risk is closed, negative** — falsified by + direct trace, not just re-reasoned about. +- Also noticed `a->table->len` visibly changes (2242 → 2241) between the + first and later `alLoadParam` wire-probe log lines for the *same* shared + `ALWaveTable`. Traced this to `load.c:396` + (`a->table->len = ADPCMFBYTES * ((s32)(a->table->len/ADPCMFBYTES));`) — + **unmodified N64 ground-truth code**, rounds the table length down to a + whole-ADPCM-frame multiple. It mutates the shared table in place but is + idempotent (rounds to the same value on every call after the first) and + behaves identically on N64. **Not a bug — ruled out.** +- **New finding (not yet root-caused as D202's cause, likely a separate + latent issue): `src/libultra/audio/load.c` lines 111, 173, 259, 320, 382, + 438 all do `f->memin = (s32) f->table->base + ...` / `a->memin = (s32) + a->table->base` — narrowing a real 64-bit `u8 *base` pointer + (`ALWaveTable.base`, `include/PR/libaudio.h:217`) into the `s32 memin` + field** (matches N64 ground truth's struct layout, `synthInternals.h`). + This is the same truncation class as the already-fixed D198 + (`K0_TO_PHYS`) and D201 (`bnkf.c` relocation offsets), but **currently + dormant in this session's build**: the observed `ALWaveTable.base` values + (e.g. `0x103128a0`) sit comfortably under 4GB (this exe loads its ROM-data + blob at a low, non-ASLR'd address), so the `(s32)` truncation followed by + `osPhysicalToVirtual`'s `u32`→pointer zero-extension round-trips exactly — + no data corruption observed in this run. Flagging as a **hardening item**, + not a confirmed D202 cause; would need a build/environment where the + ROM-data blob's heap address exceeds 4GB to prove it live. Not fixed this + session (out of scope for this investigation; needs its own D-number if + picked up). +- Delegated a read-only sibling-audit of this cast pattern across the whole + audio subsystem to the local Qwen `repo-mapper` agent (grep-only, no + edits, checked against real files). Full hit list, confirmed genuine + pointer-truncation sites beyond `load.c`: + - `src/music.c:879,1073,1266` — `(u8*)((t3 + (s32)thing.seqData) - + trackSizeBytes)` — same class, narrows a seq-data pointer before a ROM→RAM + copy. Worth checking next alongside `load.c`. + - `src/libultra/audio/heapinit.c:26` — narrows the audio heap `base` + pointer to `s32` but immediately masks with `& AL_CACHE_ALIGN` (only + alignment low-bits survive) — low risk in practice, still worth a + `uintptr_t` pass for hygiene. + - Every other `(s32)`/`(int)` cast in `src/libultra/audio/*.c` and + `src/libultrare/audio/*.c` (`resample.c`, `reverb.c`, `env.c`, + `seqplayer.c`, `csplayer.c`, `save.c`, `synthesizer.c`, `drvrNew.c`) + narrows a genuine small integer (byte/sample counts, pitch ratios, + indices) — not pointer truncation, nothing to fix. + +**M-58 — priority (1) from the M-57 write-up (decoded-PCM-per-voice-slot dump) +executed. Result: address resolution AND the ADPCM decode math are BOTH +independently verified bit-exact correct for a live fire-SFX occurrence. +D202 is NOT in `aLoadBuffer`'s DMA/address plumbing and NOT in +`aADPCMdecImpl`'s decode arithmetic. Still OPEN — the bug is downstream of +decode or in voice/output routing.** + +- Extended the `GE_MIXERTRACE` probe (env-gated, zero cost when unset) + three ways: (a) `port/src/mixer.c` `aLoadBufferImpl`/`aLoadADPCMImpl`/ + `aADPCMdecImpl` now log the actual DMEM source bytes, the loaded ADPCM + book coefficients, and the first decoded 16-sample frame (skipping the + 16-sample history lead-in) into the *same* `mixertrace.log` as the M-57 + opcode trace; (b) `src/libultra/audio/load.c` `_decodeChunk` logs + `[DMAREQ] filter= memin=` right before calling + `f->dma(...)`, and `alLoadParam`'s existing `AL_FILTER_SET_WAVETABLE` case + now also emits a `[BINDTABLE] filter= <- table=... base=...` line into + `mixertrace.log` (previously only in the separate `audiotrace_wire.log`) + so table-bind, DMA, and decode events for the same physical voice filter + interleave in one chronologically-ordered file instead of needing + cross-file correlation by line count (a real mistake made mid-session: + comparing `wavetable->base` from one run's `audiotrace.log` against + `aLoadBuffer` addresses from a *different* run's `mixertrace.log` produced + a spurious "addresses never match" false alarm — resolved by getting both + probes into one file in one run); (c) `src/audi.c` `amDmaCallback` now + logs `[DMAHIT]`/`[DMAMISS]` with the input ROM-side address, matched + buffer's `startAddr`, and the returned staging-buffer address. +- Reran the exact M-56/M-57 repro + (`GE_AUDIODUMP=1 GE_AUDIOTRACE=1 GE_MIXERTRACE=1 GE_INPUTSCRIPT=... -level_33`, + see M-56 for the exact command). Picked one live fire event + (`soundIndex=46`, `wavetable->base=0x103128a0`) and followed its physical + voice filter (`ALLoadFilter*`) end to end in one linear read of + `mixertrace.log`: + - `[BINDTABLE] filter=...706d7230 <- table=...706c4cc8 base=0x103128a0` + - `[DMAREQ] filter=...706d7230 memin=0x103128a0` — **exactly matches** the + bound table's own base. No cross-voice/cross-heap address confusion. + - `[DMAMISS] addr=0x103128a0 ... ret=0x706dd490` — legitimate cold DMA + (N64-analogous ROM→staging-buffer copy via `port/src/libultra.c`'s + `piServiceDma`, which correctly `memcpy`s from the cart-mapped `.z64` + region at `srcPA=0x103128a0`, a real, valid cart address under + `romdata.c`'s mapping — not a stray heap pointer as first suspected + mid-session before the single-file trace fix above). + - `[LOADBUF] dram=0x706dd490 ... bytes=900de113224092ecde90f602424ae1bf` — + the exact ADPCM-encoded source bytes now loaded into DMEM. + - `[ADPCMDEC] ... book0=-881` and the dumped book coefficients match + `sndPlaySfx`'s own `book=00000000706c4ca0` trace for soundIndex 46 + (`+8` byte print-pointer offset, same object) — the correct predictor + table for this sound, not a stale/aliased one. + - `[PCMOUT] book0=-881 first-decoded-frame[0..15]=0,-1536,-2475,-1165,...` +- **Independently hand-decoded the same 9 input bytes against the same + dumped book coefficients using the textbook VADPCM algorithm** (order-2 + predictor, `header byte -> shift+tableIndex`, nibble sign-extension, + cumulative predictor sum), entirely outside this codebase, no reuse of + `mixer.c`'s logic. First three samples worked by hand: `0`, `-1536`, + `-2475` — **bit-exact match** against the C implementation's dumped + output. This independently confirms `aADPCMdecImpl`'s math is a correct + VADPCM decoder, not just internally self-consistent. +- Also noticed (not a bug, recorded for the record): `sAdpcmTable` is a + single static global reused across sound switches without zeroing unused + predictor slots, so predictor indices beyond a sound's own `npredictors` + hold stale coefficients from a previous sound's book load. Harmless in + practice — a sound's own ADPCM-encoded stream only ever emits + `tableIndex` values within its own encoded `npredictors` range (confirmed + here: soundIndex 46's book is `bookSize=32` bytes = 1 predictor via + `2*order*npredictors*ADPCMVSIZE`, and its stream's header nibble was `0`, + the only valid index) — but it is fragile precisely because it depends on + the encoder never emitting an out-of-range index; a future corrupted/ + malformed bank could read garbage predictor coefficients silently. Not + pursued further this session (not shown to relate to D202). +- **This closes out the entire `aSetBuffer`→`aLoadBuffer`→`aLoadADPCM`→ + `aADPCMdec` chain for the cases actually traced.** Combined with M-56 + (ROM data/index chain closed) and M-57 (DMEM concurrency closed), the + first three links of the pipeline — request routing, ROM data integrity, + and decode — are now all independently verified correct for at least one + live fire-SFX instance. The mid-session false alarm (see above) is a + useful lesson: **always put every new correlated probe into the same + linearly-ordered trace file**; cross-file correlation by line count + across separate runs is not valid even when the repro is "the same" + command, because per-run nondeterminism (already catalogued, D117) shifts + line counts and, this session, briefly looked exactly like a real bug. + +**Still OPEN. Next session, in priority order:** (1) the remaining untraced +links are **resample → envmix → final-mix routing**, and **physical-voice +(PVoice) allocation/identity** — whether the correctly-decoded PCM for one +logical `ALSoundState` ends up accumulated into the *wrong* voice's/output's +persistent envelope-mixer volume-ramp state (`sVol`/`saved->t[]` in +`aEnvMixerImpl`) or gets attributed to the wrong output buffer, which would +produce exactly "correct audio computed, wrong audio heard." Trace the same +way as this session (one physical filter/voice followed end-to-end in one +`mixertrace.log`, `[ENVMIX] state=...` pointer identity checked against which +logical sound it's supposed to belong to) rather than assuming and re-deriving. +(2) If that's clean too, suspect PVoice allocation/identity higher up (the +synth's voice-slot assignment, independent of DMEM/decode). (3) Only after +(1)/(2): consider fixing the `load.c`/`music.c` pointer-truncation family as +a hardening pass (own D-number) — still not shown to cause D202. Do NOT +re-check the ROM offset/index/pointer chain (M-56), DMEM/`aSetBuffer` +concurrent-voice sharing (M-57), or the decode arithmetic/addressing +(M-58, this session, verified bit-exact) — all closed. + +**M-59 — likely resolution: D202 is NOT a decode/mixer bug. The "ricochet + +melee slap" audio the M-56 listening test heard is a SEPARATE, entirely +legitimate SFX request made by unmodified ground-truth game logic on every +shot in this specific repro, because the scripted aim direction hits a wall/ +prop, not open air. Recommend a follow-up open-air listening test before +resuming the M-58 priority-1 envmix/PVoice trace.** + +- Two more concrete checks, continuing directly from M-58's decode-chain + verification (which stays valid and closed): (a) independently confirmed + the actual DMEM-loaded ADPCM byte string M-58 dumped for soundIndex 46 + (`900de113224092ecde90f602424ae1bf...`) is byte-for-byte identical to the + **raw, untouched `data/ge007.ntsc-final.z64` file** at the corresponding + cart offset (`0x03128a0`, i.e. `base 0x103128a0` minus `CART_BASE + 0x10000000`) — read directly with a throwaway Python script, no project + code reused. Traced why this address is meaningful at all: + `_sfxtblSegmentRomStart` (`port/src/romassets_u.s:2948`) is a linker + constant equal to the literal cart address `0x102F19A0`, used as-is by + `alBnkfNew`'s `table` argument (`src/music.c:703`) → `_bnkfPatchWaveTable` + (`src/libultra/audio/bnkf.c:140`, `w->base += table`) — i.e. GE's + wavetable sample data is **never relaid-out or copied by + `port/src/romdata.c`'s bank-structure fixup at all**; `base` ends up + pointing straight into the same live-mapped `.z64` image `port/src/audi.c` + reads cart data from generally (`docs/dev/findings.md` cart-mapping notes, + M-56). This closes the last theoretical gap in the M-56/M-58 chain: ROM + data integrity is now verified at the raw file-byte level, not just + structurally. + - With the entire request→ROM-offset→DMA→decode chain now proven correct + end to end, (b) re-examined the *complete* `audiotrace.log` from the + M-58 repro (`-level_33` Dam, aim+fire `GE_INPUTSCRIPT`) rather than + filtering to just soundIndex 46/122, and cross-referenced every other + soundIndex against `src/bondconstants.h`'s `"NN_NAME_SFX"` string table + (which encodes the index directly in the name — e.g. `"46_ + GUN_SILPPK_A_SFX"`). **Every single `soundIndex=46` (fire) request in + the trace is immediately followed by a `soundIndex` from `{23, 24, 25, + 37}`** — `"23_RICO_6_TAJ_A_SFX"`, `"24_RICO_6_TAJ_B_SFX"`, + `"25_RICO_6_TAJ_C_SFX"`, `"37_RICO_5_C_SFX"` — i.e. **ricochet/bullet- + impact sounds**, cycling per shot, plus the already-known `122` = + `"122_CART_SPENT_SFX"` (cartridge eject). + - Found the call site: `src/game/gunfire.c` + `recall_joy2_hits_edit_detail_edit_flag()` (line ~2270, unmodified + ground-truth decomp function, original obfuscated name) is the bullet + hit-detection handler. For a hitscan that strikes a non-character prop + (`prop->type != PROP_TYPE_CHR && != PROP_TYPE_VIEWER`, i.e. scenery/ + wall, not an enemy), it unconditionally does: + `ricochet_sounds_small_copy = ricochet_sounds_small; + sndPlaySfx(g_musicSfxBufferPtr, + ricochet_sounds_small_copy.arr[randomGetNext() % 20], sound_state);` + — **every bullet that hits a wall/prop plays a randomly-chosen ricochet + SFX from a 20-entry table, by design, on real N64 too.** This is not a + port artifact; it is exactly what GoldenEye does when you shoot a wall. + - **Conclusion**: the M-52/M-56 repro's fixed aim direction + (`-level_33`, default spawn facing, held-down aim+fire) has the player + shooting directly into a nearby wall/prop the entire time, so *every* + trigger pull legitimately plays fire (46) + cartridge-eject (122) + + a random ricochet impact (23/24/25/37) simultaneously — three real, + correct SFX per shot, not one wrong one. The M-52 playtest's original + verbal description ("sounds like a melee/punch hit") and the M-56 + listening test's ("ricochet sound effect and the slapping melee attack + sound effect") both match a ricochet impact's percussive/metallic + character far better than a coincidental decode bug that happens to + always produce two *other real, nameable* game sounds. No corrupted or + misattributed audio has been found anywhere in the chain despite three + full sessions (M-56/M-57/M-58) of independent, bit-exact and byte-exact + verification at every single link — address resolution, ROM data + integrity, book/coefficient selection, ADPCM decode arithmetic, and now + the request pattern itself. + - **This does not yet prove the gunfire (46) sound itself is + perceptually correct/prominent** — only that it is being computed + correctly and is not what's "wrong" in what the tester heard. It's + possible the fire SFX is simply quiet/subtle by design and gets + acoustically masked by the louder ricochet transient, which would be + expected N64 behavior too, or (lower probability, unverified) there is + a genuine separate volume/mix-balance issue between simultaneous + voices. Not fixed or further diagnosed this session — needs a + clean listening test to settle it (see below). + +**Recommended next session (before resuming the M-58 priority-1 envmix/ +PVoice trace, which may now be unnecessary): re-run the exact same +`GE_AUDIODUMP` capture but with an `GE_INPUTSCRIPT` aim direction that +points at open air / the sky instead of a wall (or simply hold fire without +the aim-lock at a fixed wall-facing spawn), confirm via `audiotrace.log` +that NO `ricochet_sounds_small`-family soundIndex fires, and have the user +listen to that clip specifically. If the fire (46) + cartridge (122) sounds +alone are now clearly audible, correct, and recognizable as gunfire, D202 +should be closed as a repro artifact (aim-at-wall), not a bug — with a note +that the original M-52 playtest report may itself have simply been the +player firing at nearby geometry during normal play, which is entirely +expected. Only if the open-air clip STILL sounds wrong should the M-58 +priority-1 envmix/PVoice-identity trace be resumed. + +**Follow-up done same session (M-59 cont.):** turned the character with +`GE_INPUTSCRIPT="1:SRIGHT;400:SNONE;600:R,Z;..."` (sustained analog-stick +turn before the aim+fire sequence) and reran the `GE_AUDIOTRACE`+ +`GE_AUDIODUMP` capture on `-level_33`. Result: the *first* shot in this run +requests **only** `soundIndex=46` (fire) + `122` (cartridge) — no +`ricochet_sounds_small` index at all — confirming the turn moved the aim +off whatever wall/prop the original fixed-facing repro was hitting. The +*second* shot (moments later, once the turn brought the aim back toward +nearby geometry) requests `46` + `122` + `23` (`RICO_6_TAJ_A_SFX`) again, +giving a same-run A/B pair. Located both transients in `audiodump.raw` via +a 50ms-window RMS scan (clean shot ≈ t=11.0–11.7s, ricochet shot ≈ +t=11.7–12.6s), exported both as WAV (`scratchpad/ +d202_clean_shot_no_ricochet.wav` / `scratchpad/d202_shot_with_ricochet.wav`, +not committed — scratch), and sent both to the user for a direct listening +comparison. **Awaiting user confirmation** on whether the ricochet-free +clip sounds like correct, recognizable gunfire — that answer settles +whether D202 closes outright as a repro artifact or the envmix/PVoice trace +needs to resume. + +**M-59 result: user says BOTH clips are the ricochet/slap sound, just at +different volumes — the "clean" clip is not clean.** This falsifies the +"aim-at-wall repro artifact" theory as originally framed. Investigated +further: + +- The two exported clips both came from `dumppos`-adjacent events with + soundIndex 46 and a ricochet index at the **same audio-frame position** + in this run's `audiotrace.log` (a new `dumppos=` field was added to the existing `GE_AUDIOTRACE` probe — + `port/src/audio.c` now exposes `audioDumpBytePos()`, an exact running + byte counter, so future clip extraction can key off a logged sample + position instead of an RMS-scan guess). Confirmed the fire (46) and + ricochet requests land at the *same* `dumppos`, i.e. they are requested + in the same tick and their audio overlaps in time rather than being + sequential — so the RMS-peak-picking method used for the M-59 clips could + not actually isolate a ricochet-free window; both exported clips likely + had some ricochet content, just at different relative volume (distance- + dependent ricochet gain, or a different ricochet variant per shot). +- To get a **guaranteed** ricochet-free sample, decoded `soundIndex 46` + **entirely offline**: read `data/ge007.ntsc-final.z64` directly at ROM + offset `0x03128a0`, len 2242, ran the same hand-verified VADPCM decode + used in M-58 (order-2, single predictor, coefficients from the M-58 + trace dump) with no live game/mixer involved at all — zero possibility of + a simultaneous ricochet voice. Rendered to `scratchpad/ + d202_soundindex46_isolated.wav`, sent to the user. **User confirmed: "yeah + that is the slap effect that in the original game plays when you do a + melee attack."** This is unambiguous — soundIndex 46's actual ROM sample + content genuinely is (or matches) the melee-slap SFX, independent of any + mixing/overlap question. +- **Traced the request chain one level further up, past `sndPlaySfx`, to + find out why soundIndex 46 is requested for the silenced PPK's fire at + all.** `bondwalkItemGetSound(item)` (`src/game/gun.c:1345`) returns + `get_ptr_item_statistics(item)->Sound` — a `u16 Sound` field on the + per-weapon `WeaponStats` struct (`src/game/gun.h:87`, doc comment: + "Sound effect played when gun is shot. There are 261 sound effects, or + 0 - 105h."). `get_ptr_item_statistics` (`gun.c:721`) returns + `gitem_structs[item].item_weapon_stats` — **not a runtime ROM-converted + pointer**; `gitem_structs[]` (`assets/obseg/gun/gunModelFileRecord.inc.c`) + is a **static, compiled C data table**, decompiled directly from the + ROM's `.data` segment (each `GUNFILERECORD(...)` entry pulling in a + `GUNSTATS(name)` initializer). The silenced PPK's is + `assets/obseg/gun/wppksil/gunWeaponStat.inc.c` + (`WeaponStats wppksil_stats = {1.0, 11.0, ..., 0x2E, ...}`, tagged with a + decompilation RAM-address comment `//D:800326C4`) — **`.Sound` is + positionally the 14th field, value `0x2E` = decimal **46**.** This is + unmodified, hand-decompiled ground truth, not a port-side conversion — + there is no PC-specific fixup in this chain at all (unlike the wavetable + data, this table is a plain compiled literal, identical on N64 and PC by + construction). +- **Conclusion: on real N64 hardware, firing the silenced PPK requests + the exact same soundIndex 46 this port does.** Every link — decompiled + weapon-stat data, SFX-bank index resolution (M-56/M-59), ROM sample + bytes (M-58/M-59), and ADPCM decode (M-58) — is now independently + verified correct and, more importantly, **identical to what N64 hardware + would do with the same ROM**. If soundIndex 46 truly sounds like a melee + slap and not a silenced pistol, that is either (a) the original, + shipped game's actual designed audio (silenced firearms can have a soft, + percussive "thwack" character that isn't unreasonable to mistake for a + melee sound, and reusing one SFX asset across two unrelated actions to + save ROM space is a common technique of this era) — in which case there + is **no bug anywhere in this port to fix**, this is how GoldenEye 007 + actually sounds; or (b) a very rare decompilation transcription error in + `wppksil_stats`'s `.Sound` field specifically — but this project's + entire premise is byte-verified matching against the real ROM's `.data` + segment (README/AGENTS.md), so an error surviving in a byte-matched + build would be surprising, though not provably impossible without + independently re-reading the literal ROM bytes at this struct's true RAM/ + ROM address (not attempted this session — would need the N64 build's + linker map to translate the `//D:800326C4` RAM comment to a ROM file + offset). +- **Not fixed, not committed — this is a research conclusion, not a code + change.** No `port/`, `src/`, or `assets/` change is warranted unless (b) + above is confirmed; per AGENTS.md, `assets/obseg/gun/wppksil/ + gunWeaponStat.inc.c` is decompiled ROM data (ground truth) and must not + be "corrected" speculatively. + +**Recommended definitive tie-breaker (next session, if the user wants to +keep pursuing this rather than close it):** run the **same ROM** in a +known-good N64 emulator (or, if available, real hardware) and fire the +silenced PPK. If it also sounds like the melee-slap effect there, D202 is +closed for good as a genuine original-game characteristic (or a very +deep, pre-existing GoldenEye quirk) — not a decompilation or port bug, and +no further engineering time should go into it. If the emulator's silenced +PPK sounds like a normal muted gunshot, the bug is almost certainly the +rare decompilation transcription error in (b), and the fix would be a +single-field data correction in `gunWeaponStat.inc.c` guarded by the +project's existing byte-match verification (which would need to still +pass — if it doesn't, this table is confirmed byte-correct after all and +the "bug" is upstream of Bond's silenced-PPK path in some other way not +yet identified). + +**M-60 — user explicitly rejects the "ground truth, not a bug" conclusion** +("I know for a fact its the wrong sound playing in the PC port +specifically") and supplies a real reference clip instead of an emulator +A/B: `scratchpad/B00I00S2D.wav` (untracked/scratch), a genuine recording of +the silenced PPK's real fire sound, dropped directly by the user (source +not yet stated — presumably real hardware/video capture, not this repo). +Measured: mono, 16-bit, 16 kHz, 3264 samples, 0.204 s. Its 10-bucket RMS +envelope (`12645, 11548, 15152, 16172, 14006, 12766, 15106, 15669, 8280, +6608, 8032, 9409, 9812, 8849, 7745, 6819, 4299, 1477, 496, 176` over 20 +buckets) is **sustained** — energy stays in the 6000-16000 range for +roughly the first 80% of the clip's duration before decaying in the last +~20% — structurally different from `d202_soundindex46_isolated.wav`'s +envelope, which peaks early (`11209, 19213, 17387, 16284, 11296, 10047, +6116, 3801, 2238, 1059` over 10 buckets) and decays smoothly and quickly +from the first bucket onward. This is consistent with the user's "slap" +identification (a single sharp decaying transient) vs. a real gunshot's +more sustained buzz/reverb character, and is independent corroborating +evidence — not proof by itself, since envelope shape alone doesn't rule out +a resample/pitch difference distorting the comparison (see below). + +**Re-opened the investigation on the user's instruction; do not close D202 +as WONTFIX.** Two concrete threads were started this session, neither +finished: + +1. **The debug-name label itself is suspicious independent of any + listening test.** `bondconstants.h`'s soundIndex 46 is *named* + `"46_GUN_SILPPK_A_SFX"` — a name that specifically claims to be the + silenced-PPK gunshot — yet its decoded ROM content is what the user + twice identified as the melee-slap effect (which has its own, different, + adjacent indices: `47_PUNCH1_SFX`/`48_PUNCH2_SFX`/`49_PUNCH3_SFX`). A + correctly-decoded sound whose content doesn't match its own debug label + points at a **sound-bank navigation/indexing bug** (wrong bank, wrong + instrument, or an off-by-one somewhere in the ALBank -> ALInstrument -> + ALSound walk) rather than at "GoldenEye's SFX design reuses one asset + for two things" (M-59's charitable reading (a)). `src/snd.c`'s + `sndPlaySfx` doc comment is explicit about the resolution path: + `soundBank->instArray[0]->soundArray[soundIndex]` — i.e. `soundIndex` is + an index into **instrument 0's** sound array, not a bank-global flat + index. Worth checking directly: does `romdata.c`'s bank re-layout + (`afCtx`/`_bnkfPatchWaveTable`, `port/src/romdata.c` ~L582-780) walk + instruments/sounds in the same order the ROM's `ALBankFile` stores them, + or could the PC conversion silently shift indices by one somewhere in + that re-layout pass? Not yet checked line-by-line against the ROM bytes + for THIS specific bank/instrument. +2. **Started `scratchpad/rom_sfx_decode.py`** (untracked, WIP, not yet + working correctly) — a fresh, from-scratch offline ROM bank parser + (independent of the live game) intended to decode a whole *range* of + `soundIndex` values (44-50, 107, extensible) directly from + `_sfxctlSegmentRomStart`/`_sfxtblSegmentRomStart` and compare each one's + envelope/waveform against the new `B00I00S2D.wav` reference, to find + which soundIndex (if any) actually matches the real gunshot — which + would prove a specific off-by-N in the bank walk rather than relying on + ear-only comparisons. **This script's ADPCM predictor loop is currently + WRONG and must not be trusted as-is** — it stubs out the intra-subframe + accumulation term with a dead `if False` branch. Confirmed the *exact* + correct algorithm by re-reading `port/src/mixer.c`'s `aADPCMdecImpl` + (L185-239, ground truth, this is what the live port actually runs): + table shape `sAdpcmTable[predictor][2][8]`; per output sample `j` (0-7) + within a subframe, `acc = tbl[0][j]*prev2 + tbl[1][j]*prev1 + + (ins[j]<<11) + sum_{k=0}^{j-1} tbl[1][j-k-1]*ins[k]`, `ins[]` being the + 8 sign-extended-then-shifted nibbles of that subframe, `prev1`/`prev2` + the last two output samples (carried from the previous subframe/frame). + **Next session: fix `rom_sfx_decode.py`'s decode loop to match this + exactly** (the M-58/M-59 "isolated decode" script that the user's slap + identification was based on was verified byte-exact against this same + algorithm in a prior session, so that specific result stands — only the + new range-scanning script is unverified), then run it across a wider + soundIndex range and diff each envelope against `B00I00S2D.wav`. +3. **Also flagged, not yet resolved:** the M-59 "correctpitch" resample + experiment (`scratchpad/d202_soundindex46_correctpitch.wav`, pitch=23849 + from the M-58 live trace) produced *fewer* output samples (1459) than + input (3984) at a pitch ratio ~0.364. If pitch < 1.0 means "advance less + than one input sample per output sample" (slow down / stretch), output + sample count should be `input / ratio` (more samples), not `input * + ratio` (fewer) — the direction looks inverted. This resample script's + formula needs re-deriving against `port/src/mixer.c`'s actual + `aResampleImpl`/`sResampleTable` before its output is trusted either + way; it has NOT been sent to the user and should not be used as evidence + yet. + +**Status at handoff: D202 is OPEN, user-confirmed-wrong-in-PC-port, actively +being re-investigated.** Do not re-close as "ground truth, not a bug" — +that conclusion was explicitly rejected by the user with a real reference +recording now in hand (`scratchpad/B00I00S2D.wav`). No code changes made +this session (diagnostic-only), nothing to revert. + +**M-61 — both M-60 threads resolved (one closed, one fixed+run); the +decisive question is now "what byte is actually in the original ROM", and a +new logical constraint makes that the keystone fact.** Diagnostic-only +session again; no game/port code touched. Results: + +1. **Thread 1 (bank-navigation off-by-N) CLOSED — negative.** Line-by-line + review of `port/src/romdata.c`'s bank re-layout (`afCtx` / + `_bnkfPatchWaveTable`, ~L582-780) confirms the PC conversion walks + `ALBankFile -> ALInstrumentFile -> ALSoundFile` in exact ROM order with + no index shift; combined with M-56's byte-exact runtime trace, PC + soundIndex N resolves to the same ROM sample as N64 soundIndex N. The + "wrong bank / off-by-one in the walk" hypothesis is dead. Do not + re-litigate it. +2. **Thread 2 (offline decoder) FIXED and VALIDATED.** + `scratchpad/rom_sfx_decode.py`'s `adpcm_decode()` was rewritten to match + `port/src/mixer.c`'s `aADPCMdecImpl` exactly. Three bugs fixed: (a) + tbl0/tbl1 indexing was swapped, (b) the intra-subframe accumulation term + `sum_{kSound` — the raw u16 + `WeaponStats.Sound` field (offset 38; layout confirmed from + `src/game/gun.h`: 7×f32, s32 AmmoType, s16 MagSize, u8 AutoFireRate, + s8 SingleFireRate, u8 ObjectsShootThrough, u8 SoundTriggerRate, u16 + Sound, ptr cartridge, …). The silenced PPK's fire sound IS + `wppksil_stats.Sound`, used verbatim. (Unsilenced PPK uses 0x6B=107.) +4. **Build wiring: both N64 and PC builds compile the same initializer from + source.** `src/game/gun.c:135` includes the aggregate + `assets/obseg/gun/gunWeaponStats.inc.c`, which defines `wppksil_stats` + inline at `//D:800326C4` with `.Sound = 0x2E` (it only #includes the + per-weapon files for fist/knife; everything else is inline). The + per-weapon file `assets/obseg/gun/wppksil/gunWeaponStat.inc.c` carries a + second, IDENTICAL copy (also `0x2E`, same `//D:800326C4`). The PC build + exclusion list covers only `assets/obseg/{bg,brief,setup,stan}` + (CMakeLists ~L473), so gun.c's compiled data is identical in both + builds. **The decompiled source is internally consistent: 0x2E in both + copies.** +5. **KEY LOGICAL CONSTRAINT (new, this is the keystone):** the project's + premise is that the N64 build byte-matches the original ROM. Compiled + data cannot silently diverge — if `gunWeaponStat.inc.c`'s `.Sound` + were a transcription error, the built ROM would differ from the + original at exactly those 2 bytes and the match would fail. Therefore + **0x2E almost certainly IS in the original ROM**, i.e. real N64 hardware + also requests sound index 46 for the silenced PPK. That contradicts the + reference clip matching index 45 at +0.999. Exactly one of these must be + false: + (a) the byte-match verification does not actually cover this data + segment (check the verify tooling/output!); + (b) `B00I00S2D.wav` is not an in-game capture from real N64 hardware — + its filename looks like a media-library asset ID, provenance was + never stated; if it's e.g. film/marketing audio, the "N64 plays 45" + premise collapses and M-59's "faithful reproduction" conclusion + returns WITH evidence; + (c) N64 runtime resolution of index 46 differs from ROM-order index 46 + (Thread 1 closed *ordering* in the PC re-layout, not real-hardware + ALBank content — lower priority given (a)/(b)); + (d) the +0.999 envelope correlation with index 45 is misleading (room + reverb tail can inflate envelope matches). +6. **Direct ROM-byte read of `wppksil_stats.Sound` FAILED — do NOT repeat + the same searches (they looped 40+ times this session, all 0 hits):** + - Any pattern containing a decompiled float literal is unreliable: the + literals are decimal approximations that don't round-trip to exact + IEEE-754 bits (e.g. `-20.799999` → `c1a66666`, zero occurrences in + ROM). Integer-only tail patterns (`AmmoType=1, MagSize=7, 0xFF, 0x10, + …`) also returned 0 hits — either the byte layout at that ROM location + differs from assumption or the data sits where the scan didn't look. + - The hand-computed RAM→ROM mapping produced ROM offset `0xC11934` > + file size `0xC00000` — WRONG. Note `ge007.ld` puts `.csegment` at ROM + `0xC00000` (= exactly EOF), so `wppksil_stats` (vaddr `0x800326C4`) + cannot live there; the correct vaddr→ROM mapping must come from the + N64 build's map file/symbol table, not arithmetic. + - No N64 `.map` found at maxdepth 3 (only the PC CMake tree + `build-linux/`). Search DEEPER: `assets/obseg/Makefile.*`, `ld/`, + `dist/`, or any N64 ELF in the repo; `nm`/`objdump` on it yields the + exact vaddr+section, and the ROM offset then follows from the linker + script. +7. **Next session, in priority order:** + 1. **Establish what byte the original ROM holds at + `wppksil_stats.Sound`** — find the N64 build map/ELF (search deeper + than maxdepth 3) and read the 2 bytes. This single fact splits the + tree: ROM=0x2D → transcription error (then audit why byte-match + verification missed it); ROM=0x2E → data is ground truth, and the + discrepancy lives in reference-clip provenance (b) or N64 runtime + resolution (c). + 2. **Check the byte-match verification tooling** — does it compare data + segments or code only? Locate the last full-verify output. + 3. **Ask the user for `B00I00S2D.wav`'s provenance** (one question; see + (b) above). + 4. Cheap corroboration: live PC trace logging the soundIndex actually + passed to `sndPlaySfx` on PPK fire (expected 46) — confirms the PC + side behaves exactly per its data. + +**Status at handoff (M-61): D202 OPEN.** Threads 1+2 from M-60 are closed +(negative / fixed). No code changes; only `scratchpad/rom_sfx_decode.py` +modified (now correct + validated). Do not re-run the failed ROM byte- +pattern searches — go for the N64 map file instead. + +### M-62 — item (2) resolved without a build: byte-match verification DOES cover this exact struct, and its checksum was captured after the source's last edit. `.Sound = 0x2E` is very likely ground truth. Pivoting to (b)/(d). + +Static/read-only session. Skipped the N64-map/ELF route (item 1) — no MIPS +toolchain is installed in this environment (`mips-linux-gnu-*` absent, +`docs/building.md` says extraction needs `binutils-mips-linux-gnu`; building +the full N64 target to get a map file is a real toolchain-install task, not +attempted this session) — and went straight for the cheaper item (2) instead. + +**Found: this repo's own CI (`.github/workflows/ci.yml`) never builds the +N64 target at all.** `validate`/`linux-build`/`windows-build` are all PC-port +jobs; there is no job invoking the N64 `Makefile` or +`scripts/test_files.sh`. So "byte-matches US/EU/JP ROMs" (AGENTS.md's +opening claim) is inherited from the upstream `n64decomp/007` project's own +history, not continuously re-verified here — worth knowing, but a separate +concern from D202. + +**The verification tooling itself does cover `.data`, and gun.o specifically:** +`scripts/test_files_readme.md` — `test_files.sh` extracts `.text`/`.code`/ +`.bss`/`.data`/`.rodata` from every `.o` under `src/`, `src/game`, and the +four asset dirs, and diffs each against a checked-in known-good md5 +(`scripts/ge007.u-test_basis.csv`). Confirmed by direct grep: +`scripts/ge007.u-test_basis.csv:946`: `0289c36e967840bba7f6fbd026dda001,.data,build/u/src/game/gun.o` +— `gun.o`'s `.data` section (which is where `wppksil_stats` lives, via +`gun.c`'s `#include` of `gunWeaponStat.inc.c`) has a recorded checksum. +This directly falsifies alternative (a) from M-61 — the verification does +cover this exact data. + +**Timing check, so the checksum can't be stale relative to a since-edited +source:** `git blame -L946,946 scripts/ge007.u-test_basis.csv` → +`cc14d64e7 "for england james?"` (2026-08-16, upstream sync commit). +`git log -- assets/obseg/gun/wppksil/gunWeaponStat.inc.c` → last touched +`9fbe1fd5` ("sync. run make forceextractassets..."), which is an ancestor of +`cc14d64e7` in this file's history. **So the checksum was captured AFTER +the last edit to this exact source file** — it isn't testing stale content. +Re-read the file directly: `.Sound` is still `0x2E` (14th positional field, +`assets/obseg/gun/wppksil/gunWeaponStat.inc.c:3`), unchanged. + +**Conclusion (not a rebuild-confirmed pass, but strong circumstantial +evidence):** the upstream decomp project's own generated fingerprint of a +known-good `gun.o` build covers this exact struct, was captured against +this exact (still-current) source content, and this repo's non-negotiable +rule #2 (game logic/decomp source frozen, PC-port work never touches +`src/game/gun.c` or `assets/obseg/gun/**`) means nothing has drifted since. +**Actually running `test_files.sh` to get a live "pass" line still requires +installing a MIPS toolchain and doing a real N64 build — not done this +session; flagged as the one remaining way to make this airtight rather than +strongly-inferred.** Barring that, alternative (a) is now the *least* likely +of the four M-61 alternatives, not the most. Combined with M-61's already- +closed alternative (c) (traced fire path, no runtime remapping), the two +live leads are now **(b) reference-clip provenance** and **(d) envelope- +match reliability** — both point away from a port bug and toward a +misidentified reference clip. + +**Next session, in order:** +1. **Ask the user the one open question: where did `scratchpad/ + B00I00S2D.wav` come from?** (Real N64-hardware capture of this exact + weapon on this exact ROM region? An emulator? A sound-effects library + asset, given the filename reads like a media-asset ID, not a capture + log?) This is the cheapest possible next step and was never actually + asked — do it before any more code/data spelunking. +2. If the user can't confirm hardware provenance: run the same ROM in a + known-good N64 emulator (the M-59 handoff's original recommended + tie-breaker, never done), fire the silenced PPK, listen directly — + settles (b)/(d) without needing the reference clip at all. +3. Only if both of those come back still contradicting `.Sound = 0x2E`: + install a MIPS toolchain (`binutils-mips-linux-gnu` equivalent) and + actually run `scripts/test_files.sh` for real, to convert this + session's strong-inference close of alternative (a) into a hard pass/fail. +4. Cheap corroboration, still not done: live PC trace of the soundIndex + passed to `sndPlaySfx` on PPK fire (expected 46) — confirms PC-side + behavior matches its own data, independent of what the data *should* be. + +**Status at handoff (M-62): D202 OPEN**, but the center of mass has shifted +firmly away from "port bug in the data/decode chain" (four independent +sessions, M-56 through M-62, now corroborate `.Sound = 0x2E` end to end) +and toward "reference clip may not be what it was assumed to be." No code +changes this session — read-only investigation, one `findings.md` write-up. + +**Same session, follow-up: asked the user directly where `B00I00S2D.wav` +came from. Answer: "Not sure / found online"** — the user does not know its +provenance and it was sourced from the internet, not recorded from real N64 +hardware or a verified emulator run against this ROM. This closes item (1) +of the M-62 next-session list and directly confirms alternative **(b)** +from M-61 (reference clip isn't a verified in-game N64 capture) as the true +explanation — an unverified internet clip was compared against a +byte-verified ROM data table and, unsurprisingly, didn't match. + +**Reassessment: five independent sessions (M-56 through M-62) have now +verified every link in the actual chain** — soundIndex resolution, bank +re-layout order, the ALWaveTable/ALSound pointer chain, the ADPCM decode +math (hand-verified bit-exact against the C output), the ROM segment +offset (verified 3 ways), and now the weapon-stats source data itself +(byte-match checksum, captured post-edit) — **all say the silenced PPK +correctly requests and plays soundIndex 46 exactly as real N64 hardware +would.** The only unverified link left is the thing D202 was reopened over: +an internet-sourced reference clip of unknown origin. That is no longer a +credible basis to keep this open as a port bug. + +**Recommendation: close D202 as NOT-A-BUG** (same conclusion M-59 reached, +now on much stronger evidence) **unless the user wants the belt-and-braces +emulator A/B** (fire the silenced PPK on the actual ROM in a known-good N64 +emulator, listen directly) — cheap, and the only remaining way to fully +rule out a genuine transcription error without installing a MIPS toolchain +and running `scripts/test_files.sh` for a hard pass. Do not spend further +session time on the data/decode/index chain — it is exhaustively verified. + +### M-63 — SUPERSEDES the M-62 "recommend close": user reports the bug is broader than a single wrong sample — real-time mixer/voice corruption, not a data-table issue. REOPENED for real, live repro attempted. Root cause NOT found; one concrete anomaly banked for next session. + +**User's report (verbatim symptom, not the M-60 reference-clip framing):** the +wrong ("slap") sound always plays on the silenced PPK; the correct suppressed +shot sometimes plays **too** (both heard together, not one-or-the-other); +and over a play session sounds "pile up and spirally glitch out," eventually +leaving **no audio except a looping sound (e.g. Bunker's door) stuck playing +since level start.** This is categorically different from "wrong sample +selected" — it describes runtime mixer/voice-lifecycle corruption. The M-56 +through M-62 chain (which verified the index/data/decode path bit-exact) does +**not** cover this; it only proves soundIndex 46 is the *correctly requested* +sound, not that the mixer plays it *cleanly* once requested. Reopening. + +**Build+run environment IS available this session** (`export +PATH=/c/msys64/mingw64/bin:$PATH`; MSYS2 MINGW64 gcc/cmake/SDL2 all present). +No MIPS toolchain (confirmed absent again, unrelated to this thread). + +**False-alarm detour (banked so it isn't repeated): a "crash in `load_bg_file` +(`bg.c:865`) on every `-level_09` boot" is NOT a real bug** — it was this +session's own test-setup gap. A throwaway `build-pc/data/` with only the +`.z64` copied in (no `pccg-ntsc-final/`, `pcmodels-ntsc-final/`) leaves +`file_resource_table` pointing at raw big-endian ROM instead of the converted +PC sidecars (`obInit()`'s own comment describes exactly this failure mode); +the two `[WARN]` lines ("pcmodels.bin not found", "pccg.bin not found — +stage loads will fault") were sitting right in the log and should have been +read before chasing a gdb backtrace. Copying the real `data/pccg-ntsc-final/` ++ `data/pcmodels-ntsc-final/` (+ the repo's real `ge007.eep`/`ge007.ini`) +into `build-pc/data/` makes it load clean, 60s+, no crash, both with and +without the uncommitted audio-diagnostic files. **Next session: always mirror +the full `data/` dir into `build-pc/data/`, not just the ROM, before treating +any level-load failure as real.** + +**Repro built:** `GE_MIXERTRACE=1 GE_AUDIOTRACE=1 GE_AUDIODUMP=1 +GE_INPUTSCRIPT="600:R,Z;601:R,Z;690:R,Z;691:R,Z;...;3210:R,Z;3211:R,Z"` +(30 fire pulses at ~90-frame/~0.75s spacing) `timeout 60 +./ge007.x86_64.exe -level_09`, run against the real save +(`data/ge007.eep`) copied into `build-pc/data/`. Ran clean 60s, no crash. +Logs preserved: `scratchpad/d202-m63/{audiotrace.log,mixertrace.log, +audiodump.raw}` (mixertrace.log is 25MB — grep it, don't read it whole). + +**Checked and RULED OUT this session, from the trace:** +- **Voice-pool exhaustion / leak:** only **16 distinct `newState` voice-slot + pointers** were used across all 153 `sndPlaySfx` calls in the 60s window + (highest reuse count: 20), and `sndDeactivate` count (159) tracks + `sndPlaySfx` count (153) almost 1:1. The pool is being recycled + continuously, not growing unbounded — the "voices never freed" theory + (the D199/M-53–M-55 lineage) does **not** reproduce in this repro. +- Firing the silenced PPK produced exactly the expected `soundIndex=46` + requests (58 of them, one burst per scripted trigger pull, occasionally + 2 back-to-back — plausibly dual-wield, not investigated further) with no + other unexpected soundIndex interleaved in the same burst. + +**New anomaly found, not yet explained — the most concrete lead for next +session:** `soundIndex=109` (`bondconstants.h:2546`, +`"109_GUN_B4_BOLTACTION_SFX", //used for AK47`) fires **94 times in 60 +seconds** despite the player never touching an AK47 in this repro (silenced +PPK only). A chunk of those requests hit the *same* voice slot +(`newState=00000000706f4fb8`) at a suspiciously uniform **2880-byte spacing +in the mixed output stream** — at 22050Hz stereo 16-bit that's exactly +**720 samples / ~32.6ms between re-triggers**, four-in-a-row +(`dumppos=688576,691456,694336,697216,700096`). ~30 rounds/sec is far +faster than any plausible automatic-weapon cadence in this game (real +full-auto weapons here fire far slower) — this reads like something is +re-issuing `sndPlaySfx` for the same logical sound every audio tick/frame +instead of once per actual game event, which would exactly explain "sounds +piling up." **Not yet confirmed as related to the user's PPK-slap complaint +specifically** (soundIndex 109 isn't the PPK's sound) — could be a +*second*, independent SFX-retrigger bug that happens to share the same +mixer, and its presence would still explain general "audio feels broken." + +**Not yet reproduced: the user's exact reported chain** (piling +up → spiraling glitch → eventual total silence except one stuck loop). This +60s/58-shot synthetic burst didn't collapse the mixer — either it needs a +longer, more realistic session (real human play, many minutes, mixed +NPC/player/ambient audio denser than this script), or the soundIndex=109 +retrigger-storm anomaly above is the seed that, given more real playtime, +compounds into what the user described (worth designing a longer repro +around forcing that specific retrigger condition first, cheaper than a raw +extended random playtest). + +**Next session, in order:** +1. Find what actually calls `sndPlaySfx(109, ...)` (search `src/game/*.c` + for callers keyed to soundIndex 109 / `GUN_B4_BOLTACTION_SFX`) and + determine whether the ~32.6ms retrigger cadence is a real per-shot + automatic-fire loop (legitimate, if unusually fast) or a bug re-firing + once per audio buffer/tick instead of once per game event. This is the + single most concrete, checkable lead from this session. +2. If (1) doesn't explain it: build a much longer repro (5+ minutes, + `GE_INPUTSCRIPT` walking + intermittent firing near guards, or just ask + the user to reproduce with `GE_MIXERTRACE=1 GE_AUDIOTRACE=1 + GE_AUDIODUMP=1` set and hand back the logs) aimed specifically at + reaching the "eventual total silence except one stuck loop" end state, + then diff voice-slot reuse counts/timing against this session's healthy + 60s baseline to see where it diverges. +3. Listen to `scratchpad/d202-m63/audiodump.raw` directly (raw s16 stereo + 22050Hz PCM) for audible garbling/overlap around the soundIndex=109 + retrigger-storm timestamps — not done this session (no audio playback + attempted, log-analysis only). +4. Do NOT re-litigate the index/data/decode/ROM-offset chain (M-56–M-62, + exhaustively verified) — this session's finding is orthogonal to that + one; D202 is reopened on a *different* mechanism than the M-60 reference + clip. + +**Status at handoff (M-63): D202 REOPENED, root cause not found.** The +M-62 "recommend close" no longer stands — the user's actual complaint is a +runtime mixer/voice issue, not (only) a data-correctness question. One +concrete, unexplained retrigger anomaly (soundIndex 109, ~32.6ms cadence) +is banked as the top lead. No code changes this session — build+run+trace +only; the pre-existing uncommitted diagnostic files (`port/src/audio.c`, +`port/src/mixer.c`, `src/audi.c`, `src/libultra/audio/load.c`, `src/snd.c`) +were exercised, not edited. + +### M-65 — Root-caused the "eventual silence" half: an ownerless `SOUND_FLAG_LOOPED` SFX permanently consumes one of only 8 voices. Guard measured. The "wrong/multiple sounds" half gains a second, independent cause (`sndCreatePostEvent` was stubbed out, killing ALL distance attenuation). The audible stuck loop is still NOT reproduced. + +**User's report this session:** "wrong sound playing or multiple sounds playing +when there should be one", and "on bunker if the attract shows the door open, +it plays a sound and the sound gets stuck looping forever until you close the +game/level", later refined to "the double door sound by the computer room still +infinitely loops after they begin playing" and "a good chance it just happens +when the level starts if you get that attract intro with them opening". + +#### (1) CONFIRMED + MEASURED: the voice leak that produces "eventual silence" + +Chain, each link verified rather than argued: + +- `soundIndex=203` (`METAL_SLIDE_CLOSE_SFX`) has `envelope->decayTime == -1`. + Verified at the **source bank level**, not just at runtime, with a new + `GE_BANKDUMP` probe walking `instArray[0]->soundArray[i]` in the ROM-layout + blob: `BANKSRC inst@0x005778 soundIndex=203 sound@0x0046B8 env@0x000008 + decay=-1`. `GE_KEYMAPDUMP` additionally dumps every source `ALEnvelope` + (`ENVSRC src@0x000008 raw=00000000 FFFFFFFF 000007D0`). **Genuine ROM data, + faithfully converted** — 19 of the SFX instrument's 261 sounds are + deliberately sustained this way. +- `decayTime == -1` sets `SOUND_FLAG_LOOPED` (`sndSetupSound`, via + `decayTimeFlag`). A looped sound **never posts `AL_SNDP_STOP_EVT`** + (`snd.c` `AL_SNDP_DECAY_EVT` skips it), *and* the preemption scan + **explicitly refuses to steal looped voices** (`!(iterState->unk3e & 0x12)`). + So the only release is `sndDeactivate`, which needs an owner. +- `doorPlayCloseSound0/1` play it as `sndPlaySfx(..., METAL_SLIDE_CLOSE_SFX, + NULL)` — **no `pendingState`**, so nothing stores the state and nothing can + ever deactivate it. (NB the decomp names mislead: `doorStartClose` calls + `doorPlayOpenSound1`; `doorFinishOpen`/`doorFinishClose` call + `doorPlayCloseSound0`/`1`. The *Open* pair own their slide loop via a slot; + the *Close* pair are unowned terminators.) +- `maxSounds` is **8** (`MUSIC_SFX_SEQ_MAYBE_MAX_SOUNDS`, `music.c:76`). + +**Measured** (115 s headless `-level_09`, new `[VOICE+]`/`[VOICE-]` probes +pairing every `g_sndAllocatedVoicesCount++` with its decrement): +**7 of the 8 voices permanently held, every one of them `sound=706c8e48` +(soundIndex 203) with `flags=7` (`LOOPED|PLAYING`)**, never released. The +64-entry *state* pool stays healthy (18 slots recycling, never NULL) — which is +why this is silence, not a crash: later `sndPlaySfx` calls get a state but no +voice and are dropped via `sndDisposeSound`. + +**Guard (port-side deviation, `src/snd.c` preemption scan).** Allow reclaiming +a voice only when it is `SOUND_FLAG_LOOPED`, not `SOUND_FLAG_RETRIGGER`, and +`state->state == NULL` — i.e. provably ownerless, so nothing could ever have +stopped it. Owned loops keep their existing protection exactly. + +| metric (115 s `-level_09`) | before | with guard | +|---|---|---| +| `g_sndAllocatedVoicesCount` | **pinned at 8** | oscillates 1–8 | +| voices acquired over the run | 171 | **629** | +| residual stuck 203 voices at exit | **7** | **1** | + +User confirmed the symptom improves ("still happens but less cascading +perhaps"). **This is a game-logic change (AGENTS.md rule #2) justified by +measurement, not by ground truth — it is NOT known why the N64 does not leak +here.** Keep it flagged as a documented deviation, not a silent fix. + +#### (2) FIXED, independent: `sndCreatePostEvent` was stubbed out, removing all distance attenuation + +`sndCreatePostEvent` returned immediately under `#ifdef PORT` (D138), from when +"libaudio is Phase-3 parked" and nothing drained +`g_sndPlayerPtr->evtq->allocList`, so every positional tick appended an item +that was never consumed and `alEvtqPostEvent`'s ordered insert went O(n^2) +until the kernel-heartbeat watchdog tripped on Facility ambience. + +**That premise expired when the Phase-3 audio thread landed (D198-D201)** — +`amMain` now drains the queue every audio frame. Every caller posts type 8 = +`AL_SNDP_VOL_EVT` (`bondview2.c`, `chrai.c`, `gunfire.c`, `propobj.c`), so the +stub had been silently removing **all distance-based volume**: every sound +played at full volume regardless of range. That is very likely a large part of +the "wrong sound / multiple sounds / too loud" impression, independent of (1). + +Stub removed. Re-measured: `rt=0.996`, `drop=0`, frame times 91-329 us, zero +watchdog trips over 115 s — the O(n^2) regression does not return. + +#### (3) STILL OPEN: the audible endless door loop is NOT reproduced + +Not reproduced in any headless run. Everything that could make a sound audibly +repeat was measured and excluded — see the ruled-out list below. + +#### RULED OUT this session (measured — do not re-walk) + +- **Wave-level looping.** New `[WAVELOOP]` probe: `loop == NULL` on **every** + sound in the SFX bank. No sample can repeat itself. +- **Decoding past the end of a wave.** New `GE_PULLTRACE` `[PASTEND]` assertion + in `alAdpcmPull` (fires when `f->sample` exceeds the wave's total samples + while `nOver == 0`): **0 hits**. `aClearBuffer` zero-fill works; an exhausted + voice goes silent. +- **Stale / accumulating software DMEM.** `GE_DMEMWIPE` zeroes all 4 KB of + `sDmem` at the exact frame boundary (`alMainBusPull`'s `AL_MAIN_L_OUT` + clear is the first opcode of every frame). Audio still 99 % non-silent and + unchanged — no audio state carries across frames in DMEM. +- **Reverb feedback self-oscillating** (plausible because `aPoleFilterImpl`, + the damping in the feedback path, is an unimplemented no-op — D199). + `GE_NOWET` mutes the reverb send entirely: signal unchanged. +- **The `AL_SNDP_PLAY_SFX_EVT` retrigger machinery.** 0 retrigger posts in the + user's trace. (Also independently confirms D204's closure of the M-63 + `soundIndex=109` anomaly: measured here as 184 plays, **owned 184 / orphaned + 0** — a properly-owned repeated sound, not a retrigger storm.) +- **Doors oscillating.** ~4 open/close cycles per door per 110 s across 4 doors; + 203 played ~once per door state change. Normal, not thrashing. +- **Doors orphaning their loop because both slots are busy.** 0 occurrences + (`[DOORSND] ... ORPHAN-both-slots-busy` never fired). The orphan is real but + arises differently: the call site passes `NULL` by design. +- **`ALKeyMap` conversion.** `GE_KEYMAPDUMP` dumps all 220 source keymaps from + the ROM-layout blob; runtime values match, including entries that are + legitimately all-zero in the fields Rare repurposed. +- **`DoorRecord` 64-bit overlap.** `sizeof(DoorRecord)=0x128`, observed + allocation stride exactly `0x128`; `openSoundState@0x110`, + `closeSoundState@0x118`. No overlap. +- **The `ALSoundState`-pointer-to-pointer pun** in `sndPlaySfx` + (`pendingState->link.next = nextState`, relied on by `doorPlayOpenSound0/1` + and `audioPlayFromProp`). Survives the 64-bit transition — `ALLink.next` is + at offset 0 and pointer-sized on both. New `[SLOTWRITE]` probe confirms the + slot receives the state. +- **`ALWaveTable::len` drifting at runtime** (observed 6939 -> 6940 etc.). + Benign: `alLoadParam` rounds `len` down to a whole number of 9-byte ADPCM + frames on first bind, so the pre-round value is simply seen once. + +#### Corrections banked (mistakes made this session — do not repeat) + +- **`romdataFixupMusicSeqTable: seqCount 63 exceeds blob capacity 1` is + BENIGN**, not a music failure. The function is called twice and the first + call deliberately passes a 16-byte header slice (capacity `(16-4)/8 = 1`); + the full-size second call succeeds. Music loads and plays. +- **A continuous ~1400 RMS "drone" in `GE_AUDIODUMP` captures is the + background music, not a stuck sound.** It was briefly mistaken for the bug + because only *SFX* events carry `dumppos` timestamps, so music looks like + sound with nothing playing it. Confirmed music by pointer range: the heavily + rebound wavetables (`706cbaf8`, `706ccfe8`) lie outside the SFX bank's + `706c3e70`-`706ca430` range and never appear in any `sndPlaySfx`; they are + driven by `csplayer.c:535` (`alSynStartVoiceParams`). The ~28 k + `AL_FILTER_SET_WAVETABLE` binds per 60 s are ordinary note-ons, and + `f->sample` resetting per note is correct. +- **Multiple `audiotrace.log` files exist** (repo root vs `build-pc/`) and a + shell whose cwd shifts between commands will silently analyse the wrong one. + Always use an absolute path, and check `ls -la` timestamps before trusting a + log's provenance. + +#### Diagnostics added (all `#ifdef PORT` / env-gated; remove when D202 closes) + +| env var | file | what it prints | +|---|---|---| +| `GE_AUDIOTRACE` | `src/snd.c` | `[ENVELOPE]`, `[WAVELOOP]`, `[VOICES]`, `[VOICE+]`/`[VOICE-]`, `[SLOTWRITE]`, `[RETRIGGER-POST]`, keymap fields on every `sndPlaySfx` | +| `GE_AUDIOTRACE` | `src/game/propobj.c` | `[DOORSND]` — every door sound state change, both slots, and an `ORPHAN-both-slots-busy` verdict | +| `GE_KEYMAPDUMP` | `port/src/romdata.c` | `KEYMAP` + `ENVSRC` — source ROM bytes of every `ALKeyMap` / `ALEnvelope` | +| `GE_BANKDUMP` | `port/src/romdata.c` | `BANKSRC` — the source bank tree per `soundIndex` | +| `GE_PULLTRACE` | `src/libultra/audio/load.c` | `[PASTEND]` — asserts no voice decodes past its wave | +| `GE_DMEMWIPE` | `port/src/mixer.c` | zeroes software DMEM at the frame boundary | +| `GE_NOWET` | `port/src/mixer.c` | mutes the reverb send | + +**Caution:** the `[VOICE+]`/`[VOICE-]` probes are written from both the game +thread and `amMain` to one unbuffered `FILE*`, so occasional lines are torn. +Parse defensively (filter to well-formed hex) or add a lock before relying on +exact counts. + +#### Next step + +Needs a user capture at the failing door (computer-room double doors, or a +level start whose attract intro shows them opening) with **both** +`GE_AUDIOTRACE=1` and `GE_AUDIODUMP=1`. The dump allows isolating the stuck +sound numerically and separating it from music without listening, and +`dumppos` pins it to the exact door event. + +**Status at handoff (M-65): D202 PARTIALLY resolved.** The "eventual silence" +cascade is root-caused and mitigated (measured); the missing distance +attenuation is fixed; the audible stuck loop is still not reproduced. + +### M-66 — the audible stuck door loop IS root-caused: it is faithful N64 behaviour (an original-game quirk), not a port bug + +The user capture M-65 asked for arrived (`build-pc/audiodump.raw`, 31,905,536 B += 361.7 s stereo s16 @22050; `build-pc/audiotrace.log`, same run, `-level_09` +level start whose attract intro shows the computer-room double doors). The +stuck loop is **sound 203 (`METAL_SLIDE_CLOSE_SFX`) doing exactly what the ROM +tells it to do**, and every link of the chain is ground truth: + +1. **ROM data:** wavetable loop = `(start=2471, end=7719, count=-1)`; + `src/libultra/audio/load.c` implements `count` with the explicit comment + "-1 is loop forever" (decrements only when > 0). Envelope: + `attack=0 decay=-1 release=2000 aVol=127 dVol=127`. +2. **Game code (byte-matched decompilation):** `doorPlayCloseSound0/1` + (`src/game/propobj.c`) plays 203 fire-and-forget with a `NULL` owner state. + The `[SLOTWRITE]` probe confirms the close-sound state is **never stored in + any door slot**, so the door's stop-sounds path can never reach it, and no + other code calls `sndDeactivate` on it. Nothing in the game will ever stop + it except level exit. +3. **sndp (ground truth):** the preemption scan (`src/snd.c` ~line 321) skips + any voice with flag `0x12` (`SOUND_FLAG_LOOPED|SOUND_FLAG_RETRIGGER`). + `AL_SNDP_DECAY_EVT` schedules the release→stop envelope "except for a looped + sound" (`src/snd.c:509`) — a looped voice gets no stop event at all. + With `decayTime=-1`, `sndCreateSound` sets `state->priority = 0x41` + (vs `0x40` normal), and libultra's `_allocatePVoice` + (`src/libultra/audio/synallocvoice.c`) steals only voices with + `priority <= new voice priority` — so a 0x41 leaked loop is unstealable by + any ordinary 0x40 SFX or music note. +4. **Pool geometry:** GE's soft limit is 8 (`maxSounds = + MUSIC_SFX_SEQ_MAYBE_MAX_SOUNDS`, `src/music.c:76`); the physical PVoice pool + is 24 (`MUSIC_SYN_CONFIG_MAX_P_VOICES = 0x18`, `src/music.c:67`). Leaked + loops therefore don't deadlock the synth, but they do eat GE's 8-slot soft + limit — which is where M-65's guard bites. + +**Consequence (identical on N64):** every metal-door close leaks one looping +voice that persists until level exit. During attract idle no new sounds arrive, +so nothing ever evicts them and they play forever — exactly the user's report: +"the double door sound by the computer room still infinitely loops after they +begin playing". Every code path in this chain is shared original source +(`snd.c`, `propobj.c`, libultra audio); none of it is port-specific. **This is +an original-game quirk, faithfully reproduced — rule #2 says leave it.** + +#### The capture, timeline (corrected) + +`dumppos` is the dump-file byte offset; time = `dumppos/88200` s. + +- t=0–21 s: level intro — heavy SFX, door cycles on five doors (…7d2c, …7c04, + …79b4, …7adc, …80a4). 203 plays at **1.3 / 3.3 / 11.3 / 17.2 / 21.0 s**. +- t≈39.1 s: door …80a4 closes → 203 (state …5020). Then a long quiet stretch: + the same door's next close comes at **t≈360.7 s** (another 203, state + …4f50, still looping when the capture ends at 361.7 s). The attract demo + keeps running the whole time — it just gets sparse. +- Leaked 203 voices alive by t≈40 s: four (states …4db0 from 1.3 s, …50f0 from + 17.2 s, …4e80 from 21.0 s, …5020 from 39.1 s). Two earlier ones WERE + reclaimed — but only because M-65's guard fired under pool pressure: + `VOICE- snd8e48/st4e18` at t=14.8 s and `st4fb8` at t=16.4 s, each in the + same block as a new-sound play (the preemption path). Without the guard both + would persist and the 8-slot limit would fill by ~t=21 s. +- Steady state from t≈39 s: RMS ≈1560, strong periodicity at **327.0±0.1 ms**. + +#### The 327 ms mystery (solved — it was the pitch) + +The loop segment is 5248 samples; at the bank rate (22050) that is 238 ms, not +327. But the voice does **not** play at unity pitch: keymap `keyBase=54`, +`detune=50`, and `DEFAULT_SETUP_PITCH_SHIFT = -0x1770 = -6000` (`src/snd.c:20`) +give `alCents2Ratio(54*100 + 50 - 6000) = alCents2Ratio(-550)` = **0.72783**. +Loop period at playback = 5248 / (22050 × 0.72783) = **327.007 ms** — matching +the observed 327.0±0.1 ms exactly. The spectral fingerprint agrees: FFT peaks +of the decoded loop segment at 355.3 / 371.4 / 694.4 / 710.6 Hz × 0.7278 → +259 / 271 / 506 / 518 Hz vs observed steady-state lines 256.7 / 272 / 504.7 / +513.8 Hz. Cross-correlating the **pitch-stretched** loop template against the +t=100 s window gives r = 0.835 (the unpitched template gave ~0.10 — which is +why every earlier template test "failed"; see corrections below). + +#### Corrections to M-65 (banked) + +- **M-65's correction "the continuous ~1400 RMS drone in captures is the + background music, not a stuck sound" is wrong (or at best incomplete).** The + steady-state signal's spectral lines and 327 ms period are the leaked 203 + loop, pitch-shifted as above; r=0.835 template match. Music is present in the + mix too (M-65's pointer-range argument that *those particular* rebound + wavetables are music-driven still stands) — the steady state is a composite, + but the *stuck* component is 203, and it dominates the fingerprint. +- **"The audible stuck loop is still not reproduced"** — it is now: user + capture + complete mechanism, every link grounded in ROM data / byte-matched + code / libultra source. +- The earlier "full library correlation: best 0.28, sfx203 only 0.10" was an + artifact of correlating **unpitched** decodes against pitched playback. Do + not re-run template tests without applying `keyBase/detune/shift` → ratio. +- M-65's guard comment "only 8 voices exist" is imprecise: there are 24 + PVoice plus the 8-slot GE soft limit; the binding constraint for SFX is the + soft limit, and the guard operates on exactly that boundary. + +#### Guard status and decision pending (user) + +M-65's ownerless-loop preemption guard (`src/snd.c` ~line 325) fired twice in +this capture and demonstrably prevents pool exhaustion — but it can only fire +when a *new* sound arrives and the limit is hit. During attract idle nothing +arrives, so the audible loop persists with or without the guard. Options: + +- **A) Pure fidelity:** remove the guard; document D202 as "faithful + reproduction of an original quirk". Consequence (also faithful): after 8 + leaked loops in one level session, new SFX are silently dropped until level + exit — that is what N64 does. +- **B) Keep the guard (status quo, recommended):** a small, precisely scoped, + documented port-side deviation. Prevents the SFX-deadlock; the idle loop + remains audible exactly as on N64. +- **C) Extended deviation:** cap/expire ownerless loops in the port layer to + silence the idle loop. Fixes the user's complaint but is a larger departure + from ground truth than B. + +Diagnostics: with the root cause established, the M-65/M-66 probe set +(`GE_AUDIOTRACE` lines, `[DOORSND]`, `[SLOTWRITE]`, `GE_PULLTRACE`, +`GE_DMEMWIPE`, `GE_NOWET`, `GE_BANKDUMP`, `GE_KEYMAPDUMP`) may be removed once +the A/B/C decision lands and D202 closes. + +**Status (M-66): D202 root cause ESTABLISHED — the audible stuck door loop is +sound 203 behaving exactly as the ROM + ground-truth code specify; faithful +N64 behaviour, original-game quirk. Disposition: user chose C (M-66b below).** + +### M-66b — disposition C implemented and measured: ownerless infinite-loop SFX now expire (fade out) on PC + +The user chose **C** (extended deviation) as part of the PC port's audio +implementation. M-65's guard stays (slot reclamation under pool pressure); +this adds the audibility fix. + +**Placement — why `src/snd.c` and not `port/src/mixer.c`.** The predicate that +distinguishes a *leaked* loop from a legitimate one is **ownership** +(`soundState->state == NULL`), which only sndp sees. The mixer iterates +ALVoice/PVoices with no ownership field; scoping there would force either +hardcoding region-fragile SFX-bank DRAM pointer ranges or capping *all* +infinite-loop voices (music included). snd.c already carries the two documented +D202 port-side deviations (M-65 guard, D138 un-stub), all `#ifdef PORT` — every +D202 port-side change stays in one auditable place. N64 build: zero diff. + +**Mechanism.** New PC-only event `AL_SNDP_PORT_EXPIRE_EVT` (reuses the +`AL_SNDP_UNUSED_13_EVT` = 1<<13 slot, defined under `#ifdef PORT` in +`src/snd.h`; no shared-enum change). In PLAY_EVT, immediately after a voice +starts, if the state is LOOPED + FINAL_IN_SEQUENCE + not RETRIGGER + +`state->state == NULL` **and** the wave carries an ADPCM loop with `count == -1` +(finite loops self-terminate in load.c and must not be cut short), post the +expire event at `D202_EXPIRE_DELAY_US` (2 s). Handler: re-validate the full +predicate at fire time, then ramp volume to 0 over `D202_EXPIRE_FADE_US` +(500 ms) and post END_EVT — the stock fade/teardown path. Constants are in +**ALMicroTime = microseconds** (the event queue is µs-driven: `DELTA_1_MS` is +1000, `DELTA_33_MS` is 33333). + +- **Not a reused STOP_EVT:** the stock STOP ramp uses the envelope's + releaseTime — ~2.7 ms for sound 203 (release 2000 / pitch ratio 0.7278) — + which would hard-cut the loop (click). The dedicated event gives a clean + 500 ms fade. +- **Stale-event safety:** `sndDisposeSound` already removes *all* pending + events for a state (`sndRemoveEvents(..., 0xffff)`), and any rebind disposes + first — so an expire can never fire against a new binding. The fire-time + predicate re-check is belt-and-braces (also no-ops if the state was rebound + to the *same* sound). +- **Sequence-walk safety:** the type is added to the `isEventForSingleSound` + mask under `#ifdef PORT`, and the post-site requires FINAL_IN_SEQUENCE, so + the handler's do-while can never walk into an unrelated alloc-list neighbor. +- **Owned loops are untouched** (`state != NULL` fails the predicate) — they + behave exactly as on N64. Retrigger sounds (managed by PLAY_SFX/DEACTIVATE + chains) are excluded. + +**Files:** `src/snd.h` (PORT-only event define), `src/snd.c` (two constants, +post-site in PLAY_EVT, handler case, mask token, guard-comment cross-ref). +New `[EXPIRE]` line under `GE_AUDIOTRACE` (see GE-ENV-PROBES.md). + +**Measured.** 134 s headless `-level_09` run with `GE_AUDIOTRACE=1 +GE_AUDIODUMP=1`. Trace: every sound-203 play posts `[EXPIRE]`; each leaked +voice gets its VOICE- ~2 s after its VOICE+; run ends at +`[VOICES] allocated=0 / max=8` (no survivors). Spectral check (per-1-s +Goertzel at 256.7 Hz, the strongest 203-loop line × 0.7278): + +| capture | 256.7 Hz line | +|---|---| +| user pre-fix (361 s) | **flat at max from t≈39 s to end** — the complaint, verbatim | +| post-fix (134 s) | transient ~2–4 s bursts per door close, **full silence between** (gaps at t=60–62, 85–87, 94–98, 110–112, 128–131) | + +The stuck infinite loop is gone; each close now rings ~2 s + 0.5 s fade, as a +door clunk naturally would. Scripts + archived user capture: +`scratchpad/d202-m66/` (`check_dump.py`, `line_track.py`, +`user-capture-audiodump.raw`, `user-capture-audiotrace.log`). + +**Remaining:** user by-ear pass on the new binary (window is open — probes are +still in). On sign-off: remove the D202 probe set (`GE_AUDIOTRACE` additions, +`[DOORSND]`, `[SLOTWRITE]`, `GE_PULLTRACE`, `GE_DMEMWIPE`, `GE_NOWET`, +`GE_BANKDUMP`, `GE_KEYMAPDUMP`) and close D202. + +**Status (M-66b): disposition C IMPLEMENTED + measured — ownerless +infinite-loop SFX fade out ~2.5 s after starting on PC; faithful N64 behaviour +otherwise. Awaiting user by-ear verification, then probe removal + close.** + +--- + +### M-67 — Static analysis exhausted: full-bank data scan clean, DSP audit complete, reference clip REFUTED as an in-game capture; new `GE_VOICEDUMP` per-voice probe added for the decisive runtime A/B (this session) + +**1. Full-bank SFX data scan — all 261 sounds clean.** +`scratchpad/sfx_bank_scan.py` walks every sound's wavetable/book/data in the +ROM: no out-of-range ADPCM table indices, all books valid (order=2, npred 1–4), +all data within segment bounds, all type-0. The old decoder's silent clamp +(`if tableIndex >= npred: tableIndex = 0`) had been masking this check — with +it removed as a hard error, the whole bank passes. No stale/corrupt-table +hypothesis is possible; the data side of "wrong sample" is closed. + +**2. DSP audit complete (vs PD ground truth).** ADPCM scalar decode +(nibble→ins, predictor loop, clamp), the resample algorithm + 256-entry table, +the envelope mixer, and the pitch path (`alCents2Ratio` pure/correct; keymap +formula `keyBase*100 + detune - 6000`, snd.c:803/807) all match the PD port +line-for-line. The `A_LOOP` (flags&2) branch in `aResampleImpl` is **dead code +in GE**: the resampler filter's `first` is only ever 0 or 1 (load.c:461/530); +A_LOOP(2) is never passed to aResample. Not a bug — closed. + +**3. The reference clip is refuted as an in-game capture.** +`scratchpad/ref_match_all.py`: `B00I00S2D.wav` does NOT waveform-match any of +the 261 SFX decodes (best single CC = 0.19 ≈ noise; index 45's waveform CC is +0.048), and `ref_match_mix.py` shows no PPK mixture (46+122+ricochet) matches +either (best 0.09). M-61's "+0.999 with index 45" was purely an +**envelope-correlation artifact** (two percussive sounds with similar decay +envelopes). The "N64 plays index 45 for the PPK" premise is dead; the clip's +provenance is unknown and must be asked of the user. + +**4. M-63 final-mix cross-check was inconclusive, not exonerating.** +`scratchpad/m63_crosscheck.py`: door events (dumppos 23296/54976, index 203) +show CC≈0 vs offline decode, but those segments are dominated by music +(broadband RMS 2000–8000, no SFX periodicity). CC≈0 in a music-masked final +mix proves nothing about which sample the voice played. A per-voice dump (item +5) or a music-off capture is required. + +**5. New probe: `GE_VOICEDUMP=1`** (`port/src/mixer.c`, temporary, D202 diag). +Writes each voice's resampled mono stream — the `aEnvMixer` input, i.e. +post-resample/pre-envelope, per-voice, no music/reverb masking — to +`voicedump.raw` as records `[u32 stateAddr][u32 nSamples][u64 us][s16 × n]`. +The `[WIRE]` lines (load.c `alLoadParam(SET_WAVETABLE)`) and the first +`[AUDIOTRACE] sndPlaySfx` line now also carry `t=<µs>` (sysGetMicroseconds) +so all three streams correlate in time. Offline matcher: +`scratchpad/voicedump_match.py` (each record vs all 261 pitch-resampled +decodes). Self-test: `scratchpad/d202-m67/` (25 s `-level_09`, 5.7 MB dump, +clean parse, records match their sources once the item-6 direction fix is +applied). + +**6. Two tooling bugs found in the self-test (my scripts, not the game):** + +- **Resample direction was inverted in every offline model so far.** The + hardware playhead advances `pitch/16384 = ratio` input samples per output + sample (`aResampleImpl`: `pitchAccumulator += pitch<<1; in += accum>>16`), + i.e. `y[k] = x[ratio·k]` — ratio < 1 plays SLOWER and lower. The correct + offline model is `resample_linear(native, RATE*ratio, RATE)`, not + `(native, RATE, RATE*ratio)` as used in M-63's cross-check and the first + matcher pass. **All prior CC results at pitch ≠ 1 are suspect** (the M-63 + door check doubly so). +- **WIRE bases are absolute; bank offsets are segment-relative.** The + in-memory wavetables hold `base = SFXTBL_START(0x102F19A0) + bank offset`; + matching WIRE entries against raw bank offsets finds nothing. + +**7. GE's "music" is an SFX-sequence system with its own sample set.** +`sndNewPlayerInit(ALSeqpSfxConfig*)` (snd.c:168; config in src/music.c:823, +`MUSIC_SFX_SEQ_MAYBE_MAX_SOUNDS = 8`): music notes are sequenced SFX played +through the same 8-voice alSynth pool. The WIRE log shows ~8 music samples +whose bases sit at ROM 0x103ed1e8–0x10407860 — 240–350 KB **past** the last +SFX-bank sample — with wavetable structs allocated contiguously after the SFX +bank's. Music notes do not pass through `sndPlaySfx`, so they are invisible to +the `[AUDIOTRACE]` request probe: persistent voices in a voicedump are usually +music, and their "best match" against the 261-SFX bank is coincidental +(music samples reuse field recordings; free-ratio sweep found e.g. CC 0.92 vs +index 122 at transposed pitch). Any per-voice analysis must subtract music +first (or use the timestamped WIRE assignments to identify each voice's sample). + +**8. Open anomaly parked for the next session: sound 232 (PICKUP_GUN_SFX) +requested at `-level_09` start produced no audible audio.** +Self-test trace: `sndPlaySfx(232)` at dumppos=0 → VOICE+ (count 1/8) → +VOICE- within ~1 frame; the WIRE-assigned filter was recycled to music +immediately; no env state in the dump matches decode(232) (max CC 0.48 ≈ +noise, even with the corrected resample direction). Either the level script +stops it instantly (faithful N64 behaviour — needs an N64 A/B to confirm) +or something disposes it early on PC. Not chased this session. + +**Status (M-67): static analysis has hit diminishing returns — every pipeline +stage (index resolution, bank data integrity, ADPCM, resample, envelope, +pitch, loop mechanism) is verified against ROM + PD ground truth, and the +reference clip is refuted. The remaining questions (PPK/door/armor "wrong +sound" by ear) require a user runtime A/B capture with the new +`GE_VOICEDUMP` probe: `GE_AUDIOTRACE=1 GE_AUDIODUMP=1 GE_VOICEDUMP=1`, +reproduce each scenario, send `audiotrace.log` + `audiotrace_wire.log` + +`voicedump.raw`. Also owed: provenance of `B00I00S2D.wav`, and the item-8 +sound-232 anomaly.** + +### M-68 — Decisive runtime A/B (user captures, 3× level_09): all three complaint classes play the exact ROM samples at the exact ROM pitches; no port bug found (this session) + +Three interactive user captures (`audiodebug.ps1 -Play -Trace -Dump +-VoiceDump`), analysed with full-slide correlation of `voicedump.raw` +per-voice records against `decode_sound(idx)` resampled at the keymap ratio +(correct direction, M-67 item 7: `y[k] = x[ratio·k]`). A new `[EVT] t=µs +type=N state=P` probe (PORT-gated, `sndHandleEvent` top) plus a +`[STOP-EVT] … deltaUs=N` probe (release-ramp site) were added this session; +event types decode per `src/snd.h`: 1=PLAY, 2=STOP, 8=VOL, 64=DECAY, +128=END. + +**1. Doors — correct.** `METAL_SLIDE_OPEN` 202: CC **0.957** @ ratio 0.500; +`METAL_SLIDE_CLOSE` 203: CC **0.906** @ 0.728; `METAL_SLIDE_LOOP` 204: CC +**0.993** @ 0.728. Event flow is textbook: PLAY → DECAY (attack=0, fires +immediately) → self-stop at the decay-scheduled STOP (204: +154 ms = +112197 µs ÷ 0.7278; 202: ~450 ms) → release ramp (`[STOP-EVT] deltaUs` = +releaseTime ÷ ratio, e.g. 1373/3999 µs) → END → dispose. + +**2. PPK shot — correct.** `GUN_SILPPK_A` 46: CC **0.90–0.97** @ ratio 0.728 +across four separate shots (full-slide, any offset). Combined with M-59 +(46 + 122 CART_SPENT + a ricochet is exactly what the ROM scripts for a PPK +hit), the user's "gunshot plays broken glass/slap/ricochet" is the original +design, reproduced faithfully. + +**3. Body armor pickup — correct, full length.** `ARMOUR_COLLECT` 81: +voice lifetime measured **833 ms** from `[EVT]` timestamps (PLAY → decay- +scheduled STOP), against ROM prediction 469649 µs ÷ 0.5612 = **837 ms**; +release ramp 3563 µs; CC **0.828** vs decode(81) @ ratio (window contains +other concurrent voices, hence <0.95). + +**The M-67-era "instant death" metric was a line-count artifact.** Log lines +≠ time: a voice stopped by the door script's `stop-sounds` +(`door7F053B10`, `src/game/propobj.c` — stock logic, identical on N64) when +the player shuts a door quickly produces only a few log lines between birth +and death even when it plays its full ~20 ms. With µs timestamps the STOPs +land exactly at their scheduled times; no voice is disposed earlier than its +decay/stop schedule in any of the three captures. + +**13 `[EXPIRE]` events in the final capture:** the M-66b guard expired 13 +ownerless `METAL_SLIDE_CLOSE_SFX` (203) loops, each at exactly 2.5 s (2 s +delay + 0.5 s fade). This remains the single outstanding PORT deviation; +whether the N64 lets that loop run until level exit is the open by-ear A/B +(see M-66b). + +**Status (M-68): D202's "wrong sample" hypothesis is closed negative for all +three user complaint classes — PC plays exactly what the ROM specifies, +verified per-voice at runtime. Remaining: (a) N64 A/B on door-close-loop +duration to confirm or retract the M-66b cap; (b) the parked sound-232 +anomaly (M-67 item 8); (c) once the user confirms by ear, remove the D202 +probe set (`GE_AUDIOTRACE`/`GE_AUDIODUMP`/`GE_VOICEDUMP` probes in +`src/snd.c`, `src/libultra/audio/load.c`, `port/src/mixer.c`, +`src/game/propobj.c`) and decide the M-66b guard's fate.** + +### M-69 — Full-corpus exact match (scripted `-level_09`, 320 SFX requests): 286/320 OK after tooling fixes; the remaining failures are *dropped allocations*, not corruption (this session) + +New offline tool `scratchpad/exact_match.py` (gitignored scratchpad; helpers +`rom_sfx_decode.py`, `m63_crosscheck.py`) matches **every** `sndPlaySfx` +request in a full trace to its voice and slides the ROM-decoded PCM +(resampled at the keymap ratio, M-67 item 7 direction) against that voice's +`GE_VOICEDUMP` stream. Corpus: `scratchpad/exact_run/` (scripted `-level_09`, +61 input entries, `GE_AUDIOTRACE` + WIRE + `GE_VOICEDUMP`) — **320 requests**. +Chain: request → its own `[EVT] type=1` PLAY (states are recycled across +requests, so the wire window must anchor on *that* PLAY) → WIRE record with +the same wavetable in [play−10 µs, play+50 ms] → decoder index → env-state +slot address → voicedump records for that slot. + +**Result: 286/320 OK (CC ≥ 0.85).** The initial 241 OK / 35 MID / 44 BAD split +was almost entirely tooling artifacts, not audio: + +1. **`GE_VOICEDUMP` records are rendered ahead — the µs stamp is the END of + the block.** Each record's n samples end at its stamp, spaced 1/22050 s. + Treating stamps as start offsets misplaces content by up to one block and + produced a phantom "stale content" in pv20's slot (sound 29 looked ~180 ms + early). With per-sample record times, sound 29's onset lands at **+263.8 ms + vs its WIRE timestamp +264 ms — exact**. Cross-slot correlation confirmed + the dump attribution was never wrong (max CC vs neighbouring slots ≤ 0.076; + slot content is unique per voice). +2. **`slide()` needed step-1 refinement around the coarse best hit.** Coarse + stepping misses sharp onsets: sound 29 measured CC 0.590 at the coarse + argmax and **0.970 one sample later**. (Also fixed a bug: the best-offset + tracker was only updated in the last coarse loop, so refinement searched + around offset 0.) +3. **Clip the reference at voice death.** Requests whose voice is cut by a + DEACTIVATE/STOP after PLAY (e.g. idx=109, killed at +131 ms) cannot match + the full ROM decode (CC 0.016); clipping the ref to the live window gives + CC **0.999** at offset +0 ms. The matcher now parses `[EVT]` per state and + clips accordingly. + +**The remaining ~34 failures are not corruption:** +- **"NO WIRE AT PLAY" / DROPPED (no PLAY evt):** the request was processed but + `alSynAllocVoice` never gave it a voice (no `[VOICE+]`, no DECAY) — e.g. + t=567204976 idx=46. Leading suspect: the 8-voice pool is exhausted under + scripted rapid fire and low-priority sounds are dropped by design (`snd.c` + threads `state->priority` into `config.priority`; `alSynAllocVoice`'s steal + path has a ~512-sample delay). **Not yet verified — the next step.** +- **pv10 mismatches:** consecutive requests share wavetables, so the wire + window can pick up the *next* request's wire and attribute the wrong slot. + Tighten: require the WIRE to fall between this PLAY and the next PLAY on the + same wavetable. +- Log-interleaving gotcha: AUDIOTRACE lines from multiple file handles + interleave and corrupt some lines (a missing/corrupt PLAY line sends the + matcher to its wide-window fallback). + +**Status (M-69):** every request that *got* a voice plays the exact ROM sample +at the exact ROM pitch — this extends M-68's per-class verification to the +whole corpus. D202 remains "no port bug found"; open items are M-68's plus: +(a) confirm the dropped requests are pool-exhaustion drops that N64 would drop +identically (check `[VOICES] allocated=N / max=8` around each drop; +`src/snd.c:916`, `alSynAllocVoice`); (b) fix the shared-wavetable wire +attribution; then M-68's by-ear + N64 A/B, probe removal, close. + +### M-70 — Decisive full-corpus exact match on a clean re-capture (209 requests, scripted `-level_09`): every voiced request plays the exact ROM sample; D202's wrong-sample hypothesis is refuted (this session) + +The prior 316-request corpus was contaminated mid-capture (pause menu hit), +so a fresh 75 s scripted capture was taken (`GE_AUDIOTRACE=1 GE_VOICEDUMP=1`, +`-level_09`, `scratchpad/exact_run3_script.txt` fire burst) and the matcher +(`scratchpad/exact_match.py`) was fixed in three places before the decisive run: + +1. **Death-clip both sides.** Clip *stream* as well as *ref* at the voice's +downstream death event (STOP/END/DEACTIVATE). M-69 only clipped `ref`; when a +slot is reused ~200–400 ms later, the unclipped 1.5 s stream window is mostly +the *next* occupant's samples. +2. **`slide()` sparse-tail bug (the big one).** The offset search was dense +only up to 9000 samples (~408 ms) and used step = span/40 beyond that. Delayed +voicing — `sndPlaySfx` queue wait, observed up to **+1.2 s** between request +and PLAY — pushes the onset into the sparse zone, where the narrow CC peak +falls between grid points: idx=134 (onset +532 ms) scored **CC 0.154** in the +matcher but **0.973** under a full-range step-4 scan of the identical data. + Fixed: step-1 near the window start, step-4 across the *full* span, ±8 + step-1 refine around the best hit. idx=136 went 0.285 → ≥0.85 the same way. +3. **PLAY search window 600 ms → 2 s**, catching two idx=175 instances voiced + at +1196 ms (their WIREs land with the PLAY, so pairing is unambiguous). + +**Result — 209 requests:** + +| bucket | count | disposition | +|---|---|---| +| OK (CC ≥ 0.85) | **200** | exact sample at exact keymap pitch | +| MID (0.6–0.85) | 4 | all idx=109, CC 0.744–0.811 — see below | +| NO WIRE AT PLAY | 5 | 1 matcher artifact + 4 never voiced — see below | + +- **The 4 MID (idx=109):** manual verification on the tight `[play, death]` + window with a ratio sweep gives **CC 0.946 at the nominal keymap ratio** + (0.7236 = keyBase 54, detune +40, −6000 shift) — correct sample *and* + pitch. The residual is measurement, not audio: the voicedump records are + stamped with **wall-clock time at mix time**, and the scheduler's catch-up + delivers them in ~32 ms bursts of ≤160-sample records with clustered + stamps, so bisect windowing on those stamps is fuzzy by tens of ms — and + these voices die young (killed at +67…+131 ms), leaving little clean + signal to average over. +- **NO WIRE ×5:** idx=109@610342131 was in fact voiced — its WIRE lands at + **+133 ms** after PLAY, outside the matcher's 50 ms wire window (all other + idx=109 instances match fine). The other four (idx 8/40/123/202) show no + voicing evidence anywhere: no `VOICE+` on their `newState` after t0, and no + WIRE for their wavetable within 2–3 s *in any state*. **Not pool + exhaustion** — `[VOICES] allocated` was 0–5/8 at each drop (run max 7/8). + Mechanism unverified; consistent with game-logic queue/priority timing in + code identical to the N64 build, and orthogonal to D202 (no samples were + played, so nothing could be wrong). + +**Verdict:** across 204 voiced requests spanning ~30 distinct sound indices, +**zero** play a wrong sample. Combined with M-68 (per-class runtime A/B) and +M-66 (stuck door loop = faithful N64 quirk), the D202 "wrong sample / mixer +corruption" hypothesis is refuted: what the user heard is the original design +(overlapping SFX, short decays, quick-stop truncation, the ownerless infinite +door loop). + +**Status (M-70):** D202 data-closed. Remaining before final close + probe +removal: user by-ear A/B on the M-66b expiration behaviour (does the stuck +drone now fade out ~2.5 s after a door close, vs N64's forever-drone). Probes +(`GE_AUDIOTRACE`, `GE_VOICEDUMP`, `[WIRE]`, `[EVT]`, `[VOICE±]`, `[SLOTWRITE]`, +`[ENVELOPE]`, `[VOICES]`) stay in place until that pass is done; they are +`#ifdef PORT`-gated and env-var-off by default. + +### M-71 — User by-ear pass on M-66b: PASS. D202 ready to close. New complaint class split out as D205 (this session) + +**By-ear verdict (user, PC vs N64 side-by-side):** "the metal door sound +does not loop forever now" — the M-66b ownerless-infinite-loop expiration +behaves as intended; Disposition C validated. That was the last gate on +D202: probe removal + close is all that remains (see HANDOFF). + +**New user complaint class (NOT D202 — split out to D205).** With M-70's +per-voice exact-match result standing (every voiced request plays the exact +ROM sample), the user reports by ear, and confirms against an N64 A/B that +the PC is *not* faithful: (1) general gunfire has a slap/glass-shatter +layered over the correct gunshot — audible on PC, silent on N64 for the +same shot; (2) an explosion plays a scream-like sample (the soldier-death +voice); (3) armor pickup plays a wrong sample. This session exonerated the +two remaining static suspects: the D37 bank converter offline (`scratchpad/ +banktest/`: real `romdataFixupAudioBank` harness + `diff_bank.py` → all 261 +entries map identically N64→PC, 0 mismatches) and the D154/D135 GBI raycast +parsers + volume law (static re-audit, ABI-correct). The remaining +explanation is that PC **requests different or extra sound indices** than +N64 in the same scenario. See D205. + +### M-72 — Fourth symptom reported (unarmed melee → Klobb shot idx 106); controlled `GE_AUDIOTRACE` runs exonerate every request path; mechanism (b) now the leading suspect (this session) + +User added a fourth D205 symptom: Bond's unarmed slap attack plays +Klobb's shooting sound (`GUN_B1_MGUN3_3_SFX`, idx 106) instead of the slap +effect. Also fixed the test environment: the "flaky /GS crash" of earlier +attempts was `SDL2.dll` missing from PATH (it lives in +`C:\msys64\mingw64\bin`; the exe is `-mwindows`, so a failed DLL load just +dies with 0xC0000105 and no console). Launchers now prepend it. + +Controlled traces (`build-pc/d205_*.ps1`, gitignored): PPK fire at +`-level_09` requests exactly {46, one of the 20-entry ricochet table, +122} per shot — no glass/slap layer; unarmed fire-presses at levels 01–08 +(all start unarmed) request only 105 PUNCHING_AIR. Static: punch hit = +constant {47,48,49}, idx 106 is data-only (`skorpion_stats.Sound`), and the +player fire-sound path cannot run for a fist — so Bond's own punch cannot +request 106; an NPC guard firing its Skorpion can. No symptom reproduces in +micro-scenarios → the layering must come from **extra events during real +gameplay** (mechanism b). Next: user `GE_AUDIOTRACE=1` capture at the exact +failing moment (splits per-symptom), then controlled explosion + armor. +Full detail in D205. + +## D207 — **Alarm klaxon starves combat SFX (surfaced by D206)** (M-77, root-caused, fix designed & not applied) + +### How D206 caused it + +`ALARM3_SFX` (SFX_ID 163) is played by `handle_alarm_gas_timer_calldamage()` +(`src/game/propobj.c:14457`) whenever `alarmIsActive()`, owner `&ptr_alarm_sfx`. + +- **Pre-D206:** resolved to on-disk bank slot **163** — a **one-shot** sample + (`decayTime` finite, no ADPCM loop). Played once, released its voice. The + alarm was audibly the *wrong* sound but cost the voice pool nothing. +- **Post-D206:** correctly resolves to slot **162** — the real klaxon: + `envelope.decayTime == -1` (→ `SOUND_FLAG_LOOPED`, `sndSetupSound` + `snd.c:~836`), wavetable `adpcmWave.loop.count == -1` (infinite ADPCM loop). + It now holds a voice for the whole time the alarm is up. + +### Why it dominates + +The SFX player has **8 voices** (`MUSIC_SFX_SEQ_MAYBE_MAX_SOUNDS = 8`, +`src/music.c:76`; the synth has 24 physical voices but the SFX soft cap is 8 +and is what `snd.c:328` gates on). + +`sndSetupSound` sets `state->priority = (decayTime == -1) + 0x40` → the alarm +gets **0x41**, one-shots get **0x40**. + +When `g_sndAllocatedVoicesCount == 8`, `snd.c` PLAY_EVT does **not** call +`alSynAllocVoice` (which *would* do libultra priority-stealing); it runs the +manual preemption scan at `snd.c:~347-392`. That scan: +- refuses to steal any `unk3e & 0x12` voice (LOOPED | RETRIGGER) — the M-65 + carve-out only exempts *ownerless* loops (`state == NULL`), and the alarm + is **owned**; +- ignores priority entirely — it steals the first non-looped PLAYING + non-PREEMPT voice it finds; +- if it finds none → `sndDisposeSound(soundState)` → **the new SFX is + dropped** (`snd.c:391`). + +So an active alarm removes one of the 8 voices, and in a firefight (gunshots + +ricochets + impacts + enemy fire + ambient door/machine loops) the remaining +non-looped voices are all either already PREEMPT-marked from the same burst or +themselves looped → gunshots drop. + +### Measured (BUNKER1, `-level_09`) + +Temporary probe `GE_FORCEALARM=1` (added to `handle_alarm_gas_timer_calldamage`, +`#ifdef PORT` / `getenv`) pins `alarm_timer` so the klaxon stays up, plus a +scripted `GE_INPUTSCRIPT` firing Z every 8 reads: + +| condition | SFX requests | dropped (no voice) | +|---|---|---| +| no alarm | 52 | 1 (~2%) | +| alarm active | 137 | 28 (~20%) | + +Dropped indices were gunshot/impact/ricochet sounds (29, 30, 41, 90, 36, 109…). +`ptr_alarm_sfx` stayed stable while active and was cleared on +`alarmDeactivate()`; the alarm voice was freed and the pool recovered — the +**"doesn't recover after the alarm stops" half of the user report was NOT +reproduced** in any trace (light BUNKER1 combat; may need a heavier level, or +it is perceptual). + +### The 8-cap and the scan are faithful decomp + +`MUSIC_SFX_SEQ_MAYBE_MAX_SOUNDS = 8` is unmodified game code; the manual +preemption scan is stock `sndHandleEvent` (M-65 modified it in place). Upstream +GE has the same. So this is not purely a port regression — it is partly the +open **D202** voice-pool-margin problem (ambient infinite-loops holding voices +longer than they should on the port) becoming audible because the alarm now +legitimately consumes a slot. + +### Designed fix (PORT, not yet applied) + +In the `snd.c` preemption scan, add a **last-resort second pass**: after the +normal pass (non-looped victims + the M-65 ownerless-loop carve-out) finds +nothing and `limitReached` is still true, and **the incoming `soundState` is +itself not LOOPED**, run one more pass that will preempt a stoppable LOOPED, +non-RETRIGGER voice (the alarm klaxon; also the D202 door loop as a bonus). + +Self-healing: preempting the alarm posts `AL_SNDP_END_EVT` → `sndDisposeSound` +→ `sndUnlinkClearSound` clears `state->state->link.next`, i.e. sets +`ptr_alarm_sfx = 0`; the very next frame `handle_alarm_gas_timer_calldamage` +sees `ptr_alarm_sfx == 0` and re-issues `sndPlaySfx(ALARM3_SFX, &ptr_alarm_sfx)`, +which re-acquires a voice (or WAIT_VOICE-retries at 33 ms). Net effect: the +klaxon briefly yields to a gunfire burst and comes right back — gameplay audio +wins, which matches player expectation. + +Alternatives weighed and rejected for now: (a) raise `maxSounds` > 8 on the +port — game-code change, rule #2 risk, changes mix loudness; (b) properly fix +the D202 ambient-loop lifetimes — the correct fix but the hard open problem. + +### M-77b — designed fix tried, hypothesis not confirmed, reverted + +The last-resort LOOPED-preemption pass was implemented in the `snd.c` scan, +plus `[D207-DROP]` counters at both no-voice `sndDisposeSound` sites and a +`[D207-YIELD]` trace on the new pass. BUNKER1 test (`GE_FORCEALARM=1` + +`GE_INPUTSCRIPT` firing every ~10 controller reads for ~85 s, alarm cycled +on/off/on): + +- **`[D207-DROP]` = 0** — not a single SFX actually dropped. +- **`[D207-YIELD]` = 0** — the new pass never even ran; pass 1 always found a + non-looped voice to preempt. +- Per-frame voice count: **1** during quiet alarm, spikes to 3–5 under fire, + back to **0** after every alarm-off. No leak, no pegged pool. +- The earlier "≈20 % dropped" number was bogus: it counted requests whose + `[VOICE+]` came more than 40 log-lines later. Cross-checking "states that + never got a voice at all" = **0 / 136**. Non-looped sounds get 2 `unk38` + retries then the scan, which nearly always places them a frame or two late — + audible as slightly loose timing, not silence. + +So on BUNKER1 the alarm does **not** starve the pool. The change was reverted +(`snd.c` now carries only the D206 `-1`). Either BUNKER1 combat is too light to +reach 8/8, or the user's symptom is a different mechanism. + +### Owed next session — needs a real repro from the user + +The BUNKER1 forced-alarm harness cannot reproduce this. Get from the user: +**which level, which weapon/situation**, and a `GE_AUDIOTRACE=1` capture (or a +short clip) of the failing alarm. Then decide between: + +1. **Genuine 8/8 starvation on a heavy level** — Facility / Silo / Statue + firefights have many more guards firing. Soak one with `GE_FORCEALARM` + + sustained scripted fire (or the user's own capture) and watch for + `[D207-DROP]` > 0 / `count=8` pinned. If real, the reverted last-resort + pass is the fix — re-apply it. +2. **A leak on a specific alarm-stop path** — the `alarm_timer` auto-timeout + (`CHROBJ_GAS_TIMER`) vs `AI_AlarmOff` vs `deactivate_alarm_sound_effect` + from a cutscene. Trace `g_sndAllocatedVoicesCount` across each. +3. **Perceptual** — the klaxon is a *continuous* infinite loop (`slot 162`, + no gaps). A real GE alarm pulses. Check: is `ALARM3` supposed to retrigger + (keyMap velMax=7 was noted but `SOUND_FLAG_RETRIGGER` needs `keyMax & 0xf0`, + and keyMax=4 → not set)? And compare slot-162 playback **volume** to N64 — + if the drone is just mixed loud it will mask everything without dropping a + single voice. + +**Temp probe kept:** `GE_FORCEALARM` in `handle_alarm_gas_timer_calldamage` +(`src/game/propobj.c`, `#ifdef PORT` / `getenv`). + + +## D206 — **PC played every SFX one bank slot too high: `ALInstrumentAlt_s.soundArray` shifts offset 12→16 at 64-bit** (M-74 found, M-76 FIXED) + +**This is the root cause of D205's melee→Klobb and armor-pickup symptoms, and +of D202's original "silenced PPK plays a slap" report.** It was invisible for +eight sessions because *the verification tooling shares the bug* — see +"Why M-68/M-70 passed" below. + +### The evidence + +The user supplied an independently-produced, verified rip of GE's sound +effects (`B00I00S.wav`, 186 files). Filenames are **hexadecimal** bank +indices. Cross-checked by PCM frame count against `scratchpad/rom_sfx_decode.py` +decoding the ROM's `sfxctl` bank: + +``` +offset -2: 0/184 matches +offset -1: 0/185 matches +offset 0: 186/186 matches <-- rip index == bank index, exactly +offset +1: 21/185 matches +offset +2: 16/185 matches +``` + +So the rip is ground truth for *what sound lives at each bank index*, and our +decoder's indexing agrees with it. The user then identified three by ear: + +| user's statement | rip file | = bank index | game requests (from `audiotrace.log`) | +|---|---|---|---| +| correct body-armour sound | `S50` | **80** | — | +| what the PC port actually plays for armour | `S51` | **81** | **81** (`ARMOUR_COLLECT_SFX`) | +| the actual Klobb sound | `S69` | **105** | **105** (`PUNCHING_AIR_SFX`, punch whiff) | + +Both are exactly `requested index → played bank[index]`, and in both cases the +**correct** sound sits at `index - 1`. + +### The structural confirmation + +- ROM `sfxctl` bank: `bankCount=1`, `instCount=1`, **`soundCount=261`**, + `percussion=NULL` → valid `soundArray` indices are **0…260**. +- `SFX_ID` (`src/bondconstants.h`) has **262** members, beginning + `NOTHING_SFX = 0`, which `snd.c:992` explicitly treats as "no sound" + (`if (soundIndex == 0) return NULL`). +- **262 enum members − 1 sentinel = 261 real IDs (1…261), mapping exactly onto + bank slots 0…260.** The arithmetic only closes with a `-1`. +- Corollary bug: the last ID, `BIG_CLANK_SFX` = 261, currently indexes + `soundArray[261]` — **one past the end of a 261-entry array.** + +`src/snd.c:1001` is unmodified decomp code: + +```c +sound = (soundBank->instArray[0]->soundArray[soundIndex]); /* needs - 1 */ +``` + +### Independent confirmation from D202's own reference clip + +`scratchpad/B00I00S2D.wav` — the silenced-PPK reference clip that drove D202 +from M-60 to M-67, and whose provenance the user could only describe as "not +sure / found online" — **is a file from this same rip**: `S2D` = **bank 45** +(3264 frames, identical). And `wppksil_stats.Sound = 0x2E` = **46**. + +So the silenced PPK's ROM data value 46 must resolve to bank **45**, and the +user's clip *is* bank 45. **M-61 found precisely this** ("index 45 is the only +strong match for the user's reference clip, envcorr +0.999; index 46 is the +'slap'") and it was argued away over four sessions — M-67 "refuted" the clip as +a genuine capture on a waveform-correlation test. The +0.999 was right. The +"slap" heard on every silenced PPK shot is `bank[46]`, which under the correct +mapping is `PUNCH1_SFX` — **literally a slap.** + +### Why M-68/M-70's "exact match" passed anyway + +`scratchpad/exact_match.py` verifies a runtime voice against +`rom_sfx_decode.py`'s decode of **the requested index**, and that decoder walks +`soundArray[i]` with the same missing `-1`. `scratchpad/banktest/diff_bank.py` +likewise compares an N64-layout walk to a PC-layout walk using the same +indexing on both sides. **Every tool built to check the mapping reproduced the +mapping.** M-68/M-70 proved the port plays *the sample it asked for, at the +right pitch* — self-consistency — and never tested whether the index asked for +was the right one. Method note added to `porting-notes.md` §E. + +### What it explains + +- **D202** — "silenced PPK plays a slap": requests 46, plays `bank[46]` = + `PUNCH1`; correct is `bank[45]`. ✔ +- **D205 (4)** — melee plays the Klobb gun: whiff requests 105, plays + `bank[105]` = **the Klobb sample**; correct is `bank[104]`. ✔ user-confirmed +- **D205 (3)** — armour pickup wrong sample: requests 81, plays `bank[81]`; + correct is `bank[80]`. ✔ user-confirmed +- **D205 (1)** — slap/glass layered over gunfire: consistent (a gunshot ID + landing on the adjacent impact/punch sample), not yet individually confirmed. +- **D205 (2)** — explosion → scream: **NOT explained.** Explosion IDs 169–183 + map to bank 168–182, all still explosions. Stays open; M-73's reading + (guards killed by the blast screaming, ±1 s) remains the likely answer. + +### RESOLVED (M-76) — pointer-width layout shift in `ALInstrumentAlt_s` + +The M-75 reframe below was on the right track (candidate B — a PC-side layout +mismatch, not a code or data `-1`). The exact mechanism: + +`sndPlaySfx` (`snd.c:1001`) reads the on-disk sound bank — a **standard** +`ALBankFile`/`ALInstrument` (verified: `rom_sfx_decode.py` finds `soundCount` += 261 at instrument offset 14, standard `ALSound*` table at offset 16) — +through GE's cast alias: + +```c +struct ALInstrumentAlt_s { /* src/snd.h:165 */ + s32 unk0; /* off 0 */ + s32 unk4; /* off 4 */ + s32 unk8; /* off 8 */ + ALSound *soundArray[1]; /* N64: off 12 (4-byte ptr) | PC: off 16 (8-byte ptr, 8-aligned) */ +}; +``` + +- **N64:** `soundArray` at struct offset **12** — it overlaps the on-disk + instrument's `bendRange`(12) + `soundCount`(14) words. So `soundArray[0]` + is those two `s16`s (a bogus pointer, but `SFX_ID 0 = NOTHING_SFX` is + short-circuited at `snd.c:992` and never dereferenced), `soundArray[1]` is + on-disk table entry `[0]`, and in general **`soundArray[N]` == on-disk + entry `[N-1]`**. GE's `SFX_ID` enum and every `WeaponStats.Sound` byte are + therefore **1-based** into the sound table, by design. +- **PC:** 8-byte pointers + 8-byte alignment insert 4 bytes of padding after + `unk8`, so `soundArray` sits at offset **16**. `port/src/romdata.c` + `afFixupInst` rebuilds the bank to exactly this layout (16-byte header, + `8*soundCount` bytes of 64-bit pointers from offset 16, entry `i` = on-disk + entry `i`). So PC `soundArray[N]` == on-disk entry `[N]` — **one slot + higher than N64** for every SFX. + +This is the D3x class (a ROM-serialized struct whose pointer-width field +shifts the layout at 64-bit). **Fix:** `src/snd.c:1001`, `#ifdef PORT` branch +→ `soundArray[soundIndex - 1]`, N64 line kept verbatim under `#else`. The one +read site covers both the initial lookup and the retrigger chain (the loop +recomputes `soundIndex` from `keyMap->velocityMin`, same 1-based space). No +game-logic change; `romdata.c` untouched (its layout is internally correct — +it just wasn't the layout the N64 struct alias assumes). + +### Open before fixing — where the fix belongs (M-75 reframe) — SUPERSEDED by M-76 above + +**M-75:** upstream `n64decomp/007` `src/snd.c` byte-matches the real ROM, +carries no `NON_MATCHING`/`GLOBAL_ASM` guard on `sndPlaySfx`, and its index +line is identical — `soundArray[soundIndex]`, **no `-1`**. Blame puts +`snd.c:1001` in the 2022 upstream import, not this fork. So the earlier +hypothesis "(i) real ROM does `soundArray[soundIndex-1]`, decomp dropped it" +is almost certainly **dead**: a matching decomp would contain the `-1`. The +N64 runs `soundArray[N]` for ID `N` and that game sounds correct. + +That forces a reconciliation. Candidates, cheapest test first — full decision +gate and per-candidate byte-level experiments in +`docs/dev/notes/D206-IMPL-BRIEF.md`: + +- **(A)** the rip `B00I00S` is keyed by `SFX_ID`, not by 0-based bank + slot → there is **no engine bug**, the PC audio path is faithful, and + D205 / D202's slap need another cause. Re-audit whether + `scratchpad/rom_sfx_decode.py`'s 0-based `sound_offsets` walk actually + models the runtime `soundArray` (leading NULL? offset-table vs `soundCount` + mismatch? sfxctl not a plain `ALBankFile`?). Cheapest — do first. +- **(B)** PC-only off-by-one in the *converted* bank — `port/src/romdata.c` + `afFixupInst` drops/adds one `soundArray` entry vs the ROM. Test: dump PC + runtime `soundArray[81]` wavetable `base`/`len` (existing `GE_AUDIOTRACE` + probe at `snd.c:1002`) and byte-compare to `rom_sfx_decode.py` bank 80 vs + 81. PC-side fix, rule-#2 clean. +- **(C)** PC-only off-by-one in the `soundIndex` reaching `sndPlaySfx` + (request path / miscompiled or region-mismatched enum). Test: `GE_AUDIOTRACE` + already logs `soundIndex=` at the call — confirm armour logs `81`, not `82`. +- **(D)** genuine data-side: ROM `SFX_ID` constants + `WeaponStats.Sound` + bytes are each +1 vs a 0-based bank. Only reachable if A/B/C all fail. + ~532 enum refs + debug-name table + weapon-stats asset bytes — **not** a + legal port edit under AGENTS.md rule #2; stop and escalate to the user + (possible "wontfix / faithful"). + +M-62's caveat still applies: this repo never builds or byte-verifies the N64 +target, so "it byte-matches" cannot by itself settle (D). + +### Verification done (M-76) + +- Build clean (`build-pc.sh ntsc-final`). +- BUNKER1 (`-level_09`) `GE_AUDIOTRACE=1`, 60 s: crash-free, 446 `sndPlaySfx` + calls. Every one of ~90 distinct requested `soundIndex` values resolves to a + wavetable whose `len` matches `rom_sfx_decode.py` slot **`soundIndex - 1`** + (0/90 match slot `soundIndex`). Includes `ARMOUR_COLLECT_SFX`=81 → slot 80, + `GUN_B4_BOLTACTION_SFX`(AK47)=109 → slot 108, `GUN_SILPPK_A`=46 → slot 45. +- `-level_09` golden framediff (`200-440:120`): **3/3 within threshold** on two + consecutive runs (an audio-path change cannot affect rasterisation; ritual). + +### Still owed + +User by-ear A/B vs N64: armour pickup, unarmed melee whiff, silenced PP7 fire. +If clean → **D202 closes outright**, D205 shrinks to symptom (2) (explosion→ +scream) alone. Then remove the temporary D202 `GE_AUDIOTRACE` / `[VOL]` / +`[DISTVOL]` probe set (checklist in HANDOFF). + +## D205 — Wrong/extra SFX requested on PC vs N64: explosion→scream, armor pickup wrong sample, slap/glass layered over gunfire, melee→Klobb shot (M-71/M-72) + +**Symptoms (user by-ear; N64 A/B confirms non-fidelity):** +1. General gunfire: a slap/glass-shatter plays *over* the correct gunshot on + PC; silent on N64 for the same shot. +2. Explosion: plays a scream-like sample (the soldier-death voice) instead + of / over the explosion. +3. Armor pickup: wrong sample. + +**Exonerated — do not re-investigate:** +- Playback chain: M-68/M-70 exact match — every voiced request plays the + exact ROM sample at the exact ROM pitch (204/204 voiced, zero wrong-sample). +- D37 bank converter: offline harness runs the real `romdataFixupAudioBank` + on the same ROM bytes and diffs N64 vs PC images entry-by-entry — + 261/261 identical (base/len/type/keyMap/env), 0 mismatches + (`scratchpad/banktest/harness.c` + `diff_bank.py`; gitignored, local-only). +- D154/D135 GBI raycast parsers ABI-correct; volume law + (`sub_GAME_7F0537B8`: d≤200→max, sqrt falloff to 5000, linear to 6000) + and suppression logic faithful (M-71 static audit). + +**Therefore: PC requests different or extra sound indices than N64 in the +4. Unarmed melee: Bond's slap attack plays Klobb's shooting sound + (`GUN_B1_MGUN3_3_SFX`, idx 106) instead of the slap effect (M-72). + +**M-72 (controlled runtime traces, this session):** environment note first — +the earlier "flaky /GS crash" was an investigator artifact: `SDL2.dll` lives +in `C:\msys64\mingw64\bin`, which is NOT on the system PATH and not copied to +`build-pc/`; launches without it die instantly (exit 0xC0000105), with or +without any env vars. Prepending mingw64/bin to PATH in the launcher +(`build-pc/d205_*.ps1`, gitignored) gives clean 40–60 s runs. Results under +`GE_AUDIOTRACE=1`: (1) PPK fire at `-level_09` (unarmed start; first Z picks +up the spawn PPK, idx 232): every shot requests exactly {46 GUN_SILPPK, +one of 27/28/39/40 — all inside the 20-entry `ricochet_sounds_small` table, +gun.c:415 — so `rnd1 % 20` is IN RANGE, not OOB, 122 CART_SPENT}. No glass +(70), no slap (47–49) layering. (2) Unarmed melee at levels 01–08 (all start +unarmed): fire-press requests ONLY 105 PUNCHING_AIR (whiff); punch-HIT path +is the compiled constant `punch_sounds` = {47,48,49} (gunfire.c:2338), whiff += 105 (chrprop.c:1465). No 106 anywhere. (3) idx 106 is referenced by NO +code constant — it enters only via `WeaponStats.Sound` (`skorpion_stats. +Sound = 0x6A`) through `bondwalkItemGetSound`; the player fire-sound path +(gunfire.c:3192–3200) runs only in `GUN_ANIM_STATE_FIRE` and a fist sets +PUNCH state with `.Sound = 0`, so Bond's own punch cannot request 106 — an +NPC guard firing its Skorpion (`chraction.c:5588`) can. **Conclusion: +all four symptoms' request paths are faithful constants/tables, and none +reproduces in controlled micro-scenarios; the leading suspect is now +mechanism (b) — extra/different EVENTS during real gameplay (a guard firing +as you punch → 106 layered on the slap; an NPC scream at the blast; a second +surface hit adding glass/slap to a shot).** + +**M-73 (user combat capture) — ROOT CAUSE WITHDRAWN by M-74 below.** The +session analysed the user's full-combat `GE_AUDIOTRACE=1` capture +(`build-pc/audiotrace.log`) and concluded that guards were stuck +re-triggering weapon fire sound idx 109 (`GUN_B4_BOLTACTION_SFX`, +AK47/Spectre) "256x over 29.7 s with no corresponding bullets", pinning it on +`stanTestLineUnobstructed` guard->Bond LOS returning clear on converted +collision geometry. **M-74 re-counted the same file and every load-bearing +number was wrong** — see below. The code walk M-73 produced is still accurate +and worth keeping as reference: `chrlvFireWeaponRelated` (`chraction.c:6530`) +passes `phi_a2 = sp27C || sp278` to `sub_GAME_7F02BFE4` (:6902), so a +non-bullet auto tick still plays the weapon sound, gated by +`SoundTriggerRate`/`field_178[hand]` (and by `CHRHIDDEN_FIRE_TRACER` 0x80, +cleared once per `chrlvTriggerFireWeapon`); sustained attack needs +`seen_bond_time >= g_GlobalTimer - CHRLV_SEEN_RECENT_CHECK` (:6593), +refreshed by `setSeenBondTimeToNow` behind LOS queries (:3917/:3972, +`chrCanSeeBond` :3945). That mechanism is real. What M-74 shows is that +**nothing in the capture indicates it ever misfired.** + +**M-74 (re-count of the SAME capture — M-73's evidence does not hold, this +session):** counting only the canonical one-per-call line +(`sndPlaySfx: t=... soundIndex=...`) in `build-pc/audiotrace.log`: + +| M-73 claim | Actual | +|---|---| +| idx 109 requested **256x** | **128x** — the count was doubled | +| "~11 guards each re-triggering" | 128 / 29.73 s = **4.31/s aggregate**, ~0.4/s per guard | +| "sustained roar, gate blown open" | AK47 `SoundTriggerRate` = `RATE_AK47` = 4 `g_GlobalTimer` ticks (`assets/obseg/gun/gunWeaponStats.inc.c`) => design ceiling **15/s per guard**. Observed is **~6x UNDER** the weapon's own rate — `field_178` is not even the binding constraint | +| "no corresponding bullets/impacts anywhere near that rate" | In the same 29.7 s window: **~30 ricochet / wall-hit requests** (idx 19, 20, 22-27, 29, 31-33, 35-37, 40, 41), **69 x2** (flesh hit), **123-132** (12 body-falls), **134-147** (14 guard yelps). Guards were firing *and* hitting | +| "only ~32 non-109 requests in the window" | ~150 | + +Also corrected: + +- **The 128-vs-256 gap is the double-log artifact D204 already documented.** + D204 corrected M-63's "idx 109 fired 94 times in 60 s" with exactly this: + "the count is doubled by the probe (it logs two lines per call)". M-73 + re-made the same mistake on the same sound index. Count `sndPlaySfx: t=` + lines, never bare `soundIndex=` matches. +- **The monotone index runs are ground-truth round-robin, not a broken + selector.** The capture contains strictly increasing sweeps + `134,135,...,147` (GET_HIT_MALE) and `123,124,...,132` (BODY_FALL), one + request each, never repeating. This looks exactly like a `random() % n` + gone wrong and was checked specifically: `chraction.c:2454-2470` selects + `male_yelps[male_guard_yelp_counter]` and post-increments modulo 25 — a + deliberate cycling counter in byte-matched game code. Faithful. Do not + re-open. +- The trace file spans 1321.85 s wall (288 requests); the actual firefight is + ~40 s of it (`t` in 715754-715800 s). M-73's "29.7 s" is the 109 span only. + +**Net: the 109 traffic in that capture is normal, in-spec guard combat.** The +LOS / converted-geometry root cause is **withdrawn** — it was inferred from +counts that do not survive re-derivation, and M-73's Next steps 1-3 (probe +`setSeenBondTimeToNow`, decode pccg stan in Python, replay guard->Bond rays) +would have been a full session spent on a phantom. The only genuinely open +observation left from that capture is that the firefight lasted a long time, +which is a gameplay-fidelity question for an N64 A/B, not evidence of an +audio bug. + +**Where that leaves D205.** Every layer upstream of presentation is now +proven correct: bank conversion (261/261 identical, M-71), requested indices +(182 explosion x1, 81 armor x1, 46 PPK, 47/48/49 punch hits, 105 whiffs — all +correct and in range, here and in M-72's controlled runs), and sample+pitch +(M-68/M-70 exact match). That leaves exactly one untested layer between "the +right sample" and "what the user hears": **spatial presentation — per-voice +volume, pan and concurrency.** It fits all four complaints in one shape: if a +sound's apparent *position* is wrong, a guard firing behind you is by ear +indistinguishable from "a gun sound came out of my punch", a nearby guard's +death scream reads as "the explosion screamed", and a ricochet reads as "a +slap over my gunshot". The user's ear reports *what and where*; only *where* +has never been verified. It is also the youngest code in the stack — +`sndCreatePostEvent` was stubbed out entirely (D138) until M-65 un-stubbed +it, so every distance/pan post-event has been live for only a few sessions +and has never been A/B'd against N64. + +**Original M-71 framing (kept as background; DEMOTED by M-74 — every index +observed in the user's capture was correct, so "PC requests a different +index" is no longer the leading shape of the bug):** PC requests different or +extra sound indices than N64 in the same scenario. Two mechanisms, ranked: +(a) **Converted level/setup data** — sndID / explosion-type / item-sound + fields in the pccg sidecars differ from ROM. Explosions play + `sndPlaySfx(g_musicSfxBufferPtr, sp44->sndID, NULL)` + (`src/game/explosion.c:285`) with `sp44->sndID` = + `g_ExplosionTypes[type].sndid`; the *type* comes from level data. + Explosion SFX are idx 169–183 (EXPLOSION_2A_SFX=169 … EXPLOSION_9_SFX= + 183, bondconstants.h). A scream is outside that range, so either the + type index is wrong (→ a non-explosion sndID) or a second event fires. + Armor: idx 81 verified exact in M-68 — a "wrong" armor sample means a + different index is requested in the user's scenario (or a layered + second sound). +(b) **Extra hit events** — converted collision geometry makes bullet + raycasts or explosion proximity checks hit surfaces/NPCs the N64 + misses. The slap+glass-over-gunshot signature is exactly the + non-penetrating-OBJ-hit path: `sub_GAME_7F064720` + (`src/game/gunfire.c`) plays `HIT_BULLET_GLASS_SFX` (idx 70) for every + non-penetrating hit; ricochet tables classify AFDM (27–30) = prop only, + GBU/TAJ/RICO_4 (19–26, 31–33) = wall only. + +**M-74 step 1 — static audit of the presentation layer: H-A FALSIFIED, +H-B narrowed to "faithful but shallow". Done this session.** + +The pan machinery is complete and faithful end to end: +`src/snd.c:461/530` computes `pan = clamp(soundState->pan + sound->samplePan - +AL_PAN_CENTER)` → `alSynSetPan` (`synsetpan.c`, posts `AL_FILTER_SET_PAN`) → +`src/libultrare/audio/env.c:400-402` turns pan into independent L/R targets via +the `eqpower` table (`ltgt = volume*eqpower[pan]`, `rtgt = +volume*eqpower[LEN-pan-1]`) and emits `aSetVolume(A_LEFT|A_RATE)` / +`(A_RIGHT|A_RATE)` → `port/src/mixer.c` `aSetVolumeImpl` decodes A_LEFT +(0x02) into `volCur/volTgt[0|1]` and `aEnvMixerImpl` applies `vol[0]`/`vol[1]` +to `dry[0]`/`dry[1]` independently (:488-496). Nothing is dropped or collapsed +to mono. **The port's stereo path is correct.** + +**But GE never uses it.** `AL_SNDP_PAN_EVT` (`snd.h:41`) is handled at +`snd.c:527` and **posted by nothing anywhere in the tree** — grep returns the +enum and the handler, no callers. So `soundState->pan` keeps the +`AL_PAN_CENTER` set at `snd.c:842` for every SFX's entire life, and the only +pan input is the static per-sound `samplePan` from the bank. Dumped from the +converted bank image this session: **252 of 261 sounds have `samplePan == 64` +(= `AL_PAN_CENTER`)**; the 9 exceptions are 4, 24, 44, 74, 84, 104, 123. +Every sound in the user's capture is centred — 46, 47, 48, 49, 81, 105, 106, +109, 182 all `samplePan = 64`. + +**⇒ GoldenEye does not spatially pan SFX at all, on N64 or PC.** There is no +pan to get wrong. H-A is dead; do not re-open it, and do not write the +"pan varies with bearing" probe (M-74 step 2 as originally drafted) — the +answer is known and it is "never, by design". + +Field-coverage gap in M-71's bank exoneration closed at the same time: the +converter writes `samplePan`/`sampleVolume`/`flags` at `+24/+25/+26` +(`port/src/romdata.c:890`), correct for the PC layout (3 × 8-byte pointers +before them), and `scratchpad/banktest/diff_bank.py` already diffs them as +`panvol` — re-run this session, **261/261, 0 mismatches**. (M-71's prose +listed only base/len/type/keyMap/env; its actual coverage was broader, +including a 24-byte sample-data fingerprint.) The volume formula +(`snd.c:453`, `:565`) also has no PC-side divergence: peak intermediate is +127 × 32767 × 127 ≈ 5.3e8, inside s32 on both platforms, and `vol` is `s16` +(`snd.h:90`) which holds the 32767 the `[VOL]` probe reports. + +**What this leaves.** GE's only spatial cue for an SFX is distance volume, and +that law is shallow: the capture's `[DISTVOL]` lines show flat max out to +~200 units, then 0.60 of max still at dist 1794. So a guard firing across the +room plays **near-full volume, dead centre**, at a per-sample volume +comparable to the player's own actions (idx 109 `sampleVolume` = 90; punch +47/48 = 100, 49/105 = 110). **That is faithful, and it is also exactly the +perceptual condition that generates all four of the user's reports** — with no +spatial cue, a correct sample played at correct volume from across the room is +by ear a sound at your own position. This reframes D205: the complaints are +fully consistent with *correct* sounds overlapping, and the only remaining +question is whether the PC generates **more** such overlaps than the N64 +(H-D). Nothing on the PC side can settle that; it needs the N64 A/B. + +**Next (cheapest first; re-ranked by M-74 — M-73's list is withdrawn):** +0. ~~User capture~~ — DONE (M-73), but see M-74: it does NOT show a guard + re-trigger bug. 182 and 81 were both requested correctly and exactly once. +1. ~~Static audit of the presentation layer~~ — **DONE (M-74 step 1 above). + H-A falsified: the port's stereo path is correct, but GE never pans SFX at + all (no `AL_SNDP_PAN_EVT` poster exists; 252/261 bank sounds are + `samplePan == 64`). Bank `panvol` re-diffed 261/261 clean. Do not re-open.** +2. ~~Pan probe run~~ — **cancelled by step 1: pan cannot vary, by design.** +3. ~~The PC side is now exhausted~~ — **SUPERSEDED by D206.** The premise + ("requested indices are correct") was wrong: the indices are correct as + *values* but are resolved one slot too high, which D206 root-causes. What + survives from this step: the pan audit (H-A dead) and the bank `panvol` + re-diff. Original text: bank, playback, requested indices, + sample+pitch, pan and per-sound volume are all verified faithful. The only + remaining hypothesis is **H-D** — that the PC generates *more* overlapping + combat events than the N64 — and nothing measurable on the PC alone can + settle it. **The N64 A/B is now the gating step, not an optional + corroboration.** +4. **User-side, batched, held until 1-3 report:** (a) ROM samples 106 + (Skorpion/"Klobb") vs 109 (AK47) as WAVs via `scratchpad/rom_sfx_decode.py` + for the user to settle the Klobb identification by ear — their by-ear + identification stands either way, this decides *which* sound, not whether + they heard it; (b) N64 A/B of one moment: punch a guard while others fire — + does the same combat sustain on N64? +5. After root-cause + fix: user re-test of melee / explosion / armor pickup + vs N64. + +**Ranked hypotheses after M-74:** +- **H-A (top) — pan/spatialisation wrong or absent in the mix.** Untested end + to end; youngest code in the stack (D138/M-65 lineage). +- **H-B — distance law right, scale wrong.** The capture's `[DISTVOL]` lines + do follow the documented curve (flat max to ~200, falloff after) but only + reach 0.60 of max at dist 1794. Never compared to N64 loudness at the same + distance. +- **H-C — voice-pool masking.** 8-voice soft limit vs 11 guards + music. Weak: + M-70 measured alloc <= 5/8. +- **H-D — real content difference (PC guards engage where N64 guards don't).** + Possible, but after M-74 it has *no* supporting evidence. Gated behind the + N64 A/B. + +**Probes added this session (live under `GE_AUDIOTRACE`, catalogued in +GE-ENV-PROBES.md):** `[VOL] t=… state=… rawVol=… playing=…` (`src/snd.c: +556`, per-VOL-event value) and `[DISTVOL] pos=(…) player=(…) dist=… vol=…` +(`src/game/propobj.c:12780`, the distance-attenuation funnel +`sub_GAME_7F053894`). + +## D204 — Audio pipeline runs ~2 % below real time forever: GE's AI feedback loop has a 3 ms setpoint that OS jitter clears, so the DAC is padded with silence (M-64, FIXED + measured) + +**Found while reviewing the D202 audio state, not by chasing D202's stated +symptom.** D202 itself is NOT resolved by this — see "What this does and does +not explain" below. + +### The architecture (established, not assumed) + +GE's audio is a closed-loop, DAC-backpressure-driven producer: + +- `amMain` (`src/audi.c:433`) blocks on `OS_SC_RETRACE_MSG`. `__scMain` + (`src/sched.c:334`) forwards a retrace to a client either every frame or, + when the client registered with a non-zero flag, every *second* frame. The + audio client registers with `1` (`audi.c:441`), so it wakes at **30 Hz**. +- Per wake, `amHandleFrameMessage` (`audi.c:531`) sizes the next block: + `frameSamples = (u16)((g_FrameSize - (osAiGetLength() >> 2) + 16 + 0x25) & ~0xf)`, + then clamps *below* at `g_MinFrameSize`. +- NTSC constants: `outputRate=22050`, `FRAMES_PER_FIELD_AS_POW2=1`, + `MAYBE_FRAME_RATE=60` → `g_FrameSize=736`, `g_MinFrameSize=720`, + `g_MaxFrameSize=789`. `info->data` is allocated exactly `g_MaxFrameSize*4` + = 3156 bytes (`audi.c:388`). +- So `osAiGetLength()` is a **±64-sample trim**, and the loop only asks for + more than 720 once the reported length falls under ~69 frames — **3 ms**. + +### The defect + +720 samples per 30 Hz block is **21600 samples/s against a 22050 Hz device** +— the nominal rate is structurally 2 % short, and the design depends on those +under-69-frames top-up blocks (784 samples) to make it back. + +On N64 a 3 ms setpoint is fine: AI is double-buffered and the VI interrupt is +exact. On PC, `osAiGetLength()` maps onto the SDL queue, and ordinary OS +scheduling jitter empties a 3 ms cushion before the loop reacts. The queue +hits zero, SDL pads playback with silence, and **that lost time is +unrecoverable** — a queue-*depth* reading can never tell the regulator it has +already fallen behind. Measured, sustained, indefinitely: + +``` +GE_D204_OLD=1 rt=0.980 q=0..416 (q reaches 0 repeatedly) +``` + +`rt` = produced audio seconds / wall seconds. 0.980 is exactly 21600/22050. + +### The fix (F5, port-side only — `src/audi.c` untouched, rule #2 clean) + +`port/src/audio.c`'s `audioGetAiLengthBytes()` subtracts a target cushion +(`AUDIO_TARGET_FRAMES` = 1024 frames ≈ 46 ms) before reporting, so "the queue +holds the cushion" reads to the game as "the queue is empty". The loop then +tops up while ~46 ms of slack remains and settles just above the cushion +instead of oscillating into starvation. audi.c's own control law is unchanged. + +``` +after (same repro) rt=1.000 q=368..672 drop=0 (q never reaches 0) +``` + +Also landed, all in `port/src/audio.c` / `port/src/libultra.c`: + +- **F1** — `osAiGetLength()` now reports the residue of a *single* buffer + (`min(queued, lastBufferBytes)`), matching AI_LEN_REG, instead of the whole + SDL queue depth. **Robustness only: measured A/B shows zero behavioural + change.** See the falsified hypothesis below. +- **F3** — `Audio.QueueLimit` default 8192 → 2880 frames (372 ms → 130 ms + worst-case latency), and a full queue now logs a dropped block instead of + discarding it silently. **Caveat: an existing `data/ge007.ini` pins + `QueueLimit = 8192`, so this default only reaches fresh configs.** +- **F4** — invariant guard refusing any block larger than `g_MaxFrameSize*4`. + Believed unreachable; costs one compare, versus a silent heap overrun. +- **`GE_D204=1`** — audio-health monitor, one line per 5 s: `rt=` real-time + ratio, `q=` queue depth, `drop=`, `max=` largest block vs its allocation. + Deliberately cheap (~30 clock reads/s) so it can be left on for a whole + playtest. **`GE_D204_OLD=1`** restores pre-fix behaviour for in-binary A/B. + +### Hypothesis raised and FALSIFIED — do not re-open + +I predicted the u32 subtraction in `audi.c:531` wraps once the queue exceeds +`g_FrameSize + 0x35` = 789 frames, truncates to u16 (e.g. 58128), and drives a +74× heap overrun of the 3156-byte `info->data`. **The queue really does exceed +789 routinely — measured high-water 2064 frames over 5 minutes — and the wrap +really does happen, but it is harmless.** `frameSamples` is declared +**`s16`** (`audi.c:145`), so the wrapped value lands negative and audi.c's own +`(s32)frameSamples < (s32)(s16)g_MinFrameSize` clamp catches it, yielding a +nominal 720-sample block. Max block observed is 3136 bytes against the +3156-byte allocation, in both old and new modes, across every run. There is no +overrun. This is why F1 measures as a no-op. + +### Two M-63 conclusions corrected + +1. **`soundIndex=109` firing "94 times in 60 s with no AK47 present" is a red + herring — closed, negative.** The count is doubled by the probe (it logs two + lines per call); 47 real calls. All of them fall in tight bursts *after* + t≈5.4 s, interleaved with ricochet indices 27/28/30/35/39/41 — **guards + returning fire**, correct behaviour once the player starts shooting in + Bunker. And the "suspiciously uniform ~32.6 ms cadence" is not a retrigger + artifact at all: 32.6 ms **is** the audio-block quantum (720 frames × 4 + bytes = 2880 bytes), so *every* `dumppos` in the entire trace is quantised + to it. Several sounds landing in one block read as "4-in-a-row". +2. **M-63's implied "audio produced at 13 % of real time" (708608 dump bytes + = 8.03 s over a 60 s run) was a measurement artifact of its own probe.** + That run had `GE_MIXERTRACE=1`, whose unbuffered per-opcode `fprintf` + produced a 25 MB log and slowed the process enough to starve the audio + thread. The identical repro without it produces 58.0 s of audio in 60 s. + **Lesson: `GE_MIXERTRACE` is not safe for any timing-sensitive measurement** + — that is exactly why `GE_D204` was written to be cheap. M-63's + recommendation to have the user replay with `GE_MIXERTRACE=1` set would + have induced the very starvation it was looking for. + +### What this does and does not explain + +**Does:** a permanent low-level stutter/gap artifact in all PC audio — ~2 % of +playback was silence, in every level, for every user, from the first frame. + +**Does not:** D202's reported chain (wrong sound on the silenced PPK, correct +sound sometimes heard simultaneously, sounds piling up and glitching over a +session, eventual near-silence except one stuck loop). Nothing in this +session's headless runs degraded: a 5-minute `-level_09` soak held `rt=1.000`, +`drop=0`, no voice-pool growth. D202 stays OPEN. It needs a real playtest — +now instrumentable with `GE_D204=1` (plus `GE_AUDIOTRACE=1`, and **not** +`GE_MIXERTRACE`), where `rt` falling or `q` pinning at `queueLimit` with +`drop=` climbing would localise it immediately. + +### Verification + +Reproduce with **`. ools_pcudiodebug.ps1 -AB -Fire`** (added this session; +`-Play -Trace` is the instrumented interactive run for D202). `-level_09`, +scripted PPK fire, MSYS2 MINGW64 build, full `data/` mirror. In-binary A/B via +`GE_D204_OLD` (same executable, no build-to-build variance): +`rt` 0.980 → 1.000, `q` min 0 → 368, `drop` 0, `max` 3136/3156 unchanged. +Not yet verified by ear — the artifact is a ~2 % silence rate, so a human +listening pass on a real playthrough is still owed before calling it closed. + +### Harness gotchas found while building `audiodebug.ps1` (both cost a debug cycle) + +1. **`Start-Process -RedirectStandardOutput` does not capture this game's + stdout.** `CMakeLists.txt:577` sets `WIN32_EXECUTABLE TRUE` (`-mwindows`), + so `ge007.x86_64.exe` is a GUI-subsystem binary with no console of its own; + the redirect yields a 0-byte log while the output lands on the parent + console. A shell that opens the file itself before `CreateProcess` (cmd's + `>`, or a bash redirect) does work. +2. **MSYS2 `bash.exe` launched from PowerShell arrives with a stripped + environment.** Neither variables inherited from PowerShell nor the script's + own `export` reach the child process — verified with a minimal + `export GE_PROBE=hello; env | grep GE_PROBE` probe, which prints under + `sh script.sh` from MSYS but nothing when the same script is run by + `powershell -Command "& bash.exe script.sh"`. Every `GE_*` probe silently + read as unset, which looks exactly like "the probe is broken". `cmd.exe` + inherits normally, so `audiodebug.ps1` generates a `.bat` and runs that. diff --git a/docs/porting-notes.md b/docs/porting-notes.md index c0de71f0..53fc09b6 100644 --- a/docs/porting-notes.md +++ b/docs/porting-notes.md @@ -696,6 +696,45 @@ and turns every such overrun into a fatal `*** stack smashing detected ***` (especially Phase 3 audio underruns), reach first for host thread priorities (`SetThreadPriority`: scheduler thread time-critical, tick normal) and the deferred `GE_DETERM` mode (D117) — not for kernel changes. +- **A verification tool that shares the code's indexing convention proves + self-consistency, not correctness** (D206 — cost eight sessions). The port + played every SFX one bank slot too high. Real cause: `struct + ALInstrumentAlt_s` (`src/snd.h`) is a cast alias over the on-disk + `ALInstrument`, and its `soundArray` member sits at struct offset **12** on + N64 (three 4-byte words) but **16** on PC (8-byte pointer + 8-byte + alignment). On N64 that offset-12 array deliberately aliases the on-disk + `bendRange`/`soundCount` words so `soundArray[N]` == on-disk entry `[N-1]` + (GE's `SFX_ID`s are 1-based by design); on PC the alias is broken and every + ID resolved one slot high. It survived M-68/M-70's "exact match" of 200+ + requests because `exact_match.py` compared each runtime voice against a ROM + decode *of the requested index* with a decoder that walked the bank + 0-based; `diff_bank.py` likewise walked N64 and PC layouts with identical + indexing. Both faithfully reproduced the bug and reported a pass. Two rules: + (1) when validating a **mapping** (index → asset, id → record, offset → + field), the reference must be produced **independently of the code's own + accessor** — an external rip, a published table, a hand-decoded sample — or + the test only confirms the code agrees with itself; (2) when a count and an + enum disagree by one (261 bank entries vs 262 `SFX_ID` members), suspect a + **pointer-width struct-layout shift** in a ROM-serialized struct (the D3x + class) before anything subtler — check every cast-alias struct's member + offsets at 32- vs 64-bit. +- **Count a probe's events by its canonical one-per-call line, never by a bare + field match** (D204, then D205/M-73 which re-made the identical mistake on the + identical sound index). `GE_AUDIOTRACE` emits *two* lines per `sndPlaySfx` + (a `dumppos=... bank=...` line and a `sndPlaySfx: t=... soundIndex=...` line), + so `grep -c soundIndex=109` reports exactly double the truth. D204 already + corrected M-63's "idx 109 fired 94 times in 60 s" on these grounds; M-73 then + built a whole root cause ("guards stuck re-triggering, 256x/29.7 s") on the + doubled count and pinned it on converted collision geometry, which M-74 + withdrew. Two general rules: (1) grep the timestamped call line + (`sndPlaySfx: t=`), not the field; (2) **before calling a rate anomalous, + compare it to the rate the data says is legal** — the "barrage" was ~6x + *under* the AK47's own `SoundTriggerRate` ceiling, which one lookup in + `assets/obseg/gun/gunWeaponStats.inc.c` would have shown. Corollary: a + perfectly monotone index sweep in a trace looks like a broken `random() % n` + but is often a deliberate round-robin counter in ground-truth code (e.g. + `male_guard_yelp_counter`, `chraction.c:2454`) — check the selector before + reporting it. - **"Regression vs. steady state" is decided by a build-and-compare, not by reading an old handoff line** (D133). Handoff prose like "the entire intro renders" is often aspirational — the author's intent, not a measured coverage diff --git a/include/PR/abi.h b/include/PR/abi.h index 878d4fe6..b7e3a7f1 100644 --- a/include/PR/abi.h +++ b/include/PR/abi.h @@ -269,6 +269,16 @@ typedef short ENVMIX_STATE[40]; * Macros to assemble the audio command list */ +#ifdef PORT +/* PC port (D199): the N64 RSP audio ucode that would execute this acmd list + * never runs on PC (port/src/ucode.c). Following the Perfect Dark PC port's + * macro-swap pattern (docs/dev/AUDIO-PLAN.md), each aXxx macro below is + * replaced with an immediate call into port/src/mixer.c's software + * implementation instead of packing RSP command words. The N64 branch below + * is untouched. */ +#include "mixer.h" +#else /* !PORT */ + #define aADPCMdec(pkt, f, s) \ { \ Acmd *_a = (Acmd *)pkt; \ @@ -402,6 +412,8 @@ typedef short ENVMIX_STATE[40]; _a->words.w1 = (unsigned int) d; \ } +#endif /* PORT */ + #endif /* _LANGUAGE_C */ #endif /* !_ABI_H_ */ diff --git a/port/include/audio.h b/port/include/audio.h index 478e86d6..5762b8c3 100644 --- a/port/include/audio.h +++ b/port/include/audio.h @@ -23,12 +23,14 @@ void audioDestroy(void); /* Number of samples (stereo s16 frames) currently queued. */ s32 audioGetSamplesBuffered(void); +/* D204/F1: emulate the N64 AI_LEN_REG -- bytes remaining in the buffer the + * DAC is *currently* playing, NOT the whole queue depth. src/audi.c does + * arithmetic on this value that only holds inside the N64's range. */ +u32 audioGetAiLengthBytes(void); + /* Queue the next block of mixed samples (len is in bytes). */ void audioSetNextBuffer(const s16 *buf, u32 len); -/* Called at the end of each frame to flush the queued buffer to the device. */ -void audioEndFrame(void); - #ifdef __cplusplus } #endif diff --git a/port/include/audiotrace.h b/port/include/audiotrace.h new file mode 100644 index 00000000..80434480 --- /dev/null +++ b/port/include/audiotrace.h @@ -0,0 +1,17 @@ +/* + * audiotrace.h — serialized trace writer for the GE_AUDIOTRACE / WIRE probes. + * + * D202/M-70 (PORT): the probe sites live in TWO threads — sndPlaySfx runs on + * the game thread, the sndp event handlers ([EVT]/[VOICE+]/[STOP-EVT]) and + * load.c's [PASTEND] run on the audio thread. Each site used to open its own + * unbuffered FILE* on the same path; concurrent fprintf() calls interleaved + * MID-LINE and corrupted records, which M-69's offline matcher misread as + * dropped voice allocations. One FILE* per path plus a spinlock around + * vfprintf makes each line atomic. + */ +#ifndef PORT_AUDIOTRACE_H +#define PORT_AUDIOTRACE_H + +void geTracePrintf(const char *path, const char *fmt, ...); + +#endif /* PORT_AUDIOTRACE_H */ diff --git a/port/include/mixer.h b/port/include/mixer.h index 186da2b5..fd3f2c31 100644 --- a/port/include/mixer.h +++ b/port/include/mixer.h @@ -2,16 +2,21 @@ #define PORT_MIXER_H /* - * Audio mixing. - * Modelled on the PD port's port/include/mixer.h. + * Audio mixing — software implementation of GE's RSP audio ucode opcodes. * - * On the N64 the RSP audio ucode does the final mix / sample-rate handling - * before the AI DMA. Since the RSP is emulated in software here, this module - * performs that final processing on the CPU and hands the result to audio.c. + * include/PR/abi.h's aXxx macros normally pack RSP command words into an + * Acmd list for the audio ucode (aspMain, never run on PC — port/src/ucode.c) + * to execute later. Under #ifdef PORT, abi.h includes this header instead, + * which redefines every aXxx macro to call an Impl function here immediately + * — same macro-swap trick as the Perfect Dark PC port (docs/dev/AUDIO-PLAN.md). * - * NOTE: the exact responsibilities depend on how GE's libaudio output is - * wired (see src/audi.c). This is a placeholder interface to be refined in - * Phase 3 (audio). + * GE uses the classic IDO libaudio ABI (verified against every call site in + * src/libultra/audio and src/libultrare/audio/{env,reverb}.c — NOT PD's + * differently-shaped "naudio New" ABI). Several opcodes (aADPCMdec, + * aResample, aEnvMixer, aLoadBuffer, aSaveBuffer) take only a state/DRAM + * pointer; their DMEM source/dest addresses and byte counts come from the + * most recent aSetBuffer call(s), tracked here as persistent context — + * see the field comments on MixerCtx in mixer.c. */ #include @@ -23,9 +28,54 @@ extern "C" { void mixerInit(void); void mixerDestroy(void); -/* Process one frame of audio. `in` is the libaudio-mixed buffer, `frames` - * the number of stereo frames. Output is queued via audioSetNextBuffer. */ -void mixerProcess(const s16 *in, u32 frames); +/* Software RSP-audio-ucode opcode implementations (port/src/mixer.c). */ +void aSetBufferImpl(u32 flags, u16 i, u16 o, u16 c); +void aClearBufferImpl(u16 addr, u32 count); +void aLoadBufferImpl(u32 dramAddr); +void aSaveBufferImpl(u32 dramAddr); +void aDMEMMoveImpl(u16 in, u16 out, u32 count); +void aLoadADPCMImpl(u32 count, u32 dramAddr); +void aSetLoopImpl(u32 stateAddr); +void aADPCMdecImpl(u32 flags, u32 stateAddr); +void aResampleImpl(u32 flags, u16 pitch, u32 stateAddr); +void aInterleaveImpl(u16 l, u16 r); +void aMixImpl(u32 flags, u16 gain, u16 in, u16 out); +void aSetVolumeImpl(u32 flags, u16 v, u16 t, u16 r); +void aEnvMixerImpl(u32 flags, u32 stateAddr); +void aPoleFilterImpl(u32 flags, u16 gain, u32 stateAddr); +void aSegmentImpl(u32 seg, u32 base); + +#undef aSetBuffer +#undef aClearBuffer +#undef aLoadBuffer +#undef aSaveBuffer +#undef aDMEMMove +#undef aLoadADPCM +#undef aSetLoop +#undef aADPCMdec +#undef aResample +#undef aInterleave +#undef aMix +#undef aSetVolume +#undef aEnvMixer +#undef aPoleFilter +#undef aSegment + +#define aSetBuffer(pkt, f, i, o, c) aSetBufferImpl((u32)(f), (u16)(i), (u16)(o), (u16)(c)) +#define aClearBuffer(pkt, d, c) aClearBufferImpl((u16)(d), (u32)(c)) +#define aLoadBuffer(pkt, s) aLoadBufferImpl((u32)(s)) +#define aSaveBuffer(pkt, s) aSaveBufferImpl((u32)(s)) +#define aDMEMMove(pkt, i, o, c) aDMEMMoveImpl((u16)(i), (u16)(o), (u32)(c)) +#define aLoadADPCM(pkt, c, d) aLoadADPCMImpl((u32)(c), (u32)(d)) +#define aSetLoop(pkt, a) aSetLoopImpl((u32)(a)) +#define aADPCMdec(pkt, f, s) aADPCMdecImpl((u32)(f), (u32)(s)) +#define aResample(pkt, f, p, s) aResampleImpl((u32)(f), (u16)(p), (u32)(s)) +#define aInterleave(pkt, l, r) aInterleaveImpl((u16)(l), (u16)(r)) +#define aMix(pkt, f, g, i, o) aMixImpl((u32)(f), (u16)(g), (u16)(i), (u16)(o)) +#define aSetVolume(pkt, f, v, t, r) aSetVolumeImpl((u32)(f), (u16)(v), (u16)(t), (u16)(r)) +#define aEnvMixer(pkt, f, s) aEnvMixerImpl((u32)(f), (u32)(s)) +#define aPoleFilter(pkt, f, g, s) aPoleFilterImpl((u32)(f), (u16)(g), (u32)(s)) +#define aSegment(pkt, s, b) aSegmentImpl((u32)(s), (u32)(b)) #ifdef __cplusplus } diff --git a/port/src/audio.c b/port/src/audio.c index 75f9c325..70b68755 100644 --- a/port/src/audio.c +++ b/port/src/audio.c @@ -6,11 +6,11 @@ * osAiGetLength (libultra.c) route through here. * * Modelled on the PD port's port/src/audio.c (~75 lines). - * - * STATUS: scaffolding stub — implement during Phase 3. */ #include +#include +#include #include "platform.h" #include "system.h" @@ -18,21 +18,89 @@ #include "audio.h" static SDL_AudioDeviceID dev = 0; -static const s16 *nextBuf = NULL; -static u32 nextSize = 0; + +/* src/audi.c: g_FrameSize + EXTRA_SAMPLES + 0x10, the exact sample count + * info->data is allocated for (audi.c:388). Zero until amCreateAudioManager + * has run. Used by the D204/F4 oversize guard below. */ +extern u32 g_MaxFrameSize; + +/* D202 diag: GE_AUDIODUMP=1 raw-dumps every mixed s16 stereo buffer at + * 22050Hz to audiodump.raw in the CWD for offline waveform inspection. + * Remove once D202 is root-caused. */ +static FILE *s_audioDumpFile = NULL; +static int s_audioDumpChecked = 0; +static u64 s_audioDumpBytesWritten = 0; + +/* D202 diag: exact byte offset into audiodump.raw written so far, so a probe + * elsewhere (snd.c sndPlaySfx) can log precisely which sample position a + * request happened at, instead of guessing via an RMS scan. */ +u64 audioDumpBytePos(void) +{ + return s_audioDumpBytesWritten; +} static int bufferSize = 512; -static int queueLimit = 8192; + +/* D204/F3: was 8192 frames (372 ms). GE's AI feedback loop (src/audi.c:531) + * regulates to a very shallow queue -- it only asks for more than + * g_MinFrameSize once the queue is under ~69 frames -- so a 372 ms drop + * threshold is far past anything the game can steer out of, and just + * converts overproduction into silent buffer loss at high latency. + * 2880 frames (130 ms, four of GE's 720-sample blocks) sits comfortably above + * the F5 cushion of AUDIO_TARGET_FRAMES while keeping worst-case latency in a + * range the regulator can still steer out of. + * NB: an existing data/ge007.ini pins QueueLimit, so this default only + * applies to fresh configs -- see docs/dev/findings.md D204. */ +static int queueLimit = 2880; + +/* D204/F1: size in bytes of the block most recently handed to the DAC. Used + * to bound audioGetAiLengthBytes() to one buffer, like real AI hardware. */ +static u32 lastBufferBytes = 0; + +/* D204/F5: queue cushion the AI feedback loop steers toward, in stereo + * frames. ~46 ms at 22050 Hz -- enough to absorb OS scheduling jitter and a + * dropped retrace or two, small enough that input->sound latency stays well + * under a frame of the game's own 30 Hz audio cadence. See the long comment + * in audioGetAiLengthBytes(). */ +#define AUDIO_TARGET_FRAMES 1024 + +/* D204/F4: rate-limited reporting for the two ways audio can be lost. */ +static u32 dropCount = 0; +static u32 oversizeCount = 0; + +/* D204 diag (temporary): GE_D204_OLD=1 restores the pre-fix behaviour -- + * osAiGetLength() reports the whole SDL queue depth and queueLimit goes back + * to 8192 -- so before/after can be measured in ONE binary, without + * build-to-build variance. Remove once D204 is closed. */ +static int d204OldMode(void) +{ + static int cached = -1; + if (cached < 0) { + cached = getenv("GE_D204_OLD") ? 1 : 0; + if (cached) queueLimit = 8192; + } + return cached; +} int audioInit(void) { - /* TODO(Phase 3): - * - SDL_InitSubSystem(AUDIO) - * - open device: 22020 Hz (match GE's OUTPUT_RATE in src/audi.c), - * AUDIO_S16SYS, 2 channels - * - unpause - */ - sysLogPrintf(LOG_INFO, "audioInit: TODO (Phase 3)"); + if (SDL_InitSubSystem(SDL_INIT_AUDIO) < 0) { + sysLogPrintf(LOG_ERROR, "audioInit: SDL_InitSubSystem: %s", SDL_GetError()); + return -1; + } + SDL_AudioSpec want = {0}; + want.freq = 22050; /* GE's OUTPUT_RATE (src/audi.c) */ + want.format = AUDIO_S16SYS; + want.channels = 2; + want.samples = (u16)bufferSize; + SDL_AudioSpec have; + dev = SDL_OpenAudioDevice(NULL, 0, &want, &have, 0); + if (!dev) { + sysLogPrintf(LOG_ERROR, "audioInit: SDL_OpenAudioDevice: %s", SDL_GetError()); + return -1; + } + SDL_PauseAudioDevice(dev, 0); + sysLogPrintf(LOG_INFO, "audioInit: opened SDL audio device at %d Hz", have.freq); return 0; } @@ -46,20 +114,153 @@ s32 audioGetSamplesBuffered(void) return dev ? (SDL_GetQueuedAudioSize(dev) / 4) : 0; } -void audioSetNextBuffer(const s16 *buf, u32 len) +/* + * D204/F1 -- correct AI_LEN_REG semantics. ROBUSTNESS, not a bug fix: + * measured A/B (M-64) shows this changes no observable behaviour today. + * + * src/audi.c:531 sizes every audio block as + * frameSamples = (u16)((g_FrameSize - (osAiGetLength() >> 2) + 16 + 0x25) & ~0xf) + * clamped below at g_MinFrameSize. On the N64 osAiGetLength() reads + * AI_LEN_REG: the bytes left in the ONE buffer the DAC is currently playing, + * bounded by g_MaxFrameSize*4, so the term acts as a +-64-sample trim. + * + * Reporting SDL's whole queue depth instead lets that subtraction (done in + * u32) wrap once the queue exceeds g_FrameSize + 0x35 frames, which it does + * routinely -- measured high-water 2064 frames over a 5 min run. That wrap + * is HARMLESS in practice and I verified it rather than assuming: the result + * is stored into `s16 frameSamples` (audi.c:145), so it lands negative and + * audi.c's own `(s32)frameSamples < g_MinFrameSize` clamp catches it, giving + * a nominal 720-sample frame. Max block observed stays 3136 bytes against + * the 3156-byte info->data allocation -- there is no heap overrun. + * + * Bounding the report to one buffer is still the right thing: it matches the + * hardware the game was written against, keeps the arithmetic inside its + * designed range instead of relying on a signed-overflow accident, and costs + * one compare. AGENTS.md rule #2: it belongs here in port/, not in audi.c. + */ +u32 audioGetAiLengthBytes(void) { - nextBuf = buf; - nextSize = len; + u32 queued = dev ? SDL_GetQueuedAudioSize(dev) : 0; + + if (d204OldMode()) { + return queued; /* pre-fix behaviour, for A/B measurement only */ + } + + /* D204/F5 -- shift the regulator's setpoint off zero. THIS is the part + * that measurably changes behaviour. + * + * audi.c only asks for more than g_MinFrameSize (720) once the reported + * length drops under ~69 frames. 720 samples per 30 Hz audio frame is + * 21600/s against a 22050 Hz device, so at the nominal rate the pipeline + * is structurally ~2 % short and relies on those top-up frames to make it + * back. On the N64 a 69-frame setpoint is fine -- AI is double-buffered + * and the VI interrupt is exact. On PC it means the loop does not react + * until the SDL queue is 3 ms from empty, which OS scheduling jitter + * clears easily: measured rt=0.980 sustained with q reaching 0, i.e. the + * device is padding ~2 % of playback with silence, permanently. That lost + * time is unrecoverable, because a queue-depth reading cannot tell the + * regulator it has already fallen behind. + * + * Subtracting a target cushion makes "queue is at AUDIO_TARGET_FRAMES" + * read as "queue is empty", so the loop tops up while there is still + * ~46 ms of slack, and settles just above the cushion instead of + * oscillating into starvation. Purely a port-side setpoint change: audi.c + * still runs its own unmodified control law. */ + { + const u32 targetBytes = AUDIO_TARGET_FRAMES * 4u; + queued = (queued > targetBytes) ? (queued - targetBytes) : 0u; + } + + if (lastBufferBytes && queued > lastBufferBytes) { + queued = lastBufferBytes; + } + return queued; } -void audioEndFrame(void) +void audioSetNextBuffer(const s16 *buf, u32 len) { - if (nextBuf && nextSize && dev) { + if (!s_audioDumpChecked) { + s_audioDumpChecked = 1; + if (getenv("GE_AUDIODUMP")) { + s_audioDumpFile = fopen("audiodump.raw", "wb"); + } + } + (void)d204OldMode(); /* D204 diag: ensure the queueLimit override applies */ + + /* D204 audio-health monitor: GE_D204=1 prints one line every 5 s of wall + * clock with everything needed to see the pipeline degrade in real time: + * + * rt= produced audio seconds / wall seconds. 1.00 is healthy; a + * sustained value below 1 means the 30 Hz retrace that drives + * amMain is being starved, and audio is being generated slower + * than the DAC eats it (gaps, stutter). + * q= SDL queue depth in frames. Should sit shallow (GE's regulator + * in audi.c:531 only asks for more than g_MinFrameSize once the + * queue is under ~69 frames). Climbing toward queueLimit means + * overproduction, and once it pins there every new block is + * dropped -- the "eventually no audio except a stuck loop" state. + * drop= blocks discarded because the queue was full (cumulative). + * max= largest block audi.c has handed us, vs its g_MaxFrameSize*4 + * allocation. + * + * Deliberately cheap (one clock read + one counter per block, ~30/s) so + * it can be left on for a full playtest -- unlike GE_MIXERTRACE, whose + * unbuffered per-opcode log slows the process enough to starve the audio + * thread on its own (that artifact is what M-63 measured as "13 % of + * real time"; see docs/dev/findings.md D204). Remove once D204 closes. */ + if (getenv("GE_D204")) { + static u64 startUs = 0, nextReportUs = 0, produced = 0; + static u32 maxLen = 0; + u64 now = sysGetMicroseconds(); + if (!startUs) { startUs = now; nextReportUs = now + 5000000ull; } + produced += len; + if (len > maxLen) maxLen = len; + if (now >= nextReportUs) { + u64 wallUs = now - startUs; + /* produced bytes / 4 = frames; / 22050 = seconds of audio */ + u64 rtx1000 = wallUs ? (produced / 4ull) * 1000000ull / 22050ull * 1000ull / wallUs : 0; + sysLogPrintf(LOG_NOTE, + "D204 audio health t=%llus rt=%llu.%03llu q=%d/%d drop=%u max=%u/%u", + (unsigned long long)(wallUs / 1000000ull), + (unsigned long long)(rtx1000 / 1000), (unsigned long long)(rtx1000 % 1000), + (int)audioGetSamplesBuffered(), queueLimit, + (unsigned)dropCount, (unsigned)maxLen, + (unsigned)(g_MaxFrameSize * 4u)); + nextReportUs = now + 5000000ull; + } + } + + /* D204/F4: invariant guard, believed unreachable. info->data is allocated + * for exactly g_MaxFrameSize samples (audi.c:388), so a longer block means + * alAudioFrame() has already run off the end of it. audi.c's own signed + * clamp should make that impossible (see audioGetAiLengthBytes above), and + * no run has ever tripped this -- but "impossible" is worth one compare + * per block when the alternative is a silent heap overrun. */ + if (g_MaxFrameSize && len > g_MaxFrameSize * 4u) { + if (oversizeCount++ < 8) { + sysLogPrintf(LOG_ERROR, + "D204: oversized audio block %u bytes (info->data alloc %u) -- " + "audi.c frameSamples escaped its clamp; heap already overrun", + (unsigned)len, (unsigned)(g_MaxFrameSize * 4u)); + } + return; + } + + if (s_audioDumpFile && buf && len) { + fwrite(buf, 1, len, s_audioDumpFile); + s_audioDumpBytesWritten += len; + } + if (dev && buf && len) { if (audioGetSamplesBuffered() < queueLimit) { - SDL_QueueAudio(dev, nextBuf, nextSize); + SDL_QueueAudio(dev, buf, len); + lastBufferBytes = len; + } else if (dropCount++ % 128 == 0) { + /* D204/F3: dropping here used to be silent, so overproduction + * surfaced only as unexplained dropouts. */ + sysLogPrintf(LOG_NOTE, + "D204: audio queue full (%d/%d frames), dropped block #%u", + (int)audioGetSamplesBuffered(), queueLimit, (unsigned)dropCount); } - nextBuf = NULL; - nextSize = 0; } } diff --git a/port/src/libultra.c b/port/src/libultra.c index 872718ed..9b18ee99 100644 --- a/port/src/libultra.c +++ b/port/src/libultra.c @@ -851,14 +851,17 @@ s32 osAiSetFrequency(u32 hz) } s32 osAiSetNextBuffer(void *buf, u32 size) { - /* TODO(Phase 3): hand the mixed buffer to audioSetNextBuffer(). */ - (void)buf; (void)size; + audioSetNextBuffer((const s16 *)buf, size); return 1; } u32 osAiGetLength(void) { - /* TODO(Phase 3): return audioGetSamplesBuffered() in the right units. */ - return 0; + /* D204/F1: AI_LEN_REG is the bytes remaining in the buffer the DAC is + * CURRENTLY playing -- not the whole queue. Reporting the full SDL queue + * depth here pinned src/audi.c:531's frame-size regulator at its lower + * clamp and let its u32 subtraction wrap into a 74x heap overrun. See the + * long comment on audioGetAiLengthBytes() in port/src/audio.c. */ + return audioGetAiLengthBytes(); } void osAiSetConvert(u32 convert) { (void)convert; } @@ -1582,3 +1585,45 @@ void viInit(void) /* VI debug message queue (normally src/vi.c, EXCLUDED). fr.c references it. */ OSMesgQueue vi_c_debug_MQ; + +/* ------------------------------------------------------------------------ */ +/* D202/M-70: serialized trace writer for the GE_AUDIOTRACE / WIRE probes. */ +/* See port/include/audiotrace.h for why (cross-thread mid-line */ +/* interleaving corrupted the M-69 corpus). */ +/* ------------------------------------------------------------------------ */ +#include +#include + +static volatile int geTraceLock = 0; + +struct geTraceFile { const char *path; FILE *f; }; +static struct geTraceFile geTraceFiles[4]; + +void geTracePrintf(const char *path, const char *fmt, ...) +{ + struct geTraceFile *tf = NULL; + va_list ap; + + while (__sync_lock_test_and_set(&geTraceLock, 1)) { /* spin */ } + + for (int i = 0; i < (int)(sizeof(geTraceFiles) / sizeof(geTraceFiles[0])); i++) { + if (!geTraceFiles[i].path) { + if (!tf) tf = &geTraceFiles[i]; + continue; + } + if (strcmp(geTraceFiles[i].path, path) == 0) { tf = &geTraceFiles[i]; break; } + } + if (tf && !tf->f) { + tf->path = path; + tf->f = fopen(path, "a"); + if (tf->f) setvbuf(tf->f, NULL, _IONBF, 0); + } + + if (tf && tf->f) { + va_start(ap, fmt); + vfprintf(tf->f, fmt, ap); + va_end(ap); + } + + __sync_lock_release(&geTraceLock); +} diff --git a/port/src/mixer.c b/port/src/mixer.c index 663cfc8f..a6d80d21 100644 --- a/port/src/mixer.c +++ b/port/src/mixer.c @@ -1,31 +1,535 @@ /* - * Audio mixing. + * Audio mixing — software implementation of GE's RSP audio ucode opcodes. * - * On the N64 the RSP audio ucode does final mix / sample handling before the - * AI DMA. With the RSP emulated in software, this runs on the CPU. + * On the N64 the RSP audio ucode (aspMain) executes an acmd list built by + * libaudio/naudio to do ADPCM decode, resampling, and the final envelope + * mix. The RSP is never run on PC (port/src/ucode.c); instead every aXxx + * macro in include/PR/abi.h is redefined (port/include/mixer.h, included + * under #ifdef PORT) to call one of the Impl functions below immediately, + * against a small software "DMEM" scratch buffer — the Perfect Dark PC + * port's macro-swap trick (docs/dev/AUDIO-PLAN.md), adapted to GE's ABI. * - * Modelled on the PD port's port/src/mixer.c (~720 lines). + * GE uses the classic IDO libaudio ABI, not PD's differently-shaped naudio + * "New" ABI — verified against every call site in src/libultra/audio/*.c + * and src/libultrare/audio/{env,reverb}.c. The DSP algorithms below (ADPCM + * decode, linear resample, linear envelope mix) are the same Nintendo/SGI + * ucode math PD's port/src/mixer.c ported (scalar path); only the ABI/ + * addressing layer here is GE-specific. * - * STATUS: scaffolding stub — implement during Phase 3. The exact interface - * depends on how GE's libaudio output is wired (see src/audi.c). + * GE's aSetBuffer(flags, in, out, count) sets a small persistent context + * that several opcodes read instead of taking DMEM addresses/counts + * directly (see MixerCtx below) — reconstructed from reading every call + * site (docs/dev/findings.md D199), not from RSP disassembly. */ +#include +#include +#include + +#include +#include +#include "system.h" + +/* D202 diag (temporary): per-opcode DMEM address/context trace, gated by + * GE_MIXERTRACE=1, to test the M-56 "concurrent voices share/clobber DMEM + * context" hypothesis directly against a live repro. Remove once + * root-caused. */ +extern char *getenv(const char *); +static FILE *sMixerTraceFile = NULL; +static int sMixerTraceChecked = 0; +static int mixerTraceOn(void) +{ + if (!sMixerTraceChecked) { + sMixerTraceChecked = 1; + if (getenv("GE_MIXERTRACE")) { + sMixerTraceFile = fopen("mixertrace.log", "a"); + if (sMixerTraceFile) setvbuf(sMixerTraceFile, NULL, _IONBF, 0); + } + } + return sMixerTraceFile != NULL; +} +#define MTRACE(...) do { if (mixerTraceOn()) fprintf(sMixerTraceFile, __VA_ARGS__); } while (0) + #include "platform.h" +#include "system.h" #include "mixer.h" #include "audio.h" +/* Largest DMEM address GE's audio call sites use is AL_AUX_R_OUT (2048) + + * up to AL_MAX_RSP_SAMPLES (160) samples * 2 bytes; round up generously. */ +#define DMEM_SIZE 4096 + +static u8 sDmem[DMEM_SIZE]; + +#define DMEM_U8(a) (sDmem + (a)) +#define DMEM_S16(a) ((s16 *)(sDmem + (a))) + +static inline s16 mixerClamp16(s32 v) +{ + if (v < -0x8000) return -0x8000; + if (v > 0x7fff) return 0x7fff; + return (s16)v; +} + +/* + * Persistent context set by aSetBuffer, read by opcodes that don't carry + * their own DMEM addresses/counts. Reconstructed from call-site pairs: + * + * resample.c: aSetBuffer(0, inp, *outp, outCnt<<1); aResample(...) + * load.c: aSetBuffer(0, dmemAddr, 0, nbytes...); aLoadBuffer(dram) + * reverb.c: aSetBuffer(0, 0, buff, count<<1); aSaveBuffer(dram) + * mainbus.c: aSetBuffer(0, 0, 0, outCount<<1); aMix(...); aMix(...) + * save.c: aSetBuffer(0, 0, 0, outCount<<1); aInterleave(L, R) + * env.c: aSetBuffer(A_MAIN, *inp, AL_MAIN_L_OUT+*outp, outCount<<1); + * aSetBuffer(A_AUX, AL_MAIN_R_OUT+*outp, AL_AUX_L_OUT+*outp, + * AL_AUX_R_OUT+*outp); + * aEnvMixer(...) + * + * i.e. LoadBuffer/ADPCMdec/Resample read `in` as their DMEM source (or, for + * LoadBuffer, as the load destination); Resample/EnvMixer read `out` as a + * destination too; SaveBuffer/Interleave read `out` as their DMEM source; + * Mix/DMEMMove/ClearBuffer take explicit addresses and don't touch this + * context at all. The A_AUX-flagged aSetBuffer call packs three more + * envmixer-only DMEM addresses (dry-R, wet-L, wet-R) — its third argument + * is a DMEM address here, not a byte count, unlike every other opcode's use + * of aSetBuffer's `count` field. + */ +static struct { + u16 in; + u16 out; + u32 count; + u16 dryR; + u16 wetL; + u16 wetR; +} sCtx; + +void aSetBufferImpl(u32 flags, u16 i, u16 o, u16 c) +{ + if (flags & A_AUX) { + sCtx.dryR = i; + sCtx.wetL = o; + sCtx.wetR = c; + } else { + sCtx.in = i; + sCtx.out = o; + sCtx.count = c; + } + MTRACE("[SETBUF] flags=%u i=%u o=%u c=%u\n", flags, i, o, c); +} + +void aClearBufferImpl(u16 addr, u32 count) +{ + /* D202/M-65 diag (temporary): alMainBusPull's clear of AL_MAIN_L_OUT is + * the first opcode of every audio frame, so it is an exact frame + * boundary. GE_DMEMWIPE=1 zeroes the scratch region below AL_MAIN_L_OUT + * (AL_TEMP_0/1/2, AL_DECODER_IN, AL_RESAMPLER_OUT) there. Nothing on real + * hardware may read those without writing them first in the same command + * list, so this must be a no-op; if it silences the stuck drone, some + * opcode is reading scratch DMEM left over from the previous frame. + * Remove once root-caused. */ + if (addr == 1088 /* AL_MAIN_L_OUT */ && getenv("GE_DMEMWIPE")) { + memset(sDmem, 0, sizeof(sDmem)); + } + memset(DMEM_U8(addr), 0, count); +} + + +void aLoadBufferImpl(u32 dramAddr) +{ + if (mixerTraceOn()) { + const u8 *src = (const u8 *)osPhysicalToVirtual(dramAddr); + u32 n = (sCtx.count > 72 ? 72 : sCtx.count); + u32 k; + fprintf(sMixerTraceFile, "[LOADBUF] dram=0x%08x -> in=%u count=%u bytes=", dramAddr, sCtx.in, sCtx.count); + for (k = 0; k < n; k++) fprintf(sMixerTraceFile, "%02x", src[k]); + fprintf(sMixerTraceFile, "\n"); + } + memcpy(DMEM_U8(sCtx.in), osPhysicalToVirtual(dramAddr), sCtx.count); +} + +void aSaveBufferImpl(u32 dramAddr) +{ + memcpy(osPhysicalToVirtual(dramAddr), DMEM_U8(sCtx.out), sCtx.count); + + (void)0; +} + +void aDMEMMoveImpl(u16 in, u16 out, u32 count) +{ + memmove(DMEM_U8(out), DMEM_U8(in), count); +} + +void aSegmentImpl(u32 seg, u32 base) +{ + /* No-op on PC: GE's audio DMA addresses are already resolved via + * osVirtualToPhysical before reaching us (D199); segment/base never + * factor into DMEM addressing here. */ + (void)seg; (void)base; +} + +/* ------------------------------------------------------------------------ */ +/* ADPCM */ +/* ------------------------------------------------------------------------ */ + +static s16 sAdpcmTable[8][2][8]; +static ADPCM_STATE *sAdpcmLoopState; + +void aLoadADPCMImpl(u32 count, u32 dramAddr) +{ + /* `count` is GE's bookSize in bytes (2 * order * npredictors * 8); + * order=2 always for this format, matching sAdpcmTable's shape. */ + u32 n = count; + void *src = osPhysicalToVirtual(dramAddr); + if (n > sizeof(sAdpcmTable)) n = sizeof(sAdpcmTable); + memcpy(sAdpcmTable, src, n); + if (mixerTraceOn()) { + int p, q, r; + fprintf(sMixerTraceFile, "[LOADADPCM] count=%u dram=%p book=", count, src); + for (p = 0; p < 8; p++) for (q = 0; q < 2; q++) for (r = 0; r < 8; r++) + fprintf(sMixerTraceFile, "%d,", sAdpcmTable[p][q][r]); + fprintf(sMixerTraceFile, "\n"); + } +} + +void aSetLoopImpl(u32 stateAddr) +{ + sAdpcmLoopState = (ADPCM_STATE *)osPhysicalToVirtual(stateAddr); +} + +void aADPCMdecImpl(u32 flags, u32 stateAddr) +{ + ADPCM_STATE *state = (ADPCM_STATE *)osPhysicalToVirtual(stateAddr); + MTRACE("[ADPCMDEC] flags=%u state=%p in=%u out=%u count=%u book0=%d\n", + flags, (void *)state, sCtx.in, sCtx.out, sCtx.count, sAdpcmTable[0][0][0]); + u8 *in = DMEM_U8(sCtx.in); + s16 *out = DMEM_S16(sCtx.out); + s32 nbytes = (s32)((sCtx.count + 31) & ~31u); /* round up to 16-sample (32-byte) chunks */ + + if (flags & A_INIT) { + memset(out, 0, 16 * sizeof(s16)); + } else if (flags & A_LOOP) { + memcpy(out, sAdpcmLoopState, 16 * sizeof(s16)); + } else { + memcpy(out, state, 16 * sizeof(s16)); + } + out += 16; + + while (nbytes > 0) { + int shift = *in >> 4; + int tableIndex = *in++ & 0xf; + s16 (*tbl)[8] = sAdpcmTable[tableIndex]; + int i, j, k; + + for (i = 0; i < 2; i++) { + s16 ins[8]; + s16 prev1 = out[-1]; + s16 prev2 = out[-2]; + + for (j = 0; j < 4; j++) { + ins[j * 2] = (s16)((((*in >> 4) << 28) >> 28) << shift); + ins[j * 2 + 1] = (s16)((((*in++ & 0xf) << 28) >> 28) << shift); + } + for (j = 0; j < 8; j++) { + s32 acc = tbl[0][j] * prev2 + tbl[1][j] * prev1 + ((s32)ins[j] << 11); + for (k = 0; k < j; k++) { + acc += tbl[1][(j - k) - 1] * ins[k]; + } + acc >>= 11; + *out++ = mixerClamp16(acc); + } + } + nbytes -= 16 * (s32)sizeof(s16); + } + + memcpy(state, out - 16, 16 * sizeof(s16)); + + if (mixerTraceOn()) { + s16 *dumpOut = DMEM_S16(sCtx.out) + 16; /* skip the 16-sample history/init lead-in */ + u32 n = (sCtx.count > 64 ? 64 : sCtx.count); + u32 k; + fprintf(sMixerTraceFile, "[PCMOUT] book0=%d first-decoded-frame[0..%u]=", sAdpcmTable[0][0][0], n / 2 - 1); + for (k = 0; k < n / 2; k++) fprintf(sMixerTraceFile, "%d,", dumpOut[k]); + fprintf(sMixerTraceFile, "\n"); + } +} + +/* ------------------------------------------------------------------------ */ +/* Resample (linear, N64 64-phase table) */ +/* ------------------------------------------------------------------------ */ + +static const s16 sResampleTable[64][4] = { + {0x0c39, 0x66ad, 0x0d46, 0xffdf}, {0x0b39, 0x6696, 0x0e5f, 0xffd8}, + {0x0a44, 0x6669, 0x0f83, 0xffd0}, {0x095a, 0x6626, 0x10b4, 0xffc8}, + {0x087d, 0x65cd, 0x11f0, 0xffbf}, {0x07ab, 0x655e, 0x1338, 0xffb6}, + {0x06e4, 0x64d9, 0x148c, 0xffac}, {0x0628, 0x643f, 0x15eb, 0xffa1}, + {0x0577, 0x638f, 0x1756, 0xff96}, {0x04d1, 0x62cb, 0x18cb, 0xff8a}, + {0x0435, 0x61f3, 0x1a4c, 0xff7e}, {0x03a4, 0x6106, 0x1bd7, 0xff71}, + {0x031c, 0x6007, 0x1d6c, 0xff64}, {0x029f, 0x5ef5, 0x1f0b, 0xff56}, + {0x022a, 0x5dd0, 0x20b3, 0xff48}, {0x01be, 0x5c9a, 0x2264, 0xff3a}, + {0x015b, 0x5b53, 0x241e, 0xff2c}, {0x0101, 0x59fc, 0x25e0, 0xff1e}, + {0x00ae, 0x5896, 0x27a9, 0xff10}, {0x0063, 0x5720, 0x297a, 0xff02}, + {0x001f, 0x559d, 0x2b50, 0xfef4}, {0xffe2, 0x540d, 0x2d2c, 0xfee8}, + {0xffac, 0x5270, 0x2f0d, 0xfedb}, {0xff7c, 0x50c7, 0x30f3, 0xfed0}, + {0xff53, 0x4f14, 0x32dc, 0xfec6}, {0xff2e, 0x4d57, 0x34c8, 0xfebd}, + {0xff0f, 0x4b91, 0x36b6, 0xfeb6}, {0xfef5, 0x49c2, 0x38a5, 0xfeb0}, + {0xfedf, 0x47ed, 0x3a95, 0xfeac}, {0xfece, 0x4611, 0x3c85, 0xfeab}, + {0xfec0, 0x4430, 0x3e74, 0xfeac}, {0xfeb6, 0x424a, 0x4060, 0xfeaf}, + {0xfeaf, 0x4060, 0x424a, 0xfeb6}, {0xfeac, 0x3e74, 0x4430, 0xfec0}, + {0xfeab, 0x3c85, 0x4611, 0xfece}, {0xfeac, 0x3a95, 0x47ed, 0xfedf}, + {0xfeb0, 0x38a5, 0x49c2, 0xfef5}, {0xfeb6, 0x36b6, 0x4b91, 0xff0f}, + {0xfebd, 0x34c8, 0x4d57, 0xff2e}, {0xfec6, 0x32dc, 0x4f14, 0xff53}, + {0xfed0, 0x30f3, 0x50c7, 0xff7c}, {0xfedb, 0x2f0d, 0x5270, 0xffac}, + {0xfee8, 0x2d2c, 0x540d, 0xffe2}, {0xfef4, 0x2b50, 0x559d, 0x001f}, + {0xff02, 0x297a, 0x5720, 0x0063}, {0xff10, 0x27a9, 0x5896, 0x00ae}, + {0xff1e, 0x25e0, 0x59fc, 0x0101}, {0xff2c, 0x241e, 0x5b53, 0x015b}, + {0xff3a, 0x2264, 0x5c9a, 0x01be}, {0xff48, 0x20b3, 0x5dd0, 0x022a}, + {0xff56, 0x1f0b, 0x5ef5, 0x029f}, {0xff64, 0x1d6c, 0x6007, 0x031c}, + {0xff71, 0x1bd7, 0x6106, 0x03a4}, {0xff7e, 0x1a4c, 0x61f3, 0x0435}, + {0xff8a, 0x18cb, 0x62cb, 0x04d1}, {0xff96, 0x1756, 0x638f, 0x0577}, + {0xffa1, 0x15eb, 0x643f, 0x0628}, {0xffac, 0x148c, 0x64d9, 0x06e4}, + {0xffb6, 0x1338, 0x655e, 0x07ab}, {0xffbf, 0x11f0, 0x65cd, 0x087d}, + {0xffc8, 0x10b4, 0x6626, 0x095a}, {0xffd0, 0x0f83, 0x6669, 0x0a44}, + {0xffd8, 0x0e5f, 0x6696, 0x0b39}, {0xffdf, 0x0d46, 0x66ad, 0x0c39} +}; + +void aResampleImpl(u32 flags, u16 pitch, u32 stateAddr) +{ + RESAMPLE_STATE *stateBuf = (RESAMPLE_STATE *)osPhysicalToVirtual(stateAddr); + s16 *state = (s16 *)stateBuf; + MTRACE("[RESAMPLE] flags=%u pitch=%u state=%p in=%u out=%u count=%u\n", + flags, pitch, (void *)stateBuf, sCtx.in, sCtx.out, sCtx.count); + s16 tmp[16]; + s16 *inInitial = DMEM_S16(sCtx.in); + s16 *in = inInitial; + s16 *out = DMEM_S16(sCtx.out); + s32 nbytes = (s32)((sCtx.count + 15) & ~15u); + u32 pitchAccumulator; + s32 i; + + if (flags & A_INIT) { + memset(tmp, 0, 5 * sizeof(s16)); + } else { + memcpy(tmp, state, 16 * sizeof(s16)); + } + in -= 4; + pitchAccumulator = (u16)tmp[4]; + memcpy(in, tmp, 4 * sizeof(s16)); + + do { + for (i = 0; i < 8; i++) { + const s16 *tbl = sResampleTable[(pitchAccumulator * 64) >> 16]; + s32 sample = ((in[0] * tbl[0] + 0x4000) >> 15) + + ((in[1] * tbl[1] + 0x4000) >> 15) + + ((in[2] * tbl[2] + 0x4000) >> 15) + + ((in[3] * tbl[3] + 0x4000) >> 15); + *out++ = mixerClamp16(sample); + + pitchAccumulator += (u32)pitch << 1; + in += pitchAccumulator >> 16; + pitchAccumulator %= 0x10000; + } + nbytes -= 8 * (s32)sizeof(s16); + } while (nbytes > 0); + + state[4] = (s16)pitchAccumulator; + memcpy(state, in, 4 * sizeof(s16)); + i = (s32)((in - inInitial + 4) & 7); + in -= i; + if (i != 0) i = -8 - i; + state[5] = (s16)i; + memcpy(state + 8, in, 8 * sizeof(s16)); +} + +/* ------------------------------------------------------------------------ */ +/* Interleave / DMEM mix */ +/* ------------------------------------------------------------------------ */ + +void aInterleaveImpl(u16 l, u16 r) +{ + const s16 *lp = DMEM_S16(l); + const s16 *rp = DMEM_S16(r); + s16 *d = DMEM_S16(sCtx.out); + u32 n = sCtx.count >> 1; /* mono sample count */ + u32 i; + + for (i = 0; i < n; i++) { + *d++ = *lp++; + *d++ = *rp++; + } +} + +void aMixImpl(u32 flags, u16 gain, u16 in, u16 out) +{ + const s16 *inp = DMEM_S16(in); + s16 *outp = DMEM_S16(out); + u32 n = sCtx.count >> 1; + u32 i; + (void)flags; + + for (i = 0; i < n; i++) { + s32 sample = ((s32)*outp * 0x7fff + (s32)*inp++ * (s16)gain + 0x4000) >> 15; + *outp++ = mixerClamp16(sample); + } +} + +/* ------------------------------------------------------------------------ */ +/* Volume / envelope mixer */ +/* ------------------------------------------------------------------------ */ + +static struct { + s16 volCur[2]; + s16 volTgt[2]; + s32 volRate[2]; + s16 dryamt, wetamt; +} sVol; + +void aSetVolumeImpl(u32 flags, u16 v, u16 t, u16 r) +{ + if (flags & A_AUX) { + sVol.dryamt = (s16)v; + sVol.wetamt = (s16)r; + /* D202/M-65 diag (temporary): GE_NOWET=1 stops any signal entering + * the reverb send. The reverb delay lines are the only audio state + * that survives across frames in DRAM, and aPoleFilterImpl -- the + * damping in that feedback path -- is an unimplemented no-op here + * (D199). If muting the send silences the stuck drone, the drone is + * an undamped reverb feedback loop, not a stuck voice. + * Remove once root-caused. */ + if (getenv("GE_NOWET")) { + sVol.wetamt = 0; + } + } else if (flags & A_VOL) { + int ch = (flags & A_LEFT) ? 0 : 1; + sVol.volCur[ch] = (s16)v; + } else { + int ch = (flags & A_LEFT) ? 0 : 1; + sVol.volTgt[ch] = (s16)v; + sVol.volRate[ch] = ((u32)t << 16) | (u16)r; + } +} + +/* D202/M-67 diag (temporary): GE_VOICEDUMP=1 writes each voice's resampled + * mono stream (the aEnvMixer input, i.e. post-resample/pre-envelope) to + * voicedump.raw as records of [u32 stateAddr][u32 nSamples][u64 us] + * [s16 x nSamples], where us is sysGetMicroseconds() at mix time so records + * can be correlated with the timestamped [WIRE] (load.c) and [AUDIOTRACE] + * (snd.c) lines. Offline, each record is matched against all 261 ROM SFX + * decodes (pitch-resampled per keymap) to prove exactly which sample data a + * voice played, with no music/reverb masking. Remove once D202 closes. */ +static FILE *s_voiceDumpFile = NULL; + +void aEnvMixerImpl(u32 flags, u32 stateAddr) +{ + struct { + s32 t[2]; + s32 rate[2]; + s16 tgt[2]; + s16 voldry; + s16 volwet; + } *saved = (void *)osPhysicalToVirtual(stateAddr); + + MTRACE("[ENVMIX] flags=%u state=%p in=%u out=%u dryR=%u wetL=%u wetR=%u count=%u\n", + flags, (void *)saved, sCtx.in, sCtx.out, sCtx.dryR, sCtx.wetL, sCtx.wetR, sCtx.count); + const s16 *in = DMEM_S16(sCtx.in); + + if (getenv("GE_VOICEDUMP")) { + if (!s_voiceDumpFile) + s_voiceDumpFile = fopen("voicedump.raw", "wb"); + if (s_voiceDumpFile) { + u32 hdr[2] = { stateAddr, (u32)(sCtx.count >> 1) }; + u64 us = sysGetMicroseconds(); + fwrite(hdr, 4, 2, s_voiceDumpFile); + fwrite(&us, 8, 1, s_voiceDumpFile); + fwrite(in, sizeof(s16), sCtx.count >> 1, s_voiceDumpFile); + } + } + + s16 *dry[2] = { DMEM_S16(sCtx.out), DMEM_S16(sCtx.dryR) }; + s16 *wet[2] = { DMEM_S16(sCtx.wetL), DMEM_S16(sCtx.wetR) }; + u32 nsamples = sCtx.count >> 1; + + s32 t[2], tgt[2], rate[2]; + s16 voldry, volwet; + u32 i; + int j; + + if (flags & A_INIT) { + for (j = 0; j < 2; j++) { + t[j] = sVol.volCur[j] << 16; + rate[j] = sVol.volRate[j] >> 3; + tgt[j] = sVol.volTgt[j] << 16; + } + voldry = sVol.dryamt; + volwet = sVol.wetamt; + } else { + for (j = 0; j < 2; j++) { + t[j] = saved->t[j]; + rate[j] = saved->rate[j]; + tgt[j] = saved->tgt[j] << 16; + } + voldry = saved->voldry; + volwet = saved->volwet; + } + + for (i = 0; i < nsamples; i++) { + s16 gain[4]; + s16 vol[2]; + s16 insamp = in[i]; + + for (j = 0; j < 2; j++) { + t[j] += rate[j]; + if ((rate[j] <= 0 && t[j] <= tgt[j]) || (rate[j] > 0 && t[j] >= tgt[j])) { + t[j] = tgt[j]; + rate[j] = 0; + } + vol[j] = (s16)(t[j] >> 16); + } + + gain[0] = mixerClamp16(((s32)vol[0] * voldry + 0x4000) >> 15); + gain[1] = mixerClamp16(((s32)vol[1] * voldry + 0x4000) >> 15); + gain[2] = mixerClamp16(((s32)vol[0] * volwet + 0x4000) >> 15); + gain[3] = mixerClamp16(((s32)vol[1] * volwet + 0x4000) >> 15); + + dry[0][i] = mixerClamp16(dry[0][i] + (((s32)insamp * gain[0]) >> 15)); + dry[1][i] = mixerClamp16(dry[1][i] + (((s32)insamp * gain[1]) >> 15)); + wet[0][i] = mixerClamp16(wet[0][i] + (((s32)insamp * gain[2]) >> 15)); + wet[1][i] = mixerClamp16(wet[1][i] + (((s32)insamp * gain[3]) >> 15)); + } + + for (j = 0; j < 2; j++) { + saved->t[j] = t[j]; + saved->rate[j] = rate[j]; + saved->tgt[j] = (s16)(tgt[j] >> 16); + } + saved->voldry = voldry; + saved->volwet = volwet; +} + +/* ------------------------------------------------------------------------ */ +/* Pole filter (reverb/chorus lowpass) */ +/* ------------------------------------------------------------------------ */ + +void aPoleFilterImpl(u32 flags, u16 gain, u32 stateAddr) +{ + /* PC port (D199): identity passthrough — reverb/chorus tone-shaping + * (CUSTOM_FX_PARAMS_N) is not implemented. Affects reverb/chorus + * character only, not silence/garbling (docs/dev/AUDIO-PLAN.md risk 1). + * DMEM in-place buffer (sCtx set by the caller's aSetBuffer(buf,buf,..)) + * is left untouched, which is the correct no-op for this filter shape. */ + (void)flags; (void)gain; (void)stateAddr; +} + +/* ------------------------------------------------------------------------ */ + void mixerInit(void) { - /* TODO(Phase 3) */ + memset(sDmem, 0, sizeof(sDmem)); + memset(sAdpcmTable, 0, sizeof(sAdpcmTable)); + memset(&sCtx, 0, sizeof(sCtx)); + memset(&sVol, 0, sizeof(sVol)); + sAdpcmLoopState = NULL; } void mixerDestroy(void) { - /* TODO(Phase 3) */ -} - -void mixerProcess(const s16 *in, u32 frames) -{ - /* TODO(Phase 3): process + queue via audioSetNextBuffer(). */ - (void)in; (void)frames; } diff --git a/port/src/romdata.c b/port/src/romdata.c index b1be80bb..d860a0ab 100644 --- a/port/src/romdata.c +++ b/port/src/romdata.c @@ -418,8 +418,11 @@ void romdataFixupMusicSeqTable(u8 *blob, u32 blobSize) u32 count = *(const u16 *)(blob + 0); u32 maxEntries = (blobSize - 4) / 8; if (count > maxEntries) { - sysLogPrintf(LOG_ERROR, - "romdataFixupMusicSeqTable: seqCount %u exceeds blob capacity %u", + /* Expected, not an error: music.c first decodes a 0x10-byte header + * probe (capacity 1) before re-copying the full table and calling + * again. Log at NOTE so it isn't mistaken for broken music data. */ + sysLogPrintf(LOG_NOTE, + "romdataFixupMusicSeqTable: partial decode, seqCount %u > capacity %u (full table follows)", count, maxEntries); count = maxEntries; } @@ -781,6 +784,20 @@ static void afFixupEnvelope(struct afCtx *c, u32 o) afWr32(c, n + 4 * i, afRd32(c, o + 4 * i)); c->dst[n + 12] = c->src[o + 12]; /* u8 volumes: identity */ c->dst[n + 13] = c->src[o + 13]; + /* D202/M-65 diag (temporary): decayTime == -1 is what sets + * SOUND_FLAG_LOOPED, and a looped sound never releases its voice on its + * own. Print the SOURCE ROM bytes so genuine data can be told from a + * conversion artifact. Remove once root-caused. */ + if (getenv("GE_KEYMAPDUMP")) { + sysLogPrintf(LOG_NOTE, + "ENVSRC src@0x%06X -> img@0x%06X : raw=%02X%02X%02X%02X %02X%02X%02X%02X %02X%02X%02X%02X " + "attack=%d decay=%d release=%d", + o, n, + c->src[o], c->src[o+1], c->src[o+2], c->src[o+3], + c->src[o+4], c->src[o+5], c->src[o+6], c->src[o+7], + c->src[o+8], c->src[o+9], c->src[o+10], c->src[o+11], + (int)afRd32(c, o), (int)afRd32(c, o + 4), (int)afRd32(c, o + 8)); + } } static void afFixupWaveTable(struct afCtx *c, u32 o) @@ -848,6 +865,16 @@ static void afFixupSound(struct afCtx *c, u32 o) if (c->write) { c->newOff[km] = kn; memcpy(c->dst + kn, c->src + km, 6); /* ALKeyMap: all u8 */ + /* D202/M-65 diag (temporary): print the SOURCE ROM bytes of + * every ALKeyMap, so "the game sees zeros" can be separated + * from "the ROM holds zeros". Remove once root-caused. */ + if (getenv("GE_KEYMAPDUMP")) { + sysLogPrintf(LOG_NOTE, + "KEYMAP src@0x%06X -> img@0x%06X : velMin=%u velMax=%u " + "keyMin=%u keyMax=0x%02X keyBase=%u detune=%d", + km, kn, c->src[km], c->src[km + 1], c->src[km + 2], + c->src[km + 3], c->src[km + 4], (int)(s8)c->src[km + 5]); + } } } } @@ -883,6 +910,20 @@ static void afFixupInst(struct afCtx *c, u32 o) for (u32 i = 0; i < (u32)scount; i++) { /* children first */ u32 soff = afRd32(c, o + 16 + 4 * i); + /* D202/M-65 diag (temporary): dump the SOURCE bank tree per + * soundIndex -- instArray[0]->soundArray[i] is exactly how snd.c + * resolves a soundIndex. decayTime == -1 is what makes a sound + * SOUND_FLAG_LOOPED (and therefore un-releasable without an owner), + * so this says whether that flag is genuine ROM data for a given + * index or a conversion artifact. Remove once root-caused. */ + if (getenv("GE_BANKDUMP") && soff && soff + 12 <= c->srcSize) { + u32 env = afRd32(c, soff); + u32 km = afRd32(c, soff + 4); + sysLogPrintf(LOG_NOTE, + "BANKSRC inst@0x%06X soundIndex=%u sound@0x%06X env@0x%06X km@0x%06X decay=%d", + o, i, soff, env, km, + (env && env + 8 <= c->srcSize) ? (int)afRd32(c, env + 4) : 0); + } if (soff) afFixupSound(c, soff); } diff --git a/src/audi.c b/src/audi.c index 213154ad..cb91d490 100644 --- a/src/audi.c +++ b/src/audi.c @@ -7,6 +7,10 @@ #include "speed_graph.h" #if defined(PORT) #include /* PC port: memcpy for the CUSTOM_FX_PARAMS_N copy in audioInit (docs/dev/findings.md §11) */ +#if defined(__x86_64__) +#include +#include +#endif #endif /** @@ -659,6 +663,15 @@ s32 amDmaCallback(s32 addr, s32 len, void* state) /* mark it used */ dmaPtr->lastFrame = (s32) g_AudioFrameCount; freeBuffer = (dmaPtr->ptr + addr) - dmaPtr->startAddr; +#if defined(__x86_64__) + if (getenv("GE_MIXERTRACE")) { + static FILE *dtf2 = NULL; + if (!dtf2) { dtf2 = fopen("mixertrace.log", "a"); if (dtf2) setvbuf(dtf2, NULL, _IONBF, 0); } + if (dtf2) fprintf(dtf2, "[DMAHIT] addr=0x%08x len=%d bufStart=0x%08x bufPtr=%p ret=%p\n", + (unsigned)addr, (int)len, (unsigned)dmaPtr->startAddr, + dmaPtr->ptr, freeBuffer); + } +#endif return osVirtualToPhysical(freeBuffer); } @@ -718,6 +731,15 @@ s32 amDmaCallback(s32 addr, s32 len, void* state) dmaPtr->lastFrame = g_AudioFrameCount; osPiStartDma(&g_DmaIOMessageBuffer[g_NextDMa++], OS_MESG_PRI_HIGH, OS_READ, (u32)addr, freeBuffer, AUDIO_DMA_MAX_BUFFER_LENGTH, &g_DmaMessageQueue); +#if defined(__x86_64__) + if (getenv("GE_MIXERTRACE")) { + static FILE *dtf3 = NULL; + if (!dtf3) { dtf3 = fopen("mixertrace.log", "a"); if (dtf3) setvbuf(dtf3, NULL, _IONBF, 0); } + if (dtf3) fprintf(dtf3, "[DMAMISS] addr=0x%08x len=%d bufPtr=%p ret=0x%08x\n", + (unsigned)addr, (int)len, freeBuffer, + (unsigned)((s32)osVirtualToPhysical(freeBuffer) + delta)); + } +#endif return (s32)osVirtualToPhysical(freeBuffer) + delta; } diff --git a/src/game/mp_music.c b/src/game/mp_music.c index 7ff51182..9c482f2e 100644 --- a/src/game/mp_music.c +++ b/src/game/mp_music.c @@ -1,4 +1,8 @@ #include +#ifdef PORT +#include +#include "audiotrace.h" +#endif #include #include "music_0D2720.h" #include "options.h" @@ -55,6 +59,11 @@ void set_missionstate(MISSION_STATE_ID arg0) { s32 old_mission_state = mission_state; +#ifdef PORT + if (getenv("GE_AUDIOTRACE") && old_mission_state != (s32)arg0) + geTracePrintf("audiotrace.log", "[MISSIONSTATE] %d -> %d\n", (int)old_mission_state, (int)arg0); +#endif + mission_state = arg0; switch (old_mission_state) diff --git a/src/game/propobj.c b/src/game/propobj.c index d7de6fef..d0acb4fd 100644 --- a/src/game/propobj.c +++ b/src/game/propobj.c @@ -8,6 +8,12 @@ #include #include +#ifdef PORT +#include /* D202/M-65 diag probe only (doorSndProbe); remove with it */ +#include +#include "audiotrace.h" /* D202/M-71 diag probe only ([DISTVOL]); remove with it */ +#include +#endif #include #include #include @@ -12766,6 +12772,20 @@ s32 sub_GAME_7F053894(coord3d *pos, f32 low, f32 high) shortest_distance = distance; } } +#ifdef PORT + /* D202/M-71 diag: every positional-SFX volume computation funnels here. + * Log the hit pos, nearest player pos, distance and resulting vol so a + * "loud impact that N64 plays silent" can be told apart as wrong-HIT vs + * wrong-DISTANCE. Remove with probe set. */ + if (getenv("GE_AUDIOTRACE")) { + geTracePrintf("audiotrace.log", + "[DISTVOL] pos=(%.0f,%.0f,%.0f) player=(%.0f,%.0f,%.0f) dist=%.1f vol=%d\n", + (double)pos->x, (double)pos->y, (double)pos->z, + (double)prop->pos.x, (double)prop->pos.y, (double)prop->pos.z, + (double)shortest_distance, + (int)sub_GAME_7F0537B8(shortest_distance, low, high)); + } +#endif return sub_GAME_7F0537B8(shortest_distance, low, high); } @@ -12832,8 +12852,53 @@ void sub_GAME_7F053A3C(DoorRecord* arg0) } +#ifdef PORT +/* D202/M-65 diag (temporary): the two door slots are the ONLY handles on a + * door's looping SFX. doorPlayOpenSound0/1 adopt a slot only when it is + * NULL; if both are already occupied the loop starts with pendingState == + * NULL, nothing holds its state, and nothing can ever sndDeactivate it -- + * it retriggers until level exit. This probe prints the slot state at both + * ends of that contract (owner selection, and the stop path), so an + * orphaned loop is visible as an "ORPHAN" line with no later stop. + * Remove once root-caused. */ +void doorSndProbe(const char *where, DoorRecord *door, void *pendingState) +{ + static FILE *fd = NULL; + if (!getenv("GE_AUDIOTRACE")) + return; + if (!fd) { + fd = fopen("audiotrace.log", "a"); + if (fd) { + setvbuf(fd, NULL, _IONBF, 0); + /* D202/M-65: doors are carved from setup data; if the 64-bit + * DoorRecord is larger than the per-object stride the allocator + * uses, door N's sound slots land inside door N+1 and every + * looping SFX loses its owner. Print the size once so it can be + * compared against the observed record spacing. */ + fprintf(fd, "[DOORSND] sizeof(DoorRecord)=0x%X openSoundState@0x%X closeSoundState@0x%X\n", + (unsigned)sizeof(DoorRecord), + (unsigned)((char *)&door->openSoundState - (char *)door), + (unsigned)((char *)&door->closeSoundState - (char *)door)); + } + } + if (!fd) + return; + fprintf(fd, "[DOORSND] %-14s door=%p openSlot=%p(play=%d) closeSlot=%p(play=%d) sound=%d %s\n", + where, (void *)door, + (void *)door->openSoundState, + door->openSoundState ? (int)sndGetPlayingState(door->openSoundState) : -1, + (void *)door->closeSoundState, + door->closeSoundState ? (int)sndGetPlayingState(door->closeSoundState) : -1, + (int)door->doorOpenSound, + pendingState ? "owned" : "ORPHAN-both-slots-busy"); +} +#endif + void door7F053B10(DoorRecord *door) //#MATCH { +#ifdef PORT + doorSndProbe("stop-sounds", door, (void *)1); +#endif if (door->openSoundState && sndGetPlayingState(door->openSoundState)) { sndDeactivate(door->openSoundState); @@ -12863,6 +12928,10 @@ void doorPlayOpenSound0(DoorRecord *door) { pendingState = &door->closeSoundState; } +#ifdef PORT + doorSndProbe("open0", door, (void *)pendingState); /* D202/M-65 diag */ +#endif + switch (door->doorOpenSound) { case DOOR_OPEN_SOUND_01: @@ -12992,6 +13061,10 @@ void doorPlayOpenSound1(DoorRecord *door) { pendingState = &door->closeSoundState; } +#ifdef PORT + doorSndProbe("open1", door, (void *)pendingState); /* D202/M-65 diag */ +#endif + switch (door->doorOpenSound) { case DOOR_OPEN_SOUND_01: @@ -14377,6 +14450,25 @@ Gfx *countdownTimerRender(Gfx *DL) void handle_alarm_gas_timer_calldamage(void) { +#ifdef PORT + /* D207 diag probe (temporary): force + trace the alarm klaxon to + * root-cause "alarm SFX starves all other audio and never recovers". + * GE_FORCEALARM pins alarm_timer so alarmIsActive() stays true. Remove + * once root-caused. */ + if (getenv("GE_FORCEALARM")) { + static int fa_frames; + ++fa_frames; + if (fa_frames == 120 || fa_frames == 900) alarmActivate(); + if (fa_frames > 120 && fa_frames < 600) alarm_timer = 1; /* sustain on ~2..10s */ + if (fa_frames == 600) alarmDeactivate(); /* explicit off at 10s */ + if (fa_frames > 900) alarm_timer = 1; /* sustain on again */ + geTracePrintf("audiotrace.log", + "[D207] f=%d active=%d ptr_alarm_sfx=%p playstate=%d locked=%d\n", + fa_frames, (int)alarmIsActive(), (void *)ptr_alarm_sfx, + ptr_alarm_sfx ? (int)sndGetPlayingState(ptr_alarm_sfx) : -1, + (int)lvlGetControlsLockedFlag()); + } +#endif if (alarmIsActive() != 0) { if ((ptr_alarm_sfx == 0) && (lvlGetControlsLockedFlag() == 0)) diff --git a/src/libultra/audio/bnkf.c b/src/libultra/audio/bnkf.c index e49ef36b..55cbd24d 100644 --- a/src/libultra/audio/bnkf.c +++ b/src/libultra/audio/bnkf.c @@ -21,19 +21,30 @@ #include #include #include +#include /* * ### when the file format settles down a little, I'll remove these * ### for efficiency. + * + * PC port (D201): these relocation offsets were `s32`, truncating the real + * 64-bit base pointer. On N64 (32-bit pointers) that's a no-op; on PC it + * silently corrupted every bank/inst/sound/wavetable/book/loop pointer + * relocated below whenever the base allocation's low 32 bits had the top + * bit set (~50% of allocations, allocator-dependent) — sign-extension of + * the truncated s32 during pointer arithmetic reconstructs the wrong 64-bit + * address. `uintptr_t` is already this codebase's idiom for pointer-safe + * integers (src/game/*.c); widening these to it changes nothing on the + * 32-bit N64 build and fixes PC. See docs/dev/findings.md D201. */ -static void _bnkfPatchBank(ALBank *bank, s32 offset, s32 table); -static void _bnkfPatchInst(ALInstrument *i, s32 offset, s32 table); -static void _bnkfPatchSound(ALSound *s, s32 offset, s32 table); -static void _bnkfPatchWaveTable(ALWaveTable *w, s32 offset, s32 table); +static void _bnkfPatchBank(ALBank *bank, uintptr_t offset, uintptr_t table); +static void _bnkfPatchInst(ALInstrument *i, uintptr_t offset, uintptr_t table); +static void _bnkfPatchSound(ALSound *s, uintptr_t offset, uintptr_t table); +static void _bnkfPatchWaveTable(ALWaveTable *w, uintptr_t offset, uintptr_t table); void alSeqFileNew(ALSeqFile *file, u8 *base) { - s32 offset = (s32) base; + uintptr_t offset = (uintptr_t) base; s32 i; /* @@ -46,9 +57,9 @@ void alSeqFileNew(ALSeqFile *file, u8 *base) void alBnkfNew(ALBankFile *file, u8 *table) { - s32 offset = (s32) file; - s32 woffset = (s32) table; - + uintptr_t offset = (uintptr_t) file; + uintptr_t woffset = (uintptr_t) table; + s32 i; /* @@ -66,7 +77,7 @@ void alBnkfNew(ALBankFile *file, u8 *table) } } -void _bnkfPatchBank(ALBank *bank, s32 offset, s32 table) +void _bnkfPatchBank(ALBank *bank, uintptr_t offset, uintptr_t table) { s32 i; @@ -88,7 +99,7 @@ void _bnkfPatchBank(ALBank *bank, s32 offset, s32 table) } } -void _bnkfPatchInst(ALInstrument *inst, s32 offset, s32 table) +void _bnkfPatchInst(ALInstrument *inst, uintptr_t offset, uintptr_t table) { s32 i; @@ -105,7 +116,7 @@ void _bnkfPatchInst(ALInstrument *inst, s32 offset, s32 table) } } -void _bnkfPatchSound(ALSound *s, s32 offset, s32 table) +void _bnkfPatchSound(ALSound *s, uintptr_t offset, uintptr_t table) { if (s->flags) return; @@ -119,7 +130,7 @@ void _bnkfPatchSound(ALSound *s, s32 offset, s32 table) _bnkfPatchWaveTable(s->wavetable, offset, table); } -void _bnkfPatchWaveTable(ALWaveTable *w, s32 offset, s32 table) +void _bnkfPatchWaveTable(ALWaveTable *w, uintptr_t offset, uintptr_t table) { if (w->flags) return; diff --git a/src/libultra/audio/csplayer.c b/src/libultra/audio/csplayer.c index 353bb362..e4669d4a 100644 --- a/src/libultra/audio/csplayer.c +++ b/src/libultra/audio/csplayer.c @@ -37,6 +37,10 @@ */ #include +#ifdef PORT +#include +#include "audiotrace.h" +#endif #include #include #include @@ -456,6 +460,11 @@ static void __CSPHandleMIDIMsg(ALCSPlayer *seqp, ALEvent *event) voice = &vstate->voice; alSynAllocVoice(seqp->drvr, voice, &config); +#ifdef PORT + if (getenv("GE_AUDIOTRACE")) + geTracePrintf("audiotrace.log", "[MUSICNOTE] csp=%p key=%d vel=%d\n", + (void *)seqp, (int)key, (int)vel); +#endif /* * set up the voice state structure diff --git a/src/libultra/audio/event.c b/src/libultra/audio/event.c index 4efc6ea5..d89bd466 100644 --- a/src/libultra/audio/event.c +++ b/src/libultra/audio/event.c @@ -21,6 +21,9 @@ #include #include #include +#ifdef PORT +#include "audiotrace.h" /* D202/M-71: evtq drop probe */ +#endif void alEvtqNew(ALEventQueue *evtq, ALEventListItem *items, s32 itemCount) @@ -83,6 +86,13 @@ void alEvtqPostEvent(ALEventQueue *evtq, ALEvent *evt, ALMicroTime delta) item = (ALEventListItem *)evtq->freeList.next; if (!item) { +#ifdef PORT + /* D202/M-71 diag: silent drop is the prime suspect for missing + * [VOICE+]; log every exhausted-queue post. Remove with probe set. */ + geTracePrintf("audiotrace.log", "[EVTQ-DROP] evtq=%p type=%d state=%p delta=%d\n", + (void *)evtq, (int)evt->type, + (void *)((u8 *)evt + 2), (int)delta); +#endif osSetIntMask(mask); #ifdef _DEBUG __osError(ERR_ALEVENTNOFREE, 0); diff --git a/src/libultra/audio/load.c b/src/libultra/audio/load.c index 8e53c11b..d3d5520e 100644 --- a/src/libultra/audio/load.c +++ b/src/libultra/audio/load.c @@ -23,6 +23,11 @@ #include "synthInternals.h" #include #include +#if defined(__x86_64__) +#include +#include +#include "audiotrace.h" /* D202/M-70: serialized trace writer */ +#endif #ifndef MIN # define MIN(a,b) (((a)<(b))?(a):(b)) @@ -38,6 +43,7 @@ extern u32 cnt_index, adpcm_num, adpcm_cnt, adpcm_max, adpcm_min, lastCnt[]; static Acmd *_decodeChunk(Acmd *ptr, ALLoadFilter *f, s32 tsam, s32 nbytes, s16 outp, s16 inp, u32 flags); + Acmd *alAdpcmPull(void *filter, s16 *outp, s32 outCount, s32 sampleOffset, Acmd *p) { Acmd *ptr = p; @@ -65,8 +71,10 @@ Acmd *alAdpcmPull(void *filter, s16 *outp, s32 outCount, s32 sampleOffset, Acmd return ptr; inp = AL_DECODER_IN; + /* PC port (D198): was K0_TO_PHYS — same corrupted-pointer bug as the + * f->state/f->lstate call sites below; see docs/dev/findings.md D198. */ aLoadADPCM(ptr++, f->bookSize, - K0_TO_PHYS(f->table->waveInfo.adpcmWave.book->book)); + osVirtualToPhysical(f->table->waveInfo.adpcmWave.book->book)); looped = (outCount + f->sample > f->loop.end) && (f->loop.count != 0); if (looped) @@ -189,6 +197,26 @@ Acmd *alAdpcmPull(void *filter, s16 *outp, s32 outCount, s32 sampleOffset, Acmd f->memin += ADPCMFBYTES*nframes; } +#if defined(__x86_64__) + /* D202/M-65 diag (temporary): a wave with no ALADPCMloop must run out and + * be zero-filled (nOver > 0) once memin passes base+len. If a voice keeps + * decoding past that point, an unstopped voice stays audible forever + * instead of going silent -- which is what "the door sound loops until + * you quit" would actually sound like. Assert it directly, rate-limited. + * Remove once root-caused. */ + if (getenv("GE_PULLTRACE")) { + static s32 reported = 0; + s32 totalSamples = (f->table->len / ADPCMFBYTES) * ADPCMFSIZE; + if (f->sample > totalSamples + ADPCMFSIZE && nOver == 0 && reported < 40) { + reported++; + geTracePrintf("audiotrace.log", "[PASTEND] filter=%p sample=%d totalSamples=%d memin=0x%08x base=0x%08x len=%d overFlow=%d nOver=%d\n", + (void *)f, (int)f->sample, (int)totalSamples, (unsigned)f->memin, + (unsigned)(s32)f->table->base, (int)f->table->len, + (int)overFlow, (int)nOver); + } + } +#endif + /* * Put zeros in if necessary */ @@ -231,6 +259,7 @@ Acmd *alRaw16Pull(void *filter, s16 *outp, s32 outCount, s32 sampleOffset, Acmd nbytes = nSam<<1; if (nSam > 0){ dramLoc = (f->dma)(f->memin, nbytes, f->dmaState); + /* * Make sure enough is loaded into DMEM to take care @@ -319,6 +348,7 @@ Acmd *alRaw16Pull(void *filter, s16 *outp, s32 outCount, s32 sampleOffset, Acmd if (outCount > 0){ nbytes -= overFlow; dramLoc = (f->dma)(f->memin, nbytes, f->dmaState); + /* * Make sure enough is loaded into DMEM to take care @@ -360,6 +390,24 @@ alLoadParam(void *filter, s32 paramID, void *param) switch (paramID) { case (AL_FILTER_SET_WAVETABLE): a->table = (ALWaveTable *) param; +#if defined(__x86_64__) + if (getenv("GE_AUDIOTRACE")) { + extern uint64_t sysGetMicroseconds(void); /* port/src/system.c */ + geTracePrintf("audiotrace_wire.log", "[WIRE] t=%llu filter=%p <- table=%p base=%p len=%d book=%p\n", + (unsigned long long)sysGetMicroseconds(), + (void *)a, (void *)a->table, (void *)a->table->base, + a->table->len, + (a->table->type == AL_ADPCM_WAVE) ? (void *)a->table->waveInfo.adpcmWave.book : NULL); + } + if (getenv("GE_MIXERTRACE")) { + static FILE *btf = NULL; + if (!btf) { btf = fopen("mixertrace.log", "a"); if (btf) setvbuf(btf, NULL, _IONBF, 0); } + if (btf) fprintf(btf, "[BINDTABLE] filter=%p <- table=%p base=%p len=%d book=%p\n", + (void *)a, (void *)a->table, (void *)a->table->base, + a->table->len, + (a->table->type == AL_ADPCM_WAVE) ? (void *)a->table->waveInfo.adpcmWave.book : NULL); + } +#endif a->memin = (s32) a->table->base; a->sample = 0; switch (a->table->type){ @@ -445,6 +493,14 @@ Acmd *_decodeChunk(Acmd *ptr, ALLoadFilter *f, s32 tsam, s32 nbytes, s16 outp, s dramLoc; if (nbytes > 0){ +#if defined(__x86_64__) + if (getenv("GE_MIXERTRACE")) { + static FILE *dtf = NULL; + if (!dtf) { dtf = fopen("mixertrace.log", "a"); if (dtf) setvbuf(dtf, NULL, _IONBF, 0); } + if (dtf) fprintf(dtf, "[DMAREQ] filter=%p memin=0x%08x nbytes=%d\n", + (void *)f, (unsigned)f->memin, (int)nbytes); + } +#endif dramLoc = (f->dma)(f->memin, nbytes, f->dmaState); /* * Make sure enough is loaded into DMEM to take care @@ -458,11 +514,18 @@ Acmd *_decodeChunk(Acmd *ptr, ALLoadFilter *f, s32 tsam, s32 nbytes, s16 outp, s dramAlign = 0; if (flags & A_LOOP){ - aSetLoop(ptr++, K0_TO_PHYS(f->lstate)); + /* PC port (D198): was K0_TO_PHYS(f->lstate), an unconditional + * `& 0x1FFFFFFF` mask that corrupts 64-bit heap pointers above + * 512MB. Every sibling call site (env.c, resample.c, reverb.c) + * already resolves state pointers via osVirtualToPhysical, which + * is bit-identical to K0_TO_PHYS for real N64 kseg0 addresses but + * PC-shimmed correctly (port/src/libultra.c). No behavior change + * on N64; see docs/dev/findings.md D198. */ + aSetLoop(ptr++, osVirtualToPhysical(f->lstate)); } - + aSetBuffer(ptr++, 0, inp + dramAlign, outp, tsam<<1); - aADPCMdec(ptr++, flags, K0_TO_PHYS(f->state)); + aADPCMdec(ptr++, flags, osVirtualToPhysical(f->state)); f->first = 0; return ptr; diff --git a/src/libultra/audio/save.c b/src/libultra/audio/save.c index 9ce161e3..fe68dd30 100644 --- a/src/libultra/audio/save.c +++ b/src/libultra/audio/save.c @@ -21,6 +21,8 @@ #include #include "synthInternals.h" #include +#if defined(__x86_64__) +#endif #include "include/assert.h" Acmd *alSavePull(void *filter, s16 *outp, s32 outCount, s32 sampleOffset, diff --git a/src/libultra/audio/synsetfxmix.c b/src/libultra/audio/synsetfxmix.c index 63a26b27..4665ba11 100644 --- a/src/libultra/audio/synsetfxmix.c +++ b/src/libultra/audio/synsetfxmix.c @@ -19,6 +19,8 @@ *====================================================================*/ #include "synthInternals.h" +#if defined(__x86_64__) +#endif #include #include diff --git a/src/libultra/audio/synsetpan.c b/src/libultra/audio/synsetpan.c index 0d68538a..7fd44436 100644 --- a/src/libultra/audio/synsetpan.c +++ b/src/libultra/audio/synsetpan.c @@ -19,6 +19,8 @@ *====================================================================*/ #include "synthInternals.h" +#if defined(__x86_64__) +#endif #include #include diff --git a/src/libultra/audio/synsetvol.c b/src/libultra/audio/synsetvol.c index 181c96f2..c8f8a73a 100644 --- a/src/libultra/audio/synsetvol.c +++ b/src/libultra/audio/synsetvol.c @@ -19,6 +19,8 @@ *====================================================================*/ #include "synthInternals.h" +#if defined(__x86_64__) +#endif #include #include diff --git a/src/libultra/audio/synthesizer.c b/src/libultra/audio/synthesizer.c index 7d28d38f..f870d287 100644 --- a/src/libultra/audio/synthesizer.c +++ b/src/libultra/audio/synthesizer.c @@ -20,6 +20,10 @@ #include "synthInternals.h" #include "include/assert.h" +#if defined(__x86_64__) +#include +#include +#endif #ifdef AUD_PROFILE #include @@ -117,6 +121,24 @@ void alSynNew(ALSynth *drvr, ALSynConfig *c) alAuxBusParam(drvr->auxBus, AL_FILTER_ADD_SOURCE, &pv->envmixer); pv->channelKnob = (ALFilter *)&pv->envmixer; + +#if defined(__x86_64__) + /* D202/M-69 diag (temporary): GE_AUDIOTRACE=1 logs the full + * PVoice -> decoder/resampler/env-state address map at synth init, + * so offline voicedump slot addresses can be tied to WIRE filter + * addresses (and hence to wave tables / sound indices). */ + if (getenv("GE_AUDIOTRACE")) { + static FILE *mf = NULL; + if (!mf) { mf = fopen("audiotrace_wire.log", "a"); if (mf) setvbuf(mf, NULL, _IONBF, 0); } + if (i == 0) + fprintf(mf, "[VOICEMAP] sizes pv=%u dec=%u res=%u env=%u\n", + (unsigned)sizeof(PVoice), (unsigned)sizeof(ALLoadFilter), + (unsigned)sizeof(ALResampler), (unsigned)sizeof(ALEnvMixer)); + if (mf) fprintf(mf, "[VOICEMAP] i=%d pv=%p dec=%p res=%p envstate=%p\n", + i, (void *)pv, (void *)&pv->decoder, + (void *)&pv->resampler, (void *)pv->envmixer.state); + } +#endif } alSaveParam(save, AL_FILTER_SET_SOURCE, drvr->mainBus); diff --git a/src/libultrare/audio/reverb.c b/src/libultrare/audio/reverb.c index 469be7e8..d47c83ef 100644 --- a/src/libultrare/audio/reverb.c +++ b/src/libultrare/audio/reverb.c @@ -92,8 +92,11 @@ Acmd *alFxPull(void *filter, s16 *outp, s32 outCount, s32 sampleOffset, for (i = 0; i < r->section_count; i++) { d = &r->delay[i]; /* get the ALDelay structure */ - in_ptr = &r->input[-d->input]; - out_ptr = &r->input[-d->output]; + /* PC port (D200): d->input/d->output are u32, so -d->x is a u32 that + zero-extends to a +4GB offset in 64-bit pointer arithmetic; negating + in s32 restores the N64's s32-ptrdiff wrap (PD ground truth). */ + in_ptr = &r->input[(s32)-d->input]; + out_ptr = &r->input[(s32)-d->output]; if (in_ptr == prev_out_ptr) { SWAP(buff1, buff2); @@ -265,8 +268,8 @@ Acmd *_loadOutputBuffer(ALFx *r, ALDelay *d, s32 buff, s32 incount, Acmd *p) * negative of that as an index into the delay buffer. loadBuffer that uses this * value then bumps it up if it is below the delay buffer. */ - out_ptr = &r->input[-(d->output - d->rsdelta)]; - ramalign = ((s32)out_ptr & 0x7) >> 1; /* calculate the number of samples needed + out_ptr = &r->input[(s32)-(d->output - d->rsdelta)]; + ramalign = ((s64)out_ptr & 0x7) >> 1; /* calculate the number of samples needed to align the buffer*/ #ifdef _DEBUG #if 0 @@ -295,7 +298,7 @@ Acmd *_loadOutputBuffer(ALFx *r, ALDelay *d, s32 buff, s32 incount, Acmd *p) d->rs->first = 0; /* turn off first time flag */ d->rsdelta += count - incount; /* add the number of samples to d->rsdelta */ } else { - out_ptr = &r->input[-d->output]; + out_ptr = &r->input[(s32)-d->output]; ptr = _loadBuffer(r, out_ptr, buff, incount, ptr); } diff --git a/src/snd.c b/src/snd.c index cfa54a29..b73aca03 100644 --- a/src/snd.c +++ b/src/snd.c @@ -3,6 +3,11 @@ #include #include "music.h" #include "snd.h" +#ifdef PORT +#include +#include +#include "audiotrace.h" /* D202/M-70: serialized trace writer */ +#endif //likely named gslibaudio.c from xbla /** * EU .data, offset from start of data_seg : 0x3620 @@ -15,6 +20,19 @@ #define DEFAULT_SETUP_PITCH_SHIFT (-0x1770) +#ifdef PORT +/* D202/M-66 (PC port only): ownerless infinite-loop SFX voices (sound 203, + * METAL_SLIDE_CLOSE_SFX, is the one in the wild) can never be stopped by the + * game -- played fire-and-forget, looped voices skip the decay->stop chain, + * and stock preemption never touches flag-0x12 voices -- so on N64 they ring + * until level exit. On PC we let them play for D202_EXPIRE_DELAY_US, then fade + * over D202_EXPIRE_FADE_US (the stock STOP ramp uses the envelope releaseTime, + * a few ms here, which would hard-cut the loop). ALMicroTime = microseconds. + * See docs/dev/findings.md D202, M-66. */ +#define D202_EXPIRE_DELAY_US 2000000 +#define D202_EXPIRE_FADE_US 500000 +#endif + /** * Based on \n64devkit\ultra\usr\src\pr\libsrc\libultra\audio\sndp.h * ALSndpEvent @@ -275,6 +293,17 @@ void sndHandleEvent(ALSndPlayer *sndp, ALSndpEvent *event) { soundState = event->common.state; sound = soundState->sound; +#ifdef PORT + /* D202/M-67 diag (temporary): log every event the player processes, + * so each VOICE- can be attributed to the exact killing event. + * Remove once root-caused. */ + if (getenv("GE_AUDIOTRACE")) { + extern uint64_t sysGetMicroseconds(void); + geTracePrintf("audiotrace.log", "[EVT] t=%llu type=%d state=%p\n", + (unsigned long long)sysGetMicroseconds(), + (int)event->common.type, (void *)soundState); + } +#endif if (sound == NULL) { s16 numFree, numAlloc; @@ -318,7 +347,27 @@ void sndHandleEvent(ALSndPlayer *sndp, ALSndpEvent *event) { ALSoundState *iterState = (ALSoundState *) D_800243E4.node.prev; do { - if (!(iterState->unk3e & 0x12) && (iterState->unk3e & 0x4) && + /* D202/M-65 (PORT, experimental): the stock scan refuses to + * preempt any looped or retriggering voice (0x12). That is + * right for a voice somebody still owns -- its owner will + * sndDeactivate it. But a SOUND_FLAG_LOOPED voice whose + * state->state is NULL was started with pendingState == NULL + * (doorPlayCloseSound0/1 do exactly this for + * METAL_SLIDE_CLOSE_SFX, whose envelope decayTime is -1), so + * no owner exists, no STOP_EVT is ever posted for it, and it + * holds one of the 8 voices until level exit. Measured: 7 of + * 8 voices held by that one sound after 115 s of Bunker + * attract. Allow reclaiming ONLY that provably-unstoppable + * case; owned loops are still protected exactly as before. + * Companion (M-66, option C): the AL_SNDP_PORT_EXPIRE_EVT + * post in PLAY_EVT fades such voices out after a bounded + * time; this guard only reclaims their SLOTS under pool + * pressure so new sounds can still start. */ + s32 ownerlessLoop = (iterState->unk3e & SOUND_FLAG_LOOPED) && + !(iterState->unk3e & SOUND_FLAG_RETRIGGER) && + iterState->state == NULL; + if ((!(iterState->unk3e & 0x12) || ownerlessLoop) && + (iterState->unk3e & 0x4) && iterState->playingState != SOUND_STATE_PREEMPT) { // Found a lower-priority sound; it can be preempted ALSndpEvent interruptEvent; @@ -358,6 +407,47 @@ void sndHandleEvent(ALSndPlayer *sndp, ALSndpEvent *event) { alSynStartVoice(sndp->drvr, &soundState->voice, sound->wavetable); soundState->playingState = SOUND_STATE_PLAYING; g_sndAllocatedVoicesCount++; +#ifdef PORT + /* D202/M-65 diag (temporary): pair every voice acquire with + * its release to find which sounds hold the 8 voices for + * good. Remove once root-caused. */ + if (getenv("GE_AUDIOTRACE")) { + geTracePrintf("audiotrace.log", "[VOICE+] sound=%p state=%p flags=%d count=%d\n", + (void *)sound, (void *)soundState, (int)soundState->unk3e, + (int)g_sndAllocatedVoicesCount); + } +#endif +#ifdef PORT + /* D202/M-66 (PORT deviation, option C): schedule the fade-out + * for a provably ownerless infinite-loop voice. Predicate: + * LOOPED (decayTime == -1) and FINAL_IN_SEQUENCE (single sound, + * so the handler's sequence walk stops at this state), not + * RETRIGGER (those are managed by PLAY_SFX/DEACTIVATE chains), + * no owner (state->state == NULL: doorPlayCloseSound0/1 plays + * with a NULL owner and nothing ever sndDeactivates it), and the + * wave itself carries an ADPCM loop with count == -1 (finite + * loops self-terminate in load.c and must not be cut short). + * Stale-event safety: any dispose of this state (preemption, + * natural end, deactivate) runs sndDisposeSound, which removes + * ALL pending events for it (sndRemoveEvents ... 0xffff), and a + * rebind always disposes first -- so this event can never fire + * against a new binding. */ + if ((soundState->unk3e & (SOUND_FLAG_LOOPED | SOUND_FLAG_RETRIGGER | SOUND_FLAG_FINAL_IN_SEQUENCE)) + == (SOUND_FLAG_LOOPED | SOUND_FLAG_FINAL_IN_SEQUENCE) && + soundState->state == NULL && + sound->wavetable != NULL && sound->wavetable->type == AL_ADPCM_WAVE && + sound->wavetable->waveInfo.adpcmWave.loop != NULL && + sound->wavetable->waveInfo.adpcmWave.loop->count == -1) { + ALSndpEvent expireEvt; + expireEvt.common.type = AL_SNDP_PORT_EXPIRE_EVT; + expireEvt.common.state = soundState; + alEvtqPostEvent(&sndp->evtq, (ALEvent *) &expireEvt, D202_EXPIRE_DELAY_US); + if (getenv("GE_AUDIOTRACE")) { + geTracePrintf("audiotrace.log", "[EXPIRE] sound=%p state=%p delay=%dus fade=%dus (ownerless infinite loop; D202/M-66)\n", + (void *)sound, (void *)soundState, (int)D202_EXPIRE_DELAY_US, (int)D202_EXPIRE_FADE_US); + } + } +#endif delta = sound->envelope->attackTime / soundState->pitch_2c / soundState->pitch_28; volume = @@ -404,6 +494,16 @@ void sndHandleEvent(ALSndPlayer *sndp, ALSndpEvent *event) { case SOUND_STATE_PLAYING: sndRemoveEvents(&sndp->evtq, soundState, AL_SNDP_DECAY_EVT); delta = sound->envelope->releaseTime / soundState->pitch_28 / soundState->pitch_2c; +#ifdef PORT + /* D202/M-67 diag (temporary): log the release ramp a + * STOP/DEACTIVATE computes; delta==0 means the voice + * is disposed immediately. Remove once root-caused. */ + if (getenv("GE_AUDIOTRACE")) { + geTracePrintf("audiotrace.log", "[STOP-EVT] type=%d state=%p playingState=%d deltaUs=%d\n", + (int)event->common.type, (void *)soundState, + (int)soundState->playingState, (int)delta); + } +#endif alSynSetVol(sndp->drvr, &soundState->voice, 0, delta); if (delta != 0) { spAC.common.type = AL_SNDP_END_EVT; @@ -452,6 +552,14 @@ void sndHandleEvent(ALSndPlayer *sndp, ALSndpEvent *event) { break; case AL_SNDP_VOL_EVT: soundState->vol = event->vol.vol; +#ifdef PORT + if (getenv("GE_AUDIOTRACE")) { /* D202 diag: distance-vol value per VOL event; remove with the other probes */ + extern uint64_t sysGetMicroseconds(void); + geTracePrintf("audiotrace.log", "[VOL] t=%llu state=%p rawVol=%d playing=%d\n", + (unsigned long long)sysGetMicroseconds(), (void *)soundState, + (int)event->vol.vol, (int)soundState->playingState); + } +#endif if (soundState->playingState == SOUND_STATE_PLAYING) { volume = MAX( 0, g_sndSfxSlotVolume[SOUND_PARAM_GROUP(keyMap)] * @@ -504,13 +612,41 @@ void sndHandleEvent(ALSndPlayer *sndp, ALSndpEvent *event) { sndPlaySfx(event->playSfx.soundBank, event->playSfx.soundIndex, soundState->state); } break; +#ifdef PORT + case AL_SNDP_PORT_EXPIRE_EVT: + /* D202/M-66 (PORT deviation, option C): fade out an ownerless + * infinite-loop SFX voice. Re-validate the full predicate at + * fire time: if the state was disposed in the interim this + * event was already removed (sndDisposeSound); if it was + * rebound, the new binding fails the predicate and we no-op. + * Mirrors the stock STOP_EVT ramp-to-zero + END_EVT pattern, + * with a fixed fade length (see D202_EXPIRE_FADE_US). */ + if (soundState->playingState == SOUND_STATE_PLAYING && + soundState->state == NULL && + (soundState->unk3e & (SOUND_FLAG_LOOPED | SOUND_FLAG_RETRIGGER | SOUND_FLAG_FINAL_IN_SEQUENCE)) + == (SOUND_FLAG_LOOPED | SOUND_FLAG_FINAL_IN_SEQUENCE) && + sound->wavetable != NULL && sound->wavetable->type == AL_ADPCM_WAVE && + sound->wavetable->waveInfo.adpcmWave.loop != NULL && + sound->wavetable->waveInfo.adpcmWave.loop->count == -1) { + alSynSetVol(sndp->drvr, &soundState->voice, 0, D202_EXPIRE_FADE_US); + spAC.common.type = AL_SNDP_END_EVT; + spAC.common.state = soundState; + alEvtqPostEvent(&sndp->evtq, (ALEvent *) &spAC, D202_EXPIRE_FADE_US); + soundState->playingState = SOUND_STATE_STOPPING; + } + break; +#endif default: break; } soundState = nextState; isEventForSingleSound = event->common.type & (AL_SNDP_PLAY_EVT | AL_SNDP_PITCH_EVT | AL_SNDP_DECAY_EVT | - AL_SNDP_END_EVT | AL_SNDP_PLAY_SFX_EVT); + AL_SNDP_END_EVT | AL_SNDP_PLAY_SFX_EVT +#ifdef PORT + | AL_SNDP_PORT_EXPIRE_EVT /* D202/M-66: never sequence-walk */ +#endif + ); if (soundState != NULL && !isEventForSingleSound) { lastInSequence = soundState->unk3e & SOUND_FLAG_FINAL_IN_SEQUENCE; @@ -746,6 +882,13 @@ void sndUnlinkClearSound(ALSoundState *state) if ((state->unk3e & 4) != 0) { g_sndAllocatedVoicesCount--; +#ifdef PORT + if (getenv("GE_AUDIOTRACE")) { /* D202/M-65 diag; remove once root-caused */ + geTracePrintf("audiotrace.log", "[VOICE-] sound=%p state=%p flags=%d count=%d\n", + (void *)state->sound, (void *)state, (int)state->unk3e, + (int)g_sndAllocatedVoicesCount); + } +#endif } state->playingState = AL_STOPPED; @@ -855,8 +998,43 @@ ALSoundState *sndPlaySfx(struct ALBankAlt_s *soundBank, s16 soundIndex, ALSoundS { ALKeyMap *keyMap; +#ifndef PORT sound = (soundBank->instArray[0]->soundArray[soundIndex]); -#ifdef PORT +#else + /* D206 (ABI/layout, not game logic): on N64 `ALInstrumentAlt_s` + * (src/snd.h) places `soundArray` at struct offset 12 -- 3x s32, + * 4-byte pointers -- so it deliberately aliases the on-disk + * ALInstrument's `bendRange`/`soundCount` words, and `soundArray[N]` + * resolves to the on-disk sound table's entry [N-1]. GE's SFX IDs are + * therefore 1-based into that table (ID 0 = NOTHING_SFX, returned + * above and never dereferenced). On PC the 8-byte pointer plus 8-byte + * alignment pushes `soundArray` to offset 16, and the converted bank + * (port/src/romdata.c afFixupInst) is packed to match, so an + * uncompensated `soundArray[N]` would land on entry [N] -- every SFX + * one bank slot too high (D205 melee->Klobb, armour pickup; D202 + * silenced-PPK "slap"). Subtract 1 to restore the N64 mapping. This + * expression also serves the retrigger chain (`soundIndex` recomputed + * at the bottom of this loop from `keyMap->velocityMin`), whose values + * live in the same 1-based space. See docs/dev/findings.md D206. */ + sound = (soundBank->instArray[0]->soundArray[soundIndex - 1]); + + /* D202 diag probe (temporary): trace which soundIndex resolves to + * which ALSound*, to root-cause the M-52 "wrong sample plays" + * playtest report. Remove once root-caused. */ + if (getenv("GE_AUDIOTRACE")) { + extern unsigned long long audioDumpBytePos(void); + geTracePrintf("audiotrace.log", "[AUDIOTRACE] dumppos=%llu sndPlaySfx: bank=%p soundIndex=%d -> sound=%p keyMap=%p wavetable=%p base=%p len=%d type=%d flags=%d book=%p\n", + (unsigned long long)audioDumpBytePos(), + (void *)soundBank, (int)soundIndex, (void *)sound, + sound ? (void *)sound->keyMap : NULL, + (sound && sound->wavetable) ? (void *)sound->wavetable : NULL, + (sound && sound->wavetable) ? (void *)sound->wavetable->base : NULL, + (sound && sound->wavetable) ? sound->wavetable->len : -1, + (sound && sound->wavetable) ? (int)sound->wavetable->type : -1, + (sound && sound->wavetable) ? (int)sound->wavetable->flags : -1, + (sound && sound->wavetable && sound->wavetable->type == AL_ADPCM_WAVE) ? + (void *)sound->wavetable->waveInfo.adpcmWave.book : NULL); + } /* C7 / D127: the libaudio subsystem is Phase-3 parked (no real bank * playback yet). On some levels (Surface1, -level_36) a requested * soundIndex resolves to a bogus ALSound* -- the converted bank tree @@ -877,6 +1055,84 @@ ALSoundState *sndPlaySfx(struct ALBankAlt_s *soundBank, s16 soundIndex, ALSoundS newState = sndSetupSound(soundBank, sound); +#ifdef PORT + /* D202 diag probe (temporary): log the ALSoundState* handed back per + * soundIndex so a later sndDeactivate trace can be correlated back + * to "which chain link was this". Remove once root-caused. */ + if (getenv("GE_AUDIOTRACE")) { + ALKeyMap *kmp = sound ? sound->keyMap : NULL; + /* D202/M-65: the retrigger machinery is what the user's three + * symptoms all route through, and none of it was traced before. + * Rare repurposed ALKeyMap: velocityMax is the retrigger PERIOD + * in 33ms ticks, velocityMin is the NEXT soundIndex in the chain + * this call is walking, and keyMax>>4 seeds unk3e (bit 0x10 = + * SOUND_FLAG_RETRIGGER = "replay me forever until deactivated"). + * Bad values here produce, in order: wrong sound (bad chain + * link), several sounds at once (chain too long), and a sound + * that loops until level exit (period too short / flag stuck). */ + /* D202/M-65: flags bit 1 (SOUND_FLAG_LOOPED) is derived purely + * from envelope->decayTime == -1, and a looped sound never posts + * AL_SNDP_STOP_EVT -- it holds its voice until something + * deactivates it. Print the envelope so a mis-assigned envelope + * pointer can be told from genuine ROM data. */ + /* D202/M-65: an infinite ENVELOPE decay only stops the STOP_EVT + * from being posted -- it does not by itself make a sample + * repeat. If the WAVE also carries an ADPCM loop with count -1 + * the sound is audibly endless; if it has no loop, the sample + * should run out and go silent, and anything still audible is a + * port-side mixer fault rather than a lifecycle one. */ + if (sound && sound->wavetable && sound->wavetable->type == AL_ADPCM_WAVE) { + ALADPCMloop *lp = sound->wavetable->waveInfo.adpcmWave.loop; + geTracePrintf("audiotrace.log", "[WAVELOOP] soundIndex=%d wave=%p len=%d loop=%p start=%d end=%d count=%d\n", + (int)soundIndex, (void *)sound->wavetable, (int)sound->wavetable->len, + (void *)lp, + lp ? (int)lp->start : -1, lp ? (int)lp->end : -1, + lp ? (int)lp->count : -1); + } + if (sound && sound->envelope) + geTracePrintf("audiotrace.log", "[ENVELOPE] soundIndex=%d env=%p attack=%d decay=%d release=%d aVol=%u dVol=%u\n", + (int)soundIndex, (void *)sound->envelope, + (int)sound->envelope->attackTime, + (int)sound->envelope->decayTime, + (int)sound->envelope->releaseTime, + (unsigned)sound->envelope->attackVolume, + (unsigned)sound->envelope->decayVolume); + { + extern uint64_t sysGetMicroseconds(void); + geTracePrintf("audiotrace.log", "[AUDIOTRACE] sndPlaySfx: t=%llu soundIndex=%d -> newState=%p wavetable=%p flags=%d " + "keyMap=%p velMin(next)=%d velMax(period)=%d keyMin=0x%02x keyMax=0x%02x " + "keyBase=%d detune=%d retrig=%d deltaLoopUs=%d\n", + (unsigned long long)sysGetMicroseconds(), + (int)soundIndex, (void *)newState, + (void *)(sound ? sound->wavetable : NULL), + newState ? (int)newState->unk3e : -1, + (void *)kmp, + kmp ? (int)kmp->velocityMin : -1, kmp ? (int)kmp->velocityMax : -1, + kmp ? (unsigned)kmp->keyMin : 0u, kmp ? (unsigned)kmp->keyMax : 0u, + kmp ? (int)kmp->keyBase : -1, kmp ? (int)kmp->detune : -1, + (newState && (newState->unk3e & 0x10)) ? 1 : 0, + kmp ? (int)(kmp->velocityMax * DELTA_33_MS) : -1); + } + /* D202/M-65: only 8 voices exist (MUSIC_SFX_SEQ_MAYBE_MAX_SOUNDS). + * A SOUND_FLAG_LOOPED voice never self-releases and is skipped by + * the preemption scan, so each leaked one permanently costs a + * voice. Once this hits maxSounds nothing new can ever sound. */ + { + /* D202/M-71: also log evtq occupancy (64 slots; a full + * queue silently drops posts in alEvtqPostEvent). */ + ALLink *evn; + s32 evAlloc = 0; + OSIntMask evm = osSetIntMask(OS_IM_NONE); + for (evn = g_sndPlayerPtr->evtq.allocList.next; evn; evn = evn->next) + evAlloc++; + osSetIntMask(evm); + geTracePrintf("audiotrace.log", "[VOICES] allocated=%d / max=%d evtq=%d/64\n", + (int)g_sndAllocatedVoicesCount, + (int)g_sndPlayerPtr->maxSounds, (int)evAlloc); + } + } +#endif + if (newState != NULL) { ALSndpEvent playEvent; @@ -933,12 +1189,37 @@ ALSoundState *sndPlaySfx(struct ALBankAlt_s *soundBank, s16 soundIndex, ALSoundS playSfxEvent.playSfx.soundIndex = eventSoundIndex; // types dont match playSfxEvent.playSfx.soundBank = soundBank; +#ifdef PORT + /* D202/M-65 diag (temporary): this is the self-retrigger post -- + * the sound schedules itself to play again in playSfxDelta us, + * forever, until sndDeactivate clears bit 0x10. A stuck looping + * sound IS this event never stopping; a "piling up" mix is this + * period being far too short. Remove once root-caused. */ + if (getenv("GE_AUDIOTRACE")) { + geTracePrintf("audiotrace.log", "[AUDIOTRACE] RETRIGGER-POST: state=%p soundIndex=%d delayUs=%d (%.1f ms)\n", + (void *)nextState, (int)eventSoundIndex, (int)playSfxDelta, + (double)playSfxDelta / 1000.0); + } +#endif alEvtqPostEvent(&g_sndPlayerPtr->evtq, (ALEvent *)&playSfxEvent, playSfxDelta); } } if (pendingState != NULL) { +#ifdef PORT + /* D202/M-65 diag (temporary): callers like doorPlayOpenSound0 pass + * &door->openSoundState here (an ALSoundState** punned as an + * ALSoundState*), relying on ALLink.next sitting at offset 0. This + * write IS how a looping SFX gets an owner that can later stop it. + * Log it so "slot never written" can be told apart from "slot + * written then cleared". Remove once root-caused. */ + if (getenv("GE_AUDIOTRACE")) { + geTracePrintf("audiotrace.log", "[SLOTWRITE] slot=%p <- state=%p (was %p)\n", + (void *)pendingState, (void *)nextState, + (void *)pendingState->link.next); + } +#endif pendingState->link.next = (void*)nextState; } @@ -957,6 +1238,16 @@ void sndDeactivate(ALSoundState *state) evt.common.type = AL_SNDP_DEACTIVATE_EVT; evt.common.state = state; +#ifdef PORT + /* D202 diag probe (temporary): does this ever fire for the stuck + * door-loop voice? Correlate against the sndPlaySfx newState= trace. + * Remove once root-caused. */ + if (getenv("GE_AUDIOTRACE")) { + geTracePrintf("audiotrace.log", "[AUDIOTRACE] sndDeactivate: state=%p (%s)\n", + (void *)state, state ? "non-null" : "NULL-noop"); + } +#endif + if (state != NULL) { state->unk3e = (s8) (state->unk3e & (~(s16)(0x10))); @@ -1037,18 +1328,18 @@ void sndCreatePostEvent(ALSoundState *state, s16 eventType, s32 arg2) { ALSndpEvent evt; -#ifdef PORT - /* D138: libaudio is Phase-3 parked. The audio thread never drains - * g_sndPlayerPtr->evtq->allocList, so every positional-sound tick - * (chrobjSndCreatePostEvent, called per objTick for each ambient-sound - * object) appends an item and alEvtqPostEvent walks the whole list to - * insert it -> O(n^2). Facility has enough machinery ambience that after - * a few thousand frames one insert takes seconds and the kernel-heartbeat - * watchdog trips ("no frame rendered"). Skip event posting until audio - * lands (D77); no gameplay effect. */ - (void) evt; (void) eventType; (void) arg2; (void) state; - return; -#else + /* D202/M-65: this was stubbed out on PORT by D138, back when libaudio was + * Phase-3 parked and nothing drained g_sndPlayerPtr->evtq->allocList -- + * every positional-sound tick appended an item that was never consumed, + * so alEvtqPostEvent's ordered insert walked an ever-growing list (O(n^2)) + * until the kernel-heartbeat watchdog tripped on Facility's ambience. + * That premise expired when the Phase-3 audio thread landed (D198-D201): + * amMain now drains the queue every audio frame, so the list stays short. + * + * Leaving the stub in place is not neutral. Every caller posts type 8 + * (AL_SNDP_VOL_EVT), so the stub silently removed ALL distance-based + * volume attenuation, and with it the only per-tick contact the engine + * has with a playing voice. See docs/dev/findings.md D202/M-65. */ evt.common.type = eventType; evt.common.state = state; evt.unks32.val8 = arg2; @@ -1057,7 +1348,6 @@ void sndCreatePostEvent(ALSoundState *state, s16 eventType, s32 arg2) { alEvtqPostEvent(&g_sndPlayerPtr->evtq, (ALEvent *)&evt, 0); } -#endif } /** diff --git a/src/snd.h b/src/snd.h index 46899b38..e25de90e 100644 --- a/src/snd.h +++ b/src/snd.h @@ -54,6 +54,14 @@ typedef enum ALSndpMsgType_e { AL_SNDP_UNUSED_15_EVT = (1 << 15) // defined here for 16 bit completion } ALSndpMsgType; +#ifdef PORT +/* D202/M-66 (PC port only): reuses a slot marked unused above. sndp posts one + * of these for ownerless infinite-loop SFX voices so they fade out after a + * bounded time instead of ringing until level exit (the faithful N64 + * behavior). See docs/dev/findings.md D202, M-66. */ +#define AL_SNDP_PORT_EXPIRE_EVT AL_SNDP_UNUSED_13_EVT +#endif + // based on n64devkit\ultra\usr\src\pr\libsrc\libultra\audio\sndp.h struct ALSoundState_s; typedef struct ALSoundState_s { diff --git a/tools_pc/README.md b/tools_pc/README.md index b348de09..766374a1 100644 --- a/tools_pc/README.md +++ b/tools_pc/README.md @@ -32,6 +32,7 @@ investigation artifacts. | `pixcount.py` | Count non-black pixels in a PPM dump — "did the scene render anything" as a number. | | `level_sweep.sh` | Bare `-level_XX` boot of all 21 solo levels → PASS / NO-FRAMES / CRASH. Predates `verify.sh sweep`; kept as the battle-tested full-21-level runner until `verify.sh sweep` has done an equivalent soak. | | `playtest.sh` | Launch a level for `docs/dev/LEVEL-PLAYTEST.md` human validation (WS6). | +| `audiodebug.ps1` | Audio counterpart to `debug.ps1` (that one is for crashes; this one is for audio behaviour). Drives the `GE_D204` health monitor + `GE_AUDIOTRACE`/`GE_AUDIODUMP` and prints a pass/fail verdict: real-time ratio, queue starvation, dropped blocks, oversized blocks, plus a soundIndex histogram. `-AB` runs the same binary twice (with and without `GE_D204_OLD`) and prints a before/after table — how D204 was measured. `-Play` for an instrumented interactive playtest (the D202 workflow), `-Soak` for a 5-min stability run, `-SyncData` to mirror `./data` first. | | `debug.ps1` / `repro_gdb.sh` / `attach_animgen.sh` | Launch (or attach to) the game under gdb so a crash always leaves a backtrace. | | `bundle-win.sh` / `bundle-linux.sh` | Package a built tree as a distributable archive (exe + licenses + `prepare-assets/`). | | `romverify.c` | One-shot `.z64` integrity check against the repo's ground truths. | diff --git a/tools_pc/audiodebug.ps1 b/tools_pc/audiodebug.ps1 new file mode 100644 index 00000000..1669359e --- /dev/null +++ b/tools_pc/audiodebug.ps1 @@ -0,0 +1,320 @@ +<# + audiodebug.ps1 - drive the PC port's audio probes (D202 / D204) and + summarise the result, instead of hand-assembling env vars + an input + script + a grep pipeline every time. + + Companion to debug.ps1 (which is for CRASHES - run that one when you want a + gdb backtrace). This one is for AUDIO BEHAVIOUR: is the pipeline keeping up, + is the queue starving or pinning, which soundIndexes are being requested. + + Usage (from repo root): + .\tools_pc\audiodebug.ps1 # 60s headless, level_09, health only + .\tools_pc\audiodebug.ps1 -Level 33 -Seconds 120 # a different level, longer + .\tools_pc\audiodebug.ps1 -Fire # + scripted PPK fire pulses + .\tools_pc\audiodebug.ps1 -Fire -Trace # + per-sndPlaySfx trace + index histogram + .\tools_pc\audiodebug.ps1 -Fire -Trace -Dump # + raw mixed PCM to audiodump.raw + .\tools_pc\audiodebug.ps1 -AB -Fire # run BOTH modes, print before/after table + .\tools_pc\audiodebug.ps1 -Play # INTERACTIVE playtest, no timeout (D202) + .\tools_pc\audiodebug.ps1 -Soak # 5-minute stability soak + + The interesting output is the health line, one per 5 s (GE_D204): + + D204 audio health t=65s rt=1.000 q=432/8192 drop=0 max=3136/3156 + | | | | + produced audio / wall --+ | | +-- largest block + 1.000 = healthy. Below 1 means the | | vs its allocation + 30 Hz audio retrace is starving | +-- blocks discarded because + and the device is padding with | the queue was full + silence (D204). | + +-- SDL queue depth / QueueLimit. + Should sit shallow and NEVER hit 0 + (starvation) or pin at the limit + (overproduction -> the "eventually + no audio except a stuck loop" state). + + See docs/dev/findings.md D204 and docs/dev/GE-ENV-PROBES.md. +#> +[CmdletBinding()] +param( + # What to run + [string]$Level = "09", # -level_NN ; "" or "menu" boots the front end + [int]$Seconds = 60, # wall-clock cap; ignored by -Play + [switch]$Play, # interactive playtest: no input script, no timeout + [switch]$Soak, # 300 s stability run + [switch]$Fire, # scripted fire pulses (headless repro) + [int]$FireEvery = 45, # frames between pulses (45 = 0.75 s @60) + + # Probes + [switch]$Trace, # GE_AUDIOTRACE=1 -> audiotrace.log (+ _wire.log) + [switch]$Dump, # GE_AUDIODUMP=1 -> audiodump.raw + [switch]$VoiceDump, # GE_VOICEDUMP=1 -> voicedump.raw (per-voice, D202/M-67) + [switch]$MixerTrace, # GE_MIXERTRACE=1 -- SLOW, see warning below + [switch]$Old, # GE_D204_OLD=1: pre-D204 behaviour + [switch]$AB, # run twice (old, then new) and compare + + # Plumbing + [switch]$NoBuild, + [switch]$SyncData, # mirror ./data into build-pc/data first + [string]$MingwBin = "C:\msys64\mingw64\bin", + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$GameArgs +) + +$ErrorActionPreference = "Stop" +$repo = $PWD.Path +$buildDir = Join-Path $repo "build-pc" +$exe = Join-Path $buildDir "ge007.x86_64.exe" + +if ($Soak -and -not $PSBoundParameters.ContainsKey('Seconds')) { $Seconds = 300 } + +# Same DLL-resolution problem as debug.ps1: the exe links MSYS2 mingw64 SDL2 / +# libgcc / libstdc++ / libwinpthread / zlib. Without these on PATH it dies at +# startup with 0xc0000135 before any audio code runs. +if ((Test-Path $MingwBin) -and (($env:PATH -split ';') -notcontains $MingwBin)) { + $env:PATH = "$MingwBin;$env:PATH" +} + +# ---------------------------------------------------------------- build ---- +if (-not $NoBuild) { + Write-Host "building (bash build-pc.sh ntsc-final) - pass -NoBuild to skip" -ForegroundColor DarkGray + & "C:\msys64\usr\bin\bash.exe" -lc "cd '$($repo -replace '\\','/')' && export PATH=/c/msys64/mingw64/bin:`$PATH && ./build-pc.sh ntsc-final" + if ($LASTEXITCODE -ne 0) { throw "build failed" } +} +if (-not (Test-Path $exe)) { throw "no exe - build the port: bash build-pc.sh ntsc-final" } + +# ------------------------------------------------------------ data check ---- +# M-63 burned a whole session on this: with the ROM present but the converted +# sidecar dirs missing, obInit() falls back to reading raw big-endian ROM data +# and -level_09 hard-crashes in load_bg_file almost immediately. It looks +# exactly like a scary new engine bug and is purely a test-setup gap. The +# "[WARN] pccg.bin not found" lines are printed right there in the log. +$needed = @("ge007.ntsc-final.z64", "pccg-ntsc-final", "pcmodels-ntsc-final") +$missing = @($needed | Where-Object { -not (Test-Path (Join-Path $buildDir "data\$_")) }) +if ($missing.Count -gt 0) { + if ($SyncData) { + Write-Host "mirroring ./data -> build-pc/data" -ForegroundColor Cyan + New-Item -ItemType Directory -Force -Path (Join-Path $buildDir "data") | Out-Null + Copy-Item -Recurse -Force (Join-Path $repo "data\*") (Join-Path $buildDir "data") + } else { + Write-Host "WARNING: build-pc/data is missing: $($missing -join ', ')" -ForegroundColor Red + Write-Host " Levels will crash in load_bg_file and it will look like an engine bug." -ForegroundColor Red + Write-Host " Re-run with -SyncData to mirror ./data automatically." -ForegroundColor Red + } +} + +# --------------------------------------------------------- input script ---- +# Scripted fire pulses: R (aim) + Z (trigger) every $FireEvery frames. Frame +# 600 is roughly where a -level_NN boot is interactive. +$inputScript = "" +if ($Fire -and -not $Play) { + $last = 600 + [int](($Seconds - 10) * 60) + $sb = [System.Text.StringBuilder]::new() + for ($f = 600; $f -le $last; $f += $FireEvery) { [void]$sb.Append("${f}:R,Z;") } + $inputScript = $sb.ToString() +} + +# ---------------------------------------------------------------- args ----- +$gameArgv = @() +if ($Level -and $Level -ne "menu") { $gameArgv += "-level_$Level" } +if ($GameArgs) { $gameArgv += @($GameArgs | Where-Object { $_ }) } + +# ----------------------------------------------------------- run one ------- +function Invoke-AudioRun { + param([string]$Tag, [bool]$OldMode) + + $stdout = Join-Path $buildDir "audiodebug_$Tag.log" + Remove-Item -ErrorAction SilentlyContinue $stdout, + (Join-Path $buildDir "audiodump.raw"), + (Join-Path $buildDir "audiotrace.log"), + (Join-Path $buildDir "mixertrace.log") + + # Set the probes on THIS process; cmd.exe and the game both inherit them. + $env:GE_D204 = "1" + if ($OldMode) { $env:GE_D204_OLD = "1" } else { Remove-Item -EA SilentlyContinue Env:\GE_D204_OLD } + if ($Trace) { $env:GE_AUDIOTRACE = "1" } else { Remove-Item -EA SilentlyContinue Env:\GE_AUDIOTRACE } + if ($Dump) { $env:GE_AUDIODUMP = "1" } else { Remove-Item -EA SilentlyContinue Env:\GE_AUDIODUMP } + if ($VoiceDump) { $env:GE_VOICEDUMP = "1" } else { Remove-Item -EA SilentlyContinue Env:\GE_VOICEDUMP } + if ($MixerTrace) { $env:GE_MIXERTRACE = "1" } else { Remove-Item -EA SilentlyContinue Env:\GE_MIXERTRACE } + if ($inputScript) { $env:GE_INPUTSCRIPT = $inputScript } else { Remove-Item -EA SilentlyContinue Env:\GE_INPUTSCRIPT } + + $mode = if ($OldMode) { "GE_D204_OLD (pre-fix)" } else { "current build" } + $what = if ($Play) { "interactive - play until you close the window" } else { "$Seconds s" } + Write-Host "`n>>> run [$Tag] $mode - $what" -ForegroundColor Green + if ($inputScript) { + Write-Host " GE_INPUTSCRIPT: $((($inputScript -split ';').Count - 1)) fire pulses every $FireEvery frames" -ForegroundColor DarkGray + } + if ($MixerTrace) { + Write-Host " WARNING: GE_MIXERTRACE writes ~25 MB/min UNBUFFERED and slows the process" -ForegroundColor Red + Write-Host " enough to starve the audio thread. rt= will read low for that" -ForegroundColor Red + Write-Host " reason alone - it is NOT a real measurement. (findings.md D204)" -ForegroundColor Red + } + + # Launch through MSYS2 bash, not Start-Process -RedirectStandardOutput. + # CMakeLists sets WIN32_EXECUTABLE (i.e. -mwindows), so the game is a GUI- + # subsystem binary with no console of its own; PowerShell's redirect does + # not capture its stdout (the log comes out empty and everything lands on + # the parent console instead). A shell that opens the file itself before + # CreateProcess does work, and gives us coreutils `timeout` for the cap. + # debug.ps1 already shells out to this bash for the build. + # Launch through a generated .bat run by cmd.exe. Two reasons, both learned + # the hard way: + # - CMakeLists sets WIN32_EXECUTABLE (-mwindows), so the game is a GUI- + # subsystem binary with no console. PowerShell's + # Start-Process -RedirectStandardOutput does NOT capture its stdout (the + # log comes out empty and everything lands on the parent console). A + # shell that opens the file itself before CreateProcess does work. + # - MSYS2 bash was the obvious such shell, but bash.exe launched from + # PowerShell arrives with a stripped environment: neither vars inherited + # from PowerShell nor bash's own `export` reach the child, so every + # GE_* probe silently read as unset. cmd.exe inherits normally. + # The .bat is left on disk so a failed run can be re-run by hand. + $runBat = Join-Path $buildDir "audiodebug_$Tag.bat" + $argStr = ($gameArgv | ForEach-Object { "`"$_`"" }) -join ' ' + $bat = @("@echo off", + "cd /d `"$buildDir`"", + "`"$exe`" $argStr > `"$stdout`" 2>&1") + [IO.File]::WriteAllLines($runBat, $bat, (New-Object Text.ASCIIEncoding)) + + $p = Start-Process -FilePath "cmd.exe" -ArgumentList "/c", "`"$runBat`"" ` + -WorkingDirectory $buildDir -PassThru -NoNewWindow + if ($Play) { + $p.WaitForExit() + $rc = $p.ExitCode + } elseif ($p.WaitForExit($Seconds * 1000)) { + $rc = $p.ExitCode + } else { + Write-Host " (time limit reached - stopping)" -ForegroundColor DarkGray + # Kill the tree: killing cmd alone would orphan the game process and + # leave it holding ge007.x86_64.exe, which then breaks the next build. + & taskkill /T /F /PID $p.Id 2>&1 | Out-Null + $p.WaitForExit(5000) | Out-Null + $rc = 0 + } + Start-Sleep -Milliseconds 300 + if ($rc -ne 0) { Write-Host " (game exited with code $rc - check $stdout)" -ForegroundColor Red } + return $stdout +} + +# ------------------------------------------------------------ summarise ---- +function Get-AudioSummary { + param([string]$LogPath, [string]$Tag) + + $lines = @(Get-Content $LogPath -EA SilentlyContinue) + $health = @($lines | Select-String -Pattern 'D204 audio health t=(\d+)s rt=([\d.]+) q=(\d+)/(\d+) drop=(\d+) max=(\d+)/(\d+)') + + $res = [ordered]@{ + Tag = $Tag; Samples = $health.Count + RtLast = $null; RtMin = $null; QMin = $null; QMax = $null + QLimit = $null; Drop = 0; MaxBlock = $null; Alloc = $null + } + if ($health.Count -gt 0) { + $rt = @(); $q = @() + foreach ($h in $health) { + $m = $h.Matches[0].Groups + $rt += [double]$m[2].Value + $q += [int]$m[3].Value + $res.QLimit = [int]$m[4].Value + $res.Drop = [int]$m[5].Value + $res.MaxBlock = [int]$m[6].Value + $res.Alloc = [int]$m[7].Value + } + # Skip the first sample: it covers process start-up and reads high. + $rtSteady = if ($rt.Count -gt 1) { $rt[1..($rt.Count - 1)] } else { $rt } + $res.RtLast = $rt[-1] + $res.RtMin = ($rtSteady | Measure-Object -Minimum).Minimum + $res.QMin = ($q | Measure-Object -Minimum).Minimum + $res.QMax = ($q | Measure-Object -Maximum).Maximum + } + $res.Oversize = @($lines | Select-String -Pattern 'oversized audio block').Count + $res.Warns = @($lines | Select-String -Pattern '\[WARN\].*(pccg|pcmodels)').Count + return [pscustomobject]$res +} + +function Show-AudioVerdict { + param($S) + Write-Host "`n----- [$($S.Tag)] audio health -----" -ForegroundColor Yellow + if ($S.Samples -eq 0) { + Write-Host " no health lines - the run died before 5 s, or GE_D204 was not honoured." -ForegroundColor Red + return + } + Write-Host (" rt (real-time ratio) last={0:N3} min={1:N3} <- 1.000 is healthy" -f $S.RtLast, $S.RtMin) + Write-Host (" queue depth min={0} max={1} limit={2}" -f $S.QMin, $S.QMax, $S.QLimit) + Write-Host (" dropped blocks {0}" -f $S.Drop) + Write-Host (" largest block {0} / {1} allocated" -f $S.MaxBlock, $S.Alloc) + + if ($S.RtMin -lt 0.995) { + Write-Host " FAIL starving: audio is produced slower than it plays; the device is" -ForegroundColor Red + Write-Host " padding the difference with silence (D204 symptom)." -ForegroundColor Red + } else { Write-Host " OK keeping up with real time" -ForegroundColor Green } + + if ($S.QMin -eq 0) { + Write-Host " FAIL queue reached 0 - the DAC ran dry, audible gaps." -ForegroundColor Red + } else { Write-Host " OK queue never starved" -ForegroundColor Green } + + if ($S.Drop -gt 0 -or $S.QMax -ge $S.QLimit) { + Write-Host " FAIL queue pinned at the limit and blocks are being discarded -" -ForegroundColor Red + Write-Host " this is the 'eventually no audio except a stuck loop' state (D202)." -ForegroundColor Red + } else { Write-Host " OK no overproduction / no dropped blocks" -ForegroundColor Green } + + if ($S.Oversize -gt 0) { + Write-Host " FAIL $($S.Oversize) oversized blocks - audi.c frameSamples escaped its clamp;" -ForegroundColor Red + Write-Host " info->data has already been overrun. This should be impossible." -ForegroundColor Red + } + if ($S.Warns -gt 0) { + Write-Host " NOTE pccg/pcmodels warnings present - build-pc/data is incomplete;" -ForegroundColor Red + Write-Host " re-run with -SyncData before believing any of the above." -ForegroundColor Red + } +} + +function Show-TraceSummary { + $tl = Join-Path $buildDir "audiotrace.log" + if (-not (Test-Path $tl)) { return } + $t = Get-Content $tl + # Each sndPlaySfx logs TWO lines (resolution + returned state), so counting + # raw "soundIndex=" matches double-counts every call. Count resolution + # lines only -- M-63's "109 fired 94 times" was this artifact. + $plays = @($t | Select-String -Pattern 'sndPlaySfx: bank=.*soundIndex=(-?\d+)') + $deact = @($t | Select-String -Pattern 'sndDeactivate').Count + Write-Host "`n----- sndPlaySfx trace -----" -ForegroundColor Yellow + Write-Host (" {0} sndPlaySfx calls, {1} sndDeactivate calls" -f $plays.Count, $deact) + if ($plays.Count -eq 0) { return } + Write-Host " most-requested soundIndexes:" + $plays | ForEach-Object { [int]$_.Matches[0].Groups[1].Value } | + Group-Object | Sort-Object Count -Descending | Select-Object -First 10 | + ForEach-Object { Write-Host (" idx {0,-5} x{1}" -f $_.Name, $_.Count) } + + $dump = Join-Path $buildDir "audiodump.raw" + if (Test-Path $dump) { + $secs = (Get-Item $dump).Length / 88200.0 # s16 stereo @ 22050 Hz + Write-Host ("`n audiodump.raw: {0:N1} s of audio ({1:N0} bytes)" -f $secs, (Get-Item $dump).Length) + Write-Host " play it with: ffplay -f s16le -ar 22050 -ch_layout stereo build-pc\audiodump.raw" -ForegroundColor DarkGray + } +} + +# ------------------------------------------------------------------ go ----- +if ($AB) { + $oldLog = Invoke-AudioRun -Tag "old" -OldMode $true + $newLog = Invoke-AudioRun -Tag "new" -OldMode $false + $a = Get-AudioSummary -LogPath $oldLog -Tag "old" + $b = Get-AudioSummary -LogPath $newLog -Tag "new" + Show-AudioVerdict $a + Show-AudioVerdict $b + Write-Host "`n===================== A/B =====================" -ForegroundColor Yellow + Write-Host (" {0,-24} {1,12} {2,12}" -f "", "GE_D204_OLD", "current") + Write-Host (" {0,-24} {1,12:N3} {2,12:N3}" -f "rt (min, steady)", $a.RtMin, $b.RtMin) + Write-Host (" {0,-24} {1,12} {2,12}" -f "queue min", $a.QMin, $b.QMin) + Write-Host (" {0,-24} {1,12} {2,12}" -f "queue max", $a.QMax, $b.QMax) + Write-Host (" {0,-24} {1,12} {2,12}" -f "dropped blocks", $a.Drop, $b.Drop) + Write-Host (" {0,-24} {1,12} {2,12}" -f "largest block", $a.MaxBlock, $b.MaxBlock) + Write-Host "`n Both runs used the SAME binary (GE_D204_OLD switches behaviour at" -ForegroundColor DarkGray + Write-Host " runtime), so this is free of build-to-build variance." -ForegroundColor DarkGray +} else { + $log = Invoke-AudioRun -Tag "run" -OldMode ([bool]$Old) + Show-AudioVerdict (Get-AudioSummary -LogPath $log -Tag "run") +} +if ($Trace) { Show-TraceSummary } + +Write-Host "`nlogs: build-pc\audiodebug_*.log" -ForegroundColor DarkGray +if ($Trace) { Write-Host " build-pc\audiotrace.log" -ForegroundColor DarkGray } +if ($Dump) { Write-Host " build-pc\audiodump.raw" -ForegroundColor DarkGray } +if ($MixerTrace) { Write-Host " build-pc\mixertrace.log (huge - grep it, do not open it)" -ForegroundColor DarkGray }