Prepare for public release

- README, LICENSE (MIT for the port layer), NOTICE, CONTRIBUTING.
- Restructure docs: internals.md, porting-notes.md, dev-process.md +
  dev/agentic-development.md (the research write-up), dev/ (raw record).
- Replace decomp status-badge CI with a PC-port validate workflow.
- Remove session/agent scaffolding and stale planning docs from the tree.
- Add .gitattributes (normalize line endings to LF); add screenshots.

The pre-2026-08 history is the GoldenEye 007 decompilation by Larry Ficken
(KholdFuzion) and contributors, preserved unchanged. The PC-port work from
2026-08-17 on is mine.
This commit is contained in:
James Dansereau committed 2026-09-01 19:28:59 -04:00
1 parent 294e24cc26
commit 52539d10de
118 files changed
+3995 -8319

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/internals.md` as the plan of record
for closing **D77** (no audible music/sound on PC). Companion docs: `docs/HANDOFF.md`,
`docs/porting-notes.md`.
---
## 1. Goal and scope
Make GoldenEye's audio audible on the PC port with 1:1 fidelity to the N64 pipeline:
music (3 cseq tracks) and SFX through the real libaudio synth, out of SDL2.
In scope:
- Executing the per-frame **Acmd stream** that stock IDO libaudio emits (the part that
ran on the RSP under GE's `aspMain` microcode).
- SDL audio device + queue plumbing (`port/src/audio.c`, AI shims in `libultra.c`).
- One latent ABI fix in the port shim headers (`K0_TO_PHYS`, §7.1).
Out of scope:
- MP3 playback (naudio-only, PD-specific; GE never emits it).
- Input (done — D118), saves (Phase 4).
- Any change to game logic. All work lives in `port/` except the narrow, documented
shim-header exception in §7.1 (same class as D78).
## 2. Current state (verified by reading, not assumption)
Working already:
- **The whole CPU side of libaudio runs.** `src/libultra/audio/*` +
`src/libultrare/audio/{drvrNew,env,reverb}.c` compile and execute; the audio thread
(`amMain`, `src/audi.c`) gets retrace messages from the real scheduler (`src/sched.c`,
compiled — Phase 2 decision), calls `alAudioFrame()`, which fills a 3000-word Acmd
list and posts an `M_AUDTASK` to the scheduler. Bank trees, cseq fixups (D54, D36/D37)
are done. Sample DMA from cart works via `amDmaCallback` → `osPiStartDma`
(synchronous ROM-image shim in `libultra.c`).
Dead (the three gaps):
1. **Acmd list never executed.** `port/src/libultra.c:1108` `osSpTaskStartGo()` has an
explicit Phase-3 no-op branch for `M_AUDTASK`. The output buffer (`info->data`) is
never filled.
2. **AI shims are stubs.** `libultra.c:794–810`: `osAiSetNextBuffer` discards the
buffer, `osAiGetLength` returns 0 (so the per-frame sample-count feedback loop in
`amHandleFrameMessage`, `audi.c:527`, runs blind).
3. **No SDL device.** `port/src/audio.c` `audioInit()` is a TODO stub; and nothing
calls `audioEndFrame()` (zero callers repo-wide).
Existing PORT guards to revisit when audio lands:
- **C7 / D127** (`src/snd.c:859`): skips SFX whose resolved `ALSound*` pointer is out of
range — added because the converted bank tree (`romdata.c afFixupInst`) yields fewer/
rearranged `soundArray` slots than the N64 image on some levels (Surface1). May be
masking a real bank-tree defect. See **Q1**.
- `src/audi.c:365`: `#if defined(PORT)` memcpy of `CUSTOM_FX_PARAMS_N` (C++-style
initializer workaround) — harmless, keep.
## 3. The GE pipeline in one paragraph
Audio thread on each retrace: `amHandleFrameMessage()` (`src/audi.c:497`) hands the
*previous* frame's output to AI via `osAiSetNextBuffer(lastInfo->data, …)`, computes
this frame's sample count from `g_FrameSize - osAiGetLength()>>2` (clamped to
`g_MinFrameSize`), calls `alAudioFrame()` which walks the filter chains (decoder →
resampler → envmixer → [FX: GE's custom chorus/reverb] → aux bus → main bus → save)
emitting **Acmd words** into a 3000-word list, then posts an `M_AUDTASK` (ucode =
`aspMain`). The real scheduler (`sched.c:439,485`) queues it in the audio list and runs
it through `osSpTaskLoad/osSpTaskStartGo`. On the N64 the RSP executes the Acmd stream
against 4 KB of DMEM, writing interleaved s16 stereo to `info->data`; AI DMA then plays
it at 22050 Hz. On PC we must replace "RSP executes Acmd stream" — that is the entire
problem.
**Opcode inventory** (grep-verified across `src/libultra/audio/*.c` +
`src/libultrare/audio/*.c` — 15 ops):
| Op | Emitted by | PD reference code? |
|---|---|---|
| `aSetBuffer` (×20) | load, mainbus, resample, save, reverb | yes (`a*Impl` pattern; different arg ABI) |
| `aMix` (×7) | mainbus, reverb | yes — `aMixImpl` |
| `aLoadBuffer` (×7) | load, reverb | yes — `aLoadBufferImpl` |
| `aClearBuffer` (×7) | auxbus, mainbus, load, reverb | yes — `aClearBufferImpl` |
| `aSetVolume` (×5) | env.c (Rare envelope) | yes — `aSetVolumeImpl` |
| `aSaveBuffer` (×4) | save, reverb | yes — `aSaveBufferImpl` |
| `aDMEMMove` (×4) | load, resample, reverb | yes — `aDMEMMoveImpl` |
| `aResample` (×2) | resample.c, reverb.c | yes — `aResampleImpl` (SSE4.1/NEON/scalar) |
| `aLoadADPCM` (×2) | load.c, reverb.c | yes — `aLoadADPCMImpl` |
| `aSetLoop` | load.c:479 | yes — `aSetLoopImpl` |
| `aSegment` | synthesizer.c:231 | n/a (no-op on CPU) |
| `aPoleFilter` | **reverb.c:402 (GE custom FX)** | **NO — PD's stub is empty ("never gets called?")** |
| `aInterleave` | save.c | yes — `aInterleaveImpl` (naudio variant takes no args) |
| `aEnvMixer` | env.c:410/414 | yes — `aEnvMixerImpl` |
| `aADPCMdec` | load.c:483 | yes — `aADPCMdecImpl` (SSE4.1/NEON/scalar) |
PD reference = the Perfect Dark port `port/src/mixer.c` +
`port/include/mixer.h`. Reusable: the DSP *math* (ADPCM 8-coefficient loop filter,
resample table + interpolation, envelope ramping, clamps). **Not** reusable as-is:
signatures and argument conventions (naudio ABI ≠ IDO ABI, §5.3).
## 4. How the PD port does it (ground-truth comparison)
1. naudio is Rare's own library; its `n_abi.h` has a `#ifndef PLATFORM_N64` branch that
redefines every `n_a*` macro as a **direct C call** into `port/src/mixer.c`
(`mixer.h` `#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/porting-notes.md`).
- **Phase 3 — FX.** `aPoleFilter` + validate the full `reverb.c` chorus path against
the disassembly. **Milestone: music matches N64 character (chorus/reverb present, no
pumping); close D77.**
- **Phase 4 — Polish & bookkeeping.** Resolve Q1; remove/adjust temporary probes and
`GE_DAUDIO` defaults; latency/buffer tuning (`Audio.BufferSize`, `QueueLimit`);
host thread-priority tuning per A3 *only if* underruns were observed in Phases 2–3;
record findings in `docs/internals.md` §F/§H (next Dxx after D127) + update the
§F index, `AGENTS.md` Phase 3 status, `docs/HANDOFF.md`; append recurring bug classes
to `docs/porting-notes.md`.
## 11. Verification ritual (audio-specific, on top of the standard one in AGENTS.md)
1. Boot → title: menu music audible within a couple of seconds; no click at start.
2. Enter BUNKER1 (`-level_09`): gunfire/door SFX present and roughly level-correct;
level music plays.
3. No `audio: ai out of samples` spam from `amHandleDoneMessage` (`audi.c:596`) — that
print is the built-in underrun detector.
4. Run 2–3 levels incl. one with heavy SFX overlap; watch `SDL_GetQueuedAudioSize`
(log it) staying below `queueLimit` in steady state.
5. PAL build (`pal-final`) spot-check: 50 Hz retrace drives the same path; frame-size
math uses `MAYBE_FRAME_RATE` — confirm no underruns there too.
6. `Audio.Enabled=0` → fully silent, zero audio CPU cost, nothing else affected.
## 12. Risks / unknowns
| Risk | Mitigation |
|---|---|
| IDO current-buffer semantics misread → subtle wrong output (no crash) | Phase 0 disassembly is the arbiter; Acmd logger + by-ear checks per phase |
| `aPoleFilter` has no PD code | Disassembly (Q3); it's one small IIR — bounded effort |
| ADPCM state layout differs from assumption → garbage/cratchy samples | Phase 0 documents the struct from `load.c` usage + microcode; scalar fallback path makes debugging easy |
| C7/D127 hides a bank-tree bug that audio makes *audible* (wrong SFX, not just missing) | Q1; keep the guard until root-caused |
| SDL queue backpressure drops under load (slow machines) | `queueLimit` drop is graceful (silence gap, no crash); A3: raise scheduler-thread host priority before buffer tuning; PD shipped the same design |
| Synthesis cost on the scheduler thread starves gfx frame time / causes underruns | A3: host thread priorities first; a dedicated audio worker thread only if priorities provably insufficient (and revisit D24's fake-priority semantics before designing one) |
| Cross-region: PAL sample-count math differs | Verification step 5 |
## 13. Key references
| File | What it gives us |
|---|---|
| `src/audi.c` | audio thread, frame-size feedback loop, task construction (`OUTPUT_RATE` @22) |
| `src/sched.c:439,465,485` | real scheduler's M_AUDTASK path → `osSpTaskStartGo` interception point |
| `include/PR/abi.h:260–407` | IDO Acmd word formats (the decoder's spec) + A_* opcodes |
| `src/libultra/audio/synthInternals.h:57–71` | DMEM layout constants |
| `src/libultrare/audio/reverb.c`, `env.c`, `drvrNew.c` | GE custom FX (`alFxNew`), pole filter + chorus, envelope ops |
| `bin/aspboot.text.bin` / `.data.bin` (+ `src/aspboot.s`) | **ground-truth ASP microcode to disassemble** |
| `pd_port/port/src/mixer.c`, `port/include/mixer.h` | DSP math reference + the Option-B pattern |
| `pd_port/port/src/audio.c`, `pdsched.c:233–260`, `libultra.c:150–175` | PD wiring (queue design, empty AUDTASK branch, AI shims) |
| `port/src/libultra.c:794–810, 1108` | the three dead seams this plan fills |
| `port/src/dram.c`, `port/shim/PR/R4300.h` | address model (V1/V2 views) + the K0_TO_PHYS gap |
+125
View File
@@ -0,0 +1,125 @@
# AUDIT-M6 — `struct player` / `struct hand` raw-offset pass
Survey only (Track 3 of `docs/PLAN-M6-playable.md`). No source changed, no
build. Feeds a follow-up fix session. Companion to §F D98/D100/D101/D102.
## Method
Grep of `src/` for: `handoffset`, `(u8 *)g_CurrentPlayer`, `g_CurrentPlayer + 0x`,
`+ 0x[3-hex]`, `sizeof(struct hand|player)`, `* sizeof(struct`, hardcoded
`0x2A80`/`0x2A70`, `->hands[i] +`, punned `->m[` / inline-Model accesses,
`bondheadmatrices`, `copy_of_body_obj_header`, `field_59C..field_6CC`,
`field_654`, `gaitRwData`. Cross-checked every hit against the live
`struct player` / `struct hand` definitions in `src/game/bondview.h`
(player @ line 314, hand @ line 119).
## PC layout facts (from bondview.h + bondtypes.h)
- `struct hand` (bondview.h:119): N64 `sizeof` ≈ **0x3B8** (base 0x870→0xC28
inside `player`). PC adds: `audioHandle` +4, `rocket` (`AttachedObj*`) +4,
`mtxlist` (`Mtxf*`) +4, and the D102 `#ifdef PORT` tail
`struct Model weaponModel` (~0x2A8) + `u32 weaponRwPool[192]` (0x300).
**PC `sizeof(struct hand)` ≈ 0x968** — roughly **+0x5B0** vs N64, and *not*
a clean multiple, so `handnum * sizeof(struct hand)` de-syncs for `handnum==1`.
- `struct player` pointer growth **below N64 offset 0x594** (contradicts the
D100 note that “anything below 0x594 is unaffected”): `cameratile` (0x34),
`prop` (0xA8), `bodyModel` (0xD4), `autoaim_target_y` (0x130),
`autoaim_target_x` (0x140) → **+0x14** by offset ~0x144. `field_10C4`..
and the `bloodImg*` / `InvItem*` / `textoverride*` blocks add much more
higher up.
- `model` (N64 0x598): D100 made it an **inline `struct Model` (~0x2A8)**
replacing an 8-byte pointer → **+0x2A0** for everything after it, before
`field_59C..field_6CC` (which D100 grep-verified dead except `field_654`).
- `ptr_hand_weapon_buffer[2]` +0x8; `copy_of_body_obj_header[2]` — inline
`ModelFileHeader` grew 0x20→0x30 (4 pointers ×+4) → **+0x20**;
`item_related[2]` `struct texpool` pointer growth; `bondheadmatrices` /
`viewports` unchanged.
- Net delta from `player` base to `hands[]`: **≈ +0x2E0** (0x14 + 0x2A0 +
0x08 + 0x20 + texpool). So N64 `hands[0]` @ 0x870 is at PC **≈ 0xB50**;
N64 `hands[0].throw_item_pos_related` @ 0x870+0x268 = **0xAD8** is at PC
**≈ 0xDB8**.
## Findings table
| # | file:line | accessor | targets (N64 field) | N64 off | PC off (real) | PORT-adj? | BUNKER1 FP reachable? | severity | recommended fix |
|---|-----------|----------|---------------------|---------|---------------|-----------|----------------------|----------|-----------------|
| 1 | `src/game/gunfire.c:4960` `THROWMTX` (macro) used @ 5043,5047,5070,5126,5360,5364,5387,5443 | `(Mtxf*)((u8*)g_CurrentPlayer + handoffset + 0xAD8)` (`0xAD0` EU) | `hands[handnum].throw_item_pos_related` | 0xAD8 (h0) / 0xAD8+0x3B8 (h1) | ≈0xDB8 (h0) / ≈0xDB8+0x968 (h1) | **NO** | **YES** — `sub_GAME_7F068508` runs on every shot with a cartridge (PP7/KF7/…), solo only (`getPlayerCount()>=2` early-out) | **HIGH — memory corruption.** Raw 0xAD8 on PC lands inside the inline `model` / `field_59C` region of `struct player`; `matrix_4x4_copy(THROWMTX,…)` writes 64 bytes there. `handnum==1` (dual wield) additionally uses the wrong stride. Corrupts the gait `Model` / bondhead matrices → wrong Bond-body render or a delayed fault in model code. | Replace macro body with `(&g_CurrentPlayer->hands[handnum].throw_item_pos_related)`. Drop `THROWMTX_OFFSET`, `handoffset`. |
| 2 | `src/game/gunfire.c:4961` `THROWPOS(k)` used @ 5112-5114, 5164-5166, 5429-5431, 5481-5483 | `((f32*)((u8*)g_CurrentPlayer + handoffset + 0xB08))[k]` (`0xB00` EU) | `hands[handnum].throw_item_pos_related.m[3][k]` (translation row) | 0xB08 | ≈0xDE8 | **NO** | **YES** (same path as #1) | **HIGH — silent-wrong + reads corrupted memory.** Feeds `casing->vel` from the frame-to-frame delta of a garbage location → casings fly wrong / NaN, and the read is OOB-ish relative to intent. | `g_CurrentPlayer->hands[handnum].throw_item_pos_related.m[3][k]` |
| 3 | `src/game/gunfire.c:4962` `THROWPREV(k)` used @ 5112-5114, 5164-5166, 5429-5431, 5481-5483 | `((f32*)((u8*)g_CurrentPlayer + handoffset + 0xB48))[k]` (`0xB40` EU) | `hands[handnum].throw_item_pos_related_prev.m[3][k]` | 0xB48 | ≈0xE28 | **NO** | **YES** (same path) | **HIGH** (same as #2) | `g_CurrentPlayer->hands[handnum].throw_item_pos_related_prev.m[3][k]` |
| 4 | `src/game/gunfire.c:5031` and `:5348` | `handoffset = handnum * sizeof(struct hand)` | array stride for `hands[handnum]` | 0x3B8 | 0x968 (and non-uniform) | **NO** (the raw math is only consumed by #1–#3) | YES | **HIGH** — root of #1–#3’s `handnum==1` error; harmless once #1–#3 use `hands[handnum].field`. | delete; fold into the field accessors above. |
| 5 | `src/game/gunfire.c:600-661` weapon-model setup (`copy_of_body_obj_header[handnum]` → `modelInit(HAND_WEAPON_MODEL, mdlhdr, HAND_WEAPON_RWPOOL)`; `hand->mtxlist = rwmtx`; `render_pos = (RenderPosView*)rwmtx`) | field-name access + D102 macros | `hands[h].weaponModel` / `weaponRwPool` / `mtxlist`; `copy_of_body_obj_header[h]` (inline `ModelFileHeader`) | — | — | **YES (D102)** for the Model/pool pun; `copy_of_body_obj_header` is a plain inline struct (layout-consistent) | YES — this *is* the 1P weapon draw path | **MED — prime suspect for “weapon model doesn’t draw.”** `rwmtx` is `dynAllocate`’d (transient per-frame arena) but `weaponModel.render_pos` is set to it once at setup and consumed at render (`gunRenderFirstPersonGunModels:1605`); on N64 `render_pos` aliased the *persistent* `mtxlist` field, here it can point at reused arena memory. Also `flashvisptr = HAND_WEAPON_RWPOOL + node->…RwDataIndex` (gunfire.c:640) assumes N64 word indices into a pool whose PC records are ~2×. | Verify `dynAllocate` lifetime vs. render; consider persisting the matrix list. Re-check `weaponRwPool[192]` sizing against `modelCalculateRwDataLen(mdlhdr)` on PC. Confirm `subdraw` matrix path (shared with Track 2). |
| 6 | `src/game/bondview2.c:3155-3162` (PORT branch of `sub_GAME_7F07E7CC`) | `(Model*)&g_CurrentPlayer->something_with_watch_object_instance`; `static u8 watchRwPool[0xC8]`; `g_CurrentPlayer->step_in_view_watch_animation = 0` | watch-menu item preview Model punned at N64 player+0x230, pool at +0x2EC | 0x230 / 0x2EC | 0x244 / 0x300-ish | **YES (D56)** but suspect | YES — open watch menu / cycle to an item page | **MED — under-reservation.** D56 hosts the pool in a `0xC8` static (N64 capacity) but PC RW records are wider; and `animInit` writes a full inline `struct Model` (~0x2A8) starting at `&something_with_watch_object_instance`, overrunning `field_234..watch_scale_destination..` (N64 those were the tail of the 0xBC Model hole; on PC they are live watch fields). | Give the watch Model real inline storage in `struct player` under `#ifdef PORT` (D100 pattern) + size `watchRwPool` from `modelCalculateRwDataLen`. |
| 7 | `src/game/gunfire.c:4951-4957` | `#define THROWMTX_OFFSET 0xAD8` etc. | (the constants behind #1–#3) | — | — | NO (EU/non-EU split only, both N64) | — | INFO — remove with #1–#3. | delete block. |
| 8 | `src/game/player.c:136` | `mempAllocBytesInBank((sizeof(struct player)+0xF)&~0xF, …)` | player allocation | — | — | **YES (D98)** | — | OK — listed for completeness; correct. | none |
| 9 | `src/game/initBondDATAdefaults.c:103` | `animInit(&g_CurrentPlayer->model, &player_gait_object_header, g_CurrentPlayer->gaitRwData)` | inline gait Model + `gaitRwData[256]` | — | — | **YES (D100)** | YES | OK. | none |
| 10 | `src/game/bondhead.c:217-480`, `bondview2.c:922-9102` | `&g_CurrentPlayer->model`, `bondheadmatrices[0].m[..]` (field-name) | inline gait Model + head matrices | — | — | **YES** (D100 makes field access correct; single TU layout) | YES | OK — no raw offsets; safe as long as D100 inline-Model stays. | none |
### Not player/hand but same class (noted, out of scope for the fix list)
- `src/game/gunfire.c:5513` `render_data.mtxlist = (Mtxf*)model_matrices` — local, fine.
- `initexplosioncasing.c`, `initobjects.c`, `propobj.c`, `glass2.c`,
`initunk_005520.c` — all `N * sizeof(struct X)` allocations, already
`sizeof`-correct (D57 covered the rwdata capacities).
- `ramromreplay.c` `+0x110/+0x1F8/+0x21E` — `ramromfilestructure`, handled by D66/D87, unrelated.
## Total
**10 distinct offset sites; 3 live HIGH-severity bugs (findings #1–#3, one
underlying cause #4), 1 dead constant block (#7), 2 MED items to verify
(#5, #6), 4 already-correct (#8–#10).**
All 3 HIGH bugs are the same construct in `gunfire.c` and are reachable in
BUNKER1 first-person the instant the player fires a weapon that ejects a
casing (every ballistic weapon). They are NOT reachable before the first
shot, so they do not explain a weapon model that is missing from frame 1 —
but once firing starts they corrupt the inline gait `Model` region and will
produce cascading render/anim breakage or a fault.
## Where the “1P weapon model doesn’t draw” bug most likely lives
Not in a surviving raw offset — D102 already gave the weapon `Model` and its
RW pool dedicated storage, and `gunRenderFirstPersonGunModels` /
`gunTickHandState` use field names. Best candidates, in order:
1. **Finding #5 — the D102 wiring in `gunfire.c:600-661`.** `weaponModel.render_pos`
is pointed at a `dynAllocate`’d transient (`rwmtx`) that on N64 aliased the
*persistent* `hand->mtxlist` field; if the arena is recycled between the
hand-tick setup and `gunRenderFirstPersonGunModels`, the model draws with
garbage / off-screen matrices (looks like “not drawn”). Check `dynAllocate`
lifetime and whether `weaponModel` should own its matrix list on PC.
2. **The `subdraw` / `subcalcmatrices` / `process_02_position` matrix
composition** — shared with Track 2 (inverted characters). A handedness /
lookat-sign error there hides the weapon model just as it inverts guards.
3. **`field_87F` gating** (`gunfire.c:520`, `:587-598`) — if the setup branch
that sets `field_87F = 1` and calls `modelInit` isn’t taken (e.g.
`get_ptr_weapon_model_header_line(item)` returns NULL because
`ptr_hand_weapon_buffer` / weapon-model load failed), the render loop
`continue`s and nothing is emitted. Cheap to instrument.
## Recommended fix order
1. **#1–#4 & #7 (gunfire.c THROW macros)** — one mechanical change, no layout
work: rewrite `THROWMTX`/`THROWPOS`/`THROWPREV` as
`g_CurrentPlayer->hands[handnum].throw_item_pos_related[_prev]` field
accesses (`.m[3][k]` for the POS/PREV variants), delete `handoffset` and
the `THROW*_OFFSET` defines. Wrap in `#ifdef PORT` keeping the N64 macro
for the N64 build (or, since the field access is identical semantics on
both, use it unconditionally — verify byte-match). Log as §F “D115”.
2. **#5** — investigate the weapon-model `render_pos` / `mtxlist` lifetime and
`weaponRwPool` sizing; this is the likely playability blocker. May fold
into the Track-2 matrix work.
3. **#6** — give the watch preview Model inline PORT storage + sized pool
(only matters once the player opens the watch in-level).
4. Add a debug assert in `gunfire.c` weapon-model setup logging
`mdlhdr->numRecords` vs `weaponRwPool` capacity and `field_87F` state, to
settle candidate 3 above.
## §F pointer
Add to `docs/internals.md` §F after D112: *“**D115 (audit)** —
`struct player`/`struct hand` raw-offset pass: see
`docs/dev/AUDIT-M6-player-offsets.md`. Live HIGH bugs = `gunfire.c` THROWMTX/POS/PREV
macros (`+handoffset+0xAD8/0xB08/0xB48`), not PORT-adjusted; fix = field
accessors. MED = D102 weapon `render_pos` transient-arena aliasing (likely
the ‘weapon model doesn’t draw’ cause) and the D56 watch-Model pool
under-reservation.”*
+66
View File
@@ -0,0 +1,66 @@
# GE_* environment probes — consolidated list
Every `getenv("GE_…")` in the tree, from a grep of `src/` + `port/` on
2026-08-31 (master `31d036da`). All are **env-gated**: unset = zero behaviour
change, so they are safe to leave in a release build (they only cost a
`getenv` on the guarded path). "Dead" below means *the finding it was built
for is closed* — the probe is strip-candidate scaffolding, not that it does
anything harmful.
Two classes:
- **Infra / live tooling** — deliberately kept, used by the playtest harness
(`tools_pc/debug.ps1`, `playtest.sh`, `level_sweep.sh`) or by an
investigation still in progress.
- **Dxx diagnostic** — added to chase one finding; most are now closed.
## Infra / live tooling
| Env var | File:line | What it does | Status |
|---|---|---|---|
| `GE_PCDUMP` | `port/fast3d/gfx_opengl.cpp:1079` (`is_pcdump`), `gfx_opengl.cpp:700`, `port/src/video.c:188`, `port/src/config.c:46` (`configGetFrameDump`) | Per-frame framebuffer PPM dump to `$PCDUMP` dir (verification-ritual golden captures, `tools_pc/framediff.py`/`pixcount.py`). Also settable via `[Debug] FrameDump` ini (env wins). | **live** |
| `GE_INPUTSCRIPT` | `port/src/input.c:306` | Headless scripted controller-0 input: `"<frame>:<tok>,…;…"` (`SUP/SDOWN/SLEFT/SRIGHT/SNONE` sustain, buttons pulse). Sole input source when set. | **live** |
| `GE_INPUTLOG` | `port/src/input.c:329`, `port/src/config.c:53` (`configGetInputLog`) | Logs computed pad state (buttons + stick) each poll when non-zero. Also `[Debug] InputLog` ini. Also traces D165 `menuptr est/tgt/eff/stick`. | **live** |
| `GE_STARTMENU` (+ `GE_STARTMENU_PAGE`, `GE_STARTMENU_DIFF`) | `src/game/lv.c:398-402` (`#ifdef PORT`) | Boot straight into a front-end menu id (13=MISSION_COMPLETE, 10=BRIEFING, 7=MISSION_SELECT, 12=MISSION_FAILED, 6=MODE_SELECT); `_PAGE`=folder row (def 1=Dam), `_DIFF`=0..3. Crash-test a screen fast. | **live** |
| `GE_UNLOCK_ALL` | `src/game/debugmenu_handler.c:1098` (`get_debug_enable_all_levels_flag`, `#ifdef PORT`) | Mission-select shows every solo level (playtest jump-to-any-level aid). Cached in a `static int c`. | **live** |
| `GE_SAVELOG` | `src/game/file.c:46`, `src/game/file2.c:16` (`SAVELOG` macro), `src/boss.c:749`, `src/game/objective_status.c:297,343` | Traces the campaign save/unlock chain (objective completion → `end_of_mission_briefing` → `fileWriteSave` → EEPROM). Used to confirm D157. `g_savelogObjOnce` gates the per-criterion dump to the `bossReturnTitleStage` call. | **live** (strip once a few more level boundaries are playtested) |
| `GE_D160` | `src/boss.c:744`, `src/game/bondview2.c:791`, `src/aicommands.def:10055,10181,10236,10242,10247` (all `#ifdef PORT`) | Dam exit-cutscene (D148/D160) diagnostic: trace points in `bossReturnTitleStage`, `bondviewSetCameraMode`, and AI cmds `EndLevel`/`exit_level`/`CameraLookAtBondFromPad`/`CameraSwitch`. **Investigation in progress** — user owes a `GE_D160=1` Dam run. | **live** |
| `GE_DTEX` | `port/fast3d/gfx_pc.cpp:1001` | Per-`import_texture` param log (dims / line / size / lod / gen_mipmaps). Used to root-cause RC2 and D159. Kept as an inert diagnostic. | **live** (RC2/RC3 not finished) |
| `GE_TEXDUMP` | `port/fast3d/gfx_pc.cpp:1020`, `port/fast3d/gfx_opengl.cpp:700` | PPM dump of every uploaded texture + a `fmt/siz/palfmt/palidx/pal[0..3]` line. Root-caused D161 (Depot ceiling). Kept as an inert diagnostic. | **live** (RC3 not finished) |
| `GE_D116` | `port/fast3d/gfx_pc.cpp:1710,2362`, `src/game/textrelated.c:265,541` (cached `ge_d116`) | HUD/text X-mirror (D114/D116) vbo/uv trace. Bug is **parked / DO-NOT-re-static-trace** (PORT-LEARNINGS §D2) but the probe is cheap (cached int) — leave until that cosmetic bug is picked up with RenderDoc. | idle (parked bug) |
## Dxx diagnostic probes — finding closed unless noted
| Env var | File:line | Finding / what it logged | Status |
|---|---|---|---|
| `GE_D51` | `port/src/libultra.c:496`, `src/game/model.c:125,244,544` | msgQ 32-slot overflow watch / ModelSlot layout | dead (D51 closed) |
| `GE_D54` | — (all blocks stripped M-32, commit `49ce620a`) | music seq-table ABI / endianness (`ALMidiHdr`) | dead (D54 closed) — fully removed |
| `GE_D56` | `src/game/model.c:227,830` | watch `Model` raw-offset reads | dead (D56 closed) |
| `GE_D60` | `port/src/libultra.c:521,735` | gfx frame msgQ delivery trace | dead (D60 closed) |
| `GE_D61` | `port/src/libultra.c:795` (`s_d61opened`) | opens a one-shot log file | dead (D61 closed) |
| `GE_D62` | `port/src/libultra.c:477` | osRecvMesg trace | dead (D62 closed) |
| `GE_D63` | remaining: `src/game/bg.c:2525,2908,2953`, `front.c:1410,1416,7814,7889,8083,8091,8099,8366`, `image.c:2453`, `language.c:431`, `model.c:4340`, `title.c:607` | gun-barrel sub-DL clobber hunt (D63/D64/D65/D66 — the labels drift within the blocks). Stripped M-30 (`d0789358`, `gfx_pc.cpp` trail). Stripped M-32 (`49ce620a`): `memp.c`, `bg.c:2880` (incl. the bare non-PORT `d63bgprimarycount` static + entry log), `blood_animation.c`, `dyn.c`, `rsp.c`. **Remaining blocks are all `#ifdef PORT` + getenv-guarded and inert — strip candidates for a later pass.** | dead (D63 closed) — partially removed |
| `GE_D69` | `src/game/ob.c:165,227` | stage-load (D69) object trace | dead (D69 closed) |
| `GE_D69BB` | `src/game/bg.c:2460,2479,3020` | D69 bg-binary layout trace | dead |
| `GE_D69STAN` | `src/game/stan.c:266,276` | D69 stan-tile trace | dead |
| `GE_D85DUMP` | `src/game/bg.c:2510` | D85 `bgWidenRoomGdl` 8→16B dump | dead (D85 closed) |
| `GE_D85TEX` | `src/game/image.c:2479`, `src/game/tex.c:859,1050` | D85 texpool-full events (Depot analysis) | dead (D85 closed) — was useful for §3 |
| `GE_D86` | `src/game/model.c:6295`, `src/game/objecthandler_2.c:143` | D86 model rwdata trace | dead (D86 closed) |
| `GE_D87` | `src/game/ramromreplay.c:296,363,381` | D87 `ramromfilestructure` endianness | dead (D87 closed) |
| `GE_D88` | `src/game/prop.c:1367`, `src/game/stan.c:3090` | D88 `Usetup*Z` propDef stream | dead (D88 family closed; D88.4 resolved) |
| `GE_D90` | remaining: `src/game/bondview2.c:2146`, `src/game/prop.c:1391` (bondview_r.c blocks stripped M-32, `49ce620a`) | D90 bondview / prop NULL trace | dead (D90 closed) — partially removed |
| `GE_D96` | `src/game/chrprop.c:436,507` (cached `probe`) | D96 chrprop trace | dead (D96 closed) |
| `GE_D104` | `src/game/bg.c:654` (rate-limited, `d104c`) | D104 depth-clear trace | dead (D104 closed) |
| `GE_D71LOG` | `port/fast3d/gfx_pc.cpp:645` | D71 fast3d trace | dead (D71 closed) |
| `GE_D75` | `src/game/title.c` `sub_GAME_7F007F30` (capped 8) | D75 Bug 2 gun-barrel model probe: logs `chrModelInstance`/`gunModelInstance` ptr+obj+numMatrices, `render_pos`, `renderData.mtxlist`, `g_GfxMemPos`, `osVirtualToPhysical(render_pos)` | **LIVE** (D75 Bug 2 OPEN — see §F "D75 Bug 2 — RUNTIME PROBE") |
## Notes
- The bare non-`#ifdef PORT` `GE_D63` block in `bgRenderRoomPrimary`
(`d63bgprimarycount` static + entry log, was ~`bg.c:2876/2880`) was
removed M-32 (`49ce620a`). Every remaining `GE_D63` block is
`#ifdef PORT` / `#if defined(PORT)` wrapped and inert.
- `[Debug]` ini keys (`Debug.FrameDump`, `Debug.InputLog`) mirror
`GE_PCDUMP` / `GE_INPUTLOG` as a fallback (env var wins) — M-26,
`port/src/config.c`.
- Not GE_-prefixed but same category: `GE_DETERM` is **designed, not
implemented** (fixed-tick determinism mode, §F D117).
+27
View File
@@ -0,0 +1,27 @@
# GRAPHICS-BACKLOG — known cosmetic / rendering issues
Deferred below level-progression and crash work (user priority, M-11).
Minor graphical glitches are acceptable in the current state. Fix these
once the game is playable start-to-finish without crashes.
## Open
| ID | Symptom | Status / notes |
|----|---------|----------------|
| **D114/D116** | HUD text + ammo digits render each glyph individually X-mirrored (screen positions correct, texture content flipped). Likely also explains "inverted" guard skins / mislocated door props. | Root cause NOT found after 4 sessions (M-6/7/8/11). Every stage `textRenderGlyph`→GBI→fast3d→`buf_vbo`→GL runtime-verified non-mirrored (`GE_D116` `[D116/vbo]` probe). Contradiction unresolved. **Next attempt requires a RenderDoc/apitrace capture of one glyph texrect, or the asymmetric-1-texel-texture experiment** — do NOT re-run the static trace, and do NOT apply a global texrect S-swap (mirrors the whole screen). |
| **D74 wrap-block** | `gfx_pc.cpp:1546/1551` PORT block is dead code: guard `cms & G_TX_WRAP` with `G_TX_WRAP==0` is always false; and it indexes per-texunit `[2]` arrays with the vertex index `i` (0..2) → OOB if reached. | Latent. Harmless for CLAMP glyphs; could affect wrapped textures on triangles (room panels, model skins). Fix: `cms == G_TX_WRAP`, and compute the tile window once per tile not per vertex. Verify against `tools_pc/golden/` after. |
| **D75** | Nintendo logo mispositioned; gun-barrel Bond model absent; cast-roll character models absent. (Rareware logo OK.) | Front-end 3D model path. Partially overlaps D114 (shared mirror on asymmetric content). Triage plan in `findings.md` §F D75. |
| **D76** | 2D disclaimer screen only partially drawn. | §F D76. |
| **D77** | No audible music on PC. | Phase 3 (audio). §F D77. |
| **D116 (HUD)** | (subset of D114) proportional-font strings + ammo counter mirrored. | Same root cause as D114. |
| **D148** | Level-end cinematics don't play. Dam: hitting the exit trigger should show Bond rappelling down the dam (the bungee jump); instead it cuts straight to the mission-complete report. | Scripted cutscene / cinematic-camera + Bond rappel anim. Found M-27 during the first full Dam→Facility playthrough. Progression is unaffected — the report and next briefing load fine. **M-31 (§F D160):** static-traced the Dam end ailists (`ai_24`→`ai_17`, `camera_switch`→`CutsceneRecord` type-46 lookup). Diagnostic `GE_D160=1` shipped (`c95713f5`) — user runs Dam to the exit with it set; the trace pins whether the cutscene AI runs, and whether the propDef-index walk (D122/D132 family) hands `camera_switch` a bogus cutscene record. Leading hypothesis: propDef `sizepropdef` stride desync (medium confidence), not D75 anim-model. |
| **D149** | Front-end MISSION COMPLETE / mode-select 3D models (dossier, wallets) render as garbled full-screen geometry or not at all — one of the compiled sub-DLs (seg5+0x9ee4) is malformed, D146 now skips it instead of aborting. | D75 family. §F D146. |
## Resolved (for reference)
- D70–D74: intro-logo pixels (Rareware logo renders correctly).
- D103: viewport / native-resolution half-frame.
- D105: room geometry Z-buffer never cleared (`G_CLEAR_DEPTH_EXT`).
- D106: sky-void through doorways (portal near-plane min>max).
- D107: blurry ceilings/wall-panels (LOD mip tile fabrication).
- D112: skeletal models were "3D lines" (`d43_emit.py put_f32` byte order).
+520
View File
@@ -0,0 +1,520 @@
# HANDOFF ARCHIVE — session-by-session narrative
> **Reference only.** Current state, next task, and environment live in
> `docs/HANDOFF.md`; per-finding detail in `docs/internals.md` §F/§H
> (indexed at the top of §F). This file is the frozen accumulation of
> prior sessions' handoff briefs (through session M-11), kept so the
> reasoning behind a fix is recoverable. Everything below is as-written
> at the time and may be superseded.
---
# Handoff brief — GoldenEye 007 PC port (Phase 2: Session M-3 —
# STAGE-LOAD → RENDER crash chain CLEAR; BUNKER1 renders, next is D75 model quality)
## PREFLIGHT — before touching anything (a concrete next-step below is NOT a reason to skip this)
1. `CLAUDE.md` (auto-loaded) + `AGENTS.md` non-negotiables.
2. This file, top-to-bottom.
3. `docs/porting-notes.md` — recurring bug classes.
4. Dispatching a subagent? `docs/dev-process.md` FIRST. Every brief needs
FILES / BUDGET / ON-EXPIRY / CONSTRAINTS / REPORT (see CLAUDE.md "Dispatch preflight").
_Paste-ready brief. Authoritative context: `AGENTS.md`,
`docs/internals.md` §F (D69, D78-D102), `docs/BRIEF-D69-stage-load.md`._
**NEW DIRECTION (2026-08-29): `docs/PLAN-linear-level-sweep.md`** — pivot
to breadth-first: fix the boot→intro→menu→level-select path to
*functional, not crashing* (cosmetics parked in `GRAPHICS-BACKLOG.md`),
then sweep all 21 solo levels for load+render+no-crash. Start with WS1
(auto-inject `memallocstringtable` args for bare `-level_XX`).
## READ THIS FIRST — crash chain CLEAR; viewport fixed; room geometry is next
`-level_09 -ml0 -me0 -mgfx100 -mvtx50 -mt700 -ma150` **boots BUNKER1
and renders continuously with no fault** (60 s+, 5000+ VI posts at full
framerate; attract mode also clean). The stage-load → first-frame →
in-level crash chain (blocker since D69) is resolved.
**D103 (session M-4) fixed the "~46 %, lower half" symptom** — it was a
single native-resolution/viewport bug (`osViSetMode` hard-coded height
480 → `RATIO_Y` half of `RATIO_X`), not per-model. Frame is now full
(91.7 %, correct letterbox + HUD placement). See §F "D103".
**D105 (session M-4) fixed room geometry** — `zbufClearCurrentPlayer`
(`viewport.c`) used the N64 "fill the Z buffer as a colour image" idiom,
which fast3d doesn't emulate, and nothing emitted `G_CLEAR_DEPTH_EXT`, so
the depth buffer was never cleared and all ~160 k room triangles failed
the Z test (only `skyRender`'s background fill survived). PORT branch now
emits `G_CLEAR_DEPTH_EXT`. See §F "D104"/"D105". BUNKER1 now renders
recognisably — textured walls, storage racks, floor (2585 colours, sky
16 %), 70 s crash-free.
**D106 (session M-4)** — the big sky-void through doorways was the portal
BFS dropping the next room: a portal straddling the camera near-plane
projects z==0 clip points to ±1e20 screen coords, and on x86-64 that
garbage came back `min>max` on one axis, slipping past the
degenerate-box check that on N64 clamps it to full-screen. PORT guard in
`sub_GAME_7F0B5864` treats non-finite / out-of-range bounds as
full-screen. Visible rooms/frame 1–3 → 2–4. See §F "D106".
**D107 (session M-4)** — blurry ceilings/wall-panels were fast3d sampling
GE's first mip (render tile 1), whose single-`G_LOADBLOCK` TMEM slot
fast3d never registers, so `import_texture` fabricated a 16×16 crop of
the base image and magnified it. `gfx_lod_tile_offset` now returns 0
(base tile) when detail textures are off. BUNKER1 rooms render crisp.
See §F "D107".
**Skeletal models — FIXED (session M-5, D112).** The "3D line" / missing
characters were `tools_pc/d43_emit.py`'s `put_f32` reversing the wrong 4
bytes (`src[doff+4:doff:-1]` → bytes doff+1..doff+4, dropping the MSB),
corrupting every f32 in converted model rodata (joint `Origin`s, LOD
distances, BSP planes). Compiled-in front-end models were unaffected
(native LE), hence "logo fine, every guard broken". Fix: one line,
`src[doff:doff+4][::-1]`; regen with `python tools_pc/d43_emit.py
ntsc-final`. BUNKER guards now render as coherent humanoids, monitor
screens draw content. See §F "D108–D112".
**"props emit no geometry" was a STALE premise** — props/doors emit
complete leaf DLs (~15k/run, valid ptrs; fast3d transforms them). The
storage-room void is NOT closed doors failing to render.
**Priority (user, M-11): get BUNKER1 — then every level — playable
start-to-finish with no crashes. Fix crashes/hangs first; cosmetic
rendering (D74/D75/D76/D114/D116) is logged in `docs/GRAPHICS-BACKLOG.md`
and comes later.** D120 (blood-stain converter) is the nearest real
converter gap; extending `d43_emit.py` for opcode-0x18 is a good
subagent brief. After BUNKER1: walk the objective/exit path to a level
transition, then the next stage.
**Real remaining work, in order (updated session M-8):**
1. **Input layer (Phase 3)** — IN PROGRESS (M-8 agent, §F D118). `port/src/
input.c` was a stub; only a minimal keyboard path in `libultra.c`
(`contSnapshotFromKeyboard`) worked. No C-buttons/mouse-look/gamepad/
rebinding. This is the top playability blocker — you cannot aim or
properly move. Reference: `pd_port/port/src/input.c`.
2. **Weapon model doesn't draw (AUDIT-M6 #5)** — NOT STARTED (M-8 agent
hit the account session rate-limit before any work; tree untouched).
Plan: instrument `gunfire.c` weapon setup (~600-661) + `field_87F`
gating (~520/587-598) FIRST with a `GE_D119` printf — does setup even
run? Then check `weaponModel.render_pos` = `dynAllocate`'d `rwmtx`
lifetime vs the `gunRenderFirstPersonGunModels` (~1605) consumer (on
N64 it aliased the persistent `hand->mtxlist`; D102 gave it own
storage → arena may recycle). Also `weaponRwPool[192]` capacity vs
`modelCalculateRwDataLen(mdlhdr)` on PC, and `flashvisptr` word-index
assumption (~640). Narrow `#ifdef PORT` lifetime/sizing fix only — if
it needs a behavioural arena change, STOP + write up. Direct `-level_09`
boot may spawn Bond holstered — may need forced weapon state or the
attract path to get the gun on screen. Gate to playability.
3. **The HUD/text X-mirror (D116)** — DEPRIORITISED, parked in
`docs/GRAPHICS-BACKLOG.md`. M-11 confirmed the ammo digits use the
SAME `textrelated.c` path (no separate renderer — the M-8 premise was
wrong) and re-hit the same contradiction: every stage verified
non-mirrored, glyphs still flip. **Do not re-static-trace.** Next
attempt needs a RenderDoc/apitrace capture or the asymmetric-1-texel
experiment — nothing else. All cosmetic rendering issues (D74 dead
wrap-block, D75/D76/D77) now tracked in `docs/GRAPHICS-BACKLOG.md`;
they rank BELOW level-progression and crash work.
4. **Rest of the `struct player` offset pass** — D115 fixed the HIGH
`gunfire.c` THROW* bugs; #6 (watch-preview Model pool) + the broader
audit remain (`docs/dev/AUDIT-M6-player-offsets.md`).
5. Add an f32 value spot-check to `d43_emit.py`'s verify pass (it only
checks pointers/opcodes today — the D112 bug passed "ALL CHECKS PASSED").
6. **GE_DETERM fixed-tick mode (§F D117)** — deferred as not-narrow; would
unlock exact-diff regression testing + reproducible timing bugs. Design
is written up. Worth doing once the above land.
**Session M-7 — see §F D116 + "D116 probe results".** Ran the glyph
probe (overseer, after killing a subagent that was drifting toward a
global texrect S-swap). Corrected finding: the HUD text mirror is a
**per-quad texture-U flip that is NOT path-specific** — the ammo digits
"83" are mirrored too (`ppm/frame_000320.ppm`), refuting M-7's earlier
"proportional-font-specific" claim. Rect *positions* are correct; only
texture content is X-flipped. Every fast3d stage probed clean (glyph
bitmap in memory correct, texrect `ul.u=0→lr.u=max`, `import_texture_i8`
linear, GL shader UV pass-through) — so the flip is in the GL
vertex-buffer/draw layer OR a shared clip-space-X vs U desync on rect
quads. **This re-opens D114's shared-mirror hypothesis** (now per-quad
U/X, not screen-space) and plausibly also explains inverted guards /
mislocated door props. NEXT: shader/vertex-buffer probe — dump per-vertex
(x,u) for one glyph quad; render a 1-texel asymmetric test texture to see
which axis inverts. `GE_D116`-gated probes left in `textrelated.c` +
`gfx_pc.cpp` (zero-cost).
**Session M-8 wrap — state for next session.** Build GREEN at
`587c6856`, tree clean. Committed: D116 part-3 writeup + `[D116/vbo]`
probe (`5ff012b4`), `CLAUDE.md`/preflight (`fd25628a`), D117
framediff+nondeterminism (`44d9ae98`), D118 input layer
(`4ac11fe7`/`587c6856`), handoff reprioritisation. `GE_D116` and
`GE_INPUTLOG` probes are in-tree, `getenv`-gated, zero-cost. Weapon-model
(#5) agent never ran (rate-limit) — task untouched, plan in item 2 above.
Pre-existing stash `stash@{0} "d59 probes WIP"` (~300 lines,
gfx_pc/gfx_opengl/crash.c) is NOT from M-8 — provenance unknown, left as
found. Next: human input playtest (tune `ge007.ini [Input] MouseAimSpeed`),
then weapon-model #5.
**Session M-8 (continued) — D116 part 3 + input layer.** Killed the
drifting D116 agent, ran the probe as overseer: `buf_vbo` (x,u) for
glyph quads and the GL texture upload are both runtime-verified
non-mirrored (x-left<->u=0), GL shader is `gl_Position=aVtxPos` +
UV-passthrough — every stage clean, yet glyphs render X-flipped
(contradiction, §F "D116 runtime probe part 3"). Deprioritised as
cosmetic. `GE_D116` `[D116/vbo]` probe kept in `gfx_pc.cpp`. Then
dispatched the **input-layer agent** (§F D118, in progress) — top
playability blocker. `framediff.py` structural mode is the interim
regression gate (`tools_pc/golden/` is a nondeterministic D115 capture).
**Session M-8 — see §F D117.** Visual-regression tooling. Root-caused the
frame-to-frame nondeterminism: pure variable-timestep frame pacing
(`osGetCount()` = wall clock on PC → `frametiming.c waitForNextFrame`
advances logic a real-time-dependent number of 60 Hz ticks per render).
PRNG seeding verified correct (`random.c`), not a source. A `GE_DETERM=1`
fixed-tick mode was assessed **not narrow** (redesigns VI retrace/tick
semantics, deadlock risk in load-screen loops) and deferred with a full
design in §F D117 — NOT implemented. Added `tools_pc/framediff.py`
(structural/tolerant: 16×12 grid mean-colour + non-clear-% + aHash,
`--mask` for HUD, `--exact` for a future deterministic build, `--update`
to refresh goldens). Validated against the D115 golden set. No C changes,
no probes left in tree.
**Session M-10 — input playtest + guard-firefight crash chain.**
- **D119 — guard-attack crash FIXED** (`chraction.c`). Every prior
`-level_09` run segfaulted ~frame 1200 the instant a BUNKER guard
opened fire: `bondwalkItemGetAutomaticFiringRate` (`gun.c:1334`) ←
`chrlvInitActAttack`. ~28 sites pun `weapons_held[]->chr` (really a
`WeaponObjRecord*`) as `ChrRecord*` and read `.act_<x>.attack_item`,
which on N64 aliases `WeaponObjRecord.weaponnum` (act union @0x2C + 84
== 0x80). Pointer widening moves the act union to ~0x38 on PC → garbage
negative item id → OOB `g_ItemStats` → crash. Fix: `PUN_ATTACK_ITEM()`
macro reads `weaponnum` directly; `#else` branch is textually identical
to the original → N64 build unchanged. Class-A (D53.2 type-pun) bug.
- **D120 — blood-stain hang GUARDED, not fixed** (`chr.c:3322`). Next
blocker, reachable only after D119: first guard bullet-hit →
`chrCreateBloodStain` `PointUsage[]` negative-terminated chain walk
cycles forever (VI thread keeps posting, logic thread spins, kernel
heartbeat trips). Root cause: `tools_pc/d43_emit.py`'s opcode-0x18
`ModelRoData_DisplayList_CollisionRecord` conversion is incomplete —
6 pointer fields widen the struct and `PointUsage`/`CollisionVertices`
sub-array endianness+stride aren't handled (only `CollisionRelatedNode`
got the D43/D45 `u32`-vma treatment). **Interim:** `#ifdef PORT` caps
both walk loops at `numVertices+8` iters + bounds-checks `index` → game
survives, blood decals may be missing/wrong. **Real fix (next session,
own subagent brief):** byte-spec the opcode-0x18 record + PointUsage +
CollisionVertex sub-arrays vs a converted guard model, extend
`d43_emit.py`. Same shape as D69/D88 converter work.
- Result: `-level_09` now survives the guard firefight — 45 s+
crash-free, frames past 1200 (was: hard segfault ~1200 every run).
**Session M-10 — input playtested (human).** Fixed in `port/src/input.c`:
(a) mouse-look was a draining accumulator → flicks stayed "pressed" ~8
frames after you stopped (stuck looking up/down) — now per-poll delta,
no carryover; (b) horizontal mouse was routed to C-left/right = GE
*sidestep*, and A/D drove analog stick-X = GE *turn* — swapped: mouse X
→ stick-X turn (`MOUSE_TURN_GAIN 6.0`), A/D → C-left/right strafe, mouse
Y → C-up/down look. Core aim/move now works.
**Open input bugs (documented, deferred — fix in a later pass):**
- **D118a — mouse yaw slower than pitch.** Horizontal turn (analog
stick-X via `MOUSE_TURN_GAIN`) is visibly slower than vertical look
(digital C-up/down). Different transfer curves (analog rate-limited vs
digital full-press). Needs `MOUSE_TURN_GAIN` bump and/or a matched
pitch path; ideally a single tunable `MouseAimSpeed`.
- **D118b — mouse Y inverted.** Mouse up → looks down, mouse down →
looks up (X unaffected). `aimDY`→C-button sign is backwards for GE's
pitch convention (or SDL dy sign assumption wrong). Flip the
`aimDY >=`/`<=` C-up/C-down assignment (or default `MouseInvertY`).
One-line fix once confirmed against GE pitch sign.
- Still TODO from D118: rebinding, gamepad hotplug, `ge007.ini` not yet
created (config defaults are hardcoded).
**Session M-9 (Phase 3) — see §F D118.** SDL input layer implemented.
`port/src/input.c` is now real: keyboard+mouse and SDL_GameController,
mapped to GE's N64 pad (analog stick = move, C-buttons = aim, mouse-look
bridged to digital C-buttons via a clamped accumulator, RMB/LT = aim
mode, LMB/RT = fire). `libultra.c`'s SI section delegates to it (single
source of controller state; still driven by `osContStartReadData`, no
new frame hook). Config: `ge007.ini [Input]` MouseEnabled/MouseAimSpeed/
MouseInvertY. Build GREEN, boots `-level_09` crash-free 35 s.
**Owed: a human playtest** — live input is untested from the headless
agent. Manual checklist: (1) WASD moves Bond, mouse turns/looks (tune
`MouseAimSpeed` in ge007.ini if too fast/slow), LMB fires, RMB enters
aim mode, Enter opens the pause menu; (2) plug an Xbox pad — left stick
moves, right stick aims, triggers fire/aim; (3) `GE_INPUTLOG=1` prints
each nonzero OSContPad poll. Rebinding + gamepad hotplug are TODO (§F
D118).
**Session M-6 — see §F D113/D114/D115, `docs/PLAN-M6-playable.md`,
`docs/dev/AUDIT-M6-player-offsets.md`, `docs/dev-process.md`,
`docs/porting-notes.md`.** D113: portal BFS is correct, not the void.
D114: matrix chain + converter verified clean, residual = shared fast3d
mirror (open). D115: player raw-offset audit + `gunfire.c` THROW* fix
(uncommitted). Build green.
**This session's fixes (all committed, master):**
| # | commit | one-liner |
|---|--------|-----------|
| D93 | `164d7f99` | null-room (room 0) NULL-deref guards |
| D85 | `493c9838` | `bgWidenRoomGdl` (8→16 `Gfx` + `bswap32`) + `bgSwapRoomVtx`; room DLs decode to real GBI; `bg.c:2448` size-cast bug |
| D85 | `6f0208d6` | `ptr_texture_alloc_start` → real `struct texpool` storage (pool looked exhausted → ~630 room textures now resolve; cleared `Bad size for RGBA texture`) |
| D94 | `63204a27` | `chrlvInitActAttack` `(s32)`-truncated anim-table index |
| D95 | `f35eba91` `933ba52b` | 2× `g_GfxBuffers` (16-byte PC `Gfx`) + raise PC mempool ceiling `0x702F4400→0x70700000` (reclaim ~4 MB DRAM) so it doesn't OOM `MEMPOOL_STAGE` |
| D96 | `d86ec483` | `PROPRECORD_STAN_ROOM_LEN` 4→8 (PORT) — prop room-list stack overflow (guards span ≥4 rooms → no terminator → smashed `chrpropsRenderPass` frame; *this* was the "runaway GDL append") |
| D97 | `2fbcc556` | clamp negative `damagetype` (US-only OOB `g_DamageTypes[]` read) |
| D98 | `000ed6af` | `initBONDdataforPlayer` allocate real PC `sizeof(struct player)` (hardcoded `0x2A80` under-alloc scribbled the master DL) |
| D99 | `253caa23` | `Model.animflipfunc` `s32` fn-ptr truncation (flag + direct call, D92 pattern) |
| D100 | `8eaad547` | `struct player.model` is an inline `struct Model`, not a `Model*` + 45 filler s32s (PC struct 0x2A8 ≫ 0xB8 hole → `animInit` overran); + dedicated `gaitRwData[]` |
| D101 | `b2234f82` | `sub_GAME_7F06DB5C` stashed `ModelNode*`/`RenderPosView*` through an `s32` (the idiom its sibling was already fixed for) |
| D102 | `1b078f6d` | 1P weapon `Model` + RW pool punned onto `struct hand` (PC `Model` 0xE8 ≫ N64 0xBC → `modelInit` aliased `datas` onto the pool); dedicated `weaponModel`/`weaponRwPool` |
**Recurring pattern this session:** the decomp pun-allocates `struct
Model` (and `struct player`, `PropRecord`, `struct texpool`, …) into
N64-sized holes / hardcoded byte counts. Every one is bigger on x86-64
→ overrun → corruption. Fix = give it real inline storage or
`sizeof()`-based alloc under `#ifdef PORT`. **Landmine still open**
(§F D100): `struct player` / `struct hand` have raw hardcoded-offset
accessors (`gunfire.c` `THROWMTX` at `+0xAD8`, …) that are NOT
PORT-adjusted — already PC-wrong, a real `struct player` offset pass is
owed before grenade/knife-throw code works.
- Build: `export PATH="/c/msys64/mingw64/bin:$PATH" && ./build-pc.sh ntsc-final`
- Sidecar regen: `python tools_pc/d43_emit.py ntsc-final && python
tools_pc/d69_emit.py ntsc-final && python tools_pc/d88_emit.py
ntsc-final --regen`. `data/` is gitignored; if missing,
`cp baserom.u.z64 data/ge007.ntsc-final.z64` first (§F "`data/`
deletion + recovery").
- Repro: `./build-pc/ge007.x86_64.exe -level_09 -ml0 -me0 -mgfx100
-mvtx50 -mt700 -ma150` — `-m*` are BUNKER1's `boss.c` per-level sizes;
a bare `-level_09` skips them and crashes early (TODO: auto-inject).
## Next: D75 — 3D model rendering quality
BUNKER1 renders but character/skeletal models are wrong (see the D75
section further down, still accurate). `GE_PCDUMP` + `tools_pc/pixcount.py`
to measure; the animated-model path (`animInit` + `struct player` raw
offsets, `drawjointlist`, `modelApplyHeadRelations` head/body splice) is
the prime suspect. `struct tex` headers are 24 B vs 16 B on PC — a
separate open pool-pressure item; a real PC memory-budget pass is owed.
## What this session (M-2) fixed — all committed
1. **`-level_09` now works.** `osPiReadIo` was stubbed to 0, so the token
string was always empty and N64 debug switches were ignored (only
attract mode could reach a level). Now built from `argv[1..]`.
2. **Sidecar tables patched in `obInit()`** instead of lazily at first
model load — a direct stage boot loaded raw big-endian ROM before.
3. **D88.5** — stan tile-name byte-swap in the converter (0/276 →
273/273 name matches during pad setup).
4. **D88.6** — intro CAMERA `lang1c` is a `u16` pair, not an `s32`
(fixed the `langGet` NULL-bank crash).
5. **D89** — `init_path_table_links` `[-3]` OOB → SIGILL fix; NULL-tile
guard in `sub_GAME_7F0B0914`.
6. **D90** — `stanTileDistanceRelated`'s 80-byte zero-fill was clobbering
the caller's live stan-tile local (16B struct, 80B clear). The
player's spawn stan was fine all along.
7. **D91** — `(s32)&D_800442FC[portalnum]` truncation in bg portal cull.
8. **D92** — `chrAllocate` `s32` param truncated the ailist pointer;
`Model.unka0` `s32` field truncated a stored function pointer.
**Next steps for the resuming session (render milestone):**
1. **D85** — root cause confirmed (8-byte N64 `Gfx` vs 16-byte PC `Gfx`
in the unconverted per-room DL blob). Runtime widen+bswap fixup being
implemented; verify with `GE_D69BB=1` (real GBI opcodes, not
00/01/02/52), then `GE_PCDUMP` + `tools_pc/pixcount.py` for
non-degenerate BUNKER1 geometry. See §F "D85 root cause CONFIRMED".
Fallback if the widen stalls: soften the four `sysFatalError("Bad
size…")` guards in `gfx_pc.cpp` to skip-with-warning.
2. Then the front-end **render bugs (D75)**.
**D88.4 loose end (still open):** `PROPDEF_PC_BYTES` for `VEHICHLE`/
`AIRCRAFT`/`TANK`/`AMMO`/`DEPOSIT_IN_ROOM` in `d88_propdefs.py` are
placeholder guesses (BUNKER1 doesn't use them). Probe real sizes via
`d88_layoutprobe.c` before loading levels that use those types.
## Known rendering bugs (D75 — still open, orthogonal to the D88 crash)
Even once the crash chain is cleared, the front end has **broken 3D model
rendering** (user-confirmed this session):
- Rareware logo: correct (fixed in D73/D74).
- **Nintendo logo**: renders but **mispositioned**.
- **Gun-barrel intro**: the **James Bond character model is missing
entirely**.
- **Intro credits / cast roll**: the per-character 3D models **do not
appear at all** (names draw, models don't).
Pattern: textures/text draw; **animated/skeletal character models never
appear**; static 3D (logos) appears but with a bad transform. Leading
hypothesis is D75(b) — the animated-model path (`animInit` + raw offsets
into `struct player`, cf. D56) is broken independently of the D73 matrix
sin/cos fix. Full triage plan in `findings.md` §F D75.
The rest of this document (below) is the **last known-good, committed**
status as of commit `8c9c6a2c` (D86+D87 resolved) — still accurate except
D88 is now further along (D88.1–3 done/verified, D88.4 is the live crash).
## Where things stand
**D69 (the original "stage load faults" milestone blocker) remains
RESOLVED.** `load_bg_file` (bg.c) doesn't fault on BUNKER1. Since then,
two more crashes further down the load chain were found and fixed this
session (D86, D87), and a third — the current blocker — was root-caused
but not fixed (D88).
**D86 RESOLVED.** `modelInitRwData` crash (`model.c:6174`) was a single
truncating pointer cast in the player's embedded gait/arm model:
`src/game/initplayergaitobject.c:5` did
`player_gait_object_header.RootNode = (int)&player_gait_hdr;` — a
same-width no-op on N64 that truncates+zero-extends a real 64-bit pointer
on PC. Fixed with a narrow `#ifdef PORT` branch assigning the pointer
directly. Root-caused via a new node-walk trace (`GE_D86=1`, left in
place, gated in `model.c`/`objecthandler_2.c`).
**D87 RESOLVED.** Once D86 stopped blocking progress, an idle (no-input)
run eventually triggers the front-end's genuine attract-mode demo
playback (`select_ramrom_to_play()` picks a random compiled-in demo —
this is shipped retail behavior, not a debug feature) and crashed in
`ramrom_replay_handler` (`ramromreplay.c`). Root cause: `ramromfilestructure`
is a real ROM-compiled asset (big-endian, like everything else) loaded via
`romCopyAligned()` — a raw byte copy by design (D66) — with **no
byteswap**, so every multi-byte field read back scrambled (e.g. `size_cmds`
2 → 33554432) and drove wild pointer arithmetic. Fixed with a `#ifdef PORT`
`ramromFixupEndian()` called once after the load (same pattern as the D54
cseq fixup). Not BUNKER1-specific — attract mode picks any of 7 demo
locations at random, so don't rely on it for BUNKER1-specific testing (see
`-level_09` below).
**D88 — SUPERSEDED, see "READ THIS FIRST" at top.** D88.1–D88.3 (header +
sub-table width/endian conversion) are now done and verified; D88.4
(`propDefs` byteswap) is the live blocker. The paragraph below is the
original root-cause writeup, kept for context.
**D88 (original writeup) — root-caused.** Launch with
`-level_09` (NTSC `LEVELID_BUNKER1 = 9`; `boss.c:199-339` decodes
`-level_XX` into `g_StageNum`, bypassing the front end/attract-mode
entirely — fast, deterministic BUNKER1 repro, crashes in well under a
minute instead of waiting ~2 min for attract mode to maybe pick Bunker).
Crash: `proplvreset2` (`prop.c:1306`) segfaults reading
`g_CurrentSetup.pathwaypoints[i1].padID`. Root cause: the per-level
`"Usetup<name>Z"` file (`prop.c:1267`, `struct stagesetup` in
`bondtypes.h:4091`) is loaded as raw ROM bytes and has **zero PC porting
work done on it** — unlike bg/stan (D69/D80-82) and models (D43/D50).
Two compounding problems, not just one:
1. The 10 top-level fields (`pathwaypoints`/`waypointgroups`/`intro`/
`propDefs`/`patrolpaths`/`ailists`/`pads`/`boundpads`/`padnames`/
`boundpadnames`) are declared as real pointers in the live C struct,
so on PC they're 8 bytes each (an 80-byte header) instead of the
file's real 4-byte-each (40-byte) N64 layout — same class as D79
(`bg_room_data` pointer growth). Field 0 reads fine; everything after
it is reading the wrong bytes entirely.
2. The 4 meaningful bytes each field *does* store are big-endian (the
code's own comment: "stores every internal reference as a byte offset
from the start of the file") and nothing byte-swaps them — same class
as D87.
No PORT/byteswap handling exists anywhere in `prop.c` (confirmed by grep).
**Not fixed this session** — this is D69-scale format-conversion work: a
byte-accurate spec of the whole `Usetup*Z` format (top-level header +
every nested sub-table: `waypoint`/`waygroup`/`PropDefHeaderRecord`/
`PathRecord`/`AIListRecord`/`PadRecord`/`BoundPadRecord`/`pname`, each
likely with its own internal offsets not yet audited) plus either an
offline converter sidecar (preferred pattern per AGENTS.md, same shape as
`tools_pc/d69_emit.py`) or a careful runtime fixup pass that parses the
raw 40-byte N64-packed header by explicit byte offset, byte-swaps each
field, and writes results into the PC-widened struct.
**Net effect vs. last session:** the game now runs substantially further
— all the way through room-streaming setup, past the intro's model
pipeline, and into per-level "Usetup" data — before hitting D88. The
"loads without fault" acceptance bar is **still not met**, but the
remaining blocker is now narrowly scoped and has a fast, deterministic
repro (`-level_09`, no attract-mode wait, no depending on which random
demo attract-mode picks).
## Recommended next steps, in order
1. **D88.** Get a byte-level spec of `Usetup*Z` (start from BUNKER1's
file; `strResource` is built as `"U" + "sev" + "Z"`-style name in
`prop.c:1253-1265` — check `setup_text_pointers[LEVELID_BUNKER1]` for
the exact literal). Decide offline-converter vs. runtime-fixup (D69's
bg/stan work is the template for the former; D54's cseq fixup is the
template for the latter — given the struct-width mismatch on top of
the byteswap, a runtime fixup that manually walks the *raw* 40-byte
N64 header by hand (not through the live `stagesetup` struct) into a
freshly-populated `g_CurrentSetup` is probably simpler than a full
sidecar here, but verify against a raw ROM hex dump either way).
2. Once BUNKER1 loads past `proplvreset2`, re-check for further crashes
in the same vein (this session found 3 in a row — D86, D87, D88 — each
only reachable after the previous one was fixed; expect more).
3. Once BUNKER1 reaches a rendered frame with no fault, revisit **D85**
(room primary/secondary GDL binaries decode to garbage via
`texLoadFromGdl`) — use `GE_PCDUMP="<range>:10"` + `tools_pc/pixcount.py`
to confirm non-black, non-degenerate content per the original D69
acceptance bar. (This session's `GE_PCDUMP` captures around frame
2100-2400 during attract-mode-driven "loading" were still on a HUD/menu
screen, not real 3D geometry — don't read too much into pixel counts
from before D88 is fixed.)
4. Do NOT touch D75/D76/D77 (parked, lower priority, unrelated).
## Debug tooling added this session (kept, env-gated, zero cost when unset)
- `GE_D86=1` — node-walk trace in `modelInitRwData` (model.c) + a
load-identity probe in `load_object_fill_header` (objecthandler_2.c).
Resolved the D86 crash; left in place since the same
load_object_fill_header/modelInitRwData pipeline could surface new
edge cases as more of the game becomes reachable.
- `GE_D87=1` — block-setup trace in `iterate_ramrom_entries_handle_camera_out`
and consumer trace in `ramrom_replay_handler` (ramromreplay.c). Resolved
the D87 crash; left in place as it's a rarely-exercised path (attract
mode) worth having visibility into if it acts up again.
- Pre-existing `GE_D69STAN=1`, `GE_D69BB=1`, `GE_D69=1` — unchanged, still
useful for the bg/stan/D85 load path (see prior session's HANDOFF
entries, preserved in git history, for exactly what each logs).
## New: fast, deterministic BUNKER1 repro (no attract-mode wait)
Launch with `-level_09` as a program argument
(`./build-pc/ge007.x86_64.exe -level_09`) to skip the front end/attract
mode and load BUNKER1 directly — `boss.c:199-339` decodes `-level_XX`
(the two digit-chars are consumed as raw ASCII bytes:
`g_StageNum = tokenFindLevel[0]*10 + tokenFindLevel[1] - 0x210`; NTSC
`LEVELID_BUNKER1 = 9` → `"09"` since `'0'*10 + '9' - 0x210 = 9`). This is
now the preferred way to test BUNKER1-specific load/render work — it's
faster (crashes/completes in well under a minute vs. ~2+ min waiting on
attract mode) and deterministic (not dependent on which of 7 random demo
locations attract mode happens to pick).
## Environment / build
- `export PATH="/c/msys64/mingw64/bin:$PATH" && ./build-pc.sh ntsc-final`
(~5 s). Build is GREEN with all D86/D87 work included.
- Run from the **repo root**, not `build-pc/`.
- Regenerate sidecars if missing: `python tools_pc/d69_emit.py ntsc-final`
(bg/stan) and the D43/D50 model sidecar generator (pcmodels) — both
gitignored, not checked in. (Both were already present in this dev
environment this session.)
- `GE_PCDUMP="<start>-<end>:<stride>"` + `tools_pc/pixcount.py` for frame
captures once D88 is fixed and a frame actually renders real BUNKER1
geometry.
- Crash log: `ge007.crash.log` (repo root); symbolicate with
`addr2line -e build-pc/ge007.x86_64.exe -f -C <pc>` (image base
`0x140000000`).
- gdb **launch** mode is too slow for timing-dependent bugs (unchanged
guidance). **New this session:** gdb **attach** mode
(`gdb -batch -x cmds.txt -p <winpid>`, `<winpid>` = 4th column of
`ps -p <bashpid>`) works well and is fast for "watch a global for a
legitimate vs. corrupted write" questions on an already-running,
not-yet-crashed process — see `docs/internals.md` §F environment
reminders for the exact recipe used to root-cause D87.
## Non-negotiables (unchanged, see AGENTS.md)
1. N64 build files untouched.
2. Game logic unmodified except narrow, documented `#ifdef PORT`
ABI/layout exceptions (D86/D87 this session, both logged in
`findings.md` §F). D88 is explicitly **not** patched with a
quick inline hack — it needs the same disciplined
spec-then-convert/fixup treatment as D69, logged as an open finding
instead per AGENTS.md's "stop and write it up" guidance for anything
beyond a narrow, obviously-correct exception.
3. Offline sidecar conversion preferred over runtime fixup for whole
ROM-asset formats (D69/D80-82 pattern) — likely the right call for
D88 too, though a careful runtime fixup is also plausible; decide
after the byte-level spec work.
+362
View File
@@ -0,0 +1,362 @@
# LEVEL-OBJECTIVES — per-level mission objectives + win/fail criteria
Auto-generated by `tools_pc/dump_objectives.py` from the retail ROM (M-30, Track A).
Criteria semantics: see the header of `tools_pc/dump_objectives.py`.
'min difficulty' = the lowest difficulty at which the objective is REQUIRED
(D157: the reader was fixed 2026-08-31 — earlier revs showed 'Agent' for all).
=== level_09 (level 9, UsetupsevbunkerZ) ===
Objective 0 [briefing text id 30749] min difficulty: Secret Agent
- DESTROY_OBJECT tag 0 -> CCTV
- DESTROY_OBJECT tag 1 -> CCTV
- DESTROY_OBJECT tag 2 -> CCTV
- DESTROY_OBJECT tag 3 -> CCTV
Objective 1 [briefing text id 30750] min difficulty: Agent
- COPY_ITEM (key-analyzer/datavac copy flag)
Objective 2 [briefing text id 30751] min difficulty: No One Lives Forever
- COMPLETE_CONDITION stage flag mask 0x100 (bit(s) [8])
- FAIL_CONDITION stage flag mask 0x200 (bit(s) [9]) -- mission FAILS when set
Objective 3 [briefing text id 30752] min difficulty: No One Lives Forever
- COMPLETE_CONDITION stage flag mask 0x400 (bit(s) [10])
- FAIL_CONDITION stage flag mask 0x800 (bit(s) [11]) -- mission FAILS when set
- FAIL_CONDITION stage flag mask 0x200 (bit(s) [9]) -- mission FAILS when set
Objective 4 [briefing text id 30753] min difficulty: Agent
- PHOTOGRAPH tag 5 -> MULTI_MONITOR; photo-flag word @8=0x0
=== level_20 (level 20, UsetupsiloZ) ===
Objective 0 [briefing text id 34834] min difficulty: No One Lives Forever
- DEPOSIT_IN_ROOM ref @4=34 ref @8=4; runtime word @c=0x0
- DEPOSIT_IN_ROOM ref @4=34 ref @8=12; runtime word @c=0x0
- DEPOSIT_IN_ROOM ref @4=34 ref @8=88; runtime word @c=0x0
- DEPOSIT_IN_ROOM ref @4=34 ref @8=95; runtime word @c=0x0
Objective 1 [briefing text id 34835] min difficulty: Agent
- PHOTOGRAPH tag 0 -> PROP; photo-flag word @8=0x0
Objective 2 [briefing text id 34836] min difficulty: Secret Agent
- COLLECT_OBJECT tag 1 -> PROP
Objective 3 [briefing text id 34837] min difficulty: Secret Agent
- COLLECT_OBJECT tag 2 -> PROP
- COLLECT_OBJECT tag 3 -> PROP
- COLLECT_OBJECT tag 4 -> PROP
- COLLECT_OBJECT tag 5 -> PROP
- FAIL_CONDITION stage flag mask 0x100 (bit(s) [8]) -- mission FAILS when set
- FAIL_CONDITION stage flag mask 0x200 (bit(s) [9]) -- mission FAILS when set
- FAIL_CONDITION stage flag mask 0x400 (bit(s) [10]) -- mission FAILS when set
- FAIL_CONDITION stage flag mask 0x800 (bit(s) [11]) -- mission FAILS when set
Objective 4 [briefing text id 34838] min difficulty: Agent
- COMPLETE_CONDITION stage flag mask 0x8000 (bit(s) [15])
- FAIL_CONDITION stage flag mask 0x4000 (bit(s) [14]) -- mission FAILS when set
=== level_21 (level 21, UsetupsevbunkerZ) ===
Objective 0 [briefing text id 30749] min difficulty: Secret Agent
- DESTROY_OBJECT tag 0 -> CCTV
- DESTROY_OBJECT tag 1 -> CCTV
- DESTROY_OBJECT tag 2 -> CCTV
- DESTROY_OBJECT tag 3 -> CCTV
Objective 1 [briefing text id 30750] min difficulty: Agent
- COPY_ITEM (key-analyzer/datavac copy flag)
Objective 2 [briefing text id 30751] min difficulty: No One Lives Forever
- COMPLETE_CONDITION stage flag mask 0x100 (bit(s) [8])
- FAIL_CONDITION stage flag mask 0x200 (bit(s) [9]) -- mission FAILS when set
Objective 3 [briefing text id 30752] min difficulty: No One Lives Forever
- COMPLETE_CONDITION stage flag mask 0x400 (bit(s) [10])
- FAIL_CONDITION stage flag mask 0x800 (bit(s) [11]) -- mission FAILS when set
- FAIL_CONDITION stage flag mask 0x200 (bit(s) [9]) -- mission FAILS when set
Objective 4 [briefing text id 30753] min difficulty: Agent
- PHOTOGRAPH tag 5 -> MULTI_MONITOR; photo-flag word @8=0x0
=== level_22 (level 22, UsetupstatueZ) ===
Objective 0 [briefing text id 35897] min difficulty: Agent
- FAIL_CONDITION stage flag mask 0x200000 (bit(s) [21]) -- mission FAILS when set
- COMPLETE_CONDITION stage flag mask 0x100 (bit(s) [8])
Objective 1 [briefing text id 35898] min difficulty: Agent
- FAIL_CONDITION stage flag mask 0x20000 (bit(s) [17]) -- mission FAILS when set
- FAIL_CONDITION stage flag mask 0x200000 (bit(s) [21]) -- mission FAILS when set
- COMPLETE_CONDITION stage flag mask 0x400 (bit(s) [10])
Objective 2 [briefing text id 35899] min difficulty: Agent
- COMPLETE_CONDITION stage flag mask 0x200 (bit(s) [9])
- FAIL_CONDITION stage flag mask 0x200000 (bit(s) [21]) -- mission FAILS when set
Objective 3 [briefing text id 35900] min difficulty: Agent
- FAIL_CONDITION stage flag mask 0x400000 (bit(s) [22]) -- mission FAILS when set
- FAIL_CONDITION stage flag mask 0x200000 (bit(s) [21]) -- mission FAILS when set
- COMPLETE_CONDITION stage flag mask 0x800 (bit(s) [11])
Objective 4 [briefing text id 35901] min difficulty: Agent
- COLLECT_OBJECT tag 1 -> PROP
- FAIL_CONDITION stage flag mask 0x200000 (bit(s) [21]) -- mission FAILS when set
=== level_23 (level 23, UsetupcontrolZ) ===
Objective 0 [briefing text id 8233] min difficulty: Agent
- COMPLETE_CONDITION stage flag mask 0x1000 (bit(s) [12])
- FAIL_CONDITION stage flag mask 0x2000 (bit(s) [13]) -- mission FAILS when set
Objective 1 [briefing text id 8234] min difficulty: Agent
- COMPLETE_CONDITION stage flag mask 0x4000 (bit(s) [14])
- FAIL_CONDITION stage flag mask 0x10000 (bit(s) [16]) -- mission FAILS when set
Objective 2 [briefing text id 8235] min difficulty: Agent
- DESTROY_OBJECT tag 21 -> PROP
- DESTROY_OBJECT tag 22 -> PROP
- DESTROY_OBJECT tag 23 -> PROP
- DESTROY_OBJECT tag 24 -> PROP
- DESTROY_OBJECT tag 25 -> PROP
- DESTROY_OBJECT tag 26 -> PROP
=== level_24 (level 24, UsetuparchZ) ===
Objective 0 [briefing text id 2089] min difficulty: Agent
- ENTER_ROOM ref @4=152 (room); runtime word @8=0x0
Objective 1 [briefing text id 2090] min difficulty: Agent
- COMPLETE_CONDITION stage flag mask 0x100 (bit(s) [8])
Objective 2 [briefing text id 2091] min difficulty: Secret Agent
- COLLECT_OBJECT tag 2 -> PROP
- FAIL_CONDITION stage flag mask 0x1000 (bit(s) [12]) -- mission FAILS when set
Objective 3 [briefing text id 2092] min difficulty: Agent
- ENTER_ROOM ref @4=208 (room); runtime word @8=0x0
- COMPLETE_CONDITION stage flag mask 0x400 (bit(s) [10])
- FAIL_CONDITION stage flag mask 0x200 (bit(s) [9]) -- mission FAILS when set
=== level_25 (level 25, UsetuptraZ) ===
Objective 0 [briefing text id 36898] min difficulty: Agent
- DESTROY_OBJECT tag 8 -> PROP
- DESTROY_OBJECT tag 9 -> PROP
- DESTROY_OBJECT tag 10 -> PROP
- DESTROY_OBJECT tag 11 -> PROP
- DESTROY_OBJECT tag 12 -> PROP
- DESTROY_OBJECT tag 13 -> PROP
Objective 1 [briefing text id 36899] min difficulty: Agent
- COMPLETE_CONDITION stage flag mask 0x8000 (bit(s) [15])
- FAIL_CONDITION stage flag mask 0x4000 (bit(s) [14]) -- mission FAILS when set
Objective 2 [briefing text id 36900] min difficulty: Secret Agent
- COMPLETE_CONDITION stage flag mask 0x10000 (bit(s) [16])
Objective 3 [briefing text id 36901] min difficulty: No One Lives Forever
- COMPLETE_CONDITION stage flag mask 0x20000 (bit(s) [17])
Objective 4 [briefing text id 36902] min difficulty: Agent
- COMPLETE_CONDITION stage flag mask 0x100000 (bit(s) [20])
=== level_26 (level 26, UsetupdestZ) ===
Objective 0 [briefing text id 13332] min difficulty: Agent
- COMPLETE_CONDITION stage flag mask 0x100 (bit(s) [8])
- COMPLETE_CONDITION stage flag mask 0x200 (bit(s) [9])
- COMPLETE_CONDITION stage flag mask 0x400 (bit(s) [10])
- COMPLETE_CONDITION stage flag mask 0x800 (bit(s) [11])
- COMPLETE_CONDITION stage flag mask 0x1000 (bit(s) [12])
- FAIL_CONDITION stage flag mask 0x100000 (bit(s) [20]) -- mission FAILS when set
Objective 1 [briefing text id 13333] min difficulty: Secret Agent
- COMPLETE_CONDITION stage flag mask 0x10000 (bit(s) [16])
- FAIL_CONDITION stage flag mask 0x40000 (bit(s) [18]) -- mission FAILS when set
Objective 2 [briefing text id 13334] min difficulty: Secret Agent
- COMPLETE_CONDITION stage flag mask 0x20000 (bit(s) [17])
- FAIL_CONDITION stage flag mask 0x80000 (bit(s) [19]) -- mission FAILS when set
Objective 3 [briefing text id 13335] min difficulty: Agent
- COMPLETE_CONDITION stage flag mask 0x4000 (bit(s) [14])
- FAIL_CONDITION stage flag mask 0x8000 (bit(s) [15]) -- mission FAILS when set
=== level_27 (level 27, UsetupsevbZ) ===
Objective 0 [briefing text id 29743] min difficulty: Secret Agent
- COMPLETE_CONDITION stage flag mask 0x200 (bit(s) [9])
Objective 1 [briefing text id 29744] min difficulty: Agent
- FAIL_CONDITION stage flag mask 0x1000 (bit(s) [12]) -- mission FAILS when set
- COLLECT_OBJECT tag 2 -> PROP
Objective 2 [briefing text id 29745] min difficulty: Secret Agent
- DESTROY_OBJECT tag 28 -> CCTV
- DESTROY_OBJECT tag 29 -> CCTV
- DESTROY_OBJECT tag 30 -> CCTV
- DESTROY_OBJECT tag 31 -> CCTV
- DESTROY_OBJECT tag 32 -> CCTV
- DESTROY_OBJECT tag 33 -> CCTV
Objective 3 [briefing text id 29746] min difficulty: No One Lives Forever
- COLLECT_OBJECT tag 34 -> PROP
Objective 4 [briefing text id 29747] min difficulty: Agent
- FAIL_CONDITION stage flag mask 0x100 (bit(s) [8]) -- mission FAILS when set
- COMPLETE_CONDITION stage flag mask 0x800 (bit(s) [11])
- ENTER_ROOM ref @4=51 (room); runtime word @8=0x0
=== level_28 (level 28, UsetupaztZ) ===
Objective 0 [briefing text id 5141] min difficulty: Agent
- COMPLETE_CONDITION stage flag mask 0x1000 (bit(s) [12])
Objective 1 [briefing text id 5142] min difficulty: Agent
- COMPLETE_CONDITION stage flag mask 0x8000 (bit(s) [15])
=== level_29 (level 29, UsetuppeteZ) ===
Objective 0 [briefing text id 25627] min difficulty: Secret Agent
- COMPLETE_CONDITION stage flag mask 0x20000 (bit(s) [17])
- FAIL_CONDITION stage flag mask 0x40000 (bit(s) [18]) -- mission FAILS when set
Objective 1 [briefing text id 25625] min difficulty: Agent
- COMPLETE_CONDITION stage flag mask 0x4000 (bit(s) [14])
- FAIL_CONDITION stage flag mask 0x8000 (bit(s) [15]) -- mission FAILS when set
Objective 2 [briefing text id 25626] min difficulty: Agent
- COMPLETE_CONDITION stage flag mask 0x4000 (bit(s) [14])
- FAIL_CONDITION stage flag mask 0x1000 (bit(s) [12]) -- mission FAILS when set
=== level_30 (level 30, UsetupdepoZ) ===
Objective 0 [briefing text id 12303] min difficulty: No One Lives Forever
- DESTROY_OBJECT tag 10 -> PROP
- DESTROY_OBJECT tag 11 -> PROP
- DESTROY_OBJECT tag 12 -> PROP
- DESTROY_OBJECT tag 13 -> PROP
- DESTROY_OBJECT tag 14 -> PROP
- DESTROY_OBJECT tag 15 -> PROP
- DESTROY_OBJECT tag 16 -> PROP
- DESTROY_OBJECT tag 17 -> PROP
- DESTROY_OBJECT tag 18 -> PROP
- DESTROY_OBJECT tag 19 -> PROP
- DESTROY_OBJECT tag 20 -> PROP
- DESTROY_OBJECT tag 21 -> PROP
- DESTROY_OBJECT tag 22 -> PROP
- DESTROY_OBJECT tag 23 -> PROP
- DESTROY_OBJECT tag 24 -> PROP
- DESTROY_OBJECT tag 25 -> PROP
- DESTROY_OBJECT tag 26 -> PROP
Objective 1 [briefing text id 12304] min difficulty: Secret Agent
- DESTROY_OBJECT tag 1 -> MULTI_MONITOR
- DESTROY_OBJECT tag 2 -> PROP
- DESTROY_OBJECT tag 3 -> PROP
Objective 2 [briefing text id 12305] min difficulty: Secret Agent
- COLLECT_OBJECT tag 0 -> KEY
Objective 3 [briefing text id 12306] min difficulty: Secret Agent
- COLLECT_OBJECT tag 5 -> PROP
Objective 4 [briefing text id 12307] min difficulty: Agent
- COMPLETE_CONDITION stage flag mask 0x100 (bit(s) [8])
UsetuprefZ not in filelist
=== dam (level 33, UsetupdamZ) ===
Objective 0 [briefing text id 11286] min difficulty: Secret Agent
- DESTROY_OBJECT tag 0 -> ALARM
- DESTROY_OBJECT tag 1 -> ALARM
- DESTROY_OBJECT tag 2 -> ALARM
- DESTROY_OBJECT tag 3 -> ALARM
Objective 1 [briefing text id 11287] min difficulty: No One Lives Forever
- COMPLETE_CONDITION stage flag mask 0x100 (bit(s) [8])
- FAIL_CONDITION stage flag mask 0x2000 (bit(s) [13]) -- mission FAILS when set
- FAIL_CONDITION stage flag mask 0x800 (bit(s) [11]) -- mission FAILS when set
Objective 2 [briefing text id 11288] min difficulty: No One Lives Forever
- COMPLETE_CONDITION stage flag mask 0x400 (bit(s) [10])
- FAIL_CONDITION stage flag mask 0x200 (bit(s) [9]) -- mission FAILS when set
Objective 3 [briefing text id 11289] min difficulty: Agent
- COMPLETE_CONDITION stage flag mask 0x1000 (bit(s) [12])
=== facility (level 34, UsetuparkZ) ===
Objective 0 [briefing text id 3120] min difficulty: Agent
- ENTER_ROOM ref @4=86 (room); runtime word @8=0x0
Objective 1 [briefing text id 3121] min difficulty: Secret Agent
- COMPLETE_CONDITION stage flag mask 0x200 (bit(s) [9])
- FAIL_CONDITION stage flag mask 0x10000 (bit(s) [16]) -- mission FAILS when set
Objective 2 [briefing text id 3122] min difficulty: Agent
- COMPLETE_CONDITION stage flag mask 0x100 (bit(s) [8])
- FAIL_CONDITION stage flag mask 0x1000 (bit(s) [12]) -- mission FAILS when set
Objective 3 [briefing text id 3123] min difficulty: Agent
- DESTROY_OBJECT tag 1 -> GAS
- DESTROY_OBJECT tag 2 -> GAS
- DESTROY_OBJECT tag 3 -> GAS
- DESTROY_OBJECT tag 4 -> GAS
- DESTROY_OBJECT tag 5 -> GAS
- DESTROY_OBJECT tag 6 -> GAS
- DESTROY_OBJECT tag 7 -> GAS
- DESTROY_OBJECT tag 8 -> GAS
- DESTROY_OBJECT tag 9 -> GAS
- DESTROY_OBJECT tag 10 -> GAS
Objective 4 [briefing text id 3124] min difficulty: Agent
- COMPLETE_CONDITION stage flag mask 0x8000 (bit(s) [15])
- FAIL_CONDITION stage flag mask 0x4000 (bit(s) [14]) -- mission FAILS when set
=== runway (level 35, UsetuprunZ) ===
Objective 0 [briefing text id 28687] min difficulty: Agent
- COLLECT_OBJECT tag 2 -> KEY
Objective 1 [briefing text id 28688] min difficulty: No One Lives Forever
- DESTROY_OBJECT tag 0 -> AUTOGUN
- DESTROY_OBJECT tag 4 -> AUTOGUN
- DESTROY_OBJECT tag 5 -> AUTOGUN
Objective 2 [briefing text id 28689] min difficulty: Secret Agent
- DESTROY_OBJECT tag 1 -> PROP
Objective 3 [briefing text id 28690] min difficulty: Agent
- COMPLETE_CONDITION stage flag mask 0x800 (bit(s) [11])
- FAIL_CONDITION stage flag mask 0x400 (bit(s) [10]) -- mission FAILS when set
=== surface1 (level 36, UsetupsevxZ) ===
Objective 0 [briefing text id 31760] min difficulty: Agent
- COMPLETE_CONDITION stage flag mask 0x100 (bit(s) [8])
- FAIL_CONDITION stage flag mask 0x800 (bit(s) [11]) -- mission FAILS when set
Objective 1 [briefing text id 31761] min difficulty: Secret Agent
- COLLECT_OBJECT tag 0 -> KEY
Objective 2 [briefing text id 31762] min difficulty: Secret Agent
- COLLECT_OBJECT tag 2 -> PROP
Objective 3 [briefing text id 31763] min difficulty: Agent
- COMPLETE_CONDITION stage flag mask 0x8000 (bit(s) [15])
=== surface2 (level 43, UsetupsevxbZ) ===
Objective 0 [briefing text id 32788] min difficulty: No One Lives Forever
- DESTROY_OBJECT tag 3 -> CCTV
- DESTROY_OBJECT tag 4 -> CCTV
- DESTROY_OBJECT tag 6 -> CCTV
- DESTROY_OBJECT tag 8 -> CCTV
Objective 1 [briefing text id 32789] min difficulty: Agent
- DESTROY_OBJECT tag 0 -> PROP
- DESTROY_OBJECT tag 1 -> PROP
- FAIL_CONDITION stage flag mask 0x400000 (bit(s) [22]) -- mission FAILS when set
Objective 2 [briefing text id 32790] min difficulty: Secret Agent
- COMPLETE_CONDITION stage flag mask 0x100 (bit(s) [8])
- FAIL_CONDITION stage flag mask 0x200 (bit(s) [9]) -- mission FAILS when set
Objective 3 [briefing text id 32791] min difficulty: Agent
- ENTER_ROOM ref @4=3 (room); runtime word @8=0x0
=== jungle (level 37, UsetupjunZ) ===
Objective 0 [briefing text id 18469] min difficulty: Agent
- DESTROY_OBJECT tag 0 -> AUTOGUN
- DESTROY_OBJECT tag 1 -> AUTOGUN
- DESTROY_OBJECT tag 2 -> AUTOGUN
- DESTROY_OBJECT tag 9 -> AUTOGUN
- DESTROY_OBJECT tag 10 -> AUTOGUN
- DESTROY_OBJECT tag 11 -> AUTOGUN
- DESTROY_OBJECT tag 12 -> AUTOGUN
Objective 1 [briefing text id 18470] min difficulty: Agent
- COMPLETE_CONDITION stage flag mask 0x100 (bit(s) [8])
Objective 2 [briefing text id 18471] min difficulty: Secret Agent
- DESTROY_OBJECT tag 20 -> PROP
- DESTROY_OBJECT tag 21 -> PROP
- DESTROY_OBJECT tag 22 -> PROP
- DESTROY_OBJECT tag 23 -> PROP
- DESTROY_OBJECT tag 24 -> PROP
- DESTROY_OBJECT tag 25 -> PROP
- DESTROY_OBJECT tag 26 -> PROP
- DESTROY_OBJECT tag 27 -> PROP
- DESTROY_OBJECT tag 28 -> PROP
Objective 3 [briefing text id 18472] min difficulty: Agent
- COMPLETE_CONDITION stage flag mask 0x8000 (bit(s) [15])
- FAIL_CONDITION stage flag mask 0x400 (bit(s) [10]) -- mission FAILS when set
=== caverns (level 39, UsetupcaveZ) ===
Objective 0 [briefing text id 7187] min difficulty: Secret Agent
- DESTROY_OBJECT tag 0 -> MULTI_MONITOR
- DESTROY_OBJECT tag 1 -> MULTI_MONITOR
- DESTROY_OBJECT tag 2 -> MULTI_MONITOR
- DESTROY_OBJECT tag 3 -> MULTI_MONITOR
Objective 1 [briefing text id 7188] min difficulty: Secret Agent
- DESTROY_OBJECT tag 4 -> MULTI_MONITOR
- DESTROY_OBJECT tag 5 -> MULTI_MONITOR
- DESTROY_OBJECT tag 6 -> MULTI_MONITOR
- DESTROY_OBJECT tag 7 -> MULTI_MONITOR
Objective 2 [briefing text id 7189] min difficulty: Secret Agent
- DESTROY_OBJECT tag 8 -> MULTI_MONITOR
Objective 3 [briefing text id 7190] min difficulty: No One Lives Forever
- COMPLETE_CONDITION stage flag mask 0x400000 (bit(s) [22])
- FAIL_CONDITION stage flag mask 0x40000 (bit(s) [18]) -- mission FAILS when set
- FAIL_CONDITION stage flag mask 0x80000 (bit(s) [19]) -- mission FAILS when set
- FAIL_CONDITION stage flag mask 0x100000 (bit(s) [20]) -- mission FAILS when set
- FAIL_CONDITION stage flag mask 0x200000 (bit(s) [21]) -- mission FAILS when set
Objective 4 [briefing text id 7192] min difficulty: Agent
- COMPLETE_CONDITION stage flag mask 0x800000 (bit(s) [23])
- FAIL_CONDITION stage flag mask 0x1000000 (bit(s) [24]) -- mission FAILS when set
=== cradle (level 41, UsetupcradZ) ===
Objective 0 [briefing text id 9247] min difficulty: Agent
- DESTROY_OBJECT tag 0 -> MONITOR
- FAIL_CONDITION stage flag mask 0x800 (bit(s) [11]) -- mission FAILS when set
Objective 1 [briefing text id 9248] min difficulty: Agent
- COMPLETE_CONDITION stage flag mask 0x100 (bit(s) [8])
=== egypt (level 32, UsetupcrypZ) ===
Objective 0 [briefing text id 10252] min difficulty: Agent
- COLLECT_OBJECT tag 4 -> COLLECTABLE
- COLLECT_OBJECT tag 5 -> AMMO
Objective 1 [briefing text id 10253] min difficulty: Agent
- COMPLETE_CONDITION stage flag mask 0x4000 (bit(s) [14])
=== cuba (level 54, UsetuplenZ) ===
(no objective records)
+77
View File
@@ -0,0 +1,77 @@
# LEVEL-PLAYTEST — WS6 human completion-validation checklist
Run **after** `docs/LEVEL-STATUS.md` is all-21 load+render PASS. This is
the part that needs real input — a human plays each level start to finish
and confirms it matches retail. Findings feed a WS5-style triage round
(log each as a crash-class row or a new Dxx candidate).
## How to run one level
```sh
export PATH="/c/msys64/mingw64/bin:$PATH" && ./build-pc.sh ntsc-final
./build-pc/ge007.x86_64.exe -level_XX # bare — pools auto-injected (D121)
```
Controls: `port/src/input.c` — keyboard+mouse or SDL gamepad. Mouse-look is
mode-aware since M-24 (hold RMB = analog aim). **M-24 input/QoL changes +
what to verify while playing: `docs/M-24-QOL-REVIEW.md`.** Tune feel in
`data/ge007.ini` `[Input]` (`MouseAimSpeed` 50, `MouseTurnSpeed` 100,
`MouseInvertY` 0) with the game closed. Known residual (do not re-log):
D118a — hipfire pitch is digital vs analog yaw. **F12** = screenshot to
`ppm/shot_NNN.ppm`. **Mouse wheel** = cycle weapon. **Alt-Enter** =
fullscreen. Alt-tab away frees the cursor (re-grabs on focus). Title bar
shows FPS.
Difficulty for a bare `-level_XX` boot defaults to Agent; use the menu path
(mission select → difficulty) to test 00/007.
Objective status: `src/game/objective_status.c`
(`get_status_of_objective()` `:161`); objective propDefs are
`PROPDEF_OBJECTIVE_*` (`bondconstants.h:4332`).
## Per-level checklist (copy this block per level)
```
### <Level> (-level_XX) difficulty tested: [ ] Agent [ ] Secret Agent [ ] 00 Agent
- [ ] Spawn point + start orientation match retail
- [ ] Geometry / textures / lighting recognisable, no missing rooms
- [ ] Guards spawn, path, react, take/deal damage, die correctly
- [ ] Doors / lifts / switches / destructibles work
- [ ] Pickups (ammo, armour, key items, gadgets) present and collectable
- [ ] Each difficulty-gated objective reachable AND registers COMPLETE
(objective_status)
- [ ] Alarms / reinforcements / scripted events fire
- [ ] Level exit trigger fires → MISSION COMPLETE (not a hang / wrong screen)
- [ ] Auto-advance to next briefing works
- [ ] Frame rate + timing acceptable
- Notes / defects:
```
## 21 solo levels (mission order — numbers = `-level_XX`)
| # | Level | -level | Briefing asset |
|---|---|---|---|
| 1 | Dam | 33 | UbriefdamZ |
| 2 | Facility | 34 | UbriefarkZ |
| 3 | Runway | 35 | UbriefrunZ |
| 4 | Surface 1 | 36 | UbriefsevxZ |
| 5 | Bunker 1 | 09 | UbriefsevbunkerZ |
| 6 | Silo | 20 | UbriefsiloZ |
| 7 | Frigate | 26 | UbriefdestZ |
| 8 | Surface 2 | 43 | UbriefsevxbZ |
| 9 | Bunker 2 | 27 | UbriefsevbZ |
| 10 | Statue | 22 | UbriefstatueZ |
| 11 | Archives | 24 | UbriefarchZ |
| 12 | Streets | 29 | UbriefpeteZ |
| 13 | Depot | 30 | UbriefdepoZ |
| 14 | Train | 25 | UbrieftraZ |
| 15 | Jungle | 37 | UbriefjunZ |
| 16 | Control | 23 | UbriefcontrolZ |
| 17 | Caverns | 39 | UbriefcaveZ |
| 18 | Cradle | 41 | UbriefcradZ |
| 19 | Aztec | 28 | UbriefaztZ |
| 20 | Egypt | 32 | UbriefcrypZ |
| 21 | Cuba | 54 | (post-Egypt unlock) |
(Mission table: `front.c:433`. Cuba = `LEVELID_CUBA`, not in the standard
folder list — unlocked after Egypt.)
+268
View File
@@ -0,0 +1,268 @@
# LEVEL-STATUS — 21 solo-level load+render sweep (WS4)
Method: bare `./build-pc/ge007.x86_64.exe -level_XX` (per-level `-m*` pools
auto-injected, D121), `GE_PCDUMP="80-260:40"`, ~24 s watchdog then
`taskkill //F //IM ge007.x86_64.exe`, `ge007.crash.log` symbolicated with
`addr2line -e build-pc/ge007.x86_64.exe -f -C <PC>` (image base
0x140000000). "render %" = `tools_pc/pixcount.py` non-clear on the last
captured frame. Binary at `f2beae4b` + this session's build.
**M-30 re-sweep (2026-08-31, build after D157/D120/RC2): 21 / 21 PASS — no
crash logs.** First all-green sweep incl. Cuba (54) — the D129 credits-path
crash no longer reproduces on a bare `-level_54` boot (Cuba rendered 92.5%
crash-free through the window). Verifies that the M-30 changes — D157
(objective difficulty byte-offset), RC2 (`Video.FixMipTextures` mip-upload
clip, default on), D120 (`d43_emit.py` PointUsage emit), the `GE_SAVELOG`/
`GE_UNLOCK_ALL` `#ifdef PORT` diagnostics, and the front-end mouse-pointer
mode — introduced no level-load regression. Machine was idle; `level_sweep.sh`
default window/watchdog.
M-25 re-sweep (2026-08-30, port-QoL build `863f436b`): **20 / 21 PASS**,
unchanged. Caverns (39) flagged CRASH `0xc000001d` once under sweep load →
2/2 clean on retry (D117/D134 concurrent-load flakiness, not a regression).
Cuba (54) still the only real crash (known D129 credits path, parked).
**Silo (20):** PASS. A full-length `GE_PCDUMP` capture froze at ~frame 320
(silo→Bond camera descent) twice — but both those runs, *and* the pre-M-25
baseline comparison, were on a machine still loaded from the sweep. On an
idle machine Silo runs fine (user drove it live past ~1800 frames, VI
pacemaker healthy, no heartbeat stall). Classic D117/D134 load-sensitive
stall, not a deterministic bug and not an M-25 regression. If it recurs on
an idle box, count N runs before believing it (PORT-LEARNINGS §E).
Last full re-sweep: 2026-08-29 (M-22), after D134 landed (SP/DP task-done
event no longer dropped into a full sched interruptQ — the "frame-2 hang"
that M-13..M-21 wrote off as D117/machine-load flakiness). Build = HEAD
`a8f27c16`. Optimized `level_sweep.sh` (early-exit once the GE_PCDUMP set
is on disk / STALL_SECS freeze) — full 21-level sweep now ~7 min.
**M-22 result: 20 / 21 PASS.** Every level loads + renders + survives the
capture window EXCEPT Cuba. Facility (34) and Jungle (37) — the M-20
re-verify failures — both PASS cleanly now, confirming D134 was their
cause. Only remaining crash:
- **Cuba (54)** — `textMeasure` (`textrelated.c:770`) ← `bondviewRenderCredits`
(`bondview2.c:8912`), AV @ `0x1400c3b77`, fault addr NULL, after ~frame
900. This is the **known D129 residual**: the bare `-level_54` boot
reaches the cast/credits scroll referencing a text bank the real
front-end flow would have loaded. Not a real-flow blocker; Cuba loads +
renders 300+ frames fine. Parked with D76 (`docs/GRAPHICS-BACKLOG.md`).
| Level | # | Status | Crash site | Class |
|---|---|---|---|---|
| Bunker1 | 09 | **PASS** (M-20: re-verified — 1800 frames crash-free, no `ge007.crash.log`, direct `-level_09` boot at ~90us/frame). The M-19 "boot crash `frames=0` PC `0x1400066fc`" did **not** reproduce on `1fc3cff6` in three separate runs → it was sweep flakiness / D117 nondeterminism under machine load, not a real regression. | — |
| Silo | 20 | **PASS** 91.7% | — | — |
| Archives | 24 | **PASS** 91.1% | — | — |
| Train | 25 | **PASS** 87.0% | — | — |
| Caverns | 39 | **PASS** 91.6% | — | — |
| Egypt | 32 | **PASS** 90.9% | — | — |
| Cuba | 54 | **PASS** 92.5% (loads+renders 300+ frames; bare-boot end-credits path `bondviewRenderCredits` still faults intermittently — D76/D129, not a real-flow blocker) | — | — |
| Dam | 33 | **PASS** 83.3% | — (C1 fixed, D123) | — |
| Frigate | 26 | **PASS** 90.4% | — (C1 fixed, D123) | — |
| Statue | 22 | **PASS** 80.1% | — (C1 fixed, D123) | — |
| Streets | 29 | **PASS** 91.6% | — (C1 fixed, D123; no C2 crash this run — timing) | — |
| Cradle | 41 | **PASS** 55.4% (low — partial render, no crash) | — (C1 fixed, D123) | — |
| Runway | 35 | **PASS** | — (D130: `romdataFixupFont` glyph-relayout aliasing) | — |
| Facility | 34 | **PASS** (M-20 re-verified on an idle machine: frame 1500+, no crash. The M-20 mid-session "boot crash `frames=0`" was concurrent-load flakiness — three subagents/sessions sharing the box. Fix stands: D130.) | — (D130: `romdataFixupFont` corrupted glyph `#`/`"` pixeldata → fast3d AV) | — |
| Jungle | 37 | **PASS** (M-20 re-verified on an idle machine: frame 1500+, no crash. M-19/D131: 2400+ crash-free. The M-20 mid-session "frame-2 hang" was concurrent-load flakiness.) | — (D131: `osVirtualToPhysical()` truncated a compiled `.bss` matrix ptr in `explosionRenderPropSmoke` → `seg_addr` now restores the module high word) | — |
| Aztec | 28 | **PASS** 90.8% | — (C3 fixed, D125) | — |
| Bunker2 | 27 | **PASS** 91.5% | — (C3r fixed, D126) | — |
| Depot | 30 | **PASS** 79.8% | — (C4 fixed, D126) | — |
| Control | 23 | **PASS** 90.8% | — (C5 fixed, D128) | — |
| Surface2 | 43 | **PASS** 70.4% | — (C6 fixed, D126) | — |
| Surface1 | 36 | **PASS** 77.5% | — (C7 guarded, D127) | — |
**19 / 21** (M-19). D131 cleared C2m Jungle (explosion-DL `G_MTX` truncated
compiled-symbol pointer). **But `-level_09` (Bunker1) regressed to a boot
crash sometime between M-18 and now** — reproduces on clean master, so it
is not an M-19 change; bisect D125→HEAD. Net 20→19 until 09 is refixed.
**(M-18) 20 / 21 PASS.** D130 cleared C2 Facility + Runway — the crash was
`romdataFixupFont` corrupting fontchar glyphs 0/1/2 (in-place 24→32B relayout
aliases for low indices), NOT the model-GDL relocation the D124-Facility
addendum suspected. Only Jungle (C2m, explosion-DL `G_MTX`, ~frame 300)
remains.
**(older) 18 / 21 PASS.** D123 cleared C1 on all 6. D124 cleared Jungle's texture
crash (now hits an explosion-DL matrix crash). D125 cleared C3-Aztec.
**D126** (objective sub-record `->next` pointer widens 4→8B → 8-byte store
clobbers the next propdef record's header → walk desync → wrong command
indices) cleared **C3r Bunker2 + C4 Depot + C6 Surface2** in one fix
(`d88_propdefs.py` types 30/32/33/35 → 24B/6w). **D127** guarded a bogus
ALSound* in the parked audio path → **C7 Surface1**. **D128** fixed a
hardcoded-N64-stride portal-flag write → **C5 Control**. **3 crashes
remain, all one class:** C2 Runway + Facility + C2m Jungle — the model /
prop GDL relocation misaligns on the 8→16B `Gfx` stride
(`docs/BRIEF-C2gdl-model-reloc.md`).
Notes: Streets passed this run but the C1 agent saw a `gfx_sp_matrix`
crash — likely the same explosion-DL `G_MTX` (C2m) as Jungle, timing-
dependent (D117 nondeterminism). Cradle renders only 55% — watch for a
partial-load issue when it gets a playtest. Frigate/Statue vary 68–90%
run to run.
## Crash classes (most-impactful first)
### C1 — chr / AI-record pointer deref — FIXED (D123)
**RESOLVED.** Root cause: `tools_pc/d88_propdefs.py` (D122's `OBJ_TAIL_DESC`)
zeroed the widened `VehichleRecord/AircraftRecord.ailist` slot instead of
carrying its pre-populated int AI-list id, so `prop.c:1764/1786` →
`ailistFindById(0)` → `GAILIST_AIM_AT_BOND` → `ai()` ran a CHR aim list
against `ChrEntityp==NULL`. Fixed via `OBJ_ID_WORDS`. See §F/§H D123.
Dam/Frigate/Statue/Cradle now PASS; Runway/Streets fall through to C2.
<details><summary>original triage notes</summary>
`chrIsNotDeadOrShot(ChrRecord *self)` faults on `self->actiontype` with
`self` bogus (Dam: Rdi=0x1401296a0 → an image address; fault addr 0x8).
Reached from `chraidata.c:61` (`m_AimAtBond[]` AI-list region — backtrace
frame is inside the global AI-list rodata blob, i.e. the return address
was corrupted OR an AI-list entry is being called as a function pointer).
Pointer-width / BE-rodata family (PORT-LEARNINGS §A / §C): a per-level
`Usetup*Z` guard record (`GuardRecord` type 9 / `GuardAttributeRecord`
type 18) or chr-spawn pointer not converted / not pointer-width-adjusted,
OR the AI-list bytecode pointers in `chraidata.c` rodata are BE and get
called raw. **Biggest single win — 6 levels.**
</details>
### C2 — fast3d bad texture pointer (D124) — SPLIT into two causes
**Jungle (`0xabcd0824`) — FIXED (D124).** `gimgSyncCompiledGlobalDLs()`
never copied the resolved texture pointers into the compiled
`globalDL_0xNNN` explosion DLs (slot-detect keyed on a marker `texLoad()`
had already erased); the arrays kept link-time `IMAGESEG` words and the
first explosion-DL draw fed `0xABCDxxxx` into fast3d. Latent on every
level. Fix: `port/src/gimgfixup.c` detects the slot from the compiled
array. Jungle now renders ~300 frames then hits a *separate* explosion-DL
`G_MTX` crash (`gfx_sp_matrix` gfx_pc.cpp:1046 — D75/matrix family).
**Facility (`0x72181ee8`) — NOT fixed, separate cause.** Bogus `G_SETTIMG`
w1 in a model/prop GDL produced by the runtime `texLoadFromGdl()`
relocation in `sub_GAME_7F0762E0` (`objecthandler_2.c:82`): its model-GDL
output `dst` pointers are non-16-aligned (N64 8B vs PC 16B `Gfx` stride
mix in the `replacementgdl`/`name` offset math). Open D80/D82/D83
"model/room GDL runtime conversion unverified" area — shared infra, needs
a dedicated pass. Files: `src/game/objecthandler_2.c`, `src/game/tex.c`
(`texLoadFromGdl`), `src/game/model.c` (`modelNodeReplaceGdl`).
**M-14 update (partial, still NOT fixed):** crash cmd pinned —
`G_SETTIMG w0=0xfd900000 w1=0x72181ee8` where `w1` is a garbage
`tex->data` from the texture-pool path (`texLoadFromModelFileHeader` →
`texLoad`), not solely the GDL command-stream copy. Every model file
loads at an odd (`&15==1`) `objheader->Switches` base
(`mempAllocBytesInBank` does no alignment) and `delta&15` != 0.
Full write-up + next-step probe plan: PCPortResearch §F D124-Facility
addendum. Likely fix site: the `d43_emit.py`/`pcmodels` sidecar's model
texture-blob offsets (N64 8B-Gfx vs PC 16B-Gfx GDL extent).
### C3/C6 update (M-14/M-15, D125) — offline pipeline RULED OUT, bug is runtime
Symptom: converted `propDefs` blob in RAM does not match the offline output
(after record 0 it is zeros/garbage), so the runtime `sizepropdef()` walk
drifts and `setupDoor`/`modelLoad` get the wrong `pdefIndex`/modelid.
**M-15: the offline side is proven correct.** `tools_pc/d125_check.py`
byte-compares the emitted `pccg.bin` propDefs slice (post-RZ-roundtrip, at
the relocated header offset) to `convert_stream()` → **MATCH for all 21
levels**. The M-13 overseer hypothesis (pass-1 delta / `pd_end` mismatch in
`d88_emit.py` ~L308–340) is **disproven**: instrumented run shows
`tiled_pd_end == H[intro]` and `_n64len == pd_end - pd_start` exactly for
all 21, next region always `intro`. `sizepropdef()` PORT strides re-checked
vs `PROPDEF_PC_BYTES/4` — all 28 match.
**→ The corruption is at/after runtime load.** Prime suspect:
`decompressdata()` truncation in `port/src/rzdecomp.c` for the larger
converted setup files (the `while (ret == Z_OK)` inflate loop), or the
STAGE bank buffer / `langLoadToAddr` (`prop.c:1274`) overwriting the tail.
Next step: probe `decompressdata()` `ret`/`produced` vs expected size for
`UsetupsevbZ`. See §F/§H D125.
### C3 — Aztec FIXED (D125); Bunker2 residual (C3r)
**Aztec** was D125 (boundpad plink-name blob drift from the
`d88_emit.py:374` slice-width bug corrupting `stanPackId` → NULL stan →
door `model=NULL`). Fixed + regen'd, Aztec PASSES.
**Bunker2 (C3r)** — `door7F054FB4` `propobj.c:13536`, `var_s1` = `0xffff..`
walked from `linkedDoor`. `door->linkedDoor` is resolved at setup from
`door->linkedDoorOffset` (`prop.c:1204-1206`, N64 struct word 32 / 0x80) —
a read-before-write int id, D123's `ailist` pattern. `d88_propdefs.py`'s
DOOR handler (`DOOR_TAIL_PTR_WORDS`, tail loop from word 32) vs the
compiled PC `DoorRecord` layout is the suspect: either `linkedDoorOffset`
lands at the wrong PC byte, or a tail pointer word's 8-align cursor
diverges from the compiler's struct layout. Bunker2 has linked
double-doors; Aztec's doors are singletons (`linkedDoorOffset==0`) so it
never exercised this path. Needs `offsetof`/`sizeof` cross-check of PC
`DoorRecord` vs the converter cursor. Files: `tools_pc/d88_propdefs.py`,
`src/bondtypes.h` (DoorRecord), `src/game/prop.c`, `src/game/propobj.c`.
### C4 — prop tile-walk room deref (1: Depot)
`prop.c:902` — `*arg1 = sp4C->room` where `sp4C` came back bad from
`walkTilesBetweenPoints_NoCallback`. Likely a BG tile/room table not
converted for Depot. Files: `src/game/prop.c`, `src/game/bg.c`, BG
sidecar.
### C5 — bg portal points (1: Control)
`bg.c:5723` — `portal_pts = g_BgPortals[idx].offset_portal; portal_pts->numPoints`.
`offset_portal` unresolved / BE. Via `chrprop.c:62` (chr line-of-sight
through portals). Files: `src/game/bg.c`, BG portal sidecar.
### C6 — modelLoad PitemZ header (1: Surface2)
`loadobjectmodel.c:393` — `PitemZ_entries[modelid].header->RootNode`,
`header` NULL / `modelid` OOB. Direct D122 continuation (prop/item model
id from a propDef record). Fault addr 0x0. Files: `tools_pc/d88_propdefs.py`,
`tools_pc/d43_emit.py`, `src/game/loadobjectmodel.c`.
### C7 — sndSetupSound (1: Surface1)
`snd.c:653`. Audio is a parked subsystem (Phase 3) but this is a hard
crash on load, not silence. May be dodge-able with a narrow guard until
audio lands. Files: `src/snd.c`, `port/src/` audio stubs.
## Next (1 crash)
1. **C2m** Jungle `-level_37` — renders ~300 frames then an explosion-DL
`G_MTX` (`gfx_sp_matrix` gfx_pc.cpp:1046, fault 0x401c68e0), D75/matrix
family. NOT related to D130 (Facility/Runway were a font-relayout bug, now
fixed). `docs/BRIEF-C2gdl-model-reloc.md` is now largely obsolete — its
model-GDL-relocation premise was disproven (D130); the Jungle crash is a
compiled/explosion-DL matrix pointer, closer to D75/D124-Jungle territory.
Everything else loads + renders + survives the no-input window.
Hand `docs/LEVEL-PLAYTEST.md` to the user for the WS6 completion pass.
Re-run `tools_pc/level_sweep.sh` after each class fix; keep
`-level_09`/`-level_20` green (`tools_pc/framediff.py`).
---
## With-input crash sweep (M-30, 2026-08-31)
Scripted controller-0 input (walk fwd + fire pulses + turns) via
`GE_INPUTSCRIPT`, ~46-70 s per level, `GE_PCDUMP` capture. Checks
`ge007.crash.log` + VI-pacemaker health. Machine was loaded (concurrent
agents) → frame progression slow on some runs but VI posts steady = no
hang. All retried on a settled machine where slow.
| # | Level | With-input result |
|---|----------|-------------------|
| 33 | Dam | PASS (frame 300+ dump, no crash) |
| 09 | Bunker1 | PASS (VI pacemaker healthy, no crash; slow under load) |
| 20 | Silo | PASS (VI pacemaker healthy, no crash; slow under load) |
| 23 | Control | PASS (frame 600+, no crash) |
| 32 | Egypt | PASS (frame 600, no crash — first run was load-slow, clean on retry) |
| 54 | Cuba | PASS (frame 1200+, no crash) |
| 39 | Caverns | PASS — **D158 (KF7 muzzle-flash `stackpad2[-8]`) holds**, fire no longer crashes |
Earlier this session (per M-30 handoff), with-input PASS:
Facility 34, Runway 35, Surface1 36, Frigate 26, Statue 22, Bunker2 27,
Surface2 43, Streets 29, Depot 30, Train 25, Jungle 37, Cradle 41, Aztec 28.
**With-input sweep: 21/21 crash-free.** No new ABI/layout crashes found.
### stackpad / negative-stack-index audit
`grep -rnE '\)\[-[0-9]|[a-z0-9_]\[-[0-9]+\]|stackpad' src/game/*.c src/*.c`:
only `gunfire.c:830/836` (`((f32*)stackpad2)[-8]`) is a true decomp
register-alloc stackpad hack — already fixed by D158 (`#ifdef PORT`
real local `kf7_flashscale`). `front.c:5246-47` declares `s32 stackpad;`
locals but never negative-indexes them (benign, and front.c is off-limits).
All other `[-N]` hits are legitimate pointer walks (`gun.c` spline
`current[-1]`, `stan.c` `stack[-1]`, `initpathtablelinks.c`
`validationGroupCursors[-3]` already D89-handled, `options.c` `vtx[-1]`,
`decompress.c` `src[-1]`). `othermodemicrocode.c:368 (aa)[-4]` is a
texture-pool pointer walk, not a stack hack. **No further stackpad bugs.**
+286
View File
@@ -0,0 +1,286 @@
# Texture Glitch Analysis — PC Port
**Original diagnosis:** 2026-08-29 (read-only) · **Status table refreshed:** 2026-08-31 (M-31)
Companion artifacts: `<local-path>` (outside repo).
---
## 0. Current status (M-31) — READ THIS FIRST
The 2026-08-29 body below is the original diagnosis. Several root causes have
since been fixed or retracted. Authoritative current state:
| RC / bug | 2026-08-29 verdict | Current status (M-31) | Where |
|---|---|---|---|
| **RC2** — mip-chain contamination (LOD rows uploaded as image rows) | open | **FIXED** — `import_texture()` clips a full-width LOD block to the SETTILESIZE base height; GL builds correct mips. Knob `Video.FixMipTextures` (**default on**; `=0` = old byte-identical-to-golden behaviour). | `port/fast3d/gfx_pc.cpp`; §F RC2 |
| **RC4** — palette "off-by-one" in `palette_to_rgba32` | proposed 1-line fix | **RETRACTED** — the current decode is correct **RGBA5551**; the "spec" line in §2 was ARGB1555. Applying the "fix" rotates every channel + moves alpha. **Do not touch.** | `gfx_pc.cpp:~835`; §2 RC4, §F |
| **D159** ("interlaced" / venetian-blind textures, esp. front-end photos) | not yet identified | **FIXED** — `texSwapAltRowBytes` (N64 RDP odd-line TMEM XOR compensation, which fast3d does not emulate) is a `#ifdef PORT` no-op. This was the user's "interlaced textures" report. | `src/game/image.c`; §F D159 |
| **D161** — Depot (`-level_30`) ceiling blue speckle + radial rays (was §3 "blue ground" / B2) | attributed to RC2+RC3+RC4+fog | **FIXED** — a 16×16 **CI8 tile drawn with `G_TT_NONE`** (TLUT disabled) was palette-looked-up against a stale `rdp.palette`. fast3d now routes CI+`G_TT_NONE` → I (intensity). Not RC2/RC3/RC4/filtering. | `port/fast3d/gfx_pc.cpp import_texture()`; §F D161, PORT-LEARNINGS §D |
| **RC1** — wallet-Bond photo (mode-select / folder screen) garble | "implement G_SETTEX" | **PARTIALLY OPEN.** The *interlace comb* is gone (D159). The photo now renders as a recognisable grayscale portrait but is still **180°-rotated / mis-transformed** → **D75 front-end-model family** (model-GDL transform, not a fast3d opcode gap — `texLoadFromGdl` already expands G_SETTEX on PC, see §6b). Parked, cosmetic, below crash work. | §6b; GRAPHICS-BACKLOG D149; §F D75/D80/D82/D83 |
| **RC3** — wrap-period mismatch (non-PoT dims "repeat oddly") | open | **IN PROGRESS.** The dead D74 pre-wrap block is now opt-in as `Video.WrapFix` (M-29, **not default** — the naive one-liner activates never-run code and boot-crashes `-level_09`; needs the hoist-out-of-vertex-loop rework + per-level visual check). | `gfx_pc.cpp`; GRAPHICS-BACKLOG B1; §F D74 |
| RC1 (original) — G_SETTEX (0xc0) is `G_NOOP` in fast3d | "implement the command" | **NOT the blocker.** `src/game/tex.c texLoadFromGdl` already expands every G_SETTEX into a standard load sequence under `#ifdef PORT`; fast3d normally never sees raw 0xc0. See §6b. | §6b |
Everything from `## 1.` down is the original 2026-08-29 text, left intact for
the reasoning trail. Cross-check any "open / pending / proposed" wording there
against this table.
## 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)
> **M-31 status overlay** (see §0): RC2 **FIXED** (`Video.FixMipTextures`), RC4 **RETRACTED**,
> RC1 superseded — the "interlaced" symptom was **D159** (`texSwapAltRowBytes`), now **FIXED**;
> the Depot "blue ground" was **D161** (CI8 + `G_TT_NONE`), now **FIXED**. RC3 in progress
> (`Video.WrapFix`, opt-in). RC1 residual = 180°-rotated front-end photo = D75 family, open.
| # | 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~~ — **not the blocker**: `texLoadFromGdl` already expands it on PC (§6b); residual = D75 model transform |
| 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 | **FIXED** — clip LOD block to base-tile height; `Video.FixMipTextures` default on |
| 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 | **DONE (D167), behind `Video.WrapFix` default OFF** — mask-based wrap period in the hoisted pre-wrap block; needs a human eyeball on Depot before default-on. See §6d |
| RC4 | ~~**Palette off-by-one** in `palette_to_rgba32`~~ — **RETRACTED (M-30)**: current decode is correct RGBA5551 | `port/fast3d/gfx_pc.cpp:835` | nothing — the "spec" line was ARGB1555 | **do not change** |
### 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 — **RETRACTED (M-30): the current code is correct.**
The "N64 RGBA16 spec" line below is **ARGB1555**, which is *not* what the N64 uses.
N64 `G_IM_FMT_RGBA` / `G_IM_SIZ_16b` is **RGBA5551**: `RRRRR GGGGG BBBBB A`
(R = bits 15-11, G = 10-6, B = 5-1, A = bit 0).
```c
// fast3d (current, CORRECT): r = v>>11; g = (v>>6)&0x1f; b = (v>>1)&0x1f; a = v&1
// the "spec" line here was wrong: r = (v>>10)&0x1f; g = (v>>5)&0x1f; b = v&0x1f; a = v>>15
```
Both `palette_to_rgba32` (`gfx_pc.cpp:~835`) and `import_texture_rgba16`
(`~651`) use the same RGBA5551 decode and agree with each other. The palette is
`PD_BE16`-swapped at load (`gfx_dp_load_tlut`), so `palentry` is already
correctly ordered. **Applying the "fix" would rotate every channel and move
alpha to the wrong bit — do not do it.** If a hue problem is ever confirmed
in-game, it is upstream (TLUT byte order at the converter, or a wrong
`rdp.palette_fmt`), not this function.
## 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 — ORIGINAL (2026-08-29); see §0 for what actually shipped
> **M-31 outcome:** item 2 (mip) shipped as `Video.FixMipTextures`; item 4 (palette)
> retracted; the "interlace" was `texSwapAltRowBytes` not on this list (D159, fixed);
> the Depot roof was CI8+`G_TT_NONE` not this list (D161, fixed); item 5 (dump hook)
> shipped as `GE_DTEX` + `GE_TEXDUMP`. Items 1 (G_SETTEX) and 3 (wrap) not done as
> written — see §0 / §6b.
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/internals.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).
## 6b. M-30 follow-up — implementation notes (before you touch RC2)
Re-checked against the current tree while chasing the user's "interlaced
textures" screenshot (`screenshots/interlaced texture example.jpg` — main menu,
wallet-Bond photo + right panel show alternating-row tearing).
- **RC1 (G_SETTEX) is not a simple `case` add.** `src/game/tex.c texLoadFromGdl`
already *expands* every G_SETTEX (opcode 0xc0 == `G_NOOP` slot) in a model GDL
into a full standard load sequence (`texLoadFromTextureNum` + `texFindInPool` +
`texWriteTextureCmd`) written into a replacement GDL, and
`src/game/objecthandler_2.c` (`~line 82`) drives that per display list at model
load + `modelNodeReplaceGdl`s the node. It **is** `#ifdef PORT`-ported. So on PC
the RSP/fast3d should normally *not* see raw G_SETTEX. The user's crash log
showed fast3d walking into `0xfafbfbfc…` garbage (opcode 0x00, **not** 0xc0) —
i.e. the model GDL pointer / expansion is broken for the front-end wallet
models, not "an opcode is missing." This is the **D75 front-end-model family**
(D80/D82/D83 model-GDL relocation, `objecthandler_2.c`), not a fast3d opcode
gap. Implementing G_SETTEX in fast3d would be a fallback that only helps models
that reach it with an otherwise-intact DL.
- **RC2 (mip contamination) — the naive height clamp was already tried and
reverted.** `gfx_pc.cpp import_texture()` ~line 917 has a comment: a prior
branch that set height = `line * tile.height` was removed because it "dropped
mip chains" and "truncated sub-tiled textures" (Rare-logo `D_02005FF0` 20×3
tile uploaded as 32×3). So the fix must distinguish "extra rows are mip levels"
(drop) from "tile is a window into a larger image" (keep). The signal is
`rdp.tex_lod` (passed to every `import_texture_*` as `gen_mipmaps`): when LOD is
on and `size_bytes/row > tile_height`, the excess is mip data → clamp height to
`((lrt-ult)>>2)+1` from `rdp.texture_tile[tile]` **and** pass `gen_mipmaps=false`
(don't let GL build mips from the stacked image). When `rdp.tex_lod` is off,
keep today's behaviour. Verify against the Rare logo (sub-tile case) + Depot
floor (mip case) + BUNKER1 (regression).
- **The alternating-row look specifically** may also be `texSwapAltRowBytes`
(`image.c:2191`, §4: applied to *every* level of *every* texture on *every*
path — odd rows get adjacent u32 groups pairwise swapped). The game swaps the
RAM buffer *before* upload, so fast3d should read the swapped bytes — confirm
fast3d isn't reading a pre-swap copy, or double-swapping. This is the
RenderDoc / §6-item-5 dump task; do that before editing.
**Bottom line (M-30):** #3 is not a grind-fix. Highest-leverage next step is
§6-item-5 (env-gated dump of the actual uploaded GL image + tile setup for the
first ~10 texLoads on the main menu and on Depot) to see whether the base image
is wrong (decode/swap) or just over-tall (RC2 height). Then RC2 per the
`rdp.tex_lod`-gated plan above. G_SETTEX / D75 front-end models are a separate,
larger track.
## 6c. B2 Depot ceiling — ROOT-CAUSED + FIXED (D161, M-31)
The blue-speckle + radial-ray roof was **not** RC2/RC3/RC4/filtering. `GE_TEXDUMP`
(PPM dump of every uploaded texture + a `fmt/siz/palfmt/palidx/pal[0..3]` log
line, both env-gated, in `gfx_pc.cpp`/`gfx_opengl.cpp`) identified the surface as
**one 16×16 CI8 texture uploaded with `rdp.palette_fmt == G_TT_NONE`** — every
other CI texture that frame had `palfmt == 0x8000` (RGBA16). GE draws the Depot
roof with `gsDPSetTextureLUT(G_TT_NONE)`: a CI-siz tile with the TLUT disabled is
a legit N64 idiom meaning "use the raw 8-bit texel as intensity" (≈ I8).
`import_texture()` ignored `palette_fmt` for `fmt==CI` and did a palette lookup
against a **stale `rdp.palette`** → 16×16 of blue/magenta noise; the "rays" were
that noise aliasing on the receding ceiling plane (hence filtering never helped).
**Fix:** `gfx_pc.cpp import_texture()` — `fmt_eff = (fmt==CI && palette_fmt==
G_TT_NONE) ? I : fmt`, dispatch on `fmt_eff` (CI4→I4, CI8→I8). Narrow (only
touches currently-garbage surfaces). Depot corridor + control-room ceilings now
render as clean dark/grey industrial roofs; `-level_09/-30/-34` sweep PASS.
Full write-up: §F D161.
## 6d. RC3 non-PoT wrap period — IMPLEMENTED (D167, M-31), default OFF
`gfx_dp_set_tile` now stores `masks`/`maskt` on the tile (was dropped). The
hoisted per-texunit pre-wrap block in `gfx_sp_tri1` (the reworked D74 block —
already lifted out of the vertex loop at M-30, indexed by texunit `t`) gained a
branch: when the render tile is WRAP (no CLAMP/MIRROR) and `1<<mask != tex_width`,
fold the UV at the N64 period `1<<mask`, then clamp the `[dim, 1<<mask)` overflow
band (no real texels — a TMEM smear on console) to the last texel. All behind the
existing `Video.WrapFix` knob (`GE_WRAPFIX` env overrides it for testing).
**Captures** (`-level_09/-30/-34/-20`, WrapFix OFF vs ON, `GE_PCDUMP` 6-frame
windows): no crashes, 6/6 frames each; Silo ~pixel-identical (phash 0–11),
Facility 180–260 pixel-identical, Depot shows small localized texel changes on
ceiling/wall cells (dmean 5–9, no structural break). Large per-run frame deltas
are all D117 intro-camera-pan nondeterminism.
**Default kept OFF** — no regression, but a headless structural diff can't confirm
the Depot ceiling looks *better*. To finish RC3: run Depot with `Video.WrapFix=1`
and eyeball the corrugated roof / repeating wall panels vs default; flip the
default in `port/src/video.c` (`cfgWrapFix = 1`) if clean. The overflow-band clamp
is an approximation of console TMEM-smear behaviour, not an exact emulation.
## 7. Artifacts (in `<local-path>`, 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) |
+246
View File
@@ -0,0 +1,246 @@
# Agentic development: goal, method, timeline, assessment
## Why this project exists
The playable port is real, but it is not the primary deliverable. The goal was
to **test how well coding agents hold up on a large, unfamiliar, low-level
codebase** — one with none of the properties that make web-app work easy for
an LLM:
- ~230 translation units of decompiled Nintendo 64 game C, compiled
**unmodified**;
- big-endian, 32-bit, MIPS ABI assumptions throughout, run on a little-endian
64-bit host;
- bugs that surface as a fault or a garbled frame hundreds of milliseconds
after the actual cause, often in a different subsystem;
- a graphics coprocessor (the RSP) that has to be emulated in software before
anything draws at all.
Concretely it set out to validate a **two-agent arrangement**: a
locally-hosted open-weight model doing the bulk of the work on a single
consumer GPU, a hosted frontier model brought in for a collaborative phase,
and — the part that turned out most interesting — the two **handing work back
and forth** through shared written artifacts, directed by one human.
## The setup
| | |
|---|---|
| Local agent | `unsloth/Qwen3.8-27B-GGUF:UD-Q4_K_XL` on a single **NVIDIA RTX 5090**, driven mainly through the **[pi](https://pi.dev/)** coding agent. Unsloth Desktop (a newer local agentic-coding harness) was also trialed but not used significantly. |
| Hosted agent | **Claude**, via **Claude Code**, on a Claude Pro subscription — mostly **Sonnet 5**, with **Opus 5** used as an escalation tier for the hardest problems and whenever there was subscription budget to spend on it |
| Human | one person: direction, work partitioning, integration, and every build / playtest / frame-capture the agents could not run |
| Base | fork of the [GoldenEye 007 decompilation](https://github.com/n64decomp/007) (years of prior work by Larry Ficken / "KholdFuzion" and contributors) |
| Reused | the [Perfect Dark PC port](https://github.com/fgsfdsfgs/perfect_dark)'s `fast3d` software RSP — same Rare engine family |
The port is the GoldenEye-specific porting work **on top of** those two
existing bodies of work; it is not a from-scratch reimplementation of either.
In practice the models formed a **three-tier escalation**: the local model
handled well-scoped work, Sonnet 5 took what it stalled on, and Opus 5 was
reserved for the bugs that needed the most reasoning held at once — the same
"escalate when stuck" move applied at every level.
## Timeline
All dates from this repository's own commit history (August 2026).
```mermaid
gantt
dateFormat YYYY-MM-DD
axisFormat %b %d
title GoldenEye 007 PC port — from fork to playable front end
section Local model - Qwen 3.8 via pi
Fork + PC-port scaffolding :m1, 2026-08-16, 1d
Full compile + link (~230 TUs) :m2, 2026-08-20, 1d
Boot to window (ROM map + SDL2) :m3, 2026-08-20, 1d
OS shims, threads, fast3d integration :2026-08-21, 2d
First rendered frames :m4, 2026-08-22, 1d
Offline asset-conversion pipeline :2026-08-22, 2d
Entire intro renders (logos->cast) :m5, 2026-08-24, 1d
section Both agents - handoff workflow
Claude joins :milestone, 2026-08-27, 0d
Stage load; Bunker 1 renders + firefight :2026-08-27, 2d
21 solo levels: load + render + no-crash :m6, 2026-08-29, 1d
SDL input layer (kbd/mouse/gamepad) :2026-08-29, 1d
Front-end flow menu->briefing->start :m7, 2026-08-30, 1d
File-backed EEPROM saves :2026-08-31, 1d
```
- **Day 0** (16 Aug): repository forked, PC-port scaffolding added.
- **Day 4** (20 Aug): the entire ~230-TU game + libultra set compiles and
links as a host binary.
- **Day 6** (22 Aug): first real frames render.
- **Day 8** (24 Aug): the whole intro sequence renders — logos, gun-barrel,
cast roll.
- **Day 11** (27 Aug): second agent joins.
- **Day 13** (29 Aug): **all 21 solo missions load, render, and survive an
unattended play window without crashing.**
- **Day 14** (30–31 Aug): front end playable end to end (menu -> mission
select -> difficulty -> briefing -> start); file-backed saves.
So roughly **two weeks**, one person part-time, to take a decompilation from
"builds an N64 ROM" to "boots on desktop, renders every solo level, playable
through the front end into the early game" — with audio and a set of cosmetic
issues still outstanding.
We are not aware of a prior Nintendo 64 native PC port developed primarily
through coding agents, or of one reaching this state on this timeline. That is
a statement about how little precedent there is, not a claim of a record.
## By the numbers
| | |
|---|---|
| Calendar time | 16 days (16 Aug – 1 Sep 2026), one person part-time |
| Commits on the port | 217 |
| Root-caused bugs logged | 160 (`D1`–`D167` in [`findings.md`](findings.md)) |
| Handoff sessions | ~30 (`M-2` … `M-32`) |
| Game-source files given `#ifdef PORT` ABI edits | 63 files, 241 blocks |
| New port-layer / tooling files | 128 |
| Port layer | ~17,000 lines C/C++ (`port/`) |
| PC asset-conversion tooling | ~6,300 lines Python (`tools_pc/`) |
| Outcome | intro + all 21 solo missions render, front end playable; audio not yet done |
### Commit velocity
```mermaid
xychart-beta
title "Commits per day"
x-axis ["8/16", "8/17", "8/20", "8/21", "8/22", "8/23", "8/24", "8/27", "8/28", "8/29", "8/30", "8/31", "9/1"]
y-axis "commits" 0 --> 60
bar [1, 2, 2, 6, 9, 2, 4, 7, 57, 39, 39, 46, 3]
```
The 8/25–8/26 gap is between phases; the jump on 8/28 is the second agent
joining, plus the parallel multi-agent "bursts" used near the end.
### Who did what
```mermaid
pie showData title "Commits by phase"
"Phase A - local model, solo (16-24 Aug)" : 26
"Phase B - collaborative (27 Aug-1 Sep)" : 191
```
| Milestone / workstream | Primary agent | Weight |
|---|---|---|
| PC build system, region macros, full compile + link of ~230 units | local model | large |
| Boot chain: ROM map, OS-shim layer (`libultra.c`), host threads, dual-mapped DRAM | local model | large |
| Software-RSP integration + replacement scheduler (`gesched.c`) | local model | medium |
| Offline asset-conversion architecture + first converters (`tools_pc/`) | local model | large |
| Intro rendering (logos → gun-barrel → cast) | local model | medium |
| Stage load unblocked (`D69`–`D87`) | Claude | medium |
| 21-level crash sweep — ~12 crash classes root-caused (`D88`–`D167`) | Claude | large |
| SDL input layer (keyboard / mouse / gamepad, mouse-look) | Claude | medium |
| Front-end flow (menu → briefing → start), EEPROM saves | Claude | medium |
| Parallel struct-layout / converter static audits | local model | medium |
| Continuing tasks after Claude hit a usage limit | local model | small |
| The hardest structural bugs (matrix handedness, format specs) | Claude | — |
| Every build, playtest, frame capture, integration, and direction | human | — |
**Estimated effort split.** By raw commit count it is ~28% local model /
~72% Claude (Phase A entirely local, ~15–20% of Phase B local). Weighting by
milestone difficulty rather than commit count — the foundation the local model
built is a heavier third of the project than its commit count suggests — the
developer's estimate is roughly:
```mermaid
pie showData title "Estimated agent effort (milestone-weighted)"
"Claude" : 60
"Local model (Qwen 3.8 / pi)" : 40
```
The local model's ~40% is front-loaded and foundational (the build, the boot
chain, the RSP wiring, the converter architecture) plus continuous parallel
support; Claude's ~60% is the higher-volume Phase-B debugging, the two big
discrete features, and the hardest structural bugs. A 27B open-weight model on
one consumer GPU carrying the entire foundation of a project like this is the
result worth taking away.
## The handoff workflow
This is the part worth paying attention to.
```mermaid
flowchart LR
H[Human: direction, integration,<br/>build + playtest verification]
C[Claude / Claude Code<br/>frontier, hosted]
Q["Qwen 3.8 via pi<br/>open-weight, local RTX 5090"]
D[(Shared artifacts:<br/>HANDOFF.md · findings.md · porting-notes.md)]
H -->|scopes task, budget, files| C
H -->|scopes task, budget, files| Q
C <-->|reads / appends| D
Q <-->|reads / appends| D
C -.->|usage limit reached ->| Q
Q -.->|hard structural bug ->| C
C -->|patch + writeup| H
Q -->|patch + writeup| H
```
Both agents worked against the **same three written artifacts**, which is what
let them substitute for each other:
1. **`HANDOFF.md`** — the current state, the immediate next task, and the
environment gotchas. Originally a session-to-session note for one agent, it
became the **interface between the two agents**: when Claude reached a
usage limit mid-problem, the local model picked the task up from the
HANDOFF state and continued; when the local model hit a bug that needed
deeper structural reasoning, it wrote up where it was and Claude took over.
2. **`findings.md`** — the chronological finding log. 160 numbered entries,
each a root cause with `file:line` evidence and the fix. New agents (either
model) are pointed at the relevant entries before they start.
3. **`porting-notes.md`** — the append-only "recurring bug classes" file. The
single highest-leverage artifact: it stopped both models from
re-deriving the same class of N64->PC bug over and over.
Around these, the working rules (full detail in
[`../dev-process.md`](../dev-process.md)):
- **file-partitioned tasks** — each agent's task scoped to a disjoint set of
files so patches never collided;
- **visible budgets** — every investigation task carried an explicit
"N build->run cycles" limit and a defined fallback (revert probes, write up
with a confidence rating);
- **the human owns verification** — building, running the game, capturing a
frame, and judging it against N64 reference footage was never delegated.
## Assessment
Honest notes, for anyone weighing whether this transfers.
**Worked well**
- The **local model carried the groundwork phase** — build system, boot
chain, OS shims, and the offline-converter architecture. None of it was
rewritten later. A 27B open model on one consumer GPU was genuinely productive
on this.
- **The written-artifact discipline made agent output compound.** An agent
starting cold late in the project was more effective than one early on,
because the accumulated notes were good. This is also what made the two
models interchangeable on a given task.
- **Handoff on limit** turned Claude's usage cap from a hard stop into a
slowdown — the local model kept the problem moving.
- Bounded behavioural bugs (a truncated pointer, a byte-swap off by one) are a
good fit for an agent given a tight loop and a reference to diff against.
**Worked poorly / needed the human**
- **Anything requiring the running game.** Build, playtest, capture a frame,
decide whether it looks right — that loop was the human's job throughout,
and it was the bottleneck.
- **Non-deterministic bugs.** Frame-timing stalls and concurrent-build
flakiness repeatedly fooled agents into "fixing" regressions that were not
real. A number of findings are corrections of earlier findings.
- **Long structural bugs** (matrix handedness, a whole serialized-format
spec) often ended in a "here is what I know, confidence medium" writeup
rather than a fix, even with the budget raised.
- **The capability gap is a gradient, not a wall.** The local model was strong
on well-scoped work and weaker when a bug needed several interacting facts
held at once — those went to Sonnet 5, and the few that stalled Sonnet went
to Opus 5. Each tier earned its place on the problems the tier below it
couldn't close.
**Still outstanding** (parked below crash/level work): audio is not
implemented; several front-end 3D transforms and some text rendering are
wrong. See [`GRAPHICS-BACKLOG.md`](GRAPHICS-BACKLOG.md).
+4693
View File
File diff suppressed because it is too large. Load diff