mirror of
https://github.com/jkdansereau/goldeneye-pc-port.git
synced 2026-10-23 15:52:42 +02:00
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:
commit
e3bebfc454
40 files changed
+5577
-449
No files matched your search
@@ -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/
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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 |
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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) |
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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?
|
||||
@@ -0,0 +1,88 @@
|
||||
# 1P weapon viewmodel — static research (read-only)
|
||||
|
||||
Companion to `GRAPHICS-BACKLOG.md` ("1P weapon viewmodel") and findings D115
|
||||
item #5 / AUDIT-M6 #5. Goal: narrow the five audit suspects to a ranked,
|
||||
probe-ready list. No code changed by this doc.
|
||||
|
||||
## Path map (verified in source)
|
||||
|
||||
- **Setup (per frame):** `bondview2.c:9040` → `gunUpdateAndFireBothHands()`
|
||||
(`gunfire.c:1018`) → `gunUpdateAndFire(handnum)` (`gunfire.c:327`). Inside,
|
||||
the weapon-model block at `gunfire.c:599-662`: computes `field_87F` (the
|
||||
show/hide gate), then if set: `dynAllocate(numMatrices × sizeof Mtxf)` →
|
||||
identity-fill → `modelCalculateRwDataLen` → `modelInit(HAND_WEAPON_MODEL,
|
||||
©_of_body_obj_header[handnum], HAND_WEAPON_RWPOOL)` →
|
||||
`hand->mtxlist = rwmtx` and (PORT/D102)
|
||||
`weaponModel.render_pos = (RenderPosView*)rwmtx`.
|
||||
- **Render (same frame, later):** `gunRenderFirstPersonGunModels` loop at
|
||||
`gunfire.c:1532-1540`: **`if (handptr->field_87F == 0) continue;`** then
|
||||
`subdraw(&renderdata, HAND_WEAPON_MODEL(handptr))` (`model.c:5326`) which
|
||||
does `gSPSegment(gdl, 3, osVirtualToPhysical(mdl->render_pos))` and walks
|
||||
`mdl->obj->RootNode` — **a NULL RootNode returns silently, drawing
|
||||
nothing**.
|
||||
|
||||
## Statically ruled out (with evidence)
|
||||
|
||||
1. **Transient-arena lifetime** (the audit's prime suspect). `dynAllocate`
|
||||
is a bump allocator on the vtx pool (`src/game/dyn.c:123`), reset only by
|
||||
`dynSwapBuffers()` at frame end. Setup and render both happen inside one
|
||||
frame, so `render_pos`/`mtxlist` are valid when consumed — in solo play.
|
||||
(Split-screen mid-frame swaps would be a separate question; out of scope.)
|
||||
2. **Word-index / record-size mismatch** (`flashvisptr = RWPOOL +
|
||||
RwDataIndex`, `gunfire.c:650`). D52 kept `Model.datas` as `u32*` on PC
|
||||
precisely so N64 word math holds, and `RwDataIndex` values are computed
|
||||
at runtime by `modelCalculateRwDataIndexes` from the *PC* record sizes —
|
||||
self-consistent. (Self-consistent ≠ in-bounds; see suspect 2.)
|
||||
3. **Segment/render_pos mechanism itself.** Every working model in the game
|
||||
(bodies, props) uses the same `gSPSegment(3, render_pos)` +
|
||||
`bondviewTransformManyPosToViewMatrix` machinery — and it renders. The
|
||||
**watch menu draws the same weapon models** via a different path
|
||||
(`set_enviro_fog_for_items_in_solo_watch_menu`, `gunfire.c:1630+`, local
|
||||
matrices, no 87F gate, no hand rw-pool) and they appear (albeit faint —
|
||||
D75/D141 note). So the model assets and headers are very likely fine; the
|
||||
defect is in-level-path-specific.
|
||||
|
||||
## Ranked suspects + probe spec
|
||||
|
||||
**#1 — `field_87F` gating (`gunfire.c:599-610`).** The clear-condition
|
||||
disjunction contains `(Gun_hand_without_item(handnum) == 0)`. That function
|
||||
(`gun.c:812-816`) returns `hand_invisible[h] > 0 || (hand_item[h] == 0 &&
|
||||
field_2A44[h] < 0)` — so the term is TRUE (→ gate cleared → gun hidden)
|
||||
whenever the hand *has* an item and isn't invisible. Read naively, that
|
||||
hides the gun exactly when you're holding one; since the N64 shows the gun,
|
||||
the runtime values of `hand_item`/`field_2A44`/`hand_invisible` must differ
|
||||
from their names suggest — but this is the single most likely place for a
|
||||
PC-only divergence (e.g. a field the port initializes differently). If 87F
|
||||
is 0, the render loop `continue`s and the symptom is exactly "no gun, no
|
||||
crash".
|
||||
|
||||
**#2 — rw-pool capacity.** PC records are wider than N64's (pointer fields);
|
||||
`weaponRwPool[192]` (768 B) was D102's scaled-up budget, but nothing bounds-
|
||||
checks it. The `#ifdef DEBUG` print at `gunfire.c:620` still tests the *N64*
|
||||
32-word threshold — stale on PC. If `numRecords` words exceed 192,
|
||||
`modelInitRwData` overruns the pool into whatever follows it in `struct
|
||||
hand` → corruption that could kill the draw (or worse, silently).
|
||||
|
||||
**#3 — stale `copy_of_body_obj_header`.** Populated only at item load
|
||||
(`gun.c:924` shallow copy / `gun.c:931-951` `load_object_fill_header`). If a
|
||||
PC asset-load quirk zeroed it for the in-level path, `RootNode == NULL` and
|
||||
`subdraw` no-ops silently. Lower priority given the watch menu renders the
|
||||
same models — but cheap to confirm in the same probe.
|
||||
|
||||
**Probe (one session, PORT-only, env-gated `GE_DVM=1`, zero behavior
|
||||
change):** in the `field_87F != 0` block at `gunfire.c:611+`, print once per
|
||||
frame while a pistol is held in BUNKER1: `handnum`, `item`, each clear-term
|
||||
value + final `field_87F`, `©_of_body_obj_header[h]`, `numMatrices`,
|
||||
`numRecords` (words) vs 192, `RootNode`, `rwmtx`. One run triages all three
|
||||
suspects: 87F==0 → suspect #1 (log which term); numRecords>192 → #2;
|
||||
RootNode NULL/garbage → #3.
|
||||
|
||||
## Fix-shape notes (for when the probe lands)
|
||||
|
||||
- If #1: the fix is a `#ifdef PORT` initialization/correction of whichever
|
||||
hand field diverges — **not** an edit to the gate logic itself (rule #2);
|
||||
document as Dxxx with the N64-vs-PC value table.
|
||||
- If #2: enlarge `weaponRwPool` to the measured max + a PORT bounds guard
|
||||
that prints (mirrors the existing DEBUG print, correct threshold).
|
||||
- If #3: trace the `load_object_fill_header` call for the in-level weapon
|
||||
load; likely an asset-offset issue in the port's loader path.
|
||||
@@ -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.
|
||||
@@ -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)."
|
||||
|
||||
|
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -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_ */
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
*====================================================================*/
|
||||
|
||||
#include "synthInternals.h"
|
||||
#if defined(__x86_64__)
|
||||
#endif
|
||||
#include <os_internal.h>
|
||||
#include <ultraerror.h>
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
*====================================================================*/
|
||||
|
||||
#include "synthInternals.h"
|
||||
#if defined(__x86_64__)
|
||||
#endif
|
||||
#include <os_internal.h>
|
||||
#include <ultraerror.h>
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
*====================================================================*/
|
||||
|
||||
#include "synthInternals.h"
|
||||
#if defined(__x86_64__)
|
||||
#endif
|
||||
#include <os_internal.h>
|
||||
#include <ultraerror.h>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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 }
|
||||
Reference in new issue
Block a user