forked from Ports/goldeneye-pc-port
docs: audio plan (Phase 3 draft), texture glitch analysis, BACKLOG graphics-settings expansion
- AUDIO-PLAN.md: draft Phase-3 libaudio->SDL plan (D77), Acmd-stream execution + SDL device plumbing; supersedes the §6 audio paragraphs. - TEXTURE-GLITCH-ANALYSIS.md: read-only diagnosis of texture artifacting (G_SETTEX no-op, mip-chain contamination, wrap period, palette off-by-one); fixes pending. - BACKLOG.md: PC graphics settings workstream expanded with PD-parity baseline + stretch goals, config/video starting-point notes.
This commit is contained in:
1 parent
fda4c19159
commit
1c4efdaaf2
3 files changed
+782
-3
No files matched your search
@@ -0,0 +1,357 @@
|
||||
# Audio Plan — Phase 3 (libaudio → SDL)
|
||||
|
||||
Status: **draft for evaluation** (written read-only; no code changes made).
|
||||
Supersedes the audio paragraphs of §6 in `docs/PCPortResearch.md` as the plan of record
|
||||
for closing **D77** (no audible music/sound on PC). Companion docs: `docs/HANDOFF.md`,
|
||||
`docs/PORT-LEARNINGS.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal and scope
|
||||
|
||||
Make GoldenEye's audio audible on the PC port with 1:1 fidelity to the N64 pipeline:
|
||||
music (3 cseq tracks) and SFX through the real libaudio synth, out of SDL2.
|
||||
|
||||
In scope:
|
||||
- Executing the per-frame **Acmd stream** that stock IDO libaudio emits (the part that
|
||||
ran on the RSP under GE's `aspMain` microcode).
|
||||
- SDL audio device + queue plumbing (`port/src/audio.c`, AI shims in `libultra.c`).
|
||||
- One latent ABI fix in the port shim headers (`K0_TO_PHYS`, §7.1).
|
||||
|
||||
Out of scope:
|
||||
- MP3 playback (naudio-only, PD-specific; GE never emits it).
|
||||
- Input (done — D118), saves (Phase 4).
|
||||
- Any change to game logic. All work lives in `port/` except the narrow, documented
|
||||
shim-header exception in §7.1 (same class as D78).
|
||||
|
||||
## 2. Current state (verified by reading, not assumption)
|
||||
|
||||
Working already:
|
||||
- **The whole CPU side of libaudio runs.** `src/libultra/audio/*` +
|
||||
`src/libultrare/audio/{drvrNew,env,reverb}.c` compile and execute; the audio thread
|
||||
(`amMain`, `src/audi.c`) gets retrace messages from the real scheduler (`src/sched.c`,
|
||||
compiled — Phase 2 decision), calls `alAudioFrame()`, which fills a 3000-word Acmd
|
||||
list and posts an `M_AUDTASK` to the scheduler. Bank trees, cseq fixups (D54, D36/D37)
|
||||
are done. Sample DMA from cart works via `amDmaCallback` → `osPiStartDma`
|
||||
(synchronous ROM-image shim in `libultra.c`).
|
||||
|
||||
Dead (the three gaps):
|
||||
1. **Acmd list never executed.** `port/src/libultra.c:1108` `osSpTaskStartGo()` has an
|
||||
explicit Phase-3 no-op branch for `M_AUDTASK`. The output buffer (`info->data`) is
|
||||
never filled.
|
||||
2. **AI shims are stubs.** `libultra.c:794–810`: `osAiSetNextBuffer` discards the
|
||||
buffer, `osAiGetLength` returns 0 (so the per-frame sample-count feedback loop in
|
||||
`amHandleFrameMessage`, `audi.c:527`, runs blind).
|
||||
3. **No SDL device.** `port/src/audio.c` `audioInit()` is a TODO stub; and nothing
|
||||
calls `audioEndFrame()` (zero callers repo-wide).
|
||||
|
||||
Existing PORT guards to revisit when audio lands:
|
||||
- **C7 / D127** (`src/snd.c:859`): skips SFX whose resolved `ALSound*` pointer is out of
|
||||
range — added because the converted bank tree (`romdata.c afFixupInst`) yields fewer/
|
||||
rearranged `soundArray` slots than the N64 image on some levels (Surface1). May be
|
||||
masking a real bank-tree defect. See **Q1**.
|
||||
- `src/audi.c:365`: `#if defined(PORT)` memcpy of `CUSTOM_FX_PARAMS_N` (C++-style
|
||||
initializer workaround) — harmless, keep.
|
||||
|
||||
## 3. The GE pipeline in one paragraph
|
||||
|
||||
Audio thread on each retrace: `amHandleFrameMessage()` (`src/audi.c:497`) hands the
|
||||
*previous* frame's output to AI via `osAiSetNextBuffer(lastInfo->data, …)`, computes
|
||||
this frame's sample count from `g_FrameSize - osAiGetLength()>>2` (clamped to
|
||||
`g_MinFrameSize`), calls `alAudioFrame()` which walks the filter chains (decoder →
|
||||
resampler → envmixer → [FX: GE's custom chorus/reverb] → aux bus → main bus → save)
|
||||
emitting **Acmd words** into a 3000-word list, then posts an `M_AUDTASK` (ucode =
|
||||
`aspMain`). The real scheduler (`sched.c:439,485`) queues it in the audio list and runs
|
||||
it through `osSpTaskLoad/osSpTaskStartGo`. On the N64 the RSP executes the Acmd stream
|
||||
against 4 KB of DMEM, writing interleaved s16 stereo to `info->data`; AI DMA then plays
|
||||
it at 22050 Hz. On PC we must replace "RSP executes Acmd stream" — that is the entire
|
||||
problem.
|
||||
|
||||
**Opcode inventory** (grep-verified across `src/libultra/audio/*.c` +
|
||||
`src/libultrare/audio/*.c` — 15 ops):
|
||||
|
||||
| Op | Emitted by | PD reference code? |
|
||||
|---|---|---|
|
||||
| `aSetBuffer` (×20) | load, mainbus, resample, save, reverb | yes (`a*Impl` pattern; different arg ABI) |
|
||||
| `aMix` (×7) | mainbus, reverb | yes — `aMixImpl` |
|
||||
| `aLoadBuffer` (×7) | load, reverb | yes — `aLoadBufferImpl` |
|
||||
| `aClearBuffer` (×7) | auxbus, mainbus, load, reverb | yes — `aClearBufferImpl` |
|
||||
| `aSetVolume` (×5) | env.c (Rare envelope) | yes — `aSetVolumeImpl` |
|
||||
| `aSaveBuffer` (×4) | save, reverb | yes — `aSaveBufferImpl` |
|
||||
| `aDMEMMove` (×4) | load, resample, reverb | yes — `aDMEMMoveImpl` |
|
||||
| `aResample` (×2) | resample.c, reverb.c | yes — `aResampleImpl` (SSE4.1/NEON/scalar) |
|
||||
| `aLoadADPCM` (×2) | load.c, reverb.c | yes — `aLoadADPCMImpl` |
|
||||
| `aSetLoop` | load.c:479 | yes — `aSetLoopImpl` |
|
||||
| `aSegment` | synthesizer.c:231 | n/a (no-op on CPU) |
|
||||
| `aPoleFilter` | **reverb.c:402 (GE custom FX)** | **NO — PD's stub is empty ("never gets called?")** |
|
||||
| `aInterleave` | save.c | yes — `aInterleaveImpl` (naudio variant takes no args) |
|
||||
| `aEnvMixer` | env.c:410/414 | yes — `aEnvMixerImpl` |
|
||||
| `aADPCMdec` | load.c:483 | yes — `aADPCMdecImpl` (SSE4.1/NEON/scalar) |
|
||||
|
||||
PD reference = `PD_PORT_CHECKOUT` +
|
||||
`port/include/mixer.h`. Reusable: the DSP *math* (ADPCM 8-coefficient loop filter,
|
||||
resample table + interpolation, envelope ramping, clamps). **Not** reusable as-is:
|
||||
signatures and argument conventions (naudio ABI ≠ IDO ABI, §5.3).
|
||||
|
||||
## 4. How the PD port does it (ground-truth comparison)
|
||||
|
||||
1. naudio is Rare's own library; its `n_abi.h` has a `#ifndef PLATFORM_N64` branch that
|
||||
redefines every `n_a*` macro as a **direct C call** into `port/src/mixer.c`
|
||||
(`mixer.h` `#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).
|
||||
|
||||
**Why we can't copy it wholesale:** GE compiles the real `src/sched.c` and stock IDO
|
||||
libaudio, whose macros only *write words* into the Acmd list — there is no build-time
|
||||
hook unless we shadow `PR/abi.h`. Something must execute the stream. Two further ABI
|
||||
facts:
|
||||
|
||||
- **IDO's implicit "current buffer":** e.g. `aADPCMdec(pkt, f, s)` carries only flags +
|
||||
a state pointer; in/out DMEM offsets and sample count come from the preceding
|
||||
`aSetBuffer` (`load.c:482–483`, `resample.c:90–91`). naudio spells everything out per
|
||||
call. Whatever option we pick, the implementation must model current-buffer state.
|
||||
- **Addresses in the stream are RAM pointers** (`K0_TO_PHYS(f->state)`,
|
||||
`osVirtualToPhysical(e->state)`, `f->dramout`) — resolvable on PC *except* for the
|
||||
`K0_TO_PHYS` bug in §7.1.
|
||||
|
||||
## 5. Architecture options (evaluate all; recommendation marked)
|
||||
|
||||
### Option A — CPU-side Acmd interpreter at task time 【RECOMMENDED】
|
||||
|
||||
Decode the 64-bit command words from `t->t.data_ptr` inside the existing `M_AUDTASK`
|
||||
branch of `osSpTaskStartGo()` (`libultra.c:1108`), dispatching each op to an
|
||||
implementation in `port/src/mixer.c` operating on a static 4 KB "DMEM".
|
||||
|
||||
- Pros: zero game/header/build changes; treats the stream as data exactly like fast3d
|
||||
does with GBI (project-consistent); natural logging hook (`GE_DAUDIO=1` dump of op +
|
||||
args per frame) for debugging and for validating against the microcode disassembly;
|
||||
execution point matches N64 timing (DSP runs when the RSP would have run it).
|
||||
- Cons: must write the word decoder (~50 lines, trivial — op is bits 24–31 of w0 per
|
||||
`include/PR/abi.h`) and the current-buffer state machine; one scheduler tick of extra
|
||||
latency vs build-time execution (inaudible).
|
||||
|
||||
### Option B — Shadow `PR/abi.h`, redirect macros to direct C calls (the PD trick)
|
||||
|
||||
Add `port/shim/PR/abi.h`: `#include "include/PR/abi.h"` then `#undef` the 15 `a*`
|
||||
macros and `#define` them as direct calls into `mixer.c` (PD's `mixer.h` does exactly
|
||||
this for naudio). DSP then runs inline during `alAudioFrame()` on the audio thread; the
|
||||
`M_AUDTASK` branch stays a no-op, exactly like PD.
|
||||
|
||||
- Pros: battle-tested pattern (shipped in PD); no decoder; slightly less latency.
|
||||
- Cons: shadows a core PR header for every TU that includes it (only libaudio uses these
|
||||
macros on PC, so blast radius is small but nonzero — must verify include-path
|
||||
ordering in `CMakeLists.txt`); stream becomes invisible at runtime (no logging hook —
|
||||
you'd instrument inside each Impl); the shim must exactly mirror IDO arg order, which
|
||||
differs from PD's naudio redirects line-by-line.
|
||||
|
||||
### Option C — Run the real `aspMain` microcode 【NOT RECOMMENDED】
|
||||
|
||||
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).
|
||||
|
||||
### Sub-decision A1 — where samples hit the SDL queue 【RECOMMENDED: immediate】
|
||||
|
||||
- **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.
|
||||
|
||||
### Sub-decision A2 — device sample rate 【RECOMMENDED: 22050】
|
||||
|
||||
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.
|
||||
|
||||
### Sub-decision A3 — timing and thread priorities 【RECOMMENDED: inline + host priorities; no worker thread】
|
||||
|
||||
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/PORT-LEARNINGS.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/PCPortResearch.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/PORT-LEARNINGS.md`.
|
||||
|
||||
## 11. Verification ritual (audio-specific, on top of the standard one in AGENTS.md)
|
||||
|
||||
1. Boot → title: menu music audible within a couple of seconds; no click at start.
|
||||
2. Enter BUNKER1 (`-level_09`): gunfire/door SFX present and roughly level-correct;
|
||||
level music plays.
|
||||
3. No `audio: ai out of samples` spam from `amHandleDoneMessage` (`audi.c:596`) — that
|
||||
print is the built-in underrun detector.
|
||||
4. Run 2–3 levels incl. one with heavy SFX overlap; watch `SDL_GetQueuedAudioSize`
|
||||
(log it) staying below `queueLimit` in steady state.
|
||||
5. PAL build (`pal-final`) spot-check: 50 Hz retrace drives the same path; frame-size
|
||||
math uses `MAYBE_FRAME_RATE` — confirm no underruns there too.
|
||||
6. `Audio.Enabled=0` → fully silent, zero audio CPU cost, nothing else affected.
|
||||
|
||||
## 12. Risks / unknowns
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| IDO current-buffer semantics misread → subtle wrong output (no crash) | Phase 0 disassembly is the arbiter; Acmd logger + by-ear checks per phase |
|
||||
| `aPoleFilter` has no PD code | Disassembly (Q3); it's one small IIR — bounded effort |
|
||||
| ADPCM state layout differs from assumption → garbage/cratchy samples | Phase 0 documents the struct from `load.c` usage + microcode; scalar fallback path makes debugging easy |
|
||||
| C7/D127 hides a bank-tree bug that audio makes *audible* (wrong SFX, not just missing) | Q1; keep the guard until root-caused |
|
||||
| SDL queue backpressure drops under load (slow machines) | `queueLimit` drop is graceful (silence gap, no crash); A3: raise scheduler-thread host priority before buffer tuning; PD shipped the same design |
|
||||
| Synthesis cost on the scheduler thread starves gfx frame time / causes underruns | A3: host thread priorities first; a dedicated audio worker thread only if priorities provably insufficient (and revisit D24's fake-priority semantics before designing one) |
|
||||
| Cross-region: PAL sample-count math differs | Verification step 5 |
|
||||
|
||||
## 13. Key references
|
||||
|
||||
| File | What it gives us |
|
||||
|---|---|
|
||||
| `src/audi.c` | audio thread, frame-size feedback loop, task construction (`OUTPUT_RATE` @22) |
|
||||
| `src/sched.c:439,465,485` | real scheduler's M_AUDTASK path → `osSpTaskStartGo` interception point |
|
||||
| `include/PR/abi.h:260–407` | IDO Acmd word formats (the decoder's spec) + A_* opcodes |
|
||||
| `src/libultra/audio/synthInternals.h:57–71` | DMEM layout constants |
|
||||
| `src/libultrare/audio/reverb.c`, `env.c`, `drvrNew.c` | GE custom FX (`alFxNew`), pole filter + chorus, envelope ops |
|
||||
| `bin/aspboot.text.bin` / `.data.bin` (+ `src/aspboot.s`) | **ground-truth ASP microcode to disassemble** |
|
||||
| `pd_port/port/src/mixer.c`, `port/include/mixer.h` | DSP math reference + the Option-B pattern |
|
||||
| `pd_port/port/src/audio.c`, `pdsched.c:233–260`, `libultra.c:150–175` | PD wiring (queue design, empty AUDTASK branch, AI shims) |
|
||||
| `port/src/libultra.c:794–810, 1108` | the three dead seams this plan fills |
|
||||
| `port/src/dram.c`, `port/shim/PR/R4300.h` | address model (V1/V2 views) + the K0_TO_PHYS gap |
|
||||
+260
-3
@@ -1,7 +1,264 @@
|
||||
All of these items should be completed once the main original game is playable from start to finish on PC with no crashing or major issues.
|
||||
|
||||
PC graphics settings - higher resolutions, anti aliasing, etc. Any rendering improvements but make them all optional, use Nightdive remasters or things like the Quake 1 remaster as a reference of offered features.
|
||||
### PC graphics settings
|
||||
|
||||
Co Op - find a way to implement local co op for the game on the singleplayer levels. The game already has local split screen multiplayer, but we'd want to extend netplay at least for LAN so that two PCs could run the port and play co op together.
|
||||
Optional video/rendering options, modelled on the PD port (baseline) with
|
||||
Quake 1 remaster / Nightdive-style extras as stretch goals. **Every option
|
||||
defaults to the current behavior** — zero visual delta at defaults.
|
||||
|
||||
Deathmatch - use the co op netplay to make deathmatch / regular multiplayer playable across 4 instances of the PC port on a local network
|
||||
**Starting point (verified):**
|
||||
- `port/src/config.c` exists but is minimal (58 lines: Int + String only;
|
||||
PD's is 300 lines with Float/UInt, clamping, INI sections). Copy-and-adapt
|
||||
from `pd_port/port/src/config.c`.
|
||||
- `port/src/video.c` is a trimmed port of PD's (250 vs 588 lines). Render path
|
||||
is architecturally identical to PD: native-res FBO (GE = 640×480 NTSC LAN1)
|
||||
→ scaled blit to window, so all PD display options port over cleanly.
|
||||
- No `#ifdef PC` blocks exist in `src/` yet; only the documented ABI/layout
|
||||
exception edits (D3x) are present. Anything below that changes *behavior*
|
||||
(FOV, HUD placement, menu contents) is a policy decision — see route notes.
|
||||
|
||||
**Baseline (PD parity)** — each item cites its PD source:
|
||||
1. Config system: full `configRegister{Int,UInt,Float,String}` + clamps
|
||||
(`pd_port/port/src/config.c`).
|
||||
2. Window/display (`video.c` + SDL): fullscreen checkbox, fullscreen mode
|
||||
(borderless/exclusive), maximize, center window, resolution dropdown
|
||||
(display-mode list + "Custom"), HiDpi toggle.
|
||||
3. Frame pacing: vsync (Adaptive/Off/On/N-frames via `SDL_GL_SetSwapInterval`),
|
||||
framerate cap slider (PD caps FPS when vsync is off — keep that guard).
|
||||
4. Quality: MSAA Off/2x/4x/8x/16x (FBO `samples` — **verify our FBO path
|
||||
accepts samples>0 first**; PD reads it back via `videoGetMSAA`), texture
|
||||
filter Nearest/Bilinear, anisotropic 1–16 slider, mipmap filter,
|
||||
on-screen FPS display toggle.
|
||||
5. **FOV slider (baseline per owner decision).** PD does this game-side
|
||||
(`Game.PlayerN.FovY`, `g_PlayerExtCfg` in their `main.c` + decomp edits).
|
||||
Two routes — decide in a spike:
|
||||
- **(a) Port-layer:** override the camera projection in our fast3d where
|
||||
the view matrix/projection is consumed. No src changes; must find the
|
||||
exact injection point (camera math flows through game GBI setup).
|
||||
- **(b) PD-style port-only `src/` edit** reading a port-provided value.
|
||||
This would be the first *behavioral* src edit — exceeds the current
|
||||
ABI/layout exception in AGENTS.md #2; if chosen, expand the documented
|
||||
exception ("PC-port feature hooks, opt-in via config, default = original
|
||||
behavior") and record a Dxx finding.
|
||||
6. **Widescreen handling:** non-4:3 window aspect → stretch vs pillarbox
|
||||
choice, plus HUD centering (None/4:3/Wide à la PD `Game.CenterHUD`). PD
|
||||
implements the HUD part with `G_ASPECT_*_EXT` extended GBI mods set
|
||||
game-side; for us that's either a fast3d viewport intercept of the HUD
|
||||
quads (route a) or a port-only src edit (route b). Same policy note as FOV.
|
||||
|
||||
**In-game video options menu:** v1 = config file (+ CLI flags if trivial).
|
||||
PD's in-game menu (`pd_port/port/src/optionsmenu.c`, 2042 lines) hooks into
|
||||
*their* decomp's menu tables with port-side handlers; GE's menu code differs,
|
||||
so it's an adaptation, not a copy. Feasible under route (b) policy; revisit
|
||||
after the config-file v1 works.
|
||||
|
||||
**Stretch goals (Quake 1 remaster / Nightdive):**
|
||||
- Internal resolution scale: render at 2x/4x native then downscale (free
|
||||
supersampling AA, big fidelity win on modern displays).
|
||||
- FXAA post-process toggle (cheap AA alternative to MSAA).
|
||||
- SSAO + bloom / light-shaft post-processes — Quake Enhanced ships each
|
||||
enhancement as an individually-toggleable option; keep that pattern (one
|
||||
toggle each, all default off, shared post-FBO stage with FXAA/colorblind).
|
||||
- Integer-scaling option.
|
||||
- Texture upscaling filter (ESRGAN-style or pre-generated upscales) — the
|
||||
biggest item; do last. Note GE textures are RZ-compressed at load
|
||||
(`port/src/rzdecomp.c`) — an upscale pass would hook there or post-decode.
|
||||
|
||||
**Acceptance:** every option persists via config file; toggles apply live
|
||||
where PD's do; `tools_pc/golden/` dumps byte-identical at all-defaults;
|
||||
no `src/` changes unless route (b) is explicitly chosen and documented.
|
||||
|
||||
### LAN deathmatch / multiplayer — the netplay foundation (do first)
|
||||
|
||||
**The idea:** play GE's *existing* competitive multiplayer (deathmatch /
|
||||
mission-based, 2–4P) across up to **4 instances** of the PC port on a local
|
||||
network. This is the **nettech proving ground**: the game logic already
|
||||
exists in the decomp (original N64 feature — no new mode to invent), so this
|
||||
item isolates and de-risks the entire networking layer. Co-op (below) bolts
|
||||
onto whatever this produces.
|
||||
|
||||
**Milestone 0 (prerequisite, real port work):** verify GE's *existing*
|
||||
split-screen multiplayer works locally on PC — two cameras, split viewport,
|
||||
two independent input streams, full match lifecycle. Every code path netplay
|
||||
will drive must first work on one box.
|
||||
|
||||
**Net model (research item — owner leans client-server):**
|
||||
- Leading candidate: **host = server + player** (Source-engine style): host
|
||||
runs the authoritative sim, clients send inputs and receive state. Friends
|
||||
connect by manual IP entry on a LAN. Self-healing desync, one machine owns
|
||||
truth.
|
||||
- Cost to size up: a full per-tick **state snapshot format** (players,
|
||||
entities/props, AI, doors/props) must be defined from scratch — no existing
|
||||
save/replay infra covers frame state (EEPROM saves are mission-level only).
|
||||
- Alternative for the research phase: **lockstep/deterministic** (exchange
|
||||
~10 bytes of input per 60Hz tick, both machines run the full sim). Far less
|
||||
code *if* the sim is bit-deterministic across hosts — PRNG is ported
|
||||
verbatim (`port/src/random.c`, good), but host-scheduling nondeterminism is
|
||||
a known risk (see D24-implications note below). Desync = hard stop.
|
||||
A per-tick state-hash desync detector is cheap insurance either way.
|
||||
- PD port has **zero networking code** — nothing to copy; the net layer is
|
||||
new (`port/src/net.c`-style, SDL_net or plain sockets).
|
||||
|
||||
**Shape of the work under host-authoritative:** generalize to host + N clients
|
||||
(N≤3), per-client input channels, state snapshots fanned out to all clients.
|
||||
The host already runs the full sim, so scaling past 2P is mostly protocol
|
||||
fan-out — which is why 2P is a natural intermediate milestone on the way to
|
||||
the 4-instance bar.
|
||||
|
||||
**v1 bar:** LAN only, manual IP entry (host listens / client connects),
|
||||
deathmatch playable; 2P first, then scale to 4 instances. No matchmaking,
|
||||
discovery, reconnect, or internet play. Stretch: ping display, graceful
|
||||
reconnect-on-drop, mission-based (non-deathmatch) multiplayer variants,
|
||||
remote-player model interpolation between snapshots (Quake Enhanced ships
|
||||
it; hides LAN jitter).
|
||||
|
||||
### Co-op (new mode — built on the deathmatch net layer)
|
||||
|
||||
**The idea:** two players progress the single-player campaign levels
|
||||
together — locally on one PC, and over LAN with each player on their own PC.
|
||||
Sequenced **after** LAN deathmatch: reuse its connection handling, snapshot
|
||||
format, and input channels; this item adds the mode itself.
|
||||
|
||||
**Key fact (defines the scope): GE has no co-op mode.** Unlike PD, GoldenEye
|
||||
only ships split-screen *competitive* multiplayer. Co-op on solo levels exists
|
||||
in **PD only** — so this is a net-new game mode: mapping/level and GE game-
|
||||
code feature work, on top of the (already-existing) networking plumbing.
|
||||
Consequences:
|
||||
- Non-negotiable #2 ("game logic unmodified") is about not altering *existing*
|
||||
behavior; adding a new mode is a separate, explicit owner decision. Record
|
||||
the policy call when work starts.
|
||||
- `pd_port/` is the reference implementation: PD's decomp shows how two-player
|
||||
co-op was wired into this same Indy engine (shared objective progression,
|
||||
both players' AI/enemy interaction, win/lose conditions, split-screen
|
||||
presentation). Expect an adaptation study, not a copy — GE's mission/
|
||||
objective code differs file-by-file.
|
||||
- Cheaper fallback worth considering during design: "freeplay" co-op (both
|
||||
players active on a solo level, but only P1's objective progress counts /
|
||||
or objectives are duplicated per player). Much less new logic; decide after
|
||||
the PD study.
|
||||
|
||||
**v1 bar:** LAN + local 2P co-op on solo levels over the deathmatch net layer.
|
||||
No matchmaking, discovery, reconnect, or internet play.
|
||||
|
||||
### Fun features (remaster research: Quake 1 Remaster / Nightdive)
|
||||
|
||||
Feature mining from the Quake 1 Remaster (id, 2016), Nightdive's remaster
|
||||
line (Duke Nukem 3D: Forever, DOOM, System Shock), and — round 2,
|
||||
2026-08-29 — Quake Enhanced, Quake II Enhanced, Turok Resurrection, Doom 64
|
||||
Definitive Edition, SiN Reloaded. Pattern across all of them: classic
|
||||
fidelity as default + a shelf of opt-in QoL/accessibility/asset options.
|
||||
Tiered by effort; all stay in `port/` unless noted.
|
||||
|
||||
**Cheap wins (config toggles over existing infra):**
|
||||
- **Screenshot hotkey.** Productize the existing debug dump:
|
||||
`gfx_opengl_dump_bound_fbo()` (`port/src/video.c:199`) is already there —
|
||||
wire a keybind + configurable output dir, timestamped filenames. Every
|
||||
Nightdive title has this; ours is ~an afternoon.
|
||||
- **Screen-shake intensity slider (0–10x).** All shake funnels through
|
||||
`viShake()` (`src/game/bondview2.c:8300` et al.) which lands in our port
|
||||
shim — scale amplitude there, zero src changes. PD precedent:
|
||||
`Game.ScreenShakeIntensity`.
|
||||
- **No hit-flash / damage-indicator toggle.** The red full-screen flash on
|
||||
taking damage is a top community-mod option; Nightdive ships it. Impl: find
|
||||
how the flash overlay is drawn (likely a full-screen HUD quad) and intercept
|
||||
in the port layer, or route-(b) one-liner.
|
||||
- **Auto-pause / input-hold on window focus loss.** SDL focus events; trivial,
|
||||
prevents drive-by deaths when alt-tabbing.
|
||||
- **Controller polish (copy from PD `input.c`):** per-player gamepad
|
||||
assignment (`Input.ControllerIndex`), per-stick deadzone/sensitivity sliders,
|
||||
rumble scale + rumble mapping to GE's feedback moments. Note `osMotor*`
|
||||
(Rumble Pak) is currently stubbed — accessory detection only
|
||||
(PCPortResearch §4) — so wiring it to `SDL_GameControllerRumble` is part of
|
||||
this item; Doom 64 DE shipped haptic feedback (a KEX first). We have basic
|
||||
pad support (D118); this is the remaster-grade layer on top.
|
||||
- **Tick-rate / framerate options.** PD ships `Game.TickRateDivisor` +
|
||||
framerate cap. Flag: any tick-rate change must be reconciled with the future
|
||||
netplay determinism requirement before shipping as a user option (lock it to
|
||||
60Hz default, document).
|
||||
- **Fog / draw-distance option.** Turok's KEX port removed the N64's heavy
|
||||
distance fog; Quake II Enhanced added height-based fog with custom colors.
|
||||
fast3d already decodes fog generically (PCPortResearch §5) — a config
|
||||
slider scaling/overriding fog density, default = original values. Zero src
|
||||
changes.
|
||||
|
||||
**Medium:**
|
||||
- **High-res / user texture packs (the big goodwill item).** Quake 1 Remaster's
|
||||
core feature is swappable asset sets (classic vs remastered toggle). GE
|
||||
textures are RZ-compressed and decoded at load (`port/src/rzdecomp.c`) — a
|
||||
per-texture override lookup against a user directory (fall back to ROM) hooks
|
||||
there. Enables the community to make packs; also our escape hatch for the
|
||||
blurry-asset complaints without touching fidelity defaults.
|
||||
- **Accessibility: colorblind post-process filters** (protanopia/
|
||||
deuteranopia/tritanopia full-screen shader pass) + the screen-shake slider
|
||||
above. Nightdive includes these; cheap once we have a post-process stage
|
||||
(shared infra with FXAA from the graphics item — sequence together).
|
||||
Quake II Enhanced also shipped high-contrast / readability UI options —
|
||||
add where the port layer can reach (bitmap HUD text limits scope;
|
||||
document what's not doable under route b).
|
||||
- **Crosshair customization** (size/opacity at minimum; GE's crosshair is
|
||||
game-drawn — check whether it's a textured quad we can scale in the port
|
||||
layer before assuming route b).
|
||||
- **Widescreen FOV compensation** — already baseline in the graphics item;
|
||||
listed here because it's the feature every remaster ships and users will
|
||||
expect.
|
||||
|
||||
**Big / stretch:**
|
||||
- **Demo / replay record + playback.** Record the input stream (+ initial
|
||||
state), play back headlessly. Quake tradition; Nightdive titles have
|
||||
replay-ish tooling. **Strong synergy with netplay work:** a working replay
|
||||
system *is* a determinism test harness — if a recorded demo replays
|
||||
bit-identically, lockstep netplay's hardest assumption is proven. Consider
|
||||
building it as the netplay pre-flight tool even before it's user-facing.
|
||||
- **Photo mode.** Pause + free orbit camera + capture (System Shock
|
||||
precedent). Free camera = port-layer camera override; pairs with the
|
||||
screenshot hotkey.
|
||||
- **In-game console.** Type commands at runtime. Less "remaster feature"
|
||||
than dev tool, but for a WIP port it's arguably the highest-value item on
|
||||
this list: toggle debug flags, dump state, force conditions without
|
||||
restarting. PD has no equivalent; we'd own the design.
|
||||
- **Benchmark mode.** Scripted auto-run sequence + FPS report (Quake
|
||||
tradition); trivial once tick-rate options exist.
|
||||
- **AI opponents in deathmatch.** Quake Enhanced ships bots in DM. GE's N64
|
||||
MP is human-only, so bots = net-new game logic — same policy class as
|
||||
co-op (record the owner decision when work starts). Stretch: lets a solo
|
||||
player exercise the netplay layer and makes MP approachable with 1–2 humans.
|
||||
- **Animation smoothing ("wiggle elimination").** Quake II Enhanced restored
|
||||
high-res skeletal animations specifically to kill low-fps "wiggle". GE's
|
||||
model keyframes are N64-rate; interpolating (slerp) between keyframes in
|
||||
the port-layer model-transform path would visibly smooth guard/Bond motion
|
||||
on modern displays. Stretch — sequence with the graphics backlog, do late.
|
||||
- **Dynamic shadows.** Quake Enhanced's headline graphical feature. For
|
||||
fast3d this means a shadow-map pass + per-light casting — deep stretch;
|
||||
park next to texture upscaling (both "do last" items).
|
||||
- **Remastered audio option (Phase 3 note).** System Shock's remake added
|
||||
new sounds/music. Keep N64 ADPCM fidelity as the default; optionally offer
|
||||
a lossless re-encoded soundtrack toggle — owner decision + content work,
|
||||
park until Phase 3 lands.
|
||||
- **Optional modern UI skin.** SiN Reloaded shipped upgraded 2D screens /
|
||||
menu art; System Shock an overhauled interface. For GE this is route (b)
|
||||
game-side territory (menu tables in `front.c` / `initmenus.c`) — park as
|
||||
an explicit owner decision; lowest priority on this list.
|
||||
- **Stereo 3D** — both reference families shipped it; explicitly
|
||||
deprioritized as anachronistic. Park unless someone asks.
|
||||
|
||||
Out of scope by the v1 bars above: online matchmaking, leaderboards,
|
||||
anti-cheat, cross-play (Quake 1 Remaster's biggest features; revisit only
|
||||
after LAN netplay is solid).
|
||||
|
||||
Also out of scope by policy (community port): online-platform integration —
|
||||
Steam/GOG/Epic achievements, cloud saves, storefront add-on downloads. No
|
||||
platform tie-in of any kind.
|
||||
|
||||
--Old is above here
|
||||
|
||||
---
|
||||
|
||||
## Technical cleanup (backlogged; not gated on playability)
|
||||
|
||||
- **Remove TEMP D63 debug scaffolding.** ~16 references in `port/src/` (`libultra.c`): the master-DL scanner and env-gated `GE_D63` logging inside `osSpTaskStartGo()`. D63 is closed; this is dead weight in the hottest function in the port.
|
||||
- **Add a D24-implications bug class to `docs/PORT-LEARNINGS.md`.** "Host-scheduling nondeterminism / fake priority semantics": the pthread kernel does not enforce N64's 0–31 priorities (`osYieldThread` = `Sleep(0)`), so interleavings impossible on console can occur, and frame timing has host jitter. Consequence for future sessions: when a flaky timing bug appears (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.
|
||||
- **Watch item: `osYieldThread` = `Sleep(0)`.** The one place we fake cooperative behavior. If a level-sweep hot loop misbehaves under host load, this shim is the first thing to inspect. Keep as-is until then.
|
||||
|
||||
## Architecture decision (recorded; no action)
|
||||
|
||||
- **Keep the pthread + real `src/sched.c` model.** Compared against the PD port's actual design (verified in `pd_port/`: zero threads — thread API is pure no-op, `mainProc()` runs as the host main thread, `pdsched.c` is a gutted frame-callback wrapper, audio driven inline via `amgrFrame()`, M_AUDTASK dropped). PD could do that only because their *original N64* scheduler already used direct-call submission ("nothing writes to the cmdQ in PD") and they adapted game-side lib code (`audiomgr.c`). For GE — classic `osSendMesg(cmdQ)` protocol, real `__scMain` loop, `amMain` blocking on retrace/reply messages — that route means editing `src/audi.c`/submission points (non-negotiable #2 violation) and regressing Phase 3, which currently runs the full authentic audio scheduling protocol. No rewrite. Related: audio stays inline in the `M_AUDTASK` branch (`docs/AUDIO-PLAN.md`, Option A + sub-decision A3); no dedicated audio worker thread unless host thread priorities provably fail.
|
||||
@@ -0,0 +1,165 @@
|
||||
# Texture Glitch Analysis — PC Port (read-only investigation)
|
||||
|
||||
**Date:** 2026-08-29 · **Status:** diagnosis complete, fixes pending (session was read-only)
|
||||
**Supersedes nothing** — feeds the Dxx entries proposed in §7. Companion artifacts: `CAPTURES_DIR` (outside repo).
|
||||
|
||||
---
|
||||
|
||||
## 1. Reported symptoms
|
||||
|
||||
1. **In general:** textures repeat oddly with weird artifacting.
|
||||
2. **Depot (`-level_30`):** loads "completely wrong" textures — the ground is drawn a
|
||||
glitched **blue** that is not right (visible in attract mode).
|
||||
3. **Character models:** obvious texture artifacts during the credits roll.
|
||||
|
||||
## 2. Root causes (confirmed by code + ROM evidence)
|
||||
|
||||
| # | Cause | Location | Explains | Fix class |
|
||||
|---|-------|----------|----------|-----------|
|
||||
| RC1 | `G_SETTEX` (0xc0) is a **no-op** in fast3d | `port/fast3d/gfx_pc.cpp:2443` (`case G_NOOP`) | #3 character/model artifacts | implement the command |
|
||||
| RC2 | **Mip-chain contamination**: base + all LODs uploaded as one GL image | `src/game/tex.c:330` (`texGetDepthAndSize` sums all LODs) → `gfx_dp_load_block`/`import_texture_*` | #1, #2 noise bands, wrong scale, minification garbage | upload base level only; disable GL mips |
|
||||
| RC3 | **Wrong wrap period**: N64 wraps at `2^ceil(log2(dim))`, GL wraps at uploaded image size; D74 pre-wrap block is dead (`G_TX_WRAP == 0`, `include/PR/gbi.h:391`) | `port/fast3d/gfx_pc.cpp` UV math in `gfx_sp_tri1` + D74 guard | #1 "repeats oddly", squash/stretch on non-PoT dims | mask-based wrap period (fix D74 guard, extend to T axis) |
|
||||
| RC4 | **Palette off-by-one** in `palette_to_rgba32` | `port/fast3d/gfx_pc.cpp:835` | subtle hue shift on all CI textures (grays survive; red→orange-ish, blue→cyan-ish) | 1-line-class fix per channel |
|
||||
|
||||
### RC1 — G_SETTEX no-op (character artifacts)
|
||||
|
||||
- GE's `gbi_extension.h` defines opcode 0xc0 as `G_SETTEX` (`gsSPUseTexture`):
|
||||
`w0[3:0]=type, [9:6]=shiftt, [13:10]=shifts, [17:16]=tile, [19:18]=cmt, [21:20]=cms`;
|
||||
`w1 = minlevel<<24 | detail_id<<12 | texture_id`.
|
||||
- **Full ROM scan: 508 of 512 model files contain G_SETTEX — 43,314 commands total**
|
||||
(top emitters: `PwalletbondZ` 2830, `CbaronsamediZ` 976, `GautoshotZ` 975).
|
||||
- Worked example `CheadbrosnanZ`: 47 G_SETTEX commands; texture_ids (1624–1626 = 32×64,
|
||||
1607 = 64×32, 1608 = 1×1 null) exactly match the model's `Textures[]` table; the GDL
|
||||
interleaves them per mesh part. All use tile=2 (`G_TX_RENDERTILE`).
|
||||
- On N64, gmain.s dispatches 0xc0 via the jump table at offset 0xbc (top-2-bits bucket 3);
|
||||
it rebinds the render tile to the texture identified by `texture_id`.
|
||||
- Our fast3d hits `case G_NOOP: break;` — **every mesh part renders with stale tile state**
|
||||
→ wrong/shifted textures on characters. This is why the credits roll (many models, many
|
||||
texture switches) shows obvious artifacts.
|
||||
- The in-code comment at `gfx_pc.cpp:2443` ("the game never emits it") is **wrong for GE** —
|
||||
presumably inherited from PD assumptions. The PD port also no-ops 0xc0, so there is no
|
||||
reference implementation to copy; implement from gmain.s + gbi_extension.h.
|
||||
|
||||
### RC2 — Mip-chain contamination (floor/room noise)
|
||||
|
||||
- `texGetDepthAndSize()` (`src/game/tex.c:330`) sums **all** LOD levels, so one
|
||||
`gDPLoadBlock` carries base + full mip chain. fast3d uploads that as a single GL image:
|
||||
a 65×65 floor texture becomes ~65×134, rows below the base being raw mip bytes.
|
||||
- GL then wraps at the *uploaded* size and `glGenerateMipmap` + the never-disabled
|
||||
`GL_LINEAR_MIPMAP_LINEAR` min-filter (`gfx_opengl.cpp:59`) sample **mips of a
|
||||
garbage-stacked image**. Distant surfaces (attract-mode camera!) minify into noise.
|
||||
- ~75% of Depot's textures carry full mip chains (lod 6–7), so this is maximal there.
|
||||
- N64 per-LOD tile selection is already emulated by the D107 rule (always base tile,
|
||||
offset 0) — GL mips are pure poison in this port; disable them.
|
||||
|
||||
### RC3 — Wrap period mismatch ("repeats oddly")
|
||||
|
||||
- N64 wraps at the tile mask period `2^ceil(log2(dim))` (`texDimensionToMask`,
|
||||
`src/game/tex.c:361`). GL `GL_REPEAT` wraps at actual image size.
|
||||
- Depot's large room surfaces are **non-power-of-two**: 65×65, 56×56, 96×48, 128×32
|
||||
(I4/IA4, non-zlib). E.g. 65 wide: N64 period 128 vs GL period 65 → pattern squashed 2×
|
||||
horizontally; with RC2 the vertical period is ~2× too long and half of it is noise.
|
||||
- The D74 pre-wrap UV block never runs: guard tests `cms & G_TX_WRAP` but
|
||||
`G_TX_WRAP == 0` (`include/PR/gbi.h:391`) — dead code (already parked in
|
||||
`docs/GRAPHICS-BACKLOG.md`).
|
||||
|
||||
### RC4 — Palette off-by-one (subtle global hue shift)
|
||||
|
||||
`port/fast3d/gfx_pc.cpp:835`:
|
||||
|
||||
```c
|
||||
// fast3d (current): r = v>>11; g = (v>>6)&0x1f; b = (v>>1)&0x1f; a = v&1
|
||||
// N64 RGBA16 spec: r = (v>>10)&0x1f; g = (v>>5)&0x1f; b = v&0x1f; a = v>>15
|
||||
```
|
||||
|
||||
Channels are read one bit off (alpha treated as LSB). Grays (r≈g≈b) survive nearly intact —
|
||||
which is why BUNKER1 looks "mostly right" (it's gray/brown) — but saturated hues shift.
|
||||
Format mapping itself was verified correct: `TEXFORMAT_RGBA16_CI8/CI4` → `G_TT_RGBA16`,
|
||||
`IA16_CI8/CI4` → `G_TT_IA16` (`src/game/image.c:71–104`).
|
||||
|
||||
## 3. Depot-specific analysis ("why blue ground, only there?")
|
||||
|
||||
**Depot is not special — it is the worst case of RC2+RC3+RC4.**
|
||||
|
||||
- **Ruled out with data:**
|
||||
- *Texpool exhaustion / D85 placeholder path:* a real Depot load (env-gated log) shows
|
||||
342 unique texture loads, ~326 KB compressed into the 450 KB stage pool, **zero**
|
||||
`D85TEX pool-full` events. The floor is not sampling a garbage pointer.
|
||||
- *Broken decompression:* `texInflateZlib`/`texInflateNonZlib` are faithful decompilations;
|
||||
the format was independently re-derived offline (§4) and decodes byte-exact.
|
||||
- **What Depot's floor actually is** (decoded from ROM): non-zlib **I4/IA4 grayscale with
|
||||
full 7-level mip chains and non-PoT dimensions** (texnums incl. 85/86, 508, 618, 631,
|
||||
779–782, 836, 909–911, 989–996). Exactly the class RC2+RC3 mangle hardest.
|
||||
- **The blue:** nothing in Depot's texture set is actually blue (decoded color stats are all
|
||||
industrial grays/browns, e.g. avg rgb 107/107/108, 147/117/112). The perceived blue =
|
||||
garbage I4 values sampled under minification (attract camera is far → mip-filtered noise)
|
||||
+ Depot's fog table (`src/game/bgfog.c`, LEVELID_DEPOT entry) tinting distant surfaces
|
||||
cold. RC4 then shifts whatever hue survives.
|
||||
- BUNKER1 "renders recognisably" because its surfaces hit the same bugs less severely
|
||||
(PoT dims / fewer visible garbage rows at typical camera distances).
|
||||
|
||||
## 4. Texture pipeline reference (learned during this investigation)
|
||||
|
||||
Useful ground truth for whoever implements the fixes:
|
||||
|
||||
- **ROM layout:** `g_Textures[N]` = segment offset; texture N occupies
|
||||
`[g_Textures[N], g_Textures[N+1])` in the images segment (`_imagesSegmentRomStart`,
|
||||
ROM file offset 0x8F7DF0 in the NTSC dump). `assets/images.def` cumulative sizes match the
|
||||
logged runtime offsets **exactly (0/342 mismatch)** — verified against the D66 log.
|
||||
- `texLoad` (`src/game/image.c:~2450`) copies from `(thisoffset & ~15)` and re-points
|
||||
`compptr += (thisoffset & 7)`; empirically every blob decodes at the listed offset (+0).
|
||||
- **Blob header byte:** `u(1) z(1) lod(6)`. `z=1` → zlib path, else per-image
|
||||
format(4)/w(8)/h(8)/comp(4) + Huffman/RLE/lookup payloads.
|
||||
- **Zlib path** (`texInflateZlib`, `image.c:169`): MSB-first bitstream —
|
||||
format(8), numcolours(8)+1, palette nc×16; per image: width(8), height(8), then a
|
||||
**rarezip stream = 2-byte header `0x11 0x72` + raw deflate** (GE's `src/game/zlib.c` is
|
||||
stock gzip-1.2.4 inflate). Output rows are stored **tight**; `texAlignIndices`
|
||||
(`image.c:340`) repacks to 8-byte-aligned rows (CI8: 1 B/px; CI4: 1 B/2 px, high nibble
|
||||
first).
|
||||
- **`texSwapAltRowBytes`** (`image.c:2191`) is applied to **every level of every texture on
|
||||
every code path** (calls at 260, 300, 312, 316): odd rows get adjacent u32 groups pairwise
|
||||
swapped. The post-swap RAM buffer is what gets uploaded — any offline tool or dump must
|
||||
account for this.
|
||||
- Palette is appended to the buffer big-endian after all images; `G_LOADTLUT` uploads it.
|
||||
- `u=0` + lod≥2: mips are generated at load by `texShrinkPaletted` (2×2 palette average).
|
||||
|
||||
## 5. Open questions / hypotheses
|
||||
|
||||
- **H-A (high confidence):** Depot blue ground = RC2+RC3+RC4+fog, per §3. Confirm in-game
|
||||
after fixes; the before/after capture is the test.
|
||||
- **H-B (high confidence):** credits-roll model artifacts = RC1. Confirm by implementing
|
||||
G_SETTEX and re-capturing the roll.
|
||||
- **H-C (unresolved, non-blocking):** the offline contact sheet "still looks off" to the
|
||||
reviewer despite stage-by-stage verification of the decoder (§4; exact byte-count matches,
|
||||
verbatim function copies, clean odd/even row-boundary statistics). Possible explanations:
|
||||
32×32 CI8 textures are inherently noisy at native res; small-cell presentation; or a
|
||||
residual misunderstanding of the final layout. Fallback if it ever matters: a standalone C
|
||||
harness running GE's actual `texInflateZlib` on the ROM, byte-diffed against the Python
|
||||
decoder. **Not on the critical path** — the in-game fixes don't depend on it.
|
||||
|
||||
## 6. Fix plan (when read-only lifts), in priority order
|
||||
|
||||
1. **G_SETTEX** (`gfx_pc.cpp` dispatch): rebind render tile to the texpool entry for
|
||||
`texture_id` per gmain.s semantics. Biggest single visual win (characters).
|
||||
2. **Mip contamination:** upload base level only in `gfx_dp_load_block`/`import_texture_*`;
|
||||
drop `glGenerateMipmap` and the `MIPMAP_LINEAR` min-filter default. Fixes Depot floor noise.
|
||||
3. **Wrap period:** wrap at the N64 mask period (`texDimensionToMask`) — fix the dead D74
|
||||
guard (`cms == G_TX_WRAP`), extend to the T axis and non-PoT dims.
|
||||
4. **Palette off-by-one** (`gfx_pc.cpp:835`): one-line-class fix per channel.
|
||||
5. **Verification hook:** env-gated dump of the actual uploaded GL image + tile setup for
|
||||
the first N texLoads (~10 lines in fast3d) → one Depot run gives true ground truth and a
|
||||
before/after reference (also settles H-C if it ever resurfaces).
|
||||
|
||||
Each item gets its own Dxx entry in `docs/PCPortResearch.md` §F/§H at commit time
|
||||
(proposed: next free labels after D126 — check the §F index first; a stray "D160" appears
|
||||
once in the doc and may need reconciling).
|
||||
|
||||
## 7. Artifacts (in `CAPTURES_DIR`, outside repo)
|
||||
|
||||
| File | What it is |
|
||||
|------|-----------|
|
||||
| `depot_d85.log` | Full Depot run with `GE_D63=1 GE_D85TEX=1` — 342 texLoad records (texnum/thisoff/nextoff/size), zero pool-full events |
|
||||
| `decode_depot_tex.py` | Offline rarezip/bitstream decoder (verified against §4) |
|
||||
| `depot_texsheet.png` | Contact sheet, 342 tiles, labeled by texnum (zlib-decoded only; non-zlib I4/IA4 cells blank) |
|
||||
| `depot_texstats.txt` | Per-texture format/size/lod/color stats for all 342 |
|
||||
| `tiles_big.png`, `tile_*.png` | Individual full-size decodes (760, 313, 1283, 233, 1082, 21, 451, 7) |
|
||||
Reference in new issue
Block a user