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:
James Dansereau committed 2026-08-29 21:34:00 -04:00
1 parent fda4c19159
commit 1c4efdaaf2
3 files changed
+782 -3

No files matched your search

+357
View File
@@ -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
View File
@@ -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.
+165
View File
@@ -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) |