Files
goldeneye-pc-port/docs/dev/AUDIO-PLAN.md
T
James Dansereau 4ca11e4386 Prepare repository for public release
- README: attract-mode stills + demo GIF as the hero row, status/warning
  callouts, metrics table, "Contents" index, doc-index table; simplified
  the two-agent workflow diagram (LR -> TD, fewer edges) for GitHub web
- Table-of-contents indexes added to internals.md, porting-notes.md,
  agentic-development.md, AUDIO-PLAN.md
- CI: add Windows/MSYS2 build job, minimal token (contents: read),
  persist-credentials: false, concurrency group; run on main + master.
  validate job regenerates the US ROM-asset symbol file only and configures
  ntsc-final (PAL/JP need a region ROM; no ROM is used in CI)
- .github: SECURITY.md, issue forms + chooser config, PR template,
  labels.yml, dependabot (Actions, monthly)
- Do not track the proprietary SGI IRIX IDO toolchain or SGIImageViewer.exe;
  document in SetupGuide.md / building.md how to obtain IDO for N64 ROM
  builds (the PC port does not need it)
- Stop tracking two committed *.pyc files; drop stale screenshots and a
  redundant .gitignore line; misc doc cross-reference fixes

Claude-Session: https://claude.ai/code/session_01GULf6knbsqfsnW39tMr9WT
2026-09-01 22:42:52 -04:00

23 KiB
Raw Blame History

Audio Plan — Phase 3 (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.


Contents

  1. Goal and scope
  2. Current state
  3. The GE pipeline in one paragraph
  4. How the PD port does it
  5. Architecture options — options A/B/C + sub-decisions A1–A3
  6. DMEM layout
  7. Shims and ABI fixes required — incl. 7.1 K0_TO_PHYS bug
  8. Phase 0 — recon
  9. Open questions
  10. Phases and milestones
  11. Verification ritual (audio-specific)
  12. Risks / unknowns
  13. Key references

1. Goal and scope

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.

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).

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?
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)

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).

4. How the PD port does it (ground-truth comparison)

  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 #undefs 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).

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:

  • 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.

5. Architecture options (evaluate all; recommendation marked)

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".

  • 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).

Option B — Shadow PR/abi.h, redirect macros to direct C calls (the PD trick)

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.

  • 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.

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).

  • Immediate: osAiSetNextBuffer() shim calls into audio.c and SDL_QueueAudios 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.

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.

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_AUDTASKs 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
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