diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..76fa8ee9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,61 @@ +# AGENTS.md — GoldenEye 007 PC Port + +## What this is + +- `n64decomp/007`: WIP decompilation of GoldenEye 007 (N64), byte-matches US/EU/JP ROMs. +- Active work: **PC port** modelled on the Perfect Dark PC port (same Rare "Indy" engine family). +- **Reference docs:** `docs/internals.md` — architecture, GE-specific RSP deltas, phased plan (§1–§10). `docs/dev/findings.md` — the `Dxx` finding log (§F + §H, indexed at the top of §F); read the entry you need, don't linear-read. `docs/porting-notes.md` — the recurring N64→PC bug classes, one screen. +- **Current status:** the README "Status" section. Current task + environment: `docs/HANDOFF.md` (a rolling local working file). +- **Dispatching subagents?** `docs/dev-process.md` — task budgets/deadlines, file partitioning, pre-flight, the standard brief template. Every investigation subagent reads `docs/porting-notes.md` first and appends to it. + +## Non-negotiables + +1. **N64 build untouched.** `Makefile`, `tools/`, `rsp/`, `ld/` belong to the N64 build. Never modify them for the PC port. +2. **Game logic is unmodified.** The decomp's control flow and behavior are ground truth for 1:1 fidelity — never change them. All N64 *hardware* dependencies are satisfied by the `port/` layer; if a game file seems to need a behavioral change, stop — the fix belongs in `port/`. **Narrow exception (ABI/layout only):** the 32→64-bit transition forces a small class of mechanical, semantics-preserving edits that cannot be isolated in `port/` — chiefly pointer-width reconciliation in ROM-serialized structs (a struct with a 32-bit-pointer field misaligns when read as 64-bit). These follow the PD ground-truth pattern (store the embedded address as `u32`, cast to a real pointer at the use site), change no logic or behavior, and are each documented in `docs/dev/findings.md` §F/D3x. No other game-code edits are permitted. +3. **Region macros mirror the Makefile.** `CMakeLists.txt` `REGION_DEFS` must match the N64 Makefile's per-region macro set exactly (finding A1). Divergence = silent branch divergence + link failures. +4. **`src/libultrare/Makefile.libultrare` is ground truth** for original-vs-Rare libultra files (finding B3). The PC build compiles: `libultra/audio`, `libultrare/audio` (drvrNew/env/reverb), `libultra/gu`, and `libultrare/io/vitbl.c` only. All other `io/` + `os/` files are excluded and shimmed in `port/src/libultra.c`. +5. **`rsp/graphics/gmain.s` is the RSP ground truth** — the authoritative reference for which GBI commands GE emits (modified fast3d, 1545 lines). We do not run it on PC; `port/fast3d/` replaces it. Use it to validate the software RSP's command decoding and the custom CC/RM modes. + +## Critical files + +| File | Role | +|---|---| +| `docs/internals.md` | Architecture + RSP deltas + phased plan (§1–§10). Reference, not a linear read. | +| `docs/dev/findings.md` | The `Dxx` finding log (§F/§H, indexed at top of §F). | +| `CMakeLists.txt` | PC build (parallel to the N64 Makefile). Source list + `REGION_DEFS` live here. | +| `port/src/` | Shims: `libultra.c` (OS API), `gesched.c` (scheduler), `n64stubs.c` (boot/TLB/FPU/rmon), `random.c` (PRNG ported verbatim from `random.s`), `ucode.c` (microcode segment markers), `main.c`, `video.c`, … | +| `port/fast3d/` | Software RSP (adapted from the PD port). The main Phase 2 work. | +| `rsp/graphics/gmain.s` | GE's RSP ucode — ground truth for GBI/CC/RM. | +| A local **Perfect Dark PC port** checkout ([fgsfdsfgs/perfect_dark](https://github.com/fgsfdsfgs/perfect_dark)) | **Standing reference** — consult it whenever a work item has a PD analogue (same Rare engine family): port-layer ground truth (`port/fast3d/`, crash/system/video), plus copy candidates `port/src/preprocess/` (N64→PC asset conversion; `filemodel.c` is the D43 near-analogue) and `mixer.c`/`input.c`/`fs.c`. Port-layer files only; same family ≠ identical format — validate per field. Full audit: `docs/internals.md` §2.4. | + +## Build + +```sh +./build-pc.sh ntsc-final # or pal-final / jpn-final +``` + +Needs CMake + SDL2 + zlib + OpenGL, and must run from the MSYS2 MINGW64 shell +(see `build-pc.sh` header and `docs/building.md`). ROM goes in `./data/` +(not distributed); assets must be extracted from it first (`docs/building.md`). + +## Verification ritual (after any build-affecting change) + +1. **Undefined symbols.** Every symbol referenced by the compiled set (see `CMakeLists.txt`: `SRC_GAME`, `SRC_ENGINE`, `SRC_LIBAUDIO`, `SRC_LIBULTRARE_AUDIO`, `SRC_LIBULTRARE_DATA`, `SRC_GU`, `SRC_PORT*`) must be defined exactly once in the compiled set or in `port/`. Symbols that live in EXCLUDED files (`libultra/io/*`, `libultrare/io/*` except `vitbl.c`, `libultra/os/*`, `libultrare/os/*`, `sched.c`, `rmon.c`, `vi.c`, `src/*.s`) must be provided by `port/src/libultra.c`, `gesched.c`, `n64stubs.c`, `random.c`, or `ucode.c`. +2. **Duplicates.** No symbol defined twice across the compiled set (watch `sp_*` stacks, `rmon*`, `os*` shims, segment markers). +3. **Syntax.** Every touched file must parse; `./build-pc.sh` is the final word. + +Run `/linkcheck` for this sweep. Record new findings in `docs/dev/findings.md` §F/§H style (next `Dxx` label after the last used) and add the label to the §F index. + +## Phase status (summary — see README + `docs/dev/findings.md` for detail) + +- **Phase 0–1.5:** done. Build system, boot chain, OS-shim layer, fast3d + integration, first frames, full intro rendering. +- **Phase 2 (rendering):** in progress. All 21 solo levels load + render + + survive an unattended window; front end (menu → mission select → briefing → + start) is functional; file-backed EEPROM saves work. Cosmetic defects are + parked in `docs/dev/GRAPHICS-BACKLOG.md`. +- **Phase 3 (audio + input):** input layer done (`port/src/input.c`); polish + bugs open (D118*, D169). Audio not started — libaudio → SDL, copy-and-adapt + from the PD port's `mixer.c`. +- **Phase 4 (saves + polish):** file-backed EEPROM done; widescreen, config, + rebinding UI outstanding. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..078dadb9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,66 @@ +# CLAUDE.md — GoldenEye 007 PC port + +This file auto-loads every session. It is the operating manual. The full +rules are in `AGENTS.md` (imported below); this file adds the two +checklists that were being skipped. + +@AGENTS.md + +## Doc load order — read deliberately, not everything + +Context is scarce. Load by tier; do not blind-read whole files. + +- **Tier 0 — always (auto-loaded):** `CLAUDE.md`, `AGENTS.md`. Nothing + else auto-loads. +- **Tier 1 — every session start, read fully (both are short):** + `docs/HANDOFF.md` (current state + next task + environment — a rolling + local working file; may be absent in a fresh clone, in which case read + the README "Status" section instead), + `docs/porting-notes.md` (recurring bug classes). +- **Tier 2 — on demand only, do NOT read start-to-finish:** + - `docs/internals.md` — architecture / RSP deltas / phased plan (§1–§10 + of the old research doc). Read the section you need. + - `docs/dev/findings.md` — the `Dxx` finding log (§11 + §F + §H). + **Jump to a specific `Dxx` via the index at the top of §F.** Never + linear-read. + - `docs/dev/HANDOFF-ARCHIVE.md` — prior-session narrative (M-2…). Only + when tracing the history of one fix. + - `docs/dev-process.md` — before dispatching a subagent (also Tier 1 + if you may dispatch this session). + +## Session preflight — do this in your FIRST few tool calls, before acting + +Having a concrete "next step" from the handoff is NOT license to skip this. + +1. Read the Tier 1 docs: `docs/HANDOFF.md` (immediate task, standing + procedure, environment gotchas) and `docs/porting-notes.md` + (recurring bug classes — you will re-derive catalogued bugs if you don't). +2. Skim the README "Status" section for where the project stands. +3. If you may dispatch a subagent this session, read `docs/dev-process.md` + NOW — not at dispatch time. + +## Dispatch preflight — EVERY Agent/Task spawn, no exceptions + +A subagent brief is INVALID and must not be sent unless it contains all of: + +- **FILES YOU MAY TOUCH** — disjoint from every other running agent. +- **BUDGET** — `<= N build->run->inspect cycles` or `~M min`. Tight for + cheap-class bugs (truncations, off-by-ones); more room for structural ones + (matrix handedness, format specs). Escalate the default as the project hardens. +- **ON EXPIRY** — stop, revert temp probes, write up what you have in + `docs/dev/findings.md` §F with an explicit confidence rating. A good + write-up of a half-solved bug is a deliverable, not a failure. +- **KNOWN-GOOD / RULED OUT**, **CONSTRAINTS** (no game-logic changes; + ABI/layout/format only; `#ifdef PORT`; documented in §F), **REPORT** shape. +- Instruction to read `docs/porting-notes.md` first and append any + generalisable quirk to it. + +Full template: `docs/dev-process.md` -> "Investigation-brief template". + +## Verification cadence (integrator / overseer) + +- **Per merged patch:** single-frame `GE_PCDUMP` capture diffed against the + committed golden baseline. Cheap. Do NOT gate every patch on a long soak. +- **Per batch / integration checkpoint:** one 60s `-level_09` crash-free run. +- Subagents on small disjoint subsystems (input, audio, saves, converters) + don't run the game — the overseer verifies. diff --git a/assets/animationtable_data.h b/assets/animationtable_data.h index 4abf6703..45a6c3dd 100644 --- a/assets/animationtable_data.h +++ b/assets/animationtable_data.h @@ -198,7 +198,7 @@ // as the defines above, which is to index into ptr_animation_table. #if defined(__x86_64__) -/* D34 (docs/PCPortResearch.md): on N64 this segment links at VMA 0 +/* D34 (docs/dev/findings.md): on N64 this segment links at VMA 0 * (ge007.ld), so &ANIM_DATA_x is the record's offset within the * animation_data segment and game code re-bases it with * `(s32)&ANIM_DATA_x + ptr_animation_table`. A PE image cannot be placed diff --git a/assets/obseg/setup/e/UsetuplenZ.c b/assets/obseg/setup/e/UsetuplenZ.c index 3bb4a7ac..28496e2b 100644 --- a/assets/obseg/setup/e/UsetuplenZ.c +++ b/assets/obseg/setup/e/UsetuplenZ.c @@ -770,7 +770,7 @@ s32 intro[] = { _mkword(0, _mkshort(0, 1)), 0, -1, 0, /* Type = Credits; index = 3 */ #if defined(PORT) - /* PC port (docs/PCPortResearch.md §11): pointer-in-word static initializer + /* PC port (docs/dev/findings.md §11): pointer-in-word static initializer * is a hard error on the 64-bit host GCC; the real value comes from ROM * data loading in Phase 2. */ _mkword(0, _mkshort(0, 8)), 0, diff --git a/assets/obseg/setup/j/UsetuplenZ.c b/assets/obseg/setup/j/UsetuplenZ.c index 88c5060a..6070f3b6 100644 --- a/assets/obseg/setup/j/UsetuplenZ.c +++ b/assets/obseg/setup/j/UsetuplenZ.c @@ -773,7 +773,7 @@ s32 intro[] = { _mkword(0, _mkshort(0, 1)), 0, -1, 0, /* Type = Credits; index = 3 */ #if defined(PORT) - /* PC port (docs/PCPortResearch.md §11): pointer-in-word static initializer + /* PC port (docs/dev/findings.md §11): pointer-in-word static initializer * is a hard error on the 64-bit host GCC; the real value comes from ROM * data loading in Phase 2. */ _mkword(0, _mkshort(0, 8)), 0, diff --git a/assets/obseg/setup/u/UsetuplenZ.c b/assets/obseg/setup/u/UsetuplenZ.c index 15367fc3..1cd73466 100644 --- a/assets/obseg/setup/u/UsetuplenZ.c +++ b/assets/obseg/setup/u/UsetuplenZ.c @@ -717,7 +717,7 @@ s32 intro[] = { _mkword(0, _mkshort(0, 1)), 0, -1, 0, /* Type = Credits; index = 3 */ #if defined(PORT) - /* PC port (docs/PCPortResearch.md §11): pointer-in-word static initializer + /* PC port (docs/dev/findings.md §11): pointer-in-word static initializer * is a hard error on the 64-bit host GCC; the real value comes from ROM * data loading in Phase 2. */ _mkword(0, _mkshort(0, 8)), 0, diff --git a/docs/dev/HANDOFF-ARCHIVE.md b/docs/dev/HANDOFF-ARCHIVE.md index da394cd1..cb51a5bb 100644 --- a/docs/dev/HANDOFF-ARCHIVE.md +++ b/docs/dev/HANDOFF-ARCHIVE.md @@ -81,7 +81,7 @@ 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` +rendering (D74/D75/D76/D114/D116) is logged in `docs/dev/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 @@ -107,13 +107,13 @@ transition, then the next stage. 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 + `docs/dev/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`; + wrap-block, D75/D76/D77) now tracked in `docs/dev/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 diff --git a/docs/dev/LEVEL-PLAYTEST.md b/docs/dev/LEVEL-PLAYTEST.md index 93d28db9..92754c3b 100644 --- a/docs/dev/LEVEL-PLAYTEST.md +++ b/docs/dev/LEVEL-PLAYTEST.md @@ -1,6 +1,6 @@ # LEVEL-PLAYTEST — WS6 human completion-validation checklist -Run **after** `docs/LEVEL-STATUS.md` is all-21 load+render PASS. This is +Run **after** `docs/dev/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). diff --git a/docs/dev/LEVEL-STATUS.md b/docs/dev/LEVEL-STATUS.md index dc29bfd5..4fde272c 100644 --- a/docs/dev/LEVEL-STATUS.md +++ b/docs/dev/LEVEL-STATUS.md @@ -27,7 +27,7 @@ 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). +an idle box, count N runs before believing it (porting-notes.md §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" @@ -45,7 +45,7 @@ cause. Only remaining crash: 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`). + renders 300+ frames fine. Parked with D76 (`docs/dev/GRAPHICS-BACKLOG.md`). | Level | # | Status | Crash site | Class | |---|---|---|---|---| @@ -117,7 +117,7 @@ Dam/Frigate/Statue/Cradle now PASS; Runway/Streets fall through to C2. 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 +Pointer-width / BE-rodata family (porting-notes.md §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 @@ -150,7 +150,7 @@ a dedicated pass. Files: `src/game/objecthandler_2.c`, `src/game/tex.c` `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 +Full write-up + next-step probe plan: findings.md §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). @@ -224,7 +224,7 @@ audio lands. Files: `src/snd.c`, `port/src/` audio stubs. 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. +Hand `docs/dev/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`). diff --git a/docs/dev/TEXTURE-GLITCH-ANALYSIS.md b/docs/dev/TEXTURE-GLITCH-ANALYSIS.md index 74701710..82ae0677 100644 --- a/docs/dev/TEXTURE-GLITCH-ANALYSIS.md +++ b/docs/dev/TEXTURE-GLITCH-ANALYSIS.md @@ -25,7 +25,7 @@ since been fixed or retracted. Authoritative current state: | **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 | +| **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, porting-notes.md §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 | @@ -95,7 +95,7 @@ against this table. 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`). + `docs/dev/GRAPHICS-BACKLOG.md`). ### RC4 — Palette off-by-one — **RETRACTED (M-30): the current code is correct.** diff --git a/docs/dev/findings.md b/docs/dev/findings.md index 186aa76b..a547ccc4 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -386,17 +386,17 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D161 | Depot (`-level_30`) ceiling = bright-blue speckle + radial rays (B2). A CI8 tile drawn with `gsDPSetTextureLUT(G_TT_NONE)` was decoded against the stale `rdp.palette` → garbage. Fix: `#ifdef`-free narrow route CI→I when `palette_fmt == G_TT_NONE` in `gfx_pc.cpp import_texture()`. | FIXED (`port/fast3d/gfx_pc.cpp`) | | D165 · D166 | input-layer polish (M-31, port-only, `port/src/input.c`): D165 front-end cursor is now a true ~1:1 pointer (P-controller estimating the game cursor via front.c's own integrator) instead of velocity²; D166 hipfire mouse pitch emits speed-proportional C-button pulses instead of a fixed digital threshold. | FIXED | | D152+ | D152 addendum (M-31): static audit of every compiled-audio `osSetIntMask(OS_IM_NONE)` — **all balanced**, no unbalanced early-return. Real fixes: `sndSetSfxSlotVolume` now holds the mask across its list walk (matches its twin `sndDeactivateAllSfxByFlag`) + `sndApplyVolumeAllSfxSlot` batches the slot loop under one recursive hold (kills the fade-out lock-acquire storm); `portThreadWrapper` calls `imThreadExitRelease()` on thread exit (kills the "transient thread acquired `OS_IM_NONE` and died" leak + the pthread-id-reuse re-wedge). Steal-lock kept as backstop. | FIXED (`src/snd.c`, `port/src/libultra.c`, `#ifdef PORT`) — fade-out repro playtest-gated, not headless-verified | -| RC3 · D167 | **Non-power-of-two texture wrap period (`docs/TEXTURE-GLITCH-ANALYSIS.md` §6 RC3 — "textures repeat oddly", residual Depot-ceiling noise after D161).** The N64 RDP masks the texel coordinate of a wrapping render tile at `1<Index1` reads `Index2`'s value, `Index2` reads 0 → switch-doors / dual-weapons / locked-doors / safes silently fail their validity guard (non-crash; guard failure also prevents the under-sized-record `->next` overflow). | proposed fix (not applied) — see below | -| D157 | **Campaign never unlocks the next level (M-30, user bug — "complete Dam on Agent, Facility stays locked, no save").** The type-23 objective record's `MinDificulty` is a big-endian `s32` at word 3; on N64 the 0–3 value sits in its low byte == offset `0xF`, and `struct objective_entry.difficulty` (`bondtypes.h`) reads `s8` @0xF. `d88_propdefs.py` `_bswap32`'s that word (correct — it IS a 32-bit field), moving the value to offset `0xC` on LE → the `s8`@0xF read yields **0 for every objective**. `get_difficulty_for_objective()` → Agent(0) for all → `objectiveIsAllComplete()` on Agent evaluates objectives that should be difficulty-gated out (Dam "Neutralize all alarms" = Secret Agent, "Install covert modem" = 00 Agent) → they're never complete → `bossReturnTitleStage` sees `objectiveIsAllComplete=0` → `end_of_mission_briefing()` (the `fileUnlockStageInFolderAtDifficulty` → EEPROM write) never runs. The D151 sibling — same struct, the field D151 said it "left unchanged". Confirmed via GE_SAVELOG playtest + a direct ROM dump of Dam's records (word 3 = 1/2/2/0). | FIXED (`src/bondtypes.h`, `#ifdef PORT` — reorder `objective_entry`'s post-swap tail so `difficulty` reads @0xC, `unkD` @0xE; N64 layout under `#else`; sizeof unchanged 16). Also fixed `tools_pc/dump_objectives.py` (same byte-offset bug — read `buf[off+12]` = BE high byte = always 0 → showed "Agent" for every objective) + regenerated `docs/LEVEL-OBJECTIVES.md`. **User-confirmed:** Dam Agent → `objdiff=1/2/2/0` → `objectiveIsAllComplete=1` → `fileWriteSave` with the completion time; Agent checkmark shows in mission-select and persists across kill+restart. New `GE_SAVELOG=1` / `GE_UNLOCK_ALL=1` `#ifdef PORT` env diagnostics added along the way. 21/21 sweep PASS. PORT-LEARNINGS §C. | +| D157 | **Campaign never unlocks the next level (M-30, user bug — "complete Dam on Agent, Facility stays locked, no save").** The type-23 objective record's `MinDificulty` is a big-endian `s32` at word 3; on N64 the 0–3 value sits in its low byte == offset `0xF`, and `struct objective_entry.difficulty` (`bondtypes.h`) reads `s8` @0xF. `d88_propdefs.py` `_bswap32`'s that word (correct — it IS a 32-bit field), moving the value to offset `0xC` on LE → the `s8`@0xF read yields **0 for every objective**. `get_difficulty_for_objective()` → Agent(0) for all → `objectiveIsAllComplete()` on Agent evaluates objectives that should be difficulty-gated out (Dam "Neutralize all alarms" = Secret Agent, "Install covert modem" = 00 Agent) → they're never complete → `bossReturnTitleStage` sees `objectiveIsAllComplete=0` → `end_of_mission_briefing()` (the `fileUnlockStageInFolderAtDifficulty` → EEPROM write) never runs. The D151 sibling — same struct, the field D151 said it "left unchanged". Confirmed via GE_SAVELOG playtest + a direct ROM dump of Dam's records (word 3 = 1/2/2/0). | FIXED (`src/bondtypes.h`, `#ifdef PORT` — reorder `objective_entry`'s post-swap tail so `difficulty` reads @0xC, `unkD` @0xE; N64 layout under `#else`; sizeof unchanged 16). Also fixed `tools_pc/dump_objectives.py` (same byte-offset bug — read `buf[off+12]` = BE high byte = always 0 → showed "Agent" for every objective) + regenerated `docs/dev/LEVEL-OBJECTIVES.md`. **User-confirmed:** Dam Agent → `objdiff=1/2/2/0` → `objectiveIsAllComplete=1` → `fileWriteSave` with the completion time; Agent checkmark shows in mission-select and persists across kill+restart. New `GE_SAVELOG=1` / `GE_UNLOCK_ALL=1` `#ifdef PORT` env diagnostics added along the way. 21/21 sweep PASS. porting-notes.md §C. | | D160 | **Dam level-end cutscene (Bond off the dam / bungee) never plays — cuts straight to the MISSION COMPLETE report (M-30/M-31, user bug D148).** Progression is unaffected: `bossReturnTitleStage` fires, the report + next briefing load, EEPROM save lands (D157). So the *transition* works; only the scripted cutscene step is skipped or silently fails. Static trace: Dam's end sequence is `UsetupdamZ` ailist `ai_24` (id 0x1004, gated `if_bond_in_room_with_pad(0x4a01)` — Bond reaches the exit) → lock controls, `bond_set_locked_velocity` (auto-walk off edge), wait `if_bond_y_pos_less_than` (Bond falling) → fade → `if_objective_all_completed` branch → `camera_switch`/`jump_to_ai_list` into the abseil ailist `ai_17` (id 0x0412: `screen_fade_from_black`, `music_xtrack_play`, `guard_play_animation(0xb100…)` = rappel anim on a puppet chr, three `camera_switch` w/ fades, `exit_level`). `camera_switch(TAG,…)` resolves a `CutsceneRecord` (propDef type 46, records 322/324/326 paired with Tags 10/11/12) via `tagGetCommandIndex` + `setupGetPtrToCommandByIndex` (a **command-index count walk over the propDef stream** using `sizepropdef()`). CutsceneRecord itself is all-scalar 28 B, correctly converted (`d88_propdefs.py` 46→28). **Leading hypotheses, unverified:** (a) the index-count walk desyncs because some earlier propDef type Dam emits (Vehicle 321 — D122 "medium confidence" tail; TintedGlass) has a wrong PC `sizepropdef` stride → `camera_switch` gets a bogus `cdef`/`TagIndex<0` → cutscene silently skipped, `ai_17` falls through to `exit_level`; (b) the trigger chr for `ai_24`/`ai_25` isn't spawned / `if_bond_in_room_with_pad` never true and the level ends via a different path; (c) D75/anim-model family — the puppet `guard_play_animation` rappel anim doesn't render (third-person Bond model absent, like the gun-barrel/cast models) so the cutscene "plays" invisibly but the user perceives it as skipped (less likely — user reports an instant cut, not a blank pause). | DIAGNOSTIC SHIPPED, not root-caused (`c95713f5`, `#ifdef PORT` + `GE_D160=1`): trace points in `bossReturnTitleStage` (entry + caller), `bondviewSetCameraMode` (mode/stage/`g_IntroSwirl`/intro-anim-idx/caller), and AI cmds `EndLevel`/`exit_level`, `CameraLookAtBondFromPad`, `CameraSwitch` (prints `cdef` ptr / `TagIndex<0` / tag `NOT FOUND`). **Next step:** user plays Dam to the exit with `GE_D160=1` (via `debug.ps1`); the trace answers (1) does `CameraSwitch`/`CameraLookAtBondFromPad` execute at all → trigger fires? (2) if yes, is `cdef` a sane pointer → propDef-walk / converter bug (a), same family as D122/D132/D157; (3) if the cam cmds never run → ai_24 trigger / chr-spawn bug (b). If it's (c), `bondviewSetCameraMode` shows POSEND being set + control returns normally. This is likely a WRITE-UP-then-fix (propDef stride) rather than a hack. Confidence in hypothesis (a): medium. | -| D161 | **Depot (`-level_30`) ceiling renders as bright-blue speckle + radial "light-ray" streaks converging to a point instead of a dark corrugated roof (M-30/M-31, user bug B2 — `docs/TEXTURE-GLITCH-ANALYSIS.md`).** NOT filtering (3-point+trilinear+aniso didn't touch it), NOT RC2 mip-contamination, NOT the RGBA5551 decode. Root-caused with a `GE_TEXDUMP` PPM+param dump (`gfx_opengl.cpp` / `gfx_pc.cpp`): the offending surface is **one texture, `fmt=CI siz=8b, 16×16`**, uploaded with **`rdp.palette_fmt == G_TT_NONE` (0)** while every other CI texture that frame has `palfmt=0x8000` (`G_TT_RGBA16`). GE draws the Depot roof with the TLUT **disabled** (`gsDPSetTextureLUT(G_TT_NONE)` — a legit N64 idiom: a CI-siz tile with no LUT feeds the raw 8-bit TMEM texel straight into the colour pipe, i.e. it behaves as a plain **I8** texture). fast3d's `import_texture()` ignored `palette_fmt` and always did the palette lookup for `fmt==CI`, indexing a **stale `rdp.palette`** left over from a previous texture → the 16×16 image came out as pure blue/cyan/magenta noise; the "radial rays" were that high-frequency noise aliasing on the steeply-receding ceiling plane (which is also why filtering couldn't help — the data was garbage). Fix: in `import_texture()`, `fmt_eff = (fmt==G_IM_FMT_CI && rdp.palette_fmt==G_TT_NONE) ? G_IM_FMT_I : fmt`, and dispatch on `fmt_eff` (CI4→I4, CI8→I8). Narrow — only changes surfaces that are currently 100% garbage. Verified: Depot corridor + control-room ceilings render as clean dark/grey industrial roofs; `-level_09`/`-level_30`/`-level_34` sweep PASS, unregressed. `GE_TEXDUMP` (env-gated PPM dump of every uploaded texture + a `fmt/siz/palfmt/palidx` log line) kept as an inert diagnostic. Confidence: **high** (before/after screenshots + the palfmt=0 evidence). | FIXED (`port/fast3d/gfx_pc.cpp` `import_texture`, `port/fast3d/gfx_opengl.cpp` diag). PORT-LEARNINGS §D. | +| D161 | **Depot (`-level_30`) ceiling renders as bright-blue speckle + radial "light-ray" streaks converging to a point instead of a dark corrugated roof (M-30/M-31, user bug B2 — `docs/dev/TEXTURE-GLITCH-ANALYSIS.md`).** NOT filtering (3-point+trilinear+aniso didn't touch it), NOT RC2 mip-contamination, NOT the RGBA5551 decode. Root-caused with a `GE_TEXDUMP` PPM+param dump (`gfx_opengl.cpp` / `gfx_pc.cpp`): the offending surface is **one texture, `fmt=CI siz=8b, 16×16`**, uploaded with **`rdp.palette_fmt == G_TT_NONE` (0)** while every other CI texture that frame has `palfmt=0x8000` (`G_TT_RGBA16`). GE draws the Depot roof with the TLUT **disabled** (`gsDPSetTextureLUT(G_TT_NONE)` — a legit N64 idiom: a CI-siz tile with no LUT feeds the raw 8-bit TMEM texel straight into the colour pipe, i.e. it behaves as a plain **I8** texture). fast3d's `import_texture()` ignored `palette_fmt` and always did the palette lookup for `fmt==CI`, indexing a **stale `rdp.palette`** left over from a previous texture → the 16×16 image came out as pure blue/cyan/magenta noise; the "radial rays" were that high-frequency noise aliasing on the steeply-receding ceiling plane (which is also why filtering couldn't help — the data was garbage). Fix: in `import_texture()`, `fmt_eff = (fmt==G_IM_FMT_CI && rdp.palette_fmt==G_TT_NONE) ? G_IM_FMT_I : fmt`, and dispatch on `fmt_eff` (CI4→I4, CI8→I8). Narrow — only changes surfaces that are currently 100% garbage. Verified: Depot corridor + control-room ceilings render as clean dark/grey industrial roofs; `-level_09`/`-level_30`/`-level_34` sweep PASS, unregressed. `GE_TEXDUMP` (env-gated PPM dump of every uploaded texture + a `fmt/siz/palfmt/palidx` log line) kept as an inert diagnostic. Confidence: **high** (before/after screenshots + the palfmt=0 evidence). | FIXED (`port/fast3d/gfx_pc.cpp` `import_texture`, `port/fast3d/gfx_opengl.cpp` diag). porting-notes.md §D. | | D89–D92 | stage-load→frame: stan zero-fill overrun, portal address trunc, chr/AI spawn ptrs | resolved | | D93–D102 | struct-pun / hardcoded-size pass: player alloc, player.model inline Model, weapon Model pun, master-DL buffer | resolved (D95 pool bump partial) | | D103–D107 | BUNKER1 viewport height, depth-buffer clear (`G_CLEAR_DEPTH_EXT`), portal near-plane, LOD mip tile | resolved | @@ -419,22 +419,22 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D134 | **Frame-2 boot hang / sweep "NO-FRAMES" flakiness — root-caused and fixed (M-22).** `osSpTaskStartGo` (port/src/libultra.c) posted the SP/DP task-done events with `OS_MESG_NOBLOCK` into the scheduler's 8-slot `interruptQ`, which the 60 Hz VI pacemaker thread also fills with `VIDEO_MSG`. A gfx task runs the whole frame synchronously on the sched thread (fast3d); the first two frames take 30-80 ms, so the pacemaker queues several retraces meanwhile. Once the queue is full the RSP-done post is **silently dropped** -> `__scMain` never clears `sc->curRSPTask` -> the client never gets `OS_SC_DONE_MSG` -> `bossMainloop`'s `pendingGfx` never clears -> permanent stall (log: `frame 2 rendered`, then `kernel heartbeat ... frames=2`, retrace queue pinned at `valid=8/8 ret=-1`). Reproduced 2 of 3 `-level_09` boots on an idle machine, i.e. most of what M-13..M-21 wrote off as D117 nondeterminism / machine load. `OS_MESG_BLOCK` is NOT the fix (the done event is posted from the sched thread, the queue's only consumer -> self-deadlock): `portPostEventForce()` drops the OLDEST message (always a stale retrace) and retries, and `portPostVIEvent` now reserves 2 slots. Verified: 6/6 `-level_09` boots reach frame 600 with 0 heartbeats (pre-fix 1/3). | FIXED (`port/src/libultra.c`) | | D135 | **Firefight crash — `bgTestHitOnObj` (`propobj.c:8446`) is an unported N64 GBI parser (M-22, WS6 playtest).** The object bullet-hit test walks a model's display list command stream with hardcoded 8-byte-N64-`Gfx` byte/word indices (`*(s8*)gdl`, `((u32*)gdl)[1]`, `((u8*)gdl)[5..7]`, the G_TRI4 nibble reads, the backward `G_SETTIMG` scan). On PC the model GDL is the 16-byte `Gfx` layout (`d43_emit.py`, `{u64 w0; u64 w1}`), so every access reads the wrong bytes → the walk desyncs on the first `G_VTX`/`G_TRI1` → `v->coord.x` deref off the end of the DL → AV (`0x709ae02a`, a near-miss past a live DRAM ptr = the §B signature). Triggered by `propobjFindHit` every time a shot ray resolves a hit on an object model (guards' dropped weapons, attached objects, shootable props) — invisible to `level_sweep.sh` (no weapon fire). Two sibling faults seen the same session (`texSelect`/`texSetRenderMode` wild `gdl`, `objFreeEmbedmentOrProjectile` `prop->obj`) are likely the same corrupted-object cascade. PD `bgTestHitOnObj` (`pd_port/src/game/bg.c:3635`) is the fully-ported ground truth. | FIXED (`src/game/propobj.c`, `#ifdef PORT` — reads `gdl->words.w0/.w1` shifts). **Second fault, same walker:** after the parser fix the Facility firefight then hit `0xc0000094` (int div-by-zero) at `objHit` `propobj.c:9717` `randomGetNext() % impact_sounds->thing2_len` — the ported texnum-recovery (`*(s16*)(phys(w1) − 8)`) returned a bogus positive `texturenum` (`(u32)` cast truncated a 64-bit `words.w1`; the N64 "texnum 8 bytes before the texture data" layout isn't guaranteed for the port's converted/replaced GDLs) → `g_HitTypeSounds[texnum & 0xf]` picked an entry with `thing2_len == 0`. Fixed: the PORT texnum branch now always returns `-1` (→ `g_HitTypeSounds[0]` = `isnd_default`, `thing2_len == 1`, safe). `texturenum` only flavours the bullet-impact sound/decal, so generic-hit is an acceptable degradation (park with D77). **Follow-up:** the BG room-geometry sibling `sub_GAME_…` (`bg.c:~3373-3646`, walks the D85-widened `ptr_expanded_mapping_info`) has the identical unported parser pattern — latent, fires on shooting walls/floor; not yet ported. | | D136 | **Stage-unload crash — `objFree` `obj->prop` reads float garbage (M-22, WS6 Facility playthrough).** After D135 cleared the firefight crashes the level is completable; then on teardown `lvlUnloadStageTextData` → `cleanupObjects` (`cleanup_objects.c:42`) → `objFreePermanently` → `objFree` (`propobj.c:994`) → `objFreeEmbedmentOrProjectile(obj->prop)` faults at `:888` (`prop->obj` deref). Real gdb `bt`: `obj` = `0x701bd730` (valid) but `obj->prop` = `0x3e567400405c30c0` — two packed `f32` (~0.21, ~3.44), i.e. a `coord3d` read as a pointer → `ObjectRecord.prop` (N64 `/*0x10*/`, after `inherits PropDefHeaderRecord` + `s16 obj; s16 pad; u32 flags; u32 flags2;`) lands at the wrong PC offset for this object's propDef type, OR the record is undersized and setup's `->prop` store never happened / was clobbered. Same family as D122/D132 (`tools_pc/d88_propdefs.py` per-type record sizing vs the compiled `ObjectRecord`/subtype layout). Type not yet identified — needs a gdb run with a breakpoint on `objFree` to read `obj->type` (`cleanupObjects` frees WEAPON/AMMO/MAGAZINE/COLLECTABLE/MONITOR/RACK/AUTOGUN/HAT/ARMOUR/GAS_RELEASING/VEHICHLE/AIRCRAFT/GLASS/SAFE/TANK/TINTED_GLASS via this path). Mission completes; crash is before the next briefing loads. | ROOT-CAUSED → **D139** (it was NOT a layout bug — `cleanupObjects` read the type from the wrong byte on LE and walked off the propDef blob). | -| D139 | **Stage-unload crash root cause + fix (M-23).** `cleanupObjects` (`cleanup_objects.c`) walks `g_CurrentSetup.propDefs` with `(u8)obj[0]` as the record type — correct only because the header word `[u16 extrascale][u8 state][u8 type]` is **big-endian** on N64, so `type` is the low byte. On little-endian PC the low byte is `extrascale`, so `(u8)obj[0]` is never `PROPDEF_END` → the `while` runs off the end of the blob, and the `switch` dispatches on garbage → `objFreePermanently()` on records that aren't `ObjectRecord`s → `obj->prop`/`obj->model` read adjacent float data (`0x3e567400405c30c0`) → AV in `objFreeEmbedmentOrProjectile` (`propobj.c:888`). `sizepropdef()` and every other propDef consumer already use `pdef->type` (struct member, offset 3) — only this one spot used the raw byte cast. Not a `d88_propdefs.py` layout issue at all. | FIXED (`src/game/cleanup_objects.c`, `#ifdef PORT` — `CLEANUP_PDTYPE(o)` = `((PropDefHeaderRecord*)o)->type`). **UNVERIFIED** — the fast teardown test (pause→abort) hit D140 first; `-level_09` unregressed. PORT-LEARNINGS §C. | -| D140 | **Pause-menu crash (M-23 open → M-27 fixed).** `bondviewRenderWatch` (`bondview2.c:8604`) → `bondviewTransformManyPosToViewMatrix(g_CurrentPlayer->field_23C, objheader->numMatrices)` with `field_23C == NULL` → `matrix_4x4_copy(src=0x0)` AV. Root cause (NOT "cache never allocated"): `something_with_watch_object_instance` (N64 player +0x230) is a **`struct Model` (0xBC) + its RW-data pool (0xC8) punned into a 0x184-byte hole** (`field_234..field_3B0`); three of those "fields" are Model members the game reads by name — `watch_scale_destination`==`.scale`, `pause_watch_related_adjust`==`.animframe1`, **`field_23C`==`.render_pos`**. `render_pos` IS populated (by `instcalcmatrices` via `subcalcmatrices` at `:8524`), but on x86-64 `sizeof(struct Model)` grows (4→8B ptrs) so `field_23C` no longer overlaps `.render_pos` → reads Model padding (~0) → NULL. Same class as D100 (gait Model) / D102 (weapon Model) / D56 (this very watch Model's pool). Fix: `#ifdef PORT` makes `something_with_watch_object_instance` a real inline `struct Model` + `u32 watchRwPool[192]` in `struct player` (`bondview.h`); `bondview2.c` redirects the 3 named reads to the member via `GE_WATCH_{ANIMFRAME,SCALE,RENDERPOS}` macros (N64 `#else` = the verbatim field). The D56 branch of `sub_GAME_7F07E7CC` drops its `static u8 watchRwPool[0xC8]` for the inline pool. | FIXED (`src/game/bondview.h`, `src/game/bondview2.c`; `#ifdef PORT`). Exposed the next watch-render crash → **D141**. `-level_09`/`-level_20` unregressed (3/3). PORT-LEARNINGS §A. | +| D139 | **Stage-unload crash root cause + fix (M-23).** `cleanupObjects` (`cleanup_objects.c`) walks `g_CurrentSetup.propDefs` with `(u8)obj[0]` as the record type — correct only because the header word `[u16 extrascale][u8 state][u8 type]` is **big-endian** on N64, so `type` is the low byte. On little-endian PC the low byte is `extrascale`, so `(u8)obj[0]` is never `PROPDEF_END` → the `while` runs off the end of the blob, and the `switch` dispatches on garbage → `objFreePermanently()` on records that aren't `ObjectRecord`s → `obj->prop`/`obj->model` read adjacent float data (`0x3e567400405c30c0`) → AV in `objFreeEmbedmentOrProjectile` (`propobj.c:888`). `sizepropdef()` and every other propDef consumer already use `pdef->type` (struct member, offset 3) — only this one spot used the raw byte cast. Not a `d88_propdefs.py` layout issue at all. | FIXED (`src/game/cleanup_objects.c`, `#ifdef PORT` — `CLEANUP_PDTYPE(o)` = `((PropDefHeaderRecord*)o)->type`). **UNVERIFIED** — the fast teardown test (pause→abort) hit D140 first; `-level_09` unregressed. porting-notes.md §C. | +| D140 | **Pause-menu crash (M-23 open → M-27 fixed).** `bondviewRenderWatch` (`bondview2.c:8604`) → `bondviewTransformManyPosToViewMatrix(g_CurrentPlayer->field_23C, objheader->numMatrices)` with `field_23C == NULL` → `matrix_4x4_copy(src=0x0)` AV. Root cause (NOT "cache never allocated"): `something_with_watch_object_instance` (N64 player +0x230) is a **`struct Model` (0xBC) + its RW-data pool (0xC8) punned into a 0x184-byte hole** (`field_234..field_3B0`); three of those "fields" are Model members the game reads by name — `watch_scale_destination`==`.scale`, `pause_watch_related_adjust`==`.animframe1`, **`field_23C`==`.render_pos`**. `render_pos` IS populated (by `instcalcmatrices` via `subcalcmatrices` at `:8524`), but on x86-64 `sizeof(struct Model)` grows (4→8B ptrs) so `field_23C` no longer overlaps `.render_pos` → reads Model padding (~0) → NULL. Same class as D100 (gait Model) / D102 (weapon Model) / D56 (this very watch Model's pool). Fix: `#ifdef PORT` makes `something_with_watch_object_instance` a real inline `struct Model` + `u32 watchRwPool[192]` in `struct player` (`bondview.h`); `bondview2.c` redirects the 3 named reads to the member via `GE_WATCH_{ANIMFRAME,SCALE,RENDERPOS}` macros (N64 `#else` = the verbatim field). The D56 branch of `sub_GAME_7F07E7CC` drops its `static u8 watchRwPool[0xC8]` for the inline pool. | FIXED (`src/game/bondview.h`, `src/game/bondview2.c`; `#ifdef PORT`). Exposed the next watch-render crash → **D141**. `-level_09`/`-level_20` unregressed (3/3). porting-notes.md §A. | | D118d | **Watch-menu list over-scrolls with keyboard (M-27, user bug report).** `game_options_inventory_navigation` / `sub_GAME_7F0A611C` (`options.c`) have a "slam the stick" fast-scroll: a raw *level* check `joyGetStickY(PLAYER_1) < -0x46` / `>= 0x47` that steps `watch_inventory_cursor_pos` by 1 **every frame** it's held. An N64 stick rarely sustains past ~0x46 on-axis so a human taps it; keyboard W/S (and SDL pads pegged at 0x80) sit there every frame → one keypress skips several items (breaks item selection needed to finish the game). Fix: `#ifdef PORT` macro `GE_WATCH_STICK_FAST{UP,DOWN}` → `0` for the port — stick list-nav then goes only through the latched single-step path (`watch_stick_y_pressed_*`, one step per press, existing game mechanism) and the -0x1e..-0x45 smooth-scroll band; D-pad/C-buttons keep edge-repeat. N64 `#else` = verbatim. | FIXED (`src/game/options.c`, `#ifdef PORT`). Build green, `-level_09` unregressed. **Feel to be confirmed in playtest** (probe couldn't reach the INVENTORY watch page headlessly). D118 input family. Latent sibling: `front.c` menu nav uses `joyGetStick*InRange` level checks the same way. | -| D141 | **Watch-menu item-model crash (M-27), exposed by D140.** Pressing Start → watch page renders → `set_enviro_fog_for_items_in_solo_watch_menu` (`gunfire.c:1620`) → `modelGetNodeRwData(&model, root)` with `root` garbage → AV reading `root->Opcode` (`model.c:478`). `gunfire.c:1720-1743` walks `bodymodel->Switches[]` (a `ModelNode*` array) with **raw byte offsets** `((u8*)Switches) + j + 0x48` / `+ 0x5c` and `j += 4` — assumes 4-byte N64 pointers. `0x48/4=18`, `0x5c/4=23`, `k=j>>2=0..4` → intent is `Switches[18+k]` and `Switches[23+k]`. PC's 8-byte stride makes the raw math read misaligned garbage → bogus non-NULL `ModelNode*`. PORT-LEARNINGS §B (raw hardcoded stride into a pointer array, cf. D128). Fix: `#ifdef PORT` uses `bodymodel->Switches[18 + (j>>2)]` / `[23 + (j>>2)]`. | FIXED (`src/game/gunfire.c`, `#ifdef PORT`). Watch page now renders (weapon page verified — mirrored text D114/D116, dark weapon model D75, both parked cosmetic). `-level_09`/`-level_20` unregressed. | -| D150 | **Watch objective/briefing page crash (M-28).** Opening the watch → BRIEF (any subpage) or OBJECTIVES page → AV in `strcat` (`str.c:25`, `Rdi/src = 0`). The pages assemble their display text with `strcpy`/`strcat(buf, langGet(id))` (`options.c:3940-4068`, `front.c:3538-3562`, `propobj.c`). `langGet()` returns **NULL** on PC for a string bank the menu flow never loaded (D129/D143 guards — briefing/objective banks in the in-level watch flow), so `strcat(buf, NULL)` derefs NULL. On N64 those ids always resolve so the decomp never guards. Twist: `str.c`'s `strcpy/strncpy/strcat` are `__nonnull__` **builtins** to GCC, so a plain `if (src == NULL)` on the parameter is optimised away as provably-dead (verified: `-Og` elided it, no `test` in the disassembly). Fix: `#ifdef PORT` NULL guards that launder the pointer through an empty `__asm__` (`GE_IS_NULL()` / `ge_launder_ptr()`) so the check survives — NULL src → treat as empty string (blank text, not a crash), NULL dst → return. Same philosophy as the D143 textRender/textMeasure NULL guards, one layer down. Inert on N64. | FIXED (`src/str.c`, `#ifdef PORT`). Build green (`ntsc-final`); `-level_09` 600+ frames @ 91.67% unregressed. Watch-page repro is interactive — user to re-verify. PORT-LEARNINGS §C. | -| D152 | **Mission-failed / objective-failed → permanent black screen (M-28, user bug report — LIVE process inspected, not yet fixed).** User killed Trevelyan in Facility (Ctrl = fire, no crouch bind) → failed the objective → screen faded to black and never came back; process stayed alive under `debug.ps1`. `gdb.txt` (kept running): rendering stops dead at frame 12600 (`frames=12657` frozen across 206 heartbeat dumps / ~7 real min), VI pacemaker still posts. **Deadlock on `s_imLock`** — the D147 recursive-mutex that backs `osSetIntMask`. Both consumer threads are blocked in `pthread_mutex_lock(&s_imLock)` and neither owns it: `mainThread` = `sndSetScalerApplyVolumeAllSfxSlot` → `sndApplyVolumeAllSfxSlot` → `sndSetSfxSlotVolume` → `alEvtqPostEvent` (`event.c:82`) → `osSetIntMask(OS_IM_NONE)` (`libultra.c:1232`); `amMain`/audioThread = `amHandleFrameMessage` → `alAudioFrame` → `sndPlayerVoiceHandler` → `alEvtqNextEvent` (`event.c:47`) → `osSetIntMask(OS_IM_NONE)`. `sndSetScalerApplyVolumeAllSfxSlot` is the **mission-failed audio fade-out** (ramps `g_sndSfxVolumeScale` → 0 each frame, `AL_SNDP_RELEASE_EVT` posted per active `ALSoundState` per frame) — so the fade-to-black *is* the intended fail transition, and it hammers the event queue hard enough that the `osSetIntMask` lock is left owned by a party not in the 3-thread dump (a leaked unbalanced `OS_IM_NONE` — libaudio has early-return mask paths — or a transient thread that acquired it and exited; `gdb.txt` shows `New Thread`/`exited` churn every few k frames). D147's "the decomp never blocks while holding OS_IM_NONE → cannot deadlock" assumption is **falsified** by this path. Same family as D147 / PORT-LEARNINGS §D4. | MITIGATED (`port/src/libultra.c`, M-28, `#ifdef PORT`/port-only). `osSetIntMask` is now a **self-healing** logical lock: `OS_IM_NONE`/`OS_IM_ALL` acquire/release a process-wide recursive critical section tracked by `s_imHeld`/`s_imOwner`/`s_imDepth` under a short bookkeeping mutex `s_imMx` + condvar. Normal (balanced, microsecond) audio critical sections behave exactly as the D147 recursive mutex did. A waiter that blocks **> 2 s** (`OS_IM_STUCK_NS`) concludes the holder leaked it, logs `LOG_ERROR` with the stale `s_imOwner` + the stealing caller's return address, and **steals** the section — so a leaked/unbalanced `OS_IM_NONE` (or an acquire-and-exit transient thread) can no longer wedge the game forever; worst case is a ~2 s audio hiccup + a log line naming the leak. Builds clean. Still OPEN: the exact leaking call site (find it from the `D152: ... stealing from owner=` log next time it fires) and the proper narrow fix (dedicated `ALEventQueue` lock, or restore-on-thread-exit). Regression check deferred — `-level_09`/`-level_20` both flaky under the current machine load (D117/D134), and clean `master` itself has a separate nondeterministic `-level_09` frame ~900-1400 `0xc0000005` (see below). | -| D152+ | **D152 addendum — static audit + narrow fix (M-31).** Audited every `osSetIntMask(OS_IM_NONE)` in the *compiled* audio code (`event.c` `alEvtqNextEvent`/`alEvtqPostEvent`/`alEvtqFlush`/`alEvtqFlushType`, `csplayer.c` `__CSPRepostEvent`, `synaddplayer.c` `alSynAddPlayer`, `snd.c` `sndRemoveEvents`/`sndSetupSound`/`sndDeactivateAllSfxByFlag`): **all balanced on every path** — `alEvtqPostEvent`'s lone early `return` (`event.c:86`, freeList empty) restores the mask first. The §F D152 guess "libaudio has unbalanced early-return mask paths" is **not** borne out. Two real defects found + fixed `#ifdef PORT` (N64 verbatim `#else`): **(1)** `sndSetSfxSlotVolume` (`snd.c`) walks the live `ALSoundState` list and posts `AL_SNDP_RELEASE_EVT` to the shared `ALEventQueue` **without holding `OS_IM_NONE`** — unlike its structural twin `sndDeactivateAllSfxByFlag`, which wraps its identical walk. On PC that's an unguarded walk racing `amMain` *and* one lock acquire/release per matching sound; the mission-failed fade (`sndSetScalerApplyVolumeAllSfxSlot`→`sndApplyVolumeAllSfxSlot`→ this, every frame, every active sound) turns it into a per-frame lock-acquire storm on `mainThread` = exactly the §F dump's "hammers the event queue hard enough that the lock is left owned." Fix: hold `OS_IM_NONE` once across the whole walk (nested `alEvtqPostEvent` hits the recursive depth++ fast path, no new contention window); also wrap `sndApplyVolumeAllSfxSlot`'s slot loop so the whole volume update is one recursive hold (1 real acquire/frame instead of `SFX_SLOT_COUNT × active-sounds`). **(2)** The actual leak matches the §F dump's *other* hypothesis — "a transient thread acquired `OS_IM_NONE` and exited" (`gdb.txt` shows `New Thread`/`exited` churn). `port/src/libultra.c` `portThreadWrapper` now calls new `imThreadExitRelease()` after the thread entry returns: if that thread still owns `s_imHeld`, release the orphan immediately (+ `LOG_ERROR` naming it). This removes the wedge *and* the ~2 s steal hitch, and stops the **re-wedge** that occurs when a new host thread reuses the dead thread's pthread id and `imAcquire` mis-detects recursion (`s_imOwner == self`). The M-28 steal-lock stays as the last-resort backstop for a genuinely unbalanced *same-thread* path (none found, but cheap insurance). Build green (240/240); `-level_09` frame 300 renders 91.7% non-clear, crash-free; `-level_20` boots crash-free (frame-count flaky under load per D117/D134, no `D152:` log). Fade-out repro is **playtest-gated** — not reproducible headless, so not end-to-end verified; user should replay the mission-fail and confirm no black screen + no `D152:` steal/orphan log lines. | FIXED (`src/snd.c`, `port/src/libultra.c`, `#ifdef PORT`). PORT-LEARNINGS §D4. | -| D154 | **`bg.c` room-geometry ray/hit-test GBI parser port (M-28, WRITTEN / UNVERIFIED / UNCOMMITTED).** `bgTestRayIntersectionInRoom` (`bg.c:3331`, called by `bgTestBulletHitBackground` on every shot that resolves against background geometry) is the D135 sibling flagged since M-23: raw `((u8*)gdl)[k]` / `((u32*)gdl)[i]` byte/word indexing into what is now the 16-byte PC `Gfx` room DL (D85 `bgWidenRoomGdl` + `texLoadFromGdl`) → desync → OOB `vtxbase[idx]` / walk-off AV the first time you shoot a wall or floor. Ported `#ifdef PORT` (N64 verbatim under `#else`): `vtxoff`/`vtxbase`/`op` via the `.dma` view (matches the already-ported `bgBuildRoomVtxBounds`); G_TRI1 indices from `(w1>>16/8/0)&0xff /10`; the 12 G_TRI4 nibble extractions from `(u32)gdl->words.w0/.w1` (each mapped from the N64 byte/halfword layout — table in the code comment, cross-checked twice); both `texturenum` recoveries → `-1` (the KSEG0 `*(u16*)(w1-8)` deref is invalid for the port's converted GDLs, same as D135; texnum only picks the impact decal/sound, parked D77). Builds clean. **Verification blocked** — a no-input `GE_PCDUMP` capture never fires a bullet so it never calls this function; needs a real firefight into a wall on an idle machine (this session's box was thrashed → D153/driver crash on every run). | **M-30 re-audit + fixes (still playtest-gated for final verify).** Cross-checked the ported G_TRI1 + all 12 G_TRI4 nibble recoveries twice against the N64 `#else` and the raw BE-word bit derivation — **G_TRI1 and G_TRI4 are correct**. **Two bugs fixed:** (1) `vtxoff` — the port read `gdl->dma.par & 0xf`, but the PC `Gdma_le` shim (`port/shim/PR/gbi.h`) maps `.par` to bits 0-23 of word0 (the packed *length*, always 16-aligned) not the N64 byte-1 params field at bits 16-23, so vtxoff was silently forced to 0 (breaks any G_VTX batch with `v0 != 0`, i.e. multi-batch rooms → wrong `vtxbase[idx]` → OOB). Now `((u32)gdl->words.w0 >> 16) & 0xf`. (2) **Sibling ported** — `bgTestBulletHitBackground` tail (`bg.c:~3841`, the post-hit G_SETTILE back-scan for `tileformat`/`tilesize`) was itself an unported raw `((u8*)gdl)[0]`/`[1]` + 64-bit `words.w0 << 11 >> 30` parser over the same widened DL; wrapped `#ifdef PORT` (opcode from bits 24-31, byte1 from bits 16-23, `(u32)`-forced 32-bit width for the tilesize shift). **Diagnostic:** `GE_D154=1` → `osSyncPrintf` of the parsed cmd/vtx-index stream (call header + per-TRI1/TRI4 `idx`) for the first 64 invocations, for the user/integrator to eyeball. Build green (`ntsc-final`, 242/242). Runtime unverifiable in a worktree (no `data/`); still needs a firefight-into-wall on an idle machine + `-level_09` framediff. Confidence: HIGH on the mechanical correctness (bit-for-bit vs N64), MEDIUM that nothing else in the walk still bites. PORT-LEARNINGS §B (D135 corollary). | -| D156 | **Facility outro-cutscene "hang" — the actual fix (M-29, user bug report, 2nd occurrence after D155).** After D155 shipped the user re-ran the playthrough and it hung again at the end of Facility — same stack (`modelSetAnimFrame2WithChrStuff` ← `modelTickAnim` ← `chrUpdateAnim` ← `chrTick` ← `playerTick` ← `propsTick` ← `lvlRender` ← `bossMainloop`) but this time **`frames=12962` frozen CONSTANT across ~200 heartbeat dumps / minutes** — a true infinite loop, not the D155 slow-catch-up spiral. Root: the `while (1)` at `model.c:3131` in `modelSetAnimFrame2WithChrStuff` steps the root-motion accumulation one anim frame at a time from `framea` (`model->animframe1`) to `frameb` (`frame`, accumulated in `modelTickAnim` as `frame += playspeed * speed` per tick). If `frameb` arrives **NaN or blown up** — a cutscene anim whose blend/transition math produces a huge/non-finite `model->speed`/`playspeed`/`timespeed` (`model.c:3436` `/ model->unkb0`, `:3477` `/ model->timespeed`, both guarded `> 0` but not against tiny values) — then `floorFloatToInt(frameb)` yields garbage, `endframe - curframe` ≈ 2^31, and the loop (each iteration: `sub_GAME_7F06D3F4` joint transform + `cosf`/`sinf` + `modelConstrainOrWrapAnimFrame`) runs effectively forever. **Likely deeper cause (unconfirmed, needs the repro):** these speed/blend fields are `Model` struct members; if this model is punned/inline (D100/D140 family) and `sizeof(Model)` growth misaligned them, they read garbage exactly when a cutscene sets an unusual anim transition. | GUARDED (`src/game/model.c`, `#ifdef PORT`): (1) in `modelSetAnimFrame2WithChrStuff`, if `frameb` is non-finite or `|frameb| >= 1e6` snap it to `framea` (loop then does ~0 iterations; the `modelConstrainOrWrapAnimFrame` at :3211 still clamps `model->frameb` to a valid frame); (2) in `modelTickAnim` after the tick loop, if `frame`/`frame2` went non-finite/absurd fall back to the pre-loop `model->animframe1`/`animframe2` so the NaN can't poison model state every frame. `!(x > -1e6f && x < 1e6f)` catches NaN too. Anim frames are 0..few-hundred so 1e6 is a safe ceiling. Verified: build green; `-level_09` frame 200 unregressed (guards never fire in normal play). **User to re-verify the Facility outro.** If it still hangs, the NaN source is upstream — dump `model->speed/playspeed/timespeed/unkb0` in `modelTickAnim` during the cutscene. PORT-LEARNINGS §E. | -| D155 | **Facility outro-cutscene end → "hang" = unclamped `deltaFrames` spiral (M-29, user bug report).** User watched the Facility outro cutscene; at the end the game froze (process alive, kernel-heartbeat dumps). Symbolicated `mainThread` stack from the port's own hang dump: `modelConstrainOrWrapAnimFrame` ← `modelTickAnim` ← `chrUpdateAnim` ← `chrTick` ← `playerTick` ← `propsTick` ← `lvlRender`, sampled there across *every* heartbeat dump. Root cause: `waitForNextFrame()` (`src/game/frametiming.c`) sets `nextFrameTime = (osGetCount() − prev + 387937) / 775875` and passes it **unclamped** to `updateFrameCounters` as `deltaFrames`. `osGetCount()` is wall-clock on the port (D117), not VI-locked, so a real-time stall — the asset load at the cutscene→debrief boundary, made far worse by the user running a local LLM on the same box — makes `nextFrameTime` balloon to hundreds/thousands. That becomes `speedgraphframes` → `g_ClockTimer`, which drives `modelTickAnim(model, g_ClockTimer, 1)`'s `while (numticks-- > 0)` loop once per character per render, plus dozens of `for (i = 0; i < g_ClockTimer; i++)` sim loops (`bondhead.c`, `bondview2.c`, `explosion.c`, `options.c`…). One render then does thousands of anim ticks × N characters → multi-second frame → heartbeat trips, and the slow frame feeds an even bigger delta next time → unrecoverable spiral. The N64 was physically VI-bound and never produced `deltaFrames > ~2`. Timing-compensation class, cf. D117/D134. | FIXED (`src/game/frametiming.c`, `#ifdef PORT` — clamp `nextFrameTime` to `FRAMETIMING_PORT_MAX_CATCHUP` = 6). After a hitch the sim just resumes at real-time-ish pace instead of spiralling, exactly as the console did when it dropped frames under load. N64 path verbatim. Confidence: high (stack + arithmetic both point here). PORT-LEARNINGS §E. | +| D141 | **Watch-menu item-model crash (M-27), exposed by D140.** Pressing Start → watch page renders → `set_enviro_fog_for_items_in_solo_watch_menu` (`gunfire.c:1620`) → `modelGetNodeRwData(&model, root)` with `root` garbage → AV reading `root->Opcode` (`model.c:478`). `gunfire.c:1720-1743` walks `bodymodel->Switches[]` (a `ModelNode*` array) with **raw byte offsets** `((u8*)Switches) + j + 0x48` / `+ 0x5c` and `j += 4` — assumes 4-byte N64 pointers. `0x48/4=18`, `0x5c/4=23`, `k=j>>2=0..4` → intent is `Switches[18+k]` and `Switches[23+k]`. PC's 8-byte stride makes the raw math read misaligned garbage → bogus non-NULL `ModelNode*`. porting-notes.md §B (raw hardcoded stride into a pointer array, cf. D128). Fix: `#ifdef PORT` uses `bodymodel->Switches[18 + (j>>2)]` / `[23 + (j>>2)]`. | FIXED (`src/game/gunfire.c`, `#ifdef PORT`). Watch page now renders (weapon page verified — mirrored text D114/D116, dark weapon model D75, both parked cosmetic). `-level_09`/`-level_20` unregressed. | +| D150 | **Watch objective/briefing page crash (M-28).** Opening the watch → BRIEF (any subpage) or OBJECTIVES page → AV in `strcat` (`str.c:25`, `Rdi/src = 0`). The pages assemble their display text with `strcpy`/`strcat(buf, langGet(id))` (`options.c:3940-4068`, `front.c:3538-3562`, `propobj.c`). `langGet()` returns **NULL** on PC for a string bank the menu flow never loaded (D129/D143 guards — briefing/objective banks in the in-level watch flow), so `strcat(buf, NULL)` derefs NULL. On N64 those ids always resolve so the decomp never guards. Twist: `str.c`'s `strcpy/strncpy/strcat` are `__nonnull__` **builtins** to GCC, so a plain `if (src == NULL)` on the parameter is optimised away as provably-dead (verified: `-Og` elided it, no `test` in the disassembly). Fix: `#ifdef PORT` NULL guards that launder the pointer through an empty `__asm__` (`GE_IS_NULL()` / `ge_launder_ptr()`) so the check survives — NULL src → treat as empty string (blank text, not a crash), NULL dst → return. Same philosophy as the D143 textRender/textMeasure NULL guards, one layer down. Inert on N64. | FIXED (`src/str.c`, `#ifdef PORT`). Build green (`ntsc-final`); `-level_09` 600+ frames @ 91.67% unregressed. Watch-page repro is interactive — user to re-verify. porting-notes.md §C. | +| D152 | **Mission-failed / objective-failed → permanent black screen (M-28, user bug report — LIVE process inspected, not yet fixed).** User killed Trevelyan in Facility (Ctrl = fire, no crouch bind) → failed the objective → screen faded to black and never came back; process stayed alive under `debug.ps1`. `gdb.txt` (kept running): rendering stops dead at frame 12600 (`frames=12657` frozen across 206 heartbeat dumps / ~7 real min), VI pacemaker still posts. **Deadlock on `s_imLock`** — the D147 recursive-mutex that backs `osSetIntMask`. Both consumer threads are blocked in `pthread_mutex_lock(&s_imLock)` and neither owns it: `mainThread` = `sndSetScalerApplyVolumeAllSfxSlot` → `sndApplyVolumeAllSfxSlot` → `sndSetSfxSlotVolume` → `alEvtqPostEvent` (`event.c:82`) → `osSetIntMask(OS_IM_NONE)` (`libultra.c:1232`); `amMain`/audioThread = `amHandleFrameMessage` → `alAudioFrame` → `sndPlayerVoiceHandler` → `alEvtqNextEvent` (`event.c:47`) → `osSetIntMask(OS_IM_NONE)`. `sndSetScalerApplyVolumeAllSfxSlot` is the **mission-failed audio fade-out** (ramps `g_sndSfxVolumeScale` → 0 each frame, `AL_SNDP_RELEASE_EVT` posted per active `ALSoundState` per frame) — so the fade-to-black *is* the intended fail transition, and it hammers the event queue hard enough that the `osSetIntMask` lock is left owned by a party not in the 3-thread dump (a leaked unbalanced `OS_IM_NONE` — libaudio has early-return mask paths — or a transient thread that acquired it and exited; `gdb.txt` shows `New Thread`/`exited` churn every few k frames). D147's "the decomp never blocks while holding OS_IM_NONE → cannot deadlock" assumption is **falsified** by this path. Same family as D147 / porting-notes.md §D4. | MITIGATED (`port/src/libultra.c`, M-28, `#ifdef PORT`/port-only). `osSetIntMask` is now a **self-healing** logical lock: `OS_IM_NONE`/`OS_IM_ALL` acquire/release a process-wide recursive critical section tracked by `s_imHeld`/`s_imOwner`/`s_imDepth` under a short bookkeeping mutex `s_imMx` + condvar. Normal (balanced, microsecond) audio critical sections behave exactly as the D147 recursive mutex did. A waiter that blocks **> 2 s** (`OS_IM_STUCK_NS`) concludes the holder leaked it, logs `LOG_ERROR` with the stale `s_imOwner` + the stealing caller's return address, and **steals** the section — so a leaked/unbalanced `OS_IM_NONE` (or an acquire-and-exit transient thread) can no longer wedge the game forever; worst case is a ~2 s audio hiccup + a log line naming the leak. Builds clean. Still OPEN: the exact leaking call site (find it from the `D152: ... stealing from owner=` log next time it fires) and the proper narrow fix (dedicated `ALEventQueue` lock, or restore-on-thread-exit). Regression check deferred — `-level_09`/`-level_20` both flaky under the current machine load (D117/D134), and clean `master` itself has a separate nondeterministic `-level_09` frame ~900-1400 `0xc0000005` (see below). | +| D152+ | **D152 addendum — static audit + narrow fix (M-31).** Audited every `osSetIntMask(OS_IM_NONE)` in the *compiled* audio code (`event.c` `alEvtqNextEvent`/`alEvtqPostEvent`/`alEvtqFlush`/`alEvtqFlushType`, `csplayer.c` `__CSPRepostEvent`, `synaddplayer.c` `alSynAddPlayer`, `snd.c` `sndRemoveEvents`/`sndSetupSound`/`sndDeactivateAllSfxByFlag`): **all balanced on every path** — `alEvtqPostEvent`'s lone early `return` (`event.c:86`, freeList empty) restores the mask first. The §F D152 guess "libaudio has unbalanced early-return mask paths" is **not** borne out. Two real defects found + fixed `#ifdef PORT` (N64 verbatim `#else`): **(1)** `sndSetSfxSlotVolume` (`snd.c`) walks the live `ALSoundState` list and posts `AL_SNDP_RELEASE_EVT` to the shared `ALEventQueue` **without holding `OS_IM_NONE`** — unlike its structural twin `sndDeactivateAllSfxByFlag`, which wraps its identical walk. On PC that's an unguarded walk racing `amMain` *and* one lock acquire/release per matching sound; the mission-failed fade (`sndSetScalerApplyVolumeAllSfxSlot`→`sndApplyVolumeAllSfxSlot`→ this, every frame, every active sound) turns it into a per-frame lock-acquire storm on `mainThread` = exactly the §F dump's "hammers the event queue hard enough that the lock is left owned." Fix: hold `OS_IM_NONE` once across the whole walk (nested `alEvtqPostEvent` hits the recursive depth++ fast path, no new contention window); also wrap `sndApplyVolumeAllSfxSlot`'s slot loop so the whole volume update is one recursive hold (1 real acquire/frame instead of `SFX_SLOT_COUNT × active-sounds`). **(2)** The actual leak matches the §F dump's *other* hypothesis — "a transient thread acquired `OS_IM_NONE` and exited" (`gdb.txt` shows `New Thread`/`exited` churn). `port/src/libultra.c` `portThreadWrapper` now calls new `imThreadExitRelease()` after the thread entry returns: if that thread still owns `s_imHeld`, release the orphan immediately (+ `LOG_ERROR` naming it). This removes the wedge *and* the ~2 s steal hitch, and stops the **re-wedge** that occurs when a new host thread reuses the dead thread's pthread id and `imAcquire` mis-detects recursion (`s_imOwner == self`). The M-28 steal-lock stays as the last-resort backstop for a genuinely unbalanced *same-thread* path (none found, but cheap insurance). Build green (240/240); `-level_09` frame 300 renders 91.7% non-clear, crash-free; `-level_20` boots crash-free (frame-count flaky under load per D117/D134, no `D152:` log). Fade-out repro is **playtest-gated** — not reproducible headless, so not end-to-end verified; user should replay the mission-fail and confirm no black screen + no `D152:` steal/orphan log lines. | FIXED (`src/snd.c`, `port/src/libultra.c`, `#ifdef PORT`). porting-notes.md §D4. | +| D154 | **`bg.c` room-geometry ray/hit-test GBI parser port (M-28, WRITTEN / UNVERIFIED / UNCOMMITTED).** `bgTestRayIntersectionInRoom` (`bg.c:3331`, called by `bgTestBulletHitBackground` on every shot that resolves against background geometry) is the D135 sibling flagged since M-23: raw `((u8*)gdl)[k]` / `((u32*)gdl)[i]` byte/word indexing into what is now the 16-byte PC `Gfx` room DL (D85 `bgWidenRoomGdl` + `texLoadFromGdl`) → desync → OOB `vtxbase[idx]` / walk-off AV the first time you shoot a wall or floor. Ported `#ifdef PORT` (N64 verbatim under `#else`): `vtxoff`/`vtxbase`/`op` via the `.dma` view (matches the already-ported `bgBuildRoomVtxBounds`); G_TRI1 indices from `(w1>>16/8/0)&0xff /10`; the 12 G_TRI4 nibble extractions from `(u32)gdl->words.w0/.w1` (each mapped from the N64 byte/halfword layout — table in the code comment, cross-checked twice); both `texturenum` recoveries → `-1` (the KSEG0 `*(u16*)(w1-8)` deref is invalid for the port's converted GDLs, same as D135; texnum only picks the impact decal/sound, parked D77). Builds clean. **Verification blocked** — a no-input `GE_PCDUMP` capture never fires a bullet so it never calls this function; needs a real firefight into a wall on an idle machine (this session's box was thrashed → D153/driver crash on every run). | **M-30 re-audit + fixes (still playtest-gated for final verify).** Cross-checked the ported G_TRI1 + all 12 G_TRI4 nibble recoveries twice against the N64 `#else` and the raw BE-word bit derivation — **G_TRI1 and G_TRI4 are correct**. **Two bugs fixed:** (1) `vtxoff` — the port read `gdl->dma.par & 0xf`, but the PC `Gdma_le` shim (`port/shim/PR/gbi.h`) maps `.par` to bits 0-23 of word0 (the packed *length*, always 16-aligned) not the N64 byte-1 params field at bits 16-23, so vtxoff was silently forced to 0 (breaks any G_VTX batch with `v0 != 0`, i.e. multi-batch rooms → wrong `vtxbase[idx]` → OOB). Now `((u32)gdl->words.w0 >> 16) & 0xf`. (2) **Sibling ported** — `bgTestBulletHitBackground` tail (`bg.c:~3841`, the post-hit G_SETTILE back-scan for `tileformat`/`tilesize`) was itself an unported raw `((u8*)gdl)[0]`/`[1]` + 64-bit `words.w0 << 11 >> 30` parser over the same widened DL; wrapped `#ifdef PORT` (opcode from bits 24-31, byte1 from bits 16-23, `(u32)`-forced 32-bit width for the tilesize shift). **Diagnostic:** `GE_D154=1` → `osSyncPrintf` of the parsed cmd/vtx-index stream (call header + per-TRI1/TRI4 `idx`) for the first 64 invocations, for the user/integrator to eyeball. Build green (`ntsc-final`, 242/242). Runtime unverifiable in a worktree (no `data/`); still needs a firefight-into-wall on an idle machine + `-level_09` framediff. Confidence: HIGH on the mechanical correctness (bit-for-bit vs N64), MEDIUM that nothing else in the walk still bites. porting-notes.md §B (D135 corollary). | +| D156 | **Facility outro-cutscene "hang" — the actual fix (M-29, user bug report, 2nd occurrence after D155).** After D155 shipped the user re-ran the playthrough and it hung again at the end of Facility — same stack (`modelSetAnimFrame2WithChrStuff` ← `modelTickAnim` ← `chrUpdateAnim` ← `chrTick` ← `playerTick` ← `propsTick` ← `lvlRender` ← `bossMainloop`) but this time **`frames=12962` frozen CONSTANT across ~200 heartbeat dumps / minutes** — a true infinite loop, not the D155 slow-catch-up spiral. Root: the `while (1)` at `model.c:3131` in `modelSetAnimFrame2WithChrStuff` steps the root-motion accumulation one anim frame at a time from `framea` (`model->animframe1`) to `frameb` (`frame`, accumulated in `modelTickAnim` as `frame += playspeed * speed` per tick). If `frameb` arrives **NaN or blown up** — a cutscene anim whose blend/transition math produces a huge/non-finite `model->speed`/`playspeed`/`timespeed` (`model.c:3436` `/ model->unkb0`, `:3477` `/ model->timespeed`, both guarded `> 0` but not against tiny values) — then `floorFloatToInt(frameb)` yields garbage, `endframe - curframe` ≈ 2^31, and the loop (each iteration: `sub_GAME_7F06D3F4` joint transform + `cosf`/`sinf` + `modelConstrainOrWrapAnimFrame`) runs effectively forever. **Likely deeper cause (unconfirmed, needs the repro):** these speed/blend fields are `Model` struct members; if this model is punned/inline (D100/D140 family) and `sizeof(Model)` growth misaligned them, they read garbage exactly when a cutscene sets an unusual anim transition. | GUARDED (`src/game/model.c`, `#ifdef PORT`): (1) in `modelSetAnimFrame2WithChrStuff`, if `frameb` is non-finite or `|frameb| >= 1e6` snap it to `framea` (loop then does ~0 iterations; the `modelConstrainOrWrapAnimFrame` at :3211 still clamps `model->frameb` to a valid frame); (2) in `modelTickAnim` after the tick loop, if `frame`/`frame2` went non-finite/absurd fall back to the pre-loop `model->animframe1`/`animframe2` so the NaN can't poison model state every frame. `!(x > -1e6f && x < 1e6f)` catches NaN too. Anim frames are 0..few-hundred so 1e6 is a safe ceiling. Verified: build green; `-level_09` frame 200 unregressed (guards never fire in normal play). **User to re-verify the Facility outro.** If it still hangs, the NaN source is upstream — dump `model->speed/playspeed/timespeed/unkb0` in `modelTickAnim` during the cutscene. porting-notes.md §E. | +| D155 | **Facility outro-cutscene end → "hang" = unclamped `deltaFrames` spiral (M-29, user bug report).** User watched the Facility outro cutscene; at the end the game froze (process alive, kernel-heartbeat dumps). Symbolicated `mainThread` stack from the port's own hang dump: `modelConstrainOrWrapAnimFrame` ← `modelTickAnim` ← `chrUpdateAnim` ← `chrTick` ← `playerTick` ← `propsTick` ← `lvlRender`, sampled there across *every* heartbeat dump. Root cause: `waitForNextFrame()` (`src/game/frametiming.c`) sets `nextFrameTime = (osGetCount() − prev + 387937) / 775875` and passes it **unclamped** to `updateFrameCounters` as `deltaFrames`. `osGetCount()` is wall-clock on the port (D117), not VI-locked, so a real-time stall — the asset load at the cutscene→debrief boundary, made far worse by the user running a local LLM on the same box — makes `nextFrameTime` balloon to hundreds/thousands. That becomes `speedgraphframes` → `g_ClockTimer`, which drives `modelTickAnim(model, g_ClockTimer, 1)`'s `while (numticks-- > 0)` loop once per character per render, plus dozens of `for (i = 0; i < g_ClockTimer; i++)` sim loops (`bondhead.c`, `bondview2.c`, `explosion.c`, `options.c`…). One render then does thousands of anim ticks × N characters → multi-second frame → heartbeat trips, and the slow frame feeds an even bigger delta next time → unrecoverable spiral. The N64 was physically VI-bound and never produced `deltaFrames > ~2`. Timing-compensation class, cf. D117/D134. | FIXED (`src/game/frametiming.c`, `#ifdef PORT` — clamp `nextFrameTime` to `FRAMETIMING_PORT_MAX_CATCHUP` = 6). After a hitch the sim just resumes at real-time-ish pace instead of spiralling, exactly as the console did when it dropped frames under load. N64 path verbatim. Confidence: high (stack + arithmetic both point here). porting-notes.md §E. | | D153 | **`-level_09` frame ~900–1400 `0xc0000005` = D117/D134 load flakiness, NOT a bug (M-28).** Reproduced 6/6 while the machine was under heavy concurrent build+run load; on a **quiet** machine `-level_09` ran past frame 2400 with 0 heartbeats, no crash. Same false-positive class the docs warn about every session (M-13..M-22, M-25 Silo ~frame 300 freeze). The crash PC was in a system DLL (a `memcpy` off a buffer) and the frame varied run-to-run — both consistent with the port's host-scheduling jitter under load (`osGetCount` is wall-clock, variable-timestep sim), not a deterministic defect. `romdataFixupMusicSeqTable: seqCount 63 exceeds blob capacity 1` is an EXPECTED benign warning on the header-only first `romdataFixupMusicSeqTable` call (music.c:736; the full-size second call at :742 is silent) — not related. **Lesson: don't regression-test while builds/other runs are in flight.** | NOT A BUG — closed. Run anchors on a quiet machine. | -| D151 | **Watch data all blank — objectives / mission background / M / Q / Moneypenny (M-28, user bug report).** Not a crash (D150 fixed that); every line renders empty. The watch text lives in `propDefs` record types **35** (`WatchMenuObjectiveText`) and **23** (`ObjectiveStart`). In the setup stream each record's 3rd word is a **plain 32-bit language slot id** (see `assets/obseg/setup/Usetup*Z.c` `propDefs[]`: `_mkword(0,_mkshort(0,35)), menu, 11281, 0`). The decomp structs `struct watchMenuObjectiveText` / `struct objective_entry` decode that word as `u16 reserved; u16 text;` and read `text` at offset `0xA` — which only works on the N64 because the id sits in the **low 16 bits of a big-endian word**. On LE, `tools_pc/d88_propdefs.py` `_bswap32`'s the whole word (correct for a 32-bit field), moving the id to the low bytes, so `text` (u16 @0xA) reads `0x0000` → `langGet(0)` → NULL → `get_ptr_text_for_watch_breifing_page` / `get_text_for_objective` return NULL/blank. This is the "briefing/objective text renders blank" side-effect left open under D143 / D150. The per-level lang bank itself IS loaded in-level (`langLoadToAddr(langGetLangBankIndexFromStagenum(stageId))`, `prop.c:1274`). | FIXED (`src/bondtypes.h`, `#ifdef PORT` — `text` is a full `u32` covering the whole word in both structs; N64 `u16 reserved; u16 text;` kept under `#else`). No converter change, no sidecar regen. `nextentry`/`difficulty`/`unkD` unchanged. PORT-LEARNINGS §C. Build compiles clean; interactive re-verify pending (open the watch in any level). | +| D151 | **Watch data all blank — objectives / mission background / M / Q / Moneypenny (M-28, user bug report).** Not a crash (D150 fixed that); every line renders empty. The watch text lives in `propDefs` record types **35** (`WatchMenuObjectiveText`) and **23** (`ObjectiveStart`). In the setup stream each record's 3rd word is a **plain 32-bit language slot id** (see `assets/obseg/setup/Usetup*Z.c` `propDefs[]`: `_mkword(0,_mkshort(0,35)), menu, 11281, 0`). The decomp structs `struct watchMenuObjectiveText` / `struct objective_entry` decode that word as `u16 reserved; u16 text;` and read `text` at offset `0xA` — which only works on the N64 because the id sits in the **low 16 bits of a big-endian word**. On LE, `tools_pc/d88_propdefs.py` `_bswap32`'s the whole word (correct for a 32-bit field), moving the id to the low bytes, so `text` (u16 @0xA) reads `0x0000` → `langGet(0)` → NULL → `get_ptr_text_for_watch_breifing_page` / `get_text_for_objective` return NULL/blank. This is the "briefing/objective text renders blank" side-effect left open under D143 / D150. The per-level lang bank itself IS loaded in-level (`langLoadToAddr(langGetLangBankIndexFromStagenum(stageId))`, `prop.c:1274`). | FIXED (`src/bondtypes.h`, `#ifdef PORT` — `text` is a full `u32` covering the whole word in both structs; N64 `u16 reserved; u16 text;` kept under `#else`). No converter change, no sidecar regen. `nextentry`/`difficulty`/`unkD` unchanged. porting-notes.md §C. Build compiles clean; interactive re-verify pending (open the watch in any level). | | D130 | crash class C2 (Facility `-level_34`, Runway `-level_35`): `import_texture_i8` AV on a wild texture pointer (`0x72181ee8`), baked into the HUD glyph DL by `gDPLoadTextureBlock(gdl, curchar->pixeldata, …)` when the level-title string ("Chemical Warfare Facility #2") renders `#`/`"`. Root cause: `romdataFixupFont` (`port/src/romdata.c`) converts the N64 24B fontchar array to the PC 32B array **in place**, forward-field, backward-glyph. PC−N64 stride = 8, so for glyphs 0/1/2 `d` overlaps `s` by less than the 24B read span and an early field write clobbers a later field's source mid-`for k<5` loop → glyph 1's `width` = `bswap(index)` = `0x01000000`, glyph 2's `pixeldata` = `pcPixOff+(index−pixStart)` ≈ `0x020002e8` → `+= font_base` → wild → fast3d AV. On N64 the array is read-only rodata, never re-laid-out, so no bug there. NOT the model-GDL relocation (`sub_GAME_7F0762E0` / `texLoadFromGdl`) the D124-Facility addendum suspected — `gdl` there is still a segmented `0x05xxxxxx` value so the `& 0x00ffffff` masks are correct, and `texLoadFromGdl` never copies a `G_SETTIMG` for these levels. | resolved — stage all 6 N64 fields in a local `f[6]` before writing any. Facility + Runway now PASS (16→18/21). Probes reverted. | | D164 | **Disclaimer / legal screen only draws the first line (D76 root cause, M-31).** `constructor_menu00_legalscreen` (`src/game/front.c:1523`) renders the 12-entry `legalpage_text_array[]` with `legal_text_end = (struct legal_screen_text *)&legalscreen_MRD;` then `do { render; ptr++; } while (ptr < legal_text_end);`. This is a **linker-adjacency assumption**: on N64 `.data` the two file-scope globals are emitted in source order so `&legalscreen_MRD == legalpage_text_array + 12` and the loop count is exactly the array length. mingw/GCC reorders them — in `build-pc/ge007.x86_64.exe` `legalscreen_MRD` links at `0x1401305e0`, **0x60 bytes before** `legalpage_text_array` at `0x140130640` (verified via `nm`). So `legal_text_end < legal_text_ptr` from the start; the `do/while` runs the body **once** (entry 0, "TWYCROSS BOARD OF GAME CLASSIFICATION" — long + CENTER_ALIGN so it wraps to ~2 visual lines = the "classification line + one below" the user saw), then exits. The other 11 legal lines never render. NOT a D68 image-table bug — the legal screen references **zero** `sImageTableEntry`s; it is pure `langGet()` font text via `display_aligned_white_text_to_screen`→`textRender`, plus the 3D `logoinst` model (`subdraw`, absent — D75 family, parked). The D68 fixup and the `GE_IMGT` avenue in the brief are both dead ends here. | **FIX PROPOSED, NOT APPLIED** (`front.c` is owned by another agent this session). One-line `#ifdef PORT` in `constructor_menu00_legalscreen`: `legal_text_end = legalpage_text_array + ARRAY_COUNT(legalpage_text_array);` (N64 `&legalscreen_MRD` cast under `#else`). Same linker-layout class as the D75 loop-bound / D142 enum family. Confidence: **high** — mechanism confirmed by symbol addresses. | -| D159 | **Front-end wallet-Bond photo (and other large front-end I4/I8/IA8 textures) render "interlaced" / combed (M-31, user bug RC1 / D149).** The user's menu showed a garbled venetian-blind Bond photo on the folder / mode-select screen; earlier the same model DL walked into garbage (`D146` opcode spam) — that crash is already contained by the D144/D146 guards, so what's left is purely the texture. Root-caused with `GE_DTEX`: the photo is a **65×65 I4** (also seen: 65×65 I8, 95×32 IA8) with a full mip chain; `import_texture_i4` uploads it `line_size_bytes*2` (= padded stride) wide and `size_bytes/line` tall — geometry fine. The comb is **`texSwapAltRowBytes` (`src/game/image.c:2191`)**: it pre-swaps every **odd** texture row in 8-byte (one-`u32`) groups to compensate for the N64 RDP's odd-line TMEM address XOR (bit 2) during 4-byte-word `gDPLoadBlock` loads. fast3d does **not** emulate that XOR (no odd-row handling anywhere in `gfx_pc.cpp`), so the pre-swapped odd rows stay scrambled in the uploaded GL image. It's called on *every* level of *every* texture on *every* path (§4), but only bites where the texture is large enough and viewed 1:1 to see the 8-texel comb — glaring on the ~big front-end photos, ~invisible on small HUD glyphs / distant in-level surfaces (which is why it was written off as "front-end model" work for many sessions). Same class as the K0-fold / interrupt-mask hardware-quirk shims — belongs in `port/`, not game logic. | **FIXED** (`src/game/image.c`, `#ifdef PORT` early-`return` in `texSwapAltRowBytes` — fast3d wants a plain linear image; N64 body kept verbatim under it). Verified: wallet-Bond photo renders as a clean recognisable grayscale portrait (still 180°-rotated — D75 model-transform family, parked); `-level_09` frame 200 coverage 91.62 vs 91.66 golden (D117 noise) and a visual frame is clean (tiled floor / monitors / world-map screen all correct). Confidence: **high** (GE_DTEX + before/after screenshots). The residual over-tall-mip (RC2) and non-PoT wrap (RC3) issues are separate and still parked; this only removes the row-swap. PORT-LEARNINGS §C. | +| D159 | **Front-end wallet-Bond photo (and other large front-end I4/I8/IA8 textures) render "interlaced" / combed (M-31, user bug RC1 / D149).** The user's menu showed a garbled venetian-blind Bond photo on the folder / mode-select screen; earlier the same model DL walked into garbage (`D146` opcode spam) — that crash is already contained by the D144/D146 guards, so what's left is purely the texture. Root-caused with `GE_DTEX`: the photo is a **65×65 I4** (also seen: 65×65 I8, 95×32 IA8) with a full mip chain; `import_texture_i4` uploads it `line_size_bytes*2` (= padded stride) wide and `size_bytes/line` tall — geometry fine. The comb is **`texSwapAltRowBytes` (`src/game/image.c:2191`)**: it pre-swaps every **odd** texture row in 8-byte (one-`u32`) groups to compensate for the N64 RDP's odd-line TMEM address XOR (bit 2) during 4-byte-word `gDPLoadBlock` loads. fast3d does **not** emulate that XOR (no odd-row handling anywhere in `gfx_pc.cpp`), so the pre-swapped odd rows stay scrambled in the uploaded GL image. It's called on *every* level of *every* texture on *every* path (§4), but only bites where the texture is large enough and viewed 1:1 to see the 8-texel comb — glaring on the ~big front-end photos, ~invisible on small HUD glyphs / distant in-level surfaces (which is why it was written off as "front-end model" work for many sessions). Same class as the K0-fold / interrupt-mask hardware-quirk shims — belongs in `port/`, not game logic. | **FIXED** (`src/game/image.c`, `#ifdef PORT` early-`return` in `texSwapAltRowBytes` — fast3d wants a plain linear image; N64 body kept verbatim under it). Verified: wallet-Bond photo renders as a clean recognisable grayscale portrait (still 180°-rotated — D75 model-transform family, parked); `-level_09` frame 200 coverage 91.62 vs 91.66 golden (D117 noise) and a visual frame is clean (tiled floor / monitors / world-map screen all correct). Confidence: **high** (GE_DTEX + before/after screenshots). The residual over-tall-mip (RC2) and non-PoT wrap (RC3) issues are separate and still parked; this only removes the row-swap. porting-notes.md §C. | | D165 | **Front-end menu cursor doesn't feel like a mouse pointer (M-31, user bug B5 follow-up).** M-30 added a "menu pointer mode" that fed mouse *velocity* into the stick axes, but `front.c frontUpdateControlStickPosition` *integrates* the stick as a velocity into a screen-pixel cursor position (`cursor_h_pos += (stickx*0.075 ± 0.5) * g_GlobalTimerDelta`, ±5 deadzone, ±70 clamp, cursor clamped into the ~320×240 virtual rect − 20px margin). Velocity-in → velocity² cursor. **Fix (port-only, no `front.c` edit):** `input.c` now runs a P-controller — it keeps its own estimate `menuEst{H,V}` of where the game cursor is, integrated each poll with the *exact same recurrence* as `frontUpdateControlStickPosition` (`menuCursorStep()`), accumulates a `menuTgt{H,V}` target from raw mouse motion (clamped to the same rect), and emits `stick = clamp(MENU_P_GAIN·(target−est), ±70)`. `MENU_P_GAIN = 6.0` → per-poll error decay ≈ 0.45 (`0.075·6`), so no overshoot and ~8-poll settle; stick falls under the game's ±5 deadzone within ~1px of target so the cursor parks with zero jitter/drift. The estimate re-syncs to the true cursor whenever the target is held at a screen edge (both clamp). `Input.MenuPointerSpeed` scales the mouse→target mapping (bump toward 150–200 if the sweep feels short — `est` steps once per controller poll ≈ 2×/rendered-frame vs `front.c`'s 1×/frame, a rate mismatch absorbed by this knob). `Input.MenuPointerMode` (0 = old velocity, 1 = pointer, **default 1**) is the escape hatch. `GE_INPUTLOG` prints an `menuptr est=/tgt=/eff=/stick=` trace line for tuning. With no mouse motion `target==est==centre` → `eff=0` → emitted stick unchanged → prior behaviour exactly. | FIXED (`port/src/input.c`; `Input.MenuPointerMode`, `Input.HipfirePitchSpeed` added). Build green, `-level_09` + `GE_STARTMENU=6` smoke crash-free. Feel-check owed (headless can't exercise the mouse). | | D166 | **Hipfire mouse pitch is digital while yaw is analog — inconsistent feel (user bug B7 / D118a, "cheaper alternative").** In hipfire GE takes yaw from the analog stick-X but pitch only from digital C-up/C-down, so `input.c` emitted one C-button per poll once `|mouseΔY|` crossed a fixed threshold — an on/off step. **Fix (port-only):** emit the C-button as a *duty-cycled pulse* whose firing frequency scales with mouse-Y speed — `duty = |dyLook|·(HipfirePitchSpeed/100)/HIP_PITCH_FULL` (clamped ≤1), phase-accumulate, fire when the phase wraps. Fast mouse → `duty≈1` → C-button every poll (solid hold, as before); slow mouse → sparse taps proportional to speed. `HIP_PITCH_FULL = 6.0` px/poll for a solid hold; `Input.HipfirePitchSpeed` (10–500, default 100) tunes it. Aim-mode (RMB, already fully analog) untouched. No mouse motion → no pulse → prior behaviour. | FIXED (`port/src/input.c`). SMALL-FIXES B7. | @@ -1900,9 +1900,8 @@ HANDOFF Task 3. Build is GREEN. > This section began as a per-session "handoff & plan" and grew into the > D70–D121 finding log. The **D32 repeatable fix procedure** below is > permanent reference; the rest is the finding archive (use the §F index). -> **Current task and plan: `docs/HANDOFF.md` + -> `docs/PLAN-linear-level-sweep.md`.** Session narrative: -> `docs/HANDOFF-ARCHIVE.md`. +> **Current status: the README. Current task: `docs/HANDOFF.md`.** Session +> narrative: `docs/dev/HANDOFF-ARCHIVE.md`. Full paste-ready brief: **docs/HANDOFF.md** (primary thread: D69 BG/stan stage loading — the milestone blocker; secondary: final pixel check of the @@ -2141,7 +2140,7 @@ onto M-30b master which carries D159/D164/D165/D166). (`guPerspective`+`guLookAt`+`guRotate`+`guTranslate`+`guMtxF2L`, `title.c load_display_rare_logo`) and renders correctly. So no front-end matrix path that goes through gu is broken. **Future sessions: do not - re-audit gu for D75.** (Recorded in PORT-LEARNINGS §C.) + re-audit gu for D75.** (Recorded in porting-notes.md §C.) - **Bug 1 — non-animated `logoinst` models (Nintendo logo, GE logo, front.c wallets/Bond photo): wrong transform / 180° flip. Almost certainly @@ -2162,10 +2161,10 @@ onto M-30b master which carries D159/D164/D165/D166). `gfx_adjust_viewport_or_scissor` / the `MP_matrix` vertex transform (`gfx_pc.cpp` ~1122/1739/1771). Confidence the logo/photo transform bug is the D114/D116 fast3d flip: **medium-high**. This is a `port/fast3d` - correctness gap — the fix needed is the one D2 of PORT-LEARNINGS gates + correctness gap — the fix needed is the one D2 of porting-notes.md gates behind a RenderDoc/apitrace capture of one texrect (or the asymmetric 1-texel-texture experiment). No `src/` change will fix it and static - tracing it is BANNED (PORT-LEARNINGS §D2). Parked exactly as before. + tracing it is BANNED (porting-notes.md §D2). Parked exactly as before. - **Bug 2 — animated character models (gun-barrel Bond, cast-roll models) entirely absent: category (b), an independent model-instantiation / @@ -4002,7 +4001,7 @@ happen to convert cleanly; Facility's do not. *Probes:* none left in tree — `GE_C2` scratch prints removed after root-cause. *Files touched:* `port/src/gimgfixup.c` (fix), -`docs/porting-notes.md`, `docs/internals.md`, `docs/LEVEL-STATUS.md`. +`docs/porting-notes.md`, `docs/internals.md`, `docs/dev/LEVEL-STATUS.md`. *Confidence:* Jungle fix **high** (mechanism proven with a `[C2sync]` trace showing 32 slots going `abcd08xx -> 700e****`, verified render). Facility diagnosis **medium** (probe evidence strong; exact off-by in @@ -4103,7 +4102,7 @@ tree as the offline-side regression guard. scratch prints in `src/game/prop.c` (lines ~1206 and ~1865) and the temp scan scripts `tools_pc/c3_scan.py` / `tools_pc/c3_doors.py` were all reverted/deleted. *Files touched:* only `docs/internals.md` (this -entry) + `docs/LEVEL-STATUS.md` (one line). No code, no converter, no +entry) + `docs/dev/LEVEL-STATUS.md` (one line). No code, no converter, no sidecar change — **sidecars do NOT need regen**, tree builds clean at `f2beae4b` + M-13 uncommitted set. @@ -4245,7 +4244,7 @@ intro renders" line was aspirational, not a measured coverage state. Bisect not needed (state identical at the older commit). No cheap `#ifdef PORT` regression exists; the fix is the parked D75 3D-pipeline work -(`docs/GRAPHICS-BACKLOG.md`). `-level_09` re-verified PASS (690+ frames, 91.7%) +(`docs/dev/GRAPHICS-BACKLOG.md`). `-level_09` re-verified PASS (690+ frames, 91.7%) so the capture harness is sound. Worktree removed (`git worktree remove --force ../ge007-m17` — safe because @@ -4257,7 +4256,7 @@ Secondary (M-20, not investigated per scope): Facility `-level_34` boot-crashes `frames=0` PC `0x1400c3b77` (addr2line unresolved); Jungle `-level_37` renders frames 1–2 then kernel-heartbeat hangs (`frames=2`). Both claimed PASS in M-18/M-19 (D130/D131). Machine was only lightly loaded. Needs a clean-machine -re-verify (`docs/LEVEL-STATUS.md`) before calling it a regression. +re-verify (`docs/dev/LEVEL-STATUS.md`) before calling it a regression. **D131 (session M-19) — Jungle `-level_37` C2m crash: `osVirtualToPhysical()` truncates a compiled-symbol pointer inside a GBI matrix command.** Jungle @@ -4356,7 +4355,7 @@ field of `d`. (A previous iteration's write can never touch `s(i)` — for `j > i`, `d(j)` starts past `s(i)`'s end — so a full pre-read is safe.) `GLYPH_IDX` clamp in `textrelated.c` was tried and reverted: the crashing string has no control byte, and a negative-index guard is a separate latent -concern (logged in PORT-LEARNINGS §A). +concern (logged in porting-notes.md §A). **Verification:** `-level_34` 0 crashes / ~14 runs (was ~50–100%); `-level_35` 0 crashes / 4 runs (was ~3/4). `-level_09`/`-level_20`/`-level_24` unregressed @@ -4433,7 +4432,7 @@ Findings: `load_object_fill_header`. 5. *Files touched this session:* none committed; both probe edits (`src/game/objecthandler_2.c`, `port/fast3d/gfx_pc.cpp`) reverted via - `git checkout`. Docs only: this entry + `docs/LEVEL-STATUS.md`. + `git checkout`. Docs only: this entry + `docs/dev/LEVEL-STATUS.md`. *Confidence:* crash cmd + `Gfx` stride **high**; `tex->data`-is-the-bad- value **high**; root cause of *why* `tex->data` is bad **low–medium** (two candidate mechanisms, neither proven). diff --git a/port/fast3d/gfx_pc.cpp b/port/fast3d/gfx_pc.cpp index f395f564..a4b11ac0 100644 --- a/port/fast3d/gfx_pc.cpp +++ b/port/fast3d/gfx_pc.cpp @@ -943,7 +943,7 @@ static void import_texture(int i, int tile, bool importReplacement) { loaded_texture.orig_size_bytes = loaded_texture.size_bytes; } - /* RC2 (docs/TEXTURE-GLITCH-ANALYSIS.md): GE's texGetDepthAndSize() sums the + /* RC2 (docs/dev/TEXTURE-GLITCH-ANALYSIS.md): GE's texGetDepthAndSize() sums the * base level + every LOD mip into one gDPLoadBlock, so the block byte count * (-> upload height = size_bytes / row) runs ~1.3x taller than the base * image and the mip bytes render as garbage rows below it ("interlaced" @@ -998,7 +998,7 @@ static void import_texture(int i, int tile, bool importReplacement) { * RC2 (mip-chain contamination -> over-tall upload) can be told apart from a * decode/row-swap bug. tile_h = base-tile height from SETTILESIZE; if the * computed upload height is much larger, the excess rows are LOD mip data. - * docs/TEXTURE-GLITCH-ANALYSIS.md sec 6b. */ + * docs/dev/TEXTURE-GLITCH-ANALYSIS.md sec 6b. */ if (getenv("GE_DTEX")) { static int dtexCount = 0; if (dtexCount < 64) { @@ -1035,7 +1035,7 @@ static void import_texture(int i, int tile, bool importReplacement) { * the colour pipe, i.e. it behaves as a plain intensity (I) texture. GE's * Depot ceiling emits exactly this (CI8 + gsDPSetTextureLUT(G_TT_NONE)); * decoding it against the stale rdp.palette produced the blue-speckle roof - * (docs/TEXTURE-GLITCH-ANALYSIS.md, B2). Route CI4/CI8 -> I4/I8 here. */ + * (docs/dev/TEXTURE-GLITCH-ANALYSIS.md, B2). Route CI4/CI8 -> I4/I8 here. */ uint8_t fmt_eff = fmt; if (fmt == G_IM_FMT_CI && rdp.palette_fmt == G_TT_NONE) { fmt_eff = G_IM_FMT_I; diff --git a/src/audi.c b/src/audi.c index 7934afd2..213154ad 100644 --- a/src/audi.c +++ b/src/audi.c @@ -6,7 +6,7 @@ #include "bondgame.h" #include "speed_graph.h" #if defined(PORT) -#include /* PC port: memcpy for the CUSTOM_FX_PARAMS_N copy in audioInit (docs/PCPortResearch.md §11) */ +#include /* PC port: memcpy for the CUSTOM_FX_PARAMS_N copy in audioInit (docs/dev/findings.md §11) */ #endif /** @@ -364,7 +364,7 @@ void amCreateAudioManager(ALSynConfig* alconf) { #if defined(PORT) /* PC port: IDO accepted the C++-style array initializer `= CUSTOM_FX_PARAMS_N`; - * GCC rejects it. Copy instead — identical result. See docs/PCPortResearch.md §11 */ + * GCC rejects it. Copy instead — identical result. See docs/dev/findings.md §11 */ s32 sp48[CUSTOM_FX_SECTION_COUNT * CUSTOM_FX_SECTION_SIZE + 2]; memcpy(sp48, CUSTOM_FX_PARAMS_N, sizeof(sp48)); #else diff --git a/src/bondtypes.h b/src/bondtypes.h index 7bf1350f..8ff57de3 100644 --- a/src/bondtypes.h +++ b/src/bondtypes.h @@ -3187,7 +3187,7 @@ typedef union * rejects as a hard error (IDO tolerates it). The game code's `->pad` * (e.g. setupCctv) refers to the INHERITED pad index @0x08, never to this * member, so renaming it keeps the layout byte-identical. See - * docs/PCPortResearch.md. */ + * docs/dev/findings.md. */ s32 lookpad; // lookpad #else s32 pad; // lookpad @@ -3940,7 +3940,7 @@ typedef struct SetupIntroCamera * bondview2.c) against the still-40-byte-per-record file data. Kept as * u32 (same class as D79/D53.1); cast to the real pointer type at each * use site under #ifdef PORT. No behavior change -- see D88 in - * PCPortResearch.md. */ + * findings.md. */ union { u16 lang_index[2]; diff --git a/src/game/bg.c b/src/game/bg.c index 89711216..55fe4dfe 100644 --- a/src/game/bg.c +++ b/src/game/bg.c @@ -231,7 +231,7 @@ u32 D_8004481C[] = {0x1000100, 0}; * member in a nested context (an array element), which GCC enforces as a hard * error (IDO tolerated it). The code treats this data as a flat byte array * (cast to `u8*` at sub_GAME_7F0B37EC and walked byte-by-byte), so define it as - * such. The byte layout is identical. See docs/PCPortResearch.md. */ + * such. The byte layout is identical. See docs/dev/findings.md. */ u8 specialportalarray[] = { 0x03, 0x2C,0x2E,0x32, 0x37,0x3E,0x3F,0x4E, 0x56,0x59,0x5D,0x72, 0x76,0x79,0x7A,0xFF, 0x11, 0x00,0x3A,0xFF @@ -3081,7 +3081,7 @@ void bgBuildRoomVtxBounds(s32 roomID) * D80/D82 -- runtime texLoadFromGdl is supposed to convert them * the same way it converts model GDLs, but that conversion is * not yet verified correct for room-specific GDL content, see - * docs/PCPortResearch.md D83). Until that's resolved, a + * docs/dev/findings.md D83). Until that's resolved, a * corrupted command stream can produce a `vtx` pointer far * outside the room's vertex buffer; bounds-check before * dereferencing so a bad room fails to render (skips this diff --git a/src/game/blood_animation.c b/src/game/blood_animation.c index 5364d449..6f7fc773 100644 --- a/src/game/blood_animation.c +++ b/src/game/blood_animation.c @@ -200,7 +200,7 @@ u8 die_blood_image_1[] = { * address). On PC a zero-init symbol lands in .bss, a different section ~1 MB * away, so the guard never fires and the RLE decoder runs past the array into * adjacent .data, writing unbounded garbage (clobbered the gun-barrel sub-DL - * at +0x200 — see docs/PCPortResearch.md §F/D64). Define it as one-past-the- + * at +0x200 — see docs/dev/findings.md §F/D64). Define it as one-past-the- * end of the array instead. Only used in this TU. */ #define die_blood_image_end (*(const u8 *)(die_blood_image_1 + sizeof(die_blood_image_1))) #else diff --git a/src/game/chrai.h b/src/game/chrai.h index 7c6a1f2e..27f6fa16 100644 --- a/src/game/chrai.h +++ b/src/game/chrai.h @@ -75,7 +75,7 @@ Jump to Routine: [p]ointer to another animation routine where processing resumes */ #if defined(PORT) -/* PC port (docs/PCPortResearch.md §11): on a 64-bit host the bare array name in +/* PC port (docs/dev/findings.md §11): on a 64-bit host the bare array name in * these initializers is a pointer-to-integer conversion, which GCC rejects as a * static initializer ("not computable at load time"). Store an index into * _PORT_monAnimPtrs[] (defined in propobj.c) instead; the script interpreter diff --git a/src/game/chraidata.c b/src/game/chraidata.c index f67459a7..e4bc08b0 100644 --- a/src/game/chraidata.c +++ b/src/game/chraidata.c @@ -149,7 +149,7 @@ u8 m_StandardGuard[] = { return to List set by SetReturnAiList - If not set will crash */ u8 m_IdleAnimations[] = { -#ifdef PORT /* D7: SWITCH() is IDO-specific metaprogramming GCC cannot expand; the hand-written form below is its byte-identical expansion (see docs/PCPortResearch.md §11) */ +#ifdef PORT /* D7: SWITCH() is IDO-specific metaprogramming GCC cannot expand; the hand-written form below is its byte-identical expansion (see docs/dev/findings.md §11) */ IFNewRandomGreaterThan(50, lblNext) /* generate annd compare random seed to see which animation to play */ PlayAnimation(ANIM_yawning, 0, 193, ANIM_IDLE_POSE_WHEN_COMPLETE | ANIM_PLAY_SFX, ANIM_DEFAULT_INTERPOLATION) GotoNext(lblDone) /* jump to end, we're done */ @@ -213,7 +213,7 @@ u8 m_IdleAnimations[] = { return to List set by SetReturnAiList - If not set will crash */ u8 m_BashKeyboard[] = { -#ifdef PORT /* D7: SWITCH() is IDO-specific metaprogramming GCC cannot expand; the hand-written form below is its byte-identical expansion (see docs/PCPortResearch.md §11) */ +#ifdef PORT /* D7: SWITCH() is IDO-specific metaprogramming GCC cannot expand; the hand-written form below is its byte-identical expansion (see docs/dev/findings.md §11) */ IFNewRandomGreaterThan(60, lblNext) PlayAnimation(ANIM_keyboard_right_hand1, 0, 69, 0x00, ANIM_DEFAULT_INTERPOLATION) GotoNext(lblDone) /* jump to end, we're done */ @@ -588,7 +588,7 @@ u8 m_RunToBondPersistent[] = { /*DO*/ Label(lblMaybeThrowGrenade) -#ifdef PORT /* D7: SWITCH() is IDO-specific metaprogramming GCC cannot expand; the hand-written form below is its byte-identical expansion (see docs/PCPortResearch.md §11) */ +#ifdef PORT /* D7: SWITCH() is IDO-specific metaprogramming GCC cannot expand; the hand-written form below is its byte-identical expansion (see docs/dev/findings.md §11) */ IFNewRandomGreaterThan(10, lblNext) TRYThrowingGrenade(lblDone) /* depends on chr->grenadeprob value */ diff --git a/src/game/front.c b/src/game/front.c index 43fd9c4f..8cb83172 100644 --- a/src/game/front.c +++ b/src/game/front.c @@ -2476,7 +2476,7 @@ s32 interface_menu05_fileselect(void) setCursorPOSforMode(0); #if defined(PORT) - return 0; /* PC port: function is declared s32 but had a bare `return;` (hard error in GCC); the sole caller ignores the value. See docs/PCPortResearch.md §11 */ + return 0; /* PC port: function is declared s32 but had a bare `return;` (hard error in GCC); the sole caller ignores the value. See docs/dev/findings.md §11 */ #else return; #endif diff --git a/src/game/gun.c b/src/game/gun.c index ff7086d9..40e931ed 100644 --- a/src/game/gun.c +++ b/src/game/gun.c @@ -107,7 +107,7 @@ u32 D_80032458 = 0; /* PC port (D45): Gfx slots are 16B on x86-64, so model-file GDL regions double * and texture-marker expansion adds data-driven RDP commands. bondview's * body+head+held-prop chain worst case is 0x1DB9A; the suit path needs pool - * 0xA0B0 + region 0x18000 = 0x220B0. See docs/PCPortResearch.md D45. */ + * 0xA0B0 + region 0x18000 = 0x220B0. See docs/dev/findings.md D45. */ u32 size_item_buffer[] = {0x23000, 0x23000}; #else u32 size_item_buffer[] = {0x14820, 0x14820}; diff --git a/src/game/gunfire.c b/src/game/gunfire.c index 655fe1f5..76badb0d 100644 --- a/src/game/gunfire.c +++ b/src/game/gunfire.c @@ -5013,7 +5013,7 @@ CasingRecord* casingCreate(ModelFileHeader* header, Mtxf* mtx) * the inline gait Model / bondhead-matrix region and `matrix_4x4_copy(THROWMTX, * ..)` scribbles 64 bytes of live state on every shot. The offsets are exactly * `hands[handnum].throw_item_pos_related[_prev]` and its translation row, so - * use the field accessors directly. See docs/AUDIT-M6-player-offsets.md. */ + * use the field accessors directly. See docs/dev/AUDIT-M6-player-offsets.md. */ #define THROWMTX (&g_CurrentPlayer->hands[handnum].throw_item_pos_related) #define THROWPOS(k) (g_CurrentPlayer->hands[handnum].throw_item_pos_related.m[3][k]) #define THROWPREV(k) (g_CurrentPlayer->hands[handnum].throw_item_pos_related_prev.m[3][k]) diff --git a/src/game/image.c b/src/game/image.c index 18544f26..86cca08e 100644 --- a/src/game/image.c +++ b/src/game/image.c @@ -2358,7 +2358,7 @@ void texLoadFromDisplayList(Gfx *gdl, struct texpool *arg1) { // Look for GBI sequence: fd...... abcd.... #ifdef PORT - /* D68 (docs/PCPortResearch.md): after gimgFixupGlobalimagetable() the + /* D68 (docs/dev/findings.md): after gimgFixupGlobalimagetable() the * 0xABCDxxxx word is stored host-order, so the marker bytes sit at * offsets 6..7 of the Gfx. */ if (bytes[0] == G_SETTIMG && bytes[6] == 0xcd && bytes[7] == 0xab) diff --git a/src/game/image.h b/src/game/image.h index 5c7657c6..3e1dc685 100644 --- a/src/game/image.h +++ b/src/game/image.h @@ -53,7 +53,7 @@ struct tex { }; #ifdef PORT -/* D67 (docs/PCPortResearch.md): N64 layout reconstruction for struct +/* D67 (docs/dev/findings.md): N64 layout reconstruction for struct * image_entry. The decompiled field order cannot be right: texLoad() reads * `*(s32*)&entry & 0xFFFFFF` as the data offset (so dataoffset must occupy * bits 0-23 of word 0), while chrprop.c indexes entries with an 8-byte diff --git a/src/game/image_bank.c b/src/game/image_bank.c index 552d9b0b..2990c903 100644 --- a/src/game/image_bank.c +++ b/src/game/image_bank.c @@ -168,7 +168,7 @@ extern Gfx* globalDL_0x9a8; extern Gfx* globalDL_0xa50; #if defined(__x86_64__) -/* D39 (docs/PCPortResearch.md): on N64 all 49 symbols above link inside the +/* D39 (docs/dev/findings.md): on N64 all 49 symbols above link inside the * Globalimagetable segment at physical 0x02xxxxxx (ge007.ld), so * `globalbank_rdram_offset + (u32)&sym` rebases each onto pGlobalimagetable. * A PE image cannot sit at 0x02xxxxxx, so (u32)&sym is the low 32 bits of an @@ -247,7 +247,7 @@ void texReset(void) romCopy(pGlobalimagetable, &_GlobalimagetableSegmentRomStart, size); #ifdef PORT - /* D68 (docs/PCPortResearch.md): the ROM copy is N64 big-endian; convert + /* D68 (docs/dev/findings.md): the ROM copy is N64 big-endian; convert * the CPU-interpreted u32 fields (IMAGESEG Gfx w1 words and * sImageTableEntry.index) to host order before any code reads them. */ gimgFixupGlobalimagetable((u8 *)pGlobalimagetable); diff --git a/src/game/initplayergaitobject.c b/src/game/initplayergaitobject.c index a8c6e1c2..fe817a47 100644 --- a/src/game/initplayergaitobject.c +++ b/src/game/initplayergaitobject.c @@ -3,7 +3,7 @@ void init_player_gait_object(void) { #ifdef PORT - /* PC port (D86, docs/PCPortResearch.md): the N64 source does `(int)&player_gait_hdr`, + /* PC port (D86, docs/dev/findings.md): the N64 source does `(int)&player_gait_hdr`, * a same-width (32<-32) pointer->int->pointer round trip that's a no-op on N64. * On PC the int cast truncates a real 64-bit pointer to its low 32 bits, and the * implicit int->pointer conversion back into RootNode zero-extends it, producing a diff --git a/src/game/initunk_005520.c b/src/game/initunk_005520.c index 1daad594..c15c4d2d 100644 --- a/src/game/initunk_005520.c +++ b/src/game/initunk_005520.c @@ -9,7 +9,7 @@ #define ANIM_MODEL_SPARE_SLOTS 10 #ifdef PORT -/* PC port (D57, docs/PCPortResearch.md): two rwdata record structs contain +/* PC port (D57, docs/dev/findings.md): two rwdata record structs contain * pointer fields — ModelRwData_HeadPlaceholderRecord (ModelFileHeader* + * void*) and ModelRwData_DisplayList_CollisionRecord (Vertex* + Gfx*) — so * they grow 8 -> 16 bytes on x86-64. modelCalculateRwDataLen() accumulates diff --git a/src/game/model.c b/src/game/model.c index 20f7917c..67bd5d53 100644 --- a/src/game/model.c +++ b/src/game/model.c @@ -103,7 +103,7 @@ Model *modelmgrInstantiateModel(ModelFileHeader *header) } } #ifdef PORT - /* PC port (D57, docs/PCPortResearch.md): pointer-grown rwdata records can + /* PC port (D57, docs/dev/findings.md): pointer-grown rwdata records can * push a model's word count past the (grown) spare-pool capacity; fall * back to a dynamic slot+pool exactly like the LvResetting path above. * The slot is not tracked in g_MaxModelSlots, so it is never reused — @@ -210,7 +210,7 @@ Model *modelmgrInstantiateModelWithAnim(ModelFileHeader *modelFileHeader) } } #ifdef PORT - /* PC port (D57, docs/PCPortResearch.md): same fallback as + /* PC port (D57, docs/dev/findings.md): same fallback as * modelmgrInstantiateModel() — a dynamic slot+pool when no spare * fits the (pointer-grown) rwdata word count. */ if (newModel == NULL && requiredRwdatalen > 0) diff --git a/src/game/ob.c b/src/game/ob.c index 7bde5eaa..2483def4 100644 --- a/src/game/ob.c +++ b/src/game/ob.c @@ -418,7 +418,7 @@ void obBlankResourcesInBank5(void) { #if defined(PORT) -s32 fileGetIndex(char *resname) /* PC port: match the ob.h declaration (u8* vs char* is a hard error in GCC); see docs/PCPortResearch.md §11 */ +s32 fileGetIndex(char *resname) /* PC port: match the ob.h declaration (u8* vs char* is a hard error in GCC); see docs/dev/findings.md §11 */ #else s32 fileGetIndex(u8 *resname) #endif diff --git a/src/game/propobj.c b/src/game/propobj.c index 34bd594d..43a1a701 100644 --- a/src/game/propobj.c +++ b/src/game/propobj.c @@ -3120,7 +3120,7 @@ u32 monAnim35Taser[] = { }; #if defined(PORT) -/* PC port (docs/PCPortResearch.md §11): monitor-animation jump targets. +/* PC port (docs/dev/findings.md §11): monitor-animation jump targets. * MONJUMPTO/MONJUMPCHANCE store an index into this table instead of a raw * address (see chrai.h); the interpreter below resolves it. Must stay after * every monAnim* array definition above. */ @@ -8486,7 +8486,7 @@ bool bgTestHitOnObj(coord3d *arg0, coord3d *arg1, coord3d *arg2, Gfx *gdl, Gfx * * 16-byte Gfx layout (tools_pc/d43_emit.py) with the two N64 32-bit * words preserved in the low dwords of words.w0 / words.w1. Every raw * byte/word index below assumed an 8-byte N64 slot, so it desynced on - * the first G_VTX/G_TRI1 and walked off the DL (§B, PORT-LEARNINGS). + * the first G_VTX/G_TRI1 and walked off the DL (§B, porting-notes.md). * Read the words instead; mirrors PD bgTestHitOnObj (pd_port bg.c). */ u32 w0 = (u32) gdl->words.w0; u32 w1 = (u32) gdl->words.w1; diff --git a/src/game/ramromreplay.c b/src/game/ramromreplay.c index 564b48a6..357da920 100644 --- a/src/game/ramromreplay.c +++ b/src/game/ramromreplay.c @@ -533,7 +533,7 @@ void stop_recording_ramrom(void) } #ifdef PORT -/* PC port (D87, docs/PCPortResearch.md): ramromfilestructure is a compiled-in +/* PC port (D87, docs/dev/findings.md): ramromfilestructure is a compiled-in * ROM asset (the attract-mode demo blobs in `ramrom_table[]` -- ramrom_Dam_1 * etc.), so like every other N64-compiled ROM asset its multi-byte fields are * big-endian. romCopyAligned() is a raw byte copy (no endian translation, by diff --git a/src/game/title.c b/src/game/title.c index a49ed968..84b35af9 100644 --- a/src/game/title.c +++ b/src/game/title.c @@ -491,7 +491,7 @@ void initializeGunBarrelIntro(u8 *gfxBuffer, s32 bufferSize) gunbarrelgfxListPointer = (Gfx*)gfxBuffer; #ifdef PORT - /* PC port (D58, docs/PCPortResearch.md): sub_GAME_7F01BFF8 emits 31 Gfx + /* PC port (D58, docs/dev/findings.md): sub_GAME_7F01BFF8 emits 31 Gfx * (2x VTX + 28 TRI + ENDDL). On N64 sizeof(Gfx)==8 so the 0x100 reserve * covers it (248 B); on PC the Gfx union's trailing `long long` makes * sizeof(Gfx)==16, so the same DL is 496 B and overflowed the reserve, @@ -508,7 +508,7 @@ void initializeGunBarrelIntro(u8 *gfxBuffer, s32 bufferSize) #endif #ifdef PORT - /* PC port (D58, docs/PCPortResearch.md): on N64 barrelDisplayListPtr is a + /* PC port (D58, docs/dev/findings.md): on N64 barrelDisplayListPtr is a * physical DRAM address, so +0x80000000 yields the KSEG0 (RSP-visible) * address embedded in each G_VTX w1 by sub_GAME_7F01BFF8. On PC the same * buffer is a V1 pointer (0x70xxxxxx, s32-safe per dram.c), and diff --git a/src/memp.c b/src/memp.c index 40a177c9..0fafbeb1 100644 --- a/src/memp.c +++ b/src/memp.c @@ -68,7 +68,7 @@ void mempCheckMemflagTokens(s32 poolAreaStart, s32 poolAreaSize) * music heap (MUSIC_ALLOCATION_BYTES in src/music.c, 0x2E000 -> * 0x32000 on x86-64 due to libaudio pointer bloat). Grow the bank on * PC only; STAGE absorbs the difference. N64 value unchanged. - * See docs/PCPortResearch.md D36. + * See docs/dev/findings.md D36. * * D37 (PC port): MUSIC_ALLOCATION_BYTES grows again to 0x38000 (the * re-laid-out bank images live in the music heap; init demand diff --git a/src/music.c b/src/music.c index 3638cea6..8affc220 100644 --- a/src/music.c +++ b/src/music.c @@ -43,7 +43,7 @@ * larger than their N64 32-bit counterparts because their internal pointers * are 8 bytes. The full init-time demand of this bump heap grows from the * N64 ~0x2E000 budget to 0x31660 (measured under gdb). Bump the budget on PC - * only; the N64 value is unchanged. See docs/PCPortResearch.md D36. + * only; the N64 value is unchanged. See docs/dev/findings.md D36. * * D37 (PC port): the SFX/instrument bank blobs are re-laid out to PC-native * form in this same heap (romdataFixupAudioBank); their images are larger diff --git a/src/music.h b/src/music.h index 838cbefd..a3eea8c5 100644 --- a/src/music.h +++ b/src/music.h @@ -19,7 +19,7 @@ typedef struct { // address is offset from the start of .sbk file. - // D35 (docs/PCPortResearch.md): stored as the 32-bit embedded value, not a + // D35 (docs/dev/findings.md): stored as the 32-bit embedded value, not a // host pointer. As `u8 *` this struct is 16 bytes on x86-64 while the ROM // records are 8 (u32 + 2x u16); cast to a real pointer at the use sites. u32 address; diff --git a/tools_pc/d88_emit.py b/tools_pc/d88_emit.py index 9448ef42..b86ffefd 100644 --- a/tools_pc/d88_emit.py +++ b/tools_pc/d88_emit.py @@ -51,7 +51,7 @@ the cumulative shift from growth ahead of them in the file): KNOWN BUG (D125, unfixed as of M-13): the converted blob does not land correctly in the emitted file for some levels (Aztec/Bunker2/Surface2) -- suspect the pass-1 delta / region-`end` handling here (~L308-340), NOT - convert_stream itself. See internals.md D125 + docs/LEVEL-STATUS.md. + convert_stream itself. See internals.md D125 + docs/dev/LEVEL-STATUS.md. - waypoint.neighbours / waygroup.neighbours / waygroup.waypoints / PathRecord.waypoints target arrays: plain `s32` ID lists (NOT file offsets -- graph node indices), NULL/-1 terminated, walked with real diff --git a/tools_pc/playtest.sh b/tools_pc/playtest.sh index f9cc3a55..985fe6e1 100644 --- a/tools_pc/playtest.sh +++ b/tools_pc/playtest.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Playtest launcher for docs/LEVEL-PLAYTEST.md (WS6 human validation). +# Playtest launcher for docs/dev/LEVEL-PLAYTEST.md (WS6 human validation). # # Usage: # ./tools_pc/playtest.sh [difficulty-note] launch a level @@ -17,7 +17,7 @@ set -e cd "$(dirname "$0")/.." export PATH="/c/msys64/mingw64/bin:$PATH" -# name -> -level_XX (mission order; docs/LEVEL-PLAYTEST.md) +# name -> -level_XX (mission order; docs/dev/LEVEL-PLAYTEST.md) LEVELS=( "dam:33" "facility:34" "runway:35" "surface1:36" "bunker1:09" "silo:20" "frigate:26" "surface2:43" "bunker2:27" "statue:22" "archives:24" @@ -64,7 +64,7 @@ fi LOG="build-pc/playtest_L${LEVEL}_$(date +%H%M%S).log" echo "== playtest level ${LEVEL} (difficulty: ${DIFF}) ==" echo "== log: ${LOG}" -echo "== checklist: docs/LEVEL-PLAYTEST.md — spawn, geometry, guards," +echo "== checklist: docs/dev/LEVEL-PLAYTEST.md — spawn, geometry, guards," echo "== doors/pickups/objectives, alarms, exit -> MISSION COMPLETE, auto-advance" rm -f ge007.crash.log