Merge pull request #28 from jkdansereau/feat/phase3-audio

Merge Phase 3 audio: software mixer + SFX fidelity (D198–D206), D202 closed.

https://claude.ai/code/session_01U7gyYC3pcatXHhD1kPCgsR
This commit is contained in:
James Dansereau authored and GitHub committed 2026-09-08 07:32:23 -04:00
commit e3bebfc454
40 files changed
+5577 -449

No files matched your search

+8
View File
@@ -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/
+3 -1
View File
@@ -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.
+18 -11
View File
@@ -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
+170 -343
View File
@@ -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 |
+100
View File
@@ -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 <varies> 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
<particles> -> 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)
+85
View File
@@ -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.
+12
View File
@@ -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) |
+3 -3
View File
@@ -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)
+59
View File
@@ -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.
+176
View File
@@ -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?
+88
View File
@@ -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,
&copy_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`, `&copy_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.
+241
View File
@@ -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.
+12
View File
@@ -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)."
1 # GENERATED by tools_pc/gen_findings_index.py -- do not hand-edit.
21 D191
22 D193
23 D194
24 D195
25 D197
26 D198
27 D199
28 D200
29 D196
30 D192
31 D204
32 D203
33 D201
34 D202
35 D206
36 D207
37 D205
38 D152+
39 D168
40 D169
+2732
View File
File diff suppressed because it is too large. Load diff
+39
View File
@@ -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
+12
View File
@@ -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_ */
+5 -3
View File
@@ -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
+17
View File
@@ -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 */
+61 -11
View File
@@ -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 <PR/ultratypes.h>
@@ -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
}
+221 -20
View File
@@ -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 <SDL.h>
#include <stdlib.h>
#include <stdio.h>
#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;
}
}
+49 -4
View File
@@ -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 <stdarg.h>
#include <stdio.h>
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);
}
+518 -14
View File
@@ -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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <PR/abi.h>
#include <PR/os.h>
#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;
}
+43 -2
View File
@@ -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);
}
+22
View File
@@ -7,6 +7,10 @@
#include "speed_graph.h"
#if defined(PORT)
#include <string.h> /* PC port: memcpy for the CUSTOM_FX_PARAMS_N copy in audioInit (docs/dev/findings.md §11) */
#if defined(__x86_64__)
#include <stdio.h>
#include <stdlib.h>
#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;
}
+9
View File
@@ -1,4 +1,8 @@
#include <ultra64.h>
#ifdef PORT
#include <stdlib.h>
#include "audiotrace.h"
#endif
#include <music.h>
#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)
+92
View File
@@ -8,6 +8,12 @@
#include <ultra64.h>
#include <math.h>
#ifdef PORT
#include <stdio.h> /* D202/M-65 diag probe only (doorSndProbe); remove with it */
#include <stdlib.h>
#include "audiotrace.h" /* D202/M-71 diag probe only ([DISTVOL]); remove with it */
#include <stddef.h>
#endif
#include <PR/libaudio.h>
#include <assets/oddtextures.h>
#include <bondgame.h>
@@ -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))
+23 -12
View File
@@ -21,19 +21,30 @@
#include <libaudio.h>
#include <os.h>
#include <ultraerror.h>
#include <stdint.h>
/*
* ### 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;
+9
View File
@@ -37,6 +37,10 @@
*/
#include <libaudio.h>
#ifdef PORT
#include <stdlib.h>
#include "audiotrace.h"
#endif
#include <os.h>
#include <ultraerror.h>
#include <assert.h>
@@ -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
+10
View File
@@ -21,6 +21,9 @@
#include <libaudio.h>
#include <os.h>
#include <ultraerror.h>
#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);
+67 -4
View File
@@ -23,6 +23,11 @@
#include "synthInternals.h"
#include <os.h>
#include <R4300.h>
#if defined(__x86_64__)
#include <stdio.h>
#include <stdlib.h>
#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;
+2
View File
@@ -21,6 +21,8 @@
#include <libaudio.h>
#include "synthInternals.h"
#include <os.h>
#if defined(__x86_64__)
#endif
#include "include/assert.h"
Acmd *alSavePull(void *filter, s16 *outp, s32 outCount, s32 sampleOffset,
+2
View File
@@ -19,6 +19,8 @@
*====================================================================*/
#include "synthInternals.h"
#if defined(__x86_64__)
#endif
#include <os_internal.h>
#include <ultraerror.h>
+2
View File
@@ -19,6 +19,8 @@
*====================================================================*/
#include "synthInternals.h"
#if defined(__x86_64__)
#endif
#include <os_internal.h>
#include <ultraerror.h>
+2
View File
@@ -19,6 +19,8 @@
*====================================================================*/
#include "synthInternals.h"
#if defined(__x86_64__)
#endif
#include <os_internal.h>
#include <ultraerror.h>
+22
View File
@@ -20,6 +20,10 @@
#include "synthInternals.h"
#include "include/assert.h"
#if defined(__x86_64__)
#include <stdio.h>
#include <stdlib.h>
#endif
#ifdef AUD_PROFILE
#include <os.h>
@@ -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);
+8 -5
View File
@@ -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);
}
+306 -16
View File
@@ -3,6 +3,11 @@
#include <os_extension.h>
#include "music.h"
#include "snd.h"
#ifdef PORT
#include <stdio.h>
#include <stdlib.h>
#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
}
/**
+8
View File
@@ -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 {
+1
View File
@@ -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. |
+320
View File
@@ -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 }