mirror of
https://github.com/jkdansereau/goldeneye-pc-port.git
synced 2026-10-23 15:52:42 +02:00
15527 lines
1.9 MiB
Plaintext
15527 lines
1.9 MiB
Plaintext
# PC port — finding log
|
||
|
||
The raw, chronological engineering record for the port: every runtime bug,
|
||
its root cause, and the fix. Kept unedited for transparency — with one
|
||
documented exception: on 2026-09-15 (ctx-opt) ~122 fat §F table cells were
|
||
moved verbatim into `## Dxx` sections at the end of this file (nothing
|
||
deleted; three rows, D250/D252/D255, gained a status derived from their own
|
||
verdict text — flagged in-section). Recurring patterns are distilled in
|
||
[../porting-notes.md](../porting-notes.md).
|
||
|
||
**Lookup:** use `docs/dev/findings-index.csv` (label, one-liner, status;
|
||
regenerate with `tools_pc/gen_findings_index.py`), then read only the specific
|
||
`## Dxx` entry — never linear-read this file. Known index gaps: four rows whose
|
||
label format the CSV generator's `LABEL_RE` can't parse (`D69 · D78–D84`,
|
||
`RC3 · D167`, `D88.1–D88.3 · D88.5–D88.6`, `D118d`) — grep this file directly
|
||
for those.
|
||
|
||
---
|
||
## 11. Scaffolding review: findings & resolutions
|
||
|
||
The initial scaffolding was reviewed against the sources. Each finding below
|
||
was verified and resolved in the same change. Code comments in `CMakeLists.txt`
|
||
and `port/src/` reference these labels (A1–A4, B1–B4).
|
||
|
||
### A. Build-blocking issues
|
||
|
||
* **A1 — Region/version macros.** The N64 `Makefile` defines a full per-region
|
||
macro set (`VERSION_*`, `LANG_*`, `REFRESH_*`, `LEFTOVERDEBUG`,
|
||
`LEFTOVERSPECTRUM`, `BUGFIX_R*`, `BYTEMATCH`) that the game code `#ifdef`s
|
||
heavily (e.g. `VERSION_EU` in 60+ files). **Resolved:** `CMakeLists.txt`
|
||
now emits the matching `REGION_DEFS` per `ROMID`, mirroring the Makefile
|
||
exactly.
|
||
* **A2 — Missing source files.** The initial `SRC_ENGINE` list + "exclude all
|
||
`io/*.c`" strategy missed files the included code depends on. **Resolved:**
|
||
added `src/cfb.c` (defines `cfb_16`, the framebuffer),
|
||
`src/libultrare/audio/*.c` (`drvrNew`/`env`/`reverb` — the audio "New"
|
||
driver `synthesizer.c` calls), and `src/libultrare/io/vitbl.c` (defines
|
||
`osViModeTable`, referenced ~15× by `fr.c`). `motor.c` / `pfsinit.c` /
|
||
`pfsisplug.c` are **not** compiled — `joy.c` calls `osMotor*`/`osPfs*` only
|
||
for accessory detection, so they are shimmed as no-ops (see B2).
|
||
* **A3 — `mainproc()` / `init.c` + the excluded engine files' symbols.**
|
||
`mainproc()` is defined only in `src/init.c`, which the initial build
|
||
excluded, contradicting `main.c` calling it. **Resolved:** `init.c` is now
|
||
**compiled** (it provides `mainproc()` + the thread-setup helpers). Its
|
||
N64-only `init()` is compiled but not called.
|
||
|
||
Compiling `init.c` (and the other included engine files) pulls in a set of
|
||
symbols that live in the EXCLUDED files (`sched.c`, `rmon.c`, `vi.c`,
|
||
`stacks.c`). Each is now provided:
|
||
* **`src/stacks.c`** (added to `SRC_ENGINE`) — thread-stack arrays
|
||
`sp_boot`/`sp_rmon`/`sp_idle`/`sp_shed`/`sp_main`/`sp_audi` (+ `sp_debug`
|
||
under `LEFTOVERDEBUG`). Pure data; the `SP_*_SZ` sizes in
|
||
`bondconstants.h` match exactly, so `sizeof()` is correct.
|
||
* **`port/src/gesched.c`** (replaces `sched.c`) — scheduler globals
|
||
`os_scheduler`, `gfxClient[3]`, `g_schedViCurrentFrameBuffer`,
|
||
`g_ViChangeVideoModes`, `g_ViModes`, `g_ViModePtrs`, plus
|
||
`get_counters()` and `permit_stderr()`.
|
||
* **`port/src/n64stubs.c`** — `init()`'s N64 symbols (segment starts,
|
||
segment boundary pointers, `jump_decompressfile`, TLB, FPU CSR) and the
|
||
rmon host-I/O functions (`rmonMain`, `osReadHost`, `osWriteHost`,
|
||
`rmonGetToken`, `rmonStatus`, `osSyncPrintf`).
|
||
* **`port/src/libultra.c`** — the libultra OS calls `init.c` makes, plus
|
||
`osTvType` (region-based), `viInit`, `vi_c_debug_MQ` (from the excluded
|
||
`vi.c`), and `osPiReadIo` (cartridge token read, called by `token.c`).
|
||
|
||
Every external symbol referenced by the compiled set was cross-checked
|
||
against its definition; no duplicates (all other `sp_*`/`rmon*` references
|
||
are `extern` decls).
|
||
* **A4 — `libultra.c` missing the high-level message API + others.** The
|
||
initial stub had low-level `osEnqueueMesg`/`osDequeueMesg` but not
|
||
`osSendMesg`/`osRecvMesg` (used in 13/26 files) or several others. **
|
||
Resolved:** `port/src/libultra.c` now provides `osSendMesg`/`osRecvMesg`/
|
||
`osSetEventMesg`, the controller query/read API, `osPfs*`/`osMotor*`,
|
||
`osSetTimer`/`osStopTimer`/`osSetIntMask`, `osViBlack`/`osViSetYScale`/
|
||
`osViSetXScale`/`osViSetEvent`, `osUnmapTLB`, `__osGetFpcCsr`/
|
||
`__osSetFpcCsr`, `piCreateManager`, and `viDebugRemoved`. The scheduler API
|
||
(`osCreateScheduler`/`osScAddClient`/`osScGetCmdQ`) lives in
|
||
`port/src/gesched.c` (it's game scheduler API, not libultra).
|
||
|
||
### B. Research-doc corrections
|
||
|
||
* **B1 — `G_SETTEX` appears unused.** `gsSPUseTexture` (the only emitter) is
|
||
never called in the game code or `gmain.s`; the game uses standard texture
|
||
commands the PD `fast3d` already handles. **Resolved:** §5, §8 (Phase 2),
|
||
and §9 updated — Phase 2 shrinks to verifying the custom CC/RM modes; a
|
||
`G_SETTEX` decode path is kept only as a safety net.
|
||
* **B2 — Saves are EEPROM-based, not Memory Pak/PFS.** The save I/O
|
||
(`src/game/file2.c`) uses `joyGamePak*` -> `osEeprom*`; the PFS/motor code
|
||
in `joy.c` is accessory detection only. **Resolved:** §7 and Phase 4
|
||
updated — Phase 4 is a file-backed EEPROM + no-op PFS/motor shims.
|
||
* **B3 — `src/libultrare/` was unmentioned.** **Resolved:** new §3.2 documents
|
||
the two libultra trees and exactly which files the PC build compiles vs.
|
||
shims, citing `Makefile.libultrare` as ground truth.
|
||
* **B4 — `src/spectrum.c` does not exist.** **Resolved:** removed from the
|
||
`CMakeLists.txt` excluded-files comment.
|
||
|
||
### C. Assembly (`.s`) file symbols
|
||
|
||
The A/B sweeps covered excluded **`.c`** files. A second symbol source is the
|
||
MIPS assembly (`src/*.s`), also not compiled for the PC. Sweeping every global
|
||
label in the `.s` files against the compiled C set found **10 more** undefined
|
||
symbols, in two distinct classes:
|
||
|
||
* **C1 — PRNG (`src/random.s`) — ported to C, not stubbed.** `randomGetNext`,
|
||
`randomGetNextFrom`, `randomSetSeed`, and `g_randomSeed` are used for real
|
||
gameplay logic: `randomGetNextFrom()` feeds the CRC (`src/game/crc.c`),
|
||
`g_randomSeed` is persisted in replay state (`src/game/ramromreplay.c`), and
|
||
`randomGetNext()` drives `RANDOMFRAC()`/`RANDOMGETNEXT_F32()`. A no-op would
|
||
silently corrupt CRCs and replays. **Resolved:** `port/src/random.c` ports
|
||
the three functions **verbatim** (each MIPS instruction mirrored with explicit
|
||
`dsll32`/`dsrl32` masks for bit-exactness) and defines
|
||
`u64 g_randomSeed = 0xAB8D9F7781280783ULL` (the two `.words` in `random.s`).
|
||
Signatures match `src/random.h`. Verified bit-exact against a Python
|
||
simulation of the assembly (the PRNG is effectively 32-bit stored in a u64;
|
||
the initial seed's high 32 bits are exercised only on the first call).
|
||
* **C2 — RSP/ASP/GSP microcode segment markers — dummy definitions.**
|
||
`rspbootTextStart`/`rspbootTextEnd` (`rspboot.s`), `gsp3DTextStart`/
|
||
`gsp3DDataStart` (`gspboot.s`), `aspMainTextStart`/`aspMainDataStart`
|
||
(`aspboot.s`) are ROM addresses of the RSP/ASP microcode, referenced by
|
||
`src/audi.c` and `src/game/rsp.c` (declared `extern long long int <name>[]`).
|
||
**Resolved:** `port/src/ucode.c` defines all six as `long long int <name>[1] = {0}`.
|
||
The graphics path is safe permanently (fast3d interprets the GBI list directly
|
||
and never runs the gsp3D microcode, so the size differences are never read).
|
||
**Carried into Phase 3:** the audio path is the one place it could matter — if
|
||
the port emulates the ASP by *running* the `aspMain` microcode, those two
|
||
markers must point at the real microcode bytes in the ROM (PD `pd.ld`
|
||
`RSP_TEXT_SEGMENT` model); if audio is CPU-only, the dummies are fine.
|
||
A `TODO(Phase 3)` on the `aspMain*` dummies records this decision.
|
||
|
||
### D. Host-compiler portability (GCC 16 vs IDO)
|
||
|
||
The N64 build targets IDO (MIPS). The PC build targets GCC 16 (x86-64). IDO
|
||
tolerates a number of non-standard constructs that the decompilation relies on;
|
||
GCC rejects them as hard errors. Each finding below was verified and resolved
|
||
in the same change. Where a fix could not be made purely in `port/`, the minimal
|
||
`src/` change is `-DPORT`-gated (N64 build untouched) and flagged with a
|
||
`PC port:` comment. These are **portability** fixes, not game-logic changes.
|
||
|
||
* **D1 — `inherits` = struct inlining + duplicate member name.**
|
||
`src/bondtypes.h:44` does `#define inherits struct`, so `inherits X;` inside a
|
||
struct becomes `struct X;`. IDO resolves this by **inlining** X's members into
|
||
the enclosing struct (C++-style base), which the decomp depends on (offset
|
||
comments like `CCTVRecord.unk84` at `0x84`, and positional initializers like
|
||
`New_CCTVRecord(pad)` = `{New_PropDefHeaderRecord(6), 0, pad+0}` only line up
|
||
with the inlined layout). GCC 16 also inlines `struct X;` (correct layout) but
|
||
**hard-errors** on duplicate member names; Clang treats `struct X;` as a no-op
|
||
nested tag (wrong layout). No flag/pragma downgrades the duplicate-name error.
|
||
A sweep of `bondtypes.h` found exactly **one** parent/child member-name
|
||
collision: `CCTVRecord` redeclares `pad` (s32 @0x80) which `ObjectRecord` also
|
||
has (s16 @0x08). The game code's `->pad` (e.g. `setupCctv`, prop.c) is used as
|
||
the **pad index**, which `New_CCTVRecord` stores at 0x08 (the *inherited* pad),
|
||
so IDO resolves the ambiguous `->pad` to the inherited one; CCTVRecord's own
|
||
`pad` (0x80, "lookpad") is never accessed by name. **Resolved:** under `-DPORT`
|
||
only, CCTVRecord's own `pad` is renamed to `lookpad` (layout byte-identical);
|
||
the N64 build keeps `pad` (IDO tolerates it).
|
||
* **D2 — `port/shim/PR/gbi.h` needed an include guard.** The shim redefines the
|
||
`Gfx` union members as little-endian `G*_le` typedefs. With no guard, a TU that
|
||
includes it twice (directly + via another header) re-creates the anonymous
|
||
`Gdma_le`/`Gtri_le`/… struct types → "conflicting types for 'Gdma_le'". The real
|
||
header's guard is `_GBI_H_`; the shim now uses a distinct `_PORT_SHIM_GBI_H_`.
|
||
* **D3 — `New_Vector()`/`New_Coord3d()` called with zero args.** The decomp's
|
||
macros are declared with exactly 3 params (`x,y,z`) and use the
|
||
`IF_ELSE(IS_EMPTY(..))` trick to default each to 0, but the game code calls
|
||
them with **zero** args (`New_Vector()` at chrai.c:1358/1378, `New_Coord3d()`
|
||
at chrai.c:4491), relying on IDO's leniency with empty macro args. GCC rejects
|
||
`New_Vector()` against a 3-param macro. A sweep found these are the **only**
|
||
call sites (all zero-arg). **Resolved in `port/`:** `port/shim/bondtypes.h`
|
||
redefines both as `#define New_Vector(...) {0,0,0}` / `New_Coord3d(...) {0,0,0}`
|
||
after including the real header (the shim is found first via the include path).
|
||
* **D4 — local `#define osSyncPrintf()`/`(x)` arg-count mismatch.** Five files
|
||
locally disable `osSyncPrintf` with a fixed-arity macro (`#define
|
||
osSyncPrintf()` or `(x)`) but then call it with more args (2–4), relying on
|
||
IDO's leniency. GCC errors ("passed N arguments, but takes just M").
|
||
**Resolved:** the five local defines (bg.c, debugmenu_handler.c,
|
||
debug_camera.c, deb_loadallmodels.c, initexplosioncasing.c) are made variadic
|
||
(`#define osSyncPrintf(...)`); they still expand to nothing, so behavior is
|
||
unchanged.
|
||
* **D5 — array-initialized-from-array.** `chraction.c:2485` had `s16 mrs[3] =
|
||
metal_ricochet_SFX;` (a local array "initialized" by a global array). C requires
|
||
a constant-expression initializer; IDO treated it as a runtime copy. **
|
||
Resolved:** replaced with an explicit 3-element copy (no new includes needed).
|
||
* **D6 — flexible array member in a nested context.** `bg.c` declared `s_special
|
||
portal specialportalarray[]` where `s_specialportal` has a flexible array member
|
||
(`u8 portallist[]`). C forbids initializing a FAM in a nested context (an array
|
||
element); GCC enforces it, IDO tolerated it. The code treats the data as a flat
|
||
byte array (cast to `u8*` in `sub_GAME_7F0B37EC`, walked byte-by-byte). **
|
||
Resolved:** defined `specialportalarray` as a flat `u8[]` with the identical
|
||
byte sequence.
|
||
* **D7 — AI X-macro system (`chraidata.c`) — RESOLVED.** The AI command system
|
||
(`bondaicommands.h` + `aicommands.def` + `CPPLib.h`) uses deep preprocessor
|
||
metaprogramming (`SWITCH` with 49 fixed params + `IF_VA`/`IS_EMPTY`,
|
||
`DEFINED(SETUPSUBROUTINES(ID))` token-pasting, `_AI_CMD_POLYMORPH` redefined
|
||
per-include) that relies on IDO-specific `##`/expansion behavior. GCC rejects
|
||
several of these (e.g. `pasting ')' and '_'` in `DEFINED(SETUPSUBROUTINES(ID))`,
|
||
`SWITCH requires 49 arguments, but only 20 given`). Note `src/aicommands2.h`
|
||
is a **pre-generated** header (from `tools/cmdbuilder.c`) already included by
|
||
`bondaicommands.h:864`; the failing path is the *raw* `aicommands.def` include
|
||
(bondconstants.h:731 for the `AI_CMD` enum, chrai.c:172/920 for the command
|
||
table).
|
||
|
||
A general GCC-clean reimplementation of `SWITCH()` was investigated and ruled
|
||
out: its content arguments are single preprocessor arguments that expand to
|
||
top-level comma lists, and the C preprocessor cannot detect where one content
|
||
ends and the next `CASE`/`VAL` begins (arity is not recoverable after
|
||
expansion). The three active `SWITCH()` call sites in chraidata.c (m_IdleAnimations,
|
||
m_BashKeyboard, m_RunToBondPersistent) each have a **hand-written equivalent
|
||
already present in the file behind `#if 0`** — the author's own reference form.
|
||
Each was verified byte-identical to the IDO expansion of the adjacent `SWITCH`
|
||
call (including the `IFNewRandomGreaterThan(N, lbl)` == `SetNewRandom()` +
|
||
`IFRandomGreaterThan(N, lbl)` identity, confirmed against the runtime check in
|
||
chrai.c:1605). **Resolved:** under `-DPORT` the three `#if 0` blocks are
|
||
activated (the `SWITCH` calls become dead `#else` branches); `port/shim/bondaicommands.h`
|
||
keeps the original 49-param `SWITCH` defined but replaces it with a marker
|
||
that fails loudly if any *new* game code uses `SWITCH()` on the port. See also
|
||
D8–D11 for the sibling paste/comma issues in the same macro system.
|
||
* **D8 — `MODELSKELETON`/`New_ModelSkeleton` paste failure.** The model-record
|
||
macros in bondconstants.h write `SKELETON(##NAME##)`-style pastes that IDO
|
||
tolerates but GCC hard-errors on ("pasting does not give a valid
|
||
preprocessing token"). **Resolved:** `port/shim/bondconstants.h` re-emits the
|
||
affected macros with equivalent byte-identical expansions.
|
||
* **D9 — `CPPLib.h` helpers.** The CPPLib metaprogramming header's
|
||
`IS_EMPTY`/`IF_VA`/`DEFINED` family uses paste tricks that break under GCC.
|
||
**Resolved:** `port/shim/CPPLib.h` provides a paste-free reimplementation with
|
||
identical results for every usage in the tree (intercepted via the include
|
||
path; inert on N64).
|
||
* **D10 — file-record macros paste `&` onto NAME.** `CHRFILERECORD`/
|
||
`GUNFILERECORD`/`SUIT_LFRECORD` and `GUNSTATS` write `{& ## NAME ## _header, …}`.
|
||
IDO tolerated the failed `&##NAME` paste; GCC hard-errors. **Resolved:**
|
||
`port/shim/bondconstants.h` rewrites them with the `&` kept out of the paste;
|
||
expansion is byte-identical (`&NAME_header`, `&NAME_stats`).
|
||
* **D11 — generated `CALL()` double trailing comma.** The pre-generated
|
||
`CALL()` (aicommands2.h) concatenates `SetReturnAiList()` and
|
||
`SetChrAiList()`, each ending in its own trailing comma, then appends its own
|
||
separator → `…, ,` inside the array initializer. IDO accepted it; GCC
|
||
hard-errors. The artifact byte is never executed: `AI_SetChrAiList(CHR_SELF)`
|
||
switches to the called list at offset 0 and `AI_Return` resumes the return
|
||
list at offset 0 (chrai.c), so anything after the `SetChrAiList` record in a
|
||
`CALL` is dead. **Resolved:** `port/shim/bondaicommands.h` re-emits `CALL`
|
||
without the artifact byte.
|
||
* **D12 — 64-bit pointer→integer in static initializers.** On a 32-bit target
|
||
(MIPS) storing an array address in a 32-bit field is fine; on x86-64 GCC 16
|
||
makes it a hard error ("initializer element is not computable at load time")
|
||
that no warning flag suppresses. Two sites, two fixes:
|
||
* `process_monitor_animation_microcode` jump targets: the monAnim script tables
|
||
(chrai.h) stored raw `monAnim*` array addresses in the tvcmd word's 32-bit
|
||
field. **Resolved:** under `-DPORT` the initializers store an *index* into
|
||
`_PORT_monAnimPtrs[]` (defined in propobj.c after all 35 monAnim arrays, via
|
||
a shared `_PORT_MONANIM_LIST` x-macro in chrai.h) and the interpreter
|
||
resolves index→pointer at `TVCMD_SETCMDLIST`/`TVCMD_RANDSETCMDLIST`. On-script
|
||
layout unchanged (12-byte tvcmd words, same opcode bytes).
|
||
* `assets/obseg/setup/{u,e,j}/UsetuplenZ.c`: the `intro[]` table stored
|
||
`&credits_data_0` in an `s32` slot. **Resolved:** PORT-gated replacement with
|
||
`0` (the N64 initializer is kept in the `#else` branch). The value is only
|
||
consumed by romCopy-style size arithmetic, which is inert until Phase 2.
|
||
(These files are currently excluded from the PC build per D16; the patch
|
||
keeps them compilable should any setup data be pulled into the host link.)
|
||
* **D13 — assorted IDO leniency hard errors.** Small strictness failures with no
|
||
semantic content:
|
||
* `front.c:2405`: bare `return;` in an `s32` function (GCC error). The sole
|
||
caller ignores the value → PORT-gated `return 0;`.
|
||
* `audi.c` `audioInit`: C++-style array initializer `s32 sp48[…] =
|
||
CUSTOM_FX_PARAMS_N;` (IDO accepted, GCC rejects) → PORT-gated explicit
|
||
`memcpy` of the same bytes.
|
||
* **D14 — `_Printf` prototype clash (xstdio.h vs xprintf.c).** The IDO printf
|
||
engine is compiled for the PC (`src/libultrare/libc/xprintf.c` + helpers
|
||
`src/libultra/libc/xlitob.c`/`xldtob.c`, added to CMakeLists) because
|
||
`src/sprintf.c`'s `sprintf()` calls `_Printf` directly. xstdio.h declares it
|
||
with `u8 *` params; xprintf.c defines it with `char *` — IDO-compatible,
|
||
GCC-fatal. **Resolved:** `port/shim/libc/xstdio.h` renames the declaration
|
||
(`_Printf_u8decl`) before pulling in the real header via `#include_next`, so
|
||
xprintf.c's definition is the sole prototype. The only caller (sprintf.c)
|
||
doesn't include the header and passes its char*-based outfun, matching the
|
||
definition exactly.
|
||
* **D15 — host libc lacks IDO/K&R symbols.** MinGW provides none of: `bcopy`/
|
||
`bzero` (declared in PR/os.h with `int` sizes), `__libm_qnan_f` (quiet-NaN
|
||
helper used by gu/cosf.c and game/zlib.c), the `tlbmanage*` API (boss.c calls
|
||
two of them; tlb_manage.c is excluded — N64 TLB management), and
|
||
`g_ViXScales`/`g_ViYScales` (defined in vi.c on N64, written at runtime by
|
||
fr.c). Also `chrObjRandomGetNext/SetSeed` + `g_chrObjRandomSeed` live in
|
||
`src/game/chrObjRandom.s` (not built for PC). **Resolved:** all provided in
|
||
`port/src/n64stubs.c` / `port/src/libultra.c`; the chrObj PRNG is ported
|
||
verbatim from chrObjRandom.s into `port/src/random.c` (same xorshift as
|
||
`randomGetNext`, separate state).
|
||
* **D16 — asset data strategy for the single host link.** The N64 build links
|
||
each model/level file into its own ELF and `.incbin`s compressed ROM blobs;
|
||
none of that transfers. Three buckets:
|
||
* **Compiled for real** (self-contained, unique symbol names):
|
||
`assets/animationtable_data.c`, `animationtable_entries.c`,
|
||
`oddtextures.c`, `font_chardatae.c`, `font_chardataj.c`, `font_dl.c`,
|
||
`rarewarelogo.c` → added to CMakeLists (`SRC_ASSETS`). Real font/image/
|
||
animation-table data is therefore live in the binary.
|
||
* **Excluded — symbol collisions:** every `assets/obseg/{bg,brief,setup,stan}/*.c`
|
||
defines generic file-local globals (`header`, `room_data_table`, `intro`,
|
||
`padlist`, …) that collide in a single link. The N64 build isolates them by
|
||
per-file ELF linking; the PC build cannot. Their top-level symbols are
|
||
stubbed instead.
|
||
* **Stubbed — ROM-derived / absent:** `port/src/assetstubs.c` defines the 758
|
||
model/level/text symbols referenced by ob.c's `file_resource_table`
|
||
(one zero word each), the 14 `ramrom_*` replay pointers (NULL), and the
|
||
`unknown2`/`unknown2_end` pair (same address → zero-length romCopy in
|
||
title.c). All `*Segment*` linker-script markers (normally from ge007.ld)
|
||
are defined as NULL `u32 *` in n64stubs.c, so address arithmetic computes
|
||
zero lengths and any Phase-1 romCopy is a safe no-op. Real data arrives
|
||
with ROM loading in Phase 2 (`port/src/romdata.c`), which will also replace
|
||
the table's address arithmetic.
|
||
* **D17 — fast3d placeholder.** `port/fast3d/` contains only `gfx_api.h`; the
|
||
real software RSP lands in Phase 2. `port/src/gfxstub.c` provides no-op
|
||
implementations of every entry point (plus `gfx_current_dimensions`) so
|
||
video.c links. **Delete it when the real fast3d sources are added** (same
|
||
symbols → duplicate-definition error if both remain).
|
||
* **D18 — GE ROM header offsets.** The N64 header is GE-specific
|
||
(`src/rom_header.s`): magic `0x80371240` @0x00, ROM name "GOLDENEYE" @0x20,
|
||
cartridge ID "GE" **@0x3C**, country byte **@0x3E** ('E' US / 'P' EU /
|
||
'J' JP), version @0x3F. The standard-N64 offsets (ID @0x38, country @0x3A)
|
||
are wrong for GE — verified against `baserom.u.z64` (hexdump: `... 47 45
|
||
45 00` at 0x3C–0x3F). romdata.c validates all four fields.
|
||
* **D19 — music table is big-endian in the ROM.** The `.music` section of
|
||
`assets/music/music.s` (the `RareALSeqBankFile` header + 63 × 8-byte
|
||
`music_table_entry` records) is stored in the ROM in **big-endian** word
|
||
order: at 0x419790 the bytes are `00 3f 00 00 | 00 00 01 fc` = BE u16
|
||
seqCount=63 (= NUM_MUSIC_TRACKS), BE u16 unk=0, then BE u32 offset 508 for
|
||
the first track. The N64 build evidently assembles that section with
|
||
big-endian output; the PC port never reassembles it — the CSV offsets are
|
||
unaffected and `musicSeqPlayerInit()`'s romCopy + `musicSeqFileNew()`
|
||
patching work on the raw bytes unchanged.
|
||
* **D20 — US music tracks are RLE-compressed in ROM.** The `music_file`
|
||
macro incbins `build/u/assets/music/<name>.rz` under `.ifdef VERSION_US`, so
|
||
every M* track lives in the ROM 1172/RLE-compressed; the filelist sizes are
|
||
the compressed lengths and per-track `end_` symbols = start + csv_size are
|
||
correct boundaries. Decompression happens at playback (Phase 3 audio), not
|
||
at load — no Phase-1/2 action needed.
|
||
* **D21 — scanner-prefixed manifest names.** `filelist.<r>.csv` contains
|
||
entries whose file column is prefixed with the scan origin, e.g.
|
||
`assets/ge007.u.117880.jfont_dl.bin` (real name `jfont_dl.bin`).
|
||
gen_romassets.py aliases such names to their suffix after the
|
||
`ge007.<r>.<hexoffset>.` prefix, so font/image/oddtexture segments resolve.
|
||
* **D22 — CMake silently skips `.s` files without ASM enabled.** The first
|
||
assembly source in the PC build (`romassets_u.s`) was added to the target
|
||
but never compiled: `project(ge007 C CXX)` had no ASM language, and CMake
|
||
does not error on unknown extensions — it just drops them, surfacing only as
|
||
a wall of undefined asset symbols at link. Fixed with
|
||
`project(ge007 C CXX ASM)`. Lesson: when adding a new source *extension*,
|
||
verify the object file actually appears in the build graph.
|
||
* **D23 — absolute cart-address asset symbols (Phase 1 approach).** Instead of
|
||
zeroed stubs (D16), every ROM asset symbol is now an ABSOLUTE cart address:
|
||
`scripts/gen_romassets.py` emits `port/src/romassets_<r>.s` defining all
|
||
obseg labels (in `file_resource_table.inc.c` order — ob.c computes file
|
||
sizes as `table[i+1].hw_address - table[i].hw_address`, so order matters),
|
||
ramrom files, music tracks/markers, and the ge007.ld segment markers as
|
||
`0x10000000 + rom_offset` from `filelist.<r>.csv`. romdata.c maps the .z64
|
||
at exactly 0x10000000 (VirtualAlloc preferred address), so `&symbol` is a
|
||
live host pointer and the PI shims (`osPiStartDma`/`osPiRawStartDma` in
|
||
libultra.c) service reads as bounds-checked memcpys. This makes both DMA'd
|
||
assets (models, banks) and direct-read assets (fonts, image display lists)
|
||
work with unmodified game code. `assetstubs.c` is deleted; n64stubs.c keeps
|
||
only the pure-RAM segment symbols (`_bssSegmentEnd`, `_csegment*`,
|
||
`_inflate/_gameSegmentVaddr*`). US-only for now: EU/JPN manifests have
|
||
naming inconsistencies (trailing-Z add/drop, region subdirs) that need the
|
||
same treatment before those regions build.
|
||
|
||
### E. Compile + link milestone (status)
|
||
|
||
All ~235 translation units compile and the target **links**: clean build from
|
||
scratch is `236/236` steps, zero errors (`ninja ge007 -k 0`), producing
|
||
`ge007.x86_64.exe`. Remaining warnings (~4.5k) are expected IDO-leniency noise
|
||
(int-conversion, implicit declarations, etc.), demoted via the CMake warning
|
||
flags.
|
||
|
||
**Phase 1 (boot to window) is done:** `romdata.c` loads and validates the ROM
|
||
(`baserom.u.z64` at the repo root, or a final .z64 in `data/` / next to the
|
||
exe), maps it at cart base 0x10000000 (D23); `video.c` opens an SDL2 640×480
|
||
window with a GL context and clears/presents every frame; `main.c` runs a
|
||
demo loop (ESC quits) in place of `mainproc()`, which is deferred until the
|
||
software RSP + scheduler can service real frames. Verified: ROM header passes
|
||
validation, 12 MB mapped at 0x10000000, window renders for the full test
|
||
duration. Asset symbol spot-checks against the ROM confirm correct offsets
|
||
(e.g. music bank BE seqCount=63 @0x419790, first track offset 508 → Mno_music
|
||
@0x41998C; sfx/instrument banks start with GE's `B1` header).
|
||
|
||
### F. Phase 2 runtime findings (threads, DRAM, addresses)
|
||
|
||
**Dxx index — jump to a finding, do not linear-read this section.** §F
|
||
covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
|
||
|
||
| Range | Topic | Status |
|
||
|---|---|---|
|
||
| D24–D30 | green threads / kernel / crash handler | resolved |
|
||
| D31–D42 | boot ABI: ANIM_DATA, music seq-table, libaudio banks, Globalimagetable rebase, GL context, rsp toggle | resolved |
|
||
| D43–D50 | model-file loading → offline sidecar "Plan B" (`d43_emit.py`); D50.6 texCopyGdls full-slot copy | resolved |
|
||
| D51–D58 | font pixeldata, osGetCount tick rate, model RW pools, cseq BE header, synth slots, RLE menu bg, watch raw offsets, gun-barrel DL | resolved |
|
||
| D59–D68 | intro render: blood-RLE clobber, DMA validate, OSMesgQueue, HEADS/BODIES sentinels, romCopy width, image_entry, Globalimagetable BE→LE | resolved |
|
||
| D69 · D78–D84 | stage load (`load_bg_file`): bg/stan offline sidecar (`d69_emit.py`) + StandTile/bg_room_data ABI | resolved |
|
||
| D70–D74 | intro-logo pixels: C-array bswap, UV path, sinf/cosf `DVAL()`, texture-import truncation | resolved |
|
||
| D75 | front-end 3D model transforms (D77 audio split out below — resolved, unrelated cause) | **PARTIAL (M-197 fixed the logo texture blobs; v0.4.0 campaign 2026-09-28: Nintendo + legal pages confirmed clean, but the Rareware logo still shows a minor texture-filtering artifact -- re-opened as a cosmetic known issue, README-disclosed): the D50 sidecar converter dropped the models' embedded texture blobs (`PnintendologoZ`/`PgoldeneyelogoZ`/`PlegalpageZ`) — `d43_emit.py` now raw-copies + round-trip-validates them; NTSC sidecars regenerated. Remaining: pal/jpn regen on ROM-holding machines.** Historical trail — M-32 triage (see §F "D75 ADDENDUM"): (a) D73-scope-gap RULED OUT (gu tree fully endian-clean). Splits in two: Bug 1 = logo/photo transform = the parked D114/D116 fast3d viewport mirror (not game code). Bug 2 = absent animated models = category (b). M-32b runtime probe (`GE_D75=1`): `render_pos`/`dynAllocate`-arena hypothesis RULED OUT (render_pos valid + fresh each frame), model instances valid (nMtx 21/1), zero fast3d DL warnings — failure is downstream in `drawjointlist`/`dotube` vtx/node-DL resolution or an off-screen `basemtx`. Needs a drawjointlist-level probe. M-33 (upright captures, D168 fixed): "Bug 1 = D114/D116 mirror" retracted; **gun-barrel Bond RENDERS fine** (walk + fire, upright) — the "absent" reports were the flipped capture; **Nintendo logo genuinely broken** (renders as 2 white blobs, shifted left — a real `logoinst` transform/geometry bug, not a flip); cast roll not re-captured. See §F "D75 Bug 2 — M-33 UPDATE". **v0.2.0-pre playtest (user):** Nintendo logo still broken; Rareware logo nearly right but its texture filtering looks off; MISSION COMPLETE / mode-select screens have no 3D models at all, so the earlier "absent mode-select model" framing was inaccurate.** |
|
||
| D76 · D164 | **Legal screen only draws line 1 — root-caused (M-31)**: linker-adjacency assumption broken by mingw. — full `## D164` entry at file tail | **FIXED** — this row and the `## D164` entry below were stale; the proposed one-line `#ifdef PORT` fix was actually committed `8dfc578f4` (2026-08-31), well before this correction (2026-09-18). Docs simply never caught up to the merge. |
|
||
| D159 | front-end wallet-Bond photo "interlaced"/combed (RC1 / D149) — `texSwapAltRowBytes` odd-row 8-byte pre-swap (N64 RDP odd-line TMEM XOR compensation) not reversed by fast3d | FIXED (`#ifdef PORT` no-op the swap in `image.c`) |
|
||
| 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)**: cursor P-controller + hipfire C-pulse. — full `## D165` / `## D166` entries at file tail | FIXED |
|
||
| D180 · D181 | **Native-PC input pass (QoL run)**: click-to-lock capture + ScreenShakeIntensity hook. — full `## D180` / `## D181` entries at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: LANDED, config-gated, feel-checks owed |
|
||
| D186 | **`Video.FpsCap` throttles the sim, not just presentation (M-39 diag, M-42 clamp).** The frame-pacing `sysSleep`+busy-wait in `sync_framerate_with_timer` (`port/fast3d/gfx_sdl2.cpp`) runs inline on the scheduler thread (`osSpTaskStartGo`→`gfx_run`→`swap_buffers_begin`), so a low cap blocks VI-retrace delivery and drags every game thread down to the cap rate (no N64 analogue — RSP/RDP are separate silicon). **Landed:** `0 < FpsCap < 30` → treated as `0` (uncapped) + warning, at both `videoInit` (normalises a bad `ge007.ini` for the next `configSave`) and `gfx_sdl_set_target_fps` (covers the F10-overlay live path); caps ≥ 30 unchanged. Verified: default `FpsCap=0` framediff 3/3 golden-identical; `FpsCap=10` now runs at the uncapped rate; `FpsCap=60` still paces. **Owed:** move pacing off the scheduler thread so any cap only drops presented frames. See §F "D186" + porting-notes.md §E. | OPEN (2026-09-26, user direction: target an "original" framerate/timing mode, with 60 fps + options on top; pacing must stop throttling the sim). Earlier: CLAMP LANDED (`port/fast3d/gfx_sdl2.cpp`, `port/src/video.c`); real fix owed |
|
||
| D187 | **HUD ammo-type/weapon icon vanished at `-O2` (M-46 root-cause, M-47 fix + verify).** — full `## D187` entry at file tail | FIXED (`src/game/gunfire.c`, `#ifdef AVOID_UB`); PR #19 |
|
||
| D188 | **Linux `-O2` boot crash: `_Printf` passes `&args` of a by-value `va_list` param (M-48).** — full `## D188` entry at file tail | FIXED (`src/libultrare/libc/xprintf.c`, `src/sprintf.c`, `#ifdef PORT`); PR #22 |
|
||
| D189 | **Linux `-fstack-protector` aborts on the decomp's latent stack-buffer overruns (M-48) — FIXED.** — full `## D189` entry at file tail | FIXED (`CMakeLists.txt`, `src/game/stan.c` `#ifdef PORT`); PR #23 |
|
||
| D190 | **Linux `-level_45` SIGSEGV at load in `add_ptr_to_objective` (`objective.c:64`) (M-48, open).** — full `## D190` entry at file tail | **CLOSED — not a port bug; level 45 is MP-only and has no solo setup file in the ROM (root-caused M-105, reproduced on Windows).** Full chain… |
|
||
| D191 | **Windows `-O2` release-bundle crash: SIGSEGV in `modelGetNodeRwData` (`src/game/model.c:478`) — the `root->Parent` node-tree walk (M-49, user playtests of the…** — full `## D191` entry at file tail | FIXED + PLAYTEST-VERIFIED (M-79).… |
|
||
| D193 | **NPC AI locomotion is too slow — characters don't move to their destinations at the original game's rate (M-49, user playtest; clarified after the FULL 21-level…** — full `## D193` entry at file tail | **FIXED + user-playtest-verified (M-81) —… |
|
||
| D194 | **Mouse input needs another tuning pass — RMB aim over-sensitive + sensitivity couples to frame/sim rate (M-49, user playtest).** — full `## D194` entry at file tail | FIXED (2026-09-26: user reports the v0.3.0→v0.4.0 mouse changes feel solid and glitch-free; code-side validation owed). Earlier: **PARTIAL — M-120 human playtest… |
|
||
| D195 | **Transparency / alpha surfaces broken on Control (M-49, user playtest)** — full `## D195` entry at file tail | **FIXED (`port/fast3d/gfx_pc.cpp`, `gfx_sp_vertex`) —… |
|
||
| D197 | **Character face/head textures wrap around the head, seen on Silo (M-49, user playtest).** — full `## D197` entry at file tail | CLOSED — user playtest v0.2.0-pre |
|
||
| D198 | **`alAdpcmPull`/`_decodeChunk` used `K0_TO_PHYS` (an unconditional `& 0x1FFFFFFF` mask) on 64-bit heap pointers (`src/libultra/audio/load.c`).** — full `## D198` entry at file tail | FIXED (`src/libultra/audio/load.c`) — part of the Phase-3 mixer landing, not yet build/runtime-verified in isolation |
|
||
| D199 | **Phase-3 software audio mixer: macro-swap `aXxx` opcode execution + reverse-engineered `aSetBuffer` persistent-context semantics (`port/src/mixer.c`…** — full `## D199` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: LANDED (`port/src/mixer.c`, `port/include/mixer.h`, `include/PR/abi.h`) — functional on `-level_09`, context-inference risk noted, needs a wider level sweep |
|
||
| D200 | **`-level_09` segfault: reverb's `ALDelay.input/output` are u32 — `-d->output` zero-extends to a +4GB pointer offset on 64-bit (D3x class, FIXED).** `src/libultrare/audio/reverb.c` back-references the delay ring as `&r->input[-d->output]`; on N64 (s32 `ptrdiff_t`) the u32 negation wraps to a small negative index, on PC x86-64 it zero-extends to +4294967136 samples → `_loadBuffer`/`_saveBuffer` write 13–52KB wild, trampling the delay array / `r->base` / adjacent heap in a self-propagating loop until unmapped memory. Fixed with `(s32)` negation casts at 5 sites (PD ground truth `n_reverb.c` does exactly this); ramalign line gets `(s64)`. Verified: `-level_09` runs 120 s+ crash-free, DRAM guard window over the FX region sees zero OOB writes. | FIXED — 5-line cast in reverb.c (this session) |
|
||
| D196 | **OS mouse cursor stays visible after closing the F10 options overlay in click-to-lock mode (M-49, user playtest).** — full `## D196` entry at file tail | **FIXED (`6e758f1d`, merged to `main`) —… |
|
||
| D192 | **Front-end mission/level-select grid pointer still can't reach the outer cells — but only under `Input.MouseCaptureMode=0` (legacy always-grab) (M-49, user…** — full `## D192` entry at file tail | CLOSED — the buggy mode 0 was removed entirely in the v0.2.0 prep pass (see D239); click-to-lock is now the only capture mode, so the unreachable-cells path no longer exists |
|
||
| D221 | **SELECT FILE mouse hit-box offset — compiler stack-layout dependency, not coordinate mismatch (M-87 QA / M-91 fix).** — full `## D221` entry at file tail | **FIXED (M-91)** — explicit `{min,max}` `coord2d` pairs under `#ifdef PORT`; human mouse-playtest confirmed. Distinct from D192 (mode-0 clamp range, still open). |
|
||
| D222 | **High `Video.FovScale` breaks culling — NPCs and some world objects stop rendering near the screen edges (M-87, user QA).** — full `## D222` entry at file tail | **FIXED, awaiting visual verification (M-121).** Root cause traced one step further than the original write-up… |
|
||
| D223 | **QoL ask: mouse-wheel weapon cycling should be directional like a normal PC shooter — wheel up = next weapon, wheel down = previous — instead of both directions…** — full `## D223` entry at file tail | **FIXED (M-105, `port/src/input.c`) —… |
|
||
| D224 | **QoL gap (M-87, from a PD-PC-port survey): the N64 Rumble Pak is fully dropped instead of modernized.** — full `## D224` entry at file tail | **LANDED via D401 (2026-09-27, re-promoted from DEPRIORITIZED).** Rumble Pak now drives real SDL gamepad haptics (`SDL_GameControllerRumble`) via the port-layer `inputRumble*` API + `CONT_CARD_ON`/`PFS_ERR_DEVICE` gating; single global `Input.RumbleScale` (0..1) config + one `Rumble strength` slider row. Real-pad playtest **done 2026-09-28 (Steam Deck: rumble works and is validated; user: include in v0.4.0)**. Gamepad-only —… |
|
||
| D225 | **Dev-tooling QoL (M-87, user ask): a debug-build/env toggle that unlocks all missions + all cheats, for testing.** — full `## D225` entry at file tail | **FIXED (2026-09-18)** — `GE_DEBUG_UNLOCKALL=1` short-circuits `fileGetIsCheatUnlocked()` (`src/game/file2.c`, `#ifdef PORT`, cached getenv) to always return TRUE; default off, no save mutation. Build-verified; headless boot with `GE_STARTMENU=7` (mission select) crash-free. |
|
||
| D226 | **QoL ask: player-adjustable HUD scale for the ammo counter, bottom-left pickup/item status text, and top-of-screen dialogue/subtitle text.** — full `## D226` entry at file tail | **FIXED (2026-09-24)**: Game.HudScale 75–150% (user-verified, capped at 150) via fast3d G_HUDSCALE_EXT rect scaling about per-element anchors; dialogue/bottom messages width-capped. |
|
||
| D227 | **Sky renders as two independently-moving halves — "two sky processes" (M-93 QA; root cause M-100b: unit error, `unk20`/`unk24` are S10.5 not texels).** — full `## D227` entries (M-94→M-100b) at file tail | **FIXED (M-100b, PR #43)** — also fixed "scrolls too fast" (M-96) + over-tiling; human play-test confirmed. Full history in the sections. |
|
||
| D229 | **IsWater-level water renders green, pulses blue<->green (M-101; Frigate `-level_26`).** — full `## D229` entry at file tail | **FIXED (port-layer)** — ZLIB+CI8 water image now imports via the CI8 palette path (`gfx_pc.cpp`); Frigate verified live ("looks better"); sweep + Surface 2 by-eye still owed. Full trail in the section. |
|
||
| D230 | **In-level music quality: a bass instrument sounds corrupted on PC, correct on N64 reference (M-105, user reports; repro refined M-106).** — full `## D230` entry at file tail | **FIXED (M-121, v0.2.0) — RAW16 wavetable byte-order bug, fixed via in-place bswap16 in `romdataFinishCartMap()`; owes a by-ear pass** (Control elevator+track, Cavern elevator, Runway ~5-7s in). |
|
||
| D231 | **QoL batch: mute-on-focus-loss + screenshot-hotkey productization (M-105, roadmap Tier 3, port-only).** `Audio.MuteOnFocusLoss` (drop mixed blocks while the window is unfocused; pause-on-focus-loss deferred because of the D204-family AI feedback loop) plus `Video.ScreenshotKey`/`Video.ScreenshotDir` (scancode-based key, timestamped `screenshots/` PPMs). Implemented M-105 as PR #56 (`port/src/audio.c`, `port/src/video.c`, `port/include/audio.h`), never merged. | **CLOSED — PARKED/DEPRIORITIZED (user, 2026-09-25).** PR #56 closed and the `feat/qol-mute-screenshot` branch was deleted from GitHub. The code is in no release branch; it's kept locally only (local branch `feat/qol-mute-screenshot`, commit `3f6e8d98`, plus a patch in the gitignored `docs/dev/notes/parked/`). If it's revived: rebase onto the current release branch and playtest alt-tab mid-level plus the custom screenshot key/dir. |
|
||
| D233 | **All levels (intermittent): indoor level geometry invisible near doors — you can see through walls/floors into rooms you can't see into, while world objects…** — full `## D233` entry at file tail | **FIXED (port/fast3d/gfx_pc.cpp, `gfx_sp_tri1`) —… |
|
||
| D234 | **Dam: guard towers along the dam structure invisible except the first one (M-106, user QA report).** — full `## D234` entry at file tail | **VISUALLY VERIFIED FIXED (M-108) —… |
|
||
| D244 | **Self-hosted CI runner (`corpyvt6agent01`) link failure: `undefined reference to 'objectiveGetStatus_WEAK'` (2026-09-12, first-ever build attempt on the…** — full `## D244` entry at file tail | **OPEN, not yet fixed.** Next… |
|
||
| D228 | **AK47 white missing-texture patches after D217's texpool fix — separate failure mode (M-97, `-level_34` FACILITY).** — full `## D228` entries at file tail | **FIXED (`port/fast3d/gfx_pc.cpp`, `palette_to_rgba32`)** — byte-order bug in the G_TT_IA16 branch; user-verified live. Fix write-up: second `## D228` section. |
|
||
| D204 | **PC audio ran ~2 % below real time permanently (AI feedback loop's 3 ms setpoint vs PC jitter).** — full `## D204` entry at file tail | FIXED (`port/src/audio.c`, `port/src/libultra.c`, `port/include/audio.h`) — measured; a by-ear pass on a real playthrough still owed |
|
||
| D203 | **Steam Deck (SteamOS, x86_64 Linux) v0.1.0 release bundle: Facility (`-level_34`) crashes "after loading in as James Bond" (user bug report).** — full `## D203` entry at file tail | CLOSED — root cause + fix in D253. |
|
||
| D201 | **`src/libultra/audio/bnkf.c` bank/instrument/sound/wavetable relocation offsets were `s32`, truncating the real 64-bit base pointer.** — full `## D201` entry at file tail | FIXED (`src/libultra/audio/bnkf.c`) — part of the Phase-3 mixer landing, not yet build/runtime-verified in isolation |
|
||
| D202 | **Silenced PPK "slap" + broader real-time mixer corruption (M-52→M-67).** — full `## D202` entry at file tail | FIXED (2026-09-26, user by-ear: silenced PPK sounds good, no slap or lingering loop; M-66b expiry + later audio fixes). Earlier: OPEN — by-ear check owed (2026-09-26): fire the silenced PPK a lot, listen for a slap/crackle or a lingering loop. Earlier: ROOT CAUSE ESTABLISHED (M-66); Disposition C implemented + measured (M-66b) — stuck loop is sound 203, a faithful N64 quirk; PC-side expiration fades it. M-67 static analysis exhausted. Full trail in the section. |
|
||
| D206 | **PC played every SFX one bank slot too high — pointer-width layout shift in `struct ALInstrumentAlt_s` (M-76).** Root cause of D205 (3)/(4) + D202's "slap". — full `## D206` entry at file tail | **FIXED (M-76, `src/snd.c:1001`, `#ifdef PORT`)** — ABI/layout-only (D3x class); user by-ear A/B vs N64 = PASS (M-77c) → D202 closed. |
|
||
| D207 | **Alarm klaxon starves combat SFX and doesn't recover after it stops (surfaced by D206, M-77).** — full `## D207` entry at file tail | MONITORING — user 2026-09-26: "fixed possibly"; M-201 live captures showed no drops (see D241). Earlier: MONITORING (M-77c: user reports the alarm is acceptable in play after D206; no fix shipped) — voice-starvation hypothesis did NOT hold up (M-77b); designed pass reverted. |
|
||
| D208 | **Player weapon-fire sound goes permanently silent mid-firefight (all other SFX unaffected) — `struct` field `bondview.h:200 s32 field_A48` is used as a second…** — full `## D208` entry at file tail | FIXED + user playtest-verified (M-78d) — Bunker firefight: player gunshots persist through the whole engagement and survive the klaxon stopping. Committed 7601b9db (PR #29, merged). |
|
||
| D209 | **Every AI character in the game is locked to a *walk* animation regardless of the speed the ailist commands — `act_ubytes.padding[45]`, a raw-byte alias into…** — full `## D209` entry at file tail | **FIXED + user-playtest-verified (M-81).** `chraction.c:3665`, `#ifdef PORT` → `get_sound_at_range(self, self->act_gopos.unk59, ...)`; the N64 line kept verbatim under `#else`.… |
|
||
| D210 | **`chrToPatrol()` leaves `act_patrol.lastvisible60` uninitialised on PC — a raw-byte union alias (`act_init.padding[0x13]`) that shifts when `act_patrol` widens…** — full `## D210` entry at file tail | **FIXED (`chraction.c:3872`, `#ifdef PORT` → `self->act_patrol.lastvisible60 = -1;`, N64 line under `#else`).… |
|
||
| D205 | **PC plays wrong/extra SFX for gameplay events (gunfire slap layer, explosion→scream, armour pickup, melee→Klobb idx 106) — user A/B vs N64 (M-71→M-78).** — full `## D205` entry at file tail | CLOSED (M-78) — root cause was D206; all four symptoms resolved + user-confirmed by ear. |
|
||
| D152+ | **D152 addendum (M-31):** — full `## D152+` entry at file tail | 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/dev/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<<mask`, and GE sets `mask = texDimensionToMask(dim) = ceil(log2(dim))` (`src/game/tex.c:361`), so a non-PoT tile (Depot's 65×65 / 96×48 / 56×56 room surfaces) repeats at the **next power of two**, not at its image size the way GL `GL_REPEAT` does → the pattern is squashed/stretched and the seam lands in the wrong place. fast3d never stored `masks`/`maskt` at all (`gfx_dp_set_tile` dropped them) and wrapped purely at the uploaded image dimension. **Fix (`port/fast3d/gfx_pc.cpp`, behind the existing `Video.WrapFix` knob, default OFF):** store `masks`/`maskt` on the tile; in the hoisted per-texunit pre-wrap block in `gfx_sp_tri1` (D74 block — already lifted out of the vertex loop, indexed by texunit `t` not vertex `i`), when the tile is WRAP (no CLAMP/MIRROR bit) and `1<<mask != tex_width`, fold the UV at the N64 period `1<<mask` and clamp the `[dim, 1<<mask)` overflow band (which is a TMEM smear on console, no real texels) to the last texel so it reads as an edge streak instead of a bogus early image restart. `GE_WRAPFIX=0/1` env override added (env wins over the ini, matching `Debug.FrameDump`). **Per-level captures (`-level_09`/`-30`/`-34`/`-20`, WrapFix OFF vs ON, `GE_PCDUMP` 6-frame windows):** no crashes, 6/6 frames each; on settled/comparable frames Silo is ~pixel-identical (phash 0–11), Facility 180–260 pixel-identical, Depot shows small localized texel changes on ceiling/wall cells (dmean 5–9, no structural break); the large per-run deltas are all the D117 intro-camera-pan nondeterminism, not the fix. Default kept **OFF** — no regression, but a headless structural diff can't confirm the Depot ceiling actually looks *better*; needs a human eyeball with `Video.WrapFix=1`. Default-off is byte-identical to golden (all new behaviour is inside `if (g_wrap_fix)`). D74's dead in-vertex-loop wrap block was already reworked/hoisted at M-30; this only adds the mask-period trigger. Confidence: **medium** (mechanism correct; overflow-band handling is an approximation, not exact TMEM-smear emulation; visual win unconfirmed). porting-notes.md §D. | KNOB ADDED, default OFF (`port/fast3d/gfx_pc.cpp`). Needs user visual check on Depot. |
|
||
| D168 | **`GE_PCDUMP` / F12 PPM captures were vertically flipped — the entire source of the bogus D114/D116 "HUD/text X-mirror" (M-33, developer-confirmed).** — full `## D168` entry at file tail | FIXED (`port/fast3d/gfx_opengl.cpp`); D114 / D116 reclassified as capture-orientation artifacts (below). |
|
||
| D169 | **Front-end mouse pointer can't reach the outer cells of the mission/level-select grid — only an inner ~3×3 selectable (M-33, developer bug report).** — full `## D169` entry at file tail | **FIXED (M-33, `port/src/input.c`, port-only, no `#ifdef PORT`).** The `menuMode` pointer branch now derives its clamp bounds from the live virtual screen —… |
|
||
| D178 | **Pre-mission briefing screen: objectives blank / missing, briefing pages blank (also the D143 "briefing text blank" side effect) — FIXED (M-36).** — full `## D178` entry at file tail | **FIXED (M-36)** — high confidence |
|
||
| D175 | **In-game stutter / brief hang during normal play** — full `## D175` entry at file tail | CLOSED (v0.4.0 campaign sign-off 2026-09-28, user: no stutter over the full campaign -- fixed). Earlier: OPEN (user, 2026-09-26: still happens occasionally; keep). Earlier: OPEN — observed, not investigated. **Steam Deck (Linux, 2026-09-27/28): occasional frame-timing hitches at low CPU/GPU (~15–30 %) — a frame-pacing/present profile, not a throughput bottleneck (see entry).** |
|
||
| D176 | **Surface exterior renders wrong (`-level_36`), two independent defects.** (a) black sky = RDPHALF no-op, root-caused M-37; scroll-speed FIXED (perspective-correct tc in `skyPortRenderPoly`). (b) cliff/rock walls = grey diagonal static — NOT a decode bug (D183: 0/166 loads strided); re-scope from ROM ground truth of the wall texnum (M-37 inconclusive, leaning tiling-density). — full `## D176(a)` entries at file tail; see also GRAPHICS-BACKLOG.md D176 | **(a) scroll-speed FIXED** (fidelity pass; HIGH confidence on root cause, MODERATE on N64 parity); M-93 playtest + M-98 Defect-2 starburst screenshot in the sections. **(b) open.** |
|
||
| D177 | **Ladders non-functional — progression blocker (user QA) — FIXED (M-36): `count`/`rooms` land in the high half of a widened pointer.** — full `## D177` entry at file tail | **FIXED (M-36)** — high confidence; interactive climb test owed |
|
||
| D172 | **Bullet-impact / blood / spark particles render magenta or cyan instead of dark red (M-82, ROOT CAUSE FOUND + FIXED).** — full `## D172` entry at file tail | **FIXED (`port/fast3d/gfx_pc.cpp` `gfx_lod_tile_offset`)… |
|
||
| D174 | **"No blood effect" (user QA report).** — full `## D174` entry at file tail | CLOSED — faithful (2026-09-26, user side-by-side on 1964: blood matches N64). Earlier: Blood renders (user, 2026-09-26); OPEN only for an N64-accuracy comparison on 1964. Earlier: OPEN — observed; first step = BUNKER1 firefight with regenerated sidecars |
|
||
| D183 | **M-36 Family A ("texture line/pitch shear") is DISPROVEN for the Surface repro (`-level_36`).** The importers' pitch assumption is real but never fires there; the Surface cliff "grey static" is not a shear. Full evidence + what the walls actually are: §F "D183" below. A defensive, provably-no-op de-stride landed in `import_texture()` anyway (covers the `gfx_dp_load_tile` case the `SUPPORT_CHECK`s assert against), plus `GE_DTEX` STRIDED marker + `GE_TEXRAW` raw-source dump. | CLOSED (2026-09-26, user-approved: disproven hypothesis; the Surface sky issue it served was fixed under D176). Earlier: Hypothesis DISPROVEN + diagnostics shipped; D176(b)/D182(2) still OPEN |
|
||
| D173 | **Third-person Bond model spawns too high** — full `## D173` entry at file tail | **MOSTLY FIXED as a side effect of D243 (2026-09-18, user-confirmed).** User: "9/10 times he's in the right place now, if he's off it's only by a small amount, no more floating in midair or glitching out spinning like before." The M-160/M-165 shared-mechanism hypothesis (D243's animation-state/position desync class) holds for this bug too, confirmed by outcome even though the exact code path was never separately traced for D173 specifically. Small positional-offset residual remains, not investigated further — no more full floating/spin. |
|
||
| D179 | **Packaged build crashes after the logos — the D43/D69 model + bg sidecars are ROM-derived and absent from any build that has no ROM (M-34, alpha-release QA).** — full `## D179` entry at file tail | Diagnosed, not fixed — release packaging gap + latent `romdata.c` fallback |
|
||
| D180 | **Native-PC input pass (M-XX QoL run, `qol/native-pc-input-menu`, port-only).** — full `## D180` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: LANDED, port-only, no `#ifdef PORT`.… |
|
||
| D181 | **`Game.ScreenShakeIntensity` — first route-(b) `src/` gameplay-cosmetic hook (M-XX QoL run).** — full `## D181` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: **Menu row pulled (v0.2.1, user testing).** The hook is correct on its own narrow terms —… |
|
||
| D184 | **F10 port-layer options overlay — the approach-(C) surface from `OPTIONS-MENU-PLAN.md` (M-37).** — full `## D184` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: **LANDED, port-only, no `#ifdef PORT` in `src/`.** Build links 241/241.… |
|
||
| D211 | **PC FOV slider — `Video.FovScale` (M-83 QoL wave, port-only, `WIDESCREEN-FOV-PLAN.md` Phase 4).** — full `## D211` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: **PARTIAL — M-84 refix… |
|
||
| D212 | **Anisotropic-filtering config wiring — `Video.Anisotropy` (M-83 QoL wave, port-only).** — full `## D212` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: **LANDED, port-only.** Build clean. **Owed:** eyeball at 16× on a grazing surface (Depot roof / Dam walkway) vs 1×. |
|
||
| D213 | **On-screen FPS readout — `Video.DisplayFPS` (M-83 QoL wave, port-only, PD parity).** — full `## D213` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: **LANDED, port-only.** Build clean; `bunker1` no-op confirmed. **Owed:** eyeball that the counter reads a sane ~30 and doesn't overlap the HUD. |
|
||
| D214 | **Keyboard rebinding — `[Input.Bind]` config section (M-83, port-only, PD parity, Phase 4 debt).** — full `## D214` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: **PARTIAL — M-84: code PASS (alt/ctrl); one doc-only fix.** LANDED, port-only, no `#ifdef PORT`; build clean.… |
|
||
| D215 | **1P weapon viewmodel never drew in-level — `gunRenderFirstPersonGunModels` builds its `ModelRenderData` from a reinterpret across separately-declared adjacent…** — full `## D215` entry at file tail | **FIXED (`src/game/gunfire.c`, `#ifdef PORT`).** Build clean.… |
|
||
| D216 | **`Game.SkipIntro` — boot straight to the file-select menu (M-83, port + one `#ifdef PORT` game-code line, PD parity, Phase 4 quick win).** — full `## D216` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: **LANDED + VERIFIED this session.** Build clean.… |
|
||
| D217 | **1P weapon viewmodel + 3P Bond model textures garbled — wrong/stale palette on model-DL CI textures (M-84).** Two defects: ① texpool arena misalignment (the green), ② CI texture-cache collision. — full `## D217` entries (M-86→M-97) at file tail | **FIXED + merged** (PR #47 + PR #49); white residual split out as D228. |
|
||
| D218 | **Wide FOV exposes the per-level draw-distance / fog boundary — "blue artifacting" down long peripheral sightlines (M-84, user QA on the D211 FOV slider).** — full `## D218` entry at file tail | FIXED (2026-09-26 reclassified: the proposed fix shipped as Video.DrawDistance + DrawDistanceAutoFov, default 150). Earlier: **Logged (M-84), fix proposed + parked.** Plan… |
|
||
| D219 | **PPK explosions render purple on Dam (M-87, user QA, "not 3D but noting").** — full `## D219` entry at file tail | **RESOLVED (M-115, 2026-09-12)** for the original PPK-purple symptom. **The adjacent bullet-spark-rainbow investigation logged under this label (M-157/M-158) is also FIXED (2026-09-18 session)** — `glass2.c`'s `bullet_spark_render` read the spark's RGBA colour via a hardcoded raw byte offset instead of the named `s_bullet_spark` struct fields, landing in the wrong memory on 64-bit PC; fixed by reading the named fields. User live-confirmed. D252's duplicate "lingering particle residue" report closes as the same bug (M-188). |
|
||
|
||
**M-111 (2026-09-12, live session, 6 build/relaunch cycles): G_LIGHTING lead RULED OUT; texture-cache-collision lead RULED OUT; ROOT CAUSE FOUND — R/B channel swap in the decoded fire particle texture data itself.** Live-captured 4 rounds against a real user playtest (not scripted) with the user confirming explosions were still blue/purple in every capture:
|
||
1. **G_LIGHTING probe fired 5000+ times across a full playtest with explosions triggered, 100% `LIGHTING=off`, 0 `D219 SUSPECT` hits.** (First attempt used the original 40-hit cap from the D172 probe, which silently exhausted on ordinary bullet-impact particles before any explosion — fixed to exempt the lit case from the cap and add an always-on periodic counter, confirming the code path really was being hit throughout, not just early. See `port/fast3d/gfx_pc.cpp` `gfx_sp_tri1`'s D172/D219 probe block.) **G_LIGHTING is conclusively not the mechanism.**
|
||
2. **Texture-cache-collision lead also ruled out**: extended the same probe to log the actual bound `TEXEL1` texture address/GL-texture-id/byte-count for every particle draw. 7 distinct 448-byte (16×14 RGBA16, matching the DL's own `CALC_LRS(16,14,...)`) addresses observed, each internally consistent — no address aliasing, no size mismatch, no stale-cache-slot signature.
|
||
3. **Root cause: dumped the actual decoded fire textures with `GE_TEXDUMP=1`** (extended the existing dumper in `port/fast3d/gfx_opengl.cpp` to exempt 16×14 RGBA images from its own 400-image cap, same masking problem as the D172 cap) — captured all 15 `IMAGE_FIRE_0`..`14` variants as PPM after a live playtest with confirmed-purple explosions. **Every single one of the 15 decoded textures is blue/purple-dominant** (e.g. `fire000`: avg RGB `(102, 67, 138)` — B is the highest channel). Swapping the decoded R and B channels post-hoc on all 15 turns every one of them into a plausible warm fire palette (`fire000` swapped: `(138, 67, 102)`) — consistent across all 15, not a coincidence on one sample.
|
||
4. **This is NOT a bug in the shared RGBA16 decoder** (`import_texture_rgba16`, `port/fast3d/gfx_pc.cpp:653-681`) — that function's bit layout (`r=col16>>11, g=(col16>>6)&0x1f, b=(col16>>1)&0x1f, a=col16&1`) is standard N64 RGBA5551 and is proven correct by every other RGBA16 texture in the game rendering correctly; a bug in the shared function would break far more than 15 particle images. Also ruled out: the general C-array byte-order normalization (`gfx_tex_normalize_source`, D71) already runs upstream of every `import_texture_*` call including this one (`gfx_pc.cpp:1001-1008`), so it isn't a missing-normalize gap either. **The wrongness is in the decoded bytes themselves**, i.e. either (a) the ROM-resolved `Globalimagetable` entries for these 15 `IMAGE_FIRE_N` ids point at the wrong ROM offset/wrong data (an extraction/table-mapping bug, not a runtime rendering bug), or (b) these 15 images were authored/stored in a different sub-format than the rest of the game's RGBA16 assets that the shared decoder doesn't special-case. **Do NOT attempt a blanket R/B swap in `import_texture_rgba16`** — it's shared by the whole game and would break every correctly-rendering RGBA16 texture; whatever's wrong is specific to these 15 image ids. **Next, concrete:** audit how `IMAGE_FIRE_0`..`14`'s `Globalimagetable` entries resolve to ROM offsets (`port/src/gimgfixup.c`, `src/game/image.c`, `port/src/romassets_u.s`) and cross-reference against a real ROM dump / reference emulator screenshot of the same texture to confirm whether the extracted bytes are simply wrong-offset (fixable at the table/extraction level, in `port/`, no game-logic change) vs. a genuine N64-side sub-format quirk unique to `oddtextures.c`'s assets. Diagnostic infra left in tree (env-gated, inert by default): D172/D219 probe's uncapped lit-case + periodic summary counter (`gfx_sp_tri1`), and `GE_TEXDUMP`'s 16×14-exempt-from-cap fire dumper (`gfx_opengl_upload_texture`) — both safe to reuse for the follow-up.
|
||
|
||
**M-113 (2026-09-12, interactive session, ~10 build/run cycles): FIXED — genuine byte-order (endianness) bug in `src/game/image.c`'s runtime texture decompressor, not a data/extraction/combiner issue. Verified with real decoded-texture PPM captures (visual + numeric).** Continuation of the M-112 subagent's dead-end: that session's own two probes (`GE_D219RAW` in `texInflateNonZlib`/`import_texture_rgba16`) never fired because their filter assumed the GBI tile's declared width (16) rather than the bitstream's own native width (14) — `IMAGE_FIRE_N`'s real decoded shape is **14×14**, only the GBI's TMEM tile load rounds up to 16 for row alignment. Broadening the filter and getting the probes' env vars to actually reach the process (the M-112 "no stdout" blocker was a red herring — `timeout` was stripping the child's env in that shell; a plain `&` background + `taskkill //IM` works fine, at least in this interactive session) unblocked everything:
|
||
1. **Root cause:** `src/game/image.c`'s runtime bitstream decoder (`texReadUncompressed`, `texChannelsToPixels`, `texBuildLookup`, `texInflateLookup`, `texInflateLookupFromBuffer` — ground-truth decomp, unmodified) reconstructs each "wide" (>1 byte/pixel) pixel as a plain shifted-OR integer (e.g. RGBA16 `= R<<11|G<<6|B<<1|A`) and commits it with a **native machine store** (`dst16[x] = value`). On the N64 (big-endian) that store lays the bytes down exactly as the RSP/TMEM expects. `port/fast3d/gfx_pc.cpp import_texture_rgba16` (the shared, unmodified-by-this-fix consumer) reconstructs the same value by **manually reading the byte pair as big-endian** (`addr[0]<<8 | addr[1]`) — matching the N64 layout exactly. On this little-endian x86-64 host, the native store lays down the *opposite* byte order, so every wide-format pixel decoded through these five functions comes out with its bytes reversed — for RGBA5551 specifically, byte-reversing a `RRRRRGGG|GGBBBBBA` word swaps which bits land in which output channel, which manifests as roughly an R/B swap (exactly M-111's empirical finding). **Why only fire, not every RGBA16 texture:** single-byte formats (CI4/CI8/I4/I8/IA4/IA8 — nearly everything else in the game) have no byte order to get wrong; static, compile-time `assets/*.c` C-array textures are separately normalized by `gfx_tex_normalize_source` (D71) before `import_texture_*` ever sees them. `IMAGE_FIRE_0..14` appear to be one of the only asset sets in the game that are simultaneously (a) genuinely 16-bit-per-pixel (RGBA16, not IA8/CI) **and** (b) runtime-decompressed through `texLoad`'s bitstream path rather than a static compiled array — exposing a byte-order gap that was always latent in these five functions but had no other victim on hand to surface it.
|
||
2. **Confirmed empirically, not just by inspection:** dumped raw decoded RGBA5551 words at the exact point `texChannelsToPixels`/`texInflateLookup` write them (`GE_D219RAW=1`, Dam `-level_33`, scripted PPK fire via `GE_INPUTSCRIPT`) — e.g. pre-fix texnum 2111 decoded word `0xf1c2` (R=30,G=3,B=1 under standard RGBA5551 — briefly looks *plausible* red until you realize this is the value BEFORE the byte-swap fast3d applies; the two-stage mis-cancellation is what produced the reported blue/purple, not a one-step swap — see the fix verification below for the real signal). The conclusive test is the rendered/uploaded texture, not the intermediate value in isolation.
|
||
3. **Fix (`src/game/image.c`, `#ifdef PORT`/`#else` macro, no `src/` logic changed):** added `PORT_PIXEL16`/`PORT_PIXEL32` — `__builtin_bswap16`/`32` under `#ifdef PORT`, identity under `#else` (so the N64 build is byte-for-byte unaffected, verified by inspection: the macro is a no-op expansion, not a code path change) — and wrapped every wide-pixel (`RGBA32`/`RGB24`/`RGBA16`/`IA16`/`RGB15`) store in the five functions listed above. CI/I4/I8/IA4/IA8 branches (single-byte, no byte order) are untouched. This is the D3x/AGENTS.md#2 narrow ABI/byte-layout exception class: mechanical, semantics-preserving (same numeric color, corrected byte order for the LE host), scoped to exactly the format cases that need it.
|
||
4. **Verified, numerically and visually:** `GE_TEXDUMP=1` PPM capture of the 15 `IMAGE_FIRE_N` textures post-fix — average RGB flipped from B-dominant (M-111: `fire000 (102,67,138)`) to R-dominant (post-fix `fire000 (141,127,53)`, `fire003 (147,81,24)`, `fire005 (47,10,2)`); a 10x-upscaled render of one frame and a 6-frame animation strip (frames 000-006, session scratchpad) show a clean, unambiguous fire-sprite animation — bright yellow-white core cooling through orange to red/black ash, exactly the expected in-game fire look, no purple/blue anywhere. Sanity-checked a montage of 12 ordinary (non-fire) world textures from the same Dam capture (clouds, hazard stripes, pipes, grating, concrete) — all render correctly, no regression from the format-gated change. `-level_09` (BUNKER1) and `-level_33` (Dam) both boot and render crash-free post-fix (10s+ each, no `ge007.crash.log`). **Not yet done:** a full 21-level `verify.sh sweep` and a genuine live human playtest confirming the fix in real (non-scripted) gameplay — the scripted-fire + texdump evidence here is strong but the row should stay open until a human eyeballs a real firefight on this build. `docs/porting-notes.md` gets a new entry for this bug class (runtime-decoded multi-byte pixel formats need explicit byte-order handling; static C-array textures already get this via D71/`gfx_tex_normalize_source`, runtime-decompressed ones did not). **M-114 — REGRESSION, reverted.** User playtest after M-113 landed reported 1P muzzle flash rendering blue and the ammo HUD counter rendering pink — colors inverted on textures that were fine before. Root cause: M-113's `PORT_PIXEL16`/`PORT_PIXEL32` swap is applied unconditionally to every call site in `texReadUncompressed`, `texChannelsToPixels`, `texInflateLookup`, and `texInflateLookupFromBuffer` — i.e. to every RGBA16/RGB15/IA16/RGBA32/RGB24 texture decoded through these shared functions, not just `IMAGE_FIRE_N`. This is the exact failure mode already logged above against the sibling function ("do NOT attempt a blanket R/B swap in `import_texture_rgba16` — it's shared by the whole game and would break every correctly-rendering RGBA16 texture"), just repeated one file over. Muzzle flash and the ammo HUD icon evidently decode through this same shared path without needing the swap. **Fix applied:** `PORT_PIXEL16`/`PORT_PIXEL32` reverted to identity (no-op) in `src/game/image.c`; the macro/call-site wrapping is left in place as scaffolding. **D219 is OPEN again** — the fire-particle color bug is unfixed. **Next, concrete, not yet done:** find the actual property that distinguishes `IMAGE_FIRE_N`'s decode from a working same-format sibling (M-112 already narrowed one candidate: FIRE is baked directly into a DL rather than going through the `sImageTableEntry` table that flare/ammo/impact sprites use — confirm whether that DL-embedded path is what needs the swap, or whether it's specifically `compmethod`, per M-112's still-open next step) and scope the macro to that property, not to a hardcoded texnum range. Not runtime-verified this session (static revert only, done to stop an active regression fast) — a human should confirm muzzle flash/ammo colors are back to normal on this build. **M-115 (2026-09-12, delegated session, 3 build/run cycles): fix RE-APPLIED, scoped by GBI pixel format — the M-114 regression resolved by construction, fire fixed again.** Per M-114's brief, found the property that distinguishes `IMAGE_FIRE_N`'s decode from working same-format siblings and scoped the macros to it. (1) **The discriminator is the decoded pixel FORMAT, not compmethod or texnum.** Full offline census of all 2698 ROM image-table entries (`scratch/d219_headers.py`, parsing real bitstream headers with the same MSB-first `texReadBits` contract as `src/game/image_bank.c`; zlib images skipped after the 2-byte `rz` preamble per `decompressdata`): only 94 non-zlib wide-pixel images exist — RGBA16=19, RGB15=14, IA16=1, RGBA32=60. The fire frames span compmethods RLE(4)/LOOKUP(5)/RLELOOKUP(7)/HUFFMANBLUR(8) (confirmed offline AND at runtime), so compmethod cannot scope the fix; a texnum range is a hardcoded landmine. Format IS structural: every 16-bit GBI-size format (RGBA16/RGB15/IA16) is consumed by importers that read the pool as **manually big-endian bytes** (`import_texture_rgba16`: `col16=(addr[0]<<8)|addr[1]`; `import_texture_ia16`: `intensity=addr[0], alpha=addr[1]`), while every 32-bit format (RGBA32/RGB24) is consumed by `import_texture_rgba32`, which does `PD_BE32()` on a **native** u32 load — it expects host-order words. So bswap-at-store is required for exactly the 16-bit family and must be identity for the 32-bit family; M-113's blanket swap hit both (M-114), M-114's full revert broke the 16-bit family again. (2) **Change (`src/game/image.c`, `#ifdef PORT` only, no call-site edits):** `PORT_PIXEL16(x)` = `__builtin_bswap16((unsigned short)(x))`; `PORT_PIXEL32(x)` = `(x)`. Every existing call site already routes by `TEXFORMAT_*` to the right macro, so M-114's scaffolding was already format-scoped per site — only the macro definitions were wrong (both bswapped). 30-line comment at the definition records the importer contract + census. (3) **Runtime verification (Dam `-level_33`, scripted Z-pulse fire via `GE_INPUTSCRIPT`, `GE_D219RAW=1` + `GE_TEXDUMP`/`GE_TEXRAW`):** all 15 fire frames decode at runtime as format=1 (RGBA16) with compmethods 4/5/7/8 exactly matching the offline census (texnum 2085 is genuinely an nc=1 LOOKUP transparent first frame — A=0 everywhere; the visible frames are 2087–2113); the raw pool dumps decode as warm RGBA5551 sprites — mean RGB over opaque texels **(255,238,139)**, R-dominant, correct vertical-blob shape (rows 3–10 of the 16×14 tile); e.g. texnum 2111's pool word is now `0xc2f1`, which the importer reads back as `0xf1c2` = R30,G3,B1 — the true decoded value M-113 recorded pre-fix, i.e. exactly the pool state whose rendered PPMs M-113 proved correct (mean 141,127,53, clean animation). (4) **Regression safety is structural, not empirical:** RGBA32/RGB24 pool bytes are bit-identical to the M-114-revert build (`PORT_PIXEL32` = identity in both), so muzzle flash / ammo HUD / flares / crosshairs — all RGBA32 — render exactly as in the state the user last confirmed fine; no 32-bit path was touched at all. The other non-zlib 16-bit images (texnum 1198–1201 RGBA16, 2430 IA16, 2510–2523 RGB15) were equally byte-reversed before this change and are now corrected by the same importer contract; they do not load during a Dam run, so their on-screen state is owed to the next human playtest in whatever level uses them. (5) **Probes left in tree** (env-gated, inert): `GE_D219RAW` generalized in `texInflateNonZlib` — header line for every non-zlib wide-pixel decode (`format <= TEXFORMAT_IA16`), first-row pixel dump for RGBA16/RGB15/IA16, no texnum filter; plus the pre-existing D172/D219 and `GE_TEXDUMP` fire-dumper probes. **Status: RESOLVED (2026-09-12)** — user playtested this build and confirmed it works (fire correct, no muzzle-flash/ammo-HUD regression); D219 closed. |
|
||
| D220 | **1P muzzle flash position shifts upward at higher `Video.FovScale` (M-87, user QA, Jungle/M16).** — full `## D220` entry at file tail | CLOSED — merged into D303 (2026-09-26, user: the M16/AR15 muzzle flash rises regardless of FOV, so the FOV framing was wrong; tracked as D303 with a 1964 comparison). Earlier: **ANALYZED (M-91) — the suspected unscaled-viewmodel root does NOT hold; this is correct perspective behavior, likely cosmetic/QoL.** The 1P hand + flash are model nodes in ONE gun DL (`gunfire.c` builds `rwmtx[1..4]` for hammer/flash/billboard nodes) drawn through the single per-frame projection from `viSetupCurrentPlayerView` —… |
|
||
| D85 | **Room primary/secondary DL → `texLoadFromGdl` garbage.** — see §H (D85 entries: root cause + widen/texture-pool fixes) | CLOSED (2026-09-26, user-approved: widen + texture-pool fixes landed long ago, safety net in place, no current symptoms). Earlier: OPEN (safety-netted; widen/pool fixes landed, geometry now renders) |
|
||
| D86 · D87 | **modelInitRwData truncated ptr · attract-demo BE `ramromfilestructure`.** — see §H (D86, D87) | resolved |
|
||
| D88.1–D88.3 · D88.5–D88.6 | `Usetup*Z` header + sub-table width/endian conversion (`d88_emit.py`) | resolved |
|
||
| D88.4 | **`propDefs` polymorphic record stream not byteswapped → `setupDoor` crash.** — see §H (D88.4) + `## D132` | resolved (`d88_propdefs.py`); layout audit M-20 → **D132** |
|
||
| D132 | D88 propDefs layout audit (M-20): converter cursor confirmed vs real PC struct layout for all types the 21 levels emit. DOOR/OBJECT-prefix/VEHICHLE/AIRCRAFT/TANK/AUTOGUN/AMMO/TINTED_GLASS/objective sub-records all MATCH. **Divergence found:** types 14 LINK / 19 SWITCH / 38 LOCK_DOOR / 44 SAFE_ITEM — each has `s32 IndexN` fields sharing a union with a pointer (`LinkRecord.first`/`Index1`), so on PC the field sits in an 8-byte, 8-aligned union slot, but the converter emits it at N64 tight-4-byte-word offsets → `pdef->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").** — full `## D157` entry at file tail | FIXED (`src/bondtypes.h`, `#ifdef PORT` —… |
|
||
| 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).** — full `## D160` entry at file tail | FIXED (user, 2026-09-26: the Dam ending cutscene plays since v0.3.0). Earlier: DIAGNOSTIC SHIPPED, not root-caused (`c95713f5`, `#ifdef PORT` + `GE_D160=1`)… |
|
||
| 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…** — full `## D161` entry at file tail | 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 |
|
||
| D108–D112 | skeletal models: `d43_emit.py put_f32` byte-reversal bug | resolved |
|
||
| D113–D116 | portal BFS ok · matrix chain ok · player raw-offset audit + `gunfire.c` THROW* · HUD text X-mirror | **D114/D116 CLOSED — NOT A BUG (M-33, see D168): the "X-mirror" was an upside-down `GE_PCDUMP` capture misread as mirrored. PPM writer fixed; the game renders correctly on hardware.** |
|
||
| D117 | **Frame nondeterminism root-caused; `GE_DETERM` attempted, not achieved.** — see §H (D117 M-8 root cause + M-52 addenda) | OPEN (mode attempted, not achieved) |
|
||
| D118 | **SDL input layer; M-24 mouse-look rework (mode-aware map, real INI); D118b/c FIXED.** — see §H (D118 + M-24 entries) | resolved (D118a residual) |
|
||
| D150 | watch OBJECTIVES / BRIEF page crash: `strcat(buf, langGet(id))` with `langGet` → NULL (unloaded bank) → NULL deref; `str.c` str* builtins elide a plain param NULL check, so guard via asm-laundered `GE_IS_NULL` | FIXED (`src/str.c` `#ifdef PORT`); interactive re-verify pending |
|
||
| D260–D265 | v0.2.1 hotfix batch: D260 wheel direction swap (fixed) · D261 watch-menu nav auto-repeat (fixed, feel-check owed) · D262 watch item-preview model missing → root-caused to D264 (fixed at Gfx level, visual check pending) · D263 Facility Ourumov/Trevelyan beat — **resolved: not a bug**, proximity/LOS-triggered as on N64; full chain incl. execution shot verified firing on PC (teleport probe + user playtest) · D264 watch preview renderdata.flags zeroed by cross-global template read on PC (ABI/layout class; fixed) · D236/D265 Surface 1 tree "wall": IA16 importer dimensions transposed (fixed); decode attempt reverted after raw-byte proof that byte-pair is the authored image; root cause = `import_texture_ia4` decoding RDP I2:A2 as I3:A1, inverting the tree strip's alpha ramp → opaque repeating silhouettes; IA4 I3:A1→I2:A2 decode fix applied and user re-tested — **still a wall; reverted, parked** with evidence + suspect list in D265 | CLOSED (2026-09-26 bookkeeping: v0.2.1 hotfix batch shipped; D265 is a D236 sub-pass, closed M-198). Earlier: partial — see rows |
|
||
| D266–D269 | D236/D265 wall, second pass: **the wall is CI8 32×32, not IA** (D269) — band-gated probe finds two draw classes per room stream (OPA oml=0xc4112078 + XLU oml=0xc41049d8), both BILERP/TEXTLOD/2-cycle/**FOG-on**; cards are soft gradient blobs, stripe period ≈ one card per visible room. D266: re-applied the reverted I2:A2 IA4 decode + replaced the hardcoded 0.19 edge quantize with AC-aware handling (census shows zero IA4 draws at boot). D267: IA8 dump infra — the three IA8 images are noise/mast sprites, not the wall. D268: force-alpha A/B proves the blend pipeline works. Ranked suspects now: fog range/application (D218 family) > TEXTLOD/mip sampling > draw density/culling > palette | CLOSED (2026-09-26 bookkeeping: sub-pass of D236, which closed in M-198). Earlier: OPEN (the wall itself) — probes live in tree (`GE_D266`/`GE_D266W`/`GE_D267`/`GE_D268_FORCEA`); the D266 decode + D271 portal-culling fixes **landed** (`532092c7`, M-139 R2 extraction) |
|
||
| D270 | **Backfilled (M-132) — this label was never given its own row; only code comments and the HANDOFF archive documented it.** — full `## D270` entry at file tail | CLOSED (2026-09-26 bookkeeping: sub-pass of D236, which closed in M-198). Earlier: dead-ended into D271/D272; infrastructure kept in tree |
|
||
| D271 | **D236 wall, third pass — suspect #3 (draw density/culling) CONFIRMED as a real over-draw; the D106 guard was the cause (M-129).** — full `## D271` entry at file tail | FIXED-but-insufficient (`src/game/bg.c` `#ifdef PORT`) — **landed** `532092c7` (M-139 R2 extraction, probes stripped); wall still open, see D236 |
|
||
| D276 | **D236 wall, eighth pass — the true painter of the visible weave is a fully-opaque IA8 blocky-noise texture, not either of D269's named CI8 classes (M-134).** — full `## D276` entry at file tail | CLOSED (2026-09-26 bookkeeping: sub-pass of D236, which closed in M-198). Earlier: REFRAMED — `oml=0xc81049d8`/`fmt=3` is the primary suspect now, not D269's CI8 pair; `0x0c184b50` unattributed; wall still open, see D236 |
|
||
| D279 | **Backfilled — this label was consumed by an undocumented partial session (2026-09-14 ~23:00) that left artifacts in `scratch/` (`d279_run.log`…** — full `## D279` entry at file tail | CLOSED (2026-09-26 bookkeeping: sub-pass of D236, which closed in M-198). Earlier: undocumented — artifacts only |
|
||
| D281 | **`Game.AllUnlocked` (D257) still breaks audio, and can also break right-mouse aim, when enabled before any save has ever been written (v0.2.1 user testing…** — full `## D281` entry at file tail | FIXED (2026-09-23, port/src/libultra.c): the AllUnlocked patch now reproduces the game's fresh-slot result (folder saves with DEFAULT_OPTIONS + one free slot) instead of five folder-1 slots with options 0 (no aim sight). Verified by fresh-eep SAVELOG; audio half **confirmed 2026-09-28 (campaign, user: good)**. v0.4.0 decision (user-delegated, 2026-09-28): keep the shipped mitigation + README disclosure; the zero-saves root cause (sndHandleEvent/aim interaction) is v0.5.0. |
|
||
| D282 | **QoL ask (v0.2.1 user testing, 2026-09-15): front-end menu navigation (main menu, file select, mission-select map) currently requires the RIGHT analog stick —…** — full `## D282` entry at file tail | FIXED (`port/src/input.c`, `#ifdef` not needed — port-layer-only file), feel unconfirmed. |
|
||
| D283 | **Steam Deck first-run preset (D-unnumbered in `port/src/video.c`/`main.c`, `videoApplySteamOSDefaults()`) did not apply for a user who added the game via Steam…** — full `## D283` entry at file tail | OPEN — mechanism understood, root cause (Desktop-vs-Game-Mode `STEAMOS` env var difference) unverified on real hardware; user-facing workaround documented in README/release notes. |
|
||
| D280 | **D236 wall, ninth pass — fog math VERIFIED N64-faithful; the IA8 noise class VISUALLY confirmed as the weave's painter (M-135).** No quick win for trees. — full `## D280` entry at file tail | CLOSED (2026-09-26 bookkeeping: sub-pass of D236, which closed in M-198). Earlier: REFRAMED again — no fix; next steps in detail section, see D236 |
|
||
| D275 | **D236 wall, eighth pass — probe upgrades (`GE_D270PIX` list, `GE_D270KEEPOML` counter) (M-133).** — full `## D275` entry at file tail | CLOSED (2026-09-26 bookkeeping: sub-pass of D236, which closed in M-198). Earlier: done — probes live, uncommitted; see D276 for what they found |
|
||
| D274 | **D236 wall, seventh pass — probe identity (`seq`) unreliable; per-slot address stamp. Content-vs-decode question was a bug in our own offline analysis (M-132).** — full `## D274` entry at file tail | CLOSED (2026-09-26 bookkeeping: sub-pass of D236, which closed in M-198). Earlier: `d267_slot_addr` fix live (always-on); content/decode question CLOSED; mip-presence CLOSED; wall still open, see D236/D276 |
|
||
| D273 | **D236 wall, fifth pass — N64 ground-truth screenshot obtained; `GE_D273` palette-decode A/B RULED OUT (M-131).** — full `## D273` entry at file tail | CLOSED (2026-09-26 bookkeeping: sub-pass of D236, which closed in M-198). Earlier: RULED OUT (`port/fast3d/gfx_pc.cpp` `palette_to_rgba32`, pre-existing/uncommitted `GE_D273`) — wall still open, see D236/D272 |
|
||
| D272 | **D236 wall, fourth pass — per-card S/T audit RULES OUT "identical UV window" + converter bug; cards sample a wide authored UV range (M-130).** — full `## D272` entry at file tail | CLOSED (2026-09-26 bookkeeping: sub-pass of D236, which closed in M-198). Earlier: OPEN — probe `GE_D272` added to `port/fast3d/gfx_pc.cpp` (env-gated, uncommitted); wall still open, see D236 |
|
||
| D137 | **right-mouse (aim-sight) crash:** — full `## D137` entry at file tail | FIXED (`src/game/gunfire.c` `#ifdef PORT` — `sp54` is `Gfx *`) |
|
||
| D119 · D120 | guard-attack `weapons_held[]->chr` pun crash · blood-stain `PointUsage[]` chain hang | D119 fixed · **D120 FIXED (M-30)** |
|
||
| D120 | **Blood-decal `PointUsage[]` chain — converter now emits it (M-30).** — full `## D120` entry at file tail | FIXED (`tools_pc/d43_emit.py`). Interactive verify pending — blood decals on a BUNKER1 firefight should now appear + no hang. |
|
||
| D121 | **WS1 frictionless per-level boot (bare `-level_XX` injects its `-m*` row).** — see §H (D121) | resolved |
|
||
| D122 | per-level prop/item model-load crash (Dam/Facility/Runway `modelLoad`/`modelInitRwData`): `d88_propdefs.py` had no handler for 6 ObjectRecord-derived propDef types (47/39/40/45/13/20) → generic arm half-swapped the `[s16 obj][s16 pad]` word → OOB `PitemZ_entries[]` | converter fixed; residual chr/fast3d crashes on those levels are separate |
|
||
| D123 | **crash class C1: `chrIsNotDeadOrShot` NULL deref on 6 levels (Dam/Runway/Frigate/Statue/Streets/Cradle).** — full `## D123` entry at file tail | converter fixed (`OBJ_ID_WORDS`); C1 cleared on all 6, residual crashes are fast3d (C2) |
|
||
| D125 | **crash classes C3+C6 (Aztec/Bunker2/Surface2):** — full `## D125` entry at file tail | **resolved (M-16b)** — line 374 → 8 NULs; all 21 sidecars regen'd; **Aztec `-level_28` now PASSES** (was C3 CRASH). Bunker2 falls through to a separate DOOR-tail `linkedDoor` layout bug (C3 residual) |
|
||
| D126 | **crash classes C3r/C4/C6 (Bunker2 `-level_27` `door7F054FB4` propobj.c:13523, Depot `-level_30` prop.c:902, Surface2 `-level_43` loadobjectmodel.c:393):** — full `## D126` entry at file tail | resolved — `d88_propdefs.py` PROPDEF_PC_BYTES[30/32/33/35]=24 + typed handler; `loadobjectmodel.c` sizepropdef PORT returns 6. Bunker2/Depot/Surface2 now PASS (13→16/21) |
|
||
| D124 | **crash class C2: fast3d bad texture pointer.** — full `## D124` entry at file tail | Jungle fixed (`port/src/gimgfixup.c`); Facility → D130 |
|
||
| D131 | **crash class C2m (Jungle `-level_37`):** — full `## D131` entry at file tail | resolved — `seg_addr()` restores the module high word for any fallthrough w1 in `[0x40000000, 0x70000000)` (module fixed-based at `0x140000000`, no ASLR; DRAM/KSEG0/segmented/phys all handled earlier).… |
|
||
| D133 | **intro render triage (M-20):** — full `## D133` entry at file tail | RESOLVED — not a regression, parked (D75/D76, `GRAPHICS-BACKLOG.md`) |
|
||
| D134 | **Frame-2 boot hang / sweep "NO-FRAMES" flakiness — root-caused and fixed (M-22).** — full `## D134` entry at file tail | FIXED (`port/src/libultra.c`) |
|
||
| D135 | **Firefight crash — `bgTestHitOnObj` (`propobj.c:8446`) is an unported N64 GBI parser (M-22, WS6 playtest).** — full `## D135` entry at file tail | FIXED (`src/game/propobj.c`, `#ifdef PORT` —… |
|
||
| D136 | **Stage-unload crash — `objFree` `obj->prop` reads float garbage (M-22, WS6 Facility playthrough).** — full `## D136` entry at file tail | 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).** — full `## D139` entry at file tail | FIXED (`src/game/cleanup_objects.c`, `#ifdef PORT` —… |
|
||
| D140 | **Pause-menu crash (M-23 open → M-27 fixed).** — full `## D140` entry at file tail | 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.** — full `## D141` entry at file tail | 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).** — full `## D150` entry at file tail | 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).** — full `## D152` entry at file tail | FIXED (user, 2026-09-26: no black screen after mission/objective failure). Earlier: MITIGATED (`port/src/libultra.c`, M-28, `#ifdef PORT`/port-only).… |
|
||
| D152+ | **D152 addendum — static audit + narrow fix (M-31).** — full `## D152+` entry at file tail | 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).** — full `## D154` entry at file tail | **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 —… |
|
||
| D156 | **Facility outro-cutscene "hang" — the actual fix (M-29, user bug report, 2nd occurrence after D155).** — full `## D156` entry at file tail | FIXED (user, 2026-09-26: no outro hang seen). Earlier: GUARDED (`src/game/model.c`, `#ifdef PORT`)… |
|
||
| D155 | **Facility outro-cutscene end → "hang" = unclamped `deltaFrames` spiral (M-29, user bug report).** — full `## D155` entry at file tail | FIXED (`src/game/frametiming.c`, `#ifdef PORT` —… |
|
||
| D153 | **`-level_09` frame ~900–1400 `0xc0000005` = D117/D134 load flakiness, NOT a bug (M-28).** — full `## D153` entry at file tail | 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).** — full `## D151` entry at file tail | FIXED (`src/bondtypes.h`, `#ifdef PORT` —… |
|
||
| D130 | **crash class C2 (Facility `-level_34`, Runway `-level_35`):** — full `## D130` entry at file tail | 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).** — full `## D164` entry at file tail | **FIXED** — corrected 2026-09-18: the one-line `#ifdef PORT` fix (`legal_text_end = legalpage_text_array + ARRAY_COUNT(...)`, `front.c`) was already committed `8dfc578f4` (2026-08-31); confirmed live in the current tree via `git blame`. This row and the `## D164` body's "NOT APPLIED" status were simply never updated after the merge — no code change was needed this session, docs-only correction. |
|
||
|
||
| D159 | **Front-end wallet-Bond photo (and other large front-end I4/I8/IA8 textures) render "interlaced" / combed (M-31, user bug RC1 / D149).** — full `## D159` entry at file tail | **FIXED** (`src/game/image.c`, `#ifdef PORT` early-`return` in `texSwapAltRowBytes` —… |
|
||
|
||
| D165 | **Front-end menu cursor doesn't feel like a mouse pointer (M-31, user bug B5 follow-up).** — full `## D165` entry at file tail | 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").** — full `## D166` entry at file tail | FIXED (`port/src/input.c`).… |
|
||
| D233 | **All levels (intermittent): indoor level geometry invisible near doors — you can see through walls/floors into rooms you can't see into, while world objects…** — full `## D233` entry at file tail | **FIXED + VISUALLY VERIFIED (M-107 fix, M-108 verify).** See the other D233 row for full detail. |
|
||
| D234 | **Dam: guard towers along the dam structure invisible except the first one (M-106, user QA report).** — full `## D234` entry at file tail | **VISUALLY VERIFIED FIXED (M-108), mechanism unexplained.** See the other D234 row for detail. |
|
||
| D235 | **Wrong textures load after dying/restarting a level — not Facility-specific, seen on multiple levels (M-106, user QA report; clarified same session).** — full `## D235` entry at file tail | CLOSED (user, 2026-09-26: not seen since the v0.3.0–v0.4.0 work; fix wired; reopen if it recurs). Earlier: OPEN — fix wired, headless reload clean (2026-09-23); NOT reproduced with the fix off (headless) nor in the 2026-09-25 user playtest (die → mission select → restart). Keep the fix; close if it stays unreproduced through the release playtests. |
|
||
| D236 | **Surface 1: 2D billboard trees render as a wall/sheet of tree texture instead of discrete camera-facing tree sprites (M-106, user QA report).** — full `## D236` entry at file tail | **CLOSED (M-198, pass 26, `425237f9`; probes stripped `0503d335`): root cause = `TEXTURETYPE_DETAIL` tile binding — fast3d drew the detail tile (opaque IA8 noise) instead of the base image; `gfx_lod_tile_offset` now samples tile 1 when the pair differs (`Video.DetailBaseTile`, default on). Live-verified Surface 1/2 + Depot; awaiting user play-test sign-off.** Historical trail — pass 24 (2026-09-21, static) DECODED THE TREE TEXTURE** (new `tools_pc/texdecode.py` ports image.c's LOOKUP/HUFFLOOKUP/RLELOOKUP codecs). **`tex1198` LOD 0 = 64×17 RGBA5551, 44.2% opaque, and its 1-bit alpha renders as an unmistakable treeline silhouette** — pass 23's identification confirmed by decode, not inference. **Rare authored a six-level LOD pyramid that PRESERVES the silhouette and its alpha coverage (44.2% → 43.1% → 38.8%), each level a separately-authored image with its own compmethod.** Since `XLU_SURF` has no alpha test (pass 22), averaging that 0/1 alpha into fractional mip alpha would smear the minified treeline into exactly the reported band — so 'authored LOD chain replaced by driver-generated mips' is now the leading mechanism, with hard ground truth behind it. Corrects pass 23: only `tex1198` is a treeline (`tex1199` 89.4% opaque denser foliage; `tex1200`/`tex1201` 100% opaque detail textures sharing the class). Two decoder bugs found while porting (missing per-image byte-align, `image.c:1096`; a GUESSED rather than ported RLE fudge) — both produced confident wrong pictures. **Pass 25 (live) answered all three questions: (1) YES 64×17 RGBA16 intact, pool ≡ offline decode; (2) YES alpha survives (44.2% opaque); (3) NO — no code path uploads authored chains as GL mips.** New: `tex1198`–`1201` are **never drawn from an idle spawn** (zero RGBA16 imports in 4800 frames) — the spawn wall is painted by other classes (cf D276/D280); the drawn tree-class card is **`tex149` IA8 32×64 hard cutout, byte-exact import, no authored chain**. Mip mechanism located: `texGetDepthAndSize` sizes LOADBLOCKs from the header `lods` field even for single-image textures (live A/B: 2808 B vs 2048 B real data), and the port's RC2 clip + `glGenerateMipmap`/trilinear (observed live) box-filters hard 0/F alpha into fractional mips → XLU smear. Pass 26 fix directions in entry (cheapest: no mip gen for no-chain textures). |
|
||
| D237 | **QoL ask (M-106, user QA): F10 options overlay — make it scrollable with the mouse wheel, expose more settings into it (e.g. screen-flash / no-hit-flash…** — full `## D237` entry at file tail | FIXED (2026-09-23): category headers + 5 more settings (SkipIntro, Show FPS, pad invert/deadzone/trigger); wheel scroll already existed. Hands-on pass owed. |
|
||
| D238 | **QoL ask (M-106, user QA): mouse sensitivity rework toward normal PC-shooter feel.** — full `## D238` entry at file tail | FIXED (2026-09-26: covered by D337/D338 aim work; user reports mouse feel good; code-side validation owed). Earlier: OPEN — not designed; port-only (`port/src/input.c` + `config.c`) |
|
||
| D239 | **QoL ask (M-106, user QA): click-to-lock vs always-grab parity.** — full `## D239` entry at file tail | CLOSED — executed in the v0.2.0 prep pass (port-only)… |
|
||
| D240 | **Player gunshot SFX timing/cadence reportedly does not match the N64 original's rate (M-106, user QA report).** — full `## D240` entry at file tail | **CLOSED (v0.4.0 campaign sign-off 2026-09-28, user: cadence drift not heard over the full campaign -- fixed). Never root-caused -- if re-reported, the D155/D186/D193/D204/D209 timing-keystone family is the prime suspect.** Earlier: OPEN — not root-caused; static call-site tracing only, no runtime verification possible in this environment. Confidence LOW-MEDIUM that this is a *new* bug distinct from the D155/D186/D193/D204/D209 timing-keystone family (all of which gate the identical `deltaFrames→speedgraphframes→g_ClockTimer→g_GlobalTimer` chain this SFX trigger consumes).… |
|
||
| D241 | **Player gunshot SFX goes silent under some conditions — reported on Bunker with the alarm sounding, "or some other factor" — but only for PP7 and AK47, Klobb…** — full `## D241` entry at file tail | **CLOSED as the D207 family (v0.4.0 campaign sign-off 2026-09-28, user: no silence under heavy fire + alarms -- fixed).** Earlier: OPEN — not newly root-caused; high confidence this is D207 reproduced, not an independent bug. Recommend folding this repro into D207 (or closing D241 as a duplicate once D207 is reopened) rather than tracking separately.… |
|
||
| D242 | **Player-death sequence UX regression vs N64 original: PC requires an LMB click to proceed to the mission-failed/file-select screen; user recalls N64 auto-plays…** — full `## D242` entry at file tail | FIXED/CLOSED (2026-09-26, user: death -> mission failed flow works without input, matches N64). Earlier: OPEN — N64 reference obtained (2026-09-26, user on 1964: after death the game returns to mission select and shows the "killed in action" screen); PC behaviour still to validate. Earlier: OPEN — 1964 comparison owed (2026-09-26, user). Earlier: **NOT A PORT-LOGIC BUG as far as static analysis can tell.** Confidence HIGH that the LMB-click skip path is faithful original behavior (Z_TRIG-mapped, exists unmodified in `bondview2.c`), not a defect to remove.… |
|
||
| D243 | **Dam end-of-level cutscene (the abseil/bungee sequence, D148/D160's subject): the camera violently shakes/"spazzes" during each of the scripted camera-angle…** — full `## D243` entry at file tail | **FIXED, live-confirmed by the user (M-189/M-190, 2026-09-18).** Root cause found: `bondview2.c:598-599`'s manual buffer-layout math for Bond's third-person cutscene body model reserved space using a hardcoded 32-bit `sizeof(Model)` literal (`0xfb`) that never accounted for pointer widening — real 64-bit `sizeof(Model)` is 232 bytes, 44 more than reserved, so the animation-data buffer placed right after it overlapped the model's own `playspeed`/`animrate`/`unkac`/`unkb0`/`unkb4` fields, corrupting them (this is what M-183/M-185's clamps were band-aiding). Fixed by computing the real `sizeof(Model)` instead of the stale literal, matching the identical idiom already used one line above for `ModelFileHeader`. **M-190 follow-up:** M-170's camera-shake freeze was replaced with a filter re-seed-on-teleport (camera now tracks Bond continuously within each shot instead of holding a frozen angle), and an unrelated bug in the same review pass — mouse-look never gated during scripted/no-control camera states (D300's own documented-but-unimplemented safety gate) — was found and fixed in `port/src/input.c`. User confirmed both fixes live, full intro-flyover + Dam abseil playthrough: "ok yeah looks good." |
|
||
| D245 | **IsWater water: two competing patterns with a visible seam that moves as the player looks around (mouse/aim) — "looks almost exactly like skybox" (M-116, user…** — full `## D245` entry at file tail | FIXED (M-201, 2026-09-26, user-verified vs 1964): water texture imported/normalised at 32x43/16-wide instead of the 32x32 CI8 mask period (resets, 2x speed) + s16 sky/water vertices replaced by G_FLOATVTX_EXT floats (jitter). Earlier: OPEN — CONFIRMED PORT BUG (2026-09-26, user vs 1964: PC water displays glitched with a weird yellow line; view-dependent shift is subtle; animation otherwise matches). Earlier: OPEN — REOPENED, M-142 headless probe found the tc-shift `k` measurably flips during ordinary view rotation (new root-cause evidence); an opt-in `GE_D245_FIXEDSHIFT` candidate is build-verified but needs a live A/B, not shipped as default.… |
|
||
| D246 | **Tiny pixel strips at the left and right edges of the screen, "like overscan" — look like raw pixels, not 3D graphics (M-116, user QA report).** — full `## D246` entry at file tail | **FIXED (2026-09-18 session, port-only, `Video.SafeAreaCrop`).** Measured precisely (exactly 1.0 logical unit missing on both edges, every resolution); underlying clip-precision root cause not isolated, but `gfx_adjust_viewport_or_scissor` now trims a fixed 1-unit margin from both edges — same toggle/mechanism as D247. Live user playtest confirmed clean edges. |
|
||
| D247 | **Black bars top and bottom of screen — possible aspect-ratio defect (M-116, user QA report).** — full `## D247` entry at file tail | **FIXED (2026-09-18 session, port-only, `Video.SafeAreaCrop`).** Root cause: faithful, unmodified N64 TV-safe-area letterboxing that real CRT overscan used to hide; this port showed the full VI frame. Per user request, given a real fix (not left as "correct original behaviour"): `gfx_adjust_viewport_or_scissor` now remaps against the active gameplay viewport bounds so it fills the window edge-to-edge. New `Video.SafeAreaCrop` toggle (default on). Live user playtest confirmed clean edges. |
|
||
| D248 | **Port runs a stable 30 FPS instead of 60 FPS game-wide (M-117, user asked to verify FPS after the D209 timing-keystone fixes).** — full `## D248` entry at file tail | **FIXED, root-caused to a genuine pointer-width ABI bug in `src/sched.c` (compiled for the PC build, not excluded).** `osScAddClient()` (`src/sched.c:187-199`) stashes a per-client "present every retrace vs.… |
|
||
| D249 | **LOD/culling breaks down on large open levels (Streets, Egyptian named specifically) at *default* FOV — far-away geometry has visible culling issues (M-119…** — full `## D249` entry at file tail | OPEN — M-142 static review ruled out the unscaled-far-clip and LOD-multiplier suspects (both already correct); CLOSED (v0.4.0 campaign sign-off 2026-09-28, user: no distant-geometry drop-out seen on Streets/Egyptian over the full campaign -- fixed); narrowed to the portal/room-visibility BFS, needs a live screenshot+location, not more code reading.… |
|
||
| D250 | **FPS is inconsistent/low on many levels after 60 Hz shipped (M-119, user QA; D248 follow-on).** — full `## D250` entry at file tail | FIXED — per-call `getenv()` cost was the bottleneck; rock-steady 60-61 fps after, user re-tested 1440p/4K clean. |
|
||
| D251 | ****F10 options overlay QoL pass:** — full `## D251` entry at file tail | **FIXED (port-only, `port/src/optionsoverlay.c`).** (a) The panel now computes how many rows fit the *actual* current 2D height (`maxVisibleRows()` from `viGetY()`) and scrolls… The last open symptom — the bottom row deterministically duplicating the selected item (v0.2.0-pre playtest) — is resolved by D304's `overlayUpdateScroll()` minimal-scroll rewrite; user-confirmed no longer occurring (2026-09-18). |
|
||
| D252 | **Particle effects cycle through a rainbow palette (M-124, user playtest of the v0.2.0-pre candidate).** — full `## D252` entry at file tail | **FIXED (M-201, 2026-09-26, user-verified): port-side D68 shim `gimgSyncCompiledGlobalDLs` only synced the compiled explosion DLs on the first stage; every later stage kept the title stage's freed texture pointers. Earlier history: PARTIALLY FIXED — a real, distinct manifestation was found and fixed, but the bug family is not fully closed, and is intermittent on BOTH platforms, not Deck-specific (corrected 2026-09-18).** `explosionRenderPart()`'s 4-vs-5-vertex overrun (`src/game/explosion.c`) is fixed and stays in. Initial testing suggested "fixed on Windows, recurs on Deck," but further user testing found it also recurs intermittently on Windows (same Jungle repro, not always reproducing) and is level-dependent (same repro method doesn't trigger it on every level) — not a clean platform split. Repro method (`Game.AllUnlocked` + cheats for an immediate grenade launcher) is flagged as a possible, unconfirmed factor. The unconfirmed secondary lead from this session (stale TMEM-slot `src_fmt` CI8 reroute, `port/fast3d/gfx_pc.cpp` ~line 1250) remains the best-named candidate for the residual. Not re-investigated further this session. |
|
||
| D253 | **Facility (and any level with a FORCECROUCH/LADDER linked special tile) SIGSEGV on Linux/Steam Deck — `stanCheckLinkedSpecialTile` stores to a garbage…** — full `## D253` entry at file tail | FIXED — Deck-verified: user got past the crouch tile with the re-cut bundle (a *new*, later crash is D255). |
|
||
| D254 | **Linux crash handler destroyed its own logs: `ge007.crash.log` only ever contained the SIGABRT block, never the original SEGV.** — full `## D254` entry at file tail | FIXED — field-verified. |
|
||
| D255 | **Facility firefight SIGSEGV on Steam Deck (v0.2.0-pre CI bundle; D253 fix confirmed working).** — full writeup directly below this table (row status was stale; see M-140 update) | **FIXED (M-140)** — D3x-class 32-bit pointer truncation (`vtxstore_fix_refs` read `Model*` through the mistyped `ChrRecord.chrflags` 4-byte-enum alias instead of `ObjectRecord.model`), not the destroy-path race originally suspected. Verified live on real Steam Deck hardware (Facility + Caverns, zero crashes); Windows 21-level sweep clean. |
|
||
|
||
| D256 | **Saves (and config) silently failed to persist on Linux whenever the game was not launched from the directory containing `data/` — reported as "saves do not…** — full `## D256` entry at file tail | FIXED (path half) — save-flow detail still owed from the user. |
|
||
| D257 | **New `Game.AllUnlocked` option (F10; default flipped OFF for v0.2.0 — see D259): skip N64 progression entirely — all 21 solo levels selectable at every…** — full `## D257` entry at file tail | FIXED — verified by user playtest (PR #79 merged); default flipped OFF for v0.2.0 (D259). |
|
||
| D259 | **`Game.AllUnlocked` (D257) breaks audio on a fresh save: with it ON, music and SFX load at volume 0 (silence). v0.2.0 ships with `AllUnlocked` default OFF.** — full `## D259` entry at file tail | **FIXED in v0.2.1** —… |
|
||
| D260 | **v0.2.1 (user request): mouse-wheel weapon-select direction swapped.** — full `## D260` entry at file tail | FIXED — build green; one-line branch swap, trivially reviewable. |
|
||
| D261 | **v0.2.1 (user request: "fix watch menu sensitivity for wasd/controller buttons"): holding W/S (or a fully-pressed stick axis) on the watch inventory page…** — full `## D261` entry at file tail | FIXED — feel-check received; W/S sensitivity follow-up logged above. |
|
||
| D262 | **v0.2.1 (user report): "3D models missing on watch menu screen item select" — the selected-item 3D preview does not appear on the watch inventory page.** — full `## D262` entry at file tail | FIXED (primary cause, D264) — pixel-level confirmation pending user run. |
|
||
| D264 | **D262's root cause (ABI/layout class, cf. D140/D52): the watch item preview's `ModelRenderData` template is read across two adjacent globals that no longer sit…** — full `## D264` entry at file tail | FIXED — build green, probes removed; visual confirmation pending. |
|
||
| D263 | **v0.2.1 (user report): Facility — "Ourumov not shooting Trevelyan"; the scripted opening beat never triggers. RESOLVED: not a port bug — the beat is…** — full `## D263` entry at file tail | RESOLVED (not a bug), refined (v0.2.1 further user testing, 2026-09-15). The D318 re-report (2026-09-18) turned out to be a **different** failure mode (derailment race → softlock, see D318 RESOLVED) — D263's proximity-trigger mechanism map stands. |
|
||
| D265 | **D236 investigation (port-layer, `port/fast3d/gfx_pc.cpp`): the tree cards are IA8/IA4, not IA16 — and `import_texture_ia4` decoded the RDP's I2:A2 texel as…** — full `## D265` entry at file tail | CLOSED (2026-09-26 bookkeeping: sub-pass of D236, which closed in M-198). Earlier: PARKED — reverted to baseline; evidence + suspect list above. |
|
||
| D258 | **PAL/JP asset conversion is broken at the source-data level — `scripts/filelist.e.csv` and `filelist.j.csv` carry naming defects, so `gen_romassets.py e/j` (and…** — full `## D258` entry at file tail | DEFERRED (user call 2026-09-28: v0.4.0 ships NTSC-only, README-disclosed; the verified repair path is post-v0.4 backlog). Earlier: OPEN — repair path verified, not applied (out of v0.2.0 scope). |
|
||
| D284 | **`port/src/random.c`'s MIPS64 `dsll32`/`dsrl32` shift helpers were backwards — PC PRNG desynced from the N64's from draw 1, in every release to date (M-138 review; fix M-139b).** — full `## D284` entry at file tail | FIXED, 21-level sweep clean, no crashes — RNG stream changes from every prior build; existing replays will diverge. **"Savegames unaffected" was FALSE (M-148): `fileGenerateCRC` runs off a local constant seed via `randomGetNextFrom`, so the fix invalidates every stored slot CRC — v0.3.0 silently wipes all pre-fix saves on first launch (release-blocker candidate, see D295 M-148)** |
|
||
| D285 | **Steam Deck SIGSEGV during D255 live-testing = SEPARATE bug: the `sndHandleEvent` preempt-scan walks the active-sound list unlocked (M-140; fixed M-141).** — full `## D285` entry at file tail | FIXED (user, 2026-09-26: fixed around v0.2.1/v0.2.2; D305 guard also landed; not seen since). Earlier: **PARTIALLY FIXED, RECURRED PAST THE FIX (see D305, 2026-09-18).** `osSetIntMask` lock added around the preempt-scan (M-141), live-verified crash-free on real Deck hardware (2026-09-16) — but the exact same fault (same FAULT ADDR, same site) recurred on the v0.3.0 pre-release build, disproving the lock as a *complete* fix: it stops concurrent mutation but not a scan that starts on an already-empty list. D305 adds the actually-missing NULL guard. Do not conflate with D255 — different thread, different subsystem. |
|
||
| D286 | **User-confirmed real ~8s frame freeze on Caverns, Steam Deck — self-recovered, not a crash (M-140).** — full `## D286` entry at file tail | CLOSED (2026-09-26, user-approved: single occurrence on a v0.2.x-era Deck build, before the D255/D285/D305/D329/D336 fixes; not seen since; reopen on recurrence). Earlier: OPEN — one confirmed occurrence, no repro trigger identified, not root-caused; distinct from D255/D285. Do not chase a repro without a better lead. |
|
||
| D287 | **User-confirmed real, non-self-recovering freeze on Windows (M-141, 2026-09-15/16) after ~15-20 minutes with no input, triggered by clicking back into the…** — full `## D287` entry at file tail | FIXED (user playtest 2026-09-26: idle-unfocused → click-in resumes, mouse checks fine). Earlier: FIX APPLIED, pending user re-test (2026-09-25) — likely mechanism found by code review: SDL mouse-mode/cursor calls (`SDL_SetRelativeMouseMode`, `SDL_ShowCursor`) were made from the scheduler thread (every controller poll) concurrently with the host (window) thread's focus/click handling; the idle+refocus trigger is exactly when both flip grab state. Now queued and applied only on the host thread (`inputApplyMouseRequests`). Not reproduced; confirm with an idle 20 min + refocus test. **Steam Deck (Linux/Wayland, 2026-09-27/28): idle + Deck settings and resume-from-sleep both fine — idle/refocus confirmed on the Deck.** |
|
||
| D288 | **Silo intro: a huge, flat-shaded, untextured brown/tan triangle sweeps across the frame during the camera pan down the missile shaft (M-141, user report —…** — full `## D288` entry at file tail | CLOSED — not reproduced on the current build (2026-09-23 capture) and the 2026-09-25 user Silo playtest confirms it gone ("silo fixed"). Fixer not isolated (D271 A/B ruled out). |
|
||
| D289 | **Dam: a crash in the computer room, not reproducible on a second attempt (M-141, Steam Deck user playtest).** — full `## D289` entry at file tail | FIXED (user, 2026-09-26: no recurrence on post-D255 builds). Earlier: OPEN — needs a human re-test specifically on a build with the D255 fix (this session's `release/v0.3.0`, not any earlier build) to see if it still happens.… |
|
||
| D290 | **Watch's in-game "controller" section shows no control-pad graphic (M-141, Steam Deck user playtest).** — full `## D290` entry at file tail | FIXED (2026-09-23): D264-pattern template read in `watchRenderController` (flags read 0), plus 3 pointer-width/raw-Gfx sites on the same path; controller model verified rendering on Windows. |
|
||
| D291 | **Dam: texture glitches when the truck drives past (M-141, Steam Deck user playtest).** — full `## D291` entry at file tail | CLOSED — duplicate of D306, faithful (M-201, 2026-09-26: user-captured, Dam intro camera 5 only). Earlier: OPEN — observed, not investigated. Needs a live repro with the truck's exact position/timing and, ideally, a screenshot. |
|
||
| D292 | **Death animations are glitched — Bond is often sunk halfway into the floor (M-141, Steam Deck user playtest).** — full `## D292` entry at file tail | **MOSTLY FIXED as a side effect of D243 (2026-09-18, user-confirmed alongside D173) — same fix, same outcome: right 9/10 times, small residual offset only when off, no more full sinking/glitching.** See D173 for the user's exact confirmation wording; not separately re-tested, reported together. |
|
||
| D293 | **QoL ask: no discoverable way to quit the game from the UI (M-141, Steam Deck user playtest — "couldn't see a way to exit the game. Maybe build this into the…** — full `## D293` entry at file tail | FIXED (M-141) — "Quit to desktop" row added to the F10 overlay; build-verified Win+Linux, not yet live-confirmed.… |
|
||
| D294 | **Statue: ground geometry near the large CCCP/hammer-and-sickle monument briefly renders solid black over a wide area, then self-recovers 10-30 seconds later…** — full `## D294` entry at file tail | **FIXED, user playtest confirmed (2026-09-25: not reproduced)** — root cause: room-model POOL exhaustion (authored `-ma220`=220 KB < ~250 KB monument-plaza demand at FovScale 130 + max DD/Lod); `memaAlloc` NULL → silent skip → undrawn room shows the night-sky clear colour. Stochastic (wall-clock seed → load order). Fix: `portRoomPoolScale()` (video.c, identity at N64-faithful settings) applied in `bossMainloop`; verified headless (faithful→exact authored size; user settings→0 failures; `GE_ROOMPOOL=0.25`→exhaustion on demand). Playtest: main patch gone; residual corner flicker traced to full-level residency (487 KB = 2.2× authored) starving the 2× pool; the 3×/linear fix REGRESSED the renderer on the menu-driven path (D156 NaN anim state, not a bank overflow) → reverted to sqrt + 2.0 cap (ma=450560). Residual open; probes in place for next capture. High-DD horizon edge on night levels = setting-inherent, documented only.… |
|
||
| D295 | **Campaign-halting crash at the end of Control Center on 00 Agent difficulty, then a crash on every subsequent relaunch/resume (GitHub #87, user campaign playtest).** — full `## D295` entry at file tail | FIXED (M-149) — M-148 root-caused + reproduced on HEAD; fix landed: port-side JPN-cache seed (`port/src/main.c`) + `#ifdef PORT` buffer widen in `front.c` (D8 class). Verified: previously-crashing input now clean, 21/21 sweep PASS. Companion save-wipe regression → D297. History — M-148 fully root-caused + **reproduced on current HEAD**: `difficultytext[4]` (`front.c:2616`) overflow — `strcpy("00 Agent")`/`strcat("\n")` spills onto `textpos.p[0]` (y, ≥0x80 in normal geometries) → high-bit byte → `langGetJpnCharPixels` NULL deref (`language.c:320`, `g_JpnCacheCacheItems` NULL outside JPN mode — likewise unallocated on N64 non-JP builds, crash potential is decomp-native). The user's save is a *valid pre-D284* save (all 5 slot CRCs bit-exact under the old PRNG); post-fix builds wipe it at validation, which is why the unmodified artifact no longer triggers it — NOT a fix. No OOB exists (M-147 guess retracted: 007 difficulty has no stored times). Fix options flagged for sign-off (port-side JPN-cache init preferred; `difficultytext` enlarge = D8 class). Side finding: v0.3.0 silently wipes all pre-fix saves on first launch (D284 side effect — own D entry owed). Separate still-open issue found: unconditional `[EVTQ-DROP]` audio-pool-exhaustion logging, `src/libultra/audio/event.c:92`.… |
|
||
| D296 | **Control Center, 00 Agent: noticeable stutter, user's own read is "maybe from the drone guns" (M-146, same D295 investigation session).** — full `## D296` entry at file tail | CLOSED (2026-09-26, user-approved: stutter seen only under gdb (debug.ps1); D302, fixed two days later, removed a per-triangle uncached getenv costing ~15 fps; reopen if seen on a normal build). Earlier: OPEN — single live report, un-triaged; observed only under a gdb-attached debug-harness launch (`tools_pc/debug.ps1`), not the packaged release artifact — user flagged that harness overhead has caused false stutter before. Needs a plain-exe / real-release repro check before any perf tracing.… |
|
||
| D297 | **v0.3.0 silently wipes ALL pre-fix player saves on first launch (D284 side effect; found during D295/M-148).** — full `## D297` entry at file tail | FIXED (M-150) — migration shim landed: `port/src/legacycrc.c` (isolated, deliberately-wrong pre-D284 PRNG, reviewer-verified bit-exact against `0de99b4d^`) + eeprom-shim read-path hook in `libultra.c` (block 4 only; persists pristine+migrated bytes before the read-time-only AllUnlocked patch). Verified: user's #87 artifact → `D297-migrated 5`, all slots OK, idempotent on relaunch; native post-fix save untouched; corrupt slot left to the game's own wipe path; fresh eep untouched; 21/21 sweep PASS. Block-0 header needed no migration (factory seal, verified). |
|
||
| D298 | **Full HUD (not just the aim-mode crosshair) missing in a v0.3.0 dev build — user suspects a debug-launch artifact and asked NOT to raise it until seen in a regular release-targeted build; parked** — full `## D298` entry at file tail | CLOSED (2026-09-26, user: HUD is fine now). Earlier: s — regression reported 2026-09-16 during v0.3.0 work (build ce8427cf); user says "don't fix, just for context"; not yet investigated** — full `## D298` entry at file tail |
|
||
| D299 | **Game.SkipIntro crashed selecting any save slot except one — reproduced on a fresh save and on a clean Release rebuild, ruling out stale save data (found + fixed 2026-09-17).** — full `## D299` entry at file tail | FIXED — `fileValidateSaves()` (only called from the skipped legal screen) never ran under `SkipIntro`, leaving `saves[]` all-zero; added the missing call to the `SkipIntro` shortcut (`src/game/lv.c`, `#ifdef PORT`, zero game-logic change). Build-verified both `RelWithDebInfo`/`Release`; live user re-test confirms all 4 slots now selectable with `SkipIntro=1`. Not yet run through a full 21-level sweep. |
|
||
| D300 | **Hipfire look feels non-uniform (issue #89): mouse px routed through the N64 analog stick's quadratic natural-turn curve, so slow motion is "almost unrecognised" and fast motion saturates at full turn speed by ~13 px/poll.** — full `## D300` entry at file tail | FIXED, user-confirmed live — WI-1 per `docs/dev/GEPD-INPUT-PLAN.md`: new `hipDirectCompute()` (`port/src/input.c`) writes `vv_theta`/`vv_verta` directly, mirroring GEPD's hipfire formula and D194's existing aim-mode direct-write pattern — linear px→degree, FOV-scaled, dt-normalized, bypassing the stick curve entirely. Safety-gated on `bonddead`/`outside_watch_menu`/`pause_state` plus (M-190, 2026-09-18) the scripted-camera-mode check the original safety-gates writeup named but never implemented — mouse could freely move the camera during POSEND/INTRO cutscenes until this session. `Input.MouseDirectLook` (default 1) is the escape hatch back to the legacy stick path. Build/link-verified; user playtested live: "input already feels better," then again post-M-190: "ok yeah looks good." |
|
||
| D301 | **`fileGetIsCheatUnlocked()` NULL-deref, unguarded on every call site, not just the D299/SkipIntro one (GitHub #87, two reports with an identical crash PC/fault address).** — full `## D301` entry at file tail | FIXED locally on `release/v0.3.0` (not pushed) — NULL-guarded `fileGetIsCheatUnlocked()` itself (`src/game/file2.c`, `#ifdef PORT`) rather than only its one known trigger route; build-verified, crash-free smoke test. Live confirmation against the actual #87 repro still owed — a synthetic `GE_STARTMENU=6` repro attempt did not reproduce the crash even pre-fix, so the exact #87 trigger condition (why the save lookup fails) remains unconfirmed. |
|
||
| D302 | **Render FPS regression to ~40-45fps on release/v0.3.0 (user QA on the local v0.3.0 pre-release build, 2026-09-18) — a reintroduction of D250's own uncached-`getenv()`-in-the-hot-path bug class.** — full `## D302` entry at file tail | FIXED — 4 uncached `getenv()` calls added by D236 passes 10/12/14/16 directly inside `gfx_sp_tri1` (`port/fast3d/gfx_pc.cpp`, per-triangle) cached with the same `static int x = -1` pattern D250 established elsewhere in the file. Verified via `GE_D193=1 -level_33` (Dam): rock-steady `render=59-61` post-fix, matching D250's own post-fix baseline exactly (pre-fix D250 pinned this level at 45-50fps). Not yet re-confirmed by the user's own hands-on playtest. |
|
||
| D303 | **Some muzzle flashes (M16 named specifically, "and some others") draw wrong — the flash renders very long, extending straight up from the gun (user QA on the v0.3.0 pre-release build, 2026-09-18).** — full `## D303` entry at file tail | FIXED (M-201, 2026-09-26): d43_emit sized op-22 star-flash vertex arrays as count, not count*4 arms; regenerated sidecars render a clean star. Earlier: OPEN — CONFIRMED PORT BUG (2026-09-26, user vs 1964: M16/AR33 muzzle flash shoots up and draws much higher on PC; N64 shows a normal star/burst at the muzzle). Earlier: OPEN — 1964 comparison owed; absorbs D220 (user 2026-09-26: M16/AR15 flash rises regardless of FOV). Earlier: OPEN — investigated: pipeline + weapon stats + model conversion ruled out; GE_D303 capture probe in-tree (`port/fast3d/gfx_pc.cpp`), blocked on a live firing repro. |
|
||
| D304 | **Mouse sensitivity menu overhaul (user request, 2026-09-18): aim speed/turn speed sliders weren't "1:1", had too little range, and (follow-up) risked decoupling into a bad state; F10 menu also had a click-jump bug.** — full `## D304` entry at file tail | FIXED — see full entry for both the range/parity pass and the same-day follow-up (collapsed to a single `Input.MouseSensitivity` menu row + fixed `overlayUpdateScroll()`'s unconditional bottom-anchor, the actual cause of the reported "menu jumps to the setting below on click"). Build-verified (Windows) both passes; headless overlay smoke test clean after each. **Live F10 playtest done (2026-09-18): user confirmed the collapsed single-sensitivity menu works, the click-jump is gone, and the D251 bottom-row duplication no longer occurs.** |
|
||
| D305 | **Steam Deck SIGSEGV on the v0.3.0 pre-release build (Cavern, early in the level, `Game.AllUnlocked` previously enabled on the session/save) — same exact FAULT ADDR 0x62 / `sndHandleEvent` signature as D285, reproduced AFTER D285's own lock fix was already live-verified crash-free on real hardware.** — full `## D305` entry at file tail | FIXED (defensive guard) — `sndHandleEvent`'s preempt-scan (`src/snd.c`) dereferenced its list-walk pointer before ever checking it for NULL (a do-while always runs its body once); D285's `osSetIntMask` lock only prevents another thread from emptying the list mid-scan, not a scan that starts with an already-empty list, so the crash could still happen even with that fix in place. Added the missing `iterState != NULL` guard before the loop, matching D255's established skip-instead-of-dereference precedent. Root desync mechanism (why pool-exhaustion accounting and the list can disagree) still not identified; `Game.AllUnlocked` correlation not proven, only reported. **Live-verified on the Deck (2026-09-18):** user ran two test sessions on Cavern with the updated build, plus toggled `Game.AllUnlocked` off then back on and relaunched — no crash in any of them. |
|
||
| D306 | **Dam intro/truck wheels render with odd transparent-looking areas, possibly z-fighting (user QA, 2026-09-18).** — full `## D306` entry at file tail | CLOSED — faithful, confirmed on 1964 (M-201): all PC-specific leads refuted (FOV, depth precision, depth state, near plane, wheel transforms); it is the authored truck geometry seen from intro camera 5 under the truck. Absorbs D291. Earlier GE_ZF "real fighting" was a probe-quantisation artefact. |
|
||
| D307 | **In-level security camera entities can be flipped facing the wrong direction (backwards) on some levels, e.g. Bunker (user QA, 2026-09-18) — NOT the player's view camera.** — full `## D307` entry at file tail | FIXED (M-201, 2026-09-26, user-verified): renamed duplicate member `CCTVRecord.pad`→`lookpad` made setupCctv read the MOUNT pad as the look-at pad; prop.c reads lookpad on PC. Earlier: OPEN — CONFIRMED PORT BUG (2026-09-26, user vs 1964: ALL Bunker 1 security cameras face backwards on PC, correct on N64). Earlier: OPEN (investigated 2026-09-21) — no port-side bug found: orientation path traced, every input verified N64-faithful vs ROM (`PcctvZ` switch data, room-scaled boundpads, config words), all math byte-identical `src/game`. Blocked on user repro detail; `GE_D307=1` diagnostic in-tree. |
|
||
| D308 | **Occasional z-fighting confirmed on some levels' geometry, general/broader than the D306 truck-wheel instance (user QA, 2026-09-18).** — full `## D308` entry at file tail | CLOSED — faithful (2026-09-26, user: Statue ground z-fighting reproduces on 1964 GEPD bundle). Earlier: OPEN (user, 2026-09-26: possibly faithful; validate Statue ground z-fighting on 1964 vs port). Earlier: OPEN — investigated: PC depth precision == N64 (24-bit both sides; RSP writes full 16.16), so level-authored coincident geometry is the prime suspect; GE_ZF flicker probe in-tree. |
|
||
| D309 | **Complete non-self-recovering freeze (Windows) returning from Caverns to the intro screen — boss thread spins forever in `ai()` on `m_RunToBondPersistent` via a phantom `GotoNext(lblZero)` created by `chraiitemsize`'s `AI_PRINT` NUL-scan mis-sizing a stringless PRINT record (user report + live GDB diagnosis, 2026-09-18).** — full `## D309` entry at file tail | **FIXED via D310 — user-playtest-confirmed 2026-09-18** (Caverns→intro clean; the spin record itself observed resuming correctly in the post-fix probe log). The teardown-ordering open question (why PC presents the unreachable-Bond state) remains documented but is now harmless. |
|
||
| D310 | **RULE-2-SIGNOFF: `chraiitemsize()` mis-sizes 1-byte `AI_PRINT` records (NUL-scans forward), corrupting AI execution for any list containing an active PRINT — fixes D309's freeze mechanism and the Caverns standing-still guards (`Stop`+`PRINT("wait")` mis-lands on EndList, re-executed every tick; user report 2026-09-18).** — full `## D310` entry at file tail | **FIXED — user-playtest-confirmed 2026-09-18** (v2: global-list-only sizing via `d310ItemSize`; guards resume, Trevelyan pathing intact, Caverns→intro clean; probe log verified both list classes). PD precedent: PRINT removed, slot 0xAD reassigned. |
|
||
| D311 | **Complete freeze in `modelSetAnimFrame2WithChrStuff` root-motion loop — D156 guard hole: poisoned `framea` (`model->animframe1`) passes the frameb-only clamp and saturates to a ~3.1e9-iteration spin (Caverns, live GDB diagnosis, 2026-09-18).** — full `## D311` entry at file tail | FIXED — root cause D336 (`bheadAdjustAnimation` read never-written placeholder fields); the `#ifdef PORT` guard is retained as a safety net. 2026-09-25 user playtest incl. ~2 min of Caverns combat: 0 D311 ANOMALY / D156 lines in a ~46-min session. |
|
||
| D312 | **Throwable items (mines, grenades, etc.) can clip through walls in certain spots (user report, v0.3.0 pre-release playtest, 2026-09-18).** — full `## D312` entry at file tail | FIXED — `bgBuildRoomVtxBounds` mis-reads the G_VTX vertex count on PC (`Gfx_le.dma.par` bit-layout shift): n=16 batches collapse to single-point bounds and fail the per-batch AABB pre-test, so their wall triangles are never ray-tested. One-line `#ifdef PORT` fix; verified by repro. |
|
||
| D313 | **Bullet impacts do not appear on some walls/surfaces — the projectile appears to pass through them; impacts DO work on some surfaces such as doors/windows (user report, v0.3.0 pre-release playtest, 2026-09-18).** — full `## D313` entry at file tail | FIXED — same root cause as D312, merged diagnosis; same one-line fix. Doors/windows use the separate working `bgTestHitOnObj` path, hence the "surface-dependent" appearance. |
|
||
| D314 | **F10 menu items flicker randomly/intermittently (reported on the main-menu context, user report, v0.3.0 playtest build, 2026-09-18).** — full `## D314` entry at file tail | FIXED (user playtest 2026-09-26) — M-200 wheel-queue race fix + D287 window-thread cursor calls. Earlier: confirmed still present post-D316 fix (2026-09-18). Diagnostic probe (`GE_D314=1`) landed in `optionsOverlayHandleInput()` if revisited. |
|
||
| D315 | **Menu scaling differs between main menu and in-game — main menu scales OK, in-game menu "seems kinda big sometimes" (user report, v0.3.0 playtest build, 2026-09-18).** — full `## D315` entry at file tail | CLOSED (2026-09-26, user: menu scaling looks fine; likely resolved by the v0.4.0 widescreen/HUD-scale work D334/D335/D226). Earlier: OPEN — disclosed as a known issue, deferred to v0.3.1 (bigger design question, not a mechanical fix). |
|
||
| D316 | **Mouse-clicking some menu items glitches and lands on the item below instead (user report, v0.3.0 playtest build, 2026-09-18).** — full `## D316` entry at file tail | FIXED — root-caused to two compounding bugs (naive mouse->logical mapping ignoring the safe-area crop; asymmetric row hit-test band) in `port/src/optionsoverlay.c` + `port/fast3d/gfx_pc.cpp`. Build-verified (golden diff PASS, worst_cell=0); interactive click verification owed. |
|
||
| D317 | **Crash when toggling Game.AllUnlocked back and forth in-game (user report, v0.3.0 playtest build, 2026-09-18) — no dump left behind.** — full `## D317` entry at file tail | CLOSED — symptom of D316 (confirmed instant window close, no WER report; the D316 hit-band bias let a click on "All unlocked" fall through to the adjacent "Quit to desktop" row). Same fix resolves it. |
|
||
| D318 | **Facility (regular play path): after Objective C (destroying the bottling tanks) completes, Ourumov freezes and does not shoot Trevelyan as he should (user report, 2026-09-18).** — full `## D318` entry at file tail | FIXED via D329 (2026-09-26 bookkeeping: tick-granularity root cause; Rule-2 fix in model.c, user-verified 2026-09-23). Earlier: OPEN — re-classified 2026-09-23 by D329 (tick-granularity divergence at 1 tick/frame, not a platform-invariant lock); fix decision pending, watchdog stays shipped. Earlier resolution text kept in the full entry. |
|
||
| D319 | **Frame cap slider was mostly a dead zone; replaced with a plain 30/60 toggle (user ask, 2026-09-18).** — full `## D319` entry at file tail | FIXED — new `ROW_FPSCAP` cycle (30/60 only) in `port/src/optionsoverlay.c`; default changed to 60 in `port/src/video.c`. Root cause was D186 (sim tick ceiling + low-cap guard); this just fixes the menu's exposure of it. Build-verified; interactive confirmation owed. |
|
||
| D320 | **D318-class sweep: aim-hold → `_update` AI lists in 8 levels carry the same as-authored softlock race (static analysis, 2026-09-20).** — full `## D320` entry at file tail | FIXED via D329 (2026-09-26 bookkeeping: tick-granularity root cause; Rule-2 fix in model.c, user-verified 2026-09-23). **v0.4.0 campaign (2026-09-28): no D320-beat freeze seen (user: good).** Earlier: OPEN — re-classified 2026-09-23 by D329 (tick-granularity divergence at 1 tick/frame, not a platform-invariant lock); fix decision pending, watchdog stays shipped. Earlier resolution text kept in the full entry. |
|
||
| D321 | **D318 trigger chain fully mapped: tanks → combat bit is an authored ~3.5 s gas-cascade delay (chr 254 script); derail lands same-tick; PC behavior confirmed faithful to N64 (probe captures, 2026-09-20).** — full `## D321` entry at file tail | FIXED via D329 (2026-09-26 bookkeeping: tick-granularity root cause; Rule-2 fix in model.c, user-verified 2026-09-23). Earlier: OPEN — re-classified 2026-09-23 by D329 (tick-granularity divergence at 1 tick/frame, not a platform-invariant lock); fix decision pending, watchdog stays shipped. Earlier resolution text kept in the full entry. |
|
||
| D322 | **Long-session audio degradation: full campaign on v0.3.0 — audio progressively worsens from Silo, by Caverns/Cradle the OST is inaudible and SFX "come and go"; restarting the game restores it (issue #87, user report, 2026-09-20).** — full `## D322` entry at file tail | FIXED (Windows) for the periodic "garbled" bursts — root cause SDL2's WASAPI backend, not the device: side-by-side monitor runs on the same USB interface show WASAPI draining 81–88 % for ~30–50 s every ~317 s (1.4 % playback lost overall) while DirectSound stays at exact real time. `port/src/audio.c` now prefers `directsound,wasapi` (SDL_AUDIODRIVER env still overrides). Remaining: SFX drop-outs in firefights, if still heard after this fix (8-voice cap, D207/D241); #87 progressive decay: **v0.4.0 campaign (2026-09-28) -- user has not seen it again, "probably good"; closed on user trend, `GE_D322=1` capture recipe remains in the entry for any recurrence.** |
|
||
| D323 | **G_TL_LOD bindings of explicit-LOD textures: PC samples driver mips from LOD0, N64 samples the authored mip chain (static census, 2026-09-21).** — full `## D323` entry at file tail | CLOSED — parked as a fidelity enhancement, not a bug (2026-09-26, user-approved: no reported artifact). Earlier: OPEN / parked — mechanism confirmed in code; census (`tools_pc/bg_lod_audit.py`) shows 15 levels with explicit-LOD textures bound under TEXTLOD-on classes (stat 378, azt 806 binds heaviest); overwhelmingly CI8/IA8 (subtle), the only RGBA5551 cut-outs are the D236 set (already fixed). No reported artifact; fix path documented (upload GL mip chains for TEXTLOD-on bindings) but not worth doing speculatively. Also documents GE's OMH field-write encoding (op 0xBA, value at absolute bit position in w1). |
|
||
| D324 | **GE's <stdlib.h> stub declares nothing, so TUs that include it get NO CRT prototypes — implicit `int f()` declarations silently corrupt non-int returns: `atof` garbage in every float ini parse (D38 class).** — full `## D324` entry at file tail | FIXED (2026-09-22) — proven impact: `Game.ScreenShakeIntensity` (the only float ini option) silently parsed to garbage whenever a user set it; caught via the D294 `GE_ROOMPOOL` hook. Fix: `port/shim/stdlib.h` now declares atof/strtod/strtol/atexit/getenv unconditionally (K&R form); pc_protos.h gains atof/strtod for game TUs; glass2.c PORT block uses `__builtin_memset`. Residual harmless implicit decls (atoi/floorf/sqrtf/memset builtins, lround) documented. |
|
||
| D325 | **ROM-mod (xdelta) compatibility: relocating/total-conversion mods cannot work while the file table is compiled in; in-place data-only mods are one sidecar-staleness fix away (Goldfinger 64 probe, 2026-09-22).** — full `## D325` entry at file tail | CLOSED as a question — mechanism measured (GF64 relocates 797/803 vanilla file slices; 12→24 MB expanded ROM; `d43_emit` dies on a vanilla-offset zlib slice). ASM mods never work; relocating mods need a runtime-parsed file table (rule-2 collision for added levels); in-place data-only mods need only a ROM-identity stamp in `manifest.csv` (`port/src/romconvert.c`). |
|
||
| D326 | **Random Eye randomizer repacks the ROM too — the hypothesised "in-place data-only" mod tier is empty in practice; the in-ROM file table is the single keystone for any mod support (2026-09-22).** — full `## D326` entry at file tail | CLOSED as a question — identical ROM size but 96.5% of bytes and 803/803 file slices differ (content preserved, shifted +7216/+7392/−229888); same `d43_emit` zlib failure. D325's cheap tier disproven. Two bounded scans failed to find the in-ROM `fileentry` table (likely inside the compressed code image); that recovery question gates everything. Backlogged (local notes §5, extends T3.6). |
|
||
| D327 | **Mod support, resolved plan: reading the file table out of the ROM is ruled out by measurement; content-addressed offset remapping (gap inference over contiguously-packed files) is the viable route (1964GEPD fork review, 2026-09-22).** — full `## D327` entry at file tail | RESOLVED — superseded by D328; approach (a) is NOT ruled out (these scans missed the 1172-compressed data segment). `hw_address` absent from the ROM in all 4 encodings tested (0 hits for the segment-relative form); `file_resource_table.inc.c` is a decomp reconstruction. Viable route: locate unchanged files by content, bound changed ones by neighbour gaps (`obInit` differencing proves contiguity). Verified primitive: GE's `0x7F000000` segment maps uncompressed from ROM `0x34b30`, uniquely signature-findable. 1964 fork is GPL-2.0 — facts only, no code reuse. |
|
||
| D328 | **Mod support is viable and D327's "ruled out" was wrong: GE's file table IS in the ROM, inside the 1172-compressed data segment, and locating it needs no hardcoded offsets (PD dabs fork review + verification on vanilla + 2 mods, 2026-09-22).** — full `## D328` entry at file tail | CLOSED as a question — table at `0x252c4` inside the segment inflated from ROM `0x21990` (727 rows; 681 exact offset+size matches vs filelist, 0 mismatches). Hardcode-free scan reads Random Eye's own table -> **704/726 files byte-identical** (vs 0/803 at vanilla offsets in D326) and GF64's 24 MB table correctly. PD's architecture is an offline importer + mod-dir override, not a runtime ROM loader; its MIT `rompatch.c` removes the GPL xdelta3 constraint. Remaining work is the mod-dir override layer. |
|
||
| D329 | **The D318/D320 guard anim pin is a tick-granularity divergence: it exists only at 1 sim tick per rendered frame (port at 60 fps); at 2+ ticks/frame (GE's normal N64 regime) it cannot persist (lockstep A/B, 2026-09-23).** — full `## D329` entry at file tail | FIXED — Rule-2 high-FPS fix in model.c; clk=1 no longer pins (WSL + native Windows), 0 triggers at clk=2; bunker1 golden failure predates it (on/off A/B); user playtest confirms N64-matching Ourumov timing; watchdog stays shipped for now. |
|
||
| D330 | **#103 (v0.3.0, Win 11): Auto-Aim / Look Ahead "won't stay on" in the in-game watch options: the mouse drove the analog stick inside the watch, and a small leftward flick selected OFF.** — full `## D330` entry at file tail | FIXED (port/src/input.c) — user-confirmed 2026-09-25: Auto-Aim set ON in the watch stays ON while moving the mouse. |
|
||
| D331 | **Ejected shell casings never rendered on PC: `g_DefaultCasingModelRenderData` (u32[15] template) read as the wider PC `ModelRenderData` gave flags 0 (third instance of the D264 pattern; found by the D290 sibling sweep, never user-reported).** — full `## D331` entry at file tail | FIXED (gunfire.c `#ifdef PORT`, explicit template); PP7 casing verified on native Windows (before: none; after: brass casing ejects and tumbles); golden 3/3; Win + Linux builds clean. |
|
||
| D332 | **Reticule jitter in aim mode (RMB) — ROOT-CAUSED from live captures; fixed by adopting the Perfect Dark port's mouse-aim model behind `Input.PdMouseAim`.** The game's damped crosshair update runs once per SIM TICK while the port's GEPD overwrite runs once per input POLL, so the value displayed is `write * damp^k` with k varying (k=1 66%, k=2 18%, k=0 17% of frames). At damp 0.8 that stepped the drawn reticule 20% whenever k changed | **FIXED (`fix/mouse-pd-model`)** — `Input.PdMouseAim` (default off, live via `GE_PDMOUSEAim`): the port accumulates only and the game's integrator is driven by the port-supplied turn with PD's near-zero damp (0.01), via a `#ifdef PORT` hook in `sub_GAME_7F067FBC`. Two earlier fixes were tried and reverted (see the `## D332` entry). **Awaiting feel-test.** |
|
||
| D333 | **#104 (v0.3.0, Linux): FPS-style centred aim on RMB, delivered as `Input.AimStyle` (F10 "RMB aim style": N64 / PD DAMP / CENTRED), folding in D332's toggle.** — full `## D333` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: IMPLEMENTED (port/src/input.c + optionsoverlay.c, port-only); default N64 = unchanged; build + golden clean; feel check owed. |
|
||
| D334 | **Native widescreen, world side (WIDESCREEN-FOV-PLAN Option B Phase 2): the world is projected at the real window aspect (Hor+) by generalising GE's own N64 16:9 mode; `Video.NativeWidescreen` (default on).** — full `## D334` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: IMPLEMENTED (Rule-2 approved 2026-09-23); 4:3 identity (golden 3/3); native 16:9 centre matches the 4:3 frame (mean diff 4.3 vs 12.4 stretched); 21:9 clean on Bunker/Facility/Dam. HUD still stretched → Phase 3. |
|
||
| D335 | **Native widescreen, HUD + front end (WIDESCREEN-FOV-PLAN Phase 3): HUD elements anchored left/right/centre and the front end pillarboxed via the fast3d EXT aspect modes (PD pattern), `port/include/hudaspect.h`.** — full `## D335` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped, user-verified at 16:9/21:9; F10-overlay pillarbox remains as D335b in GRAPHICS-BACKLOG). Earlier: IMPLEMENTED (Rule-2 approved 2026-09-23); 4:3 byte-identical (no emission at ≤4:3); verified at 16:9/21:9 on HUD, watch, title card, legal/file-select/gun-barrel. Menu pointer mapped into the pillarbox. One cosmetic leak → GRAPHICS-BACKLOG D335a. |
|
||
| D336 | **Frigate (and any level) "all polygons break except the gun" after turning: Bond's head-bob anim read two never-written placeholder fields (`field_5C0`, `animFlipFlag`) that on N64 are raw-offset aliases into the inline `player.model` (D100 follow-up; A1 class). Likely the D311 upstream anim-poison source.** — full `## D336` entry at file tail | FIXED (src/game/bondhead.c `#ifdef PORT`, ABI/layout); headless repro (Runway → exit → Frigate → turn, D294 2× room pool) clean, D156 0 fires; golden 3/3; Win + Linux builds. **User-confirmed 2026-09-24.** |
|
||
| D337 | **Aim modes folded (user decision): N64 (default) = the N64 aim model with the mouse fed through the game's integrator at PD's damp (D332 made default); CENTRED (PC) opt-in; Q/L and Toggle-aim now route mouse aim like RMB; pad/keyboard aim no longer swallowed by the mouse integration.** — full `## D337` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: IMPLEMENTED (port/src/input.c + optionsoverlay.c); headless-measured with new scripted-mouse harness; golden 3/3; Win + Linux builds. Open: N64-range calibration decision. |
|
||
| D338 | **`Input.AimRange` (F10 "Aim range"): PC (default, GEPD/mouse-injector full-screen crosshair) or N64 (original stick limits: 65% of half-width, camera turn from ~49%).** — full `## D338` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: IMPLEMENTED + measured (PC 98%/72%, N64 65%/49%); golden 3/3; Win + Linux builds. |
|
||
| D339 | **#92 (low-end ~30 fps, Celeron N3060 / HD 400) — measured CPU budget: game thread ~0.2–0.35 ms/frame, render-thread display-list pass 0.4–1.8 ms/frame on this box; CPU very likely not the bottleneck. New `GE_PERFSTAT=1` probe (render phases, tris/batches, game-thread busy, GPU timer query).** — full `## D339` entry at file tail | OPEN (2026-09-26, user: review toward the end of v0.4.0; needs a test machine with similar HD 400-class hardware). Earlier: INVESTIGATING — probe landed; target-hardware numbers needed before any fix. |
|
||
| D341 | **Intro gun-barrel blood drip draws as static blocks/lines instead of dripping (user, v0.4.0, 2026-09-26; death blood fine).** — full `## D341` entry at file tail | FIXED (M-201, user-verified 2026-09-27): two fast3d defects — the 1-cycle blood texrect sampled a stale tile-1 declaration via the D236 LOD rule, and the in-place-regenerated dyn-pool texture hit a stale address-keyed cache entry. |
|
||
| D342 | **File select: folder bodies and Bond photos vanish after selecting a file and backing out (user, v0.4.0, 2026-09-26).** — full `## D342` entry at file tail | FIXED (M-201, user-verified): scratch placed at the N64 offset +0xA000 of `ptr_logo_and_walletbond_DL` landed inside the larger PC wallet model; moved past its 0x17000 reservation. |
|
||
| D343 | **File select: PC "Options" label opens the settings overlay (v0.4.0 feature, M1 of the route-B options screen, 2026-09-27); also fixes F10 on file select jumping to the legal screen after 30 s.** — full `## D343` entry at file tail | FIXED (M1, closed): one Rule-2 `#ifdef PORT` draw hook (user sign-off 2026-09-27); headless + user-verified (mouse, F10, >30 s, widescreen, physical controller on the bottom-bar label and options screen). |
|
||
| D344 | **Host PC bugchecks (BSOD 0x119 VIDEO_SCHEDULER_INTERNAL_ERROR) during game test runs; probable cause: quit called exit() while the render thread could be inside the GL driver (2026-09-27).** — full `## D344` entry at file tail | FIXED (probable cause; orderly quit + GE_QUITFRAME), headless + user-verified; no dump, watch for recurrence. |
|
||
| D345 | **Front-end crosshair snaps away from the pointer on screen/page changes (file select → mode select `setCursorPOSforMode(0)`, front.c:2516; every `MENU_PC_OPTIONS` page hop), and the options screen's keyboard navigation was missing/mis-modelled (2026-09-27).** — full `## D345` entry at file tail | Port-only, user-verified (parts a–g): (a) `input.c` re-assert window on menu change; (b) `cursorToItem` skips its snap while the pointer is live; (d) W/S emit stick in menus under NaturalPitch; (e/f) F10-style discrete row stepping + arrow left/right value adjust replace raw drift (first pass shipped broken, fixed); (g) screen-placed cursor parks in the left gutter, off the row text. Game-side one-liner deferred: physical-controller pass was good, no Rule-2 sign-off sought. |
|
||
| D346 | **PC-settings wording pass: option labels/values aligned to Nightdive/Turok + PD-port conventions (title-case On/Off, "Frame rate cap", "Anti-aliasing"/"None", units in values not labels, self-explanatory auto-FOV toggles); VIEW section merged into VIDEO (2026-09-27).** — full `## D346` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
|
||
| D347 | **F10 overlay controller: left/right did nothing (only A/Y adjusted values) and no input had hold-to-repeat (2026-09-27).** — full `## D347` entry at file tail | FIXED (port-only, `optionsoverlay.c`): D-pad left/right + stick-X wired into value adjust (A/X/B/Y kept); 18/4-frame hold-to-repeat on adjust and up/down nav, matching the options screen's D345(e)/(f) cadence. User validated physical-controller pass. |
|
||
| D348 | **File-select "PC Options" label: no settings icon exists in the game art (mainfolderimages = copy/del/select-arrow/X/check/dot only) -- a mini slider glyph is drawn instead (2026-09-27).** — full `## D348` entry at file tail | SUPERSEDED by D398 (glyph replaced with the IMG_DOT texture; no new art). PCDUMP pixel-verified when shipped (135 knob px vs 9 paper). |
|
||
| D349 | **GE watch settings are not normally saved to the Bond file: erase is the only caller of the settings serializer, and stage start reloads the file's old values.** — full `## D349` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
|
||
| D350 | **Watch-backed PC options: shared front/F10 Bond-file rows, explicit chooser, game-thread F10 bridge and field-scoped PC-only persistence.** — full `## D350` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
|
||
| D351 | **Existing D346 value/unit rows had positional initializers shifted into `hidePtr`: %/x rows hidden or formatted without units.** — full `## D351` entry at file tail | FIXED (port-only): designated row initializers restore unit, deadzone divisor and hide-if-auto fields; build clean, headless smoke clean. Manual settings visual check owed. |
|
||
| D352 | **D350 review of 1303c0eb: stage transitions silently drop queued F10 setting changes (queue drained before the `stageActive` check); the UI thread's chooser state is unsynchronized; rapid slider drags push one RSP command per detent and re-persist the same value each frame; persist failures are silent (2026-09-27).** — full `## D352` entry at file tail | FIXED (port-only, `port/src/watchsettings.c`): hold-not-drop at stage transitions, spinlock on chooser state + front-screen guard, per-field coalesced queue commits, warning logs on all no-op paths, `PersistField` made static. NTSC build clean, headless menu smoke clean. |
|
||
| D353 | **Turok-style settings regrouping: the D346 VIDEO mega-section (14–15 rows, at the MAX_PROWS limit) re-split into DISPLAY + GRAPHICS, the "AUDIO (BOND FILE)" hack header dropped (music/FX sliders fold into BOND FILE), all remaining rows converted to designated initializers, `buildPages` overflow now logs, and the Bond-file page title annotates the selected file (2026-09-27).** — full `## D353` entry at file tail | FIXED (port-only, `optionsoverlay.c`/`frontoptions.c`): 6 sections of 3–11 rows, all rows designated-initialized, `optionsRowIsBondChooser` accessor, build clean + headless smoke clean. Master-volume row, per-section reset (Gate E) and key rebinding remain in the M3 backlog. |
|
||
| D354 | **Front-end F10 Bond rows silently disabled: `watchSettingsAvailable()` mis-routed F10-on-file-select through the stage path (returned 0), so with the hidden chooser stuck at "none" the volume sliders read "Select file" and did nothing; maintainer-reported (2026-09-27).** — full `## D354` entry at file tail | FIXED (port-only, `port/src/watchsettings.c`): all non-stage contexts route through the front path with a lazily-resolved default folder (explicit pick > `selected_folder_num` > first valid), one-shot game-thread `fileBuildWriteNewSave(FOLDER1)` fallback, GE_WSPROBE/GE_WSPROBE_FRONT env probes headless-verified (stage: live+save changed; front: default file 1 + save changed). |
|
||
| D355 | **F10/options Music slider inaudible in-stage: the slider drives only the X-track (track 2) via `set_mTrack2Vol`, but in-stage BGM is track 1, which the game pins to `VOLUME_MAX` at level start (lv.c:364) and never rescales from `mTrack2Vol` in solo -- so neither the N64 watch nor the F10 could change the music the player was hearing; maintainer-reported (2026-09-27).** — full `## D355` entry at file tail | FIXED (port-only, `port/src/watchsettings.c`): the Music commit now also calls `musicTrack1ApplySeqpVol(v)` (the exact resync the MP path already does, mpmenu.c:354) and a once-per-activation resync runs on stage entry; by-ear confirmed by the maintainer. N64 watch path untouched. |
|
||
| D356 | **Settings regroup: Turok-style pages renamed to functional sections (INPUT/GAMEPLAY/GRAPHICS/AUDIO/VIDEO) with PD-style "(File N)" scoping annotations, the redundant N64-watch rows (`Bond.Look`, `Bond.AimControl`) off the menu surface, and a real per-section Reset-to-defaults (two-step arm->confirm, watch rows to BLANKSAVEDATA via the D352 commit path, ini rows to the port's C initializers; `__Resolution` a documented exclusion) replacing the Turok reset rows the PC build never honored (2026-09-27).** — full `## D356` entry at file tail | FIXED (port-only, `port/src/optionsoverlay.{h,c}` + `port/src/watchsettings.{h,c}` + `port/src/frontoptions.c`; plan `docs/dev/D356-SETTINGS-REGROUP-PLAN.md`): 5 sections all below MAX_PROWS, hidden rows D181/D216-style (keys + watch paths stay live), edge-triggered reset with 3 s arm window + maintain/clear disarm, GE_WSPROBE_RESET front + in-stage probes headless-verified (all sections failures=0, scope isolation OK, saved bytes 0xFF), GE_WSPROBE/GE_WSPROBE_FRONT regressions clean. |
|
||
| D358 | **D356 level-1 overlap: the front options save-file row (item 0) painted under the first content row, and `s_hl` (item-numbered) was indexed straight into `s_rowIdx` (content-numbered) -- wrong-row adjusts, a dead last row, a one-past read, wrong highlight (2026-09-27).** — full `## D358` entry at file tail | FIXED (port-only, `port/src/frontoptions.c`): content rows draw at `rowY(k+1)`; all `s_hl` -> `s_rowIdx` uses take `s_hl-1`; A-press guards `s_hl >= 1 && s_hl <= s_rowN`; hit-test/cursor untouched (already item-numbered). Companion: "(save)" tag -> "(profile)". |
|
||
| D359 | **Settings UI file terminology settled on the end-user term "Profile": the screen mixed five terms ("Save file"/"File N"/"(profile)"/"Select file"/"(no file)"), and the manual's "game file" reads as the program's own files in modern PC usage (2026-09-27).** — full `## D359` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
|
||
| D360 | **F10 INPUT heading scrolled away; F10 navigation and distance defaults (2026-09-27).** — full `## D360` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
|
||
| D361 | **Main-menu F10 Audio freezes the game: EEPROM save from joyPoll deadlocks waiting for its own poll ack (2026-09-27).** — full `## D361` entry at file tail | FIXED (port-only, `port/src/watchsettings.c` + F10 probe in `optionsoverlay.c`): queue front F10 save edits to game thread; actual poll-thread Music+FX commits tested with existing and fresh EEPROM, orderly quit, user's ini/save restored. |
|
||
| D362 | **F10 GoldenEye dossier-style visual refresh (2026-09-27).** — full `## D362` entry at file tail | CLOSED (superseded by D363 after user playtest): paper/Zurich/gold look rejected. The pinned section heading and shared layout/hit geometry remain. |
|
||
| D363 | **F10 return to a dark overlay with the N64 watch's green/Bank Gothic visual language (2026-09-27).** — full `## D363` entry at file tail | FIXED (palette/font user-approved, 2026-09-27); D364 follows up on profile tags, click alignment and slider calibration. |
|
||
| D364 | **Settings UI feedback: replace P legend; F10 slider click band misses drawn bar; input defaults visually off-centre (2026-09-27).** — full `## D364` entry at file tail | FIXED (user mostly accepted; D365 follows up on annotation size and click targets). |
|
||
| D365 | **F10 annotation hierarchy and visible mouse change targets (2026-09-27).** — full `## D365` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
|
||
| D366 | **Input calibration label: call 50 the default, not original (2026-09-27).** — full `## D366` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
|
||
| D367 | **F10 full-width control boxes misaligned with their contents (2026-09-27).** — full `## D367` entry at file tail | SUPERSEDED by D368 after user playtest: per-value boxes remained confusing. |
|
||
| D368 | **Simplify F10 click targets: setting name selects, the rest changes (2026-09-27).** — full `## D368` entry at file tail | INPUT RULE KEPT; tinted strips user-rejected, removed in D369. |
|
||
| D369 | **Remove F10 row boxes/strips entirely (2026-09-27).** — full `## D369` entry at file tail | FIXED (awaiting live acceptance): no row backgrounds, control rectangles or hover paint; selected setting distinguished by brighter text, slider tracks remain. Input rule from D368 unchanged. |
|
||
| D370 | **F10 distance/volume drags hurt frame rate (2026-09-27).** — full `## D370` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. (user: fixed.) |
|
||
| D371 | **GEPD key-layout preset + reload/crouch bindings, v0.4.0 modern options wave M3 (2026-09-27).** full `## D371` entry at file tail | FIXED (headless-verified; awaiting user live accept): `Input.Layout` (0=FPS, 1=GEPD) + `Input.CrouchMode` (hold/toggle) in F10 INPUT; GEPD preset acts as per-key effective default under `[Input.Bind]` overrides; new IA_RELOAD/IA_CROUCH emit B (unbound by default). MODERN section dropped per D356 Turok-standard rule (rows land in functional INPUT). Boot/ini-round-trip/F10-page verified; in-game feel-check owed. |
|
||
| D372 | **Low-end perf preset (v0.4.0 modern options wave M5, 2026-09-27).** full `## D372` entry at file tail | FIXED (headless-verified; awaiting user live accept): one-row `Video.LowEndMode` in F10 VIDEO; ON writes FpsCap 30 + MSAA x1 (restart), OFF restores compiled defaults (60/2). Registered key persists through orderly exit. |
|
||
| D373 | **In-game crosshair on/off + colour (v0.4.0 modern options wave M2, 2026-09-27).** full `## D373` entry at file tail | FIXED (user live-tested; rule-2 sign-off recorded 2026-09-27): `Video.CrosshairHide` (0 = on, N64) + `Video.CrosshairColor` (7 tints, 0 = white) in F10 GRAPHICS; #ifdef PORT hook in gunfire.c gunDrawSight (the wave's only rule-2 item). |
|
||
| D374 | **GEPD becomes the default key layout; preset layering fix (v0.4.0 M3 playtest feedback, 2026-09-27).** full `## D374` entry at file tail | FIXED (headless + user live-tested): GEPD playtest found Ctrl fired the gun (stale persisted [Input.Bind] Fire=Left Ctrl masked the preset) and the FPS default made no sense for a mouse FPS. D374: GEPD is now Input.Layout=0 (the default; N64 layout = 1); only EXPLICIT per-key overrides beat the preset. One-off ini flip: pre-D374 `Layout = 1` (ex-GEPD) is now 0. |
|
||
| D375 | **Crouch wired to the wrong pad bit: B is not the crouch input (v0.4.0 M3 playtest, 2026-09-27).** full `## D375` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
|
||
| D376 | **Crouch follow-up + modern-options spot check (2026-09-27).** full `## D376` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
|
||
| D377 | **GEPD independent crouch (2026-09-27).** full `## D377` entry at file tail | FIXED (scripted stance verified; user says live crouch was good; pad/hold-vs-toggle specifics owed): Ctrl/B/Y now drive game stance from the port with no aim requirement. |
|
||
| D378 | **GEPD use/reload separation (2026-09-27).** full `## D378` entry at file tail | FIXED (headless edges tested; user says E/R seemed good; targeted door/partial-mag cases owed): R reload only, E interaction only in gameplay, menu/watch/tank retains B. |
|
||
| D379 | **Crosshair colour: authored texture is red (2026-09-27).** full `## D379` entry at file tail | FIXED (cyan frame capture; live yellow/blue recheck owed): nonwhite tints use alpha silhouette instead of multiplying red RGB; default unchanged. |
|
||
| D380 | **One PC keyboard layout; PD-style rebind design (2026-09-27).** full `## D380` entry at file tail | FIXED (follow-up D383): one PC layout and migration remain; keyboard capture UI is implemented separately, with live acceptance owed. |
|
||
| D381 | **RULE-2-SIGNOFF: opt-in RGB, size and Beta crosshair (2026-09-27).** full `## D381` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
|
||
| D382 | **Crosshair menu placement and slider hitches (2026-09-27).** full `## D382` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
|
||
| D383 | **PD-style keyboard rebinding in both options UIs (2026-09-27).** full `## D383` entry at file tail | **PARTIAL (2026-09-28).** Keyboard/mouse rebinding shipped + accepted in v0.4.0; pad-rebinding UI is the v0.5.0 controller-options wave (CONTROLLER-INPUT-PLAN). |
|
||
| D384 | **Front PC Options key-bind navigation feedback + two-slot UI (2026-09-27).** full `## D384` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
|
||
| D385 | **Real mouse 1–5 action rebinding in the two-slot editor (2026-09-27).** full `## D385` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
|
||
| D386 | **Correct Aim default from SDL middle (Mouse 2) to right (Mouse 3) (2026-09-27).** full `## D386` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
|
||
| D387 | **M7 AllUnlocked's read-time fake unlocks leak into saved EEPROM (2026-09-27).** full `## D387` entry at file tail | OPEN (isolated user-data-copy save/untoggle repro; no fix): a profile-settings save persists fake cheats/0x3FF times; OFF does not undo them. Rotating writes and cheat-award suppression make simple write masking unsafe. **v0.4.0 (2026-09-28): shipped as a loudly-documented EXPERIMENTAL / irreversible option** (row = "All unlocked (EXPERIMENTAL)", ON shows "ON - UNSAFE", README backs it up); safe architecture deferred to v0.5.0. **User-confirmed 2026-09-28: the warning is sufficient -- ship.** |
|
||
| D388 | **Nest functional binding/HUD menus and remove redundant M5 toggle (2026-09-27).** full `## D388` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
|
||
| D389 | **F10 footer clipping and ambiguous submenu links (2026-09-27).** full `## D389` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
|
||
| D390 | **F10 pinned Back selection did not visibly change (2026-09-27).** full `## D390` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
|
||
| D391 | **F10 click at glyph bottom could select next row (2026-09-27).** full `## D391` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
|
||
| D392 | **Train intro soldier hunched; hand intersects torso vs 1964** — firing-table float-offset alias across an 8-byte pointer union. Full `## D392` entry at file tail. | FIXED (`#ifdef PORT` ABI/layout in `chraction.c`); Train aim-angle probe confirms correction; user visually verified the soldier looks fixed against 1964. |
|
||
| D393 | **Controller B/Y both crouch; no in-game use button** — gameplay pad face map conflated modern buttons and native N64 A/B. Full `## D393` entry at file tail. | FIXED + user live-tested A use / X reload / Y weapon cycle. D394 supersedes B crouch and shoulder cycling to match the user-supplied Xbox 1.1 chart. |
|
||
| D394 | **Xbox 1.1 Jinx controller parity** — user supplied the Xbox 1.1–1.4 control table; B is gadgets, stick clicks crouch, LB aim, RB HD toggle (not weapon cycling). | **PARTIAL (2026-09-28).** Xbox 1.1 / GEPD presets shipped and validated in the campaign playtest; presets 1.2-1.4 -> v0.5.0 wave. |
|
||
| D395 | **Controller trapped in keyboard/mouse-only binding capture** — pad could enter the binding modal but could not finish or cancel without keyboard. | RESOLVED (v0.4.0 campaign sign-off 2026-09-28, user: pad capture-block + B-cancel confirmed on the physical pad). Both options UIs block pad-initiated capture; pad B can cancel an active modal; **Steam Deck retest 2026-09-27: a pad enters bindings and is not stuck — the "trapped" symptom is not reproduced on the Deck's Steam Controller**. Distinct from the still-open controller-rebinding *feature* — PR #109 / D383.) |
|
||
| D396 | **F10 pad B/A used as slider decrement/increment, not back/select** — B/Y fed Left and A/X fed Right on every row. | RESOLVED (v0.4.0 campaign sign-off 2026-09-28, user: F10 B/A/slider behaviour confirmed on the physical pad). B backs one F10 page, closes at root; A/X accept/toggle on edge but never shift a slider; D-pad/stick left/right still adjust. |
|
||
| D397 | **SELECT FILE background + intro gun-barrel show an interlaced/comb look** — D182(2) family, but present on FIRST display, not only after re-entry (2026-09-27). Full `## D397` entry at file tail. | **RESOLVED (2026-09-28):** root cause = **H2** (confirmed by a 3-scale PCDUMP test — the comb period is fixed at ≈3 *native* rows across 640/1024/1280, i.e. window-period = 3×scale: the `(<<2)-1` 0.75-native-pixel row-strips rely on RDP floor-based scanline stepping that `gfx_draw_rectangle`'s continuous-float rasterization did not replicate). **Fix (port-layer, surgical):** `gfx_draw_rectangle` now floors the top edge to its scanline and the bottom edge to the last covered scanline (the RDP scanline range `[top,bot+1]`) before rasterizing — a no-op for whole-pixel rects, so only sub-pixel strips change; §D15 in `docs/porting-notes.md` documents the mechanism. H1 (ENV_ALPHA 5-bit CC scaling) REFUTED by a byte-identical PD-port cross-check (the darkening it predicted is faithful, not a bug). `GE_D397SH`/`D397SHM` probes removed. Verified by playtest: gun-barrel + file-select gradient render clean, comb gone. |
|
||
| D398 | **PC Options label: D348's procedural slider glyph replaced with an existing mainfolderimages texture (2026-09-27).** Full `## D398` entry at file tail. | SUPERSEDED by D400 (icon removed, text-only label; purple-hover bug moot; the D400 `textMeasure` arg-swap bug was found and fixed there). |
|
||
| D399 | **File-select bottom row (SELECT FILE text + Copy/Erase + PC Options dot/label) shifted 15px left as a block (user sign-off 2026-09-27).** Full `## D399` entry at file tail. | LANDED (port-only, D343 class): `selectpos.f[0]` 110→95 (new `#ifdef PORT` in front.c), Copy 200/222→185/207, Erase 282/304→267/289 (D343 PORT block), `LABEL_X` 358→343 (`frontoptions.c`); hit bounds recompute from the shifted positions. Clean build, PCDUMP frame 195 no overlaps; by-eye check **done 2026-09-28 (user: good) -- RESOLVED**. |
|
||
| D400 | **File-select "PC Options" label: icon removed, text-only; fixed a swapped textMeasure() arg bug and a 7px vertical misalignment vs Copy/Erase (user sign-off 2026-09-27).** Full `## D400` entry at file tail. | LANDED (port-only, `frontoptions.c`): D398's dot icon dropped on user instruction ("most straightforward design"); label now a plain `textRender` word, GAP 28 after Erase. Two measurement bugs fixed: (1) `textMeasure(textheight, textwidth)` args passed swapped -> label 35px above the bar, hot band transposed; (2) bare-label height measures 0 (vs 14 for newline-terminated Copy/Erase) -> 7px lower tops; fixed by restoring the `kLabelNL` height measure. PCDUMP frame 195: tops aligned 279.8 vs 279.3, 31px clearance from Erase; by-eye check **done 2026-09-28 (user: good) -- RESOLVED**. |
|
||
| D401 | **Rumble Pak → real gamepad haptics (re-promotes D224/M-87): `CONT_CARD_ON` gate + `osPfs*`/`osMotor*` shims rewired to `SDL_GameControllerRumble`, single global `Rumble strength` slider on `Input.RumbleScale` (2026-09-27/28).** Full `## D401` entry at file tail. | **CLOSED (2026-09-28).** Rumble shipped in v0.4.0 (D401); Steam Deck real-pad validation: works, user confirmed for release. |
|
||
| D402 | **First clean Linux (glibc / SteamOS) build of `release/v0.4.0` broke in two port-layer include-resolution spots MinGW tolerated (`bondconstants.h` first-include cycle; `video.c` `_SHIFTL`); fixed in `port/shim/` only, no `src/` edits, no rule-2 (2026-09-27).** Full `## D402` entry at file tail. | RESOLVED (Linux/Ubuntu-24.04 build → glibc-2.41-compatible binary for the Steam Deck): three `port/shim/` fixes — `PR/ucode.h` + `bondconstants.h` defer `pc_protos.h`/`bondtypes.h` during `bondconstants.h`'s own `ultra64.h` pass (breaks the `bondconstants.h`→`ultra64.h`→`pc_protos.h`→`bondtypes.h`→`bondconstants.h` re-entry); `PR/gbi.h` defines `_SHIFTL`/`_SHIFTR` when absent (the `gbi.h` macros in `video.c` lost their `mbi.h` definitions on Linux). Builds + links clean on Ubuntu-24.04; bundled `libSDL2` with `rpath=$ORIGIN`; pushed to the Deck. Shim files uncommitted at log time. |
|
||
| D403 | **Deck v0.4.0-pre test: Nintendo logo + copyright/legal page render broken, broader graphics "not reflecting new updates" — stale 0.3.0-era sidecars seeded into the bundle (2026-09-27).** Full `## D403` entry at file tail. | RESOLVED (2026-09-27, user-confirmed on the Deck: a stale-sidecar **test artifact**, never a code regression): the Deck bundle was seeded with `pcmodels`/`pccg` sidecars from the `0.3.0-local` install (data dated 2026-09-15), which predate the D75/M-197 embedded-blob raw-pixel fix (commit `425237f9`, 2026-09-21) and the D303 muzzle-vertex converter fix (2026-09-26) → the `P*` front-end logo/legal textures come out broken while the in-exe fast3d fixes (D397 gun-barrel/file-select) render correctly. Disambiguation performed (2026-09-27): `data/pcmodels-ntsc-final` + `data/pccg-ntsc-final` regenerated from the Python-source converter (`prepare-assets.py` + the current `d43/d69/d88_emit.py`, `ALL CHECKS PASSED`) on the NTSC ROM and pushed to the Deck (sha256-verified), preserving the user's `ge007.eep`/`ge007.ini`. **RESOLVED (user-confirmed on the Deck, 2026-09-27): the front-end logo / copyright / legal page now render correctly — confirming this was the stale-sidecar test artifact, not a code regression.** |
|
||
| D404 | **"Aim style: Centred (PC)" (`Input.AimMode=1`, D333/D337) has no effect when aiming with a gamepad; the row's own doc comment over-promises ("applies to every aim input … pad trigger") but the centred path is mouse-only (Steam Deck, 2026-09-27).** Full `## D404` entry at file tail. | RESOLVED (2026-09-28, port-layer only): the toggle now genuinely works on a pad. First logged as a communication bug and closed by relabel ("Aim style (mouse)"), then reopened on user challenge the same day: the pad path emits stick *deflection* into the game's crosshair integrator, so the pad was actually always in the **N64** model (a flick travels the crosshair; edge-scroll + spring-back only made a hold *feel* direct), and the "stick has no crosshair to travel" rationale was wrong. Fix: new `padDirectCompute()` in `input.c` (the pad twin of `hipDirectCompute`'s CENTRED branch -- same dead/watch/pause/cutscene gates, minus the mouse-grab gate; revised same day to the N64 path's verbatim quadratic curve -- `sign(v)\u00b7clamp(defl/70,\u00b11)\u00b2 \u00b7 (fov/60) \u00b7 3.5 deg/poll` (bondview2.c canNaturalTurn/canNaturalPitch), full-stick parity with the N64 path, pending retest on the refreshed build); the natural-pitch pad branch routes CENTRED through it and keeps N64's classic deflection behaviour (also the default, so no pad default changes). Labels reverted to device-neutral ("Aim style" / "Aim range"). **Steam Deck (2026-09-27/28): "centered aim is broken on deck too" -- fixed for real; the first-pass linear gain was measured as a dramatic sensitivity loss (2026-09-28) and replaced with the N64 path's verbatim quadratic curve (fov/60 base, 3.5 deg/poll top rate); retest on the refreshed build: user "good". RESOLVED.** |
|
||
| D405 | **fast3d/OpenGL: MSAA above the driver max black-screens; two `glBindFramebuffer` calls pass an index where a GL name is required** (PR #106, italoarruda, 2026-09-25; label rebased from the PR's D323, which collides with our D323 LOD finding) — full `## D405` entry at file tail | **CLOSED (2026-09-28).** Merged from PR #106 (code file only; security-reviewed clean - port-layer GL state fixes, no external input, no allocations). `resolve_msaa_color_buffer` clamps `msaa_level` to `GL_MAX_SAMPLES`; the resolve/copy paths bind `current_framebuffer ? framebuffers[i].fbo : 0` (the default framebuffer is GL name 0). Both Windows (opengl32) and Linux use this backend. Post-merge sweep 21/21 PASS + MSAA=16 boot check render-verified; link ritual clean. |
|
||
| D406 | **front-end PC Options screen: long sections (INPUT, 17 visible rows) run off the bottom of the 440x330 paper, and rows past the silent 14-row page cap (Crouch mode, Reset to defaults, Bindings…) are unreachable from the front screen** (user report 2026-09-28, v0.4.0 pre-push review) — full `## D406` entry at file tail | RESOLVED (2026-09-28, port-layer only, Steam Deck-validated): in-section pagination in `frontoptions.c` (11 rows/page; the vertical stepper crosses page boundaries both ways; dim 'Page p/N' marker + bottom hint on every page; slider bars start past long labels; F10 overlay untouched). D406b/c/d same-cycle refinements (label/track overlap, hint visibility/wording, hint position). |
|
||
| D407 | **Tanks cannot be boarded/exited on PC — the v0.4.0 use/reload split (D378/D393) removed the B-button tap the engine's tank handlers in `bondview2.c` consume (user report 2026-09-28, Runway/Streets)** — full `## D407` entry at file tail | PARTIAL (fix landed in v0.4.0, port-only): present `GE_CONT_B` on the use (E / pad A) rising edge only while `g_PlayerIsInTank == 1` or `g_BondCanEnterTank != 0`; E keeps its D378 no-reload-fallback semantics elsewhere, N64 layout unaffected. D407(b) same cycle: front PC Options page-edge highlight clamp + mouse-wheel / W-S paging (wheel queue consumed on the menu, D223); its wheel mapping shipped inverted and was fixed to match W/S (wheel up = step up). D407(c): board-animation lockout, menu-accept B gate in tank states, in-tank aim routed through the legacy velocity stick + `Input.TankAimScale` knob. RESOLVED 2026-09-28: all port-only (src/game zero-diff), 10 TANKDBG probes stripped, release binary verified clean, user live tank drive signed off (board + exit OK, aim feel good). |
|
||
| D408 | **`Game.SkipIntro` skips the post-mission failure dossier: with it on, entering a level then aborting (watch Z+A) or dying (KIA) returns straight to the menus — no REPORT / "Mission status: KILLED IN ACTION / ABORTED" screen** (user report 2026-09-28; save/AllUnlocked ruled out) — full `## D408` entry at file tail | OPEN (cosmetic, not root-caused past the handoff site). v0.4.0 decision: SkipIntro stays EXPERIMENTAL / not recommended for regular users — F10 row relabelled "Skip intro (EXPERIMENTAL)". |
|
||
|
||
|
||
Phase 2 replaced the Phase-1 demo loop with the real `mainproc()` on real OS
|
||
threads, compiled GE's real `src/sched.c`, and brought in PD's fast3d software
|
||
RSP (`port/fast3d/`). The boot path now runs: ROM map → DRAM reserve → video
|
||
init → mainThread (real pthread) → bossEntry → bossInitMainthreadData through
|
||
mempool init, VI init, rspInit, joyInit + controller-init timers, stanInit,
|
||
gameInit — and currently dies inside `langInit()` (D31).
|
||
|
||
* **D24 — setjmp/longjmp green threads are unusable on MinGW x64.** The first
|
||
kernel used setjmp/longjmp context switches; under Windows x64 + MinGW the
|
||
longjmp path corrupts FPU/MC state when it interacts with SEH unwinding
|
||
(observed: STATUS_DATATYPE_MISALIGNMENT-class crash inside a resumed
|
||
"thread" with garbage register state). PD's port does not use setjmp at all.
|
||
**Resolved:** `port/src/libultra.c` now runs every game thread as a real
|
||
pthread (`PortThread` side table, 8 MB stacks). Message queues keep the N64
|
||
`OSMesgQueue` layout and get a `PortQueue` side table (mutex + condvar, max
|
||
64 queues); all osSendMesg/osRecvMesg paths lock it. A dedicated tick thread
|
||
posts one VI retrace per frame (NTSC 60 Hz) and services the software timers
|
||
(both OS_MESG_NOBLOCK — the tick thread can never deadlock). `idleThread` is
|
||
intercepted by ID and parked; `mainproc` runs as a real pthread so the host
|
||
main thread is free to pump SDL/OS events (`videoPumpEvents()` in the host
|
||
loop — on Windows WndProc only runs on the window-creating thread). The GL
|
||
context is made current on shedThread per frame via
|
||
`gfx_sdl_make_context_current()`. Verified live: VI retrace flowing, all 4
|
||
controller-init timers fire, bossmq loop completes.
|
||
* **D25 — dual-mapped N64 DRAM region.** Game code needs two incompatible
|
||
address forms of the same RAM: `osVirtualToPhysical`/s32 arithmetic wants
|
||
live host pointers that fit in a positive s32, while `offset | 0x80000000`
|
||
rebuilds (bg.c:3184/3322, propobj.c:8578/8699, title.c:476) want a KSEG0
|
||
view at 0x80000000. **Resolved:** one 8 MB backing store mapped twice —
|
||
V1 @ 0x70000000 (all game RAM symbols live here) and V2 @ 0x80000000
|
||
(byte-identical mirror). `port/src/dram.c`: Windows uses
|
||
`CreateFileMappingW(INVALID_HANDLE_VALUE,…)` + two `MapViewOfFileEx(…, base)`
|
||
calls (the documented kernel32 API that maps a section at an exact address;
|
||
`NtMapViewOfSection` fails with STATUS_MEMORY_NOT_ALLOCATED 0xc0000045 and
|
||
segfaults on wrong SECTION_INHERIT values — do not retry it); POSIX uses
|
||
memfd + two MAP_SHARED|MAP_FIXED mmaps. Both views sanity-checked to alias.
|
||
`port/src/dram_syms.s` pins the absolute symbols: `cfb_16` @ 0x70000000
|
||
(0x4B000), `_bssSegmentEnd` @ 0x70050000 (mempool start), tlb block end @
|
||
0x702F4400 (= page_align_down(0x803AB400) − 93×0x2000, the exact N64 value;
|
||
the tlbmanage stub returns it so `mempInit`'s pool size is correct — a NULL
|
||
return here made the mempool spin in `while(1)` at src/memp.c:164).
|
||
* **D26 — address-width shims (K0 sign-extension).** N64 K0 addresses have bit
|
||
31 set; passing them through s32 parameters sign-extends to invalid x86-64
|
||
pointers. With the dual map, live RAM sits below 0x80000000 so identity is
|
||
safe: `port/shim/PR/R4300.h` → `PHYS_TO_K0(x) = (x)`; `port/shim/PR/os.h` →
|
||
`OS_K0_TO_PHYSICAL(x) = (u32)((char *)x - 0x70000000)` (GBI w1 words then
|
||
carry small offsets) and `OS_PHYSICAL_TO_K0(x) = (x)`. fast3d's `seg_addr()`
|
||
(gfx_pc.cpp) resolves both forms: full host pointers pass through, values <
|
||
0x800000 get +0x80000000 (landing in V2). Same remap applied to the
|
||
G_MW_SEGMENT handler.
|
||
* **D27 — ASLR must be OFF for this exe.** `src/bondgame.h:8` declares
|
||
`extern u32 *_bssSegmentEnd;` (pointer type), so `&_bssSegmentEnd` in game
|
||
code emits a `.refptr._bssSegmentEnd` slot holding the absolute value with a
|
||
BASE relocation. Under ASLR the loader rebases it to runtime_base +
|
||
0x70050000 = garbage, and `mempCheckMemflagTokens` then spins/AVs with
|
||
poolAreaStart ≈ 0xF0xxxxxx. (On MIPS `&abs_symbol` is the symbol value
|
||
itself; x86-64/PE cannot do that.) **Resolved:** MINGW link flag
|
||
`-Wl,--disable-dynamicbase` (note: `--disable-dynamic-base` is NOT
|
||
recognized by GNU ld 2.47); the image now loads at its preferred base
|
||
0x140000000 and relocations are no-ops. `main.c` fails loudly at startup if
|
||
`sysImageBase() != 0x140000000ul` so any future ASLR regression is a clean
|
||
error, not silent DRAM corruption.
|
||
* **D28 — ninja stale-object hazard with new shim headers.** If a shim header
|
||
did not exist when a .obj was last built, it is absent from that object's
|
||
.d dependency file and ninja will NOT rebuild the TU after the header
|
||
appears — the old (unshimmed) code silently persists. Observed: boss.c.obj
|
||
still contained `or $0x80000000,%eax` (original PHYS_TO_K0) days after the
|
||
shim landed. **Rule:** whenever anything under `port/shim/` changes, delete
|
||
all `.obj` files and full-rebuild. Verify with `gcc -E` using the exact
|
||
ninja flags (`ninja -C build-pc -t commands <tgt>`) plus objdump of the
|
||
rebuilt object.
|
||
* **D29 — `osPiStartDma` must post its completion message on every path.** The
|
||
N64 PI posts the caller's OSMesgPI to mq when a DMA completes; GE never
|
||
inspects the message but always blocks on it (`romReceiveMesg()` →
|
||
`osRecvMesg(OS_MESG_BLOCK)` in ramrom.c:44). The shim did the memcpy
|
||
synchronously and posted nothing → mainThread deadlocked in the first file
|
||
load. **Resolved:** post unconditionally after `piServiceDma()` (also for
|
||
skipped/dropped DMAs) with OS_MESG_BLOCK, posting the caller's OSMesgPI.
|
||
* **D30 — crash handler without SEH.** MinGW GCC 16 has no `__try`/`__except`
|
||
and does not recognize `-fseh-exceptions`, so the unhandled-exception filter
|
||
cannot rely on structured exceptions to protect symbolication. **Resolved:**
|
||
`port/src/crash.c` phase 1 writes raw fault info (registers, modules) to
|
||
`ge007.crash.log` without touching dbghelp (SymInitialize/StackWalk64 can
|
||
allocate and re-fault inside the filter); phase 2 does a validated manual
|
||
EBP-chain walk (`-fno-omit-frame-pointer` on all TUs; each frame is [saved
|
||
RBP, return address], stop if saved_fp ≤ fp or outside thread stack limits).
|
||
`TerminateProcess` instead of abort (SIGABRT can re-enter the exception
|
||
machinery). `crashDumpThreads()` (Toolhelp32 snapshot + SuspendThread +
|
||
GetThreadContext + StackWalk64 per thread) is called from the heartbeat
|
||
error dump with TID→name matching; StackWalk64 cannot read DWARF unwind info
|
||
on MinGW, so multi-frame traces depend entirely on the EBP chain. Symbols
|
||
are recovered offline: `addr2line -e build-pc/ge007.x86_64.exe -f -C
|
||
<base+rel>` (the log's "rel" = address − actual load base).
|
||
* **D31 — langInit SIGSEGV: `zlib_huft_build` overflows load_resource's frame via the x86-64-grown `struct huft` (RESOLVED).**
|
||
After D24–D29, mainThread reaches `langInit()` and dies on the **first** file
|
||
load (`_fileNameLoadToBank(LnameX_lookuptable[LGUN][…])` → index 670 →
|
||
`fileIndexLoadToBank` → `mempAllocBytesInBank` → `load_resource`). Crash
|
||
signature: RIP = 0x140330003 (inside `.bss`, in `g_Props`), RSP only ~0xD8
|
||
below langInit's entry RSP (a *shallow* chain — the stack pointer itself is
|
||
fine), and the frame region above load_resource's return address is filled
|
||
with 8-byte slots `{u32, 0x00000001}` where each u32 is the **low 32 bits of
|
||
a live .bss pointer**, alternating between `g_Props` (0x14033xxxx) and
|
||
`resource_lookup_data_array` (0x14034xxxx). The crash is a `ret` popping one
|
||
of these truncated pointers as the return address (observed RIP equals the
|
||
table value exactly, high word 0x00000001). Something in the load chain is
|
||
storing 64-bit pointers into s32/int storage (fine on 32-bit MIPS,
|
||
truncating on x86-64) and that storage overlaps a live frame.
|
||
|
||
Established facts (all under gdb, `gdb -batch -ex "handle SIGSEGV stop"`):
|
||
* All arguments at `load_resource` entry are valid: ptrdata=0x702aa400
|
||
(mempool), srcfile->hw_address=0x108ed250, rom_size=0x720, source=
|
||
0x702f3ce0. `romCopy` (PI shim memcpy) completes.
|
||
* `zlib_inflate()` itself **completes normally** (`finish` → returns 0,
|
||
rz_wp=3872; the RZ stream is plain deflate after a 2-byte header, so
|
||
endianness is not an issue in the bitstream). At its entry rz_outbuf =
|
||
ptrdata ✓, rz_inbuf = source+2 ✓, and the huft table base (tl) sits inside
|
||
load_resource's 8 KB local `buffer` (rz_hlist).
|
||
* **Root cause (confirmed):** `struct huft {u8 e; u8 b; union{u16 n;
|
||
struct huft *t} v;}` is **8 B on MIPS** (4-byte pointer in the union) but
|
||
**16 B on x86-64** (8-byte pointer, 8-aligned). GE's gzip-1.2.4 inflate
|
||
builds its Huffman tables contiguously into load_resource's fixed local
|
||
`u8 buffer[0x2100]` (8448 B), sized for the 32-bit layout (~1056 entries);
|
||
on x86-64 only ~528 entries fit, so a stream needing more (observed
|
||
`rz_hufts` ≈ 797) writes past the buffer and clobbers load_resource's frame
|
||
— including the return-address slot. The earlier "truncated-pointer table"
|
||
reading was a misattribution of this same overflow (the 8-byte `{u32,0x1}`
|
||
slots are huft records spilling over the frame).
|
||
* Ruled out by code inspection: `mempAddEntryOfSizeToBank` (only touches
|
||
pool pos/prevpos), `fileGetIndex` (reads only), `decompressdata` epilogue
|
||
(returns rz_wp).
|
||
* `load_resource` prologue: `push rbp/rdi/rsi/rbx; mov $0x2128,%eax;
|
||
call ___chkstk_ms; sub %rax,%rsp; lea 0x80(%rsp),%rbp` — RBP is a **fake
|
||
frame pointer** (RSP+0x80); the real return address is at [RBP+0x20E8].
|
||
The compiler reuses pushed-register slots as locals (verified: the saved-
|
||
rbx slot [RBP+0x20D0] legitimately receives `buffer+12`). Watchpoints on
|
||
RBP-relative offsets are therefore easy to misplace — compute from entry
|
||
RSP instead.
|
||
* Hardware watchpoint on the real retaddr slot (set at the first
|
||
instruction, where [RSP] = retaddr): first write changes it from a valid
|
||
.text return address to **`buffer+12`** (a pointer into load_resource's
|
||
own local array), reported RIP at the prologue boundary (`push %rdi`) —
|
||
i.e. either a concurrent write by another thread (hardware watchpoints are
|
||
global) or an 8-byte-attributed access. A `thread apply all bt` at that
|
||
moment showed every other game thread parked (RtlUserThreadStart / sleep
|
||
syscalls; gdb could not unwind their stacks further), so the writer is not
|
||
yet identified.
|
||
|
||
**Resolution (fix in `port/`, game code untouched).** Mirrors the Perfect
|
||
Dark port, which replaces its assembly rzip with a real-zlib C impl
|
||
(`pd_port/src/lib/rzip_c.c`: `inflateInit2` + `inflate`). We exclude
|
||
`src/game/decompress.c` + `src/game/zlib.c` from the PC build (CMake
|
||
`list(REMOVE_ITEM SRC_GAME …)`) and add `port/src/rzdecomp.c`, which backs the
|
||
two externally-referenced entry points with host zlib:
|
||
* `decompressdata(src,dst,hlist)` → `inflateInit2(-15)` (raw deflate),
|
||
`next_in = src+2` (GE's RZ = 2-byte header `0x11 0x72` + raw deflate, no
|
||
size field), loop on `Z_OK`, return `total_out`.
|
||
* `rzipGetSomething()` → returns the stored `next_in` (consumed input).
|
||
A generated `port/include/realzlib.h` (from `realzlib.h.in`) `#include`s the
|
||
**absolute** host `<zlib.h>` path, because the game's `src/game/zlib.h`
|
||
shadows `<zlib.h>` on the `-I` path.
|
||
|
||
**Verified:** rebuild links clean (238/238); the exe has the new
|
||
`decompressdata`/`rzipGetSomething` and no `zlib_inflate`/`zlib_huft_build`.
|
||
Under gdb, mainThread now boots **past langInit** (all language files load)
|
||
all the way into `bossInitMainthreadData()` (boss.c:233) →
|
||
`initWeaponAnimGroups` — i.e. the original first-file-load SIGSEGV is gone.
|
||
The next blocker is D32.
|
||
|
||
* **D32 — ROM-serialized structs with embedded pointers have divergent N64/x86-64 layout (OPEN).**
|
||
After the D31 fix, mainThread reaches `bossInitMainthreadData()` and dies in
|
||
`init_weapon_animation_groups_maybe()` (boss.c:233) → `initWeaponAnimGroups`
|
||
→ … → `modelAnimReadRootMotionValue` (model.c:914), faulting on
|
||
`desc->bitCount` where `desc = anim->bitDescriptors`.
|
||
|
||
**Mechanism.** N64 pointers are 32-bit; x86-64 pointers are 64-bit with
|
||
8-byte alignment. `struct ModelAnimation` (src/bondtypes.h:575) declares
|
||
`ModelAnimBitField *bitDescriptors` and `u8 *bitStream`. In the N64 ROM
|
||
layout these are 4-byte fields at 0x08 and 0x10; on x86-64 the compiler lays
|
||
them out as 8-byte fields — `ptype /o` (gdb) shows bitDescriptors @0x08, a
|
||
4-byte hole, then bitStream @**0x18**, sizeof = **80** (vs 64 on N64). The
|
||
animation data is loaded by `alloc_load_expand_ani_table()`
|
||
(initanitable.c:263) via `romCopy(ptr_animation_table,
|
||
&_animation_dataSegmentRomStart, size)` — raw N64-layout bytes.
|
||
`expand_ani_table_entries()` rebases the **low 32 bits** of
|
||
bitDescriptors/bitStream but (a) leaves the high 32 bits as adjacent-field
|
||
junk and (b) writes bitStream's rebase to offset 0x10, which on x86-64 is
|
||
*not* where the C struct reads bitStream (0x18). Observed under gdb:
|
||
anim = 0x702adad4 (base 0x702ad8c0 + 0x214), `*(u32*)(anim+8)` = 0xc82bd8c0 →
|
||
as a 64-bit pointer the high word is junk → SIGSEGV.
|
||
|
||
**Scope.** Any ROM-serialized struct that (i) contains a pointer field and
|
||
(ii) has other fields after it misreads the same way: each 64-bit pointer's
|
||
high word holds adjacent-field bytes, and every post-pointer field shifts by
|
||
the pointer-width delta. ModelAnimation is the first hit; bondtypes.h has ~13
|
||
struct blocks with pointer fields (fewer are actually romCopy'd from ROM).
|
||
|
||
**PD ground truth.** PD keeps its ROM-data structs at N64 layout on both
|
||
targets by storing embedded pointers as **u32** — e.g. `struct animtableentry
|
||
{ … u32 data; }` (pd_port/src/include/types.h:5124) — and casting to a real
|
||
pointer at the use site. GE's decomp instead uses real pointers in
|
||
ModelAnimation, so it diverges on x86-64.
|
||
|
||
**Resolution — Option 1 chosen (user-approved), in progress.** The user
|
||
approved the PD pattern after confirming PD does it (`animtableentry.u32 data`,
|
||
raw-DMA'd then resolved at use sites). Note PD's *exact* mechanism (u32 ROM
|
||
offset + on-demand DMA cache) differs from GE's rebased-DRAM-pointer
|
||
ModelAnimation, so we adopt the **principle** (u32 embedded address, cast at
|
||
use), not PD's code. This required refining non-negotiable #2 (see §H).
|
||
|
||
**Part A — struct layout: DONE & proven.** Changed `ModelAnimation`'s two
|
||
pointer fields to u32 (src/bondtypes.h:575): `u32 bitDescriptors` @0x08,
|
||
`u32 bitStream` @0x10; added casts at the only use sites (model.c:913/921).
|
||
`ptype /o` now shows sizeof = **64** with fields at the N64 offsets — layout
|
||
matches. `expand_ani_table_entries()` writes s32 to 0x08/0x10 via its own
|
||
`struct anim_entry`, which now aligns exactly (no change needed there).
|
||
|
||
**Part B — pointer-rebase correctness: ROOT-CAUSED (see D33), fix designed,
|
||
implementation pending.** The garbage values are *not* a bug in
|
||
`expand_ani_table_entries`'s arithmetic — the rebase is correct N64 code fed
|
||
wrong bytes, plus one genuine x86-64 ABI stride bug. Both root causes and the
|
||
full fix design are in **D33** below.
|
||
|
||
* **D33 — ROM file stores structured fields big-endian; animation load needs a per-field endianness fixup + an x86-64 loop-stride fix (root cause of D32 Part B).**
|
||
|
||
**Discovery.** The `.z64` ROM file is not a plain image: multi-byte
|
||
*structured* fields (record headers, tables) are stored in **big-endian byte
|
||
order**, while bit-packed data streams are raw. Evidence chain:
|
||
1. `baserom.u.z64` is byte-for-byte identical to `data/ge007.ntsc-final.z64`;
|
||
the ROM header is GE-specific (magic `80 37 12 40` from `src/rom_header.s`,
|
||
BE u32 CRC at 0x10/0x14 written by `tools/n64cksum.c`) — not the standard
|
||
N64 header layout.
|
||
2. Build pipeline: `$(LD)` → ELF → `objcopy -O binary --gap-fill=0xff` →
|
||
RareZip cdata compression (`data_compress.sh`) → `n64cksum`. No byte-swap
|
||
step; the mips64-elf-gcc toolchain (SGI-style BE default) emits BE bytes.
|
||
3. `tools/utils.h` documents the convention: `swap_bytes()` = "convert from
|
||
v64 to z64 ordering", `reverse_endian()` = "convert from n64 to z64
|
||
ordering" — the project's ".z64" is a word-swapped cart image (community
|
||
".n64" format). Real cart = bswap32(file).
|
||
4. Animation-blob analysis: reading headers as BE u32/u16 yields self-
|
||
consistent values (frame counts, bit widths 0–31, offsets that tile the
|
||
blob exactly); LE reads yield garbage.
|
||
|
||
**Per-field transform rule** (applied at load time to the `animation_data`
|
||
segment): u32 fields → bswap32; u16 fields → bswap16; u8 fields → identity;
|
||
bit-packed streams → identity. No single uniform word transform exists (mixed
|
||
field widths).
|
||
|
||
**Blob layout** (fully mapped; all 173 entries verified): each C array
|
||
(`animation_table_ptrs1/2` in `src/game/initanitable.c`) is a sequence of
|
||
20-byte records at the `PTR_ANIM_*` offsets. Record fields: +0x00 address
|
||
(entries-segment offset, u32), +0x04 frame count (u16), +0x06 angle bit width
|
||
(u8, used by `modelAnimReadBitsAsU16Angle`), +0x07 loop flag (u8), +0x08
|
||
bitDescriptors blob offset (u32), +0x0C bitsPerFrame root motion (u16, used by
|
||
`sub_GAME_7F06D2E4`: `scaled = unk0C * frame`), +0x0E frame size in bits
|
||
(u16, used by `loadAnimationFrame`: `frameSize = unk0E >> 3`), +0x10
|
||
bitStream blob offset (u32). Tail (+0x14..+0x3C) is unused by any code — the
|
||
effective record size is 20 bytes (= `struct anim_entry`, 5×s32). Interleave
|
||
rule: entry *i*'s payload [descriptors][stream] sits at
|
||
[PTR_ANIM_{i-1}+0x14, PTR_ANIM_i); all regions are disjoint; stream size =
|
||
ceil(frameSizeBits × frames / 8) — exact fit on every entry (fire_standing:
|
||
544 bits × 106 frames → 252 bytes). Descriptor array = `ModelAnimBitField`
|
||
{u16 bitOffset, u8 bitCount, u8 pad, u16 valueOffset} ×4 (6 bytes each); after
|
||
the transform all 692 descriptor arrays pack bitOffsets sequentially from 0.
|
||
Null entries use sentinel **1** (not 0) — `expand_ani_table_entries` skips
|
||
`*var_v0 == 1`.
|
||
|
||
**Validation.** Simulated the full transform in Python over all 173 entries:
|
||
0 bad descriptor ranges, 0 non-sequential arrays. Root-motion simulation for
|
||
fire_standing frames 0–3 (widths 6/7/6/0, bitsPerFrame=19) gives smooth
|
||
per-frame deltas (x = 7, 10, 12, 11; y ≈ 1086–1088) — proving the stream
|
||
bytes are identity-correct (no transform). The previously observed garbage is
|
||
reproduced exactly: file bytes `[00 00 01 58]` at fire_standing_fast's bd →
|
||
LE read 0x58010000 + base 0x702ad8c0 = **0xC82BD8C0**, the exact value seen
|
||
under gdb. The "zeros at odd indices" observation was a gdb display artifact —
|
||
the compiled `.data` array is dense (verified by objdump of
|
||
build-pc/ge007.x86_64.exe).
|
||
|
||
**Second root cause: x86-64 stride bug in `expand_ani_table_entries`.** The
|
||
loops iterate with `s32** var_v0; var_v0++` — +4 bytes/iter on N64, but
|
||
`s32*` is 8 bytes on x86-64. Verified in the compiled binary (`add $0x8,%rdx`)
|
||
: only even-indexed entries are rebased; odd entries keep raw small offsets
|
||
(e.g. fire_standing, index 1, would stay 0x144 → instant SIGSEGV on use).
|
||
This pattern occurs nowhere else in the compiled set.
|
||
|
||
**Fix design (implementation pending — this is the immediate task):**
|
||
1. New port function `romdataFixupAnimationData(u8 *blob, u32 blobSize,
|
||
const s32 *tableA, const s32 *tableB)` in `port/src/romdata.c` (+ decl in
|
||
`port/include/romdata.h`): for each non-null (≠0, ≠1) offset in both
|
||
tables — bswap the 20-byte record header per-field (u32: +0x00/+0x08/
|
||
+0x10; u16: +0x04/+0x0C/+0x0E; u8: +0x06/+0x07 untouched); then transform
|
||
the descriptor array at [bd, bs) step 6 (bswap16 on words 0 and 4 only).
|
||
Guards: skip if bd==0 && bs==0; require bd < bs ≤ blobSize and
|
||
(bs−bd) % 6 == 0.
|
||
2. Call it in `alloc_load_expand_ani_table()` (initanitable.c ~line 265)
|
||
**between** the `romCopy` and `expand_ani_table_entries` — one line +
|
||
`#include "romdata.h"` (`port/include` is already on the include path).
|
||
Mechanical ABI edit per non-negotiable #2; must run *before* expand
|
||
(expand reads/writes the fields as LE after fixup).
|
||
3. Fix the stride in `expand_ani_table_entries()` (initanitable.c:233):
|
||
iterate with `s32 *` instead of `s32**` — mechanical ABI fix, semantics-
|
||
preserving on N64 where it was already correct.
|
||
|
||
**Open item.** The entries segment (per-frame joint angles; file offset
|
||
0x124AC0, 0x169EC0 bytes) is left as identity for Phase 1.5 — not consumed at
|
||
boot (only during rendering). Verify visually in Phase 2 when Bond first
|
||
animates.
|
||
|
||
**D34** `ANIM_DATA_*` address placeholders break on x86-64. On N64 the
|
||
`animation_data` segment links at VMA 0, so `(s32)&ANIM_DATA_x` is a small
|
||
segment offset; on PC `&ANIM_DATA_x` is a high PE address and the game code's
|
||
ubiquitous s32 truncation yields garbage. Audit: all 82 uses are
|
||
address-taking only (`(s32)&…`, or through `ANIM_FRAC()` /
|
||
`ANIM_FRAC_MUL_FIRST()` in chraction.c/bondview2.c/title.c/
|
||
initBondDATAdefaults.c/initactorpropstuff.c) — no data dereferences, no
|
||
sizeof, no static initializers. Fix: on x86-64 each `ANIM_DATA_x` is
|
||
redefined as an lvalue at `g_pc_animdata_base + offset` where the base is the
|
||
PE image address with its low 32 bits zeroed (derived in `romdataInit()` from
|
||
a static probe; fail-fast if not 4 GiB-aligned), so
|
||
`(s32)&ANIM_DATA_x == PTR_ANIM_x` exactly. Files:
|
||
`assets/animationtable_data.h` (guarded macro branch, N64 externs kept under
|
||
`#else`), `port/src/romdata.c` (`g_pc_animdata_base` + init),
|
||
`CMakeLists.txt` (excludes `assets/animationtable_data.c` +
|
||
`animationtable_entries.c` on PC — the arrays are address-only placeholders;
|
||
the ROM is the data ground truth via romCopy). Verified under gdb:
|
||
`D_80030984 = ANIM_FRAC(ANIM_DATA_walking)` executes; the walking record at
|
||
`ptr_animation_table + 0x4018` has entry field `0x10177dcc`
|
||
(entries base `0x10124AC0` + `PTR_ANIM_ENTRY_walking`) and frame count 0x25;
|
||
after `initWeaponAnimGroups()` the derived globals hold sane floats
|
||
(4.59/11.25/16.71/16.19).
|
||
|
||
**D35** Music sequence table: pointer-width + endianness.
|
||
`musicSeqPlayerInit()` faulted in `romCopy()` with a NULL destination:
|
||
`tblSegmentSize = sizeof(RareALSeqData) * seqCount + 4` was garbage because
|
||
(1) `RareALSeqData.address` was `u8 *` — sizeof 16 on x86-64 vs the ROM's
|
||
8-byte records — and (2) the header `seqCount` and entry fields are stored
|
||
big-endian in ROM but read as LE (63 → 0x3F00 = 16128). Fix: `address` →
|
||
`u32` (`src/music.h`; sizeof 8 on both platforms) with `(void *)` casts at the
|
||
three use sites in `src/music.c`; new `romdataFixupMusicSeqTable()` in
|
||
`port/src/romdata.c` bswaps `seqCount` + per-entry `address`(u32)/
|
||
`uncompressed_len`(u16)/`len`(u16), called after both romCopys in
|
||
`musicSeqPlayerInit()`. Verified: the header-only (0x10-byte) copy logs the
|
||
expected `seqCount 63 exceeds blob capacity 1` clamp; boot passes the old
|
||
fault.
|
||
|
||
**D36** Music heap / PERMANENT pool sizing on x86-64. `alCSPNew()` SIGSEGV'd
|
||
because the music bump heap was oversubscribed: x86-64 libaudio runtime
|
||
structs are larger (ALVoiceState 88B, ALSeqPlayer 248B, ALCSPlayer 224B, …)
|
||
and the debug-token state (`tokenFind(1,"-level_")==NULL` →
|
||
`g_DebugAndUpdateStageFlag=1` → stale `-ml0 -me0` tokens) forces the
|
||
fixed-size mempool branch with PERMANENT at 296 KiB. Measured under gdb:
|
||
pre-music PERMANENT usage 0x1BCA0 left 189,280 B for music vs a measured init
|
||
demand of 0x31660 (shortfall 13,056). Fix (PC-only guards):
|
||
`MUSIC_ALLOCATION_BYTES` 0x2E000 → 0x32000 (`src/music.c`); PERMANENT fixed
|
||
branch 296/308 KiB → 320/340 KiB (`src/memp.c`, non-JP/JP). STAGE absorbs the
|
||
difference. Verified: boot reaches `bossMainloop()`.
|
||
|
||
**D37** libaudio bank trees must be re-laid out, not patched in place. The
|
||
audio thread SIGSEGV'd in `__initFromBank()` (`b=0xffffffff00000000`):
|
||
`ALBankFile/ALBank/ALInstrument/ALSound/ALWaveTable/…` are serialized in ROM
|
||
as big-endian scalars + 4-byte packed table-relative offsets, while the x86-64
|
||
C structs use 8-byte pointers — so BE values were LE-misread and 4-byte offset
|
||
arrays were walked with 8-byte stride, producing wild rebase pointers in
|
||
`alBnkfNew()`/`_bnkfPatch*()`. In-place conversion is impossible: each struct's
|
||
expanded tail overlaps whatever follows it in ROM (e.g. the ALWaveTable book
|
||
slot at +24 lands inside the ALADPCMBook that sits 12 bytes after the
|
||
wavetable). Fix: `romdataAudioBankPcSize()` + `romdataFixupAudioBank()` in
|
||
`port/src/romdata.c` — a two-pass DFS re-layout into a compact image where
|
||
every sub-struct is placed once (8-byte aligned, children before parents) and
|
||
each pointer slot stores the sub-struct's NEW offset from the image start,
|
||
zero-extended; `alBnkfNew()`/`_bnkfPatch*()` then rebase unmodified
|
||
(`ptr + (s32)file`). `ALWaveTable.base` stays an offset into the separate
|
||
wavetable data segment — `_bnkfPatchWaveTable()` adds its `table` argument,
|
||
verified at runtime (sfx: base+table = 0x102F19A0 =
|
||
`_sfxtblSegmentRomStart`). `music.c` PC branch allocates the re-layout size
|
||
and fixups after romCopy. Memory: bank images grow +0x1460 (sfx) / +0xA70
|
||
(instruments); init-time heap demand measures 0x33530, so
|
||
`MUSIC_ALLOCATION_BYTES` → 0x38000 and PERMANENT → 352/368 KiB (PC only).
|
||
Verified under gdb: runtime tree matches ROM ground truth (SFX instCount=1,
|
||
soundCount=261, bendRange=200, sampleRate=22050; INSTR instCount=75; all
|
||
env/keymap/wavetable/book/loop pointers valid heap addresses; book
|
||
order=2 npredictors=1); 25 s soak with every thread alive. Implementation
|
||
bugs caught during bring-up: a double bswap (afRd* already decodes BE),
|
||
afWr32 initially wrote BE, and the size pass used an uninitialized visited
|
||
array (undercount) — all fixed.
|
||
|
||
**D38** Implicit function declarations truncate pointer returns on x86-64.
|
||
~72 game TUs call ~400 functions with no visible prototype (missing `#include`
|
||
of the declaring header). Under C11 an implicit declaration assumes
|
||
`int f()`: harmless on N64 (32-bit pointers) but on x86-64 it silently
|
||
truncates every pointer return to 32 bits — e.g. `tokenFind()` in
|
||
`set_mt_tex_alloc()` returned a low-32-bit "pointer" that faulted in
|
||
`strtol()`. Fix: a port-layer prototype shim, `port/include/pc_protos.h`
|
||
(398 declarations; one-shot generator `scripts/gen_pcprotos.py` — the committed
|
||
header is the source of truth, hand-adjusted after generation), declaring each
|
||
function with its TRUE return type and an empty parameter list — purely
|
||
additive (only the returned value's width changes). Anchored in
|
||
`port/shim/PR/ucode.h`, the LAST include of `<ultra64.h>`: anchoring earlier
|
||
(e.g. `gbi.h`) poisons libaudio.h because the bondtypes chain reaches
|
||
`snd.h → <PR/libaudio.h>` while ultra64.h is still mid-parse. C-only guard:
|
||
C++ fast3d TUs reach this header via `SDL_stdinc.h → <stdarg.h> →
|
||
port/shim/stdarg.h`, and pulling the bondtypes/bondconstants chain into C++
|
||
brakes on `struct ALSoundState*` in `src/bondtypes.h`; no C++ TU needs a
|
||
fix here. C11 gotcha: an empty parameter name list `()` cannot match a true
|
||
prototype with default-promoted parameters (`s8`/`u8`/`s16`/…), so the 11
|
||
such functions get full prototypes with their TRUE parameter types
|
||
(substituting `int` for `s8` is NOT compatible). Also: `src/bondconstants.h`
|
||
defines function-like macros `ntohl`/`ntohs` over `CharArrayTo32/16`, which
|
||
collide with MinGW `<winsock.h>` in TUs that parse both — neutralized in the
|
||
port shim, real host functions provided in `port/src/pc_netorder.c` with
|
||
winsock-compatible signatures (`u_long` is 64-bit on LLP64!). No game-TU
|
||
edits. Result: build clean, zero warnings; the old `strtol()` crash is gone.
|
||
|
||
**D39** Globalimagetable rebasing idiom breaks on x86-64. `texReset()` /
|
||
`texLoadFromDisplayList()` compute ROM-layout pointers as
|
||
`globalbank_rdram_offset + (u32)&sym` with
|
||
`globalbank_rdram_offset = (u32)pGlobalimagetable + 0xFE000000`; on N64
|
||
`(u32)&sym` = `0x02000000 + off` and the 0x02 base cancels the 0xFE. On PC
|
||
`(u32)&sym` is a truncated PE address → garbage DL pointers. Fix (in
|
||
`src/game/image_bank.c`, PC branch): an enum of all 49 segment offsets (17
|
||
Gfx display lists + 32 sImageTableEntry tables) and
|
||
`#define GIMG_OFF(sym) (0x02000000u + g_pc_gimg_off_##sym)` — exactly the N64
|
||
`(u32)&sym`; all 49 texReset() sites replaced. No changes needed in
|
||
gunfire.c / texLoad / texSelect: all game RAM is s32-safe (V1 view,
|
||
0x70xxxxxx < 4 GiB), so the u32 math and pointer casts stay lossless.
|
||
Also fixed the segment markers: the CSV asset `Globalimagetable.bin`
|
||
(0xAC8) is TRUNCATED — the real linker segment (`ge007.ld`: oddtextures.o
|
||
`.data`) spans 0x13F8 = Gfx DLs (0xAC8) + ITE tables (0x930); the CSV's
|
||
`rarewarelogo` entry is mis-split (the logo actually starts at ROM
|
||
0x29E560). `_GlobalimagetableSegment{Rom}End` 0x1029DC28 → 0x1029E558 in
|
||
`port/src/romassets_u.s` + a size override in `scripts/gen_romassets.py`
|
||
(regeneration verified stable). All 49 offsets verified against the ROM:
|
||
the symbols tile `[0x29D160, 0x29E558)` exactly with no gaps; `gun.c`
|
||
ammo IconImage values cross-check. Verified under gdb: all 17
|
||
texLoadFromDisplayList diffs correct (0, 0x78, 0x120, …); boot passes
|
||
texReset. Known Phase-2 follow-up: `explosion.c`'s
|
||
`g_ExplosionDisplayLists[]` is a static table of EXE addresses to Gfx arrays
|
||
(16-byte entries on PC) — it must point at the ROM-layout copies in
|
||
pGlobalimagetable before explosions render.
|
||
|
||
**D40** N64-sized BSS placeholder pool overflows with pointer-stride structs.
|
||
`initModelHitEntryFreeList()` (initunk_005450.c:38, called from
|
||
lvlStageLoad) walks 600 ModelHitEntry records writing next/prev; the
|
||
decompiler-emitted BSS chain in objecthandler.c (`char g_ModelHitEntries[0xC];`
|
||
…dwords… `char g_ModelHitEntriesPenultimate[0x28]`) reproduces N64's
|
||
12 000-byte region (600 × 20 B), but PC ModelHitEntry is 40 bytes → the
|
||
free-list init writes 24 000 bytes, a 12 KB .bss overflow that clobbered
|
||
`is_ramrom_flag` (writer caught with a hardware watchpoint) → the
|
||
demo-replay path in bossMainloop ran with `address_demo_loaded == NULL` →
|
||
SIGSEGV at ramromreplay.c:341. Fix: PC branch declares a properly sized pool
|
||
(`char g_ModelHitEntries[600 * sizeof(ModelHitEntry)]`); the N64 chain stays
|
||
verbatim under `#else`; the sentinel assignment in initunk_005450.c is
|
||
computed directly on PC (`entries[LEN-1].prev = &entries[LEN-2]` — N64's
|
||
g_ModelHitEntriesPenultimate labels the start of entry 598). Verified: no
|
||
more SIGSEGV; boot progresses into GL rendering.
|
||
|
||
**D41** Cross-thread GL context binding. WGL allows a context to be current
|
||
on only ONE thread at a time. The window+context are created and made
|
||
current on the host main thread (`videoInit → gfx_sdl_init`), but all
|
||
rendering runs on the game's scheduler thread; while main still holds the
|
||
context, `wglMakeContextCurrent` from the game thread fails with "The
|
||
requested resource is in use" — and this SDL2 build (2.32.10, MSYS2)
|
||
swallows it: `SDL_GL_MakeCurrent` returns true on failure / 0 on success
|
||
(an inverted int-as-bool ABI artifact; confirmed by a minimal repro and
|
||
gdb `$1 = 255`). The silent failure left `wglGetCurrentContext() == NULL`
|
||
on the game thread → `glCreateShader` returned 0 → "Vertex shader
|
||
compilation failed" with an empty info log. Fix: new
|
||
`gfx_sdl_release_context()` (`SDL_GL_MakeCurrent(NULL, NULL)`) called at the
|
||
end of `videoInit()` after `set_swap_interval` (the last GL work on main);
|
||
the scheduler thread re-binds per frame via the existing
|
||
gfx_sdl_make_context_current() in videoStartFrame(). Do NOT branch on the
|
||
MakeCurrent return value (unreliable). Verified: frame 1 renders (69.7 ms),
|
||
several frames before the next (separate) fault.
|
||
|
||
**D42** rsp.c task-settings toggle idiom truncates pointers.
|
||
`g_gfxTaskSettingsList = (GfxInfo_s*)((u32)list ^ (u32)&g_gfxTaskSettings[0]
|
||
^ (u32)&g_gfxTaskSettings[1])` toggles between two adjacent settings structs
|
||
by XOR — fine on 32-bit, but on x86-64 the u32-truncated PE addresses XOR to
|
||
garbage → the next frame's `((GfxInfo_s*)g_gfxTaskSettingsList)->cfb = …`
|
||
(fr.c:458) SIGSEGV'd. Fix: explicit toggle under
|
||
`#if defined(__x86_64__)` (`list == &[0] ? &[1] : &[0]` — same semantics,
|
||
the list is always one of the two); N64 line verbatim under `#else`.
|
||
|
||
**D43 (OPEN)** Model-file loading ABI mismatch. Stage object load faults in
|
||
`modelPromoteNodeOffsetsToPointers()` (model.c:5688). Model files are
|
||
ROM-serialized with N64 layout (ModelFileHeader 24 B, ModelNode 20 B,
|
||
4-byte pointer fields) but on PC are read as 8-byte-pointer structs (48 B
|
||
each): `load_object_fill_header()` derives RootNode from `numtextures`
|
||
at the wrong offset, and the `PROMOTE` rebase (`(u32)var + diff`) then
|
||
operates on misaligned fields. Needs a D37-style re-layout of the model
|
||
file image (header + switches array + texture table + node tree +
|
||
ModelRoData records). Phase-2 project; see §H. Reference implementation:
|
||
PD port's `port/src/preprocess/filemodel.c` — same PROMOTE idiom, same vma
|
||
0x5000000 (§2.4); adapt + per-field validation, not a drop-in copy.
|
||
|
||
**D44 (closed 2026-08-22)** Crash-handler Phase 2 backtrace self-faulted before printing.
|
||
`crashStackTraceSym()` in `port/src/crash.c` calls
|
||
`GetCurrentThreadStackLimits(low, high)` passing the NULL pointer *values*
|
||
rather than `&low, &high` (Phase 1's `crashStackTraceRaw` passes them
|
||
correctly). The API writes the stack limits to address 0x0 → access violation
|
||
*inside the SEH filter* → Windows terminates the process without a second
|
||
dispatch. Symptom: `ge007.crash.log` contains only Phase 1 (EXCEPTION/PC/
|
||
MxCsr/thread-stack/MODULE lines) and no `BACKTRACE:` section — verified twice
|
||
live; this is why every D3x fault so far cost a full gdb launch-mode session.
|
||
Fix: one line, `GetCurrentThreadStackLimits(&low, &high)` — applied.
|
||
The build already compiles with `-fno-omit-frame-pointer` (CMakeLists.txt:193),
|
||
so every crash auto-logs an EBP-chain backtrace to console + ge007.crash.log,
|
||
symbolicable offline with addr2line. **Verified live:** the D43 run now logs
|
||
`BACKTRACE:` with #00 = `modelPromoteNodeOffsetsToPointers` (model.c:5688) and
|
||
#01 = `load_object_fill_header` (objecthandler_2.c:110) — the documented chain,
|
||
no gdb needed. Note: frames beyond the true call chain can be stale stack data
|
||
(the walk validates fp against stack bounds but not ret_addr against .text;
|
||
one such frame observed at #02). addr2line takes the full absolute address
|
||
directly (`addr2line -e build-pc/ge007.x86_64.exe -f -C 0x14007a31a`); image
|
||
base is still 0x140000000 (re-verified post-rebuild via nm).
|
||
|
||
**D45 (OPEN — sizing for D43)** Model-file buffers must grow for PC: the
|
||
GDL region doubles (8 B Gfx slots → 16 B) and texture-marker expansion in
|
||
texLoadFromGdl() emits full RDP setup sequences whose size is data-driven.
|
||
Per-file worst-case final size P_final = B_pc + 2×(D_n64−g1) + 16×Σ_markers
|
||
(K_t−1), where K_t is the exact worst-case command count per marker type t
|
||
(tex.c helpers, maxlod≤8, all texTry* state guards emitting, valid=FALSE —
|
||
the realistic branch since D_800483C4 is a gunfire texture index):
|
||
type0/LOD 37, type1/DETAIL 46, type2/MIPMAP 36, type3/TILE 18,
|
||
type4/TILE_PRESWAP 15 (preamble = PipeSync + gSPTexture ≤ 2; water check ≤ 1).
|
||
Marker type = w0&7 of the 0xC0-top-byte slot. Verified over all 512 model
|
||
files (build-pc/d43_sizes.py). Texture *pixel* data is unaffected by Gfx
|
||
width, so texpool sizes can stay at their N64 values — only the model-file
|
||
regions grow. Required buffer edits (all D40-class ABI-forced size constants,
|
||
semantics unchanged): gun.c:106 size_item_buffer 0x14820→**0x23000**
|
||
(bondview body+head+held-prop chain worst 0x1DB9A; suit pool 0xA0B0 + R
|
||
0x18000 = 0x220B0); gun.c:109 D_80032464 0x7530→**0xF000** (GautoshotZ
|
||
0xE788); gun.c ITEM_SUIT_LF_HAND R 0xBD70→**0x18000** (Csuit_lf_handZ
|
||
0x16F9C) with pool expr size−0x18000; gun.c ITEM_TRIGGER/ITEM_WATCHLASER R
|
||
0xAFD0→**0x17000** (GtriggerZ 0x16030); front.c load_walletbond R 0xA000→
|
||
**0x17000** (PwalletbondZ 0x1664C; stays below the +0x28000 DL region);
|
||
front.c cast screen bufferRemaining 0x18160→**0x1C000** (cast chain worst
|
||
0x19CA0 — zbuf at 0x19000+region must not be clobbered); initmenus.c:34 logo
|
||
buffer 0x78000→**0x7C000** (texpool 0x19000 + region 0x1C000 + zbuf
|
||
440×330×2=0x46B80). Unchanged: title/gunbarrel chain (0x16DF0 ≤ 0x23A00),
|
||
front.c logos (≤0x9BCC ≤ 0x3C000), dst=0 fileLoad path (allocates ALL
|
||
remaining STAGE space and gives the tail back via
|
||
mempAddEntryOfSizeToBank — stage props/NPC bodies are bounded only by live
|
||
STAGE headroom, ~2.3 MB). The converter will also assert P_final ≤ R at load
|
||
time with a clear error instead of overflowing.
|
||
**D46 CORRECTION:** the cast-screen values above are superseded —
|
||
front.c bufferRemaining is **0x25000** (not 0x1C000) and the initmenus.c
|
||
logo buffer is **0x85000** (not 0x7C000); see D46.
|
||
|
||
**D46 (RESOLVED — overlap safety + final buffer sizing for D43)**
|
||
The GDL expansion in sub_GAME_7F0762E0 is in-place: output starts at the
|
||
first-GDL offset and grows rightward; input is read from the mirror copy
|
||
texCopyGdls() made at the region tail ([A+R−P_conv+g, A+R)). The initial
|
||
gap is R−P_conv and the cumulative output-minus-input excess is ≤
|
||
16·Σ(K_t−1) over markers processed so far (monotone; 0xba "texture
|
||
already set" skips only reduce it). Since Gfx is 16 B for BOTH input and
|
||
output on PC, the no-clobber condition **R ≥ P_conv + 16·M_actual** is
|
||
identical to the fit condition — one constraint, not two. (The N64 game
|
||
satisfies the same identity with 8-B slots.) The converter therefore sets
|
||
`poolRemaining = P_conv = B_pc + 2×(D−g1)` EXACTLY (the pre-expansion
|
||
image; markers are expanded at runtime, consuming [P_conv, R)).
|
||
|
||
**Strict bound from N64 ground truth.** The N64 game works, so per file
|
||
8·M_a ≤ R_share_N64 − B_n64 − E. PC slots are 2× wide with the same M_a
|
||
(tex.c logic + texture data identical), hence
|
||
**P_final_actual(PC) ≤ B_pc + 2×(R_share_N64 − B_n64)** (E cancels).
|
||
N64 R_share values (game code): suit 0xBD70, trigger/watchlaser 0xAFD0,
|
||
wallet 0xA000, weapons 0x7530, bondview chain 0x14820
|
||
(size_item_buffer), cast chain 0x18160 (front.c bufferRemaining).
|
||
For chains ΣR_share = bufferRemaining EXACTLY: each subsequent load's R
|
||
is the previous file's post-load poolRemaining via
|
||
`get_pc_buffer_remaining_value()` (front.c:7809-7825), so the shares
|
||
tile the N64 budget with no margin assumption beyond "N64 works".
|
||
|
||
**tex.c expansion state machine** (for reference / any future
|
||
simulation): `sub_GAME_7F0CC4C8()` resets g_TexTileStates[8],
|
||
g_TexTileSizes[8] and g_TexLutMode at the START OF EVERY
|
||
texLoadFromGdl call → texTry*/texSetLutMode dedup only works WITHIN one
|
||
GDL. Per-marker slot counts (lutmodeindex=0): type0/LOD ≤ 2 + 6
|
||
(LoadToTmemAddr: SETTIMG+SETTILE≤1+LoadSync+LoadBlock+PipeSync) + 4
|
||
(TileFromDef: PrimColor+LUT≤1+SETTILE≤1+SETTILESIZE≤1) + 3×min(maxlod,7)
|
||
(TileLods basetile=1) + 3 (CycleType/TxLOD/Detail); type2/MIPMAP ≤ 2 + 6
|
||
+ 3×(maxlod + [maxlod==1]); type1 ≤ 2 + 6 (Zero) + 1 (TileSync) + 6 + 4
|
||
+ 3×min(maxlod,7) + 3; type3 ≤ 2+6+4+4; type4 ≤ 2+6+4. With LUT formats
|
||
the LoadToTmem* helpers double to ≤12 — see next item.
|
||
|
||
**Texture scan (build-pc/d43_lutscan.py), all 512 files:** 1071 valid
|
||
texture-table refs (TID < MAX_TEXTURES=3001; the rest are skipped by
|
||
texLoadFromModelFileHeader, and markers whose texnum was never loaded get
|
||
tex==NULL → PipeSync only). Image headers parsed via imagelist.u.csv
|
||
(order = assets/images.def order): **NO LUT textures** (formats 9-12)
|
||
exist in any model file — only format 0 (×1069) and 8 (×2) — so
|
||
lutmodeindex=0 universally and the ≤6-slot LoadToTmem bound holds.
|
||
maxlod distribution {0: 1063, 6: 7, 7: 1} → texWriteTileLods emits
|
||
NOTHING for 99% of markers. The D45 K_BOUND table {0:38, 1:46, 2:36,
|
||
3:18, 4:15} is verified a true worst-case bound under these conditions.
|
||
|
||
**Strict-bound results (build-pc/d43_chainbound.py):** suit_lf_hand
|
||
0x1002C ≤ 0x18000 ✓; trigger/watchlaser 0xED20 ≤ 0x17000 ✓;
|
||
walletbond 0x105BC ≤ 0x17000 ✓; worst weapon GmapZ 0xE91C ≤ 0xF000 ✓;
|
||
bondview chain (worst body spicebond + headbrosnan + autoshot) 0x1CE5C
|
||
≤ 0x23000 ✓; **cast chain: rifle 0x240DC / pistol 0x23F24 > 0x1C000 —
|
||
SHORT by ~0x8000** (worst: body spicebond B_pc=0x8A14/B_n64=0x7D60 +
|
||
head headbrosnan 0x1D90/0x1D50 + rifle autoshot 0x4828/0x3E28 / pistol
|
||
wppksil 0x45B0/0x3DC8). The D45 worst-K estimate (0x19CA0) was below the
|
||
strict bound because it assumed Rare's N64 buffer had zero margin.
|
||
|
||
**Resolution (two one-line constant changes, both already PORT-guarded):**
|
||
front.c init_menu18_displaycast bufferRemaining 0x1C000 → **0x25000**
|
||
(covers 0x240DC); initmenus.c logo buffer 0x7C000 → **0x85000**
|
||
(texpool 0x19000 + region 0x25000 + zbuf @ALIGN64(0x3E000) size
|
||
440×330×2=0x46B80 → 0x84B80, rounded). MEMPOOL_STAGE headroom is ample
|
||
(total stage usage at menu init ≈ 0xD0040 vs ~2.3 MB pool). The converter
|
||
should additionally assert `P_conv + 16×Σ_markers(K_t−1) ≤ R` at load
|
||
with a clear error (K_BOUND table; provably ≥ actual expansion).
|
||
|
||
**D47 (RESOLVED — D43 converter contract finalized; capstone endianness fix)**
|
||
Session that closed every open question on the model-file converter.
|
||
|
||
1. **Capstone endianness bug (environment gotcha).** All earlier-session
|
||
disassembly of the ROM used capstone's default LITTLE-endian mode against a
|
||
big-endian MIPS image → garbage register flow. MUST use
|
||
`CS_MODE_MIPS32 | CS_MODE_BIG_ENDIAN`. Compaction + accessors were re-disassembled
|
||
cleanly; where BE disassembly is still ambiguous, the byte-matched C in `src/`
|
||
is ground truth.
|
||
2. **Compaction state values (BE disasm of 0x7F0762E0 + byte-matched C).**
|
||
P = entry.poolRemaining, R = entry.rom_remaining, **delta = R − P** (not
|
||
P−R); `texCopyGdls(F+G, F+R−P+G, P−G bytes)`; per-GDL count = off_{i+1}−off_i
|
||
bytes (or P−off_last); final fileSetSize = ((rep&0xFFFFFF)+0xf)&~0xf.
|
||
Both accessors (0x7F0BD11C / 0x7F0BD100) compute `0x80090000 + idx*20` →
|
||
**resource_lookup_data_array actually lives at 0x80090000**; the ob.c comment
|
||
(0x800888B0) is stale.
|
||
3. **memp allocator (src/memp.c).** Bump allocator; `mempAddEntryOfSizeToBank`
|
||
rewinds `pool->pos` for the most recent allocation →
|
||
`fileSetSize(reallocate=1)` returns the post-compaction tail to the bank.
|
||
`load_resource` decompresses INTO ptrdata (=F), reading the compressed source
|
||
from the block's TAIL. Fresh dst==0 load: P := S_bank (all remaining stage
|
||
bank) → F := alloc(S_bank) → R := S_bank → load_resource sets P := D_N64.
|
||
4. **Reload hazard + PC decision.** `fileIndexLoadToBank` takes S_bank only
|
||
when poolRemaining==0; fileSetSize leaves P=R=post-compaction size, so a
|
||
reload within one stage would alloc(P_old) which can be < D_N64 (source
|
||
pointer before the block; decompress past it — latent on N64 too). PC fix:
|
||
PORT-guarded reset of `poolRemaining = 0` immediately before
|
||
`_fileNameLoadToBank` in load_object_fill_header → every load gets fresh-
|
||
bank semantics: staging space = whole remaining bank, steady-state bank
|
||
usage identical (fileSetSize rewinds to the same place).
|
||
5. **G_VTX w0 encoding (ROM-data proven).** GBI1/gDma1p style:
|
||
w0 = 04<<24 | dst<<16 | (16·n), dst = ((n−1)<<4)|v0. All 2826 model-file
|
||
instances have v0=0, n≤16 (batches of ≤16 verts — matches G_TRI4's 4-bit
|
||
indices). fast3d's `gfx_sp_vertex(C0(0,16)/sizeof(Vtx), C0(16,4), …)` is
|
||
correct AS-IS; the converter bswaps w0 only (no field remap). The
|
||
F3DEX_GBI_2 `gsSPVertex` block in GE's gbi.h is a red herring — the asset
|
||
toolchain emits GBI1 style.
|
||
6. **G_TRI4 = standard 4-bit indices.** The "out-of-range seg-5 w1" values
|
||
found by scanning are G_TRI4 index data, not addresses (w1 bits 20-31 hold
|
||
the last two 4-bit indices). **Opcode-aware remap rule: only {G_VTX=0x04,
|
||
G_SETTIMG=0xFD, G_LOADBLOCK=0xF3} carry addresses in w1.** Never remap
|
||
TRI4/TRI1/TEXTURE/SETOTHERMODE/CLEARGEO/SETGEO/syncs.
|
||
7. **Render-time segment bindings (model.c).** seg5 (COL1) = BaseAddr = F on
|
||
every model path; seg4 (VTX) = the record's Vertices base (or a runtime
|
||
buffer in dorottex). Hence G_VTX seg4 w1 = displacement from that array →
|
||
NO remap needed (array order preserved); G_VTX seg5 w1 = absolute file
|
||
offset — 2804/2805 verified to land inside a vertex array at vo+d with
|
||
d%16==0 → remap via the unified region map.
|
||
8. **Vertex format (all 512 files).** Normal: bswap s16 x,y,z,index,s,t;
|
||
bytes @C-F raw (rgba/normal are single bytes). Collision (34,580 verts):
|
||
bswap s16 x,y,z,index; LinkedTo u32@8 is ALWAYS 0 or 0x05xxxxxx (node vma);
|
||
CollisionRelatedIndex s16@C (range −1..113) + reserved s16@E bswapped.
|
||
9. **No embedded texture blobs in any model file** — texconfig TextureID
|
||
seg-5 count = 0 across all 512 (the earlier "3 title files" note was the
|
||
vestigial logo SETTIMG refs already covered by d43_cover.py). No blob
|
||
handling needed in the model converter.
|
||
10. **Zero-count vertex arrays.** PexplosionbitZ is the ONLY file with
|
||
nv=0 and non-null Vertices (0x98); its GDL uploads 16 verts from there via
|
||
absolute seg-5 ref. Rule: emit the array sized up to the next object offset
|
||
(→ [0x98,0x198) = exactly 256B here).
|
||
11. **GDL tight packing is safe.** fast3d `case G_ENDDL: return` — trailing
|
||
junk after ENDDL is never executed at render time. Spans: 2064 tight,
|
||
238 with trailing bytes (1904B total). Converter emits up to and including
|
||
ENDDL; each PC span = exactly 16·slots → compaction counts are exact.
|
||
12. **Reference validator: `build-pc/d43_convert.py`** implements the full
|
||
conversion spec (DFS walk with LOD/SWITCH rewiring, region map, per-opcode
|
||
record conversion, remap checks) and runs it on all 512 files:
|
||
**ALL CLEAN** — every pointer remap resolves, layout invariants hold,
|
||
max D_PC = 0xB7E0, max D_PC/D_N64 ratio = 1.31 → staging headroom is
|
||
trivially satisfied vs the ~2.3 MB stage bank. This script IS the spec for
|
||
the C implementation.
|
||
13. **Final converter contract.** Emit `[switches NS×8B LE][texconfigs NT×12B
|
||
(bswap TextureID; 0x05xxxxxx would remap — never occurs)]` → nodes+records in
|
||
DFS preorder (PC layout via struct assignment; every promoted pointer field
|
||
emitted as zero-extended u64 of 0x05|new_off; Primary/Secondary GDL ptrs
|
||
remapped but NOT "promoted"; BaseAddr emitted 0) → vertex arrays immediately
|
||
after their record (PointUsage = 2×numVertices s16 after op24 CollisionVerts)
|
||
→ **GDLs LAST, contiguous, visit order, 16B LE slots**: w0'=bswap32(w0);
|
||
w1' = bswap32(raw) EXCEPT for seg-5 of {0x04,0xFD,0xF3} → remap low24 via
|
||
the region map; **no LSB set** (ROM convention; fast3d's extended seg_addr
|
||
case handles unmarked segmented addresses). Stage at F+R−D_PC, memmove to
|
||
F. Set `poolRemaining = D_PC` exactly (never touch rom_remaining).
|
||
14. **N64 record field offsets** come from the bondtypes.h comments (which
|
||
preserve N64 offsets); all 14 opcodes present in ROM files — 1,2,4,8,9,10,
|
||
12,13,15,18,21,22,23,24 — verified against RSZ sizes. PC layout: assign
|
||
through the real structs (compiler packs); don't hand-compute.
|
||
15. **PointUsage** = 2×numVertices s16 entries, indexed by MAIN-vertex index,
|
||
chain terminated by −1 (chr.c:3309-3331).
|
||
|
||
**D48 (REVIEW — D43 re-plan: offline pre-conversion "Plan B"; process fix)**
|
||
Independent review session that audited the D47 handoff against code + ROM
|
||
before implementation. Verdict: the remaining task was mis-scoped as a 1:1 C
|
||
port of a script that does not emit bytes; a cheaper, lower-risk path exists
|
||
that reuses the existing ROM load chain. Plan B is now the default; Plan A
|
||
(D47.13 C converter) is the fallback.
|
||
|
||
1. **`d43_convert.py` does not emit bytes.** It computes the layout/region map
|
||
and validates that every pointer remap resolves (512/512 clean, re-run and
|
||
confirmed this session), but there is no emission pass in ANY language. The
|
||
byte-level contract (PC struct assignment, per-field bswaps, GDL slot writing)
|
||
is unimplemented and unvalidated. "Port d43_convert.py 1:1 to C" would have the
|
||
next session write ~250-300 lines of new emission code in C and debug it at
|
||
runtime (crash → backtrace cycles). The emission must be written once either
|
||
way — do it in Python where iteration is seconds, validate offline, then ship
|
||
the data.
|
||
2. **Plan B: offline pre-conversion through the existing ROM load path.**
|
||
Generate 512 PC-layout RZ sidecar files with a Python emit pass; serve them by
|
||
patching `file_resource_table[i].hw_address` +
|
||
`resource_lookup_data_array[i].rom_size` from the port layer. Verified
|
||
mechanical facts (all re-checkable in <1 h):
|
||
- **RZ format is trivially reproducible**: 2-byte header (`0x11 0x72`) + raw
|
||
deflate; `decompressdata()` (port/src/rzdecomp.c) skips the header and inflates
|
||
with a generous avail_in bound. A sidecar `[0x11 0x72][raw-deflate(PC image)]`
|
||
works through `load_resource` UNMODIFIED — and it sets `poolRemaining = D_PC`
|
||
automatically (the decompressed size), so no fixup call is needed at all.
|
||
- **`romCopy` on PC is a host memcpy**: src/ramrom.c → `osPiStartDma` → port
|
||
shim `piServiceDma` (port/src/libultra.c), gated only by
|
||
`romdataCartAddrValid()` (port/src/romdata.c:212). The ROM is VirtualAlloc'd at
|
||
CART_BASE 0x10000000 sized romSize — extend the reservation by the sidecar
|
||
total, place sidecars at [CART_BASE+romSize, …), extend the validity check.
|
||
- **`file_resource_table` is a plain writable global** (included in
|
||
src/game/ob.c:22). ALL 512 model loads funnel through
|
||
`load_object_fill_header` (dst==0 → `_fileNameLoadToBank`; custom-buffer
|
||
callers in front.c/gun.c/bondview2.c/initmenus.c → `_fileNameLoadToAddr`) —
|
||
both read `hw_address` via `load_resource`. No game code dereferences C*/G*/P*Z
|
||
symbols directly outside the table (verified by grep; symbols exist only in
|
||
the table + romassets_<r>.s markers).
|
||
- **The patch must run AFTER `obInit()`** (called at src/boss.c:179):
|
||
obInit computes `rom_size` from adjacent-entry hw_address DELTAS (ob.c:122), so
|
||
a pre-obInit patch would corrupt rom_size. Lazy one-shot at the PORT hook site
|
||
in load_object_fill_header is simplest — by first model load, obInit has
|
||
definitely run.
|
||
- **Footprint**: 512 files = 1,277,088 B compressed total (1.2 MB),
|
||
3,289,344 B decompressed N64 total; PC decompressed ≤ 1.31× per file (D47.12).
|
||
- **The indy path is NOT used** (`resource_load_from_indy`, ob.c:56): it is
|
||
the N64 host-protocol loader gated by `indy_ready` (src/game/indy_comms.c,
|
||
dormant on PC), and its pPayload placement underflows on reload when
|
||
poolRemaining == pc_size exactly. Table patching reuses the proven ROM path.
|
||
- **What Plan B eliminates**: the C converter (~300 lines), two-pass staging
|
||
+ memmove, staging-space guard, and the "emission bug only visible at runtime"
|
||
risk class. What it keeps (all already committed): ABI edits, fast3d seg-5
|
||
case, D46 buffer sizing — plus the one-line poolRemaining=0 reset (item 3).
|
||
- **Sidecars are region-specific** (derived from the region ROM); generator
|
||
must take the region and write `data/pcmodels-<region>/`.
|
||
3. **The poolRemaining=0 reset is STILL needed under Plan B.** fileSetSize
|
||
leaves P=R=post-compaction size (ob.c:346-347); a same-stage reload would then
|
||
alloc S_bank' = P_old, which can be < round8(compressed)+8 → `load_resource`
|
||
hits the `source − ptrdata < 8` branch → poolRemaining=0 → silent load
|
||
failure. The D47.4 reset before `_fileNameLoadToBank` covers both plans.
|
||
4. **Plan A flaw (if runtime conversion is kept):** D47.13's staging guard
|
||
"fail if D_PC > avail" is too weak — the staging region [F+R−D_PC, F+R)
|
||
overlaps the live N64 image [F, F+D_N64) whenever S_bank < D_N64 + D_PC, and
|
||
emission would then read corrupted bytes. Correct guard: `D_N64 + D_PC ≤
|
||
avail`. Low probability in practice (files load at stage start; ~2.3 MB bank
|
||
vs ~82 KB worst case), but the stated guard silently misses it.
|
||
5. **Process fix:** the 23 d43_*.py investigation scripts (incl. the reference
|
||
converter) lived in gitignored `build-pc/` — moved to tracked `tools_pc/`
|
||
and committed, so the spec is versioned and reviewable. d43_cover.py's one
|
||
internal path reference updated; converter re-run from new location: ALL CLEAN.
|
||
6. **Review checklist for the next session** (confirm each item against code +
|
||
ROM before executing Plan B; record results as D49): see docs/HANDOFF.md
|
||
§Task 1 — eight claims, each with a falsification criterion. If any fires,
|
||
fallback to Plan A with the corrected guard from item 4.
|
||
|
||
**D49 (REVIEW — Plan B verification: D48 checklist R1–R8 all CONFIRMED)**
|
||
Independent review session executed the D48.6 checklist against code + ROM
|
||
(NTSC `data/ge007.ntsc-final.z64`). **Verdict: 8/8 confirmed — Plan B is
|
||
cleared for execution (HANDOFF Task 2); no fallback to Plan A.** Per item:
|
||
|
||
1. **R1 RZ format — CONFIRMED.** `decompressdata()` (port/src/rzdecomp.c)
|
||
skips the 2-byte header and raw-deflates (`inflateInit2(-15)`, avail_in ≤
|
||
0x400000). All 512 model files (C*/G*/P*Z in
|
||
`assets/obseg/file_resource_table.inc.c`; table↔`scripts/filelist.u.csv`
|
||
set identity exact, 512=512) start `0x11 0x72` and inflate cleanly.
|
||
Σ round8(compressed) = **1,277,088 B — exactly D48's claimed number**; max
|
||
single-file compressed 0x421E; max decompressed/compressed ratio 4.75.
|
||
2. **R2 romCopy is a host memcpy with no 0x10C00000 limit — CONFIRMED.**
|
||
`romCopy` → `doRomCopy` → `osInvalDCache` (no-op shim) + `osPiStartDma` →
|
||
`piServiceDma` (port/src/libultra.c): the only gate is
|
||
`romdataCartAddrValid()` (port/src/romdata.c:212), then plain memcpy. The ROM
|
||
is VirtualAlloc'd at CART_BASE 0x10000000 sized exactly romSize
|
||
(romdata.c:156-159); nothing in the port layer bounds the region, so placing
|
||
sidecars at [CART_BASE+romSize, …) needs only a reservation-size extension +
|
||
validity-check extension in romdataInit.
|
||
3. **R3 the table is the single chokepoint — CONFIRMED.** All 512 model loads
|
||
funnel through `load_object_fill_header` (src/game/objecthandler_2.c:89) →
|
||
`_fileNameLoadToBank`/`_FileNameLoadToAddr` → `fileIndexLoad*` →
|
||
`load_resource`, which read `hw_address`/`rom_size` from
|
||
`file_resource_table` + `resource_lookup_data_array`. The C*/G*/P*Z symbols
|
||
are `.set` markers in `port/src/romassets_<r>.s` (gen'd by
|
||
`scripts/gen_romassets.py`); no game code dereferences them outside the table
|
||
(grep: all hits are string literals/comments). Callers audited: bondview2.c,
|
||
chr_b.c, ejectedcartridges.c, front.c, gun.c, loadobjectmodel.c.
|
||
4. **R4 obInit runs once, before any model load — CONFIRMED.** `obInit()`
|
||
is called exactly once at src/boss.c:179 inside one-shot
|
||
`bossInitMainthreadData()` (before the infinite main loop); it computes
|
||
`rom_size` from adjacent-entry `hw_address` DELTAS (ob.c:122). `rom_size`/
|
||
`hw_address` are touched only in ob.c. The D43 crash stack was post-obInit.
|
||
A lazy one-shot table patch at the port hook site in
|
||
`load_object_fill_header` is therefore safe.
|
||
5. **R5 buffers fit — CONFIRMED (quantified).** Fresh dst==0 loads: max
|
||
round8(compressed)+8 = 0x4228 ≪ STAGE bank (max 0x24C400 = poolArea 0x2A4400
|
||
− me 352 KiB NTSC; ≥ ~1.3 MB even after conservative pre-model stage usage —
|
||
BG stan ≤ 0xA3E0 per stage). Worst-case post-compaction size across all 512
|
||
files (D_PC + 16·Σ(K_t−1) with K_BOUND {0:38,1:46,2:36,3:18,4:15}) =
|
||
**0x16F74** (Csuit_lf_handZ) — 25× headroom vs a fresh bank. dst!=0 callers:
|
||
every buffer ≥ 0xF000 > 0x4228; `tools_pc/d43_chainbound.py` re-run against
|
||
the CURRENT constants all pass: suit 0x1002C≤0x18000, trigger/watchlaser
|
||
0xED20≤0x17000, wallet 0x105BC≤0x17000, worst weapon GmapZ 0xE91C≤0xF000,
|
||
bondview chain 0x1CE5C≤0x23000, cast rifle/pistol 0x240DC/0x23F24 ≤ 0x25000
|
||
(front.c:7795), logo 0x85000 (initmenus.c:38). `poolRemaining := D_PC` is
|
||
automatic (`load_resource` ← `decompressdata` return, ob.c:61).
|
||
6. **R6 poolRemaining hazard + reset — CONFIRMED.** Mechanism verified in
|
||
current code: `fileIndexLoadToBank` (ob.c:219-247) allocates
|
||
`poolRemaining` when non-zero (the post-compaction P left by fileSetSize),
|
||
and `load_resource` (ob.c:49-53) needs round8(rom_size)+8 ≤ bytes or it sets
|
||
poolRemaining=0 → **silent load failure**. The data CAN trigger it (e.g.
|
||
PlegalpageZ: round8(C)+8 = 0xFC8 > D_PC = 0x5F0; its own path is a custom
|
||
buffer so it is safe, but the condition exists for any file reloaded in the
|
||
same stage). Hazard window = same-stage reloads only: poolRemaining is zeroed
|
||
at stage entry/exit (boss.c:415-417 / 639-641), and all consumers of
|
||
`get_pc_remaining_buffer_for_index` / `get_pc_buffer_remaining_value` run
|
||
post-load+compaction; the PROMOTE walk (sub_GAME_7F075A90) does not touch
|
||
lookup data. The addr path is unaffected (bytes = caller's fixed buffer).
|
||
The one-line poolRemaining=0 reset before `_fileNameLoadToBank`/
|
||
`_FileNameLoadToAddr` in `load_object_fill_header` is **pending** (not yet in
|
||
tree) and closes the hazard for both plans.
|
||
7. **R7 per-region sidecars + directory matching — CONFIRMED (with notes).**
|
||
Region is build-time-fixed: `build-pc.sh` ROMID → CMake ASSET_REGION {u,e,j}
|
||
→ `romassets_<r>.s`; the loaded ROM token (`ge007.<romid>.z64` /
|
||
`baserom.<r>.z64`) uniquely determines the region and romHeaderValid enforces
|
||
the country byte, so romdataInit can derive `data/pcmodels-<region>/` with the
|
||
same token logic (baserom.u → ntsc-final, baserom.e → pal-final). Name sets
|
||
are NOT identical across regions (filelist: u=512, **e=465**, j=512) — the
|
||
generator must map whatever files exist, and the patch loop only patches names
|
||
present in the manifest. Notes: (a) romdataInit has **no JP candidate**
|
||
(GE007_IS_PAL comes from versioninfo.h.in) — PC cannot boot a JP ROM today,
|
||
so JP sidecars are moot until that is added; (b) missing sidecar directory →
|
||
warn + continue in ROM-only mode (Task 2 step 2).
|
||
8. **R8 emission-spec completeness — CONFIRMED (with one explicitness gap).**
|
||
Walked every `ModelRoData_*Record` for the 14 opcodes present in ROM
|
||
(1,2,4,8,9,10,12,13,15,18,21,22,23,24) in src/bondtypes.h: every field is
|
||
covered by Verified facts + D47.13 (pointer-promotion list D47.5; f32 bswap;
|
||
TextureID bswap32; vertex arrays D47.8; GDL slots D47.13). A fresh C probe
|
||
(mingw gcc with the EXACT flags from build-pc/compile_commands.json)
|
||
re-verified all 15 PC record sizes + ModelNode (0x30) + ModelFileHeader
|
||
(0x38) + Vertex (0x10) and every pointer-promotion offset — **no drift** vs
|
||
d43_convert.py's PC_REC / the spec. Gap: Verified facts enumerate bswap for
|
||
f32, vertex-array s16s and the TextureID u32, but do not explicitly enumerate
|
||
the **u16/s16 record scalars** (AnimPart, MatrixIndex, JointID, MatrixIDs,
|
||
Group1/2, RwDataIndex, op4 numVertices@0x10, op24 nv/ncv/ModelType/
|
||
RwDataIndex). Data check: these hold small BE values (JointID 1–11, nv ≤
|
||
73) that raw emission would corrupt (LE read → ×256); all research tooling
|
||
reads them BE (`bu16`/`be16`). The rule follows from the D33 per-field
|
||
endianness convention (u16 → bswap16), so this is an explicitness gap, not a
|
||
missing rule — but Task 2's emit pass must bswap16 every u16/s16 record
|
||
scalar (padding/reserved may be zeroed), and the HANDOFF Verified facts
|
||
should be amended to say so.
|
||
|
||
**D50 (RESOLVED — Plan B executed: offline sidecars + port plumbing; boot advances to first model GDL execution)**
|
||
|
||
Execution session ran HANDOFF Task 2 end-to-end. All 512 NTSC model files
|
||
converted offline and served through the existing load path; frames render
|
||
and model display lists execute. Sub-items (all `#ifdef PORT`-guarded or
|
||
port-layer only; N64 build untouched):
|
||
|
||
1. **D50.1 Emit pass (`tools_pc/d43_emit.py`, tracked).** Per file:
|
||
decompress the N64 image from the ROM (filelist row); build the node map;
|
||
run the EXACT `modelIterateDisplayLists` visit simulation (LOD/SWITCH
|
||
rewire, BSP splice — d43_gdlorder logic) → gdl_seq; layout `[switches
|
||
NS×8][texconfigs NT×12][DFS nodes 48B + records PC_REC + vertex arrays]
|
||
…[GDLs packed contiguously in gdl_seq order, 16B per N64 slot]`; byte-exact
|
||
emit (bswap32/16; promoted pointers → zero-extended u64 `0x05xxxxxx` VMAs;
|
||
GDL Primary/Secondary raw VMAs, NOT promoted; BaseAddr=0); **round-trip
|
||
re-parse validation of every field against the N64 source + region tiling
|
||
(no gaps)** — 512/512 pass. Compression: `0x11 0x72` + raw deflate level 6
|
||
(`zlib.compressobj(6, DEFLATED, -15)`); escalate to 9 only on a dst!=0 fit
|
||
violation (none occurred). Cross-checks all pass: per-file dst!=0 buffers
|
||
(`round8(C)+8 ≤ buf` AND `D+round8(C) ≤ buf` — inflate overlap safety), G*
|
||
hand-weapon worst case vs 0xF000, cast/title/bondview chain cumulative
|
||
P_final bounds, totals (Σ round8(C) = 1,277,088 B, matches D49). Output:
|
||
**single concatenated image** `data/pcmodels-<region>/pcmodels.bin`
|
||
(sidecars at 16-aligned offsets) + `manifest.csv` (`name,offset,size`,
|
||
decimal — the C parser uses strtol base 10; file_resource_table.inc.c
|
||
order). Deviation from D48's per-file-sidecar sketch: one blob + manifest;
|
||
pcmodels.c copies the whole image to `[CART_BASE+romSize, …)` and patches
|
||
`hw_address = cartBase+romSize+off`. Regenerate:
|
||
`python tools_pc/d43_emit.py [ntsc-final|pal-final|jpn-final|--check-only]`
|
||
(needs the region ROM in data/; sidecars are gitignored with data/).
|
||
2. **D50.2 Port plumbing (`port/src/pcmodels.c` + `port/include/pcmodels.h`,
|
||
new; romdata.c/h extended).** `pcmodelsReserveSize(romImg)` derives
|
||
`data/pcmodels-<region>/` from the ROM country byte (+0x3E), parses the
|
||
manifest, returns total bytes (0 → warn, ROM-only mode); romdataInit
|
||
reserves `romSize + sidecarTotal` at CART_BASE and
|
||
`romdataCartAddrValid` accepts the extension; `pcmodelsLoadSidecars(
|
||
cartBase, romSize)` copies the blob; `pcmodelsPatchTable()` — one-shot,
|
||
called from `load_object_fill_header` (hook block, objecthandler_2.c) after
|
||
obInit has run — redirects every manifest row's
|
||
`file_resource_table[i].hw_address` + sets
|
||
`resource_lookup_data_array[i].rom_size` to the PC compressed size. The same
|
||
hook block resets `poolRemaining = 0` for dst==0 loads (closes the D48.3/R6
|
||
reload hazard). Boot log: `[INFO] pcmodels: table patched (512 model
|
||
entries)`.
|
||
3. **D50.3 Language banks (runtime C fixup).** Banks carry a big-endian
|
||
offset table; `romdataFixupLangBank(blob, decompressedSize)` decodes it in
|
||
place. Called from language.c after each of the 7 langInit loads and lazily
|
||
per-id in the `langGetJpnCharPixels` paths (idempotent via poolRemaining
|
||
check).
|
||
4. **D50.4 Fonts (runtime C fixup).** `load_font_tables` PC branch:
|
||
allocate `romdataFontPcSize()` (PC C layout of struct font: kerning[169] +
|
||
chars[94] + glyph pixel data), romCopy the N64 size, then
|
||
`romdataFixupFont` re-lays out, shifting the pixel block below the expanded
|
||
char array; `pixeldata` fields left as relative offsets — the existing
|
||
`pixeldata += base` loop promotes them.
|
||
5. **D50.5 Legal-screen UB exposure (front.c).**
|
||
`constructor_menu00_legalscreen` reads an uninitialized pointer at a lookat
|
||
call — on N64 the register happens to hold a readable address and the
|
||
result is zeroed by `* 0.0f` anyway; the x86-64 compiler folds the UB read
|
||
to NULL → fault. PORT branch seeds it with `legalpage_text_array` (the
|
||
value assigned a few lines later); identical lookat.
|
||
6. **D50.6 `texCopyGdls` copies only w0 on PC — first model-render crash
|
||
(RESOLVED).** `Gfx` is a union whose last member is `long long int
|
||
force_structure_alignment`: 8 bytes on N64 (= the whole slot), but on x86-64
|
||
the slot is 16 bytes, so `arg1->force_structure_alignment = arg0->…`
|
||
copies only the low half. Compaction flow (sub_GAME_7F0762E0):
|
||
texCopyGdls mirrors the GDL block `[G,D)` to tail scratch `[B−D+G,B)`, then
|
||
texLoadFromGdl reads the scratch and writes expanded output back at
|
||
`[G,…)` via full-slot `*(out++)=*(in++)` — propagating the scratch's stale
|
||
w1s into the final GDLs. **Byte proof:** PlegalpageZ (NS=0, NT=5,
|
||
D=0x2638) loaded at 0x7012EA38; first 64 RAM bytes match the decompressed
|
||
sidecar exactly; the executed GDL at file offset 0x2488 has w0s matching
|
||
the sidecar exactly (0xB10000BA/0xE7000000/0xFD900000/0xE6000000) but RAM
|
||
w1s = 0x50362B58/0x66D73339/0xACAAB819/0x55BDF769 (stale mempool contents;
|
||
pad2 dwords also nonzero) vs sidecar w1s 0x0000A898/0/0x050012C8/0 — the
|
||
garbage G_SETTIMG w1 → `seg_addr` → OOB read in `import_texture_rgba16`.
|
||
Fix: `*arg1 = *arg0;` under `#ifdef PORT` (tex.c). Audit: tex.c is the only
|
||
`force_structure_alignment` use in game code (model.c:1510 is an unrelated
|
||
local 8-byte union); every other Gfx copy is full-struct. Post-fix:
|
||
PlegalpageZ's sub-DLs execute to completion; crash moves on (D51).
|
||
|
||
**D51 (RESOLVED — font `pixeldata` fixup wrote the pointer at the wrong offsets; frame-5 `import_texture_i8` SIGSEGV)**
|
||
|
||
The G_TEXRECT tile-0 upload's source was a **font glyph** (I8, loaded via
|
||
`gDPLoadTextureBlock`). Root cause in `romdataFixupFont`
|
||
(port/src/romdata.c): it wrote each char's pixel pointer at blob offsets
|
||
d+20/d+24, but on PC `struct fontchar.pixeldata` is a **u64 at char offset
|
||
+24** — the low word landed in padding and only half the pointer was
|
||
written, so glyph sources resolved to stale/invalid addresses → OOB read in
|
||
`import_texture_i8`. Fix: write the blob-relative offset into d+24 (low)
|
||
and zero-extend d+28 (high). Verified: glyphs load, SIGSEGV gone. The old
|
||
D51 hypothesis list (seg_addr / tile staleness) was wrong; its GE opcode
|
||
facts remain valid reference material (G_IMMFIRST=−65; DMA G_MTX=1 /
|
||
G_MOVEMEM=3 / G_VTX=4 / G_DL=6; IMM TRI1=0xBF … ENDDL=0xB8; GE extension
|
||
G_TRI4=0xB1, 8-bit packed indices — NOT an address carrier; RDP
|
||
pass-throughs G_SETTIMG=0xFD, G_SETCIMG=0xFF, **G_TEXRECT=0xE4 /
|
||
G_TEXRECTFLIP=0xE5** — GE-specific values, not libultra's 0x46/0x45;
|
||
width-1 stored in the 12-bit field, fmt I=4, G_TX_LOADTILE=7; main DL is
|
||
game-built per frame with gSP* macros; segment table via
|
||
`gMoveWd(G_MW_SEGMENT, seg*4, base)` → w0=(0xBC<<24)|(seg*4<<8)|6,
|
||
w1=data; fast3d's gfx_sp_moveword stores data as-is when ≥ 0x800000 else
|
||
+0x80000000).
|
||
|
||
**D52 (RESOLVED — `osGetCount` tick-rate mismatch → non-deterministic post-frame-2 hang)**
|
||
|
||
Plain runs sometimes hung after frame 2 (kernel watchdog: "no frame rendered
|
||
for 3006180 ms") instead of crashing at frame 5. Root cause:
|
||
port/src/libultra.c `osGetCount()` returned **microseconds** (1M/s), but GE's
|
||
pacing assumes the N64 RSP counter rate ≈ **46.5525 ticks/µs**:
|
||
`MAIN_LOOP_TICK_INTERVAL` = 387,937 ticks (boss.c: NTSC
|
||
`INTERVAL_INTER_MATH - 2688U`; PAL
|
||
`frameDelay*(CYCLES_PER_FRAME-6450)-(INTERVAL_INTER_MATH-3225)`), and
|
||
`waitForNextFrame()` (frametiming.c) waits for
|
||
`(elapsed+interval)/775875` ticks (NTSC frame = 775,875; PAL 931,050). At
|
||
1M/s the steady-state 16,667 µs between retraces < 387,937 → bossMainloop's
|
||
gate never passes → no DL built → hang. Frames 1–2 rendered only because
|
||
stage loading took >388 ms of real time. Fix: `osGetCount()` returns
|
||
`(u32)(((uint64_t)sysGetMicroseconds()*465525ull)/10000ull)` (rate derived
|
||
from GE's own constants: 775875/16666.67µs = 931050/20000µs); wraps every
|
||
~92 s like the HW counter. `osGetTime()` still returns µs (osSetTimer/
|
||
OSTime). PD's port has the same µs implementation but lacks GE's cycle-based
|
||
pacing gate — do not copy it blindly.
|
||
|
||
**D53 (RESOLVED: model RW-data pool addressing on PC; frame-5 SIGSEGV in `modelInitRwData`)**
|
||
|
||
Post-D51/D52, the frame-5 SIGSEGV moves to `modelInitRwData`
|
||
(model.c ~6131): first BSP node → `movl $0x0,(%rax)` right after
|
||
`call modelGetNodeRwData` — writing `visible=FALSE` through NULL. Two
|
||
compounding PC layout bugs, both in the D32 class:
|
||
|
||
- **D53.1 (applied; necessary but not sufficient): `Model.datas` word
|
||
stride.** `RwDataIndex` values are **4-byte word offsets** into the
|
||
RW-data pool (`modelCalculateRwDataIndexes` accumulates
|
||
`len += sizeof(record)/4`; pool = round16(numRecords×4) bytes, allocated
|
||
in modelmgrInstantiateModel(WithAnim)). On N64 `&data[index]` with
|
||
`union ModelRwData **data` strides 4 B; on PC it strides 8 B → every
|
||
non-zero index addresses the wrong record. Fix (3 files, all #ifdef PORT):
|
||
`Model.datas` → `u32 *datas` (bondtypes.h — layout unchanged: single
|
||
pointer field); casts in model.c (`modelGetNodeRwData` local + return
|
||
`(union ModelRwData *)&data[index]`, parent-walk
|
||
`data=(u32*)tmp->RwDatas`, `modelAttachPart`) and propobj.c:7301/14343.
|
||
- **D53.2 (root-caused; fix designed, NOT applied): `ModelSlot` /
|
||
`AnimModelSlot` ↔ `Model` type-pun breaks on PC.** The game puns the slot
|
||
structs and Model in both directions: `slot.unk08@8` ↔ `Model.obj@8` (the
|
||
**in-use flag** — `modelInit`'s `objinst->obj = header` marks a slot),
|
||
`slot.unk10@0x10` ↔ `Model.datas@0x10` (RW pool), `slot.unk02@2` ↔
|
||
`rwdatalen@2`. On N64 all pointers are 4 B → offsets agree. On PC there is
|
||
no pack pragma — natural alignment (probe-verified with the exact CMake
|
||
flags, see vsize.c): **Model = 0xE8 B**: chr@8, **obj@0x10**,
|
||
render_pos@0x18, **datas@0x20**, scale@0x28, attachedto@0x30, anim@0x40;
|
||
the slot structs still have unk08@8 / unk10@0x10. (An earlier draft of
|
||
this section said obj@0xC/datas@0x1C — that assumed packed layout and is
|
||
wrong; the log evidence below only fits the natural-alignment offsets.)
|
||
Consequences: (a) `modelInit` writes obj@0x10 — the free check reads
|
||
unk08@8 (low word of Model.chr, never written) → slots are never marked in
|
||
use → every model reuses slot 0; (b) the legal-screen logo model takes
|
||
slot 0, then `update_menu00_legalscreen()` (front.c:1430) →
|
||
`clear_model_obj(logoinst)` writes obj=NULL at @0x10..0x17 — exactly where
|
||
`slot.unk10@0x10` lives; the next instantiation passes
|
||
`modelmgrCanSlotFitRwdata` on stale Model.datas@0x20 (non-NULL) + real
|
||
unk02=20 ≥ 17, then `rwdata = g_ModelSlots[i].unk10` = NULL →
|
||
`modelInit(model, header, NULL)` → fault. **Log evidence** (TEMP D51 trace
|
||
in model.c → d52rw.log):
|
||
```
|
||
INST model=0x7020ac48 header=0x140141c40 numRecords=0 rwdata=0x7020b0f8 rwdatalen=20 lvreset=0
|
||
INST model=0x7020ac48 header=0x140142700 numRecords=17 rwdata=NULL rwdatalen=20 lvreset=0
|
||
GND obj=0x7020ac48 datas=NULL idx=0 rwdatalen=-1 op=2 data=NULL res=NULL
|
||
```
|
||
(same Model address both times = slot reuse; 20 = MODEL_SPARE_RWDATALEN
|
||
spare-slot pool.)
|
||
- **D53.2 fix APPLIED and verified.** objecthandler.h under #ifdef PORT:
|
||
both slot structs re-laid out on top of the PC struct Model — `unk02`@2,
|
||
in-use marker `unk08`@**0x10** (low word of Model.obj), pool pointer
|
||
`unk10`@**0x20** (Model.datas), then `char pad28[sizeof(struct Model)-0x28]`
|
||
so each slot is exactly sizeof(Model) = 0xE8 (a full Model, including
|
||
animInit's writes up to PC offset 0xE3, fits). Member names kept; N64
|
||
layout verbatim under #else. model.c heap fallbacks bumped PORT-guarded:
|
||
0x20 → sizeof(struct Model) (non-animated), 0xC0 → sizeof(struct
|
||
AnimModelSlot) (animated). Verified with the d52rw.log probe: both INSTs
|
||
get valid pools (rwdata=0x7020c778, non-NULL), all 17 records initialize,
|
||
frame-5 modelInitRwData crash gone; game runs to ~frame 102 and past the
|
||
Nintendo-logo transition. Note: the bump allocator's `pos` drifts with the
|
||
new sizes → some slots land on 4-byte boundaries; misaligned 8-byte field
|
||
stores are functionally fine on x86-64 (individual field accesses, no
|
||
faults) — accepted without a PORT alignment bump in memp.c.
|
||
|
||
Size/offset probe: `vsize.c` at repo root (untracked scratch — delete before
|
||
commit) prints sizeof + field offsets of Model / ModelSlot / AnimModelSlot /
|
||
struct player; compile with the CMake include order (port/shim first, then .,
|
||
include, include/PR, src, src/game, src/libultra, port/include) plus
|
||
`-DPORT=1 -DAVOID_UB=1 -std=c11`. Re-run after any slot-struct edit.
|
||
|
||
Environment notes: standalone gcc needs
|
||
`/c/msys64/mingw64/bin:/c/msys64/usr/bin` on PATH (cc1 fails SILENTLY without
|
||
it — exit 1, zero diagnostics), and `-std=c11` is required (the CMake flag;
|
||
under the default gnu23 `typedef s32 bool` in bondtypes.h breaks). Also:
|
||
`include/stddef.h`'s body is `#if 0`'d — offsetof/size_t are unavailable in
|
||
game TUs (use pointer-difference arithmetic in probes).
|
||
|
||
**D54 (RESOLVED: cseq ALCMidiHdr endianness; audio-thread SIGSEGV in
|
||
`__getTrackByte`)**
|
||
|
||
After D53, the first music load (M_INTROSWOOSH, seq 44) crashed the audio
|
||
thread in `__getTrackByte`. Root cause: a decompressed compact-sequence file
|
||
starts with `struct ALCMidiHdr` — 16 **big-endian** u32 trackOffset values +
|
||
a BE u32 division. N64 reads them natively; on an LE host alCSeqNew() does
|
||
not swap them (track-0 offset 0x44 becomes 0x44000000), builds "valid"
|
||
curLoc pointers ~1 GB past the buffer, and the first track-byte read faults.
|
||
The rest of the stream is byte-oriented (varlens, MIDI bytes, BE loop offsets
|
||
assembled byte-by-byte) so only the 17 header words need fixing. Fix: port-
|
||
layer `romdataFixupCseq(u8 *blob)` (port/src/romdata.c — bswap32 of the first
|
||
17 u32s; declared in port/include/romdata.h), called under #ifdef PORT after
|
||
each of the 3 `decompressdata` calls in musicTrack1Play/2/3 (src/music.c).
|
||
Verified: __getTrackByte crash gone; full 60 s run (exit=124 timeout) with no
|
||
audio fault, past the Nintendo-logo transition.
|
||
|
||
**D54b (RESOLVED: synthesizer param-slot sizing; audio-thread SIGSEGV in
|
||
`alLoadParam`)**
|
||
|
||
Next audio crash: `alLoadParam` dereferenced a corrupted free-list slot. Root
|
||
cause: alSynInit's "build the parameter update list" allocates
|
||
`c->maxUpdates` slots of `sizeof(ALParam)` and the game type-puns those slots
|
||
as several AL*Param structs. On N64 all of them are 0x1C bytes (one slot
|
||
each); on x86-64 **ALStartParamAlt is 0x28** (two 8-byte pointers: next +
|
||
wave) vs ALParam = 0x20 — every start-voice update wrote `wave` into the
|
||
neighbouring slot, corrupting the free list. Fix in synthesizer.c alSynInit
|
||
under #ifdef PORT: allocate `maxUpdates * sizeof(ALStartParamAlt)` and stride
|
||
the init loop by that size (cast each slot to ALParam* for the next-pointer
|
||
link); N64 verbatim under #else. Cost +8 B/slot × 0x80 slots = +1 KB vs
|
||
MUSIC_ALLOCATION_BYTES — no alHeapAlloc failure observed. Verified:
|
||
alLoadParam crash gone; full 60 s run clean.
|
||
|
||
**D55 (RESOLVED: RLE folder-menu background header endianness; SIGSEGV in
|
||
`rle_expand_8bit`)**
|
||
|
||
After the audio fixes, the game ran to the gun-barrel intro (~frame 654,
|
||
~23 s) and crashed in `rle_expand_8bit` (src/game/rle.c:30, the `*dst++`
|
||
store). Caller: title.c `sub_GAME_7F008DE4` (the initializeGunBarrelIntro path)
|
||
romCopies the asset at
|
||
`unknown2` (romassets_<r>.s, NTSC cart 0x102A4D50, size 0x1A580) and RLE-
|
||
decodes it into a 0x40400-byte buffer. Root cause: the asset is the title
|
||
folder-menu background; its raw ROM header is **big-endian** `01 B8 01 2B`
|
||
(w=440, h=299) + 6 pad bytes + a valid RLE stream (decodes to exactly
|
||
440×299 = 131560 bytes). rle_expand_8bit reads w/h as **LE** u16s (byte-
|
||
matched N64 code): the raw header gives w=47105, h=11009 → remaining ≈
|
||
518 MB written into a 256 KB buffer → SIGSEGV. The N64 build embeds this
|
||
asset into .data via `assets/romfiles2.s` (`.incbin
|
||
"assets/ge007.u.2A4D50.usedby7F008DE4.bin"`) from an extracted .bin whose
|
||
header is byte-swapped — title2.c hardcodes 440-wide I8 rows × 299, and this
|
||
is the only decodable 440×299 RLE stream in the ROM (a full-file 16-bit swap
|
||
was ruled out: it zeroes the first RLE count at +0xC), so only the 4-byte
|
||
header differs between raw ROM and the N64 .bin. Fix (port-layer, romdata.c
|
||
romdataInit, after the cart-base mapping): bswap32 the first word at
|
||
`(u32 *)&unknown2` in place — the image is a writable VirtualAlloc at CART_
|
||
BASE — guarded by "only swap if the LE-read w or h > 512" so an already-LE
|
||
region copy is a no-op. rle.c/title.c untouched (D37/D54 pattern). Verified:
|
||
RLE crash gone; game renders 600+ frames past the gun-barrel background into
|
||
the watch intro, where it hits D56.
|
||
|
||
**D56 (RESOLVED: watch-intro embedded Model/RW-pool raw offsets into struct
|
||
player; SIGSEGV in `modelSetScale`)**
|
||
|
||
Post-D55, the game renders 600+ frames (~10 s into the gun-barrel/watch
|
||
intro) then SIGSEGVs in `modelSetScale` (src/game/model.c:778,
|
||
`objinst->scale = scale`) with a garbage Model* (crash-log return frame was
|
||
corrupted; caller inferred — see below). Prime suspect, and the only code
|
||
passing a **raw N64 offset into struct player** as a Model*: `sub_GAME_7F07E7CC`
|
||
(bondview2.c:3102-3116, called from bondview2.c:3400 whenever the pause/watch
|
||
transition completes — every watch-menu open, and during the gun-barrel
|
||
intro):
|
||
```
|
||
animInit((Model *)((u8 *)g_CurrentPlayer + 0x230), itemheader, (u32 *)((u8 *)g_CurrentPlayer + 0x2ec));
|
||
modelSetScale((Model *)((u8 *)g_CurrentPlayer + 0x230), c_item_entries[41].scale * 0.1f);
|
||
modelSetAnimation((Model *)((u8 *)g_CurrentPlayer + 0x230), …ANIM_DATA_bond_watch…);
|
||
*(s32 *)((u8 *)g_CurrentPlayer + 0x220) = 0; // = step_in_view_watch_animation
|
||
```
|
||
Layout facts (probe-verified where noted):
|
||
- struct Model (bondtypes.h:1482): 8 pointer fields (chr, obj, render_pos,
|
||
datas [u32* under PORT, D53.1 — note the in-code comment there still says
|
||
"D52", flagged for rename in HANDOFF Task 3], attachedto,
|
||
attachedto_objinst, anim, anim2).
|
||
sizeof_N64 = **0xBC**; sizeof_PC = **0xE8** (vsize probe: chr@8, obj@0x10,
|
||
render_pos@0x18, datas@0x20, scale@0x28, attachedto@0x30, anim@0x40 — note
|
||
the 8-byte pointer alignment padding after the two leading s16s).
|
||
- N64 struct player: the **watch Model is embedded at +0x230** (its first
|
||
word is the anonymous s32 `something_with_watch_object_instance`), size
|
||
0xBC, so it ends exactly at +0x2EC where the **RW-data pool** begins. The
|
||
pool region runs to `buttons_pressed`@0x3B4 = **0xC8 bytes** of capacity;
|
||
every field in [0x2EC, 0x3B4) is an anonymous s32 (no pointers → no extra
|
||
PC shift inside the region).
|
||
- On PC the embedded Model sits at X = offsetof(struct player,
|
||
something_with_watch_object_instance) (≥ 0x230 + 5×4: cameratile@0x34,
|
||
prop@0xA8, bodyModel@0xD4, autoaim_target_y@0x130, autoaim_target_x@0x140
|
||
are the pointer fields before it) and spans [X, X+0xE8). Remaining capacity
|
||
before buttons_pressed is only 0x184−0xE8 = **0x9C < 0xC8** → the pool does
|
||
NOT fit embedded on PC.
|
||
- modelInit stores the pool pointer in `Model.datas` and ALL rwdata access
|
||
goes through `modelGetNodeRwData(model, node)` via `model->datas` — so
|
||
redirecting the pool to separate storage is safe; only sub_GAME_7F07E7CC
|
||
references +0x2EC directly (whole-tree grep).
|
||
- The N64 capacity 0xC8 bounds the watch model's real pool size (the game
|
||
works on N64), so a fixed static buffer of that size is safe.
|
||
**Applied as designed + verified:** bondview2.c sub_GAME_7F07E7CC under
|
||
#ifdef PORT takes the Model by field name —
|
||
`Model *watch = (Model *)&g_CurrentPlayer->something_with_watch_object_instance;`
|
||
(probe-verified at +0x24C on PC — not 8-aligned; unaligned pointer stores are
|
||
fine on x86-64 per D53.2) and hosts the pool in `static u8 watchRwPool[0xC8]`
|
||
(N64 capacity; N64 embeds it at player+0x2EC); animInit/modelSetScale/
|
||
modelSetAnimation on `watch`; the +0x220 store becomes
|
||
`g_CurrentPlayer->step_in_view_watch_animation = 0;`. N64 raw-offset path kept
|
||
verbatim under #else. Verified: the watch path no longer crashes — but the
|
||
same `modelSetScale` SIGSEGV remained, and an env-gated probe (GE_D56,
|
||
logging `__builtin_return_address(0)` in modelSetScale) proved the real
|
||
caller was **not** the watch path: `initializeGunBarrelIntro` (title.c)
|
||
calling `modelSetScale(NULL, 0.18779343f)` because `setup_chr_instance()` →
|
||
`modelmgrInstantiateModelWithAnim()` returned NULL for BODY_Brosnan_Tuxedo —
|
||
see D57.
|
||
|
||
**D57 (RESOLVED: pointer-grown rwdata records overflow the N64-sized spare
|
||
pools; `modelmgrInstantiateModelWithAnim` returns NULL → SIGSEGV in
|
||
`modelSetScale(NULL, …)`)**
|
||
|
||
The Brosnan tuxedo's computed PC `numRecords` is **153** words vs the N64-
|
||
sized anim spare-pool capacity of **140** (`ANIM_MODEL_SPARE_RWDATALEN =
|
||
0x8C`). Cause: two rwdata record structs contain pointer fields and grow 8 →
|
||
16 bytes on x86-64 — `ModelRwData_HeadPlaceholderRecord` (ModelFileHeader* +
|
||
void*) and `ModelRwData_DisplayList_CollisionRecord` (Vertex* + Gfx*). Since
|
||
`modelCalculateRwDataLen()` accumulates sizeof(record)/4 per node, every
|
||
HEAD/DLCOLLISION node adds +2 words vs N64. Fix (two parts, both #ifdef PORT):
|
||
(1) initunk_005520.c: spare capacities grown with headroom —
|
||
`MODEL_SPARE_RWDATALEN 0x14→0x38`, `ANIM_MODEL_SPARE_RWDATALEN 0x8C→0xA8`
|
||
(N64 values kept under #else); (2) model.c: in the non-LvResetting branches
|
||
of both `modelmgrInstantiateModel()` and `modelmgrInstantiateModelWithAnim()`,
|
||
a dynamic slot+pool fallback mirroring the existing LvResetting path
|
||
(`mempAllocBytesInBank(sizeof(struct ModelSlot/AnimModelSlot))` + 16-aligned
|
||
pool of numRecords words) — the slot is untracked (never reused), acceptable
|
||
because with the grown capacities it should not trigger. A u32-field approach
|
||
for the two pointer records was rejected: `ModelFileHeader` pointers there are
|
||
exe-resident globals (>0x80000000 on PC) and would truncate. Verified:
|
||
Brosnan gets a dynamic slot, `modelSetScale` succeeds, game proceeds to
|
||
rendering — where it hits D58.
|
||
|
||
**D58 (RESOLVED: gun-barrel DL — K0 vertex-pointer idiom + 16-byte Gfx
|
||
overflow of the N64-sized reservation; SIGSEGV in `gfx_sp_vertex`, then FATAL
|
||
"Unknown GBI opcode 0x00")**
|
||
|
||
Two distinct PC-layout breaks in the same buffer (initializeGunBarrelIntro,
|
||
title.c), both found via env-gated probes (GE_D57: per-command VTX/CALL/JMP
|
||
log + entry-time hexdump of the barrel DL):
|
||
- **Part A (vertex pointer):** title.c passed `barrelDisplayListPtr +
|
||
0x80000000` to sub_GAME_7F01BFF8, which embeds it verbatim in each G_VTX
|
||
w1 (GE's gDma1p writes `(uintptr_t)(v)` — no LSB). On N64 the mempool
|
||
pointer was physical, so +0x80000000 gave the RSP-visible KSEG0 address;
|
||
on PC it is a V1 pointer (0x70xxxxxx, dram.c) and +0x80000000 lands at
|
||
0xF0xxxxxx — unresolvable by fast3d's seg_addr() → SIGSEGV reading the
|
||
vertex array. Fix: rebuild the exact N64 value —
|
||
`(Vtx *)(OS_K0_TO_PHYSICAL((void *)barrelDisplayListPtr) | 0x80000000u)`
|
||
(→ 0x80xxxxxx; seg_addr passes it through to the KSEG0 mirror; segments
|
||
7/8 are never registered, so the unmarked-segment path is skipped).
|
||
- **Part B (DL reservation):** on PC `sizeof(Gfx) == 16` — the union's
|
||
trailing `long long` (gbi.h documents it: "except on 64-bit, where it is
|
||
exactly 128 bit"), same class as D50.6. Both writers (`gdl++`) and fast3d
|
||
(`++cmd`) advance by 16, so all game-written DLs are 16-byte-wide — but
|
||
the barrel-DL reservation `bufferSize -= 0x100` was sized for N64's 8-byte
|
||
Gfx. sub_GAME_7F01BFF8 emits 31 Gfx (2×VTX + 28×TRI + ENDDL) = 496 B, so
|
||
slots 16–30 (second TRI batch + ENDDL) overflowed into the RLE region at
|
||
+0x300, and sub_GAME_7F008DE4's expand then clobbered them with image data.
|
||
At render the RSP executed VTX/TRI×14/VTX fine, then hit slot 16 = RLE
|
||
pixels (w0=0x00000001 → opcode 0x00; fast3d has no G_SPNOOP case) → FATAL.
|
||
Fix: reserve 0x200 under PORT. The 0x200 vertex reserve still fits
|
||
(30 Vtx × 16 B = 0x1E0). NOTE for future asset work: any other N64-sized
|
||
reservation for game-written DLs/vertex arrays must be re-checked against
|
||
the 16-byte Gfx / 16-byte Vtx widths (recurring class, cf. D50.6/D53.2).
|
||
Verified: barrel DL executes to ENDDL; game proceeds past the gun-barrel
|
||
hole into model rasterization — where it hits D59.
|
||
|
||
**D59 (OPEN — current blocker: SIGSEGV inside an external GL DLL during the
|
||
first real model rasterization after the gun-barrel hole)**
|
||
|
||
Post-D58, the barrel DL runs clean (probe-verified: VTX@+0, TRI×14,
|
||
VTX@slot15(+0xF0 in 16-byte form), … ENDDL) and the game crashes shortly
|
||
after with EXCEPTION 0xc0000005 at PC 0x7ff8d42a44d3 — inside a DLL loaded
|
||
at 0x7ff8d4230000 (offset +0x744d3; almost certainly the OpenGL driver,
|
||
not yet confirmed). The crash-log backtrace frame #1 (main+0x227cc0) is a
|
||
BSS symbol (`memoryMesgMB`) — garbage stack, no usable caller. Draw path:
|
||
gfx_flush() (gfx_pc.cpp:299) → `gfx_rapi->draw_triangles(buf_vbo,
|
||
buf_vbo_len, buf_vbo_num_tris)` → gfx_opengl.cpp:813-816
|
||
`glBufferData(GL_ARRAY_BUFFER, sizeof(float)*buf_vbo_len, buf_vbo,
|
||
GL_STREAM_DRAW); glDrawArrays(GL_TRIANGLES, 0, 3*buf_vbo_num_tris)`, where
|
||
`buf_vbo` is the static `float buf_vbo[MAX_BUFFERED*(32*3)]`. Hypotheses:
|
||
(a) buf_vbo overflow while accumulating transformed vertices for the first
|
||
real model (Brosnan) — check the vertex-append site in gfx_pc.cpp (the
|
||
transform loop after ~line 1118) for a missing bounds check against
|
||
MAX_BUFFERED; (b) garbage buf_vbo_len/num_tris; (c) bad texture/shader state
|
||
on first model draw. Next: env-gated probe logging buf_vbo_len/
|
||
buf_vbo_num_tris at every gfx_flush + identify the DLL (PowerShell module
|
||
list during a run, or the GL vendor string in the log).
|
||
|
||
**D59 RESOLVED (sessions G–I).** The "external GL DLL" crash was not a
|
||
driver bug: it was an msvcrt.dll `memcpy` faulting on a wild source — the
|
||
gun-barrel sub-DL region at `ptr_logo_and_walletbond_DL + 0x200` had been
|
||
clobbered by the unbounded RLE write of D64 (below). With D64 fixed, the
|
||
barrel renders and the game advances; no fast3d/vertex-buffer change was
|
||
needed. The crash handler gained permanent improvements along the way:
|
||
FAULT ADDR (ExceptionInformation[1]), a 16-qword STACK@RSP window, and a
|
||
module list in `ge007.crash.log` (crash.c + psapi).
|
||
|
||
**D60 RESOLVED — DMA target validation (port layer).**
|
||
`osPiStartDma` (libultra.c) now validates ROM-read targets: the N64 PI can
|
||
DMA to any KSEG address, but on PC an unmapped target is a wild memcpy.
|
||
`dramHostAddrValid()` accepts DRAM V1/V2 and any host-committed region
|
||
(VirtualQuery), so legitimate `.bss`/`.data` targets (e.g.
|
||
`ramrom_data_target`) pass while s32-truncated wild addresses are rejected
|
||
with a logged FATAL instead of a silent crash. Also added the GE_D60
|
||
sidecar-read tracer and GE_D61 per-ROM-read log (`d61dma.log`).
|
||
|
||
**D62 RESOLVED — OSMesgQueue/OSScMsg layout (port layer).** The shim's
|
||
message-queue bookkeeping had to match the PC struct widths: OSMesgQueue is
|
||
40 bytes on PC (two OSThread* + 3×s32 + OSMesg*), OSScMsg stays 32 bytes.
|
||
Scheduler-thread message flow (retrace/pre-NMI/interrupt/cmd queues in
|
||
`os_scheduler`, g_AudioManager frame/reply queues) verified against those
|
||
layouts.
|
||
|
||
**D63 — TEMP diagnostics (to strip).** GE_D63-gated probes in gfx_pc.cpp /
|
||
blood_animation.c / front.c / rsp.c tracing the VTX-pool bump pointer
|
||
(`g_GfxMemPos`), the gun-barrel sub-DL slot word, dram-branch targets and
|
||
rspGfxTaskStart hand-off. Used to prove D64's clobber path and to rule out
|
||
VTX-pool overflow; no permanent change.
|
||
|
||
**D64 RESOLVED — blood RLE sentinel (src/game/blood_animation.c).**
|
||
The N64 build places `die_blood_image_end` in the same section directly
|
||
after `die_blood_image_1[]`; the RLE decoder's guard `bloodImgNxt <
|
||
&die_blood_image_end` relies on that adjacency (only the address is used).
|
||
On PC a zero-init symbol lands in `.bss` ~1 MB away, so the guard never
|
||
fires and the decoder writes unbounded past the array — it clobbered the
|
||
gun-barrel sub-DL at `ptr_logo_and_walletbond_DL + 0x200`, which is what
|
||
surfaced as the D59 "GL DLL" crash. Fixed under #ifdef PORT by defining
|
||
`die_blood_image_end` as one-past-the-end of the array.
|
||
|
||
**D65 RESOLVED — `enum HEADS` signed sentinels (src/bondconstants.h).**
|
||
The N64 toolchain gave this enum a signed underlying type, so
|
||
`HEAD_FIXED == -1` and `head >= 0` guards were real branches. PC GCC 16
|
||
picks `unsigned int` for enums whose enumerators are all non-negative
|
||
(0xFFFFFFFF > INT_MAX), making every `head >= 0` always true and turning
|
||
`c_item_entries[HEAD_FIXED]` into a wild 64-bit OOB read (SIGSEGV in
|
||
init_menu18_displaycast). Under PORT the sentinels are now negative
|
||
literals (`HEAD_FIXED = -1`, `HEAD_RANDOM = -97`) — identical bit pattern,
|
||
signed semantics restored.
|
||
|
||
**D65b RESOLVED — `enum BODIES` signed sentinel (src/bondconstants.h).**
|
||
Same class as D65: ROM tables store 0xFFFFFFFF in `body` fields and the
|
||
cast-end check compares `intro_char_table[f].body < 0`; PC's unsigned
|
||
underlying type deleted the reset branch, so the cast screen rendered the
|
||
terminator entry and `langGet(0)` dereferenced a NULL bank. Added
|
||
`BODY_FIXED = -1` under PORT (forces signed underlying type; no existing
|
||
value changes).
|
||
|
||
**D66 RESOLVED — romCopyAligned pointer width (src/ramrom.c/.h) +
|
||
ramrom replay truncations (src/game/ramromreplay.c).** The N64 build did
|
||
all of `romCopyAligned` in s32; on PC targets live in `.bss` above 4 GiB,
|
||
so `(s32)target` truncated (0x1401C6F00 → 0x401C6F00) and the DMA went to a
|
||
wild address. PORT version uses uintptr_t throughout and returns `void *`
|
||
callers assign straight to pointers. The ramrom replay path had the same
|
||
class of `(s32)` truncation on `ramrom_data_target`.
|
||
|
||
**D67 RESOLVED — struct image_entry layout (src/game/image.h).** The
|
||
decompiled field order cannot be right: texLoad() reads
|
||
`*(s32*)&entry & 0xFFFFFF` as the data offset (dataoffset must occupy bits
|
||
0-23 of word 0) while chrprop.c indexes entries with an 8-byte stride
|
||
(sizeof == 8). Under PORT the struct is re-declared with all-u32 bitfields
|
||
and `dataoffset : 24` first, so GCC packs it to exactly two words on both
|
||
targets and the raw word read is satisfied. The IMAGE() macro initializer
|
||
order in image.c is adjusted to match under PORT.
|
||
|
||
**D68 RESOLVED — Globalimagetable endianness (port/src/gimgfixup.c +
|
||
src/game/image_bank.c + src/game/image.c).** The ROM-copied Globalimagetable
|
||
segment (texReset) is N64 big-endian, but PC code reads its CPU-interpreted
|
||
u32 fields natively: the IMAGESEG-marked G_SETTIMG w1 words
|
||
(`IMAGESEG(id) = 0xABCD0000 | id`) and the `sImageTableEntry.index` field of
|
||
all 32 table arrays. Unfixed, texLoad computed texnum from byte-swapped ids
|
||
(e.g. 52651 for IMAGE_SMOKE_11 = 2106) → out-of-range offsets → a 925 KB
|
||
ROM read into the 4000-byte stack compbuffer (FATAL at boot). Fix:
|
||
`gimgFixupGlobalimagetable()` bswaps exactly those u32s in place after the
|
||
romCopy (17 Gfx DLs walked op-by-op for the AB CD marker; table entry
|
||
counts fall out of the D39 symbol layout, 12-byte stride); everything else
|
||
in the segment is byte-level (opcodes, single-byte fields, raw pixel blocks
|
||
referenced via 0x02xxxxxx segmented addresses) and untouched. Two
|
||
consequences handled under PORT: (1) texLoadFromDisplayList's marker scan
|
||
now checks bytes 6..7 (CD AB — the LE encoding of 0xABCDxxxx) instead of
|
||
4..5; (2) explosion.c executes the *compiled* globalDL_0xNNN shadows via
|
||
g_ExplosionDisplayLists[], so `gimgSyncCompiledGlobalDLs()` copies the
|
||
texLoad()-patched IMAGESEG w1 values from the ROM copy into those arrays
|
||
(command j of the 8-byte ROM DL maps to Gfx slot j of the 16-byte compiled
|
||
array; D39 verified them byte-identical). Verified: 137 texLoads with valid
|
||
in-range ids (2106, 2084, …), real offsets/sizes from g_Textures, and the
|
||
game runs the full ~3.5-minute intro (logo → gun barrel → cast) at ~59 fps
|
||
to the first stage load.
|
||
|
||
**D69 (OPEN — current blocker: BG-file big-endian headers at stage load)**
|
||
|
||
Post-D68 the game plays through the entire intro and crashes in
|
||
`load_bg_file` (src/game/bg.c:830) when loading the first stage (BUNKER1,
|
||
"bg/bg_sev_all_p.seg", cart 0x10438660). The header IS loaded correctly —
|
||
`obLoadBGFileBytesAtOffset` works: `&fileentry->hw_address[offset]`
|
||
evaluates to `hw_address + offset` (a valid cart address; the compiled
|
||
absolute asset symbols point into the ROM mapped at the cart base, and the
|
||
PI shim memcpys from there). The bug is interpretation: BG-file offsets are
|
||
N64 big-endian u32s in segment-0x0F form. Header word 1 in the ROM is
|
||
`0F 00 00 14` (BE value 0x0F000014 → file offset 0x14 after
|
||
BG_SEG_TO_PTR's `+ 0xF1000000` fold); PC reads it LE as 0x1400000F, so
|
||
`ptr_bgdata_room_fileposition_list = header + 0x1400000F - 0xF000000`
|
||
lands ~0x5 MB past the stack buffer and `...[1].pPointTableBin` faults
|
||
(EXCEPTION 0xc0000005, FAULT ADDR ≈ header + 0x500000F). The whole
|
||
stage-load path (bg .seg headers/room tables + Tbg_*_stanZ geometry files)
|
||
is riddled with BE u32 fields — the same class as D68 but a far larger
|
||
format surface. This is the "next asset type" milestone anticipated in
|
||
AGENTS.md. Strategy options: (a) offline per-region conversion of all bg/*.seg
|
||
+ Tbg_*_stanZ files into sidecars (the D43/Plan-B pattern; requires fully
|
||
decoding GE's BG/stan formats from bg.c/stan.c — note PD's
|
||
preprocess/filebg.c describes a *different*, zipped multi-section format;
|
||
same family ≠ identical, validate per field); (b) runtime port-layer fixup
|
||
after each load (same format knowledge, placed in port/). Either way the
|
||
first task is reverse-engineering the formats: header words 0..3 are
|
||
pointers (rooms/portals/bgcmds/lights-style tables per the D69 probe:
|
||
word1=0x14 room-fileposition list), bg_room_data records carry more
|
||
0x0Fxxxxxx offsets (pPointTableBin at record+0x28, see crash disasm), and
|
||
stanZ files go through stanDetermineEOF/stanLoadFile. TEMP D69 probe in
|
||
ob.c (GE_D69) logs name/index/rom_size/hw_address per BG load.
|
||
|
||
### G. Phase 2 status snapshot (HISTORICAL — frozen ~D74)
|
||
|
||
> Stale point-in-time snapshot. **Current status: `docs/HANDOFF.md`.**
|
||
> Finding detail: §F index above + §H below.
|
||
|
||
Done through **D68**: PD fast3d integrated (`port/fast3d/`); GE's real
|
||
`src/sched.c` + pthread kernel; dual-mapped DRAM; ROM mapped at cart base;
|
||
SDL2 window; full boot chain (D31–D42); **Plan B executed (D50)** — all 512
|
||
NTSC model files offline-converted to PC-layout RZ sidecars, served through
|
||
the existing load path via a port-layer table patch; runtime C fixups for
|
||
language-bank BE offset tables (D50.3) and font re-layout (D50.4);
|
||
legal-screen UB seed (D50.5); `texCopyGdls` w1 partial-copy bug fixed and
|
||
byte-proven (D50.6). **D51–D58 resolved** (font pixeldata fixup, osGetCount
|
||
tick rate, model RW-data pools, cseq BE header, synth param slots, RLE
|
||
folder-menu background, watch-intro raw offsets, spare-pool capacities,
|
||
gun-barrel DL idiom + reservation). **D59 resolved** (the "GL DLL" crash
|
||
was D64's unbounded blood-RLE write clobbering the barrel sub-DL),
|
||
**D60–D62 resolved** (DMA target validation, OSMesgQueue layout),
|
||
**D63** TEMP diagnostics, **D64 resolved** (blood RLE sentinel adjacency),
|
||
**D65/D65b resolved** (HEADS/BODIES enum signed sentinels under PORT),
|
||
**D66 resolved** (romCopyAligned + ramrom replay 64-bit pointer width),
|
||
**D67 resolved** (struct image_entry N64 layout reconstruction),
|
||
**D68 resolved** (Globalimagetable BE→LE fixup for IMAGESEG Gfx words +
|
||
sImageTableEntry.index; compiled globalDL shadows synced after texLoad).
|
||
The game now boots, plays the intro music, and renders the **entire intro**
|
||
(Nintendo logo → gun barrel with Brosnan → cast screen; ~frame 2100, ≈2 min
|
||
wall-clock at the current ~20 fps clean-run rate), then crashes in
|
||
`load_bg_file` on the first stage load —
|
||
**D69, the milestone blocker**: BG-file headers are N64 big-endian
|
||
(segment-0x0F offsets) and PC reads them LE. Details in §F/D59–D69.
|
||
|
||
**D70–D74 (intro-logo pixel work):** D70 env-gated PPM frame capture
|
||
(`GE_PCDUMP` → `./ppm/`) for numerical visual debugging; **D71 resolved** —
|
||
C-array texture sources (the four rarewarelogo.c RGBA16 images) were
|
||
byte-swapped on LE PC (pink/green logo); port-layer per-source bswap in
|
||
`import_texture`. **D72 resolved** the UV path (GE always uses authored tc[]
|
||
UVs; `lookat_enabled` defaults false). **D73 resolved** — root cause of
|
||
D72.3: sinf/cosf `du` double constants are big-endian word pairs, garbage on
|
||
LE PC → guMtxF2L emitted −32768 for every sin/cos entry → logo triangles
|
||
projected off-screen; DVAL() macro fix in guint.h/sinf.c/cosf.c under PORT.
|
||
**D74 resolved** — texture import fallback no longer truncates valid
|
||
gDPLoadBlock data (mip chains + sub-tiled textures), TextureCacheKey gains
|
||
`size_bytes`, and the VBO path now wraps UVs by tile size for WRAP sub-tiles
|
||
(N64 semantics). The logo now renders its four gold letters on the dark-blue
|
||
plate (PPM-verified at frame ~555); a final pixel-perfect comparison against
|
||
N64 reference footage is still open. **D69 remains the milestone blocker**
|
||
(stage load).
|
||
|
||
**Committed through D74**: D51–D74 fixes; this session's TEMP probes are
|
||
stripped, but previously committed TEMP diagnostics (D63 blocks, GE_D71LOG,
|
||
and the older D51–D66 leftovers) are still in the tree — strip list in
|
||
HANDOFF Task 3. Build is GREEN.
|
||
|
||
### H. Finding log continued (D32 procedure, D70–D121)
|
||
|
||
> 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 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
|
||
now-rendering intro logo). For D69:
|
||
reverse-engineer GE's bg .seg + Tbg_*_stanZ formats from the decompiled
|
||
consumers (bg.c, stan.c), then choose offline sidecar conversion (Plan-B
|
||
pattern, D43) vs runtime port-layer fixup; PD's preprocess/filebg.c is a
|
||
reference for the *approach* only — its BG format is different (zipped
|
||
multi-section).
|
||
Summary:
|
||
|
||
**State.** D50–D74 resolved and verified (D73 = sinf/cosf endianness, D74 =
|
||
texture import fallback + sub-tile UV wrap); committed through the D74
|
||
milestone. The game boots, plays intro music, and renders the entire intro
|
||
(logo → gun barrel with Brosnan → cast screen) — the Rareware logo now shows
|
||
its four gold letters on the dark-blue plate (D71–D74; PPM-verified at frame
|
||
~555, final pixel-perfect check vs N64 footage still open). TEMP diagnostics
|
||
from earlier sessions are still in the tree (strip list in HANDOFF Task 3);
|
||
visual-debug tooling: `GE_PCDUMP` frame capture. Build is GREEN.
|
||
|
||
**Thread 1 — D69 (milestone blocker, now primary).** `load_bg_file`
|
||
(src/game/bg.c:830) faults on the first stage load (BUNKER1): BG-file header
|
||
words are N64 big-endian segment-0x0F offsets; PC reads them LE, so the
|
||
room-fileposition-list pointer lands ~5 MB past the stack header buffer.
|
||
Full analysis + strategy options in §F/D69. This is the next milestone:
|
||
decode the bg .seg + Tbg_*_stanZ formats, convert/fix for PC, get a stage to
|
||
load and render.
|
||
|
||
**Thread 2 — logo final check (low priority).** Compare the rendered logo
|
||
frames (ppm/, frame ~550–560) against N64 reference footage; if any letter
|
||
is still off, re-add a lightweight triangle/texture attribution probe (the
|
||
D74 probes are stripped; the import sizes and sub-tile wrap are verified at
|
||
the data level).
|
||
|
||
**After D69:** get a stage to load + render (bg .seg + Tbg_*_stanZ format
|
||
work), then continue the diagnose→fix→verify loop through gameplay; strip
|
||
TEMP diagnostics at each milestone; pixel-assert soak (PPM dump +
|
||
tools_pc/pixcount.py) once a stage is stable.
|
||
|
||
**D32 repeatable fix procedure** (apply to any ROM-serialized struct that faults
|
||
on a pointer-field read):
|
||
1. At the fault, `ptype /o <Struct>` in gdb. If a pointer field's offset/size
|
||
diverges from the N64 offset comment (e.g. an 8-byte pointer where N64 has 4),
|
||
it is this bug class.
|
||
2. Change the embedded pointer fields to `u32` in the struct (keeps N64 layout on
|
||
x86-64). Add casts at every use site (`(T *)field`). Document as D3x.
|
||
3. Verify the load-time rebase/fixup writes valid **V1** DRAM addresses (< 0x80000000)
|
||
into those u32 fields; if not, debug the fixup (see Part B below).
|
||
4. Rebuild and confirm the boot advances past this struct to the next init step.
|
||
5. If the struct is a *tree* of packed sub-structs (like the libaudio banks, D37,
|
||
or model files, D43), an in-place BE→LE patch will not fit — the expanded
|
||
8-byte pointer slots overrun the following ROM data. Re-lay it out into a
|
||
fresh compact image and rewrite each pointer slot as the sub-struct's new
|
||
offset (zero-extended), so the existing `ptr + (s32)base` rebase still works.
|
||
6. If the fault is NOT in ROM-loaded data but in an address arithmetic idiom over
|
||
exe-resident symbols (`(u32)&sym`, XOR toggles, pointer-delta math — D39/D42),
|
||
guard a PC branch that reproduces the N64 32-bit value exactly (e.g. keep the
|
||
0x02000000 base) or replace the idiom with an explicit equivalent; N64 line
|
||
stays verbatim under `#else`.
|
||
7. If two structs are **type-punned** (cast back and forth, e.g. ModelSlot ↔
|
||
Model), verify their layouts still agree on PC — pointer-width changes break
|
||
puns silently: fields the game reaches "through" one struct land at wrong
|
||
offsets in the other (D53.2). Also check any fixed-size allocation that must
|
||
contain the grown struct (the 0x20-byte model heap fallback, D53).
|
||
8. If a hang (not a crash) appears with no thread making progress, suspect a
|
||
**timing/pacing gate**: GE's loop gates on `osGetCount()` deltas in N64 RSP
|
||
counter ticks (~46.5525/µs), not µs (D52).
|
||
9. If the fault is a store through a pointer built from a **raw byte offset
|
||
into a struct that contains pointers** (`(u8 *)ptr + 0xNNN`, D56): every
|
||
pointer field before 0xNNN shifts +4 on PC (plus 8-byte alignment padding),
|
||
so the offset no longer lands on the intended object. Fix under #ifdef PORT
|
||
via the named field (`&s->field`); if the embedded sub-object has grown
|
||
(sizeof_PC > sizeof_N64) and its trailing companion storage (e.g. a model
|
||
RW pool packed right after it in the struct) no longer fits before the next
|
||
live field, relocate that companion to a static buffer of the N64 capacity —
|
||
safe when all access goes through a pointer stored in the sub-object at init
|
||
(modelInit → Model.datas) and only the one site references the raw offset.
|
||
|
||
**D70 (dev tooling, TEMP):** env-gated PPM frame capture for numerical visual
|
||
debugging: `GE_PCDUMP="first-last[:step]"` dumps the bound FBO to
|
||
`./ppm/frame_NNNNNN.ppm` from `videoEndFrame()` (`gfx_opengl_pcdump_enabled()`/
|
||
`gfx_opengl_dump_bound_fbo()` implemented in gfx_opengl.cpp). Established the
|
||
intro timeline numerically (legal text f20–100, Rareware logo f380–580,
|
||
iris/gun-barrel f900–1220, red region f1240–1360, cast f1520+) and confirmed
|
||
the user's "pink and green" report at f480 (left half RGB≈(135,78,129), right
|
||
half (82,104,0)). Strip per HANDOFF Task 3.
|
||
|
||
**D71 (RESOLVED):** the Rareware logo rendered as two flat colors — pink-red
|
||
and bright green — instead of gold lettering. Root cause: the four RGBA16 logo
|
||
images in assets/rarewarelogo.c (`imgRAre_0x0020` etc.) are `u32` C arrays
|
||
compiled into the exe `.data`; on LE PC each N64 texel pair is stored as a
|
||
little-endian u32, so `import_texture_rgba16`'s big-endian u16 read produced
|
||
byte-swapped texels (raw 0xED0F gold → 0x4FCC green + 0x0FED pink — exactly
|
||
the two observed colors). All other texture sources are raw N64 BE byte
|
||
streams (ROM cart map 0x10xxxxxx, model sidecar 0x10Cxxxxx, KSEG0/V1 buffers
|
||
0x70–0x90xxxxxx) and must not be touched. Fix (port/fast3d/gfx_pc.cpp):
|
||
`gfx_tex_source_is_c_array()` classifies by address range;
|
||
`gfx_tex_normalize_source()` bswaps each u32 once per source into a stable
|
||
cached buffer (cache key stays the original address) and `import_texture()`
|
||
decodes from it. Verified: exactly two sources normalized in a full run (the
|
||
logo image banks); logo now renders gold on dark blue. One-shot per-source log
|
||
gated by `GE_D71LOG`.
|
||
|
||
**D72 (RESOLVED + D72.3 OPEN):** the logo UV path.
|
||
- D72.1: removed the PD-inherited normal/lookat-based UV overwrite in
|
||
`gfx_sp_vertex()` — GE always uses authored per-vertex `tc[]` UVs: no GE
|
||
code sets G_TT_BASE/G_TT_CLAMP (only G_TT_NONE), and stage geometry has
|
||
gSPLookAt set every frame yet N64 textures are fixed to surfaces, so the
|
||
normal-derived path is wrong for GE in general.
|
||
- D72.2: `rsp.lookat_enabled` now defaults false — N64 boots with RSP memory
|
||
zeroed; no lookat exists until gSPLookAt writes one (the intro logo has
|
||
none). GL UV convention confirmed as U.5 (texel×32) from the LUT/rect path,
|
||
so DL_RAREWARETEXT's identity-scale corner UVs 0x0010..0x03F0 map to full
|
||
32×32 coverage with a half-texel inset.
|
||
- D72.3 (OPEN): after D71+D72 the logo still does not appear: DBGTRI traces
|
||
show ALL logo triangles (plate fan + letter quads) project off-screen
|
||
(clip x=y≈−1.6e11, w<0, screen ≈(32776,32776)), yet the frame shows a large
|
||
flat dark-blue (0,0,64) region — rows 150..479, cols 1..639 — filled as a
|
||
PERFECT checkerboard (50/50 pixel parity), i.e. broken rasterization of some
|
||
big triangle drawn after clear_framebuffer_black. Identity not yet
|
||
established (`GE_DBGTALL` all-triangle trace captured; analysis pending).
|
||
Ruled out: matrix-format mismatch — this codebase's guMtxF2L is Rare's
|
||
modified variant writing the interleaved hi16/lo16 s32 Q15.16 packed format
|
||
that gfx_sp_matrix decodes exactly (FTOFIX32=×65536); `D_8002A7D0` is a
|
||
zero-init u32 so `[D_8002A7D0]`==`[0]`; alloc_intro_matrices() runs from
|
||
initmenus. **SUPERSEDED by D73** — the off-screen projection was never a
|
||
matrix-format or UV problem: guMtxF2L's sinf/cosf inputs were garbage on PC.
|
||
|
||
**D73 (RESOLVED — root cause of D72.3):** GE's `sinf`/`cosf`
|
||
(src/libultra/gu/sinf.c, cosf.c) build their double constants through the
|
||
`du` bit-union (`{ struct { u32 hi; u32 lo; } word; double d; }`) with
|
||
big-endian word pairs `{hi, lo}` (rpi, pihi, and the P[] polynomial tables).
|
||
On an LE PC the `.d` read has the two words swapped: `rpi.d` ≈ 2^733,
|
||
`pihi.d` ≈ 2^257. Range reduction then overflows: `dn = dx*rpi.d` huge →
|
||
ROUND(dn) saturates in cvttsd2si (n = −2^31) → `dx = dx − dn*pihi.d` ≈ 2^288
|
||
→ xsq ≈ 2^576 → the result overflows float → sinf/cosf return ±inf/NaN.
|
||
FTOFIX32's `(int)(±inf·65536)` is another cvttss2si saturation →
|
||
0x80000000 (−32768). Every sin/cos-derived entry of guMtxF2L became −32768,
|
||
so the Rareware logo's guRotate matrix was garbage and all its triangles
|
||
projected off-screen (the D72.3 symptom; the "checkerboard" was a separate
|
||
large triangle rasterizing over the empty logo region). The logo scene is the
|
||
first visually verifiable consumer of guMtxF2L sin/cos output in the intro;
|
||
other scenes that build matrices via guRotate/guLookAt are affected by the
|
||
same bug and were fixed by this change. Fix (narrow ABI exception — constant
|
||
interpretation only, algorithm verbatim): `duD()` static-inline + `DVAL(x)`
|
||
macro in src/libultra/gu/guint.h under #ifdef PORT re-pack hi/lo into the
|
||
correct LE double; the 5 `.d` reads in sinf.c and 4 in cosf.c now use
|
||
`DVAL(...)`. The N64 build is untouched (#else branch = original expression).
|
||
Only sinf.c/cosf.c use `du` (grep-verified); `fu` (single u32) constants are
|
||
endian-safe. Verified numerically: P decodes to the expected perspective
|
||
matrix, Rot(−40°Y) gives clean cos=0.766/sin=−0.643, and logo vertices clip
|
||
to small finite on-screen NDC values. (tools_pc/mtxtest.c is a standalone
|
||
scratch harness for the matrix-convention question that was ruled out along
|
||
the way — GE stores all matrices transposed; row-vector ≡ column pipeline.)
|
||
|
||
**D74 (RESOLVED — final pixel check pending):** with D73 in, logo geometry
|
||
was correct but the texture side was still wrong: a dim red-brown blob plus
|
||
a flat vertical bar at the right edge, one letter visible at a time. Three
|
||
port-layer bugs, all in port/fast3d/gfx_pc.cpp (with gfx_pc.h):
|
||
- **Import fallback truncation.** `import_texture`'s old condition
|
||
`(rdp.tex_lod && tile >= rdp.first_tile_index + rdp.tex_detail) ||
|
||
!loaded_texture.addr` overwrote valid gDPLoadBlock tmem data with
|
||
`line_size_bytes * tile.height`. For the logo: letter mip chains 2744 B →
|
||
2048 B (mips 1–5 dropped), and D_02005FF0's 32×32 → 192 B (32×3, stale
|
||
tile.height=3 left by `gsDPSetTileSize(0, 46, 116, 124, 124)` in DL
|
||
D_02004758). Fix: fall back only when `!loaded_texture.addr` (N64 TMem is
|
||
persistent — a populated slot is the faithful source).
|
||
- **Cache poisoning.** TextureCacheKey lacked the upload size, so the first
|
||
(truncated) import of an address poisoned every later import. Added
|
||
`size_bytes` to the key (gfx_pc.h + both key initializers + the bucket-only
|
||
aggregate in gfx_texture_cache_delete).
|
||
- **Sub-tile UV wrap period.** N64 wraps UVs by the TILE size when a render
|
||
tile is a sub-region of the image; the port wrapped at the full uploaded
|
||
image size (GL_REPEAT). D_02004758's 20×3 tile at offset (11.5, 29)
|
||
therefore sampled row 0 instead of rows 29–31 — the flat bar's color
|
||
matched texel [0,4] of D_02005FF0 exactly. Fix: per-vertex pre-wrap in the
|
||
VBO path — for a WRAP sub-tile (`tex_width2 < tex_width`, where
|
||
`tex_width2 = (lrs−uls+4)/4` is the tile window in texels),
|
||
`u = fmodf(u, tw); if (u<0) u += tw; u += uls/4.0f` (same for v). Known
|
||
limitations: half-texel edge bleed at the sub-tile window edges (GL
|
||
bilinear neighbors may sample just outside [O, O+W)); mirror sub-tiles are
|
||
not handled (the logo doesn't use them; audit if another scene regresses).
|
||
Verified: runtime import sizes correct (D_02005FF0 = 2048 B, imgRAre =
|
||
2744 B); PPM frame ~555 shows the four-letter RARE band with gold/warm
|
||
colors and per-letter segmentation; clean `GE_PCDUMP` runs are stable past
|
||
1156 frames. Final pixel-perfect confirmation against N64 reference footage
|
||
is still open (see HANDOFF Task 1). All env-gated probes added for D72.3/D74
|
||
(GE_DBGUV/GE_DBGTRI/GE_DBGTALL/GE_DBGMAT/GE_D74IMP/GE_D74DUMP/GE_DBGLOAD)
|
||
were stripped with this change; the previously committed TEMP D63 blocks and
|
||
the GE_D71LOG normalize log remain on the HANDOFF strip list.
|
||
|
||
**D75 (OPEN: 3D rendering — mispositioned / missing 3D models throughout the
|
||
front end).** Post-D74 the Rareware logo is correct (gold letters on dark
|
||
blue), but every other intro 3D element is still wrong. Confirmed symptoms
|
||
(user observation, 2026-08-28 session L):
|
||
- **Nintendo logo** renders but is **positioned wrong**.
|
||
- **Gun-barrel intro**: the **James Bond figure is entirely missing** (the
|
||
animated walk-and-shoot character model). Barrel/spiral effect status not
|
||
separately confirmed.
|
||
- **Intro credits / cast roll**: the per-character 3D models (each shown
|
||
beside their actor/character name) **do not appear at all** — names draw,
|
||
models do not.
|
||
Pattern: 2D/texture and text elements draw; **skeletal/animated character
|
||
models never appear**, and non-animated 3D (logos) appears but with a bad
|
||
transform. This strongly suggests **(b) below is the dominant bug** — the
|
||
animated-model path is broken independently of the matrix sin/cos fix. Two
|
||
candidate causes — distinguish before any fix:
|
||
- **(a) D73 scope gap.** D73 (`DVAL()` in `src/libultra/gu/guint.h`, PORT-only) was
|
||
documented as fixing *"all scenes using guRotate/guLookAt-derived matrices … not just the
|
||
logo."* If these elements still fail, either their matrix path bypasses that fix or a
|
||
second coordinate defect remains.
|
||
- **(b) Separate model/RW-pool path.** The player models specifically may be on the
|
||
`animInit` + embedded raw-offsets-into-`struct player` path (cf. D56), which is *not* the
|
||
guMtxF2L sin/cos path — i.e. an independent bug, not "the same coordinate issue."
|
||
|
||
Files to check: `src/libultra/gu/guint.h`, `src/game/model.c`, `bondview2.c` (animInit /
|
||
modelSetScale sites), `port/fast3d/gfx_pc.cpp`. Verify: determine whether the Nintendo logo
|
||
and intro player models build matrices via guRotate/guLookAt (→ 75a) or another transform
|
||
(→ 75b); capture `GE_PCDUMP` frames across the logo transition and the gun-barrel/cast
|
||
segments to localize.
|
||
|
||
**D75 ADDENDUM (session M-32) — triage: (a) is DISPROVEN, the defect splits
|
||
into two independent bugs, both outside `src/libultra/gu` and `src/game/model.c`.**
|
||
Static-only pass (no runtime probe; ~1 build-budget spent on reading + rebase
|
||
onto M-30b master which carries D159/D164/D165/D166).
|
||
|
||
- **(a) D73 scope gap — RULED OUT, high confidence.** The whole
|
||
`src/libultra/gu/` tree is endian-clean post-D73. Audited every file:
|
||
`rotate.c` (`guRotateF`/`guRotate`), `perspective.c` (`guPerspectiveF`),
|
||
`translate.c`, `scale.c`, `ortho.c`, `lookat.c`, `mtxutil.c` (`guMtxF2L`),
|
||
`normalize.c`, `align.c`. Only `sinf.c`/`cosf.c` ever use the `du` bit-union,
|
||
and those 9 reads are `DVAL()`-wrapped under `#ifdef PORT`; every other gu
|
||
file uses plain `float` literals (`3.1415926/180.0` etc.) or pure integer
|
||
bit-packing (`FTOFIX32` + shift/mask — byte-order independent). The Rareware
|
||
logo exercises the *entire* gu matrix pipeline front-end
|
||
(`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 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
|
||
the D114/D116 shared fast3d viewport/MP-matrix mirror — NOT in scope,
|
||
NOT game code.** These do NOT use gu at all: `front.c` builds `basemtx`
|
||
with `matrix_4x4_set_lookat_target(&m, 0,0,3000/4000, 0,0,0, 0,1,0)`
|
||
(`matrixmath.c:643` → `matrix_4x4_set_lookat`), copies it into a
|
||
`dynAllocate`d `render_pos`, calls `model.c subdraw`, then
|
||
`matrix_4x4_f32_to_s32` in place (`front.c:1774/2023/2304/2929`, all
|
||
identical). `matrix_4x4_set_lookat*` / `matrix_4x4_set_projection` /
|
||
`matrix_4x4_f32_to_s32` are pure float/int math, native-LE, byte-identical
|
||
in behaviour to the gu equivalents (D114 already verified this for the
|
||
in-level path and disproved converter-axis / lookat-handedness / F2L as the
|
||
cause). D159 (M-31) fixed the *texture* comb on the wallet photo and its
|
||
addendum explicitly notes the residual **"still 180°-rotated"** — a pure
|
||
two-axis negation, i.e. exactly the symptom D114's write-up predicts for a
|
||
hidden X- or Y-flip in `gfx_calc_and_set_viewport` /
|
||
`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 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 (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 /
|
||
`render_pos` lifetime bug, NOT a transform bug. Root cause not pinned
|
||
(needs runtime probing).** Key discriminator: **in-level skeletal guards DO
|
||
render as humanoids** (`-level_09`, M-12) through the *identical* joint
|
||
path — `subcalcmatrices`→`modelUpdateMatrices`(`process_02_position` etc.)
|
||
→`drawjointlist`→`modelRenderNodeDl`. So the joint math, `matrix_4x4_f32_to_s32`,
|
||
and `gfx_sp_matrix` decode are all proven. What the front-end animated path
|
||
does *differently* from the working in-level path:
|
||
1. `renderData.mtxlist = dynAllocate(numMatrices << 6)` — the per-frame GFX
|
||
arena (`dyn.c:140`, bump-allocated inside `g_VtxBuffers[0]` /
|
||
MEMPOOL_STAGE emulated DRAM), then `instcalcmatrices`/`subcalcmatrices`
|
||
set `model->render_pos = (RenderPosView*)arg0->mtxlist` (`model.c:2409`)
|
||
and the joint matrices are written there and converted **in place**
|
||
f32→s32 by the caller (`title.c:281`, `front.c:1568` …). D115 MED item
|
||
**#5** already flags this exact mechanism: *"the D102 weapon-model
|
||
`render_pos` is pointed at a `dynAllocate`'d transient arena that on N64
|
||
aliased the persistent `hand->mtxlist`; likely the '1P weapon model
|
||
doesn't draw' cause."* The gun-barrel Bond uses the same
|
||
dynAllocate→render_pos pattern (`title.c:253/257`). If the arena is
|
||
swapped / overwritten between the `f32_to_s32` write and the
|
||
`gSPSegment(3, osVirtualToPhysical(render_pos))` bind in `subdraw`
|
||
(`model.c:5346`) / `drawjointlist`, every joint matrix is garbage →
|
||
model collapses to a point / projects off-screen → "absent".
|
||
2. `chrModelInstance` / `gunModelInstance` are stand-alone `Model`s created
|
||
for the intro (`title.c initializeGunBarrelIntro`), not the in-level
|
||
`chr`/`prop` pool — check they are non-NULL, `obj->numMatrices > 0`, and
|
||
the model file actually loaded (the `pcmodels` sidecar must contain the
|
||
Bond/cast model files; a missing manifest row → `hw_address` unpatched →
|
||
load serves N64-layout or fails).
|
||
3. `modelSetAnimation(chrModelInstance, (ModelAnimation*)((s32)&ANIM_DATA_bond_eye_fire + (s32)&ptr_animation_table->data), …)` (`title.c:220`, and `title.c:542`).
|
||
Looks like a truncation risk but is **probably already covered by D34**:
|
||
the PC branch of `assets/animationtable_data.h` defines `ANIM_DATA_*` as
|
||
an lvalue at `g_pc_animdata_base + offset`, so `(s32)&ANIM_DATA_*` is the
|
||
bare offset, and `ptr_animation_table` points into low (<4 GiB, bit-31-clear)
|
||
emulated DRAM, so the `(s32)` sum and the cast back to a 64-bit pointer are
|
||
lossless. Verify anyway with a probe (cheap), but do not lead with it.
|
||
Confidence this is category (b) and not (a): **high**. Confidence on the
|
||
exact mechanism (1 vs 2): **low** — pick with a `GE_PCDUMP` + a capped
|
||
env-gated probe logging `chrModelInstance`, `obj->numMatrices`,
|
||
`render_pos`, `g_GfxMemPos`, and the first joint matrix at the gun-barrel
|
||
frame. **Lead with mechanism 1** (`render_pos` = transient `dynAllocate`
|
||
arena, D115 MED #5) — it also explains why the *in-level* path works (there
|
||
`render_pos` is a persistent per-chr pool, not the gfx arena). An in-scope
|
||
fix would give the front-end animated models a persistent `render_pos`
|
||
buffer (`#ifdef PORT`) instead of the swapped arena, mirroring the D100/D102
|
||
inline-pool pattern.
|
||
|
||
- **D144/D146 "seg5+0x9ee4 malformed sub-DL" is a DL-walk desync, not a
|
||
transform bug** — "unresolved matrix pointer then garbage opcodes" is the
|
||
D135 signature (a previous command decoded at the wrong width/offset
|
||
desyncs everything after). seg5 = model COL1/BaseAddr (§11.7), so a G_MTX
|
||
landing on seg5 means the walk is already lost, not that a matrix pointer
|
||
is "unresolved". d43_emit.py's opcode-aware remap (§11.6) deliberately does
|
||
**not** touch G_MTX w1; if a front-end model GDL span is emitted with a
|
||
wrong slot count or a `G_TRI*` / GE tex-macro (`0xba`, cf. D124 Facility)
|
||
is mis-sized, the following `G_MTX` reads garbage. This is
|
||
`tools_pc/d43_emit.py` + `port/fast3d/gfx_pc.cpp` territory — outside this
|
||
task's file scope. Recommend a dedicated converter/fast3d agent dump the
|
||
raw bytes of the `logoinst`/`walletinst` model GDL at `seg5+0x9ee4` with
|
||
`GE_DBG*` and diff the PC span against `build-pc/d43_convert.py`'s
|
||
reference output for that file.
|
||
|
||
**Net for D75:** (a) closed. Bug 1 (logo/photo transform) folds into the
|
||
parked D114/D116 fast3d-mirror item. Bug 2 (absent animated models) is a
|
||
real, in-scope lead at `title.c:220` `(s32)&symbol` truncation +
|
||
`render_pos`=`dynAllocate` arena lifetime — next session should build with a
|
||
`#ifdef PORT` `(uintptr_t)` cast there and a capped probe, not a static pass.
|
||
Confidence overall: **medium** (triage solid, no runtime confirmation).
|
||
|
||
> **M-33 UPDATE — Bug 2 (gun-barrel Bond) is substantially a capture artifact
|
||
> (D168), and/or has since regressed away.** With the PPM writer fixed and a
|
||
> fresh bare-front-end capture (`GE_PCDUMP="700-1200:25"`, no `-level`), the
|
||
> gun-barrel **Bond model renders and animates correctly**: frame ~1075 shows
|
||
> Bond walking across the iris, frame ~1150 shows him turn and fire, both
|
||
> upright, recognisable tuxedo silhouette, correct position inside the barrel.
|
||
> The M-32b probe's "entirely absent — no silhouette" reading was of an
|
||
> upside-down capture (an inverted gun-barrel is a mostly-black frame with the
|
||
> iris low and Bond hanging inverted from it — easy to call "absent"). The
|
||
> `chrModelInstance` being valid with sane matrices, which the probe couldn't
|
||
> reconcile with "absent", now makes sense: it was drawing all along.
|
||
> **Still genuinely broken:** the **Nintendo logo** — frame ~775 renders it as
|
||
> two plain overlapping white ellipsoids shifted left of centre, no wordmark,
|
||
> no logo geometry. That is NOT a flip (a flipped Nintendo logo is still a
|
||
> Nintendo logo) — it's a real `logoinst` model-transform / geometry bug (Bug
|
||
> 1 territory). **Cast-roll models** not captured this pass — status unknown,
|
||
> re-check with a `1400-2200` window. Net: D75 shrinks to "Nintendo logo model
|
||
> renders as degenerate white blobs" plus the unverified cast roll; the
|
||
> gun-barrel half is retired. The M-32b probe text below is left for history.
|
||
|
||
> **M-195 UPDATE (v0.4.0 Phase 1, 2026-09-21) — Nintendo logo: vertex transform proven CORRECT; leading hypothesis now = G_VTX slot overwrite.** Three-pass investigation this session: **(1) Emission is fine.** Fresh `GE_D63=1` bare-front-end run (log `/c/tmp/d75_d63.log`, 32k lines): all 23 nintendologo Primary GDLs are emitted **every frame** of the logo screen (~1002 hits each, BaseAddr=0x70157a98), and the log contains **zero** `D146: unknown GBI opcode` lines — the software RSP consumes every DL to completion. PC-side GDLs verified byte-faithful to the N64 source via the d43 toolchain (same custom GE opcode sequences, same vertex counts). **(2) Transform is fine.** New `GE_D75V="lo-hi"` probe in `gfx_sp_vertex` (per-batch transformed-vertex min/max + MP translation row; uses new `videoGetFrameCount()` accessor) shows the logo's vertices land exactly where the wordmark geometry says they should: x∈[−2500,+2500], y∈[±644], w≈3.9k–5k, MP translation (0,0,3878.7) matching `lookat(0,0,4000)` — this eliminates basematrix, the f32→s32 conversion loop, and the matrix-list suspects. **(3) Leading hypothesis: vertex-slot overwrite.** Every `G_VTX` batch in these GDLs encodes **v0=0** (fast3d decodes `n=C0(0,16)/sizeof(Vtx)`, `dest_index=C0(16,4)`; probe confirms di=0 for every batch), so a node whose vertices span multiple 16-vertex batches writes all of them to slots 0..n−1 of the shared buffer — each later batch **overwrites** the earlier ones and only the last batch survives. Triangles referencing indices beyond the last batch's count read stale slots from prior nodes (likely clipped, not visible as junk — consistent with the observed single ~25×6px orbiting fragment matching the smallest node's 4-vert quad at x≈1020 → ~155px orbit radius vs observed ~200px). The N64 RSP must handle multi-batch nodes differently (per-batch ring or relative indexing) — GE's own ucode is not in this repo, so the next step is empirical: dump one full node's GDL stream from the ROM and cross-reference its `G_TRI4`/`G_TRI1` index ranges against per-batch vertex counts. If confirmed, the fix is port-layer only (fast3d vertex-slot management) — no rule-2 sign-off needed. Probe registered in `GE-ENV-PROBES.md` as `GE_D75V`.
|
||
|
||
> **M-196 UPDATE (v0.4.0 Phase 1, 2026-09-21) — slot-overwrite hypothesis REFUTED; bug narrowed to the color-combiner/texture path.** New `GE_D75D="lo-hi"` probe in `port/fast3d/gfx_pc.cpp`: per-frame G_VTX/G_TRI token stream + per-triangle rejection/emission accounting (trivial-reject, backface-cull, NDC bbox, invisible-flag, vertex-color range, oml, depth z/w, combine mode, prim/env color). Front-end boot (no `-level`), logo window = frames ~244+ where a large model appears (nv=93 G_VTX batches, **nt=1018** tris/frame; the earlier frames 5–243 are the title-screen textured quads, nv=2/nt=12, which render fine at 8.36% coverage). **(1) Slot-overwrite is refuted.** The logo model's DL is **streamed**: `seq` = `VTTTT…VTTT…` (each G_VTX batch immediately followed by its own triangles), and `maxtri=15` — no triangle ever references a vertex index beyond 15, i.e. within a single 16-slot batch. M-195's "all batches write to slot 0, only the last survives" mechanism **cannot apply** to this model; there is no cross-batch clobbering. **(2) Rejection gates are not the cause.** Of the 1018 tris/frame: `rej_triv=0`, `rej_cull≈520` (a normal ~half for a closed mesh), **~500 reach OpenGL**; and all ~500 are on-screen (`emit_on≈500, emit_off=0`, NDC bbox ⊂ [−1,1]²). Not the invisible flag (`invis=0`). Vertex colors span `[0,255]` (not black). Depth `z/w ∈ [−1, 0.97]`, no NaN. **(3) Symptom quantified.** PPM pixel analysis of the logo window: **0–33 content px/frame** (a ~19×2px fragment right of centre) vs the title quads' 8.36% — this is M-195's "tiny orbiting fragment", now measured. **(4) Narrowed cause = texture-modulate material rendering black.** The model's combine mode is `0x009ffe4f19ffe4f1` (`is_2cyc=0`). A one-shot decode in `gfx_generate_cc` (new `D75DCC:` log, gated to this exact word) shows it parses to **rgb a=`G_CCMUX_TEXEL0`(1), c=`G_CCMUX_SHADE`(4)** — a texture-modulate material — and `gfx_generate_cc` sets `used_textures[0]=1`. `env=(0,0,0,0)`, `prim=(255,255,255,255)`. Intended output = TEXEL0 × SHADE; if TEXEL0 samples black at draw time (no/empty texture bound), the whole model is black-on-black → invisible except AA-edge fragments. **Anomaly to close next:** at emit time BOTH `gfx_rapi->shader_get_info(prg)` and `comb->used_textures[0]` read **0**, even though `gfx_generate_cc` set it to 1 for this exact combine_mode — so the TEXEL0 input is being lost between combiner build and draw (suspects: `shader_id0`→`gfx_lookup_or_create_shader_program`, the `prg[tm]` variant selection at gfx_pc.cpp:~2503, or the GL backend's sampler binding / texture upload for this model's tile). If the TEXEL0 sampler is dropped, `SHADER_TEXEL0` defaults to 0 → MODULATE → black. Port-layer fast3d only; no rule-2 sign-off needed. Probes (`GE_D75D`, `D75DCC`) registered in `GE-ENV-PROBES.md`; both env-gated/inert without the var.
|
||
|
||
> **M-197 UPDATE (v0.4.0 Phase 1, 2026-09-21) — ROOT CAUSE FOUND + FIXED: the D50 sidecar converter reserved and remapped the model's embedded texture-blob span but never copied the pixel bytes into the emitted image, so TEXEL0 sampled a zeroed buffer.** The M-196 "utex=0 anomaly" was first closed as a probe artifact: the frame-level `utex`/`cutex` fields were sampled on the *first* emitted triangle of the frame (a title quad, utex=0) while `comb` held the *last* triangle's mode; new logo-mode-specific fields (`lg_utex`/`lg_tex`, gated to combine word `0x009ffe4f19ffe4f1`) show both = 1 for every logo triangle — the combiner and GL shader correctly claim a texture. The real defect is upstream in the asset pipeline: the NINTENDO logo model (`PnintendologoZ`, loaded via `front.c` `init_menu00_nintendoscreen` → `load_object_fill_header(dst=ptr_logo_and_walletbond_DL, 0x3c000)`) carries its texture pixels **embedded in the model file** — GDL `G_SETTIMG` (GE opcode `0xFD`) slots with seg-5 (file-base-relative) targets. The N64 original has a 1024-byte white-wordmark IA8 blob at file offset `0x408`. The D50 offline converter (`tools_pc/d43_emit.py`) detected the blob span (`[0x408, 0x808)`), reserved it in the PC layout (new offset `0x6428`), and remapped all 23 SETTIMG slots to `w1=0x05006428` — **but the emit pass never copied `src[0x408:0x808]` into the output buffer**, so the sidecar's RZ image carried a zero-filled blob region. At runtime `seg_addr(0x05006428)` = file base `0x70157a98` (M-195's BaseAddr) + `0x6428` = **`0x7015dec0`** — exactly the all-zero texture buffer the `GE_TEXDUMP_ADDR` probe measured (both imports, dls=242 and dls=4444: `nonzero=0/1024`). TEXEL0 × SHADE over black pixels = the whole 1018-tri model renders black-on-black. **Three files are affected** (the only model files with seg-5 SETTIMG targets, found by scanning all 512): `PnintendologoZ` (`[0x408,0x808)`), `PgoldeneyelogoZ` (`[0x68,0xb30)`, 2 images), `PlegalpageZ` (`[0x90,0x2070)`, 5 images). **Fix (tooling-only, no `src/game` change, no rule-2 sign-off):** `d43_emit.py` now tracks `blob_regions` through layout, raw-copies each span in the emit pass (`buf[n:n+osz] = src[o:o+osz]` — the RDP reads these bytes as-is, no transform), and round-trip-validates the blob bytes so this cannot silently regress. Sidecars regenerated (`ALL CHECKS PASSED`, incl. the new blob check); offline re-decode confirms all 8 blob targets now carry real pixels (nintendologo: `ffffffff…` white wordmark, 713/1024 nonzero). **Live-verified:** bare front-end boot, `GE_PCDUMP="240-330:10"` — logo window now renders the animated 3D logo: frame 320 = **23,302 non-black px (7.59%)**, wide wordmark bbox `(94,190)-(542,290)`, vs M-196's 0–33 px; frames 340–560 show the logo rotating through its full animation at 3–10% coverage. No placed object offset falls inside any blob span (checked for all three files), so the old-space overlap between a PC-footprint node region and the nintendologo blob start is a bookkeeping artifact with no pointer ambiguity. **User-confirmed live (2026-09-21):** "the images and legal page and Nintendo logo are good now." **Remaining:** `pal-final`/`jpn-final` sidecars must be regenerated on machines holding those ROMs (`python tools_pc/d43_emit.py <region>`). Probes added this pass: `GE_TEXDUMP_ADDR=<hex>` (raw source-byte dump + stats for every import from one address, bypassing the 400-cap) and the D75D `lg_*` logo-mode fields; both env-gated.
|
||
|
||
**D75 Bug 2 — RUNTIME PROBE (session M-32b, `GE_D75=1` in `title.c`
|
||
`sub_GAME_7F007F30`, kept env-gated).** Booted the bare front end
|
||
(`GE_D75=1 GE_PCDUMP="1-1200:120"`, no `-level`) and captured through the
|
||
gun-barrel sequence. Findings:
|
||
|
||
- **Visually confirmed:** the gun-barrel spiral circles (2D backdrop DL)
|
||
render fine; the animated **Bond chr model AND the gun model are
|
||
entirely absent** — no silhouette, no muzzle. `chrModelInstance`
|
||
(`obj=0x14012da80`, `numMatrices=21`) and `gunModelInstance`
|
||
(`obj=0x140148620`, `numMatrices=1`) are **both non-NULL with valid
|
||
`obj` pointers and sane matrix counts** — so mechanism (2) "model file
|
||
not loaded / manifest row missing" is **ruled out**.
|
||
- **`render_pos` is NOT stale / clobbered — mechanism (1) as stated is
|
||
ruled out.** Per-frame `chrModelInstance->render_pos` alternates cleanly
|
||
between `0x700a8f10` and `0x700a3f10` (delta `0x5000` = the double-buffer
|
||
swap). On each frame `render_pos` == the `renderData.mtxlist` base that
|
||
`dynAllocate(21<<6)` returned *that same frame* (`gfxpos 0x700a9490` −
|
||
`0x540` chr − `0x40` gun = `0x700a8f10` exactly), i.e. it points at the
|
||
freshly-`subcalcmatrices`-written matrices, exactly as on N64. It is a
|
||
real low-DRAM (`0x700xxxxx`) address; `osVirtualToPhysical(render_pos)`
|
||
returns it **unchanged** (no D131 truncation). Nothing overwrites the
|
||
arena between the write and the draw within the frame.
|
||
- **fast3d logs ZERO DL warnings** during the whole sequence (no
|
||
"ending DL" / unknown-opcode / `fast3d_ptr_ok` / bad-matrix-substitution
|
||
lines) — so Bug 2 is **not** the D144/D146 corrupt-front-end-sub-DL
|
||
family either. The Bond/gun DL is submitted and consumed silently but
|
||
emits no visible geometry.
|
||
- Mechanism (3), the `(s32)&ANIM_DATA_bond_eye_fire` truncation, was not
|
||
the probe's focus but the model has valid matrices regardless, so a
|
||
broken anim would at worst freeze a pose, not delete the model.
|
||
|
||
**Where that leaves Bug 2:** the failure is **downstream of matrix setup**
|
||
— inside `drawjointlist` / `modelRenderNodeDl` / `dotube` for these
|
||
title-screen `Model` instances: either the joint **vertex / node-DL
|
||
segmented-pointer resolution** produces no tris, or the model is
|
||
transformed off-screen / to zero scale by `renderData.basemtx`
|
||
(`= matrix` from `manipulateGunbarrelAndLogoMatrices`, which folds
|
||
compiled DL pointers through `OS_K0_TO_PHYSICAL` at `title.c:121/123/138`
|
||
— a D58/D84-class `(u32)`-wrap candidate, though the backdrop DLs it
|
||
feeds *do* render). **Next probe:** inside `drawjointlist`/`dotube` at the
|
||
gun-barrel frame, log the resolved `vtx`/`nodeDl` pointers and whether
|
||
`modelRenderNodeDl` emits any `gSPVertex`/`gSP1Triangle`, plus the
|
||
composed `basemtx * render_pos` for joint 0 (is it in the view frustum?).
|
||
Confidence the cause is joint vtx/DL resolution vs off-screen transform:
|
||
**low** — needs the drawjointlist-level probe. The `render_pos`-arena
|
||
in-scope fix hypothesised above is **no longer the lead** — do not land a
|
||
persistent `render_pos` buffer, it would not change anything.
|
||
|
||
**D76 (OPEN: 2D graphics — disclaimer/legal screen only partially drawn). ROOT-CAUSED (M-31) → D164.**
|
||
The image-table/D68 hypothesis is **wrong**: the legal screen (`constructor_menu00_legalscreen`,
|
||
`front.c:1523`) references **zero** `sImageTableEntry`s — it is 12 lines of `langGet()` font text
|
||
(`display_aligned_white_text_to_screen` → `textRender`) plus one 3D `logoinst` model (`subdraw`,
|
||
D75 family, parked). The "only 2 lines render" is a **linker-adjacency bug** in the text loop — see
|
||
**D164**.
|
||
|
||
**D77 (RESOLVED — in-level background music was silent; the general audio
|
||
pipeline was never at fault).** Original framing ("nothing reaches the PC
|
||
speakers") is **stale/wrong** — superseded by the M-78 audio work
|
||
(D198–D208): the SDL device opens, the software mixer runs, and menu/intro
|
||
music is audibly correct. The actual, narrower symptom (still present until
|
||
this fix): **in-level** background music never plays, while the SAME
|
||
`musicTrack1Play()` API called directly from the front end (menu/intro)
|
||
worked fine.
|
||
|
||
**Root cause: D6-class missing-`return` bug (see `docs/porting-notes.md`
|
||
§D6), NOT a mixer/trigger-plumbing gap.** `sub_GAME_7F0C0BF0()`
|
||
(`src/game/mp_music.c`) is a one-line wrapper `{ get_mTrack2Vol(); }` with no
|
||
`return` — on N64/IDO the value happened to already sit in `$v0`; at the PC
|
||
release build's `-O2` the return register is genuinely undefined. Every
|
||
in-level music trigger goes through `set_missionstate()`'s
|
||
MISSION_STATE_1/4 cases (the ONLY call path that starts a level's
|
||
background track — `lvlStageLoad()` → `sub_GAME_7F0C11FC()` →
|
||
`set_missionstate()`), which calls
|
||
`musicTrack1ApplySeqpVol(sub_GAME_7F0C0BF0())` /
|
||
`musicTrack3ApplySeqpVol(sub_GAME_7F0C0C10())` immediately before
|
||
`musicTrack1Play()`/`musicTrack3Play()`. The front-end/menu music
|
||
(`front.c`'s several direct `musicTrack1Play(M_INTRO/M_FOLDERS/...)` calls)
|
||
never goes through `set_missionstate()` at all, which is exactly why it
|
||
worked while level music didn't — different call path, not a different
|
||
pipeline. With the garbage/zero volume applied, `musicTrack1Play()`'s own
|
||
tail call `musicTrack1ApplySeqpVol(musicTrack1GetVolume())` re-applies the
|
||
same zeroed value, so the whole track plays at (effectively) zero gain for
|
||
its entire duration.
|
||
|
||
**Why this was hard to see from a note-level trace alone:** the compact-seq
|
||
player keeps working correctly downstream of the bug — `csplayer.c`'s
|
||
existing `GE_AUDIOTRACE` `[MUSICNOTE]` probe showed ~170-250 real-looking
|
||
note-on events (sane key/velocity) firing continuously through a whole
|
||
BUNKER1 capture. The bug is a *track/channel-level gain* fed once per
|
||
mission-state transition, not a per-note field, so a per-note trace looks
|
||
perfectly healthy; only a trace of the actual mixed output distinguishes
|
||
"notes are being processed" from "notes are audible".
|
||
|
||
**Verification (this session, `chore/golden-bunker1-rebaseline-d215`
|
||
worktree, `-level_09`/BUNKER1):**
|
||
- Added a temporary, cheap (~1/s, gated on `GE_AUDIOTRACE`) `[AUDIOLVL]`
|
||
peak/RMS probe of the exact buffer `audioSetNextBuffer()` hands to SDL
|
||
(`port/src/audio.c`), writing into the SAME `audiotrace.log` (same
|
||
`geTracePrintf` lock) as the existing `[MUSICNOTE]` line so the two
|
||
interleave in true call order without needing per-line timestamps.
|
||
- **Before the fix:** 173 `[MUSICNOTE]` events over a 28 s capture; **every
|
||
single `[AUDIOLVL]` reading (29 samples spanning the full run) read
|
||
`peak=0 rms=0.0`** — literally zero output reaches the mixed buffer for
|
||
the entire run despite continuous note-on activity.
|
||
- **After the fix** (`return` added under `#ifdef AVOID_UB`, `#else` keeps
|
||
the N64 body verbatim): same 28 s BUNKER1 capture, 173 `[MUSICNOTE]`
|
||
events, and **every `[AUDIOLVL]` reading from the first note onward is
|
||
strongly non-zero and sustained** (`peak` ~2700–10300, `rms` ~800–2900,
|
||
continuously, not brief bursts) — a categorical before/after difference,
|
||
not a marginal one.
|
||
- `tools_pc/verify.sh bunker1`: **PASS**, `frames=3 worst_cell=0` — no
|
||
visual regression (this is a one-line audio-only fix; nothing touches
|
||
rendering).
|
||
- Runtime audible confirmation (human ears on real hardware) is still owed
|
||
— the capture above proves samples reach the SDL queue with real
|
||
amplitude, which is the strongest evidence obtainable headless, but a
|
||
human should do a by-ear BUNKER1 pass to close this out completely.
|
||
|
||
**Fix:** `src/game/mp_music.c`, `sub_GAME_7F0C0BF0()` — `return
|
||
get_mTrack2Vol();` under `#ifdef AVOID_UB` (`#else` keeps the N64 body
|
||
verbatim, matching the D187/D6 precedent). One line. No game-logic change
|
||
(same value the N64 build always effectively returned); ABI/UB-class fix,
|
||
not the D3x pointer-width family (no pointer/struct-layout involved here).
|
||
|
||
**Probe left in the tree:** `port/src/audio.c`, the `[AUDIOLVL]` block,
|
||
gated on `getenv("GE_AUDIOTRACE")` (same env var as the pre-existing
|
||
`[MUSICNOTE]` probe in `csplayer.c`, not touched this session). Cheap
|
||
(~1 peak/RMS scan per second of audio, only when the env var is set) — safe
|
||
to leave for future audio work reusing the same trace file. Remove whenever
|
||
`GE_AUDIOTRACE`'s other temporary probes are cleaned up.
|
||
|
||
**Confidence: HIGH.** Root cause matches an already-catalogued bug class
|
||
(D6/D187) exactly; the fix is a single `return`; and the before/after
|
||
`[AUDIOLVL]` measurement is a direct, categorical (zero → sustained
|
||
non-zero) reading of the exact buffer handed to the audio device, not an
|
||
inference from upstream state.
|
||
|
||
**Cross-cutting (Q1 — shared blocker?):** before ordering D75 vs D77, spend one check on whether
|
||
any single root cause touches both audio and rendering. Current evidence says **independent**
|
||
(audio = libaudio→SDL device; 3D = fast3d RSP emulation; 2D = image-table fixup + texture
|
||
import) — default to render-first if no shared cause is found.
|
||
|
||
**Non-negotiable #2 refinement (applied to AGENTS.md).** The original "game code
|
||
compiles unmodified / fix belongs in port/" is too absolute: pointer-width layout
|
||
cannot be isolated in `port/` (no hook between the romCopy and the first read).
|
||
Refined to "game **logic** is unmodified" with a narrow, documented exception for
|
||
mechanical, semantics-preserving ABI/layout changes forced by the 32→64-bit
|
||
transition (embedded pointers → u32 + cast at use; PC-guarded pool sizing),
|
||
following PD ground truth. No logic/behavior changes; each such edit is logged in
|
||
§F/D3x.
|
||
|
||
**D78 (RESOLVED — StandTile bitfield ABI, `#ifdef PORT` layout exception).**
|
||
`StandTile` (`src/bondtypes.h`) declares `u32 id : 24;` immediately followed by
|
||
a non-bitfield `u8 room;`. On MIPS/GCC (N64) these share one 4-byte storage
|
||
unit (id = bits 31:8, room = the low byte) giving an 8-byte tile header
|
||
(id/room word + `mid` u16 + `tail` u16) — the stride `list_of_tilesizes[]`
|
||
(0x20…0x58 = `8 + 8*pointCount`) and `stanFillin`'s `link << 3` addressing
|
||
both hard-depend on. x86 GCC never lets a non-bitfield member share a
|
||
bitfield's storage unit, so the stock declaration compiles to a **10-byte**
|
||
header on PC (`room`@4, `mid`@6, `tail`@8 — confirmed via
|
||
`offsetof()` probe against the real project headers/flags) — every
|
||
`tile->room`/`tile->mid`/`tile->tail`/`tile->points[]` access would silently
|
||
misalign, independent of any byte-swapping. `id` is provably dead (no
|
||
`.id`/`->id` read or write anywhere in the compiled game code — grep-verified
|
||
across `src/`), so under `#ifdef PORT` it is widened to `u8 id[3]` (order
|
||
irrelevant, decorative-only) with `room` immediately following as a plain
|
||
byte. Verified: this restores the exact N64 stride (`room`@3, `mid`@4,
|
||
`tail`@6, `points`@8). Layout-only, no behavior/logic change — same class as
|
||
D53.2. This is a prerequisite for D69 (byte-swapping alone cannot fix stan
|
||
tile reads if the struct itself is misaligned).
|
||
|
||
**D79 (RESOLVED — `bg_room_data` pointer-width ABI, `#ifdef PORT` layout
|
||
exception).** `bg_room_data` (`src/game/bg.h`) declares `pPointTableBin` /
|
||
`pPriMappingBin` / `pSecMappingBin` as `void *`. These are ROM-serialized as
|
||
plain 4-byte N64 segment-0x0F offset values and are **never dereferenced**
|
||
anywhere in the codebase (grep-verified: every use in `src/game/bg.c` casts
|
||
to `(u32)`/`(s32)`/`(u8*) + int` for arithmetic, never `->` or `*`). On
|
||
x86-64 `void *` is 8 bytes, silently growing the 24-byte N64 room record to
|
||
40 bytes and breaking every `ptr_bgdata_room_fileposition_list[i]` array
|
||
index. Fixed under `#ifdef PORT` by declaring them `u32` instead — verified
|
||
via `sizeof()`/`offsetof()` probe: `sizeof(bg_room_data)` == 24,
|
||
field offsets 0/4/8/12 (matching N64 exactly), so the room table needs no
|
||
resizing in the offline conversion, only in-place bswap32. No behavior
|
||
change (every existing use site already treats the value numerically); same
|
||
class as D53.1/D66.
|
||
|
||
**D80 (bg `.seg` format spec — converter spec of record).** Header
|
||
(`s32 header[0x10]`, only words 0–4 consulted by `load_bg_file`): word0 must
|
||
be 0 (bswap32, harmless either way); word1/2/3/4 are `0x0Fxxxxxx`
|
||
self-relative offsets (masked `&0xFFFFFF`) to: room-fileposition list,
|
||
portal-data-entry table, envdata table (0 = absent), and an optional f32
|
||
array (only meaningful if word3 != 0). **Table order in the file is not
|
||
index order** — verified across all 34 unique NTSC bg files
|
||
(`bg/*.seg` referenced from `levelinfotable`): word3 (envdata) < word2
|
||
(portal) in every sample; room-table extent = `[word1, min(word2, word3 if
|
||
word3>word1 else word2))`; in every sampled file this divides evenly by 24
|
||
(`bg_room_data` record size) and word4 was always 0 (f32-array path
|
||
unexercised in this ROM — converter asserts word4==0 and errors loudly if a
|
||
future region violates this, rather than silently mishandling it).
|
||
- `bg_room_data` (24B): 3× `u32` offset fields (D79) + `coord3d pos` (3×f32).
|
||
All 6 words are plain numeric — blanket bswap32, **no resize** (PC stride
|
||
== N64 stride after D79).
|
||
- `bg_envdata_entry_local` (8B, local to `load_bg_file`): `u8 type` + `pad[3]`
|
||
(untouched) + `s32 data` (bswap32). Terminated by `type==0`. **Exception:**
|
||
when `type==ENVIRONMENTDATA_ALT` (100), `data` is not arbitrary — it is
|
||
compared post-rebase against `g_BgPortals[i].offset_portal`
|
||
(`getIndexOfPORTALID`), i.e. it lives in the *same offset space* as
|
||
`bg_portal_data_entry.offset_portal` (the portal point-data blob, below)
|
||
and must receive the identical `+portal_delta` relocation, in addition to
|
||
bswap32.
|
||
- `bg_portal_data_entry` (N64 8B: `u32 offset_portal` + 4× `u8`). Unlike
|
||
`bg_room_data`, `offset_portal` **is** dereferenced pervasively elsewhere in
|
||
`bg.c` (`->numPoints`, `->point`, portal/room-visibility walks) — declaring
|
||
it `u32` would require touching dozens of call sites, so it is left as the
|
||
native `bg_portal_entry *` pointer type (no header edit): PC
|
||
`sizeof(bg_portal_data_entry)` is **16B** (`offset_portal`@0 8B,
|
||
`connectedRoom1/2`+`controlbytes1/2`@8-11, 4B pad) — confirmed via probe.
|
||
The **offline converter** (not game code) re-lays the table at 16B/record
|
||
(N portal records + 1 zero terminator record), writing `offset_portal` as
|
||
an 8-byte field: low 4 bytes = bswapped original offset value
|
||
**+ portal_delta**, high 4 bytes = 0. `portal_delta = 8 * (N+1)` (the extra
|
||
bytes inserted by 8B→16B growth). The `bg_portal_entry` point-data blob
|
||
that follows the portal table in the file (target of every
|
||
`offset_portal`/ALT-envdata value) needs **no per-record resize** — PC
|
||
`sizeof(bg_portal_entry)` is 16B, identical to N64 (`u8 numPoints` + `pad[3]`
|
||
+ `coord3d point`, no pointer fields) — it is simply relocated by
|
||
`+portal_delta` as a block, with `numPoints`/`pad` copied verbatim and
|
||
`point` (3×f32) bswapped. Net effect: the whole `.seg` file grows by
|
||
exactly `portal_delta` bytes; nothing outside the portal table/blob region
|
||
needs remapping (room table, envdata, and the header's word1/word3 all sit
|
||
*before* word2 and are untouched).
|
||
|
||
**D81 (`Tbg_*_stanZ` format spec — converter spec of record).** RZ-compressed
|
||
(`0x11 0x72` + raw deflate, same scheme as models — decompress/recompress
|
||
around the conversion). Decompressed layout: `struct StanPrefixRecord { s32
|
||
stanfile; StandTile *ptr_firstroom; }` is dereferenced directly against the
|
||
raw loaded buffer (`stanLoadFile`/`stanDetermineEOF` receive the file pointer
|
||
itself as `StanPrefixRecord *`), so — same class as D78/D79 — the struct's
|
||
PC-compiled layout must match the file's byte layout. N64: `stanfile`@0 (4B)
|
||
immediately followed by `ptr_firstroom`@4 (4B pointer). PC: pointer-alignment
|
||
forces an implicit 4B pad after `stanfile`, so `ptr_firstroom` compiles to
|
||
offset **8**, not 4 — and each subsequent room-offset array slot is a real
|
||
8-byte pointer (`stanDetermineEOF`'s `void **roomPtr; roomPtr++` walk and
|
||
in-place `*roomPtr = *roomPtr + delta` rebase already use genuine
|
||
pointer-width semantics — **no code change needed there**, only the file's
|
||
data layout). Converter fix (constant shift, no game-code edit): insert a 4B
|
||
zero pad after `stanfile` (array now starts at file offset 8, matching PC
|
||
struct layout), and widen every room-offset array slot from 4B to 8B (low
|
||
4 bytes = bswapped original file-offset value + `array_delta`, high 4 bytes
|
||
= 0; the terminator NULL slot becomes 8 zero bytes). `array_delta = (8 + 8*
|
||
(N+1)) - (4 + 4*(N+1)) = 4*(N+2)` where N = room-offset entries before the
|
||
terminator. Everything from the old tile-data start to EOF shifts by
|
||
`+array_delta` as a block; **tile records need no resize** (D78 restored the
|
||
exact N64 8-byte header stride, and `StandTilePoint`/`link` addressing is
|
||
already relative to `standTileStart`, computed at runtime — unaffected by
|
||
where the tile-data block sits in the file). Per-tile conversion: the 4-byte
|
||
id/room word is copied **verbatim** (D78 makes the PC struct byte-identical
|
||
to N64 there — no swap needed, it's a byte array not a scalar); `mid.half`
|
||
and `tail.half` (s16, top nibble of `tail` = `pointCount` selecting record
|
||
size via `list_of_tilesizes[]`: `8 + 8*pointCount`, pointCount 3–10 →
|
||
0x20…0x58) are bswap16; each of the `pointCount` `StandTilePoint` entries (8B:
|
||
x/y/z s16 + link u16) are bswap16 per field. The **N64-order (still-BE) tail
|
||
half must be read to size each record** while walking — same discipline as
|
||
the D50 model-node walk. Net: the whole stan file grows by exactly
|
||
`array_delta` bytes before recompression; the RZ-compressed sidecar size
|
||
(recorded in the manifest, patched into `rom_size`) differs from the N64
|
||
compressed size, same as pcmodels (D50) — this is expected and fine, nothing
|
||
in the load path assumes N64 compressed size.
|
||
|
||
**D82 (converter + port wiring — see `tools_pc/d69_emit.py` /
|
||
`port/src/pccg.c`).** Implements D80/D81 above: per NTSC bg/stan file
|
||
referenced by `levelinfotable`, converts and concatenates into
|
||
`data/pccg-ntsc-final/pccg.bin` + `manifest.csv` (`name,offset,size` decimal,
|
||
`file_resource_table` order) — same manifest shape as `pcmodels.bin`
|
||
(D50/`d43_emit.py`). Port layer (`port/src/pccg.c`, cloned from
|
||
`pcmodels.c`): `pccgReserveSize`/`pccgLoadSidecars`/`pccgPatchTable`, wired
|
||
into `port/src/romdata.c`'s cart-reservation extension alongside
|
||
`pcmodels*`, and `romdataCartAddrValid`/`libultra.c`'s D60 DMA-source bounds
|
||
check extended to cover the pccg byte range. One-shot patch call:
|
||
`pccgPatchTable()` from the same `load_object_fill_header` hook site as
|
||
`pcmodelsPatchTable()` (idempotent, matches every table entry by filename,
|
||
rewrites `hw_address`/`rom_size`). Regenerate: `python tools_pc/d69_emit.py
|
||
[ntsc-final|pal-final|jpn-final]`; only `ntsc-final` regenerated/verified
|
||
this session (PAL/JPN ROMs not present in this environment) — `data/pccg-*/`
|
||
is gitignored like `pcmodels-*`.
|
||
|
||
**D83 (RESOLVED — StandTileHeaderMid/StandTileHeaderTail bitfield ABI,
|
||
found during D69 verification).** After D78-D82 landed, a clean run reached
|
||
`stanBuildRoomData` (`stan.c:245`) without faulting, but then **hung
|
||
forever** (kernel heartbeat: no frame rendered, stuck at the same PC across
|
||
repeated snapshots). Root cause: same MIPS-BE-vs-x86-LE bitfield-packing
|
||
class as D78, but in a struct D78 didn't touch. `StandTileHeaderTail {
|
||
s16 pointCount:4; s16 headerC:4; s16 headerD:4; s16 headerE:4; }` — on
|
||
N64/MIPS the FIRST-declared field occupies the HIGH bits (`pointCount` =
|
||
top nibble); x86 GCC packs the first-declared field into the LOW bits
|
||
(`pointCount` = bottom nibble instead). `tile->tail.hdrTail.pointCount` is
|
||
read pervasively (`list_of_tilesizes[]` tile-size lookup used for
|
||
navigation, edge walks, `stanBuildRoomData`'s bounds loop) — with the stock
|
||
declaration this silently read the wrong nibble on PC. An env-gated probe
|
||
(`GE_D69STAN=1` in `stanBuildRoomData`, TEMP, kept) proved it directly:
|
||
tile tail=`0x03dc` (N64: pointCount=0, top nibble) decoded to
|
||
`pointCount=12` on PC (bottom nibble) — `list_of_tilesizes[12]` is
|
||
out-of-bounds (table has 12 entries, 0-11) and happened to read a stray 0,
|
||
so `tile` never advanced — infinite loop. Fixed under `#ifdef PORT` by
|
||
declaring both `StandTileHeaderMid` and `StandTileHeaderTail`'s fields in
|
||
**reverse order**: x86's low-to-high packing then lands each field in the
|
||
same bit position MIPS's high-to-low packing does (byte-identical numeric
|
||
result, verified via a union/probe against `0x03dc` returning
|
||
`pointCount=0`). `StandTileHeaderMid`'s fields (`special`/`r`/`g`/`b`) are
|
||
never read via their bitfield names either (only via `.mid.half >> 0xc`
|
||
elsewhere in stan.c) so that half of the fix is precautionary. Same
|
||
narrow-ABI-exception class as D78; no logic change.
|
||
|
||
**D84 (RESOLVED — bg.c hand-inlined segment-fold 64-bit-pointer overflow,
|
||
found during D69 verification).** With D83 in, `stanBuildRoomData`
|
||
completed and the game proceeded into room streaming
|
||
(`bgCheckIfRoomModelNeedsLoad` → `bgLoadRoomModelData` →
|
||
`bgLoadRoomVtxData`/`bgLoadRoomPrimaryGdl`/`bgLoadRoomSecondaryGdl`), which
|
||
then **segfaulted** at a fixed, reproducible fault address
|
||
(`0x7104561d`, identical across runs) inside `bgBuildRoomVtxBounds`
|
||
(`bg.c:2852`, reading `vtx[i].v.ob[0]`). Root cause: `bgLoadRoomVtxData` /
|
||
`bgLoadRoomPrimaryGdl` / `bgLoadRoomSecondaryGdl` each hand-roll the
|
||
`BG_SEG_TO_PTR` fold instead of calling the macro:
|
||
`offset = (((u8 *)room->pPointTableBin + ptr_bg_data) - ptr_bg_data) +
|
||
0xf1000000;` (and the Pri/Sec-mapping equivalents). On N64 this "+base
|
||
-base" cancellation is a no-op inside 32-bit pointer arithmetic that wraps
|
||
for free. D79 made `pPointTableBin`/`pPriMappingBin`/`pSecMappingBin`
|
||
plain `u32` fields (never dereferenced, matching every other use site), but
|
||
these three call sites still cast them to `(u8 *)` and did the arithmetic
|
||
as real 64-bit pointers: `+0xf1000000` no longer wraps at 32 bits the way
|
||
`BG_SEG_TO_PTR`'s explicit `(u32)` cast does, so the computed `offset`
|
||
came out roughly 4 GiB too large, corrupting every downstream room-file
|
||
read (compressed-data location and size). Fixed under `#ifdef PORT` by
|
||
doing the fold as plain `u32` math at all three sites, matching
|
||
`BG_SEG_TO_PTR` exactly (`offset = (u32)room->pPointTableBin +
|
||
0xf1000000;`, no pointer involved) — same narrow ABI-exception class as
|
||
D79/D69's original BG_SEG_TO_PTR fix, no logic change. (Root-caused via an
|
||
env-gated probe, `GE_D69BB=1` in `bgBuildRoomVtxBounds`/
|
||
`bgLoadRoomPrimaryGdl`, TEMP, kept — confirmed the compressed room-DL bytes
|
||
now start with the correct `11 72` RZ magic at the right file offset.)
|
||
|
||
**D85 (OPEN — room primary/secondary DL binaries decode to garbage after
|
||
D84; safety-netted, not crash-fixed at the geometry level).** With D84 in,
|
||
the compressed room DL binary loads and decompresses correctly (verified:
|
||
`11 72` RZ header at the right offset, plausible decompressed size), but
|
||
the **content** `texCopyGdls`/`texLoadFromGdl` produce from it is not a
|
||
valid GBI command stream (`GE_D69BB=1` dump: `cmd=00`, `01`, `02`, `52`...
|
||
none of these are display-list opcodes actually present in the source
|
||
bytes — the raw N64 bytes are untouched by the offline converter (D80: the
|
||
whole per-room DL/point-index blob is a byte stream, deliberately left
|
||
unconverted, out of scope for this milestone) and `texLoadFromGdl` is the
|
||
*same, already-working* model-GDL runtime converter (`bgLoadRoomPrimaryGdl`
|
||
calls it identically to the model-loading path) — so either room GDLs use
|
||
a BG-specific command/marker convention `texLoadFromGdl`'s marker-expansion
|
||
logic doesn't handle, or something upstream of it (compression alignment,
|
||
`csize_primary_DL_binary`/`csize_secondary_DL_binary` delta sizing) is
|
||
still off. Not yet root-caused; full triage is D75-class 3D-pipeline work,
|
||
out of scope for this session. **Crash prevented, not geometry fixed:**
|
||
added a `#ifdef PORT` bounds check in `bgBuildRoomVtxBounds` before every
|
||
`vtx[i]` dereference (`vtxOff`/`vtxEnd` must fit inside
|
||
`usize_point_index_binary`) — a garbage command stream now produces an
|
||
empty/degenerate bounding box for that vertex batch instead of an
|
||
out-of-bounds read, so a bad room fails to render sanely rather than
|
||
segfaulting. Follow-up: decode what `texLoadFromGdl` actually does with
|
||
room-specific opcodes (`bgApplyDynamicCCRMLUT`/`ptrDynamic_CC_RM_LUT`/
|
||
`DL_LUT_PRIMARY_ADDFOG` suggest room GDLs carry CC/RM-LUT-selection markers
|
||
models don't use) and verify `csize_*_DL_binary` sizing end-to-end.
|
||
|
||
**D86 (RESOLVED — `modelInitRwData` crash was a single truncating pointer
|
||
cast in the player's embedded gait/arm model init, unrelated to bg/stan).**
|
||
Root-caused with a new env-gated trace (`GE_D86=1`: node-walk trace in
|
||
`modelInitRwData` + a load-identity probe in `load_object_fill_header`,
|
||
both TEMP, left in place). The trace showed the crash node's low 32 bits
|
||
were `(header_ptr & 0xFFFFFFFF) + 0x1E0` with the high 32 bits zeroed, and
|
||
that this header was **never** loaded via `load_object_fill_header` (no
|
||
matching probe line) — pointing at a statically-embedded model, not a
|
||
dynamically-loaded one. `src/game/initplayergaitobject.c:5` does
|
||
`player_gait_object_header.RootNode = (int)&player_gait_hdr;` — a
|
||
same-width (32-bit) pointer→int→pointer round trip that's a no-op on N64,
|
||
but on PC `(int)` truncates the real 64-bit `&player_gait_hdr` to its low
|
||
32 bits, and the implicit int→pointer conversion back into `RootNode`
|
||
zero-extends it, dropping the executable's load-base high bits (module
|
||
maps at `0x140000000`, so the truncated pointer silently loses the
|
||
`0x1`). `init_player_gait_object()` runs once from `boss.c:236`, and
|
||
`player_gait_object_header` is only used once real gameplay starts
|
||
(`initBondDATAdefaults.c:99` `animInit`s the player's gait model) — never
|
||
exercised while the game only ever got as far as the intro/cast screens.
|
||
Fixed with a `#ifdef PORT` branch in `initplayergaitobject.c` that assigns
|
||
the real pointer directly (behavior-identical to the N64 assignment,
|
||
ABI-width fix only). Verified: BUNKER1 now loads past this point with a
|
||
clean, deterministic repro via `-level_09` (see D88).
|
||
|
||
**D87 (RESOLVED — attract-mode demo playback (`ramrom_replay_handler`)
|
||
crashed on a big-endian `ramromfilestructure` read with no byteswap).**
|
||
Found while re-verifying D86: an idle front-end run (no player input)
|
||
eventually calls `select_ramrom_to_play()` (`ramromreplay.c`), which picks
|
||
a random compiled-in demo blob from `ramrom_table[]` (`ramrom_Dam_1`,
|
||
`ramrom_BunkerI_1`, etc. — genuine shipped attract-mode assets, not a
|
||
debug-only feature; the debug-menu replay path, `DEB_REPLAYRAMROM`, is
|
||
structurally unreachable in this `ntsc-final`-equivalent build since
|
||
`DEBUGMENU` isn't defined — confirmed with a `gdb -p <pid>` **attach**
|
||
hardware watchpoint on `is_ramrom_flag`, which resolved cleanly and
|
||
quickly this session; attach mode works fine for a non-timing-dependent
|
||
write, unlike the launch-mode-only guidance logged after the D56 session —
|
||
worth a retry next time attach seems useful). `replay_recorded_ramrom_at_address`
|
||
loads `ramromfilestructure` via `romCopyAligned()`, a raw byte copy (by
|
||
design, D66) from a real ROM-compiled asset — so, like every other
|
||
N64-compiled ROM asset, its multi-byte fields are big-endian, and nothing
|
||
byte-swaps them on read. A real `size_cmds` of 2 (BE bytes `00 00 00 02`)
|
||
read as native LE prints as `33554432` (`0x02000000`); that garbage then
|
||
drives the loop bound and pointer arithmetic in
|
||
`iterate_ramrom_entries_handle_camera_out`/`ramrom_replay_handler`, which
|
||
walks far outside the small `ramrom_blkbuf_2`/`ramrom_blkbuf_3` scratch
|
||
buffers and segfaults reading `temp_v0->stick_x`
|
||
(`ramromreplay.c:301`/`ramrom.c` callers). Root-caused with a new
|
||
env-gated probe (`GE_D87=1`, left in place). Fixed with a `#ifdef PORT`
|
||
`ramromFixupEndian()` in `ramromreplay.c`, called once right after the
|
||
`romCopyAligned()` in `replay_recorded_ramrom_at_address` (same pattern as
|
||
the D54 cseq-header fixup): byte-swaps every multi-byte field
|
||
(`u64`/`u32`/enum fields via `__builtin_bswap64`/`32`, `save_data.options`
|
||
via `bswap16`); `save_data`'s single-byte fields and the `times[]` byte
|
||
array are left alone. The **downstream** per-frame chunks
|
||
(`ramrom_seed`/`ramrom_blockbuf`, read via the same `romCopyAligned`
|
||
pattern in `iterate_ramrom_entries_handle_camera_out`) are all-`u8`
|
||
structs and need no swap. Not BUNKER1-specific — this is a front-end/
|
||
attract-mode path that can select any of the 7 demo locations at random;
|
||
use `-level_09` (see D88) to skip the front end entirely for deterministic
|
||
BUNKER1 testing instead of waiting on/fixing attract mode.
|
||
|
||
**D88 (OPEN — root-caused, next blocker: per-level `Usetup*Z` "stage
|
||
setup" file is raw N64-endian/width ROM bytes read directly through a
|
||
PC-widened struct, with no conversion at all).** Found immediately after
|
||
D86/D87 while re-verifying BUNKER1 specifically — launch with `-level_09`
|
||
(`boss.c:199-339` decodes `-level_XX` into `g_StageNum`, bypassing the
|
||
front end/attract-mode entirely for a fast, deterministic repro; NTSC
|
||
`LEVELID_BUNKER1 = 9`, and the token's two digit-chars are consumed as raw
|
||
ASCII bytes, so `"09"` → `'0'*10 + '9' - 0x210 = 9`) reaches the exact same
|
||
crash as the random attract-mode run, immediately and reproducibly:
|
||
`proplvreset2` (`prop.c:1306`) segfaults reading
|
||
`g_CurrentSetup.pathwaypoints[i1].padID`. `prop.c:1267-1282` loads the
|
||
level's `"Usetup<name>Z"` file with `_fileNameLoadToBank` (raw ROM bytes,
|
||
**not** run through any PC-layout converter — unlike bg/stan (D69/D80-82)
|
||
and models (D43/D50), this asset type has zero PC porting work done on
|
||
it) into `local_stage`, then rebases 10 top-level fields
|
||
(`pathwaypoints`/`waypointgroups`/`intro`/`propDefs`/`patrolpaths`/
|
||
`ailists`/`pads`/`boundpads`/`padnames`/`boundpadnames`, plus nested
|
||
`neighbours`/`waypoints`/`ailist` pointers inside the sub-tables) with
|
||
`(void *)(((u32) local_stage) + ((u32) local_stage->pathwaypoints))` —
|
||
i.e. by reading the *raw file bytes* directly through the live
|
||
`struct stagesetup` (`bondtypes.h:4091`), whose 10 fields are declared as
|
||
real pointers. This is worse than a plain missing-byteswap bug (cf. D87):
|
||
on N64 those 10 fields are 4 bytes each (40-byte header, correctly
|
||
self-describing "byte offset from file start" per the code's own
|
||
comment), but the PC struct widens every pointer field to 8 bytes (an
|
||
80-byte header) — the same class as D79 (`bg_room_data` pointer growth)
|
||
— so field N's read doesn't even land on the right *bytes* of the file
|
||
past field 0, before even considering that the 4 meaningful bytes it does
|
||
read are big-endian. Confirmed no PORT/byteswap handling exists anywhere
|
||
in `prop.c` (`grep` for `bswap`/`#ifdef PORT` in the file: zero hits).
|
||
**Not fixed this session** — this is format-conversion work at the same
|
||
scale as D69 (a whole ROM asset type needs a byte-accurate spec + either
|
||
an offline converter sidecar, the established preferred pattern per
|
||
AGENTS.md, or a careful runtime fixup pass that parses the raw 40-byte
|
||
N64-packed header by explicit byte offset, byte-swaps each field, and
|
||
writes the results into the PC-widened `stagesetup` struct — plus the
|
||
same treatment for every nested sub-table referenced from it
|
||
(`waypoint`/`waygroup`/`PropDefHeaderRecord`/`PathRecord`/`AIListRecord`/
|
||
`PadRecord`/`BoundPadRecord`/`pname`, each of which likely has its own
|
||
internal offsets/BE fields not yet audited). **This is the actual next
|
||
blocker to a rendered BUNKER1 frame** — reachable deterministically via
|
||
`-level_09` in well under a minute, no attract-mode wait required.
|
||
|
||
**D88.1–D88.3 RESOLVED / VERIFIED (2026-08-28, session L).** The
|
||
`Usetup*Z` offline converter (`tools_pc/d88_emit.py`, 531 lines) was
|
||
written and run in a prior interrupted session; this session verified its
|
||
output is correct and consumed at runtime:
|
||
- `port/src/pccg.c` `PCCG_MAX_FILES` grown 128 → 256 so the sidecar image
|
||
can also carry the 21 `Usetup*Z` rows (manifest now has them, e.g.
|
||
`UsetuparchZ,3375808,19265`).
|
||
- The converter delta-relocates the 8 growing tables (header 40→80B,
|
||
waypoint 16→24, waygroup 12→24, PathRecord 8→16, AIListRecord 8→16,
|
||
PadRecord 44→56, BoundPadRecord 68→80, pname 4→8) and bswaps the s32
|
||
ID/offset fields — same technique as D80/D81, generalized to many
|
||
interleaved regions.
|
||
- `src/bondtypes.h` `SetupIntroCamera`: `lang1c`/`lang20`/`prev`
|
||
ROM-serialized pointer-shaped fields kept narrow (`u32`) under
|
||
`#ifdef PORT` so `sizeof` stays 40 and the fixed-stride intro-record
|
||
walk in `bondview_r.c`/`bondview2.c` still matches the 40-byte file
|
||
records; use sites cast `(char *)(uintptr_t)` at each read. Same class
|
||
as D79/D53.1. **Write-before-read verified**: `bondviewLoadSetupIntroSection`
|
||
(`bondview_r.c:276-300`) writes `prev` (list link) and both `lang_ptr`
|
||
members (via `langGet()`) before the only subsequent reads.
|
||
- **Verified via `-level_09` + `GE_D88=1` probe**: `proplvreset2` now
|
||
walks the entire pads table correctly — plink name strings (`p1988e`,
|
||
`p12295e`, …) and sane BUNKER1 world coordinates for every pad; the
|
||
crash at `prop.c:1306` (`pathwaypoints[i1].padID`) is **gone**.
|
||
|
||
**D88.4 RESOLVED / VERIFIED (2026-08-28, session M).** The `propDefs`
|
||
polymorphic record stream is now converted N64→PC by the offline sidecar.
|
||
`-level_09` no longer crashes in `setupDoor`/`modelLoad` (or anywhere else)
|
||
— BUNKER1 loads its full stage setup and renders **1000+ frames
|
||
continuously** with zero FATAL/EXCEPTION (only the pre-existing unrelated
|
||
`romdataFixupMusicSeqTable` warning).
|
||
|
||
Key facts established:
|
||
- The `propDefs` stream is a flat `s32[]`. Each record's **serialized N64
|
||
word count is fixed per `type` byte across all 21 levels** — verified by
|
||
parsing the getools C sources (`assets/obseg/setup/Usetup*Z.c`), which
|
||
tile every `propDefs` region byte-for-byte against the retail ROM
|
||
(BUNKER1: 206 records, 6477 words, end offset lands exactly on `intro`).
|
||
Table: `PROPDEF_N64_WORDS` in `tools_pc/d88_propdefs.py`.
|
||
- Every pointer member inside a serialized record is **`0` in the file**
|
||
(runtime-populated by the `New_*Record` macros). So there is no
|
||
garbage-pointer / delta-relocation problem in the file — only that on PC
|
||
those slots widen 4→8B, growing records that contain pointers and
|
||
changing the `sizepropdef()` walk stride.
|
||
- The brief's "narrow the trailing pointers `#ifdef PORT`" idea (D88.1
|
||
pattern) is a **poor fit**: `ObjectRecord.prop`/`.model` are mid-struct,
|
||
and `ObjectRecord`/`DoorRecord`/`GuardRecord` are used in ~350 runtime
|
||
sites — narrowing would touch core gameplay code broadly.
|
||
- The `#if 1` branch of `sizepropdef()` was **already N64-correct for every
|
||
type except `OBJ_COPY_ITEM`** (returned 1, real serialized size 3).
|
||
|
||
Fix (chosen: **converter grows records to native PC layout**, no struct
|
||
changes):
|
||
1. `tools_pc/d88_propdefs.py` — rewrites each record to its native PC
|
||
struct size: header word (`u16 extrascale`+`u8 state`+`u8 type`) and
|
||
`_mkword` half-pairs byte-swapped independently, scalars `bswap32`'d,
|
||
pointer slots widened to 8 zero bytes (8-aligned), runtime areas
|
||
zero-filled. Per-type PC size = `PROPDEF_PC_BYTES`, sourced from the
|
||
compiler-verified `tools_pc/d88_layoutprobe.c` (`sizeof`/`offsetof`
|
||
against the real port include chain).
|
||
2. `tools_pc/d88_emit.py` — feeds the converted stream + its growth into
|
||
the cumulative delta so `intro` and every later sub-table shift.
|
||
3. `loadobjectmodel.c` `sizepropdef()` — `#ifdef PORT` branch returns
|
||
`PROPDEF_PC_BYTES/4` so the in-place walk (`prop.c`,
|
||
`loadobjectmodel.c`, `objective_status.c`) matches the emitted stride.
|
||
|
||
`intro` conversion was already correct (blanket per-word `bswap32`; the
|
||
type discriminant is a full `s32`). **Not yet done:** `PROPDEF_PC_BYTES`
|
||
for `VEHICHLE`/`AIRCRAFT`/`TANK`/`AMMO`/`DEPOSIT_IN_ROOM` are placeholder
|
||
guesses (not used by BUNKER1) — probe them before those levels load.
|
||
|
||
**D88.5 (WATCH — stan tile name lookups all miss during pad setup).**
|
||
With the `GE_D88` probe on, every `stanMatchTileName` call from
|
||
`proplvreset2`'s pad loop walks the full ~2599-tile room and returns "no
|
||
match". May be benign at load (a NULL `pad->stan` is tolerated by the
|
||
reset path), but the stan-id derivation (`stanIdHi`/`stanIdLo` from the
|
||
pad `plink` name) could be another victim of a residual endian/width bug.
|
||
Re-check after D88.4; do not treat as resolved just because it doesn't
|
||
crash.
|
||
|
||
**D88.5 (RESOLVED — stan tile-name byte-swap in the converter).** With the
|
||
`GE_D88` probe on, every `stanMatchTileName` call during pad setup missed
|
||
(0 matches / 276 misses). Root cause: `stanMatchTileName` reads a tile's
|
||
packed name id through a `StandTilePoint` alias — `(u16)tile->x ==
|
||
stanIdHi` and `*((u8 *)&tile->y) == stanIdLo`. D78 left the 4-byte
|
||
id/room word as a verbatim byte array on the premise "`id` is provably
|
||
dead", which missed this aliased *scalar* read: on little-endian PC the
|
||
`(u16)` load of the big-endian id-hi bytes comes back byte-swapped.
|
||
Fix (converter, `tools_pc/d69_emit.py` stan path): swap bytes 0–1 of the
|
||
id/room word; byte 2 (`stanIdLo`) and byte 3 (`room`, read as
|
||
`tile->room`) stay put. Verified 273/273 name matches after the fix; pads
|
||
now resolve real stan tiles. Needs sidecar regen. Committed.
|
||
|
||
**D88.6 (RESOLVED — intro CAMERA `lang1c` is a `u16` pair).**
|
||
`SetupIntroCamera.lang1c` is `union { u16 lang_index[2]; u32 lang_ptr; }`
|
||
and `bondview_r.c:295` reads `lang1c.lang_index[1]`. `d88_emit.py`'s intro
|
||
converter `bswap32`'d the whole word, which swaps element 0 with element 1
|
||
— the consumer then read the wrong language-slot id, indexed `g_LangBanks`
|
||
out of range and crashed in `langGet` (`language.c:421`) on a NULL bank.
|
||
Fix: byte-swap each `u16` of `lang1c` in place; `lang20` (a real `s32`)
|
||
still gets `bswap32`. Needs `d88_emit.py --regen`. Committed.
|
||
|
||
**D89 (RESOLVED — two crashes between stage-load and first frame).**
|
||
(a) `init_path_table_links` (`initpathtablelinks.c:144`):
|
||
`validationGroupCursors[-3]` is a decomp artifact — a constant negative
|
||
index into a 1-element stack array standing in for a plain cursor local.
|
||
GCC proves it OOB and emits a trap → SIGILL on PC. Fixed under `#ifdef
|
||
PORT` by pointing the name 3 elements into a real 4-element backing buffer
|
||
(identical `[-3]` expressions, now in bounds); N64 build keeps the plain
|
||
array. (b) `sub_GAME_7F0B0914` (`walkTilesBetweenPoints`): callers like
|
||
`domakedefaultobj` pass `&pad->stan`, legitimately NULL when a pad's stan
|
||
name doesn't resolve. On N64 the walk reads ~0 for `pointCount` and
|
||
returns TRUE via the `crossings==0` early-out; on PC the near-NULL read
|
||
faults. Guarded `*tileStack == NULL → return TRUE`. Committed.
|
||
|
||
**Session-M-2 infra fixes (committed).** Two port-layer gaps that made
|
||
`-level_09` a no-op were fixed: (1) `osPiReadIo` was stubbed to 0 so the
|
||
cartridge-token read always yielded an empty string — N64 debug switches
|
||
were silently ignored and only attract-mode demo playback could reach a
|
||
level. Now synthesised from `argv[1..]` (`sysGetTokenString` in
|
||
`system.c`, served from the 0xFFB000 range in `libultra.c`). (2)
|
||
`pccgPatchTable`/`pcmodelsPatchTable` were called lazily from the first
|
||
model load; a direct `-level_XX` boot loads a stage first and
|
||
`load_bg_file` read raw big-endian ROM. Moved the one-shot calls to the
|
||
end of `obInit()`. NOTE: a bare `-level_09` still needs the per-level
|
||
memory args too (`-ml0 -me0 -mgfx100 -mvtx50 -mt700 -ma150` for BUNKER1,
|
||
from `boss.c`'s `memallocstringtable`) — the `-level_` branch skips the
|
||
default `-m*` string. TODO: auto-inject from `memallocstringtable` in the
|
||
port so bare `-level_XX` works.
|
||
|
||
**D90 (RESOLVED — `stanTileDistanceRelated` zero-fill overran the caller's
|
||
stack).** Symptom: after D88.5/D88.6/D89, `-level_09` loaded BUNKER1 and
|
||
faulted in `stanIsSpecialBit1Set` (`stan.c:2364`, `arg0 == NULL`) on the
|
||
first player collision tick (`bondviewCalcUpdatePlayerCollision` →
|
||
`bondviewTrySimpleMovePlayerCollision` → `bondviewTryMoveToStan` →
|
||
`stanTileDistanceRelated` → `sub_GAME_7F0B1DDC` → `callbackA(NULL, …)`).
|
||
Root cause was NOT the pad→stan resolution (GE_D90 probe confirmed all
|
||
159 BUNKER1 pad names resolve and the player spawn pad #102 has a valid
|
||
stan). It was `stanTileDistanceRelated`'s N64 "HACK" init loop: it
|
||
zero-fills `((s32*)arg4)[0..19]` — **80 bytes** — while
|
||
`sizeof(StandTileLocusCallbackRecord)` is 16B. On N64 the 64-byte overrun
|
||
landed in adjacent stack scratch; on PC the frame layout differs (and
|
||
locals are pointer-widened), so the fill zeroed `bondviewTryMoveToStan`'s
|
||
live `sp90` (= `field_488.current_tile_ptr`) right before it was passed
|
||
as `&sp90` to the walk. Fix: `#ifdef PORT` clears exactly the 4 record
|
||
fields (every consumer only uses those four — cf. `sub_GAME_7F0B21B0`).
|
||
Committed. GE_D90 probes left in place (env-gated).
|
||
|
||
**D91 (RESOLVED — bg portal-descend truncated an array-element address).**
|
||
`sub_GAME_7F0B7F84` (both variants, `bg.c`): `i = (s32) &D_800442FC[
|
||
portalnum];` then later `*((u8 *) i) = depth;`. The `(s32)` cast drops
|
||
the top 32 bits of the array address on PC, so the byte store faulted
|
||
during portal occlusion culling. `i` is only used as an `if (i);` no-op
|
||
after the cast, so under PORT keep it a plain value and write
|
||
`D_800442FC[portalnum] = (u8) depth;` directly. Committed.
|
||
|
||
**D92 (RESOLVED — two truncated pointers on the chr/AI spawn path).**
|
||
(a) `chrAllocate`'s 5th parameter was declared `s32` but both call sites
|
||
pass `ailistFindById()`'s `AIRecord *`. The 64-bit pointer was truncated
|
||
binding to `s32 arg4`, then forwarded to
|
||
`init_GUARDdata_with_set_values`'s `AIListRecord *arg5` → `chr->ailist`
|
||
held e.g. `0x40127640` instead of `0x140127640`, and `ai()` faulted on
|
||
`(AiListp + Offset)->cmd` at the first AI tick. Param widened to
|
||
`AIListRecord *` under `#ifdef PORT` (`chr.c` + `chr.h`). (b)
|
||
`Model.unka0` is a 32-bit field that on N64 holds a function pointer —
|
||
always `sub_GAME_7F01FC10` (`chr.c:1618` stores `(s32)sub_GAME_7F01FC10`,
|
||
the only value the setter ever gets). `model.c` `subcalcpos` calls it
|
||
back through a cast → truncated jump target. Widening the field would
|
||
shift the rest of `Model`, so under PORT the setter stores a nonzero flag
|
||
and `subcalcpos` calls `sub_GAME_7F01FC10` directly. Committed.
|
||
|
||
**D85 revisited (OPEN — now the live blocker on `-level_09`).** With
|
||
D90–D92 in, BUNKER1 loads and ticks all the way to the **first render**,
|
||
which immediately hits `sysFatalError("Bad size for RGBA texture in tile
|
||
0: 00")` (`port/fast3d/gfx_pc.cpp:967`) — a `G_SETTILE` with `fmt=RGBA
|
||
siz=0` (invalid). This is the room-GDL-decodes-to-garbage problem from
|
||
D85 (the raw N64 per-room DL/point-index blob is left unconverted, D80),
|
||
surfacing in the texture path this time rather than
|
||
`bgBuildRoomVtxBounds`. Attract mode's "~2100 frames" never hit this
|
||
because those frames were HUD/menu screens, not room geometry. This is
|
||
the render-milestone work: decode what `texLoadFromGdl` does with
|
||
room-specific opcodes / CC-RM-LUT markers, and verify `csize_*_DL_binary`
|
||
sizing (see the original D85 entry above). Interim option if a fresh
|
||
session wants to keep moving past it: soften the four `sysFatalError`
|
||
"Bad size…" guards in `gfx_pc.cpp` to skip-with-warning (same
|
||
safety-net philosophy as the D85 `bgBuildRoomVtxBounds` bounds check) so
|
||
the frame renders with placeholder textures instead of aborting.
|
||
|
||
**D93 (RESOLVED — null-room (room 0) NULL-deref sat in front of the D85
|
||
texture wall).** Committed `164d7f99`. On `-level_09` the visible-room
|
||
draw list includes room 0, which has no geometry (`csize_*_DL_binary ==
|
||
0`), so `bgLoadRoomModelData` never assigns `ptr_expanded_mapping_info`
|
||
and it stays NULL. Two consumers then walk it unconditionally:
|
||
`bgApplyDynamicCCRMLUT` (called `start=NULL, end=NULL` → the `end==NULL`
|
||
sentinel-scan branch derefs address 0) and `bgBuildRoomVtxBounds`
|
||
(`while (gdl[i].dma.cmd != G_ENDDL)` on `gdl=NULL`). On N64 address 0 is
|
||
readable RDRAM so both walks wander harmlessly; PC page 0 is unmapped.
|
||
Fixed with narrow `#ifdef PORT` NULL guards (same safety-net class as the
|
||
D85 `bgBuildRoomVtxBounds` vtx-bounds check). `-level_09` now reaches the
|
||
documented D85 `sysFatalError("Bad size for RGBA texture in tile 0: 00")`.
|
||
|
||
**D85 root cause CONFIRMED (was "not yet root-caused").** The
|
||
decompressed per-room primary/secondary DL blob is **raw N64 data with
|
||
8-byte big-endian `Gfx` slots**, but every PORT-patched consumer
|
||
(`texCopyGdls`/`texLoadFromGdl` in `tex.c`, `bgApplyDynamicCCRMLUT`,
|
||
`bgBuildRoomVtxBounds`) was patched under `#ifdef PORT` to assume 16-byte
|
||
little-endian PC `Gfx` slots — so they stride at 2× the real rate and
|
||
read the middle of each N64 command pair as an opcode (the observed
|
||
`cmd=00,01,02,52…` garbage), and even at the right stride the `w0/w1`
|
||
words are unswapped BE. The model-GDL path is immune only because its
|
||
offline sidecar (`tools_pc/d43_*.py`) pre-widens + byteswaps every GDL;
|
||
D80 explicitly left the per-room DL/point-index blob unconverted.
|
||
**Fix chosen: runtime fixup**, not a sidecar — the blob is RZ-compressed
|
||
inside the bg `.seg` and delta-sized (its size is only the offset delta
|
||
between consecutive rooms' `pPriMappingBin`), so an offline widen would
|
||
force rewriting the whole bg-header offset table / recompression. The
|
||
transform is purely mechanical (8→16 byte widen + `bswap32` each word);
|
||
room GDLs need no pointer remapping (`G_VTX` seg addresses are resolved
|
||
at runtime via `SEGMENT_OFFSET(...) + (u32)vertices`, textures via the
|
||
`G_NOOP`+`texnum` marker). Implemented in `bgLoadRoomPrimaryGdl` /
|
||
`bgLoadRoomSecondaryGdl` between `bgDecompress` and `texCopyGdls`, plus a
|
||
`Vtx` short-field bswap in `bgLoadRoomVtxData` (positions/uv are BE; the
|
||
4 rgba `u8`s are fine). Watch the alloc budget — the PC blob is 2× the
|
||
decompressed size. Also fix the pre-existing bug at `bg.c:2448`:
|
||
`texLoadFromGdl((Gfx *)scratch, (Gfx *)expanded_size, ...)` casts the
|
||
size arg to a pointer.
|
||
|
||
**D85 widen fix IMPLEMENTED (committed `ea8a37a0`/`c732425d` = master
|
||
`c732425d`).** `bgWidenRoomGdl()` — in-place back-to-front 8→16 widen +
|
||
`bswap32` per word — runs in `bgLoadRoomPrimaryGdl`/`bgLoadRoomSecondaryGdl`
|
||
right after `bgDecompress`; the doubled size flows into `texCopyGdls`/
|
||
`texLoadFromGdl`/`usize_*_DL_binary`. `bgSwapRoomVtx()` `bswap16`s the 6
|
||
leading `u16`s of each `Vtx` in `bgLoadRoomVtxData`. The `bg.c:2448`
|
||
size-cast-to-pointer bug is fixed under `#ifdef PORT`. Verified: room
|
||
GDLs now decode to real GBI (`GE_D69BB` dump: `E7` RDPPIPESYNC / `BA`
|
||
SETOTHERMODE_H / `B9` SETOTHERMODE_L / `FC` SETCOMBINE / `BB` TEXTURE /
|
||
`B7` SETGEOMETRYMODE), G_NOOP markers decode to sane texnums. Alloc
|
||
budget needs no change (first-load block is `memaGetLongestFree`-sized,
|
||
then shrunk from post-widen `used`).
|
||
|
||
**D85 texture pool FIXED (committed `2b3ee6e7` → master `6f0208d6`).**
|
||
`ptr_texture_alloc_start` is declared `struct texpool *` but every use
|
||
takes its address and treats the storage *as* a `struct texpool`
|
||
(`texInitPool` writes 4 members through `&…`, `texLoad`/`texFindInPool`
|
||
read them back). N64: 4-byte members, 16-byte struct, works by layout
|
||
luck. PC: 32-byte struct (4 widened pointers) → `texInitPool` smashes
|
||
24 bytes of trailing BSS and `->leftpos`/`->rightpos` read back garbage
|
||
→ stage pool looks permanently exhausted (`texFreeBytesInBuffer() < 0`)
|
||
→ every room `texLoad` bails → `texFindInPool` NULL for every texnum →
|
||
`Bad size for RGBA texture` abort. Fix: `#ifdef PORT` define it as a
|
||
real `struct texpool` (`image.c:14`, `image.h:98`); all call sites
|
||
already `&…` it. Verified: pool fills, ~630 room textures resolve,
|
||
FATAL gone. **Latent (not fixed):** `sizeof(struct tex)` is 24 on PC vs
|
||
16 N64 (widened `u8 *data` + bitfield align); the pool is a dual stack
|
||
(pixels up from `start`, `struct tex` headers down from `end`), so the
|
||
stacks collide ~`bytes/3` early. BUNKER1 `-mt700` doesn't hit it;
|
||
texture-heavier levels will need a separate header allocation or a
|
||
per-level `-mt` bump. (Enlarging the pool alloc starves `MEMPOOL_STAGE`
|
||
and hangs `mempAllocBytesInBank`'s OOM `while(1)` — don't.)
|
||
|
||
**D85 texture wall CLEARED (session M-3, verified).** With sidecars
|
||
present and `493c9838`+`6f0208d6` in, `-level_09` **no longer hits
|
||
`Bad size for RGBA texture`** — BUNKER1 renders multiple full frames
|
||
(`GE_D85GDL` probe: `g_BgNumberOfRoomsDrawn=5`, roomids 9/10/11/15/17,
|
||
`b_min/b_max` 0/2, frame GDL advances ~3 KB/frame cleanly). The room-GDL
|
||
stream-decode + texture-pool layers of D85 are done. What remains is a
|
||
cluster of **non-deterministic** crashes newly reachable now that the
|
||
render loop + guard AI actually run in-level (4-run sample: 3 distinct
|
||
fault sites). Treat as a fresh crash-chain, not D85:
|
||
|
||
1. **`bgScissorCurrentPlayerView` frame-GDL overrun** (`bg.c:1355`,
|
||
fault `0x70800000` = 1 byte past the 8 MB emulated DRAM). Hits ~1/4
|
||
runs. The frame GDL write pointer runs off the end — either the PC
|
||
frame-DL buffer is undersized for real room geometry, or a room GDL
|
||
without a clean `G_ENDDL` drives a runaway append somewhere upstream
|
||
(`sub_GAME_7F0B3C8C` double loop over `chrpropsRenderPass` /
|
||
`bgRenderRoomPrimary`). NOTE: an *earlier* "deterministic overrun"
|
||
reading was a red herring — it was `data/` sidecars missing (see the
|
||
`data/` deletion note below), which faults in `load_bg_file:847` on
|
||
raw BE bg-header bytes.
|
||
2. **`chrlvInitActAttack` bad pointer** (`chraction.c:1316`, fault
|
||
`~0x40123350`). Hits ~2/4 runs — a guard starting an attack derefs a
|
||
truncated/garbage pointer (`0x401xxxxx` looks like a 64-bit pointer
|
||
with the high word lost, cf. D86/D92 class).
|
||
3. **`gfx_sp_matrix` unrelocated segment addr `0x90000000`**
|
||
(`gfx_pc.cpp:1077` via `gfx_run_dl`). Hits ~1/4 runs — a room-GDL
|
||
`gsSPMatrix` w1 carries N64 segment 9 unresolved. Room-GDL matrix
|
||
arg relocation still needed (the widen byte-swaps the words but does
|
||
not remap seg addresses; `G_VTX` seg-addrs are resolved elsewhere via
|
||
`SEGMENT_OFFSET`, but `gsSPMatrix` is not).
|
||
4. **`bgTestRayIntersectionInRoom`** (`bg.c:3302` `((u32*)gdl)[1]`,
|
||
`:3383/3388/3521/3526` `*(u8*)gdl`) still reads opcodes/w1 N64-style;
|
||
post-widen the opcode is byte 3 and w1 is `((u32*)gdl)[2]`. Operated
|
||
on garbage before (so "worked"); needs PORT accessors. Hitscan, not
|
||
render — lower priority.
|
||
3. **Frame-GDL buffer overrun in `chrpropsRenderPass`**
|
||
(`bgScissorCurrentPlayerViewDefault`, `chrprop.c:569` → `bg.c:1355`,
|
||
write fault ~`0x70800000`). Timing-dependent, masked behind #1.
|
||
Prop/character render path (D75-adjacent), newly reachable.
|
||
|
||
**D94 (RESOLVED — truncating `(s32)` pointer cast in `chrlvInitActAttack`).**
|
||
Committed `63204a27`. `chraction.c:1221`/`1231` compute the firing-anim
|
||
table entry as `(s32)arg1[anim_index]->table + (s32)(idx*sizeof(...))` —
|
||
`table` is `weapon_firing_animation_table (*)[]`, and the `(s32)` cast
|
||
truncates the 64-bit pointer, then the cast back zero-extends, dropping
|
||
the `0x1_00000000` module-base bit → `panim_float ≈ 0x4012xxxx` →
|
||
`panim_float->anim.anim` faults there when a guard starts an attack
|
||
in-level. Replaced both with plain array indexing (`&(*table)[idx]`) under
|
||
`#ifdef PORT` — the code's own comments say that's the intent.
|
||
|
||
**D95 (OPEN — the `-mgfx` master-DL buffer is half-capacity on PC; the
|
||
naive fix OOMs `MEMPOOL_STAGE`).** `dyn.c:56` sizes `g_GfxBuffers` from
|
||
`-mgfx` (a byte budget from `boss.c`'s per-level `memallocstringtable`,
|
||
sized for N64 8-byte `Gfx`). On x86-64 a `Gfx` is 16 bytes, so the master
|
||
display list holds **half** the commands for the same budget. Every
|
||
render fn appends with a bare `gdl++` and **no bounds check**, so once
|
||
BUNKER1 emits real room geometry the list overruns `g_GfxBuffers[1]`/`[2]`,
|
||
runs off the stage mempool, and faults writing a GBI command at the top
|
||
of the 8 MB emulated DRAM (`0x70800000`) — **non-deterministically**, and
|
||
scribbling GBI across DRAM on the way (the run-to-run `Unknown GBI opcode
|
||
0x3f/0xffffb9` and `gfx_sp_matrix` seg-9 `0x90000000` faults were all
|
||
downstream corruption from this one overrun). Doubling the `g_GfxBuffers`
|
||
allocation (`* sizeof(Gfx)/8`) under PORT **stops the overrun** (verified:
|
||
`-level_09` then runs 90 s+ / 5000+ VI posts, no crash) **but** the extra
|
||
~100 KB starves `MEMPOOL_STAGE` so `zbufAllocate` →
|
||
`mempAllocBytesInBank` (`memp.c:204`) spins in its OOM `while(1)` and no
|
||
frame ever renders (`kernel heartbeat: frames=0`; mainThread stuck in
|
||
`lvlRender`→`viClearZBufCurrentPlayer`→`zbufInit`). Committed as
|
||
`70784f80`, reverted `2a506284`, **re-applied `f35eba91`** alongside the
|
||
mempool-ceiling fix below.
|
||
|
||
**`malloc`-the-gfx-buffer is NOT viable** — `osVirtualToPhysical` /
|
||
`OS_K0_TO_PHYSICAL` in the port `(u32)`-truncate and subtract
|
||
`0x70000000`, so any pointer that flows through them (sub-DL branches
|
||
built in the gfx buffer, etc.) must live in the `0x70000000` DRAM window.
|
||
|
||
**Mempool ceiling fix (`933ba52b`, kept).** `port/src/n64stubs.c`'s
|
||
`tlbmanageGetTlbAllocatedBlock` returned the N64-fidelity ceiling
|
||
`0x702F4400`, leaving ~5 MB of live mapped DRAM unused below the 8 MB top
|
||
(only `animations_frame_buffer` @ `0x707FFD30` up there). Raised to
|
||
`0x70700000`; the ~4 MB gain goes to `MEMPOOL_STAGE` (`boss.c:218`). This
|
||
+ the re-applied 2x `g_GfxBuffers` removes the OOM hang: `-level_09` now
|
||
**renders ~5 frames** (`frame N rendered` logs, VI posts climbing).
|
||
|
||
The "runaway GDL append at ~frame 5" that D95 chased turned out to be
|
||
**two memory-corruption bugs**, both now fixed:
|
||
|
||
**D96 (`d86ec483`) — prop room-list stack overflow.**
|
||
`chrpropUpdateRoomList` + helpers build room lists of up to 7 entries,
|
||
then write `prop->rooms[0..n]` + a `0xff` terminator. `PropRecord.rooms`
|
||
and `chrpropsRenderPass`'s `s32 sp48[…]` local are both
|
||
`PROPRECORD_STAN_ROOM_LEN` = **4**. BUNKER1 patrol guards routinely span
|
||
≥4 rooms (`[1a 13 14 10]`, …) → 4 IDs, no terminator inside the array →
|
||
`chraiGetPropRoomIds`'s `for (i=0; self->rooms[i] != 0xff; i++)` walks
|
||
off the end, overflowing the caller's stack frame → garbage `gdl` →
|
||
GBI write fault. Every *other* `chraiGetPropRoomIds` caller already used
|
||
`s32[8]`. Fix: `PROPRECORD_STAN_ROOM_LEN` → 8 under `#ifdef PORT`
|
||
(`bondconstants.h`), + a defensive bound in `chraiGetPropRoomIds`. N64
|
||
unchanged.
|
||
|
||
**D97 (`2fbcc556`) — `bondviewPlayerTickDamageAndHealth` negative
|
||
`damagetype`.** US build (unlike EU/JP) has no low clamp;
|
||
`damagetype = (s32)(health*8)` goes negative on a lethal hit → OOB
|
||
`g_DamageTypes[]` read → segfault when a guard shoots Bond (~frame 5).
|
||
Extended the EU/JP clamp to PORT.
|
||
|
||
**D98 (`000ed6af`) — `initBONDdataforPlayer` under-allocates the player
|
||
struct.** It `mempAllocBytesInBank`s a hardcoded `0x2A80` (N64
|
||
`sizeof(struct player)`, `0x2A70` EU). The PC struct is much larger
|
||
(pointer fields widened 4→8). The player block sits directly below
|
||
`g_GfxBuffers[0]` in `MEMPOOL_STAGE`, so writes past ~offset `0x2A08`
|
||
(`bondviewRenderDebugBondView`'s `g_CurrentPlayer->field_2A08 = ft4`,
|
||
run every frame since `debug_render_raster` defaults to `DEB_BOND_VIEW`)
|
||
scribbled the zbuf-clear `gsDPSetRenderMode`'s `w1` onto master-DL slot
|
||
11 → `Unknown GBI opcode 0xffffb9`. Fix: allocate
|
||
`(sizeof(struct player) + 0xF) & ~0xF` under `#ifdef PORT`.
|
||
|
||
**D99 (`253caa23`) — `modelTickAnim` garbage function-pointer call,
|
||
FIXED.** `struct Model.animflipfunc` (`bondtypes.h:1640`, "0x98") is
|
||
`s32` but `modelSetAnimFlipFunction` (`model.c:2840`) stores a `void *` →
|
||
truncated on PC → `((void(*)(void))animflipfunc)()` at `model.c:3534`
|
||
jumped to `0x00010100` (`bheadFlipAnimation` at module+0x10100). It is
|
||
*only* ever set to `bheadFlipAnimation`, for `g_CurrentPlayer->model`
|
||
(`initBondDATAdefaults.c:198`, `bondhead.c:430`). Fix (D92/`unka0`
|
||
pattern): under `#ifdef PORT` the field is a bool flag and
|
||
`modelTickAnim` calls `bheadFlipAnimation()` directly. `-level_09` now
|
||
reaches VI post ~601 (was ~421).
|
||
|
||
**D100 (`8eaad547`, PARTIAL) — `struct player.model` is an inline
|
||
`struct Model`, not a pointer.** Every use is `&g_CurrentPlayer->model`
|
||
passed to a `modelXXX(struct Model *)` fn. The decomp splits it as
|
||
`Model *model;` + ~45 `s32 field_59C..field_650` (≈ N64
|
||
`sizeof(struct Model)` ~0xB8). PC `struct Model` is ~0x2A8, so
|
||
`animInit(&model, …)` overran into `field_654` (the gait RW-data pool
|
||
`animInit` was *also* handed), `bondheadmatrices`, and the viewport
|
||
fields → garbage `model->datas` → the bit-32 fault. Fix (PORT only):
|
||
`model` becomes an inline `struct Model`; a dedicated `u32
|
||
gaitRwData[256]` at the end of `struct player` replaces the `&field_654`
|
||
gait pool; `initBondDATAdefaults.c` points `animInit` there. `sizeof`
|
||
grows ~0x2A0 — D98's `sizeof(struct player)` alloc already covers it, and
|
||
`field_59C..field_650` are grep-verified dead (only `field_654` was
|
||
used). **⚠️ LANDMINE this exposes:** `struct player` has raw
|
||
hard-coded-offset accessors above 0x594 that are NOT PORT-adjusted and
|
||
are now further off — `gunfire.c:4934-4945` `THROWMTX/THROWPOS` at
|
||
`g_CurrentPlayer + handoffset + 0xAD8`, used for grenade/knife throwing.
|
||
Those were *already* PC-wrong (PC struct ≠ N64 before D100 too); D100
|
||
doesn't regress a working path, but a real `struct player` PC-offset
|
||
pass is owed. (`bondview2.c:3165` `+0x230` watch model and `+0x2ec` are
|
||
below 0x594 → unaffected.)
|
||
|
||
**D101 (`b2234f82`) — `sub_GAME_7F06DB5C` `(s32)` pointer stash, FIXED.**
|
||
The anim2-blend variant of `modelBuildGroupMatrices` did
|
||
`sp1C = (s32)arg2->Parent` then `modelFindNodeMtx(arg1, (ModelNode*)sp1C,
|
||
0)` — the exact 32-bit-pointer idiom the sibling was already cleaned up
|
||
for. `(s32)` drops the `0x140000000` base of a static `ModelNode` →
|
||
`modelFindNodeMtxIndex` deref `0x4012c9c0`. Also stashed `&sp48[sp54]`
|
||
(a `RenderPosView*`) the same way and mis-called `g_ModelJointPositionedFunc`
|
||
with a 3-arg cast. Under `#ifdef PORT`, full-width pointer work + the real
|
||
2-arg callback (mirrors `modelBuildGroupMatrices`).
|
||
|
||
**D102 (`1b078f6d`) — 1P weapon `Model` / RW-pool `struct hand` overlap,
|
||
FIXED.** `gunfire.c` punned the weapon `Model` onto `hand->field_B68` and
|
||
its RW-data pool onto `hand->modeldatas` (a fixed run at `field_B68 +
|
||
0x20`). N64 `sizeof(struct Model)` ~0xBC keeps `modelInit`'s header
|
||
writes clear of the pool; PC `struct Model` is 0xE8, so `modelInit`
|
||
writes `objinst->datas` (0x20) **directly onto `&hand->modeldatas`** —
|
||
the field and pool base alias, and the first `modelInitRwData` record
|
||
write clobbers `datas` (bit 32 set) → the `modelInitRwData` fault
|
||
(`0x1_70076514`). `objinst->obj` (0x10) lands on `hand->mtxlist`
|
||
likewise. Fix: `struct hand.weaponModel` (inline `struct Model`) +
|
||
`weaponRwPool[192]` under `#ifdef PORT`, routed through
|
||
`HAND_WEAPON_MODEL`/`HAND_WEAPON_RWPOOL` macros; set
|
||
`weaponModel.render_pos` explicitly (N64 aliased `hand->mtxlist`). Same
|
||
class as D53.2 (watch) / D100 (player gait model). The `d43` record
|
||
layout was ruled out — `GE_D51` proved it was `Objinst->datas` *field*
|
||
corruption, not a garbage `RwDataIndex` (`idx` was 0/1, valid).
|
||
|
||
**RENDER CHAIN CLEAR (session M-3).** With D93–D102 in, `-level_09` boots
|
||
BUNKER1 and **renders continuously with no fault** (60 s+, 3500+ VI
|
||
posts, full framerate; attract mode also clean). ~46 % non-clear pixels,
|
||
content biased to the lower half of the screen. The
|
||
stage-load→first-frame→in-level crash chain (the blocker since D69) is
|
||
**resolved**. Remaining: **D75** — 3D-model rendering quality
|
||
(animated/skeletal character models), a separate track; and the
|
||
`struct player` / `struct hand` raw-hardcoded-offset landmine above
|
||
(grenade/knife throw). `struct tex` headers 24 B vs 16 B on PC is a
|
||
separate open pool-pressure item — a PC memory-budget pass is owed.
|
||
|
||
**Still open, separate:** `struct tex` headers are 24 B vs 16 B on PC
|
||
(texpool-triage) — pool-pressure; a real PC memory-budget pass should
|
||
cover it.
|
||
|
||
**D103 (session M-4) — native-resolution height, FIXED.**
|
||
`port/src/libultra.c` `osViSetMode` hard-coded the fast3d native
|
||
resolution to the *visible scanline count* (`pal ? 400 : 480`) instead of
|
||
the CFB space GE authors its GBI in. fast3d's `SCREEN_WIDTH`/
|
||
`SCREEN_HEIGHT` (`gfx_pc.h`) alias `gfx_current_native_viewport`, and
|
||
`RATIO_X/RATIO_Y` (`gfx_pc.cpp:53-54`) scale the game's 320×240-space
|
||
`gSPViewport`/`G_SETSCISSOR`/2D-texrect coords onto the 640×480 window.
|
||
With height 480 the ratios were asymmetric — `RATIO_X = 640/320 = 2.0`
|
||
but `RATIO_Y = 480/480 = 1.0` — so every viewport/scissor came out a
|
||
220 px band (≈45.8 % of the frame → the "~46 % non-clear" figure),
|
||
placed in the upper GL region and shown (post `invert_y`) in the **lower
|
||
half of the screen**. `gfx_current_native_aspect` was likewise wrong
|
||
(320/480 = 0.67). Fix: recover the real CFB height from
|
||
`vm->fldRegs[0].yScale` (GE sets it to `bufy * YSCALE_MAX(0x800) /
|
||
SCREEN_HEIGHT_MAX(480)`, `fr.c:417`) with a `pal ? 272 : 240` fallback;
|
||
`comRegs.width` already carried `bufx` so width was fine. Verified:
|
||
`-level_09` `GE_PCDUMP` frames go from ~46 % / lower-band to **91.7 % /
|
||
bbox (0,20)-(639,459)** — the full 10..230 viewport ×2 with correct
|
||
letterbox, HUD ammo counter correctly placed top-right. Purely a
|
||
transform fix — no per-model work.
|
||
|
||
**D104 (session M-4) — investigation, folded into D105.** Chased "flat
|
||
dark-blue fill + HUD, no room geometry" after D103. Ruled out (probes +
|
||
subagent trace): segment 0x0E vtx resolution, room-DL chaining/execution,
|
||
per-room scissor (`GE_D104` shows bbox `(1,10)-(319,230)` full-view,
|
||
`viewleft/top/x/y = 0/10/320/220`), the view-projection matrix
|
||
(`field_10E0` decodes to a sane perspective×lookat — rows ≈ unit-scale
|
||
3×3, translate row `518,-22,430,459`), and the room GDL decode (valid
|
||
F3DEX2, no embedded `G_MTX`). A temporary fast3d tri counter proved
|
||
~160 k room triangles WERE being submitted to GL each frame — yet the
|
||
final image was 90 % exactly `(0,16,65)` (BUNKER1's `env->{Red,Green,
|
||
Blue}` sky-fill from `skyRender`, `sky.c:326`, the 1P no-clouds
|
||
`gDPFillRectangle` path). Geometry drawn, invisible → depth. `GE_D104`
|
||
room-render-pass probe left in `bg.c` (capped, gated).
|
||
|
||
**D105 (session M-4) — depth buffer never cleared, FIXED.**
|
||
`zbufClearCurrentPlayer` (`src/game/viewport.c:89`) clears the N64 Z
|
||
buffer with the classic "point the colour image at the Z buffer and
|
||
`gDPFillRectangle` it with a packed-Z fill colour" idiom. fast3d does
|
||
**not** emulate that: `gfx_dp_fill_rectangle` (`gfx_pc.cpp:2281`) bails
|
||
when `color_image_address == z_buf_address` on the assumption the depth
|
||
clear already happened via `glClear` — but the frame-start clear is
|
||
`clear_framebuffer(true, false)` (`gfx_pc.cpp:2855`), **colour only**.
|
||
fast3d expects the game to emit `G_CLEAR_DEPTH_EXT` (opcode `0x44`,
|
||
`gfx_pc.cpp:2678`) for a real depth clear, and **nothing in GE ever
|
||
emits it** (grep-confirmed). Worse, here the two addresses don't even
|
||
match (`gDPSetDepthImage(z_buffer & ~0x3F)` vs `gDPSetColorImage(
|
||
OS_K0_TO_PHYSICAL(z_buffer))`), so the bail isn't taken and the packed-Z
|
||
fill colour gets scribbled onto the real framebuffer instead. Net: every
|
||
in-level frame renders against a stale/garbage depth buffer, all ~160 k
|
||
room triangles fail the Z test, and only `skyRender`'s background fill
|
||
survives. Fix: `#ifdef PORT` branch in `zbufClearCurrentPlayer` emits a
|
||
bare `G_CLEAR_DEPTH_EXT` Gfx word and returns, skipping the N64 idiom
|
||
(which is a no-op / actively harmful in fast3d anyway). Verified
|
||
`-level_09`: frame goes from 90 % flat sky-colour to **2585 distinct
|
||
colours, sky down to 16 %** — recognisable BUNKER1: textured walls,
|
||
storage racks, floor. 70 s run, crash-free.
|
||
|
||
**D106 (session M-4) — portal near-plane projection garbage culled the
|
||
next room, FIXED.** After D105, many camera angles in BUNKER1 showed a
|
||
large flat sky-fill void where an adjacent room should be visible
|
||
through a doorway. A `GE_D104` probe (per-frame visible-room list) showed
|
||
the portal-visibility BFS returning only 1–3 rooms; a `GE_D106` probe in
|
||
`sub_GAME_7F0B7F84` showed the plane-side metric cull (`bg.c:4135/4144`)
|
||
and `zfar` (10000, fine) were NOT the cause — portals were dying in
|
||
`sub_GAME_7F0B5864`'s screen-space projection. When the player straddles
|
||
a portal plane, `sub_GAME_7F0B5528` emits z==0 near-plane clip points and
|
||
`transform3Dto2DWithZScaling` (`bondview.c:730`) uses `inv_z = -1e20`,
|
||
projecting them to ±1e20-scale coords. On N64 the two clip points
|
||
bracket the view symmetrically → the degenerate-box check (`bg.c:1713`)
|
||
or the downstream `screensize` clamp yields a full-screen box and the
|
||
room is kept. On x86-64 the exact garbage can come back `min > max` on
|
||
only one axis (or non-finite), slipping past `bg.c:1713` → portal
|
||
dropped → room vanishes → sky-fill through the doorway. Fix: `#ifdef
|
||
PORT`, any wildly out-of-range / non-finite projected bound is treated
|
||
as degenerate → full `screensize` (matches the N64 outcome). Verified:
|
||
per-frame visible-room count 1–3 → 2–4; rooms past near doorways (r11
|
||
from r27, etc.) now render. `c_screenleft/top/halfwidth/halfheight/
|
||
recipscalex/recipscaley` were all confirmed correct (0/10/160/110/
|
||
190.53/190.53), so the projection *fields* are fine — this was purely
|
||
the z==0 edge case. `GE_D104` probe (visible-room list) left in `bg.c`,
|
||
gated + capped; `GE_D106` probes removed.
|
||
|
||
**D107 (session M-4) — fast3d sampled an unloaded mip tile for GE's LOD
|
||
textures (blurry surfaces), FIXED.** GE room GDLs emit `G_TL_LOD +
|
||
G_TD_DETAIL` for mip-mapped textures, but `gfx_detail_textures_enabled`
|
||
is false for this port. `gfx_lod_tile_offset` (`gfx_pc.cpp:1258`) then
|
||
ran `rdp.tex_lod ? rdp.tex_detail : i` → returned `tex_detail` (1) for
|
||
every texel → fast3d sampled GE's first mip (render tile 1). GE loads
|
||
the whole mip chain with one `G_LOADBLOCK` to TMEM 0 and points tiles
|
||
1..5 at offsets inside it; fast3d keys `loaded_texture[]` by TMEM addr
|
||
and has no entry for tile 1's offset, so `import_texture` fabricated a
|
||
16×16 from the top-left 256 B of the base image and magnified it across
|
||
the polygon — the "blurry brown/grey ceilings & wall panels" in BUNKER1.
|
||
Fix: with detail textures disabled, `gfx_lod_tile_offset` always returns
|
||
0 (the base render tile — the only correctly-loaded level; matches the
|
||
N64 near-surface look). Verified: BUNKER1 ventilation room
|
||
ceilings/walls/light panels render crisp. Single-level textures (racks,
|
||
room 1) were already fine, unaffected. (Root-caused by a subagent — the
|
||
`gfx_detail_textures_enabled=false` + GE's unconditional `G_TD_DETAIL`
|
||
contradiction; hi-confidence, matches every symptom + the
|
||
room-1-vs-storage-room split.) A residual minor vertical squish from
|
||
`G_LOADBLOCK` size including the mip bytes is noted but not fixed.
|
||
|
||
**Storage-room sky void — root-caused, NOT a visibility bug (session
|
||
M-4).** The large void in rooms 27/28/29 is **closed doors**: portals 25
|
||
and 26 (the storage-area doorways) carry `controlbytes1 & 1`
|
||
(`PORTALFLAG_DISABLED`) at runtime — verified correct: `load_bg_file`
|
||
clears bit 0 for every portal at load (`bg.c:1015`), then
|
||
`bgToggleDataPortalsContrlBytes1Bit1` (`bg.c:5502`) re-sets it for
|
||
doors that start closed. So `sub_GAME_7F0B7F84` correctly refuses to see
|
||
through them (`bg.c:4147`). The bug is that **the door prop/model that
|
||
should fill each closed doorway does not render** — `chrpropsRenderPass`
|
||
(`chrprop.c:482`) *runs* with `n=3..10` props per room (`GE_D96`), but
|
||
the frame GDL barely advances (~7 `Gfx` for 9 props), i.e. props are
|
||
enumerated but emit almost no geometry. This is the prop/character
|
||
model-rendering track (D75(b) + the `struct player`/prop raw-offset
|
||
landmines), not portal/visibility work. Portal visibility itself is
|
||
healthy post-D106 (room 18 → 8 rooms deep; enabled doorways traverse
|
||
correctly). Other residuals: low-frequency stray green/yellow polygons
|
||
(1–2 per room — a degenerate vertex, likely one bad entry per room-vtx
|
||
table via `bgSwapRoomVtx` or the point-index blob length); front-end
|
||
text draws mirrored. Next: prop/door model rendering (unblocks the void
|
||
AND the missing weapon), then D75(b) skeletal models.
|
||
|
||
**D108–D112 (session M-5) — the "props emit no geometry" premise was
|
||
stale; the real bug was a converter byte-swap off-by-one, FIXED.**
|
||
Re-probing `chrpropsRenderPass` / `chrpropRender` / `modelRenderNodeDl`
|
||
(`GE_D96*`) showed props *do* emit complete leaf DLs now (~15k
|
||
`nodeDl` EMITs/run, valid `gdl`/`vtx`/`BaseAddr`; fast3d transforms
|
||
~300–600 prop verts/frame with sane `w`). Two separate residual bugs
|
||
were isolated: (1) in the BUNKER storage area the per-frame visible-room
|
||
count drops to 1–2 while `cameramode` goes 1→0 and `g_RoomLoadBudget`
|
||
200→3 — but the 200→3 is *intentional* N64 behaviour
|
||
(`bgRoomVisibilityRelated`: 0xC8 for intro/swirl cams, 3 for FP) and
|
||
`currentPlayerGetProjectionMatrix()` + `g_CurrentPlayer->screensize`
|
||
were both verified valid, so this is a portal-BFS under-reach, still
|
||
open. (2) **Skeletal characters rendered as a "3D line" / not at all**
|
||
(also visible in attract mode): `drawjointlist` → `subcalcmatrices` →
|
||
`modelUpdateMatrices` → `process_02_position` → `modelBuildGroupMatrices`
|
||
produced `render_pos[]` joint matrices with **sane rotations but garbage
|
||
/ 1e27 / NaN positions**. `basemtx` (`camGetWorldToScreenMtxf()`) was a
|
||
clean orthonormal lookat; joint *rotations* from the anim bitstream were
|
||
fine; but `group->Origin` (`ModelRoData_GroupRecord.Origin`, a
|
||
`coord3d`) read as garbage for every joint whose bytes weren't
|
||
zero — while the adjacent `JointID`/`MatrixIDs` (u16/s16) read fine.
|
||
Root cause: **`put_f32` in `tools_pc/d43_emit.py` had an off-by-one in
|
||
its BE→LE byte reversal** — `buf[o:o+4] = src[doff+4:doff:-1]` yields
|
||
bytes `doff+1..doff+4` (dropping the value's MSB, pulling in one byte of
|
||
the next field) instead of `doff..doff+3`. This corrupted *every* f32
|
||
field in converted model rodata (joint `Origin`s, LOD near/far, BSP
|
||
planes, bounding radii) for every sidecar-loaded model; the compiled-in
|
||
front-end intro models (native-LE C structs, not run through the
|
||
converter) were unaffected, which is why the Rareware logo looked right
|
||
but every in-level character collapsed. Fix: `src[doff:doff+4][::-1]`.
|
||
After regen (`python tools_pc/d43_emit.py ntsc-final`), all 20
|
||
`render_pos` entries of a BUNKER guard are coherent (~±60 units around
|
||
the body), monitor screens draw their content, and character models
|
||
render as recognisable humanoids (still some pose/orientation polish
|
||
owed — a guard appears inverted — a separate matrix-handedness item).
|
||
`d43_emit.py`'s verify pass only checks pointers/opcodes, not float
|
||
values — a float spot-check is worth adding. Probes `GE_D96GATE /
|
||
D96DL / D108 / D109 / D110 / D111 / D112 / GE_TRICNT` were all removed
|
||
after the fix.
|
||
|
||
**D113 (session M-6) — "portal-BFS under-reach / storage-room void" was a
|
||
STALE premise; there is no BFS bug.** A subagent dumped BUNKER1's runtime
|
||
portal table and traced every accept/reject decision of `sub_GAME_7F0B7F84`
|
||
(non-EU/LEFTOVERDEBUG copy, `bg.c:4091`; queue = 5-arg `bgQueuePortalTraversal`
|
||
`bg.c:3843` — confirmed the compiled ones). With the camera in room 29 the
|
||
BFS visits portals 25,26,27,28: portals 25 (r29↔r26) and 26 (r29↔r27)
|
||
carry `PORTALFLAG_DISABLED` (`controlbytes1 & 1`) because door props obj
|
||
153/154 spawn `openPosition==0` → `doorDeactivatePortal`, and nothing
|
||
re-opens them in the fly-through — so they are culled at `bg.c:4138`, a
|
||
site that is **not** `#ifdef PORT`-guarded and behaves identically on N64.
|
||
Result: a stable 3 visible rooms {29,28,25} (D104 probe agrees), which is
|
||
the correct answer for this topology, not "1–2". No s32/pointer
|
||
truncation, no `min>max`/NaN reaching an accept flip, no depth-cap
|
||
(`D_8004489C=0xF`) or visited-set (`>=9`) early-out firing. `sub_GAME_7F0B5864`
|
||
is fine post-D106. **The void = the two closed-door models (obj 153/154)
|
||
filling rooms 26/27's doorways not rendering where they should** —
|
||
`GE_D96` shows room 29's prop pass emits their leaf DLs, so the door
|
||
geometry exists but its world transform puts it outside the doorway,
|
||
leaving the wall hole unoccluded → sky. This is the D114 model-transform
|
||
track, not visibility. No `bg.c` change. Probes reverted.
|
||
|
||
> **M-33 CORRECTION (D168): this finding is retracted as a capture artifact.**
|
||
> Every `GE_PCDUMP` PPM was written upside-down (`gfx_opengl_dump_bound_fbo`
|
||
> did not reverse `glReadPixels` rows). The developer confirms the game renders
|
||
> correctly on real hardware. The "shared fast3d mirror" this and the D116
|
||
> entries chase never existed — the probes kept coming back clean because there
|
||
> was nothing wrong; the analysts were reading inverted screenshots of
|
||
> asymmetric content (guards, text, logo) and calling them "mirrored". The PPM
|
||
> writer is fixed. The genuinely-open item that remains is D75 Bug 2 (front-end
|
||
> animated models absent), which is unrelated. Original text kept below for
|
||
> history.
|
||
|
||
**D114 (session M-6) — character inversion / mispositioned crate:
|
||
OPEN, matrix chain verified sane, residual is a shared fast3d mirror
|
||
(D75 class).** A subagent statically traced the whole joint-matrix chain
|
||
(`chr.c:2564` → `subcalcmatrices` → `modelUpdateMatrices` →
|
||
`process_02_position` `model.c:1662` → `modelBuildGroupMatrices`
|
||
`model.c:1318` → `matrix_4x4_multiply_homogeneous` →
|
||
`bondviewTransformManyPosToViewMatrix` → `matrix_4x4_f32_to_s32`
|
||
`matrixmath.c:495` → `gfx_sp_matrix`) and an env-gated `GE_D114` probe
|
||
(24 joint builds, 3+ BUNKER guards). Findings: `basemtx` from
|
||
`camGetWorldToScreenMtxf()` is a proper orthonormal right-handed lookat
|
||
(+Y up, `up ≈ (-0.09, 0.96, -0.26)`, `right = up × forward`), scaled by
|
||
`bgGetLevelVisibilityScale()` = 0.1 (also applied to the room path —
|
||
expected); joint `render_pos` are coherent and **upright** (head y ≈ 280
|
||
> pelvis ≈ 230 > limb ends ≈ 132); view-space z negative (in front,
|
||
correct). Converter clean: `put_f32` is now the only float writer and is
|
||
correct post-D112; `matrix_4x4_f32_to_s32` is byte-identical to
|
||
`guMtxF2L`; `matrix_4x4_set_lookat` / `guPerspectiveF` /
|
||
`matrix_4x4_multiply_homogeneous` match N64 and are native-LE. So the
|
||
plan's hypotheses (converter axis/sign, lookat handedness, compose
|
||
order, F2L) are all **disproven**. Leading residual: a horizontal (±
|
||
vertical) mirror in the **shared fast3d viewport / MP-matrix path**, not
|
||
the model path — consistent with the open D75 notes ("front-end text
|
||
draws mirrored", "Nintendo logo mispositioned"): a flip is only visible
|
||
on asymmetric content (humanoid, text, logo) and invisible on a boxy
|
||
room. GE bakes a left-handed screen convention into its own transforms
|
||
(`transform3Dto2DCoords`, `bondview.c:726`: `screenX = center −
|
||
x·invz·scale`); if GE's RSP (`rsp/graphics/gmain.s`) applies an X flip
|
||
that `port/fast3d` does not, every model and room is mirrored. Suspects:
|
||
`gfx_calc_and_set_viewport` / `gfx_adjust_viewport_or_scissor`
|
||
(`gfx_pc.cpp:1739/1771`), `MP_matrix` vertex transform
|
||
(`gfx_pc.cpp:1122`). This is a fast3d-correctness gap → write-up, not a
|
||
narrow ABI patch. Also: **rebuild with D112+D115 and eyeball a guard
|
||
first** — the D115 `gunfire.c` fix stops a per-shot 64-byte scribble into
|
||
the inline-Model / bondhead-matrix region that could itself have caused
|
||
"inverted". The crate: static-prop placement comes via a per-prop
|
||
`basemtx` from the Usetup `PropRecord` position/rotation — check
|
||
`d88_emit.py` separately from the character question. Probe reverted;
|
||
re-apply snippet in the M-6 agent report.
|
||
|
||
**D115 (session M-6) — `struct player` / `struct hand` raw-offset audit +
|
||
first fix.** Full survey in `docs/dev/AUDIT-M6-player-offsets.md` (10 offset
|
||
sites; 3 live HIGH, 1 dead const block, 2 MED, 4 already-correct).
|
||
**Fixed:** `gunfire.c:4960-4962` `THROWMTX` / `THROWPOS(k)` / `THROWPREV(k)`
|
||
were raw byte offsets — `(u8*)g_CurrentPlayer + handoffset + 0xAD8/0xB08/
|
||
0xB48` with `handoffset = handnum * sizeof(struct hand)`. Both the stride
|
||
(`sizeof(struct hand)` ≈ 0x968 PC vs 0x3B8 N64, D102) and the base offset
|
||
are N64-sized, so on x86-64 the address lands inside the inline gait
|
||
`Model` / bondhead-matrix region of `struct player`, and
|
||
`matrix_4x4_copy(THROWMTX, …)` scribbles 64 bytes of live render state on
|
||
**every shot from a casing-ejecting weapon** (`sub_GAME_7F068508`,
|
||
solo only). The offsets are exactly
|
||
`hands[handnum].throw_item_pos_related[_prev]` and its translation row, so
|
||
the macros now use those field accessors under `#ifdef PORT` (N64 build
|
||
keeps the byte-offset macros unchanged). **Still open (MED):** #5 — the
|
||
D102 weapon-model `render_pos` is pointed at a `dynAllocate`'d transient
|
||
arena that on N64 aliased the persistent `hand->mtxlist`; likely the
|
||
"1P weapon model doesn't draw" cause, may fold into D114. #6 — the D56
|
||
watch-preview Model pool (`watchRwPool[0xC8]`) is N64-sized and the
|
||
inline Model overruns live watch fields. See the audit doc for the full
|
||
table and recommended fix order.
|
||
|
||
> **M-33 CORRECTION (D168): retracted as a capture artifact — see the D114
|
||
> correction above and D168.** "Every stage probed is clean, yet the output is
|
||
> mirrored" was the tell: the output was not mirrored, it was upside-down (the
|
||
> PPM writer never reversed `glReadPixels` rows). No `GE_D116` S-swap / U-flip
|
||
> was ever warranted. Probe scaffolding (`GE_D116` in `textrelated.c` /
|
||
> `gfx_pc.cpp`) can be stripped. Original text kept below for history.
|
||
|
||
**D116 (session M-7) — the HUD/menu text mirror (D75 class) is a
|
||
per-glyph TEXTURE-space flip, NOT a screen/framebuffer/matrix mirror.**
|
||
Rebuilt at D115 (`14b6b432`), build green, captured `-level_09` frames
|
||
(`GE_PCDUMP="120-600:60"`, `ppm/frame_0003*.ppm`). Findings:
|
||
- The "OBJECTIVE C: FAILED" flash (proportional font,
|
||
`textrelated.c:textRenderGlyph` → per-glyph `gDPLoadTextureBlock`
|
||
`G_IM_FMT_I/G_IM_SIZ_8b` + plain `gSPTextureRectangle`, dsdx `0x400`)
|
||
renders with **string order preserved (O first, D last) but every
|
||
glyph individually horizontally mirrored**. That signature is a
|
||
texture-S reversal per glyph, not a block/screen flip.
|
||
- The HUD ammo digits ("83", top-right, upright and correct) use a
|
||
different font path and are **not** mirrored → the bug is specific to
|
||
the proportional-font `textRenderGlyph` load/sample path, not global.
|
||
- Static trace found nothing: `gfx_dp_texture_rectangle` / `gfx_draw_rectangle`
|
||
(`gfx_pc.cpp:2161/2086`) assign `uls`→left corner, `lrs`→right corner
|
||
with positive dsdx; `import_texture_i8` (`gfx_pc.cpp:810`) uploads
|
||
linearly; viewport/scissor paths have no X negation; GE's camera
|
||
(`fr.c:694` `guPerspectiveF` + `matrix_4x4_set_lookat` = standard
|
||
gluLookAt) is conventional RH (confirms D114).
|
||
- **Consequence for D114:** the unified "shared fast3d screen mirror"
|
||
hypothesis is at least partly wrong — the text mirror is texture-space.
|
||
The inverted-guard / mislocated-crate symptoms are either a *separate*
|
||
defect or the same texture-S flip applied to model textures (an
|
||
asymmetric guard skin flipped reads as "facing wrong way").
|
||
- **Next (needs a runtime probe, not static):** dump the glyph texrect's
|
||
tile size / `line_size_bytes` / uploaded texture width vs `curchar->width`
|
||
for one glyph, and the final S texcoords fast3d hands GL. Suspects:
|
||
`gDPLoadTextureBlock` 8b `line`/pad (`(curchar->width+7)&0xF8` load
|
||
width vs real `curchar->width` sample width) interacting with tile
|
||
wrap; or the compiled-in font blob (`assets/obseg/text/LmiscE.h` /
|
||
`chars[]` `pixeldata`) being 32-bit word-swapped by the asset step
|
||
(would reverse 4-texel groups — check whether the mirror is clean or
|
||
chunked at 4px). Probe artifacts: `ppm/frame_000300.ppm` (obj text),
|
||
`ppm/frame_000480.ppm` (ammo).
|
||
|
||
**D116 probe results (session M-7, overseer-run — supersedes the "specific
|
||
to the proportional font" claim above).** Killed a subagent that was
|
||
drifting toward a global `GE_D116FLIP` S-swap on every non-flip texrect
|
||
(mirrors the whole HUD/3D to "fix" glyphs — wrong). Kept its
|
||
`GE_D116`-gated `fprintf` probes in `textrelated.c` (both glyph paths) and
|
||
`gfx_pc.cpp:gfx_dp_texture_rectangle`. Built green at D115, ran
|
||
`-level_09` + `GE_PCDUMP="200-360:40"`. Data:
|
||
- **The ammo digits ARE mirrored too.** `ppm/frame_000320.ppm` top-right:
|
||
"83" renders with each digit individually X-flipped, digit order
|
||
preserved; the clip-count glyph left of the mag icon likewise. So the
|
||
prior session's "ammo digits render correct → bug is `textRenderGlyph`-
|
||
specific" differentiator is **FALSE at D115**. The mirror is NOT
|
||
path-specific. (`textRenderGlyphOutlined` is what the level text uses;
|
||
`-level_09` direct-boot renders no "OBJECTIVE" flash / crosshair at
|
||
all — only the ammo HUD — so the M-7 "OBJECTIVE C: FAILED" note came
|
||
from a different scenario/run.)
|
||
- **Rect screen position is correct; only the texture content is flipped.**
|
||
Ammo HUD sits top-right and renders top-right. So this is a texture-U
|
||
reversal per quad, not a screen/framebuffer/viewport flip and not a
|
||
scissor issue.
|
||
- **Every stage probed is clean, yet the output is mirrored:**
|
||
- glyph bitmap in memory: correctly oriented — dumped `curchar->pixeldata`
|
||
for 'D' (idx 68, w7/loadw8/h9) row-by-row; stroke on the left, bowl on
|
||
the right, col 7 = wrap-pad of col 6. Not reversed. Rules out (c) a
|
||
font-blob word-swap.
|
||
- `gDPLoadTextureBlock` params: `loadw = (w+7)&0xF8` = 8 (or 16 for w9),
|
||
`line_bytes` = 8, tile `siz=1 fmt=4 cms=2`. Consistent. No evidence
|
||
the load-width vs sample-width interaction reverses anything — rules
|
||
out (b) as the *primary* cause.
|
||
- fast3d texrect: `ul.u = 0`, `lr.u = <positive max>` (e.g. 256 = 8
|
||
texels); `ul.x < lr.x`. Corner↔UV pairing is correct
|
||
(`gfx_dp_texture_rectangle` 2187-2211, `gfx_draw_rectangle` 2113-2136:
|
||
ul=(ulx,uls), lr=(lrx,lrs)). Rules out (a) at the texrect layer.
|
||
- `import_texture_i8` (`gfx_pc.cpp:810`): strictly linear byte copy, no
|
||
row/col reversal.
|
||
- `gfx_opengl.cpp`: `vTexCoord = aTexCoord` pass-through; no U negation
|
||
in the vertex/fragment shader; `cms=2`→`GL_CLAMP_TO_EDGE`.
|
||
- **Conclusion:** the U-flip is downstream of everything fast3d computes —
|
||
in the GL vertex-buffer assembly / draw, OR it is a shared transform on
|
||
the rect quad's clip-space X that desyncs from U. This **re-opens
|
||
D114's shared-mirror hypothesis** (relocated from screen-space to
|
||
per-quad U/X space) and retires the D116 "proportional-font-specific,
|
||
texture-S load path" framing. Same mechanism plausibly explains the
|
||
"inverted" guards (mirrored skin) and mislocated door props (D113).
|
||
- **Next:** shader/vertex-buffer-level probe — dump the actual per-vertex
|
||
(x, u) pairs in the buffer handed to `glDrawArrays` for one glyph quad,
|
||
and render a 1-texel asymmetric test texture on a known rect to see
|
||
which axis inverts. Confidence the mirror is a real per-quad U/X flip
|
||
(not a capture artifact): **high** — reproduced on two independent HUD
|
||
text paths, rect positions provably correct. Confidence in the
|
||
GL-layer-vs-shared-transform split: **low** — not yet isolated.
|
||
- Probes left in tree (all `#ifdef PORT` + `getenv("GE_D116")`, zero-cost
|
||
when unset): `textrelated.c` textRenderGlyph / textRenderGlyphOutlined;
|
||
`gfx_pc.cpp` gfx_dp_texture_rectangle.
|
||
|
||
**D116 code-trace follow-up (session M-7, overseer, static — no build).**
|
||
Traced the rect quad end-to-end through fast3d for the I8 glyph case
|
||
(`cms=2`=CLAMP, `tile.uls=0`, `dsdx=1024`, 8px glyph):
|
||
- `gfx_dp_texture_rectangle` 2187-2211: `ul={x:ulxf, u:uls=0}`,
|
||
`ur={x:lrxf, u:lrs=256}`, `ll={x:ulxf,u:0}`. `ulxf<lrxf`. Correct pairing.
|
||
- `gfx_sp_tri1(ul, ll, ur, is_rect=true)` -> `gfx_sp_tri1` 1497-1572: for
|
||
each vtx `u = v->u/32 - tile.uls/4`; the D74 wrap block is gated on
|
||
`cms & G_TX_WRAP` and G_TX_WRAP==0 so it is a no-op for CLAMP glyphs;
|
||
`is_rect` skips the persp/filter half-texel. Result: `buf_vbo.u` =
|
||
`0/8 = 0.0` for left vtx, `256/32 / 8 = 1.0` for right vtx.
|
||
- `gfx_adjust_x_for_aspect_ratio` 1092: `(aspect_ofs*w + x) * aspect_scale
|
||
/ aspect_ratio`. `aspect_scale` (1714) is always a positive aspect
|
||
ratio; `aspect_ofs` is a monotonic shift. **Cannot invert X.** Ruled out.
|
||
- `buf_vbo` X for a rect = `v->x` verbatim (clip space from
|
||
`gfx_draw_rectangle` 2105-2111), Y optionally `invert_y`-negated (1504),
|
||
X never negated.
|
||
So the **entire fast3d 2D texrect -> vertex-buffer path emits a correct,
|
||
non-mirrored quad** (x-left<->u=0, x-right<->u=1). Whatever flips U is
|
||
DOWNSTREAM of `buf_vbo`: the GL vertex/fragment shader, the ortho/MVP the
|
||
backend applies to direct (non-fb) draws, or the sampler. That also means
|
||
it is testable without determinism (static per-quad property).
|
||
- **Still owed (needs a build — currently serialized behind the
|
||
determinism/framediff agent):** (1) a probe dumping `buf_vbo` (x,u) for
|
||
one glyph tri right before `glDrawArrays`, to confirm the CPU-side
|
||
buffer is non-mirrored as traced; (2) render a 1-texel asymmetric test
|
||
texture on a known screen rect to see which axis GL inverts; (3) check
|
||
whether 3D world geometry is ALSO X-mirrored (bunker is near-symmetric
|
||
— need an asymmetric in-world texture or a guard-facing check) to tell
|
||
a HUD-only (direct-draw) flip from a global one.
|
||
|
||
**D116 runtime probe part 3 (session M-8, overseer). CONTRADICTION —
|
||
every stage from font-bitmap to GL-draw verified non-mirrored at runtime,
|
||
yet the on-screen glyphs are unambiguously X-flipped. Investigation
|
||
budget-capped; deprioritised (cosmetic, not a playability blocker).**
|
||
Confirmed symptom (zoomed `ppm/frame_000300.ppm`): "OBJECTIVE C: FAILED"
|
||
(via `textRenderGlyphOutlined`) AND the ammo "83" + spare-clip digit (a
|
||
*separate* HUD-number path — `textrelated.c` probes never fire for the
|
||
digit indices) both render with word/column order preserved and every
|
||
glyph individually horizontally mirrored; screen positions correct.
|
||
Runtime probes added (`GE_D116`-gated, `#ifdef PORT`):
|
||
- `[D116/vbo]` in `gfx_sp_tri1` after the per-vertex `buf_vbo` writes:
|
||
for the OBJECTIVE glyph quads (left edge, `tile.uls=0 lrs=28`,
|
||
`cms=2`=CLAMP) it prints `vtx0 x=-0.8125 u=0.0` and `vtx2 x=-0.7625
|
||
u=1.0`. **x-left <-> u=0, x-right <-> u=1. The CPU vertex buffer handed
|
||
to GL is correct and non-mirrored.**
|
||
- `[D116/i8up]` (since reverted) in `import_texture_i8` dumped the
|
||
uploaded bytes for glyph 'D' — **byte-identical** to `curchar->pixeldata`
|
||
in memory (`00 00 0f 37 34 0b 00 00` ...), and the bitmap is correctly
|
||
oriented (stroke col 1, bowl col 6-7). No flip, no word-swap.
|
||
- GL backend: vertex shader is `gl_Position = aVtxPos` (fast3d does all
|
||
transform on the CPU — no GL matrix can flip anything);
|
||
`vTexCoord = aTexCoord` pass-through fragment path; sampler
|
||
`GL_CLAMP_TO_EDGE`/`GL_NEAREST`; `gfx_opengl_copy_framebuffer` has only
|
||
a `flip_y`, no X flip; `G_TEXRECT` decode (`gfx_pc.cpp:2602`) extracts
|
||
`ulx<lrx` correctly; `gfx_adjust_x_for_aspect_ratio` is a positive
|
||
scale+shift (cannot invert).
|
||
- `GE_D116TEST` (reverted) replaced glyph I8 textures with a
|
||
left-opaque/right-transparent split; on screen the digit slots came
|
||
back near-uniform white, not half-and-half — **inconclusive** (the
|
||
9-pass outline multi-draw and/or the unidentified digit path's sampling
|
||
window smear the result).
|
||
- **Conclusion + confidence:** the flip is real (HIGH — two independent
|
||
text paths, clean zoomed capture). It is NOT in: the font asset, the
|
||
texture upload, the fast3d texrect->vbo math, the GL shaders/sampler,
|
||
the framebuffer blit (each HIGH, runtime-verified). Where it IS: unknown
|
||
(LOW). The contradiction means a stage is being mis-modelled — leading
|
||
candidates now: (a) the render-*tile* setup (`gDPSetTile`/`gDPSetTileSize`
|
||
S params — probes covered the *load* tile) vs fast3d's tile-window
|
||
sampling; (b) a GL-driver-level surprise only a real API trace
|
||
(RenderDoc/apitrace) would show; (c) the dedicated ammo-digit renderer
|
||
(unidentified — not in `textrelated.c`) emitting mirrored S, with
|
||
`textRenderGlyphOutlined` doing likewise via a shared lower-level
|
||
helper. NEXT PERSON: capture one glyph texrect in RenderDoc, or find
|
||
and read the HUD-number path, before touching fast3d again. Do not
|
||
exceed ~30 min without one of those in hand. Probe left in tree:
|
||
`[D116/vbo]` in `gfx_pc.cpp` (`GE_D116`, zero-cost).
|
||
|
||
**D117 (session M-8) — frame-to-frame nondeterminism root-caused;
|
||
`GE_DETERM` fixed-tick mode assessed NOT-narrow, deferred with a design.
|
||
`tools_pc/framediff.py` added (structural/tolerant).**
|
||
|
||
*Root cause — pure frame pacing (variable timestep), NOT PRNG / uninit
|
||
state. Confidence: HIGH.*
|
||
|
||
GE is a variable-timestep simulation. Per rendered frame it advances game
|
||
logic by `deltaFrames` = *however many 60 Hz ticks of wall-clock elapsed
|
||
since the previous frame*:
|
||
|
||
- `src/game/frametiming.c:75` `waitForNextFrame()` —
|
||
`nextFrameTime = (osGetCount() - copy_of_osgetcount_value_1 + 387937)
|
||
/ 775875` (NTSC: 775875 RSP-counter ticks per 1/60 s), loops until
|
||
`>= frameDelay` (normally 1), then `updateFrameCounters(nextFrameTime)`.
|
||
- `src/game/frametiming.c:46` `updateFrameCounters(deltaFrames)` sets
|
||
`speedgraphframes = deltaFrames`; everything downstream (physics, AI,
|
||
animation blends, `g_Vars.lvupdate*`) scales by it.
|
||
- `src/boss.c:456-495` main loop: blocks on `gfxFrameMsgQ` for
|
||
`OS_SC_RETRACE_MSG` (posted by the port pacemaker), and only builds a
|
||
new frame once `mainTickElapsed = osGetCount() - copy_of_osgetcount_value_1
|
||
>= MAIN_LOOP_TICK_INTERVAL` (387937). A two-level gate, both levels
|
||
keyed on `osGetCount()`.
|
||
|
||
On the console `osGetCount()` is the CP0 Count register (fixed CPU rate);
|
||
on PC `port/src/libultra.c:88-100` maps it to **real elapsed
|
||
microseconds** scaled to 46.5525 ticks/µs (D52 — required so
|
||
`waitForNextFrame` doesn't block ~388 ms/frame). So `deltaFrames` tracks
|
||
real render time: a frame that took 28 ms advances logic 2 ticks, one
|
||
that took 14 ms advances 1. Machine load, GL driver, vsync phase and the
|
||
~20 fps clean-run rate all jitter this → two runs of the same build take
|
||
different numbers of logic steps to reach "frame N" and their sim
|
||
trajectories diverge. Measured with `framediff.py --exact`: two
|
||
`-level_09` runs differ **32 % (frame 440) – 69 % (frame 200)** of pixels.
|
||
|
||
Ruled out as *primary*:
|
||
- **PRNG seeding is correct.** `port/src/random.c:22`
|
||
`g_randomSeed = 0xAB8D9F7781280783ULL` (the two `.word`s from
|
||
`random.s`), `g_chrObjRandomSeed` likewise (`:93`); the xorshift is a
|
||
line-by-line port. `randomSetSeed` matches the `.s` (`+1` before
|
||
store). *However* — because AI/animation code calls `randomGetNext()` a
|
||
`deltaFrames`-dependent number of times per frame, the PRNG *stream
|
||
position* still diverges between runs. It is a victim of the pacing
|
||
jitter, not an independent source.
|
||
- **Uninitialised state:** not investigated exhaustively, but the
|
||
`--exact` divergence grows smoothly from frame 200→445 rather than
|
||
being present at frame 1, which is the signature of accumulated
|
||
timestep drift, not a per-run uninitialised seed.
|
||
|
||
*`GE_DETERM=1` fixed-tick mode — assessed NOT NARROW, deferred. Confidence
|
||
that it's not narrow: MEDIUM-HIGH.*
|
||
|
||
The obvious hook — make `osGetCount()` a virtual clock that advances
|
||
exactly 775875 ticks per presented gfx frame (`videoEndFrame` in
|
||
`osSpTaskStartGo`, `port/src/libultra.c:1187`) — deadlocks the `boss.c`
|
||
main loop. That loop only presents a frame *after* the retrace gate
|
||
`mainTickElapsed >= 387937` passes, and with a frame-coupled clock
|
||
`mainTickElapsed` is 0 until a frame is presented → the first in-loop
|
||
frame never renders. `boss.c:442` (`waitForNextFrame()` after
|
||
`lvlStageLoad`, before any frame) hangs the same way. Breaking the
|
||
seal requires the **VI retrace post itself** to drive the virtual clock
|
||
(advance a fixed quantum per `portPostVIEvent`) *and* the pacemaker to be
|
||
frame-gated so it can't enqueue >1 retrace per render (else `deltaFrames`
|
||
jumps to the queue depth). That is a redesign of the port pacing model
|
||
(`portTickThread` / `portPostVIEvent` / `osGetCount`), with real deadlock
|
||
risk in loops that pump retraces without presenting — the `boss.c:448`
|
||
`NOBLOCK` drain, multi-frame stage loads, the pause menu, `front.c`
|
||
menu loops. It is contained in `port/` and env-gatable, but it is not
|
||
"a few lines, obviously correct" — it changes retrace/tick semantics, so
|
||
per AGENTS.md it is written up here rather than patched.
|
||
|
||
**Recommended design (for a future dedicated pass):**
|
||
1. `GE_DETERM=1` → `portTickThread` stops pacing on wall clock. Instead:
|
||
a global `g_determFrameReady` flag is set by `videoEndFrame`; the tick
|
||
thread posts exactly one `OS_SC_RETRACE_MSG` and advances a virtual
|
||
`g_determTicks += 775875` (931050 PAL) **only** when it sees a new
|
||
presented frame (or when `g_viRetraceMQ->validCount == 0` and no frame
|
||
is pending — to service pre-first-frame / load-screen waits, advancing
|
||
by the same quantum so `waitForNextFrame` sees exactly 1).
|
||
2. `osGetCount()` returns `g_determTicks` verbatim in this mode (no
|
||
sub-frame interpolation — `store_osgetcount`/profiling just see 0
|
||
deltas, which is harmless).
|
||
3. Seed `g_determTicks = 775875` at `portKernelInit` so the first
|
||
`waitForNextFrame` computes `(775875 + 387937)/775875 == 1`.
|
||
4. Leave `osGetTime()` (µs wall clock) alone — audio mixing cadence and
|
||
the heartbeat watchdog should stay real-time.
|
||
5. Prove with `framediff.py --exact`: two
|
||
`GE_DETERM=1 GE_PCDUMP=... ` runs must produce ~0 % pixel diff
|
||
(allow `--tol 2` for GL dithering). Then regenerate
|
||
`tools_pc/golden/` with `--update` and switch CI to `--exact`.
|
||
Risk to watch: any game loop that calls `waitForNextFrame()` in a context
|
||
where no gfx task will be submitted (true loading spinners) — those need
|
||
the step-1(b) "no frame pending" fallback or they hang.
|
||
|
||
*`tools_pc/framediff.py` — DONE, committed. Confidence: HIGH (validated).*
|
||
|
||
Structural/tolerant by default (no determinism to lean on): per-frame it
|
||
computes (a) 16×12 grid-cell mean-RGB delta, (b) whole-frame non-clear
|
||
(non-black, pixcount.py rule) pixel-% swing, (c) a 16×16 aHash Hamming
|
||
distance; fails the frame if any exceeds its threshold. `--mask
|
||
X0,Y0,X1,Y1` (repeatable) drops known-animating regions (HUD) from all
|
||
three. `--exact` mode (per-pixel, `--tol`/`--tol-pct`) is there for a
|
||
future deterministic build. `--update` refreshes the golden set. Reads
|
||
`.ppm` (GE_PCDUMP) and `.png` (golden) on either side; PNG decoder is
|
||
built in (stdlib `zlib`; falls back to PIL for odd formats). Validated:
|
||
two nondeterministic re-runs of HEAD pass structural (worst cell
|
||
dmean 15, phash ≤ 23, non-clear Δ ≈ 0) while `--exact` correctly reports
|
||
32–69 %; a deliberately wrong frame pair fails (18 cells, phash 105).
|
||
Golden set at `tools_pc/golden/frame_0002{00,320,440}.png` is the D115
|
||
baseline — since it is a nondeterministic capture, only structural mode
|
||
is meaningful against it today.
|
||
|
||
**D117 — M-52 addendum: `GE_DETERM=1` implemented per the design above,
|
||
tested live (Windows console). Boot deadlock FIXED as predicted; full
|
||
determinism NOT achieved. Confidence: HIGH on both findings (live-tested,
|
||
not argued).**
|
||
|
||
Implemented exactly the 5-step design (`port/src/libultra.c`, env-gated,
|
||
default off, zero change to the non-`GE_DETERM` path): `osGetCount()`
|
||
returns a virtual `g_determTicks` seeded to the region quantum; a
|
||
`g_determFrameReady` flag is set at the `osSpTaskStartGo` graphics-task call
|
||
site (i.e. by the real "a frame was presented" event, not `videoEndFrame`
|
||
directly — same effect, one call site, no `video.c` change needed);
|
||
`portTickThread` gets a `GE_DETERM` branch that advances the clock + posts
|
||
one retrace only when that flag is set, polling every 200 µs instead of
|
||
pacing to the real 16.7/20 ms tick interval.
|
||
|
||
*Step 1(b) risk, confirmed exactly as written.* Without a load-phase
|
||
fallback, `-level_09 GE_DETERM=1` hangs before the first frame: symbolicated
|
||
the live hang (not guessed) — `shedThread` blocked in `osRecvMesg`
|
||
(`sched.c:256`, `__scMain`) and `mainThread` in `joyCheckStatusThreadSafe`
|
||
(`joy.c:166`), both waiting on the scheduler's interruptQ (the same queue
|
||
`portPostVIEvent` posts into, per the D134 comment), which never receives a
|
||
retrace because no frame has rendered yet to set `g_determFrameReady` —
|
||
exactly the chicken-and-egg the design called out ("boss.c:442 hangs the
|
||
same way").
|
||
|
||
*Fallback added, per the design's own suggestion — fixes the hang, breaks
|
||
determinism.* Added: when `g_determFrameReady` is false AND
|
||
`g_viRetraceMQ->validCount == 0` (nothing left to consume from a prior
|
||
tick), advance the clock and post a retrace anyway, so a boot-time waiter
|
||
isn't stuck forever. This **does** fix the hang — `-level_09 GE_DETERM=1`
|
||
now boots and renders past frame 3000+ instead of hanging. It does **not**
|
||
achieve determinism: two independent runs, same build, same command,
|
||
`framediff.py --exact` at the golden frame numbers (200/320/440):
|
||
|
||
```
|
||
FAIL frame_000200: 276998/307200 px over tol (90.169%, maxchan=218)
|
||
FAIL frame_000320: 85131/307200 px over tol (27.712%, maxchan=102)
|
||
FAIL frame_000440: 47936/307200 px over tol (15.604%, maxchan=102)
|
||
```
|
||
|
||
That's *worse* at frame 200 than the port's existing wall-clock
|
||
nondeterminism without any `GE_DETERM` mode at all (32–69 % per the
|
||
original D117 measurement above) — the fallback didn't just fail to fix
|
||
determinism, it introduced a new, bigger source of it. Root cause (analysis,
|
||
not yet re-tested): the fallback fires whenever the 200 µs poll happens to
|
||
observe an empty queue, which during the load phase depends on real
|
||
OS-scheduling latency between the polling thread and whatever the load path
|
||
is doing — so the *number* of extra ticks consumed before the first real
|
||
frame renders varies run-to-run, exactly the same class of divergence D117
|
||
already documents, just relocated from "wall-clock frame pacing" to
|
||
"wall-clock load-phase tick count." User-observed side effect, consistent
|
||
with this: with the fallback active the game visibly runs at a different
|
||
apparent frame rate and sped up — because the tick loop is no longer paced
|
||
to real 60/50 Hz at all once it starts free-running on empty-queue polls,
|
||
it advances (and lets the sim advance) as fast as the poll thread schedules,
|
||
not at any fixed rate.
|
||
|
||
**D117 — M-52 2nd attempt (same session): moved generation from a polled
|
||
fallback to a synchronous one. Root-caused precisely why that still isn't
|
||
enough. Confidence: HIGH (live-tested, mechanism traced through sched.c).**
|
||
|
||
Traced the actual consumer chain instead of guessing: `sched.c` `__scMain`
|
||
is the **sole** consumer of `g_viRetraceMQ` (`sc->interruptQ`), and every
|
||
retrace it processes drives task execution (`osSpTaskStartGo`), `joyPoll()`
|
||
(which is what unblocks `joyCheckStatusThreadSafe` too — the other M-52
|
||
1st-attempt hang site, for free), and the forward to `mainThread`, all
|
||
**synchronously on that one thread** before it asks for the next message.
|
||
So replaced the poll-driven tick-thread fallback with generation **inside
|
||
`osRecvMesg()` itself**: when `GE_DETERM` is on and the sole consumer of
|
||
`g_viRetraceMQ` finds it empty, synthesize the message inline (advance
|
||
`g_determTicks`, enqueue) instead of blocking — driven by call sequencing,
|
||
not by an independent thread's wall-clock poll.
|
||
|
||
Result: still boots (no regression on the deadlock fix), and the divergence
|
||
profile changed but did not go to ~0%: two runs, `framediff.py --exact`:
|
||
|
||
```
|
||
FAIL frame_000200: 101582/307200 px over tol (33.067%, maxchan=150)
|
||
FAIL frame_000320: 116844/307200 px over tol (38.035%, maxchan=161)
|
||
FAIL frame_000440: 43617/307200 px over tol (14.198%, maxchan=117)
|
||
```
|
||
|
||
Better than the 1st attempt's 90/28/16% at frame 200, but still nowhere
|
||
near the ~0% acceptance target. **Root cause, found by diffing the two
|
||
boot logs line-for-line** (not guessed): `boss.c`'s boot sequence has a
|
||
genuine, bounded, wall-clock real-time wait —
|
||
`for (i = 0; i != MAXCONTROLLERS; i++) { osSetTimer(100ms); osRecvMesg(BLOCK); }`
|
||
(`src/boss.c:188-190`, controller detection) — untouched by `GE_DETERM` by
|
||
design (`osGetTime()`/real timers are explicitly out of scope). During
|
||
that ~400 ms real window, **nothing is yet consuming `__scMain`'s forwarded
|
||
retraces** (`mainThread` hasn't reached its own retrace-consuming loop
|
||
yet), so the new synchronous-generation code has nothing throttling it —
|
||
`__scMain` free-spins through `osRecvMesg` as fast as the OS schedules that
|
||
thread, and *how many* virtual ticks accumulate during that fixed 400 ms of
|
||
real time depends on real thread-scheduling throughput. Same nondeterminism
|
||
class as both prior attempts, relocated a third time: generation-on-ask is
|
||
only deterministic when something else deterministically paces the asking,
|
||
and right now nothing does during any window where a client isn't actively
|
||
mid-frame.
|
||
|
||
**Disposition:** kept in the tree, env-gated (`GE_DETERM` unset by default,
|
||
zero behavior change confirmed via `verify.sh bunker1` before/after across
|
||
both attempts — worst cell 12.1–12.5 throughout, within existing noise).
|
||
The single-consumer synchronous-generation mechanism in `osRecvMesg()` is
|
||
the right shape and should stay — it's strictly better than polling and
|
||
fixed a real deadlock class cleanly. What's still missing: a genuine
|
||
**rate limit** on `__scMain`'s synthetic-retrace generation, decoupled from
|
||
real time — e.g. only synthesize the next one once the client has actually
|
||
finished processing the previous forward (a real request/response coupling
|
||
to `mainThread`'s progress, not just "the queue looked empty"), with a
|
||
separate, explicitly-bounded allowance for pre-first-frame boot code that
|
||
legitimately needs to service its own `osSetTimer` waits without the
|
||
scheduler racing ahead. That is a genuine design task, not a quick
|
||
follow-up — leaving D117 OPEN. `porting-notes.md` not updated (this
|
||
addendum's lesson is already exactly what §D117 above says, restated at
|
||
one more layer: any unthrottled or wall-clock-throttled generator racing
|
||
against a genuinely real-time-bound consumer reintroduces the same
|
||
divergence class, no matter how many layers deep you push it).
|
||
|
||
**D117 — M-52 4th pass: instrumented tracing (`GE_DETERM_TRACE=1`) precisely
|
||
localizes what's left — and it is NOT the virtual clock. Confidence: HIGH,
|
||
directly measured, not inferred.**
|
||
|
||
Rather than keep auditing code paths by inspection (two dispatches -- one to
|
||
the local delegate, one done directly -- had just spent a round ruling out
|
||
the level-load path and the audio-thread shared-globals angle with nothing
|
||
to show for it), added a temporary trace: every `g_determTicks`
|
||
advance/cap event and the `first_task_run` transition get a monotonic
|
||
sequence number + timestamp-free log line (`port/src/libultra.c`, gated by
|
||
`GE_DETERM_TRACE=1`, zero cost when unset). Ran `-level_09` twice, diffed
|
||
the two trace logs directly instead of guessing.
|
||
|
||
**Result: the tick mechanism is now proven fully deterministic.** Both runs
|
||
are byte-identical (same `ticks=` value at every logged step) all the way
|
||
through the `first_task_run` transition — the only difference is *which
|
||
spin iteration* it happens on (run 1: spin 4020, run 2: spin 4086 — 66 more
|
||
capped no-op spins in run 2, real-OS-scheduling-dependent as expected), but
|
||
the **virtual clock value at that moment is identical in both** (1551750 =
|
||
`2 × g_determQuantum`). Re-aligning the two logs by that spin-count offset
|
||
shows the entire subsequent tick progression is byte-for-byte identical.
|
||
The M-52 3rd-attempt fix (cap pre-task ticks to a fixed constant) works
|
||
exactly as designed — this is not where the remaining divergence comes
|
||
from.
|
||
|
||
**The actual mechanism, found by reading `sched.c` with the trace numbers
|
||
in hand:** every synthesized retrace — capped ones included — is a real
|
||
`VIDEO_MSG` that `__scMain` processes via `__scHandleRetrace()`
|
||
(`src/sched.c`), which unconditionally does `sc->frameCount++`
|
||
(`sched.c:319`) and then, per registered client, gates forwarding on
|
||
`(*((s32*)client + 2) == 0) || ((sc->frameCount & 1) == 0)`
|
||
(`sched.c:334`) — i.e. some clients only receive every *other* frame's
|
||
retrace by `frameCount` parity. The audio client is registered as exactly
|
||
this kind of half-rate client: `osScAddClient(&os_scheduler,
|
||
&g_AudioClient[0], &g_AudioManager.frameMessageQueue, 1)` (`src/audi.c:437`,
|
||
the trailing `1` is the half-rate flag). Since `frameCount` increments on
|
||
*every* capped spin — not gated by the virtual clock at all — and the total
|
||
spin count before the first real task varies run-to-run by a genuinely
|
||
real-time-scheduling-dependent amount, **`frameCount`'s parity at the
|
||
moment real gameplay begins is not guaranteed to match between runs**, even
|
||
though the clock value is now provably identical. This run pair happened to
|
||
land on a same-parity gap (66, even) — a different pair easily could not.
|
||
If it lands on opposite parities, the audio client's very first retrace
|
||
forward shifts by one frame's phase between runs, and audio-task scheduling
|
||
(which shares the same `osSpTaskStartGo`/tick-gating machinery) diverges
|
||
from there.
|
||
|
||
**Why no 5th live-coded attempt this session:** the natural next lever —
|
||
don't call `__scHandleRetrace` (and thus don't increment `frameCount`) for
|
||
a purely-capped "keep `__scMain`/`joyPoll` alive" spin — reopens the exact
|
||
chicken-and-egg the 1st attempt hit: `joyCheckStatusThreadSafe`'s handshake
|
||
and `__scMain`'s own unblocking need *something* processed during that
|
||
window, and `sc->frameCount` is decomp-internal state with no port-visible
|
||
accessor to correct after the fact. A real fix needs `frameCount`'s parity
|
||
(or the half-rate audio client's phase) to be made deterministic
|
||
independent of the real-time-dependent capped-spin count — a genuine
|
||
4th-layer design task, not a variant of any of the first three attempts.
|
||
Stopping here deliberately rather than rushing a live guess under momentum,
|
||
per the same discipline the earlier attempts were written up under.
|
||
|
||
**Disposition:** `GE_DETERM_TRACE` tracing kept in the tree alongside
|
||
`GE_DETERM`, same env-gating, zero cost when unset — it's the tool that
|
||
should be reused for the next attempt rather than re-instrumented from
|
||
scratch. D117 stays OPEN. porting-notes.md not updated yet (the
|
||
generalisable lesson here — "a per-frame parity/half-rate dispatch gate
|
||
fed by an unconditionally-incrementing counter inherits whatever
|
||
nondeterminism feeds that counter, even after the counter's *primary*
|
||
consumer is made deterministic" — is specific enough to this scheduler
|
||
that it's better captured here than genericized prematurely).
|
||
|
||
**D118 (session M-9) — SDL input layer implemented (Phase 3).**
|
||
`port/src/input.c` was an unimplemented stub; input only worked via
|
||
`libultra.c`'s `contSnapshotFromKeyboard()` (Z/Space=A, X=B, LCtrl=Z,
|
||
arrows=stick, WASD=D-pad — no C-buttons, no mouse, no gamepad).
|
||
|
||
Implemented a focused `input.c` (NOT a full port of pd_port's 1551-line
|
||
module — GE's menu/config code never calls that VK/bind-string API).
|
||
Provides `inputInit/inputUpdate/inputDestroy/inputGetNumControllers`
|
||
plus two helpers for `libultra.c`: `inputConnectedMask()` and
|
||
`inputComputePad(idx, *sx, *sy) -> u16 button`. The N64 button bits are
|
||
duplicated as `GE_CONT_*` in `input.c` rather than `#include <PR/os.h>`
|
||
(its `u8 errno;` field collides with `<errno.h>`'s macro; libultra.c
|
||
only gets away with it via a `#pragma push_macro` dance).
|
||
|
||
*Binding scheme.* Kbd/mouse (controller 0): WASD or arrows = analog
|
||
stick (move/strafe); mouse motion = C-buttons (aim, see bridge below);
|
||
LMB/LCtrl = Z (fire); RMB/LShift = R (aim mode); Space/Z/E = A;
|
||
X/R/F = B; Q = L; Enter/Tab = Start. Gamepad (SDL_GameController; pad 0
|
||
merges into controller 0, pads 1-3 → controllers 1-3): left stick =
|
||
stick, right stick = C-buttons (digital, 50 % threshold), RT = Z,
|
||
LT = R, A/X = A, B/Y/RB = B, LB = L, D-pad = D-pad, Start = Start.
|
||
|
||
*Mouse-look → C-button bridge (the subtle part).* GE aims with digital
|
||
C-buttons and has no analog-aim hook reachable without editing `src/`.
|
||
`inputUpdate()` integrates the relative-mouse delta (× `MouseAimSpeed`/
|
||
100) into a per-axis signed accumulator clamped to ±8. Each controller
|
||
poll `inputComputePad()` emits the matching C-button while |accum| ≥ 0.5
|
||
and drains one unit — so a flick holds the C-button for several frames
|
||
(proportional dwell) instead of a single blip. Limitations: still
|
||
digital (GE's own accel curve makes turn speed non-linear in mouse
|
||
speed); fast flicks saturate at ~8 frames of turn; diagonals limited to
|
||
the 8 C-button combos. A real analog path would need an `#ifdef PORT`
|
||
hook in `bondview.c` — left as TODO.
|
||
|
||
*Wiring.* `contSnapshotFromKeyboard()` in `libultra.c` (SI section) now
|
||
just calls `inputUpdate()` then marshals `inputComputePad()` output into
|
||
`g_contPad[]`/`g_contStatus[]` for all `MAXCONTROLLERS`, and sets
|
||
`g_contConnected` from `inputConnectedMask()`. It is still driven by
|
||
`osContStartReadData`/`osContStartQuery` (once per game logic tick) — no
|
||
new frame hook in `video.c` was needed. `osContGetReadData()` now
|
||
`memcpy`s the whole `g_contPad` array (N64 semantics: one pad per
|
||
channel) instead of only controller 0 — joy.c passes a
|
||
`MAXCONTROLLERS`-long array. `inputInit()` opens the gamepad subsystem +
|
||
all connected controllers and enables relative mouse mode.
|
||
|
||
*Config.* `ge007.ini` `[Input]` `MouseEnabled` (0/1), `MouseAimSpeed`
|
||
(1..500, default 50), `MouseInvertY` (0/1), via `configRegisterInt` in a
|
||
`PD_CONSTRUCTOR`. `config.c` has no float support so speed is an int
|
||
percent. Key/button REBINDING is not wired (would need the pd_port
|
||
bind-string system or a new mini-parser) — hardcoded scheme + TODO.
|
||
|
||
*Status.* Build GREEN (`ntsc-final`). Boots `-level_09` crash-free for
|
||
35 s, 900+ frames, `input: ready (mask=0x1, 1 controller(s))` logged.
|
||
`GE_INPUTLOG=1` gates a per-poll `sysLogPrintf` of the OSContPad when
|
||
button/stick are nonzero (zero-cost when unset, kept). Live input
|
||
UNTESTED (headless agent) — needs a human pass: see
|
||
`docs/HANDOFF.md`. Confidence: build/wiring HIGH; in-game feel of the
|
||
mouse→C-button bridge MEDIUM (aim speed tuning likely needed); gamepad
|
||
UNVERIFIED (none present in build env).
|
||
TODO: (1) key rebinding; (2) gamepad hotplug (opened only at init today);
|
||
(3) optional analog-aim `#ifdef PORT` hook in bondview.c — only needed now
|
||
for D118a (fully-analog hipfire pitch) and toggle-aim-scheme correctness.
|
||
[M-24: mouse-look rework + real INI parser done — see "Mouse-look rework"
|
||
below.]
|
||
|
||
*Mouse-look rework — M-24 (`port/src/input.c`, port-layer only, no `src/`
|
||
change).* Root reading of `bondviewProcessInput`/`MoveData`: GE's aim is
|
||
**mode-dependent** — hipfire (`!insightaimmode`) yaw = analog stick-X,
|
||
pitch = digital C-up/C-down (stick-Y = move, does not pitch); aim mode
|
||
(R held) yaw+pitch = analog stick past ±60 → `(stick-60)/10`, and
|
||
C-up/C-down there = crouch/lean/zoom (`bondview2.c:5340,5351`), *not* aim.
|
||
Also GE's native pitch is inverted (`U_CBUTTONS → speedVertaDown`, i.e.
|
||
C-up looks down, `:5272-5279`). New mapping in `inputComputePad`, keyed on
|
||
our own RMB/LShift state as the hold-to-aim proxy:
|
||
- **aim mode** → push `stick_x`/`stick_y` into the 61..80 band
|
||
(proportional to per-poll mouse delta × `MouseAimSpeed`/100 × 4), **emit
|
||
no C-buttons**. Yaw and pitch now identical analog feel.
|
||
- **hipfire** → yaw `stick_x += dx × MouseTurnSpeed/100 × 6`; pitch =
|
||
digital C-up/C-down on `|dy| ≥ 1.5` px/poll.
|
||
- "mouse-down = look down" by default (accounts for GE's inversion);
|
||
`MouseInvertY` flips.
|
||
Per-poll deltas, no accumulator (rate device). `ge007.ini [Input]`:
|
||
`MouseEnabled`, `MouseAimSpeed` (50), `MouseTurnSpeed` (100), `MouseInvertY`
|
||
(0) — **now actually parsed** (`config.c` INI load/save implemented, M-24).
|
||
|
||
- **D118b (FIXED, M-24)** — mouse-Y inversion resolved by the "mouse-down
|
||
looks down" default above + `MouseInvertY` toggle.
|
||
- **D118c (FIXED, M-24)** — aim + mouse-down → crouch. Fixed *without* a
|
||
`src/` hook: in aim mode the mouse now drives the analog stick and emits
|
||
no C-button, so the crouch/lean/zoom mappings are never triggered by
|
||
look input. (Proxy caveat: a toggle-aim control scheme would need a read
|
||
of `g_CurrentPlayer->insightaimmode` since RMB-held ≠ aim state there.)
|
||
- **D118a (RESIDUAL, lower)** — in **hipfire only**, yaw (analog) vs pitch
|
||
(digital C-button) still feel different. Aim mode is now fully analog and
|
||
consistent. A fully-analog hipfire pitch would need the `#ifdef PORT`
|
||
`bondview.c` hook (TODO 4). Minor — precise vertical aim in GE happens in
|
||
aim mode, not hipfire.
|
||
- **D137 (FIXED)** — right-mouse *crash* (not the crouch bug):
|
||
`gunDrawSight` `s32 sp54` truncated a `Gfx*`; see §F D137.
|
||
- Weapon switch on kbd/mouse = the A button (`Space`/`Z`/`E`), same as
|
||
action/use (GE overloads it). No dedicated key; mouse-wheel cycle is a
|
||
candidate quick-win.
|
||
|
||
**`data/` deletion + recovery (session M-3).** `git worktree remove
|
||
--force` on an agent worktree that had a directory *junction*
|
||
`worktree/data → main/data` followed the junction and deleted the real
|
||
`data/` contents (both `.z64` baseroms + `pccg-*`/`pcmodels-*` sidecars).
|
||
`data/` is gitignored so nothing tracked was lost. Recovered:
|
||
`cp baserom.u.z64 data/ge007.ntsc-final.z64` (sha1
|
||
`abe01e4a…` == the canonical `ge007.u.z64` build hash, so byte-identical
|
||
to what was there), then regenerated all three sidecars — `python
|
||
tools_pc/d43_emit.py ntsc-final` (pcmodels, 512), `d69_emit.py
|
||
ntsc-final` (pccg bg/stan, 52), `d88_emit.py ntsc-final --regen` (Usetup,
|
||
→ 73 rows, `pccg.bin` 3605249 B) — all "ALL CHECKS PASSED". **Lesson:
|
||
never junction `data/` into a throwaway worktree; copy it or point the
|
||
generator's `ROM_PATH` at the repo-root baserom.** The extra
|
||
`GoldenEye 007 (U) [!].z64` copy was not restored (unused — runtime and
|
||
generators use `ge007.ntsc-final.z64`, falling back to root
|
||
`baserom.u.z64`).
|
||
|
||
**Docs-to-commit reminder (session L).** The D88.1–D88.3 work
|
||
(`tools_pc/d88_emit.py`, `port/src/pccg.c`, `src/bondtypes.h`,
|
||
`src/game/bondview2.c`, `src/game/bondview_r.c`) plus the `GE_D88` probes
|
||
in `prop.c`/`stan.c` have been format-verified and pass `-level_09` up to
|
||
the D88.4 crash, but **remain uncommitted** (carried through two
|
||
interrupted sessions). Commit in sub-milestones per the usual pattern
|
||
once D88.4 is understood: format spec → converter → port wiring → probes.
|
||
|
||
**D69 status after D78-D88: the ORIGINAL blocker (`load_bg_file` faulting
|
||
on first stage load) is fully resolved and verified** — a clean run loads
|
||
BUNKER1's header/room/portal/envdata tables and its full ~1066-tile stan
|
||
file correctly (spot-checked byte-for-byte against the N64 source via the
|
||
`GE_D69STAN` probe: tile room/mid/tail/point values match). The game now
|
||
progresses substantially further than before (through room-streaming
|
||
setup, past the old D86 model-init crash and the D87 attract-mode crash)
|
||
before hitting D88's separate, newly-exposed "stage setup" file format
|
||
gap. D85 (room geometry renders wrong, not yet crash-free at the *visual*
|
||
level) and D88 (stage-setup file format, unconverted) remain open
|
||
follow-ups — **the "loads without
|
||
fault" acceptance bar is not yet fully met** (the process still exits via
|
||
crash, just much later in the load sequence), but the converter, port
|
||
wiring, and every ABI fix identified so far are format-verified correct
|
||
and committed.
|
||
|
||
**Environment reminders.** MSYS2 tools in `/c/msys64/mingw64/bin/` (not on PATH —
|
||
prefix `export PATH=…`). Build: `./build-pc.sh ntsc-final`. gdb **launch** mode
|
||
is far too slow for timing-dependent crashes (a D56-class crash ~10 s in took
|
||
>300 s under gdb to reach 2 frames — DBGHELP symbol loading + the D51
|
||
stall-heartbeat thread dumps; don't wait on it): prefer env-gated TEMP probes +
|
||
the built-in crash log for reproducible faults. **Correction (D87 session):**
|
||
gdb **attach** mode (`gdb -batch -x cmds -p <winpid>`, where `<winpid>` is the
|
||
Windows PID from `ps`, 4th column — the game must already be running, e.g.
|
||
launched with `nohup ... &`) works fine and is fast, since the process is
|
||
already warmed up and running at full speed before you attach; a hardware
|
||
watchpoint (`watch *(int*)0xADDR`) caught a global's write in well under a
|
||
minute. Useful for "is this global legitimately written, or corrupted"
|
||
questions on a long-running, non-crashing process — attach once the process
|
||
has been running a while, `continue`, and it'll fire on the very next real
|
||
write. Still avoid gdb for the crash itself if the crash is reproducible via
|
||
the crash log; symbolicate
|
||
offline with `addr2line -e build-pc/ge007.x86_64.exe -f -C <0x140000000+rel>`.
|
||
Image base 0x140000000. `load_resource`/many init fns use a fake RBP — compute
|
||
stack offsets from entry RSP. The D30 crash handler writes `ge007.crash.log`
|
||
with a working Phase-2 backtrace (**D44** fixed) — first stop for any fault;
|
||
frames past the true chain may be stale (a corrupted return address outside the
|
||
module, as in D56, means unwind depth is limited — confirm callers by code-
|
||
path analysis + behavior). Standalone probe compiles need `-std=c11` (without
|
||
it `typedef s32 bool` in bondtypes.h breaks under gnu23) and pointer-difference
|
||
arithmetic instead of offsetof (include/stddef.h is #if 0'd).
|
||
|
||
---
|
||
|
||
**D121 (session M-12, WS1) — frictionless per-level boot.** A bare
|
||
`./build-pc/ge007.x86_64.exe -level_XX` with no `-m*` args booted every
|
||
level with the default memory pools and OOM-crashed early in the load
|
||
sequence; you had to hand-copy the per-level `-ml -me -mgfx -mvtx -mt -ma`
|
||
row from `memallocstringtable[]` (`boss.c:101`) onto the command line.
|
||
|
||
*Root cause.* On N64 the per-stage auto-inject loop in `bossMainloop`
|
||
(`boss.c` ~line 425: `tokenSetString(memallocstringtable[i].string)`)
|
||
supplies that row, but it is gated on `g_DebugAndUpdateStageFlag`. With
|
||
`-level_` present that flag is left to `rmonGetToken()` (`boss.c:178`),
|
||
which the PC stub (`port/src/n64stubs.c`) returns 0 for, so the loop never
|
||
runs.
|
||
|
||
*Rejected fix.* Setting `g_DebugAndUpdateStageFlag = 1` when `-level_` is
|
||
present (the "mirror `boss.c:199-202`" idea in the old HANDOFF) is **not
|
||
behaviour-preserving on PC**: it makes boot render the full title-stage
|
||
intro (logos → gun-barrel → cast) instead of loading the level directly.
|
||
The flag does more than pick the token row.
|
||
|
||
*Fix (`#ifdef PORT` in `bossInitMainthreadData`, after the flag's own
|
||
`tokenSetString` block).* When `-level_` is present and no `-m` token was
|
||
given, look up the stage's `memallocstringtable[]` row (same match the N64
|
||
loop does) and `tokenSetString` it — **prefixed with `-level_XX` (and
|
||
`-hard N` if present)**, because `tokenSetString` (`src/token.c:41`)
|
||
`strcpy`s over the whole token buffer; without the prefix `bossMainloop`'s
|
||
`tokenFind(1, "-level_")` returns NULL and it boots the title stage. No
|
||
flag change, no control-flow change.
|
||
|
||
*Verified.* Bare `-level_09` now loads BUNKER1 directly (85–92 % non-clear
|
||
frame content from frame 30, no intro), matching the old
|
||
`-level_09 -ml0 -me0 -mgfx100 -mvtx50 -mt700 -ma150` repro. Residual
|
||
BUNKER1 prop/`modelLoad` crash is intermittent (D117 nondeterminism +
|
||
D88.4) and pre-existing — out of WS1 scope. HANDOFF repro line updated to
|
||
drop the manual `-m*` list.
|
||
|
||
**D122 (session M-12) — per-level prop/item model-load crash: propDef
|
||
converter missing handlers for ObjectRecord-derived record types.**
|
||
`-level_33` (Dam), `-level_34` (Facility), `-level_35` (Runway) crashed
|
||
deterministically before frame 1: `modelLoad` (`loadobjectmodel.c:393`,
|
||
`PitemZ_entries[modelid].header->RootNode` with an OOB `modelid` — `Rax`
|
||
held ASCII) and `modelInitRwData` (`model.c:6249`). BUNKER1 (`-level_09`)
|
||
and Silo (`-level_20`) were fine.
|
||
|
||
*Root cause.* The D88.4 propDef stream converter (`tools_pc/d88_propdefs.py
|
||
convert_record`) had per-type handlers for most record layouts but **four
|
||
`inherits ObjectRecord` types fell through to the generic arm**: 47
|
||
TINTED_GLASS, 39 VEHICHLE, 40 AIRCRAFT, 45 TANK (plus 13 AUTOGUN and 20
|
||
AMMO/MultiAmmoCrate, exposed once the first four were fixed). The generic
|
||
arm bswap32's N64 word 1 as a single 32-bit value, but for an
|
||
ObjectRecord that word is `[s16 obj][s16 pad]` — it needs `_hh_word`
|
||
(swap each half in place). Result: `obj` (the model id, read by
|
||
`domakedefaultobj` at `prop.c:155` → `modelLoad`) landed in the wrong
|
||
half → garbage/OOB index into `PitemZ_entries[]`. The generic arm also
|
||
never widened the ObjectRecord's embedded pointer slots (prop*/model* 4→8
|
||
B), so every tail field was mis-offset too. BUNKER1/Silo use none of
|
||
these six types (verified with a per-level `PROPDEF_N64_WORDS` type
|
||
histogram), which is why they were unaffected — the stream *walk* was
|
||
self-consistent for all 21 levels (`convert_stream` validates region
|
||
length), only the *field contents* of these records were wrong.
|
||
|
||
*Fix (converter only — `tools_pc/d88_propdefs.py`).* Added `OBJ_TAIL_DESC`
|
||
= {type: (ptr_word_set, hh_word_set)} for types 47/39/40/45/13/20 and a
|
||
handler that emits the real 144-byte PC ObjectRecord prefix
|
||
(`_emit_object_prefix`, which does `obj` correctly) followed by the tail
|
||
with pointer members widened 4→8 B and 8-aligned, `_hh_word` for u16-pair
|
||
words (vehicle aioffset/aireturnlist; ammo-crate `slots[]`), `_bswap32`
|
||
elsewhere. Updated `PROPDEF_PC_BYTES` (39: 176→208, 40: 180→208, 45:
|
||
224→248; 47/13/20 already correct) and the matching
|
||
`sizepropdef()` `#ifdef PORT` arms in `loadobjectmodel.c` (VEHICHLE
|
||
44→52, AIRCRAFT 45→52, TANK 56→62 words; N64 values kept in the trailing
|
||
comment / `#else` `#if 1` switch). No game-logic change. Regenerate:
|
||
`python tools_pc/d88_emit.py ntsc-final --regen` (21/21, ALL CHECKS
|
||
PASSED).
|
||
|
||
*Verified.* `-level_33/34/35` all get past the model-load chain now (no
|
||
`loadobjectmodel.c:393` / `model.c:6249` crash). BUNKER1 unregressed
|
||
(83–92 % non-clear frame content, frames 80–200). Silo unregressed
|
||
(exercises the new type-20 handler — no crash).
|
||
|
||
*Residual (separate blockers, out of D122 scope — hand to the level
|
||
sweep).* Dam + Runway now crash later in `chrIsNotDeadOrShot`
|
||
(`chraction.c:4483`, `self` = an image rodata address — guard/chr setup,
|
||
not propDef-related). Facility crashes in `import_texture_i8`
|
||
(`gfx_pc.cpp:821`, bad texture pointer — fast3d). Neither is touched by
|
||
this fix.
|
||
|
||
*Tail-layout confidence: medium* for 39/40/45 (VehichleRecord/
|
||
AircraftRecord/TankRecord field offsets have "locs need confirming" notes
|
||
in `bondtypes.h`; the pointer-word sets and total sizes are derived from
|
||
the struct as written and may need a nudge if a level actually drives
|
||
tank/vehicle motion). **High** for 47/13/20 (offsets are known) and for
|
||
the crash fix itself (the `obj`-field half-swap is unambiguous).
|
||
|
||
---
|
||
|
||
**D123 (session M-13) — crash class C1: `chrIsNotDeadOrShot` NULL deref on
|
||
6 levels (Dam 33, Runway 35, Frigate 26, Statue 22, Streets 29, Cradle
|
||
41). Converter zeroed the vehicle/aircraft `ailist` id. FIXED
|
||
(`tools_pc/d88_propdefs.py` only).**
|
||
|
||
*Symptom.* Bare `-level_33` etc. crashed before frame 1 in
|
||
`chrIsNotDeadOrShot` (`chraction.c:4483`, `self->actiontype`), fault addr
|
||
0x8 → `self ≈ NULL`. (The log's frame #1 `0x1401296a0` / `chraidata.c:61`
|
||
was a stale stack-scan hit, not a real frame — misled the original
|
||
triage.)
|
||
|
||
*Real backtrace (gdb):* `objTick` (`propobj.c:5504`) →
|
||
`ai((PropDefHeaderRecord *)poTruck, PROP_TYPE_OBJ)` →
|
||
`ai()` sees `Entityp->type == 39` (PROPDEF_VEHICHLE) so sets
|
||
`VehichleEntityp`, `AiListp = VehichleEntityp->ailist`, `ChrEntityp =
|
||
NULL`. `AiListp` resolved to `m_AimAtBond` (`GAILIST_AIM_AT_BOND` == id 0).
|
||
Its first opcode is `AI_TRYFireOrAimAtTarget` →
|
||
`actor_aim_at_actor(ChrEntityp=NULL,…)` → `chrIsNotDeadOrShot(NULL)`.
|
||
|
||
*Root cause.* `prop.c:1764` does `pdef_veh->ailist =
|
||
ailistFindById(pdef_veh->ailist)` — it reads the **pre-populated integer
|
||
AI-list id** out of the ROM record (VehichleRecord.ailist @ N64 0x80;
|
||
Dam's is `0x040a` = 1034, a per-level list). But D122's `OBJ_TAIL_DESC`
|
||
put word 32 in `ptr_word_set`, whose handler emits **8 zero bytes** for
|
||
the widened 4→8B slot (correct for `path*`/`Sound*`, which `prop.c`
|
||
overwrites with 0 — but wrong for `ailist`, read before overwrite).
|
||
Result: `ailistFindById(0)` → `isGlobalAIListID(0)` true → global list id 0
|
||
= `GAILIST_AIM_AT_BOND` → a vehicle runs a CHR-only aim list with a NULL
|
||
chr. `AircraftRecord.ailist` (`prop.c:1786`) is identical. BUNKER1/Silo
|
||
emit no vehicle/aircraft propDef → unaffected. All 6 crashing levels have
|
||
a vehicle or aircraft prop.
|
||
|
||
*Fix (converter only).* New `OBJ_ID_WORDS = {39:{32}, 40:{32}}` in
|
||
`tools_pc/d88_propdefs.py`; the `OBJ_TAIL_DESC` walk now emits
|
||
`_bswap32(w)` into the low 4 bytes of the 8-aligned slot for id words
|
||
(high 4 stay zero), so the u16/s32 id survives into the widened pointer
|
||
field. Words 32 removed from the 39/40 `ptr_word_set`. Struct size
|
||
unchanged → no `sizepropdef()` edit. TANK (45, `collision*`) and AUTOGUN
|
||
(13, `unkC4/unkC8/beam`) left as ptr/zero — verified genuinely
|
||
runtime-populated (`prop.c:1712` / `setupAutogun` `prop.c:694`). Regen:
|
||
`python tools_pc/d88_emit.py ntsc-final --regen` (21/21 ALL CHECKS
|
||
PASSED).
|
||
|
||
*Verified.* `chrIsNotDeadOrShot` crash gone on all 6. Dam 83%, Statue
|
||
80%, Frigate 68–90%, Cradle 77% non-clear frames, no crash log.
|
||
BUNKER1 (91.7%) + Silo (91.7%) unregressed. Runway and Streets now crash
|
||
**later** in fast3d (`palette_to_rgba32`/`import_texture_i4`
|
||
`gfx_pc.cpp:851`; `gfx_sp_matrix` `gfx_pc.cpp:1046`) — class C2/other
|
||
track, not C1. Frigate/Statue/Cradle/Dam reach a frame cleanly.
|
||
|
||
*Confidence: high* — root cause reproduced in gdb, fix is a 1-field
|
||
converter change matching the exact `prop.c` read pattern, and the id
|
||
value (0x040a) matches the N64 `UsetupdamZ.c` Vehicle record tail.
|
||
|
||
**D124 (session M-13) — crash class C2: fast3d handed a garbage texture
|
||
pointer on Facility (`-level_34`) and Jungle (`-level_37`).** Symptom:
|
||
early deterministic crash — Facility `import_texture_i8` `gfx_pc.cpp:821`
|
||
fault `0x72181ee8`; Jungle `gfx_tex_normalize_source` `gfx_pc.cpp:644`
|
||
fault `0xabcd0824` (= `IMAGESEG(0x824)`). Two *different* root causes:
|
||
|
||
*Jungle — FIXED.* `gimgSyncCompiledGlobalDLs()` (`port/src/gimgfixup.c`,
|
||
D68) is meant to copy the texture pointers that `texLoadFromDisplayList()`
|
||
patched into the ROM copy of the Globalimagetable back into the compiled
|
||
`globalDL_0xNNN` shadow arrays (`assets/oddtextures.c`) that `explosion.c`
|
||
executes via `g_ExplosionDisplayLists[]`. Its slot-detect test was
|
||
`p[6]==0xCD && p[7]==0xAB` — the *post-fixup marker* — but by the time it
|
||
runs `texLoad()` has **already replaced every IMAGESEG w1 in the ROM copy
|
||
with a real pointer**, so that test never matches and the sync copied
|
||
*nothing*. The compiled arrays kept their link-time `IMAGESEG(id) =
|
||
0xABCD0000|id` words; the first explosion/smoke/particle DL that
|
||
Facility/Jungle render feeds `0xABCDxxxx` straight into fast3d
|
||
`seg_addr()` (falls through to `return (void*)w1`) → `import_texture`
|
||
deref. The 7 "working" levels are only working because nothing triggered
|
||
an explosion-DL draw inside the ~22 s capture — **the bug was latent in
|
||
every level.** *Fix:* detect the slot from the **compiled** array instead
|
||
(`dst[j]` is a `G_SETTIMG` whose w1 is still `0xABCDxxxx`), then copy the
|
||
ROM copy's resolved `*(u32*)(p+4)`. `port/src/gimgfixup.c` only; no
|
||
converter/sidecar change. Verified: Jungle now renders ~300 frames
|
||
(91.7% non-clear) before a *separate* downstream crash (`gfx_sp_matrix`
|
||
`gfx_pc.cpp:1046`, bad G_MTX seg addr in the same explosion-DL stream —
|
||
those DLs also carry unconverted `G_MTX` words; follow-up, D75/matrix
|
||
family). BUNKER1 (83%) + Silo (91.7%) unregressed.
|
||
|
||
*Facility — NOT fixed (separate class).* Fault `0x72181ee8` is a bogus
|
||
`G_SETTIMG` w1 (`fmt=4 siz=2 w=0`, prev cmd an unconsumed `0xba` GE
|
||
tex-macro opcode) in a DL living in V1 DRAM — a **model/prop GDL from the
|
||
runtime `texLoadFromGdl()` relocation path** in `sub_GAME_7F0762E0`
|
||
(`objecthandler_2.c:82`), not a room GDL and not the global bank.
|
||
`GE_C2` probe of `texLoadFromGdl` shows its model-GDL calls (second batch,
|
||
`src≈0x706a****`) write to **non-16-aligned `dst`** (e.g.
|
||
`0x701eac01`, `0x701eb7d5`) — the `replacementgdl`/`name`(=srcsize)
|
||
offset arithmetic in `objecthandler_2.c` still mixes N64 8-byte and PC
|
||
16-byte `Gfx` strides, so converted commands land mid-slot and a
|
||
`G_SETTIMG` w1 reads as garbage. This is the open D80/D82/D83
|
||
"model/room GDL runtime conversion not verified" area — shared infra
|
||
touched by every level; out of C2 budget, left for a dedicated pass.
|
||
BUNKER1/Silo/Jungle don't hit it because their visible prop models
|
||
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/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
|
||
`objecthandler_2.c` not yet pinned).
|
||
|
||
**D125 (session M-14) — crash classes C3 + C6: the converted `propDefs`
|
||
blob in RAM does not match `tools_pc/d88_propdefs.py`'s offline output;
|
||
the `sizepropdef()` walk drifts, so every `pdefIndex`-keyed lookup
|
||
(`setupDoor` → `linkedDoor`, `weaponAssignToHome`, `modelLoad(modelid)`)
|
||
gets the wrong record.** NOT root-caused; investigation cut for a usage
|
||
reset. Affects Aztec (`-level_28`, `propobj.c:13523/:13601`), Bunker2
|
||
(`-level_27`, same `door7F054FB4`), Surface2 (`-level_43`,
|
||
`loadobjectmodel.c:393` `PitemZ_entries[modelid].header`).
|
||
|
||
*Evidence.* Instrumented the `proplvreset2` propDef walk
|
||
(`prop.c:1865`, `#if defined(PORT)` `getenv("GE_C3")` print of
|
||
`pdefIndex / phead->type / sizepropdef(phead) / phead / g_CurrentSetup.propDefs`)
|
||
and the `setupDoor` `linkedDoor` resolve (`prop.c:1206`). On `-level_27`
|
||
(`UsetupsevbZ`, `setup_text_pointers[27]`):
|
||
- `g_CurrentSetup.propDefs = 0x701ce814`.
|
||
- Runtime walk: idx0 `type=35` (WATCH_MENU, correct), then idx1–4
|
||
`type=0`, idx6 `type=112`, idx15 `type=26`, … — i.e. after the first
|
||
record the blob is **zeros / garbage**, but `sizepropdef` still
|
||
strides the phantom `type=35` at idx0/5/10 forward 16 B each.
|
||
- By the first real DOOR the walk has counted **147** records where the
|
||
offline converter (`convert_stream(UsetupsevbZ)`) puts it at **46**
|
||
(verified: offline output = 346 records, DOOR at idx 46, self-
|
||
consistent with `PROPDEF_PC_BYTES` == `sizepropdef()` PORT strides —
|
||
every type cross-checked against `d88_layoutprobe` sizes, all match).
|
||
- `setupDoor` then gets `arg2=147` for a stream-index-46 door;
|
||
`linkedDoorOffset(-1) + 147 = 146` → `setupGetPtrToCommandByIndex(146)`
|
||
returns a `type=0` record → `door->linkedDoor` garbage → fault
|
||
walking the linked-door list in `door7F054FB4`. Surface2's C6 is the
|
||
same mechanism one step earlier: a drifted `pdefIndex`/`modelid`.
|
||
|
||
*What is NOT the bug (ruled out).* (a) `d88_propdefs.py` `convert_stream`
|
||
itself — self-test passes, offline walk of all 3 crashing levels'
|
||
converted output is clean, DOOR/linkedDoorOffset values correct, stream
|
||
tiles `[propDefs, intro)` exactly (`end=OK` for all 21). (b)
|
||
`sizepropdef()` PORT strides — every `case` equals
|
||
`PROPDEF_PC_BYTES[type]/4` and equals the compiler `sizeof` of the widened
|
||
struct (checked via `d88_layoutprobe`). (c) DOOR converter layout —
|
||
`linkedDoorOffset@144`, `linkedDoor@216`, `unkcc@224`, size 296 all match
|
||
`d88_layoutprobe`; tail pointer-widening walk is correct end-to-end. (d)
|
||
The per-type histogram diff (brief's suggested approach): the 3 crashing
|
||
levels emit no propDef type absent from the 12 passing levels — TINTED_GLASS
|
||
and AUTOGUN appear in passing Caverns/Control too. **This is not a
|
||
forgotten-type converter gap like D122/D123.**
|
||
|
||
*Best hypothesis (M-14) — DISPROVEN (session M-15).* M-14 guessed the
|
||
converted `propdefs_pc` bytes were not landing in the sidecar at the
|
||
relocated `propDefs` offset (a `d88_emit.py` region-tiling / delta-reloc
|
||
bug, lines ~308-338/471-476). The M-13 overseer note refined this to a
|
||
pass-1 delta / region-`end` mismatch (`pd_end != pd_start + _n64len`).
|
||
**Both are wrong.** New diagnostic `tools_pc/d125_check.py` decompresses
|
||
the *emitted* `data/pccg-ntsc-final/pccg.bin` entry for every Usetup*Z,
|
||
reads the relocated `propDefs` header field, and byte-compares that slice
|
||
to a fresh `convert_stream()`:
|
||
|
||
- **All 21 levels: `MATCH`** (Aztec/Bunker2/Surface2 included). The
|
||
sidecar's propDefs blob, post-RZ-roundtrip, is byte-identical to the
|
||
converter output, placed exactly where the relocated header field
|
||
points.
|
||
- Instrumented `d88_emit.py` (D125_DEBUG, since reverted): for all 21,
|
||
`tiled_pd_end == H[intro]` exactly, `_n64len == pd_end - pd_start`
|
||
exactly, the region after `propdefs` in the sorted list is always
|
||
`intro`, and the growth `pclen - n64len` fed to `cum` is correct.
|
||
There is **no** region-end/delta bug at `d88_emit.py:308-343`.
|
||
- Full re-cross-check of `sizepropdef()` PORT strides vs
|
||
`PROPDEF_PC_BYTES/4`: all 28 types match (as M-14 already found).
|
||
|
||
**Therefore the offline pipeline (converter → emit → RZ compress) is
|
||
correct end-to-end. The "idx0 correct, then zeros" RAM signature M-14
|
||
observed must originate at or after runtime load** — candidates, in
|
||
rough order: (a) `decompressdata()` truncation — `port/src/rzdecomp.c`
|
||
inflates with `Z_FINISH` and a 4 MiB `avail_out`, loops while `Z_OK`;
|
||
if the deflate stream for these larger converted files hits `Z_BUF_ERROR`
|
||
or `Z_STREAM_END` mid-output the `while (ret == Z_OK)` exits early and
|
||
the tail of `dst` stays zero (needs a probe: log `ret` + `produced` vs
|
||
expected decompressed size for `UsetupsevbZ`); (b) the STAGE bank alloc /
|
||
`mempAllocBytesInBank` giving a buffer that is fine for the N64-size file
|
||
but the converted file is ~15-25% larger — check `mempGetBankSizeLeft`
|
||
at the `_fileNameLoadToBank(strResource, …, 256, MEMPOOL_STAGE)` call
|
||
(`prop.c:1271`) vs the decompressed size; (c) the `prop.c:1280-1308`
|
||
rebase loop or `langLoadToAddr` (`prop.c:1274`, runs right after the
|
||
load, same bank) overwriting the tail of the setup file. M-14's own
|
||
runtime dump had `g_CurrentSetup.propDefs = 0x701ce814` (`&15 == 4`).
|
||
|
||
*Next step.* Probe `decompressdata()` for `UsetupsevbZ`: print `ret`,
|
||
`produced`, and the first 64 bytes at `dst + 16` (offline record 1
|
||
start). If `produced` < the offline decompressed file size → truncation
|
||
(fix the inflate loop / `avail_in`). If `produced` is correct but the
|
||
bytes are still zero → something overwrites it post-load (bisect
|
||
`langLoadToAddr` / the rebase loop). `tools_pc/d125_check.py` stays in
|
||
tree as the offline-side regression guard.
|
||
|
||
*Probes:* **none left in tree** — the two `#if defined(PORT)`/`GE_C3`
|
||
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/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.
|
||
|
||
**D125 addendum (session M-16) — ROOT CAUSE FOUND: `d88_emit.py:374`
|
||
4-byte literal into an 8-byte slice; per-record buffer shrink; out-of-range
|
||
leaf writes insert at the current end → boundpad name blob drift.**
|
||
|
||
*Crash mechanism (Aztec, `-level_28`), fully traced.* Door idx 226
|
||
(`objid=307`, `pad=33`) is left with `model=NULL`/`prop=NULL` by
|
||
`setupDoor()`: its boundpad's plink name in the sidecar is `"8d2"`
|
||
(original ROM: `"p138d2"`) → `stanPackId()` rejects it (valid = `p`/`q`
|
||
+ decimal ≤32767 + letter + optional digit 0-7; probe line `D88
|
||
stanMatchTileName id=8d2 hi=ffff lo=ff` is the failure sentinel) →
|
||
`init_pathtable_something()` returns 0 with `*tile_stack=NULL` →
|
||
`getposstan()` nonzero → `setupDoor` sets `door->prop=NULL`, skips
|
||
`doorInit()` → later tick `door7F054FB4()` (`propobj.c:13601`) derefs
|
||
`door->model->obj`; `Model.obj` sits at PC offset 0x10 (widened `chr`
|
||
ptr) → fault addr 0x10, Rcx=0. Linked-door pair: idx225 (`pad=32`, valid)
|
||
has `linkedDoorOffset=1` → idx226. All 178 regular pads resolve fine; only
|
||
boundpad names are corrupt.
|
||
|
||
*The bug.* `tools_pc/d88_emit.py` `emit_pad()` line 374:
|
||
`out[dst_o + 0x30:dst_o + 0x38] = b"\x00\x00\x00\x00"` — the stan zero-fill
|
||
assigns a **4-byte** literal to an **8-byte** slice. Python silently allows
|
||
in-range short-RHS slice assignment, so each of Aztec's 179 pads + 148
|
||
boundpads shrinks the output `bytearray` by exactly 4 B (measured: after
|
||
header 0x14d1c → after pads 0x14a50 → after boundpads 0x14800). Later
|
||
verbatim leaf writes (the plink string blob, relocated to ~0x147EC+) then
|
||
target offsets at/past the shrunken tail; for those, Python clamps the
|
||
slice to an empty range **at the current end** and inserts the data there —
|
||
each string lands at the buffer tail instead of its relocated offset,
|
||
drifting earlier with every write. A simulation of exactly this semantics
|
||
reproduces the observed sidecar names byte-for-byte: pad30 `p139d2`
|
||
(intact), pad31 `139d2`, pad32 `37d1`, **pad33 `8d2`**, pad34 `2`,
|
||
pad35 `1`, pad36 `` (empty).
|
||
|
||
*Why it was missed.* No error is raised; the final file is only
|
||
`(n_pads+n_boundpads)*4 − re-extension` bytes short of `total_new`
|
||
(Aztec: 85276 vs 85145) because later leaf writes re-extend the buffer;
|
||
`d125_check.py` verified only the propDefs slice. M-14's "propDefs zeros in
|
||
RAM" signature was a misread — M-15 already proved sidecar propdefs
|
||
byte-correct, and M-16's post-load RAM dump matched the sidecar byte-for-
|
||
byte (85,145 B), so runtime load is clean too.
|
||
|
||
*Fix (pending).* Line 374 → `b"\x00\x00\x00\x00\x00\x00\x00\x00"` (8 NULs).
|
||
Audit of every other `out[` write in the file: all remaining slice/RHS
|
||
pairs are width-matched — this is the only one. **All 21 Usetup*Z sidecars
|
||
are affected to varying degrees** (shrink = 4×(pads+boundpads) per level;
|
||
which names drift depends on where the blob lands vs the shrunken tail) —
|
||
regen all: `python tools_pc/d88_emit.py ntsc-final --regen`, then re-run
|
||
`tools_pc/level_sweep.sh` (expect C3 Aztec+Bunker2 to clear; currently-
|
||
passing levels can only improve, but the sweep is the proof).
|
||
|
||
*Probes in tree (temporary — remove after fix verified):* `GE_D125`
|
||
env-gated prints in `port/src/rzdecomp.c` (decompress ret/produced),
|
||
`src/game/prop.c` (setup header + post-load 0x15000 dump to
|
||
`d125_setupdump.bin` + post-`setupDoor()` door state), `src/game/propobj.c`
|
||
(`door7F054FB4` NULL-model detect+skip, `objInit` failure print, plus temp
|
||
`#ifdef PORT` stdio/stdlib includes). Scratch: `tools_pc/d125_convtest.py`
|
||
(instrumented converter copy — source of the shrink evidence),
|
||
`tools_pc/d125_check.py` (**keep** — offline regression guard),
|
||
`tools_pc/d125_inflate_test.c`, `d125_offs_tmp.c`, `d125_t.c`,
|
||
`d125_run.log`, `d125_setupdump.bin`, `build-pc/d125conv/`.
|
||
|
||
**D125 addendum (session M-16b) — FIX LANDED.** `d88_emit.py:374` changed
|
||
to 8 NUL bytes (+ a warning comment). Offline proof (Aztec, throwaway
|
||
`d125_proof.py`): the fixed converter's boundpad plink names 30–36 read
|
||
`p139d2 p139d2 p837d1 p138d2 p85a2 p1377b1 p504d2` (= original ROM), 147
|
||
boundpad records recovered (M-14's "46" was the corrupt read — no second
|
||
bug). `d43/d69/d88 --regen` → 21/21; `d125_check.py` → 21/21 propDefs
|
||
still MATCH. Direct re-test: **Aztec `-level_28` PASSES** (91.0%, 900+
|
||
frames, was C3 CRASH at `propobj.c:13601`); Bunker1/Silo unregressed.
|
||
**Bunker2 `-level_27` still crashes** — moved to `door7F054FB4`
|
||
`propobj.c:13536` (`var_s1->openPosition`, `var_s1` = `0xffff…` from
|
||
`linkedDoor`). Separate bug: the DOOR-tail converter in `d88_propdefs.py`
|
||
(`DOOR_TAIL_PTR_WORDS` / `linkedDoorOffset` word 32) vs the compiled PC
|
||
`DoorRecord` layout — `linkedDoor` is resolved from `linkedDoorOffset`
|
||
(`prop.c:1204`), a read-before-write int id like D123's `ailist`; Bunker2
|
||
has linked double-doors, Aztec's are singletons so it dodged it. Folded
|
||
into the converter write-audit (C3 residual). Sweep: 12 → 13/21.
|
||
All M-16 probes reverted, scratch files deleted, `d125_check.py` kept.
|
||
|
||
**D126 (session M-17) — objective-subrecord `->next` pointer growth desyncs
|
||
the propdef walk (C3r Bunker2 + C4 Depot + C6 Surface2).** The Bunker2
|
||
`door7F054FB4` crash was NOT a DOOR-tail layout bug (the M-16b hypothesis).
|
||
Instrumenting the `proplvreset2` first-loop walk showed `pdefIndex` drifting
|
||
~100 ahead of the offline `convert_stream` record index; a byte dump proved
|
||
the RAM propDefs matched the sidecar exactly *at load* but had record N+1's
|
||
header zeroed *by the time the walk reached it*. Cause: four objective
|
||
sub-record types — `criteria_picture` (30), `criteria_roomentered` (32),
|
||
`criteria_deposit` (33), `setup_objective_text` (35) — are
|
||
`{ s32 x N; T *next; }`. `set_parent_cur_obj_photograph/enter_room/
|
||
deposited_in_room` and `setup_briefing_text_entry_parent` (`objective.c`)
|
||
write `arg0->next` unconditionally while the setup walk visits each record,
|
||
building a runtime linked list. On N64 `next` is a 4-byte word at the end of
|
||
a 16/20-byte record — no spill. On x86-64 the pointer widens to 8B and the
|
||
compiler 8-aligns it at offset 16, so the struct is 24B; the 8-byte
|
||
`->next` store lands at record+16..24, clobbering the *following* record's
|
||
`PropDefHeaderRecord` (type byte at +0x13). Walk desyncs → command indices
|
||
drift → `setupDoor`'s `setupGetPtrToCommandByIndex(linkedDoorOffset + arg2)`
|
||
returns the wrong record → `door->linkedDoor` chain walks into non-door
|
||
memory → crash. Depot (`prop.c:902` tile-walk room) and Surface2
|
||
(`PitemZ_entries[modelid]`, the C6 "D122 continuation") were the same
|
||
desync landing on different downstream derefs. Fix:
|
||
`tools_pc/d88_propdefs.py` `PROPDEF_PC_BYTES[30/32/33/35] = 24` + a typed
|
||
handler emitting the leading s32 words then an 8-byte zero `next`;
|
||
`src/game/loadobjectmodel.c` `sizepropdef` PORT returns 6 for those types
|
||
(N64 `#else` kept). Regen'd all 21 sidecars, `d125_check.py` 21/21 MATCH.
|
||
**Bunker2 `-level_27`, Depot `-level_30`, Surface2 `-level_43` now PASS**
|
||
(91.5% / 79.8% / 70.4%); Bunker1/Silo/Aztec/Archives/Egypt/Train
|
||
unregressed. Sweep 13 → 16/21. Generalisable quirk appended to
|
||
`porting-notes.md` §A. Remaining crashes: C2 Runway/Facility (model-GDL
|
||
align), C2m Jungle (`G_MTX`), C5 Control (BG portal), C7 Surface1
|
||
(`sndSetupSound`).
|
||
|
||
**D133 (session M-20) — intro "mostly black" is the D75/D76 steady state, not a
|
||
regression.** The M-18/M-19 handoff "Next task" listed "intro renders mostly
|
||
black" as a regression against the M-17 handoff line "the entire intro renders —
|
||
logos → gun barrel → cast".
|
||
|
||
Method: `git worktree add ../ge007-m17 9ec6121e` (COPY `data/`, never junction —
|
||
see the data-dir-junction-hazard memory), `./build-pc.sh ntsc-final`, capture
|
||
`GE_PCDUMP="20-900:20"`, `tools_pc/pixcount.py` per frame. Compared against the
|
||
integrator's HEAD (`0b5f5d1a`) capture.
|
||
|
||
| frames | M-17 `9ec6121e` | HEAD `0b5f5d1a` |
|
||
|---|---|---|
|
||
| 20–100 (legal screen) | non-clear **6677 (2.17%)**, bbox (50,265)-(589,446) | non-clear **6677 (2.17%)**, same bbox |
|
||
| 120–360 | 0–16 non-clear px (near-black) | 0–16 non-clear px |
|
||
| 380–400 (centred logo) | 20.5k–22.1k (6.7–7.2%), bbox ~(253,142)-(394,336) | 18k–24k (6–8%), bbox ~(250,138)-(390,340) |
|
||
|
||
The legal-screen frame is a static 2D framebuffer; a **pixel-identical**
|
||
non-clear count on two independently-built binaries is proof the render path is
|
||
unchanged. 2D/text/texture layers draw on both; the animated character-model
|
||
layers (Nintendo-logo transform, gun-barrel Bond figure, cast models) never
|
||
appear on either — this is **D75** verbatim ("skeletal/animated character models
|
||
never appear; non-animated 3D appears with a bad transform"). The M-17 "entire
|
||
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/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
|
||
`data/` was copied, not junctioned). Tree restored to `master`. Confidence:
|
||
**high** (pixel-identical legal-screen coverage; symptom is a verbatim match to
|
||
the open D75 description).
|
||
|
||
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/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
|
||
renders ~300 frames then AVs in `gfx_sp_matrix` (`gfx_pc.cpp:1046`,
|
||
`int32_t int_part = addr[…]`) with `addr = 0x401c68e0`. Caller is the `G_MTX`
|
||
case of `gfx_run_dl` → `seg_addr(cmd->words.w1)`.
|
||
|
||
Probe (`GE_MTXPROBE`, reverted) dumped the faulting DL — a well-formed model
|
||
render sequence in a per-frame DRAM GDL: `gSPMatrix(PROJECTION,LOAD)` w1 =
|
||
`0x700d8070` (valid), `gSPMatrix(MODELVIEW,LOAD)` w1 = `0x401acbe0` (bad),
|
||
`gSPMatrix(MODELVIEW,MUL)` w1 = `0x401c68e0` (bad, the fault), `gSPVertex`
|
||
w1 = `0x700da350` (valid). All lens `0x40`, opcodes sane — the walk is *not*
|
||
desynced, only the modelview-matrix pointers are wrong.
|
||
|
||
`0x401c68e0` == `(u32)(0x1_401c68e0)` — a compiled-module address with the
|
||
`0x1_00000000` high word dropped (module fixed-based at `0x140000000`, cf.
|
||
D94 `chraction.c:1243` comment). Source: `explosionRenderPropSmoke`
|
||
(`explosion.c:1501`) does
|
||
`gSPMatrix(gdl++, osVirtualToPhysical((void*)&dword_CODE_bss_8007A100),
|
||
G_MTX_NOPUSH|G_MTX_MUL|G_MTX_MODELVIEW)` — `&dword_CODE_bss_8007A100` is a
|
||
compiled `.bss` matrix; `osVirtualToPhysical()` is `(u32)(uintptr_t)va`
|
||
(`libultra.c:1207`), so the store truncates. The `[-1]` LOAD matrix is the
|
||
room matrix from `applyRoomMatrixToDisplayList` (same truncation path).
|
||
Why the projection matrix is fine: `get_BONDdata_field_10E0()` returns a
|
||
runtime `0x70xxxxxx` pointer that survives the `u32` cast. Why
|
||
`gSPDisplayList(&globalDL_0xNNN)` is fine: the port's `Gwords.w1` is a
|
||
64-bit `uintptr_t` (`include/PR/gbi.h:1730`) and `gDma1p` stores the full
|
||
pointer — only `osVirtualToPhysical` truncates.
|
||
|
||
~30 sites pass a compiled matrix/vtx symbol through `osVirtualToPhysical`
|
||
(`explosion.c`, `glass.c`/`glass2.c`, `blood_animation.c`, `bondview2.c`) —
|
||
each latent until that effect first draws, which is why only Jungle (an
|
||
early scripted explosion) tripped it in the no-input sweep.
|
||
|
||
**Fix** (`port/fast3d/gfx_pc.cpp` `seg_addr`): at the final fallthrough,
|
||
if `w1 ∈ [0x40000000, 0x70000000)` restore the module high word
|
||
(`(mod_hi | w1)`, `mod_hi` read from this TU's own load address). Everything
|
||
legit that reaches the fallthrough is either DRAM V1 (`≥0x70000000`), KSEG0
|
||
(`≥0x80000000`), an LSB-set / low-nibble segmented address, or a
|
||
sub-`0x800000` physical offset — all handled in earlier branches — so the
|
||
range is unambiguous. Narrow, ABI/pointer-width class (same as D94/D3x).
|
||
Verified: Jungle renders to logic-frame 2400+ crash-free at 91.7% non-clear;
|
||
`-level_20` (Silo) + `-level_24` (Archives) unregressed. **Sweep 18→19/21.**
|
||
|
||
Not fixed this session: **`-level_09` (BUNKER1) now crashes at boot**
|
||
(`frames=0`, PC `0x1400066fc`, fault `0x00a2fc68` — a stack pointer
|
||
`0x0fa2fc68` with the top nibble masked, i.e. another 28/24-bit segment-mask
|
||
applied to a real pointer). Reproduced on a **clean `git stash` checkout of
|
||
master `0a4b3bae`** → pre-existing regression, not caused by D131 (a
|
||
fast3d-only change cannot affect a pre-first-frame boot crash). Contradicts
|
||
the M-18 handoff's "`-level_09` unregressed" claim; likely fallout from a
|
||
D126/D128 propdef/portal change or an environmental sidecar/ROM mismatch.
|
||
Next session: `git bisect` D125→HEAD against `-level_09`. Silo also shows an
|
||
intermittent hang in the intro fly-down-to-Bond cinematic (user report;
|
||
not always reproducible — ran 300 frames clean here).
|
||
|
||
---
|
||
|
||
**D130 (session M-18) — Facility + Runway C2 crash was `romdataFixupFont`,
|
||
NOT the model-GDL relocation.** `-level_34` (and `-level_35`) fault in
|
||
`import_texture_i8` (`gfx_pc.cpp:821`) on `loaded_texture.addr = 0x72181ee8`,
|
||
an un-mapped wild pointer. gdb showed the fault DL is a 2D `G_TEXRECT` block
|
||
(`gfx_dp_texture_rectangle` → `gfx_sp_tri1(is_rect)`), with the wild pointer
|
||
baked into a preceding `G_SETTIMG` w1. Traced back through `GE_C2GDL` probes:
|
||
|
||
- `texLoadFromGdl` **never copies a `G_SETTIMG` (0xFD)** on these levels, and
|
||
`texWriteLoadToTmemAddr/Zero` are **never called** — so the M-14 addendum's
|
||
"model-GDL relocation writes non-16-aligned dst" hypothesis is wrong. A
|
||
probe in `sub_GAME_7F0762E0` also confirmed `gdl` from
|
||
`modelIterateDisplayLists` is still a **segmented `0x05xxxxxx` value**
|
||
(`modelPromoteNodeOffsetsToPointers` does NOT promote DL Primary/Secondary),
|
||
so the `Switches + (x & 0x00ffffff)` idiom is correct as written.
|
||
- The wild `G_SETTIMG` is emitted by `gDPLoadTextureBlock(gdl,
|
||
curchar->pixeldata, G_IM_FMT_I, G_IM_SIZ_8b, …)` in
|
||
`textRenderGlyphOutlined` (`textrelated.c`) while rendering the level-title
|
||
string "Chemical Warfare Facility #2" — specifically glyph `#` (and `"`).
|
||
- `curchar->pixeldata` for BankGothic/ZurichBold glyph indices **0, 1, 2** is
|
||
corrupt after `load_font_tables`: glyph 1 `.width = 0x01000000`, glyph 2
|
||
`.pixeldata = 0x020002e8` (→ `+= font_base` → wild).
|
||
|
||
Root cause: `romdataFixupFont` (`port/src/romdata.c`) re-lays-out the N64 24B
|
||
`fontchar` array into the PC 32B array **in place** — backward over glyphs,
|
||
forward over the 5 leading `u32` fields per glyph. PC−N64 stride = 8, so
|
||
`d = s + 8*i`; for `i ∈ {0,1,2}` `d` overlaps the 24-byte source read span
|
||
and an early `*(u32*)(d+4k) = bswap(*(u32*)(s+4k))` write clobbers a later
|
||
`k`'s source. Glyph 1 (`Δ=12`): k=3/k=4 read bytes k=0/k=1 already
|
||
overwrote → `width = bswap(bswap(index)) = index = 0x01000000`. Glyph 2
|
||
(`Δ=20`): k=0 writes `s+20`; line 507 then reads `s+20` for the pixeldata
|
||
offset → `o = index = 0x02000000` → `o >= pixStart` → remapped to
|
||
`pcPixOff + (0x02000000 − pixStart) ≈ 0x020002e8`. Confirmed exactly against
|
||
a raw-bytes dump. On N64 the array is read-only rodata — never re-laid-out —
|
||
so no bug there; this is purely a PC-relayout aliasing bug.
|
||
|
||
Fix: stage all six N64 fields into a local `u32 f[6]` before writing any
|
||
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 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
|
||
(91.3% / 91.7% / 90.8% non-clear). `-level_37` (Jungle) unchanged — still the
|
||
separate C2m `gfx_sp_matrix` explosion-DL `G_MTX` crash at ~frame 300 (D75
|
||
family). Sweep **16 → 18/21**. All probes reverted; only
|
||
`port/src/romdata.c` (11 lines) changed. Confidence: **high** (exact
|
||
byte-level match, deterministic value, 2 levels fixed, no regression).
|
||
|
||
**D124-Facility addendum (session M-14 — partial, NOT fixed, out of time).**
|
||
*(Superseded by D130 — the model-GDL hypothesis below was wrong.)*
|
||
Re-instrumented with a `GE_C2GDL` probe in `sub_GAME_7F0762E0`
|
||
(`objecthandler_2.c`) + a bad-`G_SETTIMG` catcher in fast3d
|
||
(`gfx_pc.cpp` G_SETTIMG case). Both probes **reverted** — tree is clean.
|
||
Findings:
|
||
|
||
1. *Exact crash cmd.* `-level_34` faults in `import_texture_ia16`
|
||
(`gfx_pc.cpp:757`, via `import_texture` :955) — NOT `import_texture_i8`
|
||
as the old note said. The offending command in the relocated model GDL
|
||
(dList base `0x7007b870`, cmd at `0x70080490`) is:
|
||
`w0=0xfd900000` (`G_SETTIMG`, fmt=7/rgba? siz per bits, w=0),
|
||
`w1=0x72181ee8`. `seg_addr(0x72181ee8)` returns it unchanged (top byte
|
||
0x72 is not a segment) → deref of unmapped DRAM → AV. Preceding slot is
|
||
`0xba 00 0e 02` (GE tex-load macro, w1=0) then a `00000000` slot.
|
||
2. *`Gfx` stride confirmed.* `Gwords{ uintptr_t w0; uintptr_t w1; }` →
|
||
on x86-64 `w0`@+0 (8B), `w1`@**+8** (8B), `sizeof(Gfx)==16`. The probe
|
||
dump (8-byte granular) shows w0 at slot+0 and w1 at slot+8, i.e. the
|
||
command is genuinely `{0xfd900000, 0x72181ee8}` — the converter wrote a
|
||
real 16-byte slot, it is not a half-slot artefact.
|
||
3. *`w1` is a bad `tex->data`.* This `G_SETTIMG` is emitted by
|
||
`gDPSetTextureImage(gdl++, .., tex->data)` inside
|
||
`texWriteLoadToTmemAddr` / `texWriteLoadToTmemZero` (`tex.c`). So the
|
||
**texture-pool** entry `tex` returned by `texFindInPool()` has a
|
||
garbage `.data` (should be a `0x05xxxxxx` seg-5 ref, resolved later by
|
||
fast3d `segmentPointers[5]`). i.e. the fault is (at least partly) in
|
||
the `texLoadFromModelFileHeader` → `texLoad` texture-pool path, driven
|
||
off `objheader->Textures`, **not** solely the `texLoadFromGdl`
|
||
command-stream copy. This partially contradicts the original
|
||
D124-Facility hypothesis.
|
||
4. *Alignment smoking gun.* `GE_C2GDL` probe shows **every** model file's
|
||
`objheader->Switches` base has `((uintptr_t)Switches & 15) == 1`
|
||
(odd!). `mempAllocBytesInBank` (`memp.c`) does **zero** alignment —
|
||
`allocation = pool->pos; pool->pos += bytes;` — so a single earlier
|
||
odd-sized bank alloc leaves the STAGE pool cursor permanently odd and
|
||
every subsequent model file loads at an odd address. `delta`
|
||
(`romremaining - pcremaining`, the scratch-relocation offset) is also
|
||
not 16-aligned (`&15` = 3, 7, 11 across files). `sub_GAME_7F0762E0`
|
||
then forms `dst = Switches + (replacementgdl & 0x00ffffff)` and
|
||
`src = Switches + gdloff + delta`; observed `dst&15` = 5, 7, 9, 13.
|
||
The N64 code assumes the file base is Gfx-aligned (8 on N64). The
|
||
converted model GDLs the sidecar carries also have per-DL offsets that
|
||
are only 4/8-aligned (`gdloff0 & 15` = 4, 8, 12), so even a 16-aligned
|
||
base would not make every DL 16-aligned.
|
||
- *Note:* an odd/misaligned base is self-consistent between the
|
||
converter writer (`Switches+off`) and the fast3d reader (seg-5 =
|
||
`Switches`, `+off`), so it does **not by itself** corrupt command
|
||
*content* on x86 (unaligned `uintptr_t` loads are tolerated). The
|
||
bad `tex->data` (#3) must come from an *offset* miscalc, likely the
|
||
model texture-blob offset assuming N64-sized (8B-Gfx) GDL extent
|
||
while the PC sidecar's GDLs are pre-expanded to 16B — the texture
|
||
data then sits at a stale/too-large offset (`0x72181ee8` is ~0x1.9M
|
||
past the bank end `~0x706a8000`), OR `texLoad` reads a `textures[i]`
|
||
descriptor at a wrong stride off the odd `objheader->Textures` base
|
||
(`(u8*)filedata + sizeof(ModelNode*)*numSwitches`, D43/D45 PORT line).
|
||
- *Next step:* probe `texLoad` (`image.c`) — dump `textures[i]`
|
||
`TextureID` / offset / resulting `tex->data` for `-level_34`, and
|
||
dump `objheader->Textures`, `numtextures`, `numSwitches`, and the
|
||
sidecar's texture-section layout. Compare against `d43_emit.py` /
|
||
`pcmodels` sidecar builder: does it emit model texture blob offsets
|
||
in N64 (8B-Gfx) or PC (16B-Gfx) GDL-extent terms? That converter is
|
||
the likely fix site (offline sidecar per the D43/D69/D88 rule),
|
||
with a fallback `#ifdef PORT` 16-align of the STAGE pool cursor /
|
||
the model file base in `mempAllocBytesInBank` or
|
||
`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/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).
|
||
|
||
---
|
||
|
||
## D186 — `Video.FpsCap` throttles the whole sim, not just presentation (M-39 diagnosed, M-42 clamp landed)
|
||
|
||
**Symptom (M-39, user report):** window title read "GoldenEye 007 - 1 fps";
|
||
the game had become a slideshow. Root cause was `Video.FpsCap = 10` in the
|
||
(gitignored) `data/ge007.ini` `[Video]` section — almost certainly dinged down
|
||
via the F10 options overlay (D184) during testing; ESC-close calls
|
||
`configSave()` so it persisted.
|
||
|
||
**Mechanism.** `gfx_set_target_fps(cfgFpsCap)` feeds `sync_framerate_with_timer()`
|
||
in `port/fast3d/gfx_sdl2.cpp`, which does `sysSleep(left)` + a `sysCpuRelax()`
|
||
busy-wait. That wait runs **inline on the game's scheduler thread**:
|
||
`src/sched.c __scExec` → `osSpTaskStartGo` (`port/src/libultra.c:1119`) →
|
||
`gfx_run` → `gfx_sdl_swap_buffers_begin` → `sync_framerate_with_timer`, and only
|
||
*after* it returns does `osSpTaskStartGo` post `OS_EVENT_SP`/`OS_EVENT_DP`. While
|
||
that thread is parked in the pacing wait it is not delivering VI-retrace events
|
||
either, so every game thread blocked on `osRecvMesg(retraceQ)` stalls with it —
|
||
the sim is dragged down to the render-cap rate. On console the RSP/RDP are
|
||
separate silicon and VI retrace is unconditional, so a "render cap" cannot slow
|
||
the CPU; there is no N64 analogue. Verified M-39 (`-level_09`, fixed 35 s
|
||
window): `FpsCap=10` → 5 frames; `FpsCap=30` → 5; `FpsCap=60` → frame ~300;
|
||
`FpsCap=0` → frame 900+.
|
||
|
||
**Landed (M-42, clamp only — `port/fast3d/gfx_sdl2.cpp` + `port/src/video.c`):**
|
||
any `0 < FpsCap < 30` is treated as `0` (uncapped) with a one-line warning, at
|
||
both the config-load site (`videoInit`, so a bad ini is normalised and persists
|
||
sane on the next `configSave()`) and the fast3d chokepoint (`gfx_sdl_set_target_fps`,
|
||
which also covers the live path from the F10 overlay). Caps ≥ 30 are unchanged.
|
||
Verified: `-level_09` framediff 3/3 within threshold at the default `FpsCap=0`
|
||
(golden-identical); with `FpsCap=10` the sim now runs to VI post ~1740 in a
|
||
30 s window (≈ the `FpsCap=0` rate) instead of ~300; `FpsCap=60` still paces
|
||
(~56/s, no warning).
|
||
|
||
**Still OWED (the real fix):** move the frame-pacing wait off the scheduler
|
||
thread so *any* cap only drops/duplicates presented frames without blocking
|
||
retrace delivery (e.g. pace in the host event-pump thread, or gate the swap
|
||
without sleeping the RSP path). Until then a low cap is refused rather than
|
||
supported. Same timing-compensation family as D117 / D134 / D155.
|
||
porting-notes.md §E.
|
||
|
||
---
|
||
|
||
## D183 — M-36 "Family A" texture line/pitch shear: DISPROVEN on `-level_36` (M-36)
|
||
|
||
**Brief:** fix the "diagonal grey static / comb interlacing" attributed to
|
||
`import_texture_*` assuming `full_image_line_size_bytes == line_size_bytes`
|
||
(`docs/dev/M-36-TRIAGE.md` Family A; bugs D176(b) Surface walls, D182(2)
|
||
file-select spiral). Repro: `-level_36` Surface, headless `GE_PCDUMP`.
|
||
|
||
### 1. The pitch hypothesis does not fire on Surface — measured, not argued
|
||
|
||
`full_image_line_size_bytes != line_size_bytes` can only be produced by
|
||
**`gfx_dp_load_tile`** (`gfx_pc.cpp:2166`, a windowed sub-rect load).
|
||
`gfx_dp_load_block` (`:2139`) sets `line = full = size_bytes` unconditionally,
|
||
so the two are equal by construction.
|
||
|
||
GE loads *every* texture through `gDPLoadBlock`: `texWriteLoadToTmemZero` /
|
||
`texWriteLoadToTmemAddr` (`src/game/tex.c:492,617`) emit
|
||
`gDPSetTextureImage` + `gDPSetTile(…, line = 0, …)` + `gDPLoadBlock` and never
|
||
a `gDPLoadTile`. Confirmed at runtime: `GE_DTEX` (now prints `line=`/`full=`
|
||
and a `<-- STRIDED` marker) over a Surface run — **0 of 64** logged imports
|
||
strided; the full `GE_TEXDUMP` set — **0 of 166**. So no importer on this level
|
||
ever does the flat-read-of-a-strided-source that the hypothesis needs.
|
||
|
||
### 2. The importers' width/height/pitch math is *verified correct*
|
||
|
||
New probe `GE_TEXRAW=1` (with `GE_TEXDUMP=1`) writes the raw source bytes
|
||
handed to each importer to `texdump/rNNN_f<fmt>_s<siz>_<w>x<h>.bin`. Scanning
|
||
each dump's vertical neighbour-difference over every candidate row pitch
|
||
4…128 finds the true pitch of the source image:
|
||
|
||
| texture | fmt/siz | tile | pitch fast3d uses | best-scoring pitch | vdiff |
|
||
|---|---|---|---|---|---|
|
||
| `r030` | IA8 | 54×54 | 56 | **56** | 0.22 (smooth) |
|
||
| `r021` | CI8 | 32×32 | 32 | **32** | 0.72 (smooth) |
|
||
| `r031` (the wall) | IA8 | 32×32 | 32 | none — flat ~3.7 at *every* pitch | — |
|
||
|
||
The importers pick exactly the pitch the data is stored at. (The 54→56 case is
|
||
`texAlignIndices`/`texChannelsToPixels` 8-byte row alignment,
|
||
`src/game/image.c:340,1740` — handled correctly today.)
|
||
|
||
Also re-tested and re-confirmed: applying `texSwapAltRowBytes`' odd-row u32-pair
|
||
swap to these dumps makes vdiff **worse** (0.22 → 1.57 on `r030`). **D159's
|
||
`#ifdef PORT` no-op is correct; do not re-enable it.**
|
||
|
||
### 3. What the Surface walls actually are
|
||
|
||
The cliff/perimeter wall texture is `GE_TEXI` fmt=3 siz=1 = **IA8, 32×32,
|
||
block size exactly 1024 B (no LOD chain)**. Its source bytes are a
|
||
high-frequency grey noise field: intensity nibble mean 6.5, full 0–15 range,
|
||
horizontal neighbour diff 2.98 / vertical 4.06 (uniform random ≈ 5.3); alpha
|
||
nibble is a constant 15. There is no pitch, no row swap and no bit-depth
|
||
reinterpretation that turns it into a coherent image — the *data itself* is
|
||
noise-like. It is decoded and uploaded faithfully; the on-screen "static" is
|
||
that texture tiled at ≈1 texel per pixel on the wall faces.
|
||
|
||
Two filtering theories were tested and **both failed** (temporary
|
||
`GE_MINFIX` probe in `gfx_opengl.cpp`, since reverted):
|
||
|
||
- forcing mip minification when the game point-samples
|
||
(`gfx_opengl.cpp:752` `linear_filter ? … : GL_NEAREST`) — **no pixel change**;
|
||
the walls are already `linear_filter == true`.
|
||
- forcing `mipmaps = true` on every sampler apply — **no change to the walls**,
|
||
and it *regressed* the 2D HUD ammo digits to white blocks.
|
||
|
||
So the walls already get `GL_LINEAR_MIPMAP_LINEAR`. Not a filter bug.
|
||
|
||
### 4. Where D176(b) actually goes next (unresolved)
|
||
|
||
Remaining candidates, in order:
|
||
|
||
1. **The wrong texture is bound to those faces.** The next decisive step is ROM
|
||
ground truth: decode Surface's texnums offline (the
|
||
`TEXTURE-GLITCH-ANALYSIS.md` §7 toolchain) and check whether *any* Surface
|
||
texture is this 32×32 IA8 noise field, or whether the rock wall should be a
|
||
different, larger, structured texture. If it exists in ROM as-is, D176(b) may
|
||
be **not a bug at all** (GE's Surface cliffs are a mottled grey rock) and the
|
||
user report is really about *tiling density*.
|
||
2. **UV / tiling scale** — the texture repeats far too densely across each wall
|
||
face. That is RC3/`Video.WrapFix` territory (D167) plus the `shifts`/`shiftt`
|
||
the game sets per LOD tile in `texWriteTileLods` (`tex.c:596` passes
|
||
`shifts = shiftt = lod`), which fast3d stores but the LOD-tile selection
|
||
(D107 "always base tile") may be mismatching.
|
||
3. **D182(2)** (file-select spiral after re-entry) is *not* covered by this
|
||
finding — it was never reproduced here, and it is the one symptom that could
|
||
still be a real `gfx_dp_load_tile` strided case (front-end `texLoadFromGdl`
|
||
paths do use windowed loads). The defensive fix in §5 would cover it if so.
|
||
|
||
**M-37 UPDATE (inconclusive, ~8-min timebox):** the §7 offline decoder + logs
|
||
live *outside* this checkout — a full offline ROM decode was not achievable.
|
||
Static findings: Surface's bg is `bg/bg_sevx_all_p.seg` (`bg.c:201`); the wall
|
||
texture is a `u16` image ID *embedded in the bg GDL stream*, not a static
|
||
per-level texnum list, so it cannot be grepped — needs a runtime `GE_DTEX`/
|
||
`GE_TEXRAW` correlation. `assets/images.def` has no rock/cliff names (only
|
||
`STATIC_NOISE 0x389`); Surface-named entries are mission-select thumbnails.
|
||
Leaning **"real texture / tiling-density" (candidate 2), low-med confidence** —
|
||
D183's own measurement (constant full alpha 0x0F + full-range noise intensity,
|
||
zero residual structure at any pitch) is the signature of an intentional grey
|
||
rock-detail tile, not a mis-decoded structured image. Decisive next steps:
|
||
(i) grep the *converted* `bg_sevx_all_p` GDL for opcode `0xC0` (`G_SETTEX`) —
|
||
settles the one remaining "wrong bind" scenario (stale tile state, TEXTURE-
|
||
GLITCH-ANALYSIS §2 RC1) offline; (ii) headless `-level_36` `GE_DTEX/TEXRAW=1`
|
||
logging the image ID bound to the cliff tris, look it up in `g_Textures[]`,
|
||
read + decode that ROM range per §4. Scratch note: M-37 session scratchpad
|
||
`D176b-rom-groundtruth.md`.
|
||
|
||
### 5. What shipped (`port/fast3d/gfx_pc.cpp` only)
|
||
|
||
- **De-stride in `import_texture()`**, before the format dispatch: when
|
||
`full_image_line_size_bytes > line_size_bytes`, compact the strided rows into
|
||
a contiguous scratch buffer and hand every importer a row-packed image with
|
||
`line == full`. This is the correct behaviour the seven `SUPPORT_CHECK
|
||
(full_image_line_size_bytes == line_size_bytes)` asserts (`gfx_pc.cpp:689,
|
||
710, 739, 765, 791, 818, 866`) merely assert against — and those asserts
|
||
compile out in the release build, so today the wrong read is silent. Only the
|
||
CI8 importer strides correctly on its own (`:893`); it becomes a no-op stride
|
||
after this change. **Strictly a no-op whenever `full == line`, which is
|
||
166/166 loads on Surface and every `gDPLoadBlock` in the game** — hence
|
||
golden-safe. Unverified as a *fix* (nothing in the current repro exercises it);
|
||
kept because it is provably-correct and removes a latent silent-corruption path.
|
||
- **`GE_DTEX`** now prints `line=`/`full=` and flags `<-- STRIDED (pitch shear?)`.
|
||
- **`GE_TEXRAW=1`** (alongside `GE_TEXDUMP=1`) dumps raw importer-input bytes.
|
||
|
||
### 6. Verification
|
||
|
||
- `-level_09` golden framediff: **3/3 within threshold** (200/320/440;
|
||
worst dmean 10.4, phash 21/1/4) — unchanged.
|
||
- `-level_36` before/after: **visually identical** (as expected — the de-stride
|
||
never fires there). The wall static is unchanged and remains open.
|
||
|
||
*Confidence:* "Family A pitch shear is not the Surface bug" — **high**
|
||
(runtime-counted, 0/166). "The wall texture is decoded correctly" — **high**
|
||
(independent offline pitch scan of the raw importer input). "The shipped
|
||
de-stride is golden-safe" — **high**. "What D176(b) really is" — **low**;
|
||
needs ROM ground truth for that texnum.
|
||
|
||
---
|
||
|
||
## D176(a) — Surface black sky: env data is correct, the cloud-sky emit renders nothing (M-36, partial)
|
||
|
||
Surface (`-level_36`) sky is solid **black** where the N64 shows a warm
|
||
sunset cloud gradient. Headless `-level_36` + `GE_D176=1` probe
|
||
(`src/game/bgfog.c` `fogLoadLevelEnvironment`, `src/game/sky.c` `skyRender`):
|
||
|
||
- **The environment data is right.** `fog_tables[]` matches `Id=36`,
|
||
`sizeof(EnvironmentRecord)=92`, `Sky.Clouds=1`, `Sky.RGB=96,96,128`,
|
||
`CloudRGB=240,120,30` (the warm sunset), `CloudRepeat(skyheight)=10000`,
|
||
`SkyImageId=0`, `IsWater=0`. So this is **not** a serialized-struct
|
||
byte-order bug like D178 — `fog_tables[]` is a compiled-in C initializer
|
||
and every field reads sane.
|
||
- **`skyRender` runs the cloud path** (not the `!Clouds` flat-fill early
|
||
return). Corner probe: `eye=(-467.7, 374.2, -7190.5)`,
|
||
`WaterConcavity=7.0`; the four screen-corner unproject rays come back
|
||
`c0/c1.y ≈ +62` (`skyIsScreenCornerInSky` → TRUE) and `c2/c3.y ≈ -13.5`
|
||
(→ FALSE). So the corner-classification switch value is
|
||
`(1<<3)|(1<<2)|0|0 = 12` — the "top half of the screen is sky" case,
|
||
which is correct for that camera.
|
||
- **Nothing draws.** Case 12 → `s1=4`, the sky region is built from the
|
||
edge-vertex path and handed to `sub_GAME_7F097388` (pure-C project +
|
||
perspective divide → screen coords) then `skyRenderTri` / `skyRenderFull`
|
||
with `texSelect(&skywaterimages[SkyImageId=0], …)` and a
|
||
`SHADE,ENV,TEXEL0,ENV` combine. The output is pure black, not even the
|
||
`env->Red/Green/Blue` (96,96,128) fill — so the sky polygons are either
|
||
degenerate after projection, culled, or the `skywaterimages[0]` bind is
|
||
failing in a way that kills the primitive.
|
||
|
||
**Not yet root-caused.** Next steps (needs ~1–2 h / a dedicated pass):
|
||
1. Probe `sub_GAME_7F097388`'s output `unk28/unk2c` (screen x/y ×4 subpixel)
|
||
for the 4 verts — are they on-screen and non-degenerate, or all clamped
|
||
to one edge? That splits "projection math wrong" from "emit path wrong".
|
||
2. Check `skywaterimages` (`src/game/image_bank.c:284`,
|
||
`globalbank_rdram_offset + GIMG_OFF(s_skywaterimages)`, PC offset
|
||
`0xFB4`) actually resolves to valid `sImageTableEntry` records on PC, and
|
||
that entry `[0]` (the Surface sky texture) loads — a bad global-image-bank
|
||
offset (D69/pccg family) would give `texSelect` a junk texture.
|
||
3. fast3d-side: trace whether the sky tris reach `gfx_sp_tri` at all and
|
||
with what verts/CC. `skyRenderTri` is a 500-line subdivided-tri emitter —
|
||
a PC vtx/DL bug there is plausible (cf. D75 model-transform family).
|
||
4. Cross-check another cloud-sky level (Dam `-level_33` exterior, Statue
|
||
`-level_22`) — if they're also black it's the emit path; if only Surface,
|
||
suspect that level's sky image / `skywaterimages[0]`.
|
||
|
||
`GE_D176=1` probe left in tree (`#ifdef PORT`, env-gated, inert):
|
||
`src/game/bgfog.c` + `src/game/sky.c`. Separate from D176(b) (the tree/rock
|
||
"grey static", Family A / a different investigation).
|
||
|
||
### D176(a) — M-37 UPDATE: ROOT-CAUSED (static analysis, high confidence)
|
||
|
||
The emit path is not "wrong" — **the PC software RSP deliberately discards it.**
|
||
`port/fast3d/gfx_pc.cpp:2901`:
|
||
|
||
```cpp
|
||
case (uint8_t)G_RDPHALF_1:
|
||
case (uint8_t)G_RDPHALF_2:
|
||
case (uint8_t)G_RDPHALF_CONT:
|
||
// on N64 skyRender uses these to render some types of skies and skybox water
|
||
// by issuing low-level ucode commands G_TRI_FILL and G_TRI_SHADE_TXTR
|
||
// the port renders the sky in a different manner
|
||
break;
|
||
```
|
||
|
||
`skyRenderTri` / `skyRenderFull` build their geometry **exclusively** as
|
||
`gImmp1(gdl++, G_RDPHALF_1 / G_RDPHALF_CONT / G_RDPHALF_2, …)` pairs — a bespoke
|
||
packed triangle-raster stream (command byte in bits 24–31 = `G_TRI_SHADE_TXTR`/
|
||
`G_TRI_FILL`, `0x00800000` backface flag, subpixel Y coords, then S15.16 edge X +
|
||
dX/dY slopes via `sub_GAME_7F094298`, then per-vertex RGBA shade + S/T/W tex-coord
|
||
gradients, `G_RDPHALF_2` terminator) that GE's modified RSP ucode (`gmain.s`)
|
||
interprets. There is **no** `gSPVertex`/`gSP*Triangle` fallback and **no**
|
||
fill-rect on the textured case-12 branch — only `viSetFillColor(env->RGB)` sets
|
||
FILL state that nothing consumes. → dropped → solid black.
|
||
|
||
Explains every prior observation: `!Clouds` levels use a plain `gDPFillRectangle`
|
||
(→ work today); `Clouds` levels (Surface, Statue `-level_22`, Frigate `-level_29`,
|
||
Dam exterior) go through the RDPHALF stream (→ black). The projector
|
||
`sub_GAME_7F097388` is pure float math off endian-clean matrices — not the bug.
|
||
|
||
**No PD-port shortcut:** `pd_port/port/fast3d/gfx_pc.cpp:2512` has the identical
|
||
no-op + comment and PD's `sky.c` emits the same stream; no port-side sky
|
||
replacement exists anywhere in the PD tree. The comment is aspirational in both.
|
||
|
||
**Fix (Phase 2, ~1 session, genuine new fast3d code):** decode the RDPHALF stream
|
||
in `gfx_pc.cpp` — accumulate words, on a `G_RDPHALF_1` carrying a tri opcode start
|
||
a primitive, synthesise screen-space `LoadedVertex[3]` (bypass model/proj
|
||
transform like the 2D `gSPTextureRectangle` path; screen XY = value/4 − viewport;
|
||
shade + ST from integrating the slopes over the vertex delta), rasterise with the
|
||
current combiner/tile, close on `G_RDPHALF_2`. Full stream spec + the alternative
|
||
`#ifdef PORT` sky.c option in the M-37 scratch note.
|
||
|
||
Cheap first step: headless `-level_22` / `-level_29` — confirm also black (proves
|
||
emit path, not a Surface asset).
|
||
|
||
### D176(a) — M-42: `-level_22` headless repro CONFIRMED
|
||
|
||
`-level_22` (Statue Park, night) at frame ~360, **bare boot, no input** — the
|
||
spawn/intro camera already faces the horizon: the entire upper half of the
|
||
frame is **pure black** `(0,0,0)` where N64 shows a moonlit sky; the park
|
||
pillars and ground render fine, HUD present. Confirms the defect is the shared
|
||
cloud-sky emit path (RDPHALF stream dropped), not a Surface-specific asset.
|
||
`-level_22` frame 360 is the cleanest headless verification target for the
|
||
RDPHALF-decoder fix — no `GE_INPUTSCRIPT` needed.
|
||
|
||
Note: `-level_36` / `-level_43` (Surface) bare-boot intro cameras point **down
|
||
at the terrain**, so a no-input capture there does not frame the sky. `-level_29`
|
||
(Streets, night) top-of-frame reads dim `(29,24,22)` not pure black — needs a
|
||
visual check to distinguish "dark night sky drawn" from "partial".
|
||
|
||
### D176(a) — M-43 UPDATE: full stream decode + two implementation paths (static analysis)
|
||
|
||
The M-37 scratch note (`D176a-sky-rootcause.md`) was never committed. This is the
|
||
recovered, complete spec, re-derived from `src/game/sky.c` and `include/PR/gbi.h`.
|
||
No build, no run — analysis only. **Implementation is still owed and is a full
|
||
session; this entry exists so the next pass starts from the format, not a
|
||
grep.**
|
||
|
||
**What the stream actually is.** `skyRenderTri` (`sky.c:1480`, textured tris),
|
||
`skyRenderFull` (`sky.c:1976`, textured quads = 2 tris) and the untextured
|
||
`G_TRI_FILL` path emit a **verbatim N64 RDP triangle command**, byte-for-byte,
|
||
chopped into 32-bit halves and carried as `gImmp1(G_RDPHALF_1 / _CONT / _2, word)`
|
||
pairs. GE's modified RSP ucode (`rsp/graphics/gmain.s`) does nothing clever with
|
||
them — it copies the words into a DMEM scratch buffer and DMAs the assembled
|
||
command straight to the RDP as a `G_TRI_FILL` (0xc8) or `G_TRI_SHADE_TXTR` (0xce)
|
||
edge-walked triangle. So "decode the stream" == "own an RDP triangle rasteriser
|
||
with edge + shade + S/T/W coefficient planes." fast3d has no such thing (it is an
|
||
RSP-level vertex/`gSP*Triangle` interpreter); `pd_port` has the identical no-op.
|
||
|
||
**Word-by-word layout** (all values are S15.16 or int/frac-split 16.16 unless
|
||
noted; `sub_GAME_7F094298(f)` = clamp ±32767.9 then `(s32)(f * 65536)`, i.e. a
|
||
float→S15.16 fixed convert):
|
||
|
||
1. *Edge header* — 2 words:
|
||
- `w0 = (opcode<<24) | (backface ? 0x00800000 : 0) | (s32)YL`
|
||
where opcode is `G_TRI_SHADE_TXTR` or `G_TRI_FILL`, backface = `sp444 < 0`
|
||
(signed double area after the 3-way `unk2c` vertex sort), `YL = sp47c->unk2c`
|
||
(lowest vertex, screen-Y × 4, subpixel).
|
||
- `w1 = ((s32)YM << 16) | (s32)YL_mid` → actually `(s32)sp480->unk2c << 16 |
|
||
(s32)sp484->unk2c` = `YM<<16 | YH` (mid, high). N64 order is YL/YM/YH; here
|
||
the code sorts so `sp484 ≤ sp480 ≤ sp47c` on `unk2c`, then emits
|
||
hi=sp484, mid=sp480, lo=sp47c.
|
||
2. *Edge slopes* — 3 word-pairs, `(X.16.16 , dXdy.16.16)` for the L, M, H edges:
|
||
- `(7F094298(sp480->unk28 * 0.25), 7F094298(sp384))` — XL, DxLDy
|
||
- `(7F094298(sp410), 7F094298(sp394))` — XH, DxHDy
|
||
- `(7F094298(sp408), 7F094298(sp38c))` — XM, DxMDy
|
||
`sp384/38c/394` are the edge inverse-slopes (`dx/dy`) clamped to ±1877;
|
||
`sp408/sp410` are the H/M edge X starting values back-stepped to the YH
|
||
scanline (`sp37c` = sub-pixel fraction of `sp484->unk2c * 0.25`).
|
||
`*0.25` because `unk28/unk2c` are stored ×4.
|
||
**`skyRenderTri` returns here if `!textured`** (G_TRI_FILL: header + edges
|
||
only).
|
||
3. *Shade coefficients* (textured only) — 8 word-pairs = the RDP shade DMEM
|
||
block: RGBA base, DrDx/DgDx/DbDx/DaDx, DrDe.., DrDy.. each as an int-parts
|
||
word then a frac-parts word (`(v & 0xffff0000) | (next & 0xffff0000) >> 16`
|
||
packs two ints; the matching `<<16 | (next & 0xffff)` packs the fracs). Values:
|
||
`sp210[0..3]` = colour at origin, `sp290[0..3]` = d/dx, `sp2b0[0..3]` = d/dy,
|
||
`sp230[0..3]` = d/de, all run through `7F094298`. Colours are the per-vertex
|
||
`SkyRelated38.rgba` barycentrically solved to a plane via `sp440`
|
||
(= 1/doubled-area) and the `sp3c8..sp3d4` edge deltas.
|
||
4. *Texture coefficients* (textured only) — 8 word-pairs, same int/frac packing,
|
||
for S, T, W: `sp210[4..6]`, `sp290[4..6]`, `sp2b0[4..6]`, `sp230[4..6]`, each
|
||
scaled by `sp190` (an LOD/overflow clamp: `1/max(perspective-corrected
|
||
gradient magnitude / 1024, 1)`). The final pair is emitted with
|
||
`G_RDPHALF_2` (not `_CONT`) — **that is the stream terminator.**
|
||
|
||
Total data words: **2 + 6 + 16 + 16 = 40** for `G_TRI_SHADE_TXTR` (20 RDPHALF
|
||
pairs), **2 + 6 = 8** for `G_TRI_FILL`. The 2-word "header" is exactly the raw
|
||
N64 RDP triangle command's word0 (`[cmd 8b][backface 1b @23]...[YL 14b]`) +
|
||
word1 (`[YM 14b][YH 14b]`) — matches the canonical hardware format; the shade
|
||
and texture blocks are the standard 16-word int-halves-then-frac-halves RDP
|
||
coefficient layout (cross-checked M-43 against `include/PR/gbi.h` immediate-word
|
||
packing conventions — the low-level RDP tri macros themselves are RSP-internal
|
||
and not in this tree's `gbi.h`).
|
||
|
||
`skyRenderFull` is the same but solves the plane over 4 corner verts and emits
|
||
the quad as two RDP tris sharing the coefficient blocks; its own header pair also
|
||
ends the first tri and a second header starts the second.
|
||
|
||
**Vertex data available upstream** (before the DL): `sub_GAME_7F097388`
|
||
(`sky.c:1398`) fully projects each sky corner and writes `SkyRelated38`:
|
||
`unk28` = screen X ×4, `unk2c` = screen Y ×4 (minus `WaterConcavity*4`),
|
||
`unk30` = screen Z (0..0x7fff), `unk34` = 1/w, `unk20/unk24` = S/T, `rgba` =
|
||
per-vertex colour. **This is a complete screen-space vertex.** The projection is
|
||
pure float math off endian-clean matrices — not the bug, and reusable as-is.
|
||
|
||
---
|
||
|
||
**Path A — RDP triangle rasteriser in `port/fast3d/gfx_pc.cpp` (port-only, "correct").**
|
||
At `case G_RDPHALF_*` (`gfx_pc.cpp:~2901`): accumulate words into a small buffer;
|
||
when a `G_RDPHALF_1` carries a `0xc8`/`0xce` opcode start a command, close on
|
||
`G_RDPHALF_2`. Then either (a) feed a real edge-walk rasteriser writing into the
|
||
current render target with the active combiner/tile, or (b) **invert the plane
|
||
equations** — you have YH/YM/YL, the three edge X-at-Y and slopes, so recover the
|
||
3 screen (x,y); evaluate the RGBA and STW planes at those 3 points to get 3
|
||
`LoadedVertex` (already-projected: set `.x/.y` from screen coords mapped to
|
||
NDC-ish like the `gDPTextureRectangle` path, `.z` from a fixed sky depth, `.w`
|
||
from `unk34`), then hand to the existing `gfx_sp_tri` / GL path with a forced
|
||
"2D, no model matrix" flag. (b) is a few hundred lines and reuses the whole
|
||
existing combiner/texture pipeline; (a) means a new software rasteriser. Prefer
|
||
(b). Cost: ~1 session, genuinely new code, verifiable headless.
|
||
|
||
**Path B — swap the emit in `src/game/sky.c` behind `#ifdef PORT` ("cheap").**
|
||
`skyRenderTri` / `skyRenderFull` already hold 3–4 fully-projected screen-space
|
||
`SkyRelated38` verts. Under `#ifdef PORT`, skip the whole RDPHALF block and emit
|
||
a normal `gSPVertex` of screen-space `Vtx` (XYZ from `unk28/2c/30` ÷4, ST from
|
||
`unk20/24`, RGBA from the struct) + `gSP1Triangle` / `gSP2Triangles`, with a
|
||
`G_TEXTURE`/combiner setup mirroring what the RDP path implied
|
||
(`SHADE,ENV,TEXEL0,ENV`, `texSelect(&skywaterimages[SkyImageId], …)` already runs
|
||
just before). fast3d already eats screen-space verts (that is the front-end / HUD
|
||
path). ~30–60 lines, no new fast3d code. **Cost: a few hours.** Tension with the
|
||
"no game-logic edits" rule — but this is a pure N64-RSP-idiom substitution (same
|
||
class as the `G_TRI4` and dynamic-lighting `#ifdef PORT`s already in `src/`), it
|
||
changes no behaviour on N64 (`#else` keeps the stream verbatim), and it is
|
||
exactly the "if a game file seems to need a behavioural change, the fix belongs
|
||
in `port/`" *narrow* exception for a hardware idiom that cannot be isolated in
|
||
`port/` without reimplementing the RDP. Document as D176(a) in §F if taken.
|
||
|
||
**Recommendation:** Path B first — it lights up every cloud-sky level
|
||
(Surface `-level_36`, Statue `-level_22`, Frigate `-level_29`, Dam exterior) for
|
||
a few hours' work and de-risks Path A by giving a visual ground truth. Path A
|
||
stays the "right" long-term answer if Path B's screen-space verts show
|
||
seams/precision artefacts vs. the RDP's subpixel edge walk. Either way the
|
||
verification target is **`-level_22` (Statue, night) frame ~360, no input** — the
|
||
M-42 clean headless black-sky repro.
|
||
|
||
**Cheap first step (unchanged, still not done):** headless `-level_22` /
|
||
`-level_29` — confirm also black, proving it is the shared emit path and not a
|
||
Surface-only asset.
|
||
|
||
### D176(a) — M-46: Path B implemented (`src/game/sky.c`, `#ifdef PORT`)
|
||
|
||
Path B is in. `src/game/sky.c` only; N64 path kept byte-for-byte under `#else`.
|
||
|
||
**What changed.** A new file-scope `#ifdef PORT` helper `skyPortRenderPoly(gdl,
|
||
SkyRelated38 **v, nverts)` and an early `#ifdef PORT` return in both
|
||
`skyRenderTri` (3 verts, `arg1..arg3`) and `skyRenderFull` (4 verts,
|
||
`arg1..arg4`), placed right after the `sp444/sp488 == 0` degenerate-area guard
|
||
and `1.0f / area` so all shared early-outs still run. The helper:
|
||
|
||
- `dynAllocateVertices(n)` + two `dynAllocateMatrix()` from the vtx pool
|
||
(same lifetime idiom as the bondview2 watch-gauge ortho overlay and
|
||
explosion.c billboards).
|
||
- `guOrtho(proj, l, l+w, t+h, t, -32768, 32768, 1)` where `l/t/w/h =
|
||
getPlayer_c_screen{left,top,width,height}()` — maps the already-projected
|
||
pixel coords 1:1 into clip space, Y flipped (screen-down → NDC-up); identity
|
||
modelview. `gSPMatrix` LOAD PROJECTION then MODELVIEW. `bg.c` reloads the
|
||
perspective projection at the top of `bgLevelRender` (lines 638/689/…), so
|
||
leaving ortho loaded after `skyRender` is safe.
|
||
- Per vertex: `ob = {unk28, unk2c, unk30} * 0.25` (the `*4` subpixel scale the
|
||
RDP path used); `tc = {unk20, unk24} * 32` (standard N64 S10.5); `cn = rgba`
|
||
from the struct.
|
||
- `gSPClearGeometryMode(G_LIGHTING|G_CULL_BOTH|G_FOG)` +
|
||
`gSPSetGeometryMode(G_SHADE|G_SHADING_SMOOTH)`, then `gSPVertex` +
|
||
`gSP1Triangle(0,1,2)` (tri) / `gSP2Triangles(0,1,3, 0,3,2)` (quad, winding
|
||
irrelevant with culling off). Combiner (`SHADE,ENV,TEXEL0,ENV`), env colour
|
||
and `texSelect(&skywaterimages[SkyImageId/WaterImageId], 1,0,2)` are already
|
||
emitted by the caller immediately before — the helper does not touch them.
|
||
|
||
**Verified.** `src/game/sky.c` compiles clean under the real PC build flags
|
||
(`gcc.exe … -DPORT=1 -std=gnu11 -Wall`, exit 0, no new warnings) with the
|
||
worktree's `build-pc/` CMake configuration. Full link + run NOT done: the
|
||
worktree has no `data/` ROM so assets can't be extracted here. Every symbol the
|
||
helper uses (`dynAllocate*`, `guOrtho`, `guMtxIdent`, `gSPVertex`,
|
||
`gSP{1,2}Triangle{,s}`, `osVirtualToPhysical`) is already linked by the compiled
|
||
set, so link is expected clean.
|
||
|
||
**Owed to a human (cannot headless-verify):**
|
||
1. Eyeball `-level_22` (Statue, night) frame ~360, bare boot no input — the
|
||
M-42 black-sky repro. Expect a rendered moonlit sky in the upper half.
|
||
Also spot-check `-level_36` (Surface), `-level_29` (Streets).
|
||
2. **Untested risk — texture scale.** `tc = unk20/unk24 * 32` is the
|
||
conventional guess; `unk20/unk24` come out of `sub_GAME_7F097388` as
|
||
`unk0c * (65535/65536)` with `unk0c ≈ worldX*0.1` for clouds. If the sky
|
||
texture is tiled wrong / smeared, the fix is localised to the two `tc[]`
|
||
lines in `skyPortRenderPoly` (multiplier, or an explicit `gSPTexture`
|
||
scaling override) — no other code moves.
|
||
3. **Untested risk — ortho Z / depth.** `ob[2] = unk30*0.25` in `[0,8192]`
|
||
under an `[-32768,32768]` ortho; sky renders first into a cleared buffer so
|
||
depth ordering should not matter, but if the sky Z-fights or is culled,
|
||
drop `ob[2]` to 0.
|
||
4. Seam/precision check vs the N64 RDP subpixel edge-walk (the Path A
|
||
motivation) — only relevant once it renders at all.
|
||
|
||
**Confidence:** MODERATE. The emit-path substitution is sound and matches the
|
||
established screen-space-overlay idiom; compile is clean. The two numeric
|
||
unknowns (texture scale, and whether fast3d's viewport transform composes with
|
||
this ortho exactly as the projection math intends) need one visual check.
|
||
|
||
### D176(a) — M-82: PR #18 rebased; Defect 1 (texcoord) fixed by static analysis
|
||
|
||
- **PR #18 rebased onto `main`** (was 5 days stale, `docs/porting-notes.md`
|
||
conflict resolved). Builds clean; `bunker1` verify PASS, `worst_cell`
|
||
unchanged (sky.c change is `#ifdef PORT` and only touches cloud-sky levels).
|
||
- **Defect 1 root-caused, no visual iteration needed.** The M-46 "untested
|
||
risk — texture scale" is resolved:
|
||
- Cloud tile = `s_skywaterimages[0]` = `IMAGE_CLOUDS_GRAYSCALE`, **64×64
|
||
IA8, `G_TX_WRAP`** (`assets/oddtextures.c:646`). It *tiles* — S/T are not
|
||
[0,1) fractions.
|
||
- GE's own screen-space textured-quad idiom is `tc = width << 5`
|
||
(`src/game/glass2.c:678`), so the patch's `× 32.0f` **is the right scale**.
|
||
- `unk20`/`unk24` are texel coords reaching ±20 000 near the horizon (M-47),
|
||
so `(s16)(S × 32)` **overflows** → the streak.
|
||
- **Fix (committed):** per-primitive phase-fold — subtract the same
|
||
multiple of the 64-texel tile period from every vertex of one triangle
|
||
before scaling. Preserves inter-vertex deltas (hence the interpolated
|
||
texture); keeps `tc` in s16 range. Exact for any triangle whose S/T span
|
||
is < ~1000 texels — i.e. everything except triangles straddling the
|
||
horizon line, which remain Defect 2.
|
||
- **Defect 2 (coverage / horizon-straddling tris) still open** → adaptive
|
||
tessellation, denser toward the horizon.
|
||
- **Owed:** one `-level_22` visual check — see `docs/dev/D176a-SKY-NOTES.md`
|
||
"Verification ask".
|
||
|
||
### D176(a) — fidelity pass: "scrolls too fast / tiles too visibly" ROOT-CAUSED + FIXED (linear vs. perspective-correct tc interpolation)
|
||
|
||
M-84/M-87 playtest found Path B partially working: where the sky renders it
|
||
"scrolls too fast and repeats too visibly" (tiling period reads too short).
|
||
Root-caused with a `GE_D176SCROLL` probe (temporarily added to
|
||
`skyPortRenderPoly`, removed after use — same throwaway-probe pattern as
|
||
`GE_D176`):
|
||
|
||
- **`skyPortRenderPoly` placed every sky vertex under a plain pixel-space
|
||
`guOrtho` projection**, i.e. every vertex got clip `w = 1`. fast3d's normal
|
||
GPU pipeline then interpolates `tc` (and every other varying) *linearly in
|
||
screen space* across the primitive — correct only when every vertex shares
|
||
the same `w`.
|
||
- **They don't.** `unk20`/`unk24` (S/T, texel units) are computed upstream
|
||
(`sub_GAME_7F097388`, `sky.c:1401`) from the *world-space* cloud-plane
|
||
intersection, with **no perspective divide applied** — exactly like the
|
||
real RDP's `G_TRI_SHADE_TXTR` coefficient block, which carries `S·w′`/`T·w′`/`w′`
|
||
triples (`docs/dev/D176a-SKY-NOTES.md` wire-format section) and relies on
|
||
the RDP's own perspective-correct texture unit to divide back out per
|
||
pixel. `SkyRelated38.unk0c` (a field Path B wasn't reading before this fix)
|
||
already carries that raw camera-space `w` (`sky.c:1450`,
|
||
`arg5->unk0c = sp68[3]` — the same `w` sub_GAME_7F097388 itself divides by
|
||
to get screen X/Y, so it's directly reusable).
|
||
- **Measured on `-level_22` (Statue), frame ~330:** one sky quad's four
|
||
corners had camera-space `w` = 5469/5469 (top edge, near) vs.
|
||
207300/207257 (bottom edge, at the horizon) — a **38x ratio** — over an S
|
||
span of ~30000 texels. Linear interpolation smears that whole 30000-texel
|
||
delta evenly across every screen pixel of the quad; perspective-correct
|
||
interpolation concentrates it almost entirely into the last few pixels
|
||
next to the horizon-grazing vertices (hand-computed check: naive linear
|
||
interpolation at the screen-space midpoint gives S≈-9090, true
|
||
perspective-correct gives S≈-884 — an order of magnitude apart). The
|
||
linear version reads exactly as reported: a uniformly dense, aliased
|
||
"hatching" over the *entire* visible sky instead of a gentle gradient that
|
||
only gets busy right at the horizon.
|
||
- **Fix (`src/game/sky.c`, `skyPortRenderPoly`, `#ifdef PORT`):** replaced
|
||
the `guOrtho` load with a hand-built projection matrix that gives each
|
||
vertex its *real* camera-space `w` (from `unk0c`) instead of `w=1`, while
|
||
keeping every vertex's screen-space `x,y` position bit-identical (the
|
||
matrix is constructed so `clip.xy = ndc.xy * w` and `clip.w = w`, so the
|
||
GPU's perspective divide recovers the same `ndc.xy` regardless of `w` —
|
||
only the *interpolation* across the primitive changes). `Vtx.ob[]` is
|
||
`s16` and can't hold raw `w` (up to ~2×10^5 near the horizon), so each
|
||
vertex pre-divides by a per-primitive `wScale` (`max|w| / 30000` when that
|
||
exceeds 30000, else 1) and the projection matrix multiplies back by
|
||
`wScale` for `clip.x/y/w`; `ob[2]` (unused for depth — the sky draws first
|
||
into a cleared buffer, M-46) carries `w/wScale` through to the matrix's
|
||
w-column. This rides the exact same fast3d vertex/triangle/GLSL pipeline
|
||
every other textured draw in the game already depends on for correct
|
||
perspective (`gl_Position = aVtxPos;`, `port/fast3d/gfx_opengl.cpp:320` —
|
||
default GLSL `varying` interpolation is perspective-correct whenever
|
||
`w != 1`); no `port/fast3d` changes.
|
||
- **Verified:**
|
||
- `tools_pc/verify.sh bunker1` — PASS, `frames=3`, no crash (non-sky level,
|
||
confirms no regression).
|
||
- `-level_22` (Statue) and `-level_41` (Cradle) headless `GE_PCDUMP`
|
||
captures, before/after, eyeballed. The intro camera pan isn't frame-exact
|
||
between runs (small wall-clock-driven timing drift in the pre-gameplay
|
||
camera — a separate, out-of-scope-for-this-task timing question, not the
|
||
D193/D209 gameplay-tick class which is already fixed), so an exact
|
||
per-pixel diff isn't reliable, but the *qualitative* pattern change is
|
||
unambiguous and reproduces every run: before, uniform parallel aliased
|
||
streaks over the whole sky; after, a proper perspective-converging fan —
|
||
coarse near the top (near vertices), converging to fine detail only near
|
||
the horizon edge (far vertices) — the textbook look of a textured plane
|
||
correctly extending to a horizon. Cradle (`-level_41`) sky at default FOV
|
||
renders a normal blue sky with cloud streaks, not black.
|
||
- Not verified: an actual side-by-side against real N64 footage (none
|
||
available in this environment), and whether the residual "busy" look at
|
||
the very top of Cradle's sky (still fairly dense) is acceptable or needs
|
||
Defect 2's tessellation on top — see below.
|
||
- **Interaction with Defect 2 (coverage / horizon-straddling tris, still
|
||
open):** this fix corrects the interpolation *mode* (linear → perspective)
|
||
but does not add tessellation. A primitive whose w-ratio between vertices
|
||
is this extreme will still show real (now correctly-shaped, but still
|
||
dense) detail concentrated at its horizon edge — finer subdivision near
|
||
the horizon (Defect 2) is what would spread that out further and is still
|
||
the right follow-up for full visual quality. Defect 2 itself (vertical
|
||
coverage / horizon-straddling triangles needing adaptive tessellation,
|
||
and the separate FOV-coupled black-sky-past-frustum-edge symptom on Cradle
|
||
at high FovScale, D218/D222-adjacent) is **unchanged/still open** — not
|
||
attempted this pass.
|
||
- **Confidence:** HIGH on root cause (confirmed numerically: measured 38x
|
||
`w` ratio + hand-computed order-of-magnitude interpolation error) and on
|
||
the fix being a real, correct improvement (reproducible qualitative
|
||
before/after pattern change, matches the mathematically expected
|
||
perspective-converging shape). MODERATE on "fully resolves the reported
|
||
symptom to N64 parity" — no N64 reference capture to diff against in this
|
||
environment; a human should eyeball `-level_22` and `-level_41` and say
|
||
whether the remaining density right at the horizon reads as acceptable or
|
||
still warrants Defect 2's tessellation work.
|
||
- **Porting-notes quirk added:** `docs/porting-notes.md` §D — screen-space
|
||
"already-projected" GBI overlays (HUD/front-end idiom) are only safe to
|
||
place under an identity/orthographic (`w=1`) transform when every vertex
|
||
in the primitive really does share one depth/scale; a primitive whose
|
||
per-vertex data was computed via a *world-space* perspective projection
|
||
upstream (sky, and potentially other N64 RDP-immediate idioms) needs its
|
||
real `w` preserved through to the GPU, or every non-positional varying
|
||
(texture coords, vertex colour) interpolates wrong across the primitive.
|
||
|
||
---
|
||
|
||
## D177 — Ladders non-functional: `count`/`rooms` land in the high half of a widened pointer (M-36)
|
||
|
||
**FIXED (M-36).** Climbing was completely dead — `MoveBond`
|
||
(`src/game/bondview2.c`) probes for a ladder tile every frame via
|
||
`stanTileDistanceRelated(&curLocus)` and gates the whole ladder-collision
|
||
path on `stanGetLocusCount(&curLocus)`, which was always 0 on PC.
|
||
|
||
Two compounding pointer-width ABI bugs (same class as D79/D90), all in the
|
||
stan navigation layer — **no game logic touched**:
|
||
|
||
1. **The ladder signal was written into the wrong half of a pointer.**
|
||
`stanCheckLinkedSpecialTile` receives the caller's
|
||
`struct StandTileLocusCallbackRecord` typed as `s32 *outFlags` and does
|
||
raw `outFlags[0] = 1` (FORCECROUCH) / `outFlags[1] = 1` (LADDER). The
|
||
record's first member is `s32 *rooms` — 4 bytes on N64, **8 on PC** — so
|
||
on x86-64 `outFlags[1]` is the *upper* 4 bytes of `rooms`, and `count`
|
||
(which moved from +4 to +8) never gets written. `stanGetLocusCount()`
|
||
reads `record->count` → still 0 → ladder path never runs. FORCECROUCH
|
||
survived by luck (LE low half of `rooms` is at +0, and
|
||
`stanGetLocusField0` truncates it back). Fix: `#ifdef PORT` cast to the
|
||
real struct and write `->rooms` / `->count` by name.
|
||
2. **`curLocus` was too small to hold the record.** It was declared
|
||
`struct move_bond_temp_struct` — a 2-word "placeholder while matching"
|
||
(`bondview.h`). 8 bytes on N64 = exactly the record; on PC the record is
|
||
larger, so `stanTileDistanceRelated`'s field writes (the D90 zero-fill)
|
||
and the fixed `count` store overflowed the local. Fix: `#ifdef PORT`
|
||
declare it as the real `struct StandTileLocusCallbackRecord`.
|
||
3. **`stanGetMoveBondCollisionTiles` would have AV'd on the first climb.**
|
||
Once the ladder path actually runs it calls
|
||
`stanGetTileOrderedPointWorldPos(…, (coord3d *)((s32)coords + off))` —
|
||
`(s32)coords` truncates the 64-bit `&bondCollision` stack pointer → the
|
||
four quad corners written through a garbage address. Fix: `PORT_PTRADD`
|
||
macro (`uintptr_t` arithmetic), added at the top of `stan.c`.
|
||
|
||
Touched: `src/game/stan.c` (macro + `stanCheckLinkedSpecialTile` +
|
||
`stanGetMoveBondCollisionTiles`), `src/game/bondview2.c` (`curLocus` type).
|
||
All `#ifdef PORT`, N64 path kept verbatim under `#else`. Candidate root-cause
|
||
came from an M-36 subagent; parts 1/3 were its work, part 2 (the local size)
|
||
found on review. **Verified:** build links clean; `-level_09` framediff 3/3;
|
||
`-level_36` (Surface, has mandatory ladders) boots crash-free.
|
||
**Interactive climb test — DONE (M-37): user confirmed climbing works
|
||
in-game.** Merged to `main` via PR #5 (`955349db`).
|
||
|
||
porting-notes.md §C (pointer-width in ROM/record structs).
|
||
|
||
---
|
||
|
||
## D132 — D88 propDefs layout audit (M-20)
|
||
|
||
Static analysis only (no build, no game run). Goal: prove
|
||
`tools_pc/d88_propdefs.py`'s per-type cursor matches the real compiled PC
|
||
struct layout (8-byte pointers, 8-byte alignment) for every `PROPDEF_*`
|
||
record type any of the 21 solo `Usetup*Z` levels actually emits, and that
|
||
the handler set is total.
|
||
|
||
### Handler totality
|
||
|
||
Authoritative per-level type histogram: walk each level's `propDefs`
|
||
region with `d88_propdefs.PROPDEF_N64_WORDS` as the stride (the same
|
||
table `convert_stream` uses; it walks all 21 to an exact region-end
|
||
match, and `d125_check.py` confirms offline emit == converter for all 21
|
||
— so the stride table and the encountered type set are sound).
|
||
|
||
Union of all `type` bytes emitted across the 21 levels:
|
||
|
||
1 2 3 4 5 6 7 8 9 10 11 12 13 14 17 18 19 20 21 22 23 24 25 26 27 28
|
||
30 32 33 34 35 36 37 38 39 40 42 43 44 45 46 47 48
|
||
|
||
Never emitted (in the enum, absent from every shipped solo level):
|
||
15 (DEBRIS), 16, 29 (OBJ_DEPOSIT), 31 (OBJ_NULL), 41.
|
||
|
||
`tools_pc/d88_propdef_scan.py` is **stale / unreliable** — its private
|
||
`WC` + `SIZEOF_N64` guess table desyncs mid-walk and reports spurious
|
||
`unmapped type NOTHING`. `d88_propdefs.convert_stream` is the ground-truth
|
||
walker. (Scan tool left as-is; a follow-up could point it at
|
||
`PROPDEF_N64_WORDS`.)
|
||
|
||
Every emitted type has a non-generic handler **or** is provably safe
|
||
under the generic arm (header `_hdr_word` + `_bswap32` of the remaining
|
||
words, no pointer widening, no sub-word packed field):
|
||
|
||
- Generic-safe: 2 (DOOR_SCALE, `s32 Scale`), 23 (OBJECTIVE_START — see
|
||
below), 24 (OBJECTIVE_END, header only), 25/26/27/28 (the meaningful
|
||
field is the `s32 ObjRefID` at 0x4 that `get_status_of_objective` reads
|
||
via the `MissionObjectiveRecord` cast — the `u16 unk4` in the stub
|
||
structs is not how it is accessed, so `_bswap32` is correct), 34
|
||
(OBJ_COPY_ITEM, 3 scalar words), 46 (CAMERAPOS / `CutsceneRecord` —
|
||
`coord3d pos; f32 theta; f32 verta; s32 pad`, **no pointer**, 7 words,
|
||
`PROPDEF_PC_BYTES[46]=28` correct).
|
||
|
||
### Per-type layout comparison (audited types)
|
||
|
||
Legend: N64 word -> PC byte offset; check = converter cursor lands on the
|
||
same PC offset the compiler would.
|
||
|
||
**ObjectRecord prefix** (shared by 3/5/12/17/36/42/43 and the head of
|
||
1/4/6/7/8/10/11/13/20/21/39/40/45/47). N64 0x80 / 32 w -> PC **144 B**.
|
||
Field-by-field: header@0; obj/pad `_hh_word`@4; flags@8; flags2@12;
|
||
prop*@16(+8); model*@24(+8); Mtxf mtx@32..96; runtime_pos@96..108;
|
||
runtime_bitflags@108; collisiondata*@112(+8); projectile*@120(+8);
|
||
maxdamage@128; damage@132; shadecol@136 (verbatim); nextcol@140;
|
||
sizeof **144**. Converter `_emit_object_prefix` cursor lands on every one
|
||
of these. MATCH.
|
||
|
||
**DOOR (1)** — `DoorRecord`, tail N64 w32..w63. The C3r Bunker2 suspect.
|
||
PC struct = **296 B** (`PROPDEF_PC_BYTES[1]=296`, `sizepropdef` PORT
|
||
`return 74`).
|
||
|
||
| field | N64 off | PC off (compiler) | converter | ok |
|
||
|---|---|---|---|---|
|
||
| linkedDoorOffset s32 (read-b4-write id) | 0x80 | 0x90 | 0x90 `_bswap32` | yes |
|
||
| maxFrac..maxSpeed 5xf32 | 0x84..0x98 | 0x94..0xa8 | same | yes |
|
||
| doorFlags u16 / doorType u16 | 0x98 | 0xa8 (`_hh_word`) | 0xa8 | yes |
|
||
| keyflags..doorOpenSound 3xu32 | 0x9c..0xa4 | 0xac..0xb4 | same | yes |
|
||
| frac..speed 5xf32 | 0xa8..0xb8 | 0xb8..0xc8 | same | yes |
|
||
| openstate s8/unkbd s8/calcopacity s16 | 0xbc | 0xcc (byte-pattern) | 0xcc | yes |
|
||
| TintDist s32 | 0xc0 | 0xd0 | 0xd0 | yes |
|
||
| CullDist s16/soundType s8/fadeTime60 s8 | 0xc4 | 0xd4 (byte-pattern) | 0xd4 | yes |
|
||
| linkedDoor* | 0xc8 | 0xd8 (8-al) +8 | 0xd8 +8 | yes |
|
||
| unkcc* (Vertex*) | 0xcc | 0xe0 +8 | 0xe0 +8 | yes |
|
||
| bbox (u32 + `struct bbox` 24B = 28B, 7 w) | 0xd0 | 0xe8..0x104 | 0xe8..0x104 | yes |
|
||
| openedTime u32 / portalNumber s32 | 0xec / 0xf0 | 0x104 / 0x108 | same | yes |
|
||
| openSoundState* | 0xf4 | 0x110 (8-al, 4B pad) +8 | 0x110 +8 | yes |
|
||
| closeSoundState* | 0xf8 | 0x118 +8 | 0x118 +8 | yes |
|
||
| lastcalc60 union s32/f32 | 0xfc | 0x120 | 0x120 `_bswap32` | yes |
|
||
| **sizeof** | 0x100 | **0x124 -> pad 296** | 296 | yes |
|
||
|
||
**Conclusion: the DOOR converter cursor matches the real PC layout
|
||
exactly.** `linkedDoorOffset` lands at PC 0x90 and is emitted as an int
|
||
id (not zeroed). The C3r Bunker2 residual was fixed by **D126** (objective
|
||
sub-record `->next` growth desyncing the walk so `linkedDoorOffset+arg2`
|
||
resolved to the wrong record) — not a DOOR-tail cursor bug. Bunker2
|
||
currently PASSES, consistent with this.
|
||
|
||
**VEHICHLE (39)** — `VehichleRecord`, PC **208 B** (`sizepropdef` PORT
|
||
`return 52`). ailist@w32 = int id (`OBJ_ID_WORDS`, D123);
|
||
aioffset|aireturnlist@w33 = `_hh_word`; path@w41, Sound@w43 = ptr.
|
||
Cursor: id 144->152, hh 152->156, 7xf32 156->184, path 184(8-al)->192,
|
||
nextstep 192->196, Sound 200(8-al, 4 pad)->208. MATCH (208).
|
||
|
||
**AIRCRAFT (40)** — `AircraftRecord`, PC **208 B**. Same shape; path@w43,
|
||
Sound@w44. Cursor lands path 192(8-al)->200, Sound 200->208. MATCH.
|
||
|
||
**AUTOGUN (13)** — `AutogunRecord`, PC **248 B** (`sizepropdef` PORT
|
||
`return 62`). 17 scalar tail words 144->212, then unkC4*/unkC8*/beam*
|
||
(w49/50/51) 216(8-al, 4 pad)->224->232->240, is_active 240->244, unkD4
|
||
244->248. MATCH (`PROPDEF_PC_BYTES[13]=248`).
|
||
|
||
**AMMO / MultiAmmoCrate (20)** — `MultiAmmoCrateRecord`, PC **200 B**.
|
||
Tail = `slots[13]` of `{u16 modelnum; u16 quantity}` -> 13 `_hh_word`
|
||
writes 144->196, pad->200. `AMMOTYPE_GLOBAL_MAX == 13` matches
|
||
`PROPDEF_N64_WORDS[20]=45` (32+13). MATCH (`sizepropdef` PORT `return 50`).
|
||
|
||
**TANK (45)** — `TankRecord`, converter **248 B** (`_emit_object_prefix`
|
||
+ collision*@w32 + 23 `_bswap32` words). The `TankRecord` struct as
|
||
declared in `bondtypes.h` is **53 words**, but `PROPDEF_N64_WORDS[45]=56`
|
||
(getools/ROM) — the struct's `//s32 unk88..` comments imply a 3-word gap
|
||
the C declaration omits, so the *field offsets* past `rect` are
|
||
unconfirmed. However: the only pointer is `collision`@w32 (correctly
|
||
widened + kept in slot), the stride is self-consistent with `sizepropdef`
|
||
PORT (`return 62` = 248), and TANK appears only twice (Depot x1, Runway
|
||
x1). Walk integrity is intact; only tank-field *semantics* (non-crash)
|
||
could be off. **Confidence medium**; acceptable until a tank level is
|
||
played.
|
||
|
||
**TINTED_GLASS (47)** — `TintedGlassRecord`, PC **168 B**. 5 `s32` tail
|
||
words 144->164, pad->168. No pointers. MATCH (`sizepropdef` PORT
|
||
`return 42`).
|
||
|
||
**Objective sub-records 30/32/33/35** (D126) — re-confirmed: each is
|
||
`{header; s32 payload x N; T *next}`; converter emits header + N
|
||
`_bswap32` words + 8 zero bytes for `next` at offset 16, total 24 B.
|
||
`criteria_deposit` (33) has N=3 (5 N64 words); the rest N=2 (4 words).
|
||
MATCH (`sizepropdef` PORT `return 6`).
|
||
|
||
**OBJECTIVE_START (23)** — `MissionObjectiveRecord` ends in
|
||
`WatchMenuObjectiveTextRecord *nextentry` @0x10, so PC `sizeof` is 24,
|
||
but `PROPDEF_N64_WORDS[23]=4` (nextentry is not serialized) and
|
||
`sizepropdef` PORT `return 4` (16 B stride). This is **safe**: nothing
|
||
writes `MissionObjectiveRecord.nextentry` — the briefing-text linked
|
||
list (`ptr_last_briefing_setup_entry_type23`, `objective.c:54`,
|
||
`objective_status.c:88`) is a chain of `struct watchMenuObjectiveText`
|
||
(type-35 records) via *their* `nextentry`, despite the misleading global
|
||
name. The type-23 record is only ever *read* (`get_status_of_objective`
|
||
walks it as `ObjRefID`@4 / `TextID`@8 / `MinDificulty`@0xc). Uniform
|
||
16-byte stride, no pointer store -> no D126-class overflow.
|
||
|
||
### Divergence found — types 14 / 19 / 38 / 44 (union-with-pointer index slots)
|
||
|
||
`LinkRecord` (14), `SwitchRecord`=`LinkRecord` (19), `LockDoorRecord`
|
||
(38), `SafeObjectRecord` (44) each declare their index fields as a
|
||
**union with a pointer**:
|
||
|
||
typedef struct LinkRecord {
|
||
inherits PropDefHeaderRecord; // 0, 4 B
|
||
union { struct PropRecord *first; s32 Index1; }; // PC: off 8 (8-al, 8 B)
|
||
union { struct PropRecord *second; s32 Index2; }; // PC: off 16
|
||
struct LinkRecord *next; // PC: off 24
|
||
} LinkRecord; // PC sizeof = 32
|
||
|
||
On PC the union is 8 bytes / 8-aligned, so `Index1` sits at **byte 8**
|
||
(4 B pad at byte 4), `Index2` at **byte 16**, `next` at **byte 24**.
|
||
`LockDoorRecord` is the same shape (sizeof 32). `SafeObjectRecord` has
|
||
three such unions (`item`/`safe`/`door`) + `next` -> `Index1`@8,
|
||
`Index2`@16, `Index3`@24, `next`@32, sizeof **40**.
|
||
|
||
The converter's handler for `(14, 19, 38, 44)` does:
|
||
|
||
out[0:4] = _hdr_word(src[so:so + 4])
|
||
for i in range(1, n64w):
|
||
out[4 * i:4 * i + 4] = _bswap32(src[so + 4 * i: so + 4 * i + 4])
|
||
|
||
i.e. it lays the N64 index words at PC bytes **4, 8, 12** — the N64
|
||
tight-4-byte packing. Result on PC:
|
||
|
||
| read | converter put | compiler expects | effect |
|
||
|---|---|---|---|
|
||
| `pdef->Index1` (byte 8) | N64 word 2 = **Index2** | Index1 | wrong id |
|
||
| `pdef->Index2` (byte 16) | (never written) = **0** | Index2 | always 0 |
|
||
|
||
`PROPDEF_PC_BYTES` also under-sizes 14 & 19 & 44 (24 vs real 32 / 32 / 40;
|
||
38 is coincidentally 32).
|
||
|
||
**Runtime effect (`prop.c` `proplvreset2` walk):** `PROPDEF_SWITCH`
|
||
reads `index1 = pdef_switch->Index1` / `index2 = pdef_switch->Index2`,
|
||
resolves `doorA`/`doorB`, and only if
|
||
`doorA && doorA->prop && doorB && doorB->type==PROPDEF_DOOR && doorB->prop`
|
||
writes `pdef_switch->first/second` + calls `initSetLevelLoadPropSwitch`
|
||
(which does `arg0->next = ...`). Because the indices read wrong, that
|
||
guard **fails**, so the pointer stores (and the would-be `->next`
|
||
overflow past the under-sized record) never happen. Same for
|
||
`PROPDEF_LOCK_DOOR` (`pdef_lock_door->door/lock` + `->next`),
|
||
`PROPDEF_SAFE_ITEM` (`pdef_safe->item/safe/door`), and `PROPDEF_LINK`
|
||
(`propweaponSetDual`, also `guna && gunb` guarded).
|
||
|
||
**Net: non-crashing but silently broken** — switch-activated doors, dual
|
||
(left+right) weapon pickups, padlocked doors, and safe/safe-item links do
|
||
not initialise on PC. This explains why Streets (`UsetuptraZ`, 20x
|
||
LOCK_DOOR) and Aztec/Dam (SWITCH) still PASS the load+no-crash sweep.
|
||
Below crash work in priority, but a WS6 objective-playthrough blocker.
|
||
|
||
Affected levels: LINK — Caverns, sevb. SWITCH — Archives, Aztec, Dam.
|
||
LOCK_DOOR — Dam, sevx, sevxb, **Streets x20**. SAFE_ITEM — Archives,
|
||
Depot, sevb, sevx.
|
||
|
||
**Not previously caught:** D125's stride re-check only asserted
|
||
`sizepropdef == PROPDEF_PC_BYTES/4` (internal consistency), never
|
||
`PROPDEF_PC_BYTES == real compiler sizeof`. D122's totality note flagged
|
||
`[u16|u16]` half-swap but not the `union{ptr; s32}` index-slot case.
|
||
|
||
### APPLIED (M-20, commit pending) — ABI/layout only, `#ifdef PORT`
|
||
|
||
Fix below applied verbatim. `tools_pc/d88_propdefs.py`: `PROPDEF_PC_BYTES`
|
||
`14/19 -> 32`, `44 -> 40`; the `(14,19,38,44)` handler now emits each
|
||
`Index{k}` into the low 4 B of the 8-aligned slot at PC `8 + 8*k`
|
||
(`nidx = {14:2,19:2,38:2,44:3}`). `loadobjectmodel.c sizepropdef()` PORT
|
||
switch: `LINK/SWITCH/LOCK_DOOR -> return 8`, `SAFE_ITEM -> return 10`,
|
||
`TAG` stays `6`. Regen chain run. Verified: `-level_09` framediff 3/3
|
||
PASS; `-level_20` behaviour byte-identical to a freshly-built pre-D132
|
||
baseline (the `frame_000320` phash delta is pre-existing stale-golden /
|
||
Silo slowdown, NOT a regression — confirmed by building the baseline);
|
||
Archives (`-level_25`, SWITCH+SAFE_ITEM), Streets (`-level_29`, 20x
|
||
LOCK_DOOR), Dam (`-level_33`, SWITCH+LOCK_DOOR) all load + render +
|
||
no-crash. Sweep runs this pass were heavily flaky (level_09 itself
|
||
0-framed on a loaded machine) — pure D117/watchdog noise, no crashes.
|
||
|
||
### Original proposed fix (as written pre-apply)
|
||
|
||
`tools_pc/d88_propdefs.py` — `PROPDEF_PC_BYTES`: `14: 24 -> 32`,
|
||
`19: 24 -> 32`, `44: 24 -> 40` (38 stays 32). Replace the
|
||
`(14, 19, 38, 44)` handler:
|
||
|
||
if type_byte in (14, 19, 38, 44): # LINK / SWITCH / LOCK_DOOR / SAFE_ITEM
|
||
# D132: each Index{1,2,3} field shares a union with a pointer, so on
|
||
# PC it lives in the LOW 4 bytes of an 8B/8-aligned slot at
|
||
# PC offset 8 + 8*(N-1); the record ends in a *next the setup walk
|
||
# writes. The N64 image packs the indices as tight 4-byte words;
|
||
# emitting them there put Index1 where the compiler reads Index2.
|
||
nidx = {14: 2, 19: 2, 38: 2, 44: 3}[type_byte]
|
||
out[0:4] = _hdr_word(src[so:so + 4])
|
||
for k in range(nidx):
|
||
w = src[so + 4 * (k + 1): so + 4 * (k + 1) + 4]
|
||
out[8 + 8 * k: 12 + 8 * k] = _bswap32(w) # low 4B, LE; high 4B = 0
|
||
return bytes(out)
|
||
|
||
`src/game/loadobjectmodel.c` `sizepropdef()` `#ifdef PORT` switch: move
|
||
`PROPDEF_LINK` / `PROPDEF_SWITCH` out of the `return 6` group and give
|
||
`LINK`/`SWITCH`/`LOCK_DOOR` -> `return 8` (32 B) and `SAFE_ITEM` ->
|
||
`return 10` (40 B). `PROPDEF_TAG` stays `return 6` (its `ID`/`OffsetToObj`
|
||
are plain `u16`/`s16` at 0x4, not in a pointer union; handler already
|
||
correct).
|
||
|
||
After applying: `d88_emit.py --regen` (all 21 sidecars), then the full
|
||
`d43 && d69 && d88 --regen` chain, then re-verify `-level_09` / `-level_20`
|
||
golden + a Streets/Dam/Archives load.
|
||
|
||
### Confidence per type
|
||
|
||
| type(s) | verdict | confidence |
|
||
|---|---|---|
|
||
| ObjectRecord prefix (3/5/12/17/36/42/43) | matches | high |
|
||
| 1 DOOR | matches (incl. linkedDoorOffset @0x90) | high |
|
||
| 39 VEHICHLE / 40 AIRCRAFT | matches | high |
|
||
| 13 AUTOGUN | matches | high |
|
||
| 20 AMMO/MultiAmmoCrate | matches | high |
|
||
| 47 TINTED_GLASS | matches | high |
|
||
| 30/32/33/35 objective sub-records | matches (D126) | high |
|
||
| 23 OBJECTIVE_START | 16B stride safe (nextentry never written) | high |
|
||
| 2/24/25/26/27/28/34/46 (generic) | safe under generic arm | high |
|
||
| 4 KEY / 7 MAGAZINE / 21 ARMOUR / 8 COLLECTABLE | prefix + plain tail; widely used in passing levels | medium-high |
|
||
| 45 TANK | struct offsets unconfirmed (53 vs 56 w); stride self-consistent, walk intact, non-crash | medium |
|
||
| **14 LINK / 19 SWITCH / 38 LOCK_DOOR / 44 SAFE_ITEM** | **DIVERGENT** — union index slots at wrong PC offset; silently non-functional (non-crash) | fix high confidence |
|
||
|
||
### Files touched
|
||
|
||
Docs only: this subsection + the §F index rows (D88.4 status, new D132
|
||
row). No code changed; the proposed diff above is not applied. No temp
|
||
probe scripts left.
|
||
|
||
## D185 — fresh `d69_emit.py` run yields a game-crashing `data/pccg-<region>/` (M-38)
|
||
|
||
**Status: OPEN — alpha-release blocker.** (Add to the §F index.)
|
||
|
||
**Symptom.** `python tools_pc/d69_emit.py ntsc-final` in this repo writes
|
||
`data/pccg-ntsc-final/pccg.bin` = 3346895 B (52 sidecars, 1856-B manifest),
|
||
prints `ALL CHECKS PASSED`, and `-level_09` / `-level_20` then segfault before
|
||
frame 1 (`EXCEPTION: 0xc0000005`, faulting return address is an ASCII
|
||
model-name string). The pre-migration working sidecar
|
||
(`C:/Users/james/Source/Repos/007/data/pccg-ntsc-final/pccg.bin`, 3604378 B,
|
||
2421-B manifest) drops into this tree and `-level_09` runs crash-free.
|
||
|
||
**Isolation done (M-38).**
|
||
- Not the romdata M-38 change: baseline (stashed) crashes identically.
|
||
- Not the build: same exe, only the `data/pccg-ntsc-final/` bytes swapped.
|
||
- `tools_pc/d69_emit.py` is byte-identical between the public repo and the
|
||
pre-migration repo `007` (only a doc-path comment differs).
|
||
- The migration commit `52539d10 "Prepare for public release"` normalised the
|
||
committed d69 inputs from CRLF to LF: `scripts/filelist.u.csv` (−812 B, one
|
||
CR/line) and `assets/obseg/file_resource_table.inc.c`.
|
||
- **Restoring CR to both inputs did NOT restore the 3604378-B output** → line
|
||
endings are not the (whole) cause. Some other part of the d69 input closure
|
||
regressed in the history rewrite, or the working sidecar predates a real
|
||
d69/input change and 3346895 is a separate second bug.
|
||
- `d43_emit.py` is unaffected (its `find_row` tries `name`, `name[1:]`,
|
||
`name[:-1]`, `name[1:-1]` — tolerant of a mangled basename; `d69`'s
|
||
`fl_by_base.get(basename)` is exact).
|
||
|
||
**Impact.** `docs/building.md` tells users to run `d69_emit.py`; M-38's
|
||
`prepare-assets.py` does the same. Both currently produce a broken game on a
|
||
clean checkout. The committed `data/` dirs (carried from pre-migration) hide it.
|
||
|
||
**Next.** Diff the sidecar *name* set: pre-migration `manifest.csv` vs a fresh
|
||
run's — find which bg/stan entries d69 now drops, trace through `find_row` →
|
||
`file_resource_table.inc.c` → `filelist.u.csv`. Fix so a fresh run reproduces
|
||
3604378 B / 2421-B manifest; re-verify `-level_09`/`-20` crash-free. Do NOT
|
||
regenerate `data/pccg-ntsc-final/` before the fix (it holds the working copy).
|
||
|
||
### D185 — RESOLVED (M-38): not a d69 regression, a missing d88 step
|
||
|
||
`d88_emit.py` **appends** the 21 `Usetup*Z` per-level stage-setup files
|
||
(object placement, AI opcode streams, pads/nav) to the same
|
||
`data/pccg-<region>/pccg.bin` + `manifest.csv` that `d69_emit.py` writes
|
||
(52 bg/stan rows → 74 after d88). The M-38 `prepare-assets.py` first shipped
|
||
ran only d43 + d69, so every level loaded with no setup data → instant
|
||
segfault. The "working" pre-migration sidecar was simply a complete
|
||
d43+d69+d88 output; nothing regressed in d69 or its inputs.
|
||
|
||
Fix (commit `4491db3f`): `prepare-assets.py` runs d43 → d69 → `d88 --regen`;
|
||
`bundle-win.sh` vendors `d88_emit.py` + its only local import
|
||
`d88_propdefs.py`. Verified: fresh 3-script run from an assembled bundle is
|
||
byte-identical to the known-good `pccg.bin` (3604378 B) / `pcmodels.bin`;
|
||
`-level_09` + `-level_20` boot crash-free. The CRLF→LF observation on
|
||
`filelist.u.csv` / `file_resource_table.inc.c` is real but a red herring for
|
||
this crash (d69 tolerates it; output unchanged).
|
||
|
||
## D200 — `-level_09` segfault: u32 reverb delay indices negate into +4GB offsets on 64-bit (fixed)
|
||
|
||
**Symptom.** `-level_09` reliably segfaulted within seconds of audio starting, in
|
||
`_filterBuffer` (`src/libultrare/audio/reverb.c`) — `d->lp` (a pointer into the AFX
|
||
delay array) resolved to garbage like `0x664d59be`.
|
||
|
||
**Root cause.** `ALDelay.input` / `ALDelay.output` are `u32`, and reverb.c computes
|
||
back-references into the delay ring as `&r->input[-d->output]` (and variants). On
|
||
N64, `ptrdiff_t` is s32, so `-160` (the u32 constant `0xFFFFFF60`) wraps to −160 and
|
||
the pointer arithmetic is correct. On PC x86-64, `ptrdiff_t` is s64: the u32 negation
|
||
**zero-extends** to +4294967136 samples, so `out_ptr` flies forward ~8GB — verified
|
||
exactly: `0x706d30a0 (r->input) + 0xFFFFFF60·2 = 0x2_706D2F60`, the observed wild
|
||
address. The subsequent `aSaveBuffer`/`aLoadBuffer` then write 13–52KB of wild data,
|
||
corrupting the delay array, `r->base`, and adjacent heap blocks — a self-propagating
|
||
loop that grows each frame until it hits unmapped memory.
|
||
|
||
**Fix (ABI/layout only, D3x class).** Five sites in `src/libultrare/audio/reverb.c`
|
||
cast to `(s32)` before negation so the offset sign-extends: `alFxPull` (`in_ptr`,
|
||
`out_ptr`), `_loadOutputBuffer` ×2 (`out_ptr`), plus the ramalign line, which uses
|
||
`(s64)out_ptr & 0x7`. Perfect Dark ground truth (`src/lib/naudio/n_reverb.c`) does
|
||
exactly this: `(s32)-d->input`, `(s32)-d->output`, `(s32)-(d->output - d->rsdelta)`,
|
||
`(intptr_t)out_ptr & 0x7`. No game logic changed; behaviour on N64 is identical.
|
||
|
||
**Verification.** `-level_09` now runs 120 s+ crash-free (previously segfaulted in
|
||
seconds); the delay pointers advance correctly (`out_ptr = input − 320B`, +0x140/frame).
|
||
A temporary DRAM guard window over the FX region, registered with the software mixer
|
||
(`port/src/mixer.c`), confirmed zero out-of-bounds writes after the fix. All
|
||
instrumentation removed; only the five casts remain in the diff.
|
||
|
||
**Sibling-risk audit.** Grep of `src/libultra/audio/*.c` + `src/libultrare/audio/*.c`
|
||
for negative pointer indexing: reverb.c is the only file with the pattern (its one
|
||
positive index, `&r->input[d->output]`, is safe — positive u32 zero-extension is
|
||
correct).
|
||
|
||
**Lineage.** D3x pointer-width reconciliation; same family as D177 (widened-pointer
|
||
high half) and D191 (32-bit field read as 64-bit).
|
||
|
||
## D202 — Phase-3 audio: silenced PPK plays "slap" not gunshot, PLUS runtime mixer corruption (piling up / glitching / eventual silence) (M-52; reclassified M-59; REOPENED M-60 on reference clip; M-61/M-62 closed the data/index/decode chain and recommended close; M-63 REOPENED again; M-65 PARTIALLY resolved — voice-leak cascade root-caused + guarded, `sndCreatePostEvent` un-stubbed; **M-66 ROOT CAUSE ESTABLISHED for the audible stuck door loop — sound 203 behaving exactly as ROM + ground-truth code specify: faithful N64 behaviour / original quirk; M-66b DISPOSITION C IMPLEMENTED + MEASURED — port-side expiration fades ownerless infinite loops out ~2.5 s after start, awaiting user by-ear pass**)
|
||
|
||
**Symptom (M-52 playtest, first listening pass on the Phase-3 software mixer,
|
||
post-D200).** In-level (Bunker1, Dam — not menu/intro, which is music-only and
|
||
correct): (a) weapon fire consistently plays the WRONG but always-the-same
|
||
clip per weapon (e.g. Bond's silenced PPK plays what sounds like a melee/punch
|
||
hit, every single shot — fully deterministic, not intermittent garble); (b)
|
||
level music never plays — a looping SFX-like sound plays in its place from
|
||
level start.
|
||
|
||
**Ruled out, with hard evidence (do not re-investigate these):**
|
||
- `soundIndex -> ALSound*` resolution (`snd.c sndPlaySfx`, `soundBank->instArray[0]->soundArray[soundIndex]`):
|
||
traced live with a `GE_AUDIOTRACE` probe against real gameplay (`-level_09`,
|
||
repeated PPK fire). `soundIndex=46` (confirmed via `bondconstants.h`'s SFX
|
||
name table = `46_GUN_SILPPK_A_SFX`) resolves to the same `ALSound*`/`ALWaveTable*`
|
||
every time; 13+ distinct indices captured across one session, all distinct,
|
||
all valid.
|
||
- D37's bank re-layout (`port/src/romdata.c romdataFixupAudioBank`): preserves
|
||
`soundArray`/`instArray` order; not a re-ordering bug.
|
||
- The `ALWaveTable.base`/`.book` pointer chain: traced all the way to
|
||
`alLoadParam(AL_FILTER_SET_WAVETABLE, ...)` (`src/libultra/audio/load.c`) —
|
||
the exact same `table`/`base`/`book` addresses `sndPlaySfx` resolved are the
|
||
ones wired into the physical decode filter. No swap between resolution and
|
||
voice setup.
|
||
- `ALStartParamAlt` (40B on x86-64) vs the generic `ALParam` pool slot (32B):
|
||
already fixed pre-M-52 (D54, `synthesizer.c:127-140`, `#ifdef PORT` sizes
|
||
each slot for the largest variant). Re-confirmed still in place, not a
|
||
regression.
|
||
- Per-voice synth processing (`synthesizer.c`'s client-list handler loop) is
|
||
strictly sequential — one client's full setup+decode completes before the
|
||
next starts. Ruled out global-mixer-scratch (`sCtx`/`sAdpcmTable`/`sVol` in
|
||
`port/src/mixer.c`) cross-talk between concurrently active voices.
|
||
- `ALSndpEvent` (snd.c-local union, force-cast to `ALEvent*` through
|
||
`alEvtqPostEvent`/`alEvtqNextEvent`, which copy `sizeof(ALEvent)` bytes)
|
||
vs `ALEvent` (libaudio.h): measured directly with a throwaway compile —
|
||
both are exactly 32 bytes on x86-64. Not a truncating-copy bug.
|
||
- `sndCountAllocList`'s `(ALEventQueue *)&D_800243E4` cast (a real, compiler-flagged
|
||
`-Warray-bounds` OOB read — `D_800243E4_s` is 24B, aliased as 40B `ALEventQueue`):
|
||
confirmed genuine but functionally inert — its output (`numFree`/`numAlloc`)
|
||
is computed and immediately discarded in `sndHandleEvent`, never used.
|
||
|
||
**Not yet ruled out — pick up here:**
|
||
1. **`aADPCMdecImpl`'s decode math** (`port/src/mixer.c`, this session's new
|
||
Phase-3 code, never verified against real N64 audio output). Structurally
|
||
matches the standard VADPCM order-2 predictor algorithm on inspection
|
||
(9 input bytes -> 16 output samples, intra-frame reverb-of-already-decoded-
|
||
samples loop) but no reference trace/audio diff exists yet.
|
||
2. **The raw PCM bytes embedded in the exe at the resolved ROM offset being
|
||
wrong at the source** (asset-embedding/segment-placement issue, unrelated
|
||
to anything traced above) — would need a byte-level compare of the
|
||
PC-embedded `_sfxtblSegmentRomStart` segment against the source `.z64`.
|
||
|
||
**Key diagnostic fact:** the wrong sample is 100% deterministic per weapon
|
||
(same wrong clip every shot, confirmed by user) — this argues against a
|
||
stateful/interleaving bug (already the least-likely explanation given the
|
||
sequential-processing proof above) and toward either (1) or (2) above, both
|
||
of which would consistently mis-decode/mis-fetch the same bytes every time
|
||
for the same `ALWaveTable`.
|
||
|
||
**Diagnostic tooling added this session (still in the tree, `#ifdef`/env-gated,
|
||
harmless if left in, should be stripped once root-caused):**
|
||
- `src/snd.c` `sndPlaySfx`: `GE_AUDIOTRACE=1` env var → appends resolved
|
||
`soundIndex`/`sound`/`wavetable`/`base`/`len`/`type`/`flags`/`book` to
|
||
`audiotrace.log` (cwd-relative, unbuffered).
|
||
- `src/libultra/audio/load.c` `alLoadParam` `AL_FILTER_SET_WAVETABLE`:
|
||
same env var → appends the filter/table/base/len/book actually wired into
|
||
a voice to `audiotrace_wire.log`.
|
||
|
||
**Also noticed, not yet a confirmed bug:** `alLoadParam`'s
|
||
`a->memin = (s32) a->table->base;` (`load.c:382,438`) truncates a real 64-bit
|
||
`u8 *` to `s32` — currently harmless only because the ROM is deliberately
|
||
mapped at a low, sub-2GB virtual address (`0x10000000`, 12MB window per the
|
||
boot log), so no real address in range overflows 32 bits. Fragile; worth a
|
||
`(s64)`/`uintptr_t` widening pass regardless of whether it's D202's cause.
|
||
|
||
**M-53 update — both of the two remaining suspects now independently checked,
|
||
both look clean (static analysis only, no listening pass possible this
|
||
session):**
|
||
|
||
1. **`aADPCMdecImpl` decode math (`port/src/mixer.c:148-192`) — compared
|
||
line-by-line against the local Perfect Dark PC port's scalar reference
|
||
(`pd_port/port/src/mixer.c:333-352`, the `#else` non-SIMD path).**
|
||
Identical: same nibble unpack (`(((*in >> 4) << 28) >> 28) << shift` /
|
||
same for the low nibble), same `prev1`/`prev2` history read from `out[-1]`/
|
||
`out[-2]`, same accumulator (`tbl[0][j]*prev2 + tbl[1][j]*prev1 +
|
||
(ins[j]<<11)` plus the `k<j` intra-frame correction term), same `>>11`
|
||
scale-down + clamp, same 16-sample/32-byte state carry via
|
||
`memcpy(state, out-16, ...)`. No discrepancy found. This was PD's
|
||
ground-truth non-vectorized fallback, i.e. the reference the SIMD paths
|
||
are themselves checked against — a strong match.
|
||
2. **`_sfxtblSegmentRomStart`'s ROM offset (`0x102F19A0`,
|
||
`port/src/romassets_u.s:2948`) — cross-validated three independent ways,
|
||
all agree:**
|
||
- `scripts/filelist.u.csv` row 28: `3086752,797360,assets/music/sfx.tbl` →
|
||
`0x10000000 + 3086752 = 0x102F19A0` exactly.
|
||
- Row 27 (`sfx.ctl`, `3063264,23488`) is contiguous with row 28:
|
||
`3063264 + 23488 = 3086752` — matches `music.c:686`'s assumption
|
||
(`size = &_sfxtblSegmentRomStart - &_sfxctlSegmentRomStart`, i.e. ctl
|
||
immediately precedes tbl in ROM with no gap).
|
||
- `ge007.ld:164-165` (N64 ground truth, untouched) places
|
||
`sfx.ctl.o (.data)` immediately followed by `sfx.tbl.o (.data)` in the
|
||
`musicfiles` segment — same adjacency, independently, from the linker
|
||
script rather than the CSV scanner.
|
||
- Read the real bytes from `data/ge007.ntsc-final.z64` at both offsets
|
||
(this session, ad hoc Python): `sfx.ctl` decodes as a plausible
|
||
`ALBankFile` (`bankCount=1`, one bank offset `0x5ba0` inside the 23488B
|
||
ctl blob); no structural red flag. Byte-level content correctness
|
||
beyond that needs a decode+listen pass, not available in this session.
|
||
|
||
Net: the segment-offset math is corroborated by 3 independent sources
|
||
(scanner CSV, N64 linker script, raw ROM read) and is very unlikely to be
|
||
a simple wrong-base bug. Downgrading this suspect's priority.
|
||
|
||
**Also re-confirmed clean, not previously called out explicitly:** the ADPCM
|
||
book coefficients (`ALADPCMBook`, referenced off `ALWaveTable.waveInfo.
|
||
adpcmWave.book`) go through `afFixupBook()` (`port/src/romdata.c:704-735`),
|
||
which correctly `afRd16`/`afWr16` byte-swaps every predictor coefficient
|
||
during the D37 bank re-layout — not just the offset/pointer fields. And
|
||
`_bnkfPatchWaveTable()`'s `w->base += table;` (`bnkf.c:140`) is `uintptr_t`
|
||
end-to-end post-D201, so no truncation there either.
|
||
|
||
**Net effect: the M-52 write-up's two recommended suspects are now the
|
||
LEAST likely explanations, not the most likely.** The bug is probably
|
||
further downstream, in a stage neither this nor the M-52 session traced:
|
||
**the resample/pitch pipeline** (`aResampleImpl` / whatever computes a
|
||
voice's playback rate from `ALKeyMap`/`unityPitch` — `synallocvoice.c`,
|
||
`seqplayer.c`, `synport.c`/equivalent — not yet audited this session). A
|
||
wrong pitch/rate calculation would (a) make a correctly-decoded SFX sample
|
||
sound like a completely different sound if pitch-shifted far enough to be
|
||
mistaken for one, matching "PPK sounds like a melee hit" better than a
|
||
literal wrong-sample-content theory would, since sample *content* tracing
|
||
(D202 original session) already came back clean; and (b) explain "music
|
||
never plays, an SFX-like loop plays instead" if the same mis-pitched/
|
||
mis-selected-voice bug applies to sequence-player note events too, not just
|
||
raw `sndPlaySfx()` calls. **Recommended next step:** trace one PPK-fire note
|
||
event's computed pitch/rate value (another `GE_AUDIOTRACE`-style probe, this
|
||
time in the resample-rate / `ALKeyMap` lookup path) against what the N64
|
||
build would compute for the same key/velocity, rather than re-auditing the
|
||
decode or offset math further. Not yet attempted — no build/run done this
|
||
session (static-analysis-only pass); still needs the real listening-pass
|
||
sign-off per the standing M-52 note.
|
||
|
||
**M-53 cont. (build+run, same session) — user rebuilt off the M-53 doc-only
|
||
commit and confirmed no behavior change (expected — no code changed yet);
|
||
then supplied two new concrete data points that reframe symptom (b) and
|
||
partially (a):**
|
||
|
||
1. **"the looping sound [that plays instead of music] is actually a door
|
||
sound in the attract screen getting looped and continuing to play."**
|
||
This is not a wrong-sample or wrong-pitch bug — it's confirmed to
|
||
literally be a real, correctly-identified SFX (a door slide-loop) that
|
||
fails to stop. Traced the mechanism live with `GE_AUDIOTRACE`
|
||
(`-level_09`, scripted door-interact input): a single player action
|
||
fires a **chained** `sndPlaySfx` sequence — soundIndex 202
|
||
(`METAL_SLIDE_OPEN_SFX`) → 204 (`METAL_SLIDE_LOOP_SFX`) → 203
|
||
(`METAL_SLIDE_CLOSE_SFX`) — via the `do…while` chain in `sndPlaySfx`
|
||
(`snd.c:927-928`, each `ALSound`'s `keyMap->velocityMin`/`keyMin` fields
|
||
double as a "next soundIndex" link; this chain mechanic is stock N64
|
||
design, not a port bug).
|
||
- The game-code side that's supposed to **stop** a stuck door loop —
|
||
`door7F053B10()` (`propobj.c:12835`, marked `//#MATCH`, i.e. decompiled
|
||
byte-exact to the N64 binary, so its logic is ground truth and NOT a
|
||
candidate for the bug itself) — calls `sndDeactivate(door->
|
||
openSoundState)` whenever the door's *open* or *close* sound is still
|
||
playing. `door->openSoundState` is already a real `ALSoundState *`
|
||
in `bondtypes.h:3042` (not a narrowed/truncated field — checked, this
|
||
is NOT another D3x pointer-width bug).
|
||
- So the bug is downstream of this game-logic call: either (a)
|
||
`sndDeactivate`'s posted `AL_SNDP_DEACTIVATE_EVT` (delta 0) isn't
|
||
being serviced/dequeued reliably by the PC event-queue/mixer tick, or
|
||
(b) (more likely given the user specifically saw this originate **in
|
||
the attract screen** and persist **past it, into gameplay**) whatever
|
||
N64 does to reset/clear all playing SFX at an attract-demo → real-game
|
||
mode transition isn't happening equivalently on PC, orphaning the
|
||
loop's voice with no `DoorRecord` left alive to ever call
|
||
`sndDeactivate` on it again. **Not yet located** — haven't found the
|
||
mode-transition audio-reset call (candidate: `sndDeactivateAllSfxByFlag`
|
||
family, `snd.c:996`, or an equivalent full-stop at level-load).
|
||
2. **"ammo pick up is one of the knife sounds."** Checked against
|
||
`bondconstants.h`: `PICKUP_AMMO_SFX`=234, `PICKUP_KNIFE_SFX`=233 — adjacent
|
||
indices. Combined with the M-53 PPK report (`GUN_SILPPK_A_SFX`=46 sounding
|
||
like `PUNCH1_SFX`=47, also adjacent but in the OPPOSITE direction), this
|
||
does **not** fit a simple constant index-shift theory (would need to be
|
||
the same sign both times) — leaves either (a) two unrelated single-entry
|
||
bugs, or (b) the "wrong sound" being a badly-mispitched *correct* sample
|
||
that a listener reasonably mistakes for a neighboring, timbrally-similar
|
||
entry (a hard door clank at the wrong pitch could pass for a knife sound;
|
||
a gunshot pitched down for a punch). Not resolved this session — would
|
||
need an actual decoded-audio comparison (dump the decoded PCM for a
|
||
known-bad play and listen/compare band energy against the two
|
||
candidates), which the environment can produce (`GE_AUDIOTRACE` gives the
|
||
exact `base`/`len`/`book` addresses) but wasn't attempted here.
|
||
|
||
**Still OPEN. Build/run environment confirmed available this session**
|
||
(`./build-pc.sh ntsc-final` after `export PATH=/c/msys64/mingw64/bin:$PATH`,
|
||
`GE_INPUTSCRIPT` with `"A"`=`GE_CONT_A`, `"Z"`=`GE_CONT_G` per
|
||
`port/src/input.c:311` — note **"Z" triggers the door/action interact in
|
||
this control scheme in `-level_09`, not weapon fire**; hadn't yet found the
|
||
right button/level combo to headless-repro an actual gunshot before running
|
||
out of session time). **Next step:** locate and instrument the attract→game
|
||
(or menu→level) audio-reset call to confirm/deny the orphaned-voice theory
|
||
for symptom (b); separately, dump+listen-compare decoded PCM for one
|
||
mis-sounding SFX to settle symptom (a)/(2)'s index-vs-pitch question.
|
||
|
||
**Follow-up (same session) — checked the level-transition "deactivate all"
|
||
sweep, found it's flag-filtered, which narrows the orphaned-voice theory
|
||
to a specific, already-catalogued bug family.** `lv.c:1443/1473` calls
|
||
`sndDeactivateAllSfxByFlag_1()` at level transitions → `flag=1`
|
||
(`SOUND_FLAG_FINAL_IN_SEQUENCE` only) → `sndDeactivateAllSfxByFlag`
|
||
(`snd.c:996`) only touches sounds whose `unk3e` has **every** bit in `flag`
|
||
set (`(item->unk3e & flag) == flag`). A door's loop-tail sound is very
|
||
unlikely to carry `FINAL_IN_SEQUENCE` (it's a middle link in the
|
||
open→loop→close chain, not the end), so this sweep would **not** catch an
|
||
orphaned door loop even on real N64 — meaning N64 must instead rely on the
|
||
attract-mode demo's scripted input naturally executing its "close door"
|
||
press before the demo ends, letting `door7F053B10()` clean up the loop
|
||
normally. **This reframes the theory:** if the attract-mode demo's
|
||
canned input is driven by a fixed *frame count* but the PC build's
|
||
sim-time-per-frame differs from N64's (the same `g_GlobalTimerDelta`/
|
||
`g_ClockTimer` wall-clock-vs-sim-time family already root-cause-pending
|
||
for **D193**, the AI-locomotion-too-slow bug), the demo could get cut off
|
||
before its scripted door-close input fires on PC even though it always
|
||
does on N64 — orphaning the loop's voice with no code path left to stop
|
||
it. **If true, D202(b) (the stuck-loop symptom) may not be a distinct
|
||
audio bug at all — it may be a second visible symptom of D193's root
|
||
cause once that's found**, not something to fix in the audio code. Worth
|
||
confirming/ruling out before spending more time in `snd.c`/`propobj.c`.
|
||
|
||
**M-54 update — checked the D193-linkage theory's timing mechanics
|
||
directly; it's weaker than M-53 framed it, and got two direct
|
||
experimental data points instead.**
|
||
|
||
1. Read `frametiming.c`/`libultra.c`'s timing chain end to end.
|
||
**The ramrom-demo path (`iterate_ramrom_entries_handle_camera_out`,
|
||
`ramromreplay.c:355`) does NOT go through `waitForNextFrame()`** — it
|
||
calls `updateFrameCounters(ramrom_blkbuf_2->speedframes)` directly
|
||
(`ramromreplay.c:400`), feeding the **recorded** N64 speedframes value,
|
||
not a value derived from PC wall-clock elapsed time. The D155
|
||
catch-up clamp (`FRAMETIMING_PORT_MAX_CATCHUP`) lives inside
|
||
`waitForNextFrame()` and is irrelevant here. The call site
|
||
(`boss.c:502-533`) gates *when* this fires on real elapsed ticks
|
||
(`mainTickElapsed >= MAIN_LOOP_TICK_INTERVAL`) but that gate
|
||
self-throttles correctly even if `gfxFrameMsgQ`'s retrace messages
|
||
back up during a stall (checked: `osCreateMesgQueue(&gfxFrameMsgQ,
|
||
..., 32)`, `init.c:236` — 32-deep, so a stall *can* queue up dozens
|
||
of retrace messages) — the very first drained message after a stall
|
||
recomputes a big `mainTickElapsed` and fires exactly once, which
|
||
immediately refreshes `copy_of_osgetcount_value_1`, so every
|
||
subsequent backlogged message in the same burst sees a near-zero
|
||
elapsed and does nothing. **No runaway/duplicate advance mechanism
|
||
found.** This weakens (doesn't fully rule out) the D193-linkage
|
||
theory: the demo's sim-time progression looks insulated from PC
|
||
real-time hiccups by design, not just by luck. The D193 tie-in is
|
||
still plausible in principle (if the *count* of real retrace ticks
|
||
over the whole demo differs from N64's, e.g. via `osGetCount()`'s
|
||
scaling formula, D117/D134) but the specific "backlog causes runaway
|
||
catch-up" mechanism I'd hoped to point to isn't there. Downgrading
|
||
this from "leading theory" to "one of several still-open".
|
||
2. Extended the existing `GE_AUDIOTRACE` probes: `sndPlaySfx` now also
|
||
logs the `newState` pointer per `soundIndex` link (not just the
|
||
`ALSound*`), and `sndDeactivate` now logs every call with its `state`
|
||
arg (`snd.c`, both `#ifdef PORT`/`getenv("GE_AUDIOTRACE")`-gated,
|
||
still uncommitted). Ran two headless captures
|
||
(`GE_AUDIOTRACE=1 GE_D87=1 ./build-pc/ge007.x86_64.exe`, no level arg,
|
||
no input, 100s then 240s, idling at the front end so the >=30s idle
|
||
timer — `front.c:2493`, `MENU_TIMER >= 1801` at NTSC 60fps — kicks
|
||
attract mode on). Findings:
|
||
- **`sndDeactivate` DOES fire and DOES work** when called: trace
|
||
shows `sndPlaySfx: soundIndex=260 -> newState=...706f4e18` followed
|
||
later by `sndDeactivate: state=...706f4e18 (non-null)`, and that
|
||
same `ALSoundState*` slot gets legitimately reused for a new
|
||
`soundIndex` right after. This rules out "the event-queue doesn't
|
||
service `AL_SNDP_DEACTIVATE_EVT` reliably" (branch (a) from the
|
||
M-53 writeup) as a *general* mechanism — deactivation is not
|
||
structurally broken.
|
||
- Both captures only ever logged front-end/menu-click `soundIndex`
|
||
values (258, 260, 111, 232, 109) — despite `GE_D87` confirming
|
||
`iterate_ramrom_entries_handle_camera_out`/`ramrom_replay_handler`
|
||
WERE firing repeatedly (159 D87 log lines in the 240s run, i.e. the
|
||
attract-mode camera flythrough demo was genuinely playing), **no
|
||
door-chain soundIndex (202/203/204) appeared in either capture.**
|
||
Root cause of the miss, found by reading `ramromreplay.c:631-634`:
|
||
which of the 14 `ramrom_table[]` demo files plays is
|
||
`randomGetNext() % i` — **randomly selected per attract cycle**, not
|
||
a fixed sequence. A short capture has no guarantee of landing on a
|
||
demo that triggers a door open/loop at all (most of the 14 clips
|
||
— Dam/Facility/Runway/Silo/Frigate/Train camera paths — may not
|
||
pass a metal sliding door in-frame). Neither capture is long enough
|
||
to be conclusive either way for symptom (b); this needs either (a)
|
||
a much longer unattended capture (several attract cycles, each
|
||
clip's `totaltime_ms` likely 30-90s, so plausibly 10+ min for good
|
||
odds of hitting a door-bearing clip), or (b) a temporary
|
||
`PORT`-gated env-var override pinning `ramrom_table` selection to a
|
||
specific known door-bearing index for fast, deterministic repro
|
||
(not yet added).
|
||
- Separately confirmed the `romdataFixupMusicSeqTable: seqCount 63
|
||
exceeds blob capacity 1` `[ERROR]` log line seen at boot is a
|
||
**known, benign, single-fire false alarm** — it's the deliberate
|
||
D35 header-only peek (`music.c:731-736`, buffer intentionally sized
|
||
16 bytes to decode just the `seqCount` field before the real,
|
||
correctly-sized allocation+decode at `music.c:738-742`) — confirmed
|
||
it fires exactly once per run and the real table load that follows
|
||
doesn't. Not a new lead; noting it here only so a future session
|
||
doesn't re-flag it as one.
|
||
|
||
**M-54 cont. — got the live repro, and it's NOT a door.** Re-ran headless
|
||
with a `GE_INPUTSCRIPT` pressing A every second for the first ~20s (to
|
||
clear the Rare/Nintendo/legal/cast-intro screens fast) then idling, 280s
|
||
total. Landed on the `ramrom_Train` attract demo (confirmed: `GE_D87`
|
||
shows `iterate_ramrom_entries_handle_camera_out`/`ramrom_replay_handler`
|
||
actively firing — real attract-mode canned-input playback, not the boot
|
||
cinematic, which is a separate unrelated state machine in `front.c`).
|
||
`audiotrace.log` (full, unedited):
|
||
|
||
```
|
||
soundIndex=64 -> newState=706f4db0 flags=2 (TRAIN_GO_SFX, first voice)
|
||
soundIndex=64 -> newState=706f4f50 flags=2 (TRAIN_GO_SFX, second voice)
|
||
...
|
||
sndDeactivate: state=706f4e80 (non-null)
|
||
sndDeactivate: state=706f4ee8 (non-null)
|
||
```
|
||
|
||
`soundIndex=64` = `TRAIN_GO_SFX` (`bondconstants.h`), `flags=2` =
|
||
`SOUND_FLAG_LOOPED` — the **only** looped sound requested in the entire
|
||
run. **Neither `706f4db0` nor `706f4f50` ever appears in a
|
||
`sndDeactivate` line, anywhere in the log** — confirmed by grepping the
|
||
full file for both pointers. Meanwhile two *other*, non-looped states
|
||
(`706f4e80`, `706f4ee8`) DO get swept and deactivated, at exactly the
|
||
point the demo cycle ends and the sequence rolls back to the boot logos
|
||
(`RARELOGO_SFX`/`RARELOGO_FAINT_SFX`, indices 258/260, reappear
|
||
immediately after). **This is a direct trace-level confirmation of the
|
||
M-53 "Follow-up" theory**: whatever cleanup sweep runs at the
|
||
demo-cycle/mode-transition boundary deactivates some voices but
|
||
specifically skips the looped one(s) — consistent with
|
||
`sndDeactivateAllSfxByFlag`'s `FINAL_IN_SEQUENCE`-only flag filter
|
||
(`snd.c:996`, see M-53 above) never matching a genuinely-looped ambience
|
||
voice. Reframes the bug from "door-specific" to **"any `SOUND_FLAG_
|
||
LOOPED` voice started during a `ramrom` attract demo has no code path
|
||
that ever stops it once the demo cycle moves on"** — the door report and
|
||
this train report are the same bug, different SFX.
|
||
|
||
**New candidate mechanism for why N64 doesn't audibly show this**:
|
||
`src/libultra/audio/synallocvoice.c`'s `_allocatePVoice`/`alSynAllocVoice`
|
||
implement real N64 hardware **voice-stealing** — a small fixed pool of
|
||
physical `PVoice`s (`ALSynConfig.maxPVoices`, set from
|
||
`MUSIC_SYN_CONFIG_MAX_P_VOICES` in `music.c:772`, unchanged decompiled
|
||
value) where a new voice request silently steals and ramps out the
|
||
lowest-priority already-playing physical voice once the pool is full
|
||
(lines 100-131). On real hardware this would eventually silence an
|
||
orphaned looped voice anyway, once enough *other* sounds compete for
|
||
the same small voice pool — even though its `ALSoundState` bookkeeping
|
||
never gets told to stop. **Not yet checked**: whether the PC port's
|
||
mixer (`port/src/mixer.c`) and the synth's free/alloc/lame-list
|
||
machinery actually enforce the same `maxPVoices` cap end-to-end, or
|
||
whether something in the Phase-3 PC mixer path effectively gives every
|
||
voice a free physical channel (never triggers stealing), which would
|
||
make an orphaned loop audible forever on PC even in scenes where N64
|
||
would eventually have silently stolen it away. This is the most
|
||
promising next-step lead — cheaper to check (audit `mixer.c`'s voice
|
||
dispatch / count) than adding more ramrom-timing probes.
|
||
|
||
**Still OPEN.** Next session, in priority order: (1) audit whether
|
||
`port/src/mixer.c` honors `maxPVoices`/voice-stealing identically to
|
||
`synallocvoice.c`'s original algorithm — if it doesn't, that's the fix,
|
||
and it would explain both the door and train loop reports without
|
||
touching `snd.c`/`propobj.c`/`front.c` game logic at all; (2) if voice
|
||
stealing checks out fine, look at whether N64 has some OTHER explicit
|
||
stop call for these ambience loops that isn't in the `Dxx`-traced code
|
||
paths yet (e.g. a `lvlManageMpGame`-adjacent full-audio-reset at
|
||
attract-cycle boundaries not yet located). Symptom (a)/(2)
|
||
(wrong-sample-per-weapon) still untouched — still needs the
|
||
decoded-PCM listen-compare. The `GE_AUDIOTRACE` trace additions to
|
||
`snd.c` (now covering `sndPlaySfx`'s `newState=`/`flags=` and every
|
||
`sndDeactivate` call) are left in place, uncommitted, for reuse.
|
||
|
||
**M-55 — priority-(1) voice-stealing audit done; result is negative, and
|
||
narrows the bug further.** Static/read-only session (local Qwen `triage`
|
||
dispatch for the grep/citation legwork, spot-checked line-for-line
|
||
against the real files before trusting it).
|
||
|
||
- **`port/src/mixer.c` doesn't need to honor `maxPVoices` — it never
|
||
participates in voice allocation at all.** Grepped it for
|
||
`maxPVoices`/`pAllocList`/`pFreeList`/`pLameList`/`numPVoices`: zero
|
||
hits. `mixer.c` (~436 lines) only implements the low-level `aXxx`
|
||
RSP-acmd DSP commands (envmixer/resample/etc `Impl` functions) that
|
||
`synallocvoice.c`'s already-allocated `PVoice`s get pointed at — the
|
||
lame-list/free-list/steal-lowest-priority bookkeeping (D202 M-54's
|
||
suspect) lives entirely in **unmodified ground-truth**
|
||
`src/libultra/audio/synallocvoice.c` (`alSynAllocVoice`/
|
||
`_allocatePVoice`, lines 27-131, byte-identical to decomp) plus
|
||
`src/libultra/audio/synthesizer.c`'s `alSynNew` (`numPVoices =
|
||
c->maxPVoices`, `music.c:772`'s `MUSIC_SYN_CONFIG_MAX_P_VOICES`,
|
||
unchanged). **The M-54 "maybe the PC mixer gives every voice a free
|
||
channel" theory is ruled out — there's no PC-specific voice-pool code
|
||
to have that bug in.** Since this pool logic is unmodified decomp
|
||
code, its steal behaviour on PC is provably identical to N64's.
|
||
- **Priority (2) has an answer too, and it's negative in a useful way.**
|
||
Traced whether the `ALSynth` driver (or anything else audio-wide) gets
|
||
reset/reinitialized at the attract-demo → boot-logo transition:
|
||
`alInit` (`src/libultra/audio/sl.c:28-33`) is a **hard singleton**
|
||
(`if (!alGlobals) { alSynNew(...); }`) called exactly once, transitively
|
||
from `bossEntry()` → `musicSeqPlayerInit()` (`src/boss.c:312`,
|
||
`src/music.c:648`) → `amCreateAudioManager()` (`src/music.c:781`) →
|
||
`alInit()` (`src/audi.c:374`/`378`) → `alSynNew()`. The only teardown,
|
||
`alClose()` (`src/audi.c:481`), fires solely on audio-thread
|
||
`MAIN_QUIT_MESSAGE` (process exit), not at a demo boundary. The actual
|
||
attract-demo stop path, `stop_demo_playback()`
|
||
(`src/game/ramromreplay.c:604-616`), touches only joystick-playback
|
||
bookkeeping (`joySetPlaybackFunc`/`joySetContDataIndex`/
|
||
`ramrom_demo_related_3`/`is_ramrom_flag`) — **no audio call of any
|
||
kind.** All of `sl.c`, `audi.c`, `boss.c` here are unmodified
|
||
ground-truth, so **N64 has exactly the same absence of a
|
||
demo-boundary audio reset** — confirming there is no missing-on-PC
|
||
system-reset bug to find in this direction either.
|
||
- **Net effect: both of M-54's priority leads are now closed, and
|
||
neither explains the bug.** This pushes the M-53 "Follow-up" theory
|
||
back to the front — a genuinely `SOUND_FLAG_LOOPED` voice has *no*
|
||
code path that stops it at a `ramrom` attract-cycle boundary on
|
||
**either** platform; whatever the real fix is, it is a **pre-existing
|
||
N64 behavior being newly exposed**, not a PC regression, and per rule
|
||
#2 (game logic is ground truth) is very unlikely to be something we
|
||
should "fix" by adding a stop call the original game never had. The
|
||
live-repro data (M-54 cont.) already showed this exact voice audibly
|
||
looping forever in the PC build; whether real N64 hardware's `PVoice`
|
||
stealing (now confirmed byte-identical logic) actually does silence it
|
||
in practice depends on whether some *other* sound eventually claims
|
||
that priority slot during/after the demo cycle — untested acoustically
|
||
either way.
|
||
|
||
**Still OPEN. Next session:** stop auditing the voice-pool/reset code —
|
||
both candidate mechanisms are now cross-validated clean/absent on both
|
||
platforms. The two live options are (a) accept this as a real, harmless-
|
||
on-N64-in-practice edge case (the orphaned voice gets stolen once enough
|
||
other SFX compete for the small `PVoice` pool during normal gameplay,
|
||
just not during an idle unattended attract-mode capture) and downgrade
|
||
D202(b) priority accordingly, or (b) do the acoustic check directly —
|
||
run a longer capture (10+ min, several attract cycles) or a temporary
|
||
`PORT`-gated `ramrom_table` index override to force a door/train demo
|
||
reliably, then listen past the demo boundary to hear whether the loop
|
||
in fact does eventually cut out once other SFX start competing for
|
||
voices (would confirm (a) and close D202(b) as WONTFIX/expected).
|
||
Symptom (a)/(2) (wrong-sample-per-weapon, PPK→PUNCH1) is still fully
|
||
untouched and is now the higher-value remaining D202 thread — needs the
|
||
decoded-PCM listen-compare, not more voice-pool tracing.
|
||
|
||
**M-56 — D202(a) got the decoded-PCM listen-compare, live user confirmation,
|
||
and the index/pointer/ROM-data chain is now independently proven byte-exact
|
||
against the real ROM (not just "ruled out" by code audit). Root cause is
|
||
narrowed to the actual decode/mix runtime.**
|
||
|
||
- Added a temporary `GE_AUDIODUMP=1` probe (`port/src/audio.c`,
|
||
`audioSetNextBuffer`) that raw-dumps every mixed s16 stereo buffer
|
||
reaching the SDL device to `audiodump.raw` — the true final output, same
|
||
path as what plays in-game. Ran a headless repro (`-level_33` Dam,
|
||
`GE_INPUTSCRIPT` holding aim+fire for 8 trigger-pulls starting ~10s in),
|
||
converted the dump to WAV, located the transients via a 50ms-window RMS
|
||
scan, and sent the clip to the user. **Exact repro command** (run from
|
||
repo root, `export PATH=/c/msys64/mingw64/bin:$PATH` first):
|
||
```
|
||
rm -f audiotrace.log audiodump.raw
|
||
GE_AUDIODUMP=1 GE_AUDIOTRACE=1 GE_INPUTSCRIPT="600:R,Z;601:R,Z;650:R,Z;651:R,Z;700:R,Z;701:R,Z;750:R,Z;751:R,Z;800:R,Z;801:R,Z;850:R,Z;851:R,Z;900:R,Z;901:R,Z;950:R,Z;951:R,Z" \
|
||
timeout 30 ./build-pc/ge007.x86_64.exe -level_33
|
||
```
|
||
`audiotrace.log` (per-call soundIndex/pointer trace) and `audiodump.raw`
|
||
(raw s16le stereo @ 22050Hz, no header — wrap with Python's `wave` module
|
||
or `ffmpeg -f s16le -ar 22050 -ac 2 -i audiodump.raw out.wav`) land in the
|
||
repo root. **User confirmation: "it's playing
|
||
the ricochet sound effect and the slapping melee attack sound effect on
|
||
each gunshot"** — i.e. two distinct wrong-but-real game sounds, not
|
||
silence/noise/a single mislabeled clip.
|
||
- The `GE_AUDIOTRACE` probe (already in tree) on that same run showed the
|
||
fire event genuinely requests **soundIndex=46 (`GUN_SILPPK_A_SFX`)**
|
||
paired with **soundIndex=122 (`CART_SPENT_SFX`, the shell-casing sound —
|
||
`gunfire.c:5582`)** — both legitimate, correct call sites for this
|
||
weapon/action, resolving to non-null, structurally-valid
|
||
sound/keyMap/wavetable/book pointers. This is a **live, real-gameplay
|
||
confirmation** of what M-52 had only checked structurally.
|
||
- **Independently re-parsed the raw ROM bytes from scratch in Python**
|
||
(no reuse of any project code — a from-first-principles walk of
|
||
`ALBankFile → ALBank → ALInstrument → ALSound → ALWaveTable` against
|
||
`data/ge007.ntsc-final.z64` at the `_sfxctlSegmentRomStart`/
|
||
`_sfxtblSegmentRomStart` addresses) to check the PC port's custom
|
||
`port/src/romdata.c` re-layout tool (`romdataAudioBankPcSize`/
|
||
`romdataFixupAudioBank`, the `afFixup*` family — hand-written PORT code,
|
||
not decomp, and the one part of this chain not previously scrutinized
|
||
this closely). **Result: exact match.** Index 46's wavetable resolves to
|
||
`base=0x103128A0 len=2242`, index 122's to `base=0x10351DF8 len=2196` —
|
||
**both bit-for-bit identical to the live runtime trace.** Also pulled
|
||
each sound's ADPCM book (order/npredictors/coefficients) and `ALKeyMap`
|
||
directly from ROM: indices 46/47/122 all have distinct, non-aliased book
|
||
coefficients (not a copy-paste/dedup collision), though 46 and 47
|
||
(`PUNCH1_SFX`) do share an identical `keyBase=54/detune=50` keymap —
|
||
coincidental (both are attack-transient sounds tuned the same), not by
|
||
itself an index confusion.
|
||
- **This closes out the index/pointer/ROM-offset chain for good** — it is
|
||
now proven correct by independent reconstruction, not just "audited and
|
||
believed clean" (M-52's original phrasing). Since each sound's raw
|
||
ROM-embedded ADPCM data is objectively distinct (different books), the
|
||
wrong-sounding output can only be happening in the **decode or mixing
|
||
runtime itself** — i.e. the actually executed `port/src/mixer.c` path,
|
||
not the data feeding it.
|
||
- **New leading suspect, not yet tested**: **D199's flagged risk** — the
|
||
Phase-3 mixer's `aSetBuffer` **persistent-DMEM-context inference**
|
||
(macro-swap `aXxx`→`aXxxImpl` execution with no RSP disassembly to
|
||
verify against, `findings.md` D199) — if two voices active in the same
|
||
audio frame (e.g. the continuous Dam wave-ambience loop already seen
|
||
looping throughout this capture, soundIndex 65/67, plus the fire SFX)
|
||
end up sharing/clobbering the same inferred DMEM decode-context instead
|
||
of each getting its own, one voice's ADPCM decoder state could bleed
|
||
into another's output — producing exactly this "correct data in, wrong
|
||
but real-sounding-like-something-else data out" symptom, deterministically
|
||
per concurrent-voice pairing. This is a hypothesis, not yet verified.
|
||
|
||
**Still OPEN. Next session:** audit `port/src/mixer.c`'s `aSetBuffer`/DMEM
|
||
context handling for whether concurrently-active voices (not just
|
||
back-to-back `Impl` calls for the *same* voice) get isolated decode state —
|
||
trace which physical DMEM buffer/context each of the fire SFX and the
|
||
continuous ambience loop actually uses per frame during the same repro
|
||
(`GE_AUDIODUMP`+`GE_AUDIOTRACE`, `-level_33`, aim+fire script above already
|
||
gives a clean repro). Do NOT re-check the ROM offset/pointer/index chain
|
||
again — independently re-derived byte-exact this session, closed for good.
|
||
|
||
**M-57 — the M-56 concurrent-voice-DMEM hypothesis is FALSIFIED by a live
|
||
opcode-level trace, not just re-audited. A new (currently dormant)
|
||
pointer-truncation bug was found in the same family as D198/D201, and a
|
||
full sibling audit was produced, but NEITHER explains D202 yet — still
|
||
OPEN.** Interactive session, build+run environment confirmed working
|
||
(`export PATH=/c/msys64/mingw64/bin:$PATH` from repo root).
|
||
|
||
- Added a temporary `GE_MIXERTRACE=1` probe (`port/src/mixer.c`) that logs
|
||
every `aSetBuffer`/`aLoadADPCM`/`aADPCMdec`/`aResample`/`aEnvMixer` call
|
||
with its DMEM addresses and state pointer, in call order, to
|
||
`mixertrace.log`. Reran the exact M-56 repro
|
||
(`GE_AUDIODUMP=1 GE_AUDIOTRACE=1 GE_MIXERTRACE=1 GE_INPUTSCRIPT=... -level_33`)
|
||
and read the trace around the soundIndex 46/122 fire event (cross-referenced
|
||
via `audiotrace.log` and the `alLoadParam` wire probe's `audiotrace_wire.log`,
|
||
both already in tree from M-52/M-56).
|
||
- **Result: every voice's full chain — `aLoadADPCM` → `aSetBuffer`(decode) →
|
||
`aADPCMdec` → `aSetBuffer`(resample) → `aResample` → `aSetBuffer`(main) →
|
||
`aSetBuffer`(aux) → `aEnvMixer` — runs to completion, strictly sequentially,
|
||
before the next voice's `aLoadADPCM` starts.** No interleaving between
|
||
voices was observed anywhere in ~62k logged opcodes spanning the fire
|
||
event. The single shared `sDmem`/`sAdpcmTable`/`sCtx` globals are safe
|
||
under this call pattern for exactly the same reason the real RSP's single
|
||
physical DMEM is safe: one voice's full pipeline finishes and its result is
|
||
accumulated into the persistent `AL_MAIN_L_OUT`/`AL_AUX_*` buffers before
|
||
the scratch region (`AL_DECODER_IN`=0) is reused by the next voice. **D199's
|
||
"concurrent-voice DMEM clobber" risk is closed, negative** — falsified by
|
||
direct trace, not just re-reasoned about.
|
||
- Also noticed `a->table->len` visibly changes (2242 → 2241) between the
|
||
first and later `alLoadParam` wire-probe log lines for the *same* shared
|
||
`ALWaveTable`. Traced this to `load.c:396`
|
||
(`a->table->len = ADPCMFBYTES * ((s32)(a->table->len/ADPCMFBYTES));`) —
|
||
**unmodified N64 ground-truth code**, rounds the table length down to a
|
||
whole-ADPCM-frame multiple. It mutates the shared table in place but is
|
||
idempotent (rounds to the same value on every call after the first) and
|
||
behaves identically on N64. **Not a bug — ruled out.**
|
||
- **New finding (not yet root-caused as D202's cause, likely a separate
|
||
latent issue): `src/libultra/audio/load.c` lines 111, 173, 259, 320, 382,
|
||
438 all do `f->memin = (s32) f->table->base + ...` / `a->memin = (s32)
|
||
a->table->base` — narrowing a real 64-bit `u8 *base` pointer
|
||
(`ALWaveTable.base`, `include/PR/libaudio.h:217`) into the `s32 memin`
|
||
field** (matches N64 ground truth's struct layout, `synthInternals.h`).
|
||
This is the same truncation class as the already-fixed D198
|
||
(`K0_TO_PHYS`) and D201 (`bnkf.c` relocation offsets), but **currently
|
||
dormant in this session's build**: the observed `ALWaveTable.base` values
|
||
(e.g. `0x103128a0`) sit comfortably under 4GB (this exe loads its ROM-data
|
||
blob at a low, non-ASLR'd address), so the `(s32)` truncation followed by
|
||
`osPhysicalToVirtual`'s `u32`→pointer zero-extension round-trips exactly —
|
||
no data corruption observed in this run. Flagging as a **hardening item**,
|
||
not a confirmed D202 cause; would need a build/environment where the
|
||
ROM-data blob's heap address exceeds 4GB to prove it live. Not fixed this
|
||
session (out of scope for this investigation; needs its own D-number if
|
||
picked up).
|
||
- Delegated a read-only sibling-audit of this cast pattern across the whole
|
||
audio subsystem to the local Qwen `repo-mapper` agent (grep-only, no
|
||
edits, checked against real files). Full hit list, confirmed genuine
|
||
pointer-truncation sites beyond `load.c`:
|
||
- `src/music.c:879,1073,1266` — `(u8*)((t3 + (s32)thing.seqData) -
|
||
trackSizeBytes)` — same class, narrows a seq-data pointer before a ROM→RAM
|
||
copy. Worth checking next alongside `load.c`.
|
||
- `src/libultra/audio/heapinit.c:26` — narrows the audio heap `base`
|
||
pointer to `s32` but immediately masks with `& AL_CACHE_ALIGN` (only
|
||
alignment low-bits survive) — low risk in practice, still worth a
|
||
`uintptr_t` pass for hygiene.
|
||
- Every other `(s32)`/`(int)` cast in `src/libultra/audio/*.c` and
|
||
`src/libultrare/audio/*.c` (`resample.c`, `reverb.c`, `env.c`,
|
||
`seqplayer.c`, `csplayer.c`, `save.c`, `synthesizer.c`, `drvrNew.c`)
|
||
narrows a genuine small integer (byte/sample counts, pitch ratios,
|
||
indices) — not pointer truncation, nothing to fix.
|
||
|
||
**M-58 — priority (1) from the M-57 write-up (decoded-PCM-per-voice-slot dump)
|
||
executed. Result: address resolution AND the ADPCM decode math are BOTH
|
||
independently verified bit-exact correct for a live fire-SFX occurrence.
|
||
D202 is NOT in `aLoadBuffer`'s DMA/address plumbing and NOT in
|
||
`aADPCMdecImpl`'s decode arithmetic. Still OPEN — the bug is downstream of
|
||
decode or in voice/output routing.**
|
||
|
||
- Extended the `GE_MIXERTRACE` probe (env-gated, zero cost when unset)
|
||
three ways: (a) `port/src/mixer.c` `aLoadBufferImpl`/`aLoadADPCMImpl`/
|
||
`aADPCMdecImpl` now log the actual DMEM source bytes, the loaded ADPCM
|
||
book coefficients, and the first decoded 16-sample frame (skipping the
|
||
16-sample history lead-in) into the *same* `mixertrace.log` as the M-57
|
||
opcode trace; (b) `src/libultra/audio/load.c` `_decodeChunk` logs
|
||
`[DMAREQ] filter=<ALLoadFilter*> memin=<addr>` right before calling
|
||
`f->dma(...)`, and `alLoadParam`'s existing `AL_FILTER_SET_WAVETABLE` case
|
||
now also emits a `[BINDTABLE] filter=<ptr> <- table=... base=...` line into
|
||
`mixertrace.log` (previously only in the separate `audiotrace_wire.log`)
|
||
so table-bind, DMA, and decode events for the same physical voice filter
|
||
interleave in one chronologically-ordered file instead of needing
|
||
cross-file correlation by line count (a real mistake made mid-session:
|
||
comparing `wavetable->base` from one run's `audiotrace.log` against
|
||
`aLoadBuffer` addresses from a *different* run's `mixertrace.log` produced
|
||
a spurious "addresses never match" false alarm — resolved by getting both
|
||
probes into one file in one run); (c) `src/audi.c` `amDmaCallback` now
|
||
logs `[DMAHIT]`/`[DMAMISS]` with the input ROM-side address, matched
|
||
buffer's `startAddr`, and the returned staging-buffer address.
|
||
- Reran the exact M-56/M-57 repro
|
||
(`GE_AUDIODUMP=1 GE_AUDIOTRACE=1 GE_MIXERTRACE=1 GE_INPUTSCRIPT=... -level_33`,
|
||
see M-56 for the exact command). Picked one live fire event
|
||
(`soundIndex=46`, `wavetable->base=0x103128a0`) and followed its physical
|
||
voice filter (`ALLoadFilter*`) end to end in one linear read of
|
||
`mixertrace.log`:
|
||
- `[BINDTABLE] filter=...706d7230 <- table=...706c4cc8 base=0x103128a0`
|
||
- `[DMAREQ] filter=...706d7230 memin=0x103128a0` — **exactly matches** the
|
||
bound table's own base. No cross-voice/cross-heap address confusion.
|
||
- `[DMAMISS] addr=0x103128a0 ... ret=0x706dd490` — legitimate cold DMA
|
||
(N64-analogous ROM→staging-buffer copy via `port/src/libultra.c`'s
|
||
`piServiceDma`, which correctly `memcpy`s from the cart-mapped `.z64`
|
||
region at `srcPA=0x103128a0`, a real, valid cart address under
|
||
`romdata.c`'s mapping — not a stray heap pointer as first suspected
|
||
mid-session before the single-file trace fix above).
|
||
- `[LOADBUF] dram=0x706dd490 ... bytes=900de113224092ecde90f602424ae1bf` —
|
||
the exact ADPCM-encoded source bytes now loaded into DMEM.
|
||
- `[ADPCMDEC] ... book0=-881` and the dumped book coefficients match
|
||
`sndPlaySfx`'s own `book=00000000706c4ca0` trace for soundIndex 46
|
||
(`+8` byte print-pointer offset, same object) — the correct predictor
|
||
table for this sound, not a stale/aliased one.
|
||
- `[PCMOUT] book0=-881 first-decoded-frame[0..15]=0,-1536,-2475,-1165,...`
|
||
- **Independently hand-decoded the same 9 input bytes against the same
|
||
dumped book coefficients using the textbook VADPCM algorithm** (order-2
|
||
predictor, `header byte -> shift+tableIndex`, nibble sign-extension,
|
||
cumulative predictor sum), entirely outside this codebase, no reuse of
|
||
`mixer.c`'s logic. First three samples worked by hand: `0`, `-1536`,
|
||
`-2475` — **bit-exact match** against the C implementation's dumped
|
||
output. This independently confirms `aADPCMdecImpl`'s math is a correct
|
||
VADPCM decoder, not just internally self-consistent.
|
||
- Also noticed (not a bug, recorded for the record): `sAdpcmTable` is a
|
||
single static global reused across sound switches without zeroing unused
|
||
predictor slots, so predictor indices beyond a sound's own `npredictors`
|
||
hold stale coefficients from a previous sound's book load. Harmless in
|
||
practice — a sound's own ADPCM-encoded stream only ever emits
|
||
`tableIndex` values within its own encoded `npredictors` range (confirmed
|
||
here: soundIndex 46's book is `bookSize=32` bytes = 1 predictor via
|
||
`2*order*npredictors*ADPCMVSIZE`, and its stream's header nibble was `0`,
|
||
the only valid index) — but it is fragile precisely because it depends on
|
||
the encoder never emitting an out-of-range index; a future corrupted/
|
||
malformed bank could read garbage predictor coefficients silently. Not
|
||
pursued further this session (not shown to relate to D202).
|
||
- **This closes out the entire `aSetBuffer`→`aLoadBuffer`→`aLoadADPCM`→
|
||
`aADPCMdec` chain for the cases actually traced.** Combined with M-56
|
||
(ROM data/index chain closed) and M-57 (DMEM concurrency closed), the
|
||
first three links of the pipeline — request routing, ROM data integrity,
|
||
and decode — are now all independently verified correct for at least one
|
||
live fire-SFX instance. The mid-session false alarm (see above) is a
|
||
useful lesson: **always put every new correlated probe into the same
|
||
linearly-ordered trace file**; cross-file correlation by line count
|
||
across separate runs is not valid even when the repro is "the same"
|
||
command, because per-run nondeterminism (already catalogued, D117) shifts
|
||
line counts and, this session, briefly looked exactly like a real bug.
|
||
|
||
**Still OPEN. Next session, in priority order:** (1) the remaining untraced
|
||
links are **resample → envmix → final-mix routing**, and **physical-voice
|
||
(PVoice) allocation/identity** — whether the correctly-decoded PCM for one
|
||
logical `ALSoundState` ends up accumulated into the *wrong* voice's/output's
|
||
persistent envelope-mixer volume-ramp state (`sVol`/`saved->t[]` in
|
||
`aEnvMixerImpl`) or gets attributed to the wrong output buffer, which would
|
||
produce exactly "correct audio computed, wrong audio heard." Trace the same
|
||
way as this session (one physical filter/voice followed end-to-end in one
|
||
`mixertrace.log`, `[ENVMIX] state=...` pointer identity checked against which
|
||
logical sound it's supposed to belong to) rather than assuming and re-deriving.
|
||
(2) If that's clean too, suspect PVoice allocation/identity higher up (the
|
||
synth's voice-slot assignment, independent of DMEM/decode). (3) Only after
|
||
(1)/(2): consider fixing the `load.c`/`music.c` pointer-truncation family as
|
||
a hardening pass (own D-number) — still not shown to cause D202. Do NOT
|
||
re-check the ROM offset/index/pointer chain (M-56), DMEM/`aSetBuffer`
|
||
concurrent-voice sharing (M-57), or the decode arithmetic/addressing
|
||
(M-58, this session, verified bit-exact) — all closed.
|
||
|
||
**M-59 — likely resolution: D202 is NOT a decode/mixer bug. The "ricochet +
|
||
melee slap" audio the M-56 listening test heard is a SEPARATE, entirely
|
||
legitimate SFX request made by unmodified ground-truth game logic on every
|
||
shot in this specific repro, because the scripted aim direction hits a wall/
|
||
prop, not open air. Recommend a follow-up open-air listening test before
|
||
resuming the M-58 priority-1 envmix/PVoice trace.**
|
||
|
||
- Two more concrete checks, continuing directly from M-58's decode-chain
|
||
verification (which stays valid and closed): (a) independently confirmed
|
||
the actual DMEM-loaded ADPCM byte string M-58 dumped for soundIndex 46
|
||
(`900de113224092ecde90f602424ae1bf...`) is byte-for-byte identical to the
|
||
**raw, untouched `data/ge007.ntsc-final.z64` file** at the corresponding
|
||
cart offset (`0x03128a0`, i.e. `base 0x103128a0` minus `CART_BASE
|
||
0x10000000`) — read directly with a throwaway Python script, no project
|
||
code reused. Traced why this address is meaningful at all:
|
||
`_sfxtblSegmentRomStart` (`port/src/romassets_u.s:2948`) is a linker
|
||
constant equal to the literal cart address `0x102F19A0`, used as-is by
|
||
`alBnkfNew`'s `table` argument (`src/music.c:703`) → `_bnkfPatchWaveTable`
|
||
(`src/libultra/audio/bnkf.c:140`, `w->base += table`) — i.e. GE's
|
||
wavetable sample data is **never relaid-out or copied by
|
||
`port/src/romdata.c`'s bank-structure fixup at all**; `base` ends up
|
||
pointing straight into the same live-mapped `.z64` image `port/src/audi.c`
|
||
reads cart data from generally (`docs/dev/findings.md` cart-mapping notes,
|
||
M-56). This closes the last theoretical gap in the M-56/M-58 chain: ROM
|
||
data integrity is now verified at the raw file-byte level, not just
|
||
structurally.
|
||
- With the entire request→ROM-offset→DMA→decode chain now proven correct
|
||
end to end, (b) re-examined the *complete* `audiotrace.log` from the
|
||
M-58 repro (`-level_33` Dam, aim+fire `GE_INPUTSCRIPT`) rather than
|
||
filtering to just soundIndex 46/122, and cross-referenced every other
|
||
soundIndex against `src/bondconstants.h`'s `"NN_NAME_SFX"` string table
|
||
(which encodes the index directly in the name — e.g. `"46_
|
||
GUN_SILPPK_A_SFX"`). **Every single `soundIndex=46` (fire) request in
|
||
the trace is immediately followed by a `soundIndex` from `{23, 24, 25,
|
||
37}`** — `"23_RICO_6_TAJ_A_SFX"`, `"24_RICO_6_TAJ_B_SFX"`,
|
||
`"25_RICO_6_TAJ_C_SFX"`, `"37_RICO_5_C_SFX"` — i.e. **ricochet/bullet-
|
||
impact sounds**, cycling per shot, plus the already-known `122` =
|
||
`"122_CART_SPENT_SFX"` (cartridge eject).
|
||
- Found the call site: `src/game/gunfire.c`
|
||
`recall_joy2_hits_edit_detail_edit_flag()` (line ~2270, unmodified
|
||
ground-truth decomp function, original obfuscated name) is the bullet
|
||
hit-detection handler. For a hitscan that strikes a non-character prop
|
||
(`prop->type != PROP_TYPE_CHR && != PROP_TYPE_VIEWER`, i.e. scenery/
|
||
wall, not an enemy), it unconditionally does:
|
||
`ricochet_sounds_small_copy = ricochet_sounds_small;
|
||
sndPlaySfx(g_musicSfxBufferPtr,
|
||
ricochet_sounds_small_copy.arr[randomGetNext() % 20], sound_state);`
|
||
— **every bullet that hits a wall/prop plays a randomly-chosen ricochet
|
||
SFX from a 20-entry table, by design, on real N64 too.** This is not a
|
||
port artifact; it is exactly what GoldenEye does when you shoot a wall.
|
||
- **Conclusion**: the M-52/M-56 repro's fixed aim direction
|
||
(`-level_33`, default spawn facing, held-down aim+fire) has the player
|
||
shooting directly into a nearby wall/prop the entire time, so *every*
|
||
trigger pull legitimately plays fire (46) + cartridge-eject (122) +
|
||
a random ricochet impact (23/24/25/37) simultaneously — three real,
|
||
correct SFX per shot, not one wrong one. The M-52 playtest's original
|
||
verbal description ("sounds like a melee/punch hit") and the M-56
|
||
listening test's ("ricochet sound effect and the slapping melee attack
|
||
sound effect") both match a ricochet impact's percussive/metallic
|
||
character far better than a coincidental decode bug that happens to
|
||
always produce two *other real, nameable* game sounds. No corrupted or
|
||
misattributed audio has been found anywhere in the chain despite three
|
||
full sessions (M-56/M-57/M-58) of independent, bit-exact and byte-exact
|
||
verification at every single link — address resolution, ROM data
|
||
integrity, book/coefficient selection, ADPCM decode arithmetic, and now
|
||
the request pattern itself.
|
||
- **This does not yet prove the gunfire (46) sound itself is
|
||
perceptually correct/prominent** — only that it is being computed
|
||
correctly and is not what's "wrong" in what the tester heard. It's
|
||
possible the fire SFX is simply quiet/subtle by design and gets
|
||
acoustically masked by the louder ricochet transient, which would be
|
||
expected N64 behavior too, or (lower probability, unverified) there is
|
||
a genuine separate volume/mix-balance issue between simultaneous
|
||
voices. Not fixed or further diagnosed this session — needs a
|
||
clean listening test to settle it (see below).
|
||
|
||
**Recommended next session (before resuming the M-58 priority-1 envmix/
|
||
PVoice trace, which may now be unnecessary): re-run the exact same
|
||
`GE_AUDIODUMP` capture but with an `GE_INPUTSCRIPT` aim direction that
|
||
points at open air / the sky instead of a wall (or simply hold fire without
|
||
the aim-lock at a fixed wall-facing spawn), confirm via `audiotrace.log`
|
||
that NO `ricochet_sounds_small`-family soundIndex fires, and have the user
|
||
listen to that clip specifically. If the fire (46) + cartridge (122) sounds
|
||
alone are now clearly audible, correct, and recognizable as gunfire, D202
|
||
should be closed as a repro artifact (aim-at-wall), not a bug — with a note
|
||
that the original M-52 playtest report may itself have simply been the
|
||
player firing at nearby geometry during normal play, which is entirely
|
||
expected. Only if the open-air clip STILL sounds wrong should the M-58
|
||
priority-1 envmix/PVoice-identity trace be resumed.
|
||
|
||
**Follow-up done same session (M-59 cont.):** turned the character with
|
||
`GE_INPUTSCRIPT="1:SRIGHT;400:SNONE;600:R,Z;..."` (sustained analog-stick
|
||
turn before the aim+fire sequence) and reran the `GE_AUDIOTRACE`+
|
||
`GE_AUDIODUMP` capture on `-level_33`. Result: the *first* shot in this run
|
||
requests **only** `soundIndex=46` (fire) + `122` (cartridge) — no
|
||
`ricochet_sounds_small` index at all — confirming the turn moved the aim
|
||
off whatever wall/prop the original fixed-facing repro was hitting. The
|
||
*second* shot (moments later, once the turn brought the aim back toward
|
||
nearby geometry) requests `46` + `122` + `23` (`RICO_6_TAJ_A_SFX`) again,
|
||
giving a same-run A/B pair. Located both transients in `audiodump.raw` via
|
||
a 50ms-window RMS scan (clean shot ≈ t=11.0–11.7s, ricochet shot ≈
|
||
t=11.7–12.6s), exported both as WAV (`scratchpad/
|
||
d202_clean_shot_no_ricochet.wav` / `scratchpad/d202_shot_with_ricochet.wav`,
|
||
not committed — scratch), and sent both to the user for a direct listening
|
||
comparison. **Awaiting user confirmation** on whether the ricochet-free
|
||
clip sounds like correct, recognizable gunfire — that answer settles
|
||
whether D202 closes outright as a repro artifact or the envmix/PVoice trace
|
||
needs to resume.
|
||
|
||
**M-59 result: user says BOTH clips are the ricochet/slap sound, just at
|
||
different volumes — the "clean" clip is not clean.** This falsifies the
|
||
"aim-at-wall repro artifact" theory as originally framed. Investigated
|
||
further:
|
||
|
||
- The two exported clips both came from `dumppos`-adjacent events with
|
||
soundIndex 46 and a ricochet index at the **same audio-frame position**
|
||
in this run's `audiotrace.log` (a new `dumppos=<byte offset into
|
||
audiodump.raw>` field was added to the existing `GE_AUDIOTRACE` probe —
|
||
`port/src/audio.c` now exposes `audioDumpBytePos()`, an exact running
|
||
byte counter, so future clip extraction can key off a logged sample
|
||
position instead of an RMS-scan guess). Confirmed the fire (46) and
|
||
ricochet requests land at the *same* `dumppos`, i.e. they are requested
|
||
in the same tick and their audio overlaps in time rather than being
|
||
sequential — so the RMS-peak-picking method used for the M-59 clips could
|
||
not actually isolate a ricochet-free window; both exported clips likely
|
||
had some ricochet content, just at different relative volume (distance-
|
||
dependent ricochet gain, or a different ricochet variant per shot).
|
||
- To get a **guaranteed** ricochet-free sample, decoded `soundIndex 46`
|
||
**entirely offline**: read `data/ge007.ntsc-final.z64` directly at ROM
|
||
offset `0x03128a0`, len 2242, ran the same hand-verified VADPCM decode
|
||
used in M-58 (order-2, single predictor, coefficients from the M-58
|
||
trace dump) with no live game/mixer involved at all — zero possibility of
|
||
a simultaneous ricochet voice. Rendered to `scratchpad/
|
||
d202_soundindex46_isolated.wav`, sent to the user. **User confirmed: "yeah
|
||
that is the slap effect that in the original game plays when you do a
|
||
melee attack."** This is unambiguous — soundIndex 46's actual ROM sample
|
||
content genuinely is (or matches) the melee-slap SFX, independent of any
|
||
mixing/overlap question.
|
||
- **Traced the request chain one level further up, past `sndPlaySfx`, to
|
||
find out why soundIndex 46 is requested for the silenced PPK's fire at
|
||
all.** `bondwalkItemGetSound(item)` (`src/game/gun.c:1345`) returns
|
||
`get_ptr_item_statistics(item)->Sound` — a `u16 Sound` field on the
|
||
per-weapon `WeaponStats` struct (`src/game/gun.h:87`, doc comment:
|
||
"Sound effect played when gun is shot. There are 261 sound effects, or
|
||
0 - 105h."). `get_ptr_item_statistics` (`gun.c:721`) returns
|
||
`gitem_structs[item].item_weapon_stats` — **not a runtime ROM-converted
|
||
pointer**; `gitem_structs[]` (`assets/obseg/gun/gunModelFileRecord.inc.c`)
|
||
is a **static, compiled C data table**, decompiled directly from the
|
||
ROM's `.data` segment (each `GUNFILERECORD(...)` entry pulling in a
|
||
`GUNSTATS(name)` initializer). The silenced PPK's is
|
||
`assets/obseg/gun/wppksil/gunWeaponStat.inc.c`
|
||
(`WeaponStats wppksil_stats = {1.0, 11.0, ..., 0x2E, ...}`, tagged with a
|
||
decompilation RAM-address comment `//D:800326C4`) — **`.Sound` is
|
||
positionally the 14th field, value `0x2E` = decimal **46**.** This is
|
||
unmodified, hand-decompiled ground truth, not a port-side conversion —
|
||
there is no PC-specific fixup in this chain at all (unlike the wavetable
|
||
data, this table is a plain compiled literal, identical on N64 and PC by
|
||
construction).
|
||
- **Conclusion: on real N64 hardware, firing the silenced PPK requests
|
||
the exact same soundIndex 46 this port does.** Every link — decompiled
|
||
weapon-stat data, SFX-bank index resolution (M-56/M-59), ROM sample
|
||
bytes (M-58/M-59), and ADPCM decode (M-58) — is now independently
|
||
verified correct and, more importantly, **identical to what N64 hardware
|
||
would do with the same ROM**. If soundIndex 46 truly sounds like a melee
|
||
slap and not a silenced pistol, that is either (a) the original,
|
||
shipped game's actual designed audio (silenced firearms can have a soft,
|
||
percussive "thwack" character that isn't unreasonable to mistake for a
|
||
melee sound, and reusing one SFX asset across two unrelated actions to
|
||
save ROM space is a common technique of this era) — in which case there
|
||
is **no bug anywhere in this port to fix**, this is how GoldenEye 007
|
||
actually sounds; or (b) a very rare decompilation transcription error in
|
||
`wppksil_stats`'s `.Sound` field specifically — but this project's
|
||
entire premise is byte-verified matching against the real ROM's `.data`
|
||
segment (README/AGENTS.md), so an error surviving in a byte-matched
|
||
build would be surprising, though not provably impossible without
|
||
independently re-reading the literal ROM bytes at this struct's true RAM/
|
||
ROM address (not attempted this session — would need the N64 build's
|
||
linker map to translate the `//D:800326C4` RAM comment to a ROM file
|
||
offset).
|
||
- **Not fixed, not committed — this is a research conclusion, not a code
|
||
change.** No `port/`, `src/`, or `assets/` change is warranted unless (b)
|
||
above is confirmed; per AGENTS.md, `assets/obseg/gun/wppksil/
|
||
gunWeaponStat.inc.c` is decompiled ROM data (ground truth) and must not
|
||
be "corrected" speculatively.
|
||
|
||
**Recommended definitive tie-breaker (next session, if the user wants to
|
||
keep pursuing this rather than close it):** run the **same ROM** in a
|
||
known-good N64 emulator (or, if available, real hardware) and fire the
|
||
silenced PPK. If it also sounds like the melee-slap effect there, D202 is
|
||
closed for good as a genuine original-game characteristic (or a very
|
||
deep, pre-existing GoldenEye quirk) — not a decompilation or port bug, and
|
||
no further engineering time should go into it. If the emulator's silenced
|
||
PPK sounds like a normal muted gunshot, the bug is almost certainly the
|
||
rare decompilation transcription error in (b), and the fix would be a
|
||
single-field data correction in `gunWeaponStat.inc.c` guarded by the
|
||
project's existing byte-match verification (which would need to still
|
||
pass — if it doesn't, this table is confirmed byte-correct after all and
|
||
the "bug" is upstream of Bond's silenced-PPK path in some other way not
|
||
yet identified).
|
||
|
||
**M-60 — user explicitly rejects the "ground truth, not a bug" conclusion**
|
||
("I know for a fact its the wrong sound playing in the PC port
|
||
specifically") and supplies a real reference clip instead of an emulator
|
||
A/B: `scratchpad/B00I00S2D.wav` (untracked/scratch), a genuine recording of
|
||
the silenced PPK's real fire sound, dropped directly by the user (source
|
||
not yet stated — presumably real hardware/video capture, not this repo).
|
||
Measured: mono, 16-bit, 16 kHz, 3264 samples, 0.204 s. Its 10-bucket RMS
|
||
envelope (`12645, 11548, 15152, 16172, 14006, 12766, 15106, 15669, 8280,
|
||
6608, 8032, 9409, 9812, 8849, 7745, 6819, 4299, 1477, 496, 176` over 20
|
||
buckets) is **sustained** — energy stays in the 6000-16000 range for
|
||
roughly the first 80% of the clip's duration before decaying in the last
|
||
~20% — structurally different from `d202_soundindex46_isolated.wav`'s
|
||
envelope, which peaks early (`11209, 19213, 17387, 16284, 11296, 10047,
|
||
6116, 3801, 2238, 1059` over 10 buckets) and decays smoothly and quickly
|
||
from the first bucket onward. This is consistent with the user's "slap"
|
||
identification (a single sharp decaying transient) vs. a real gunshot's
|
||
more sustained buzz/reverb character, and is independent corroborating
|
||
evidence — not proof by itself, since envelope shape alone doesn't rule out
|
||
a resample/pitch difference distorting the comparison (see below).
|
||
|
||
**Re-opened the investigation on the user's instruction; do not close D202
|
||
as WONTFIX.** Two concrete threads were started this session, neither
|
||
finished:
|
||
|
||
1. **The debug-name label itself is suspicious independent of any
|
||
listening test.** `bondconstants.h`'s soundIndex 46 is *named*
|
||
`"46_GUN_SILPPK_A_SFX"` — a name that specifically claims to be the
|
||
silenced-PPK gunshot — yet its decoded ROM content is what the user
|
||
twice identified as the melee-slap effect (which has its own, different,
|
||
adjacent indices: `47_PUNCH1_SFX`/`48_PUNCH2_SFX`/`49_PUNCH3_SFX`). A
|
||
correctly-decoded sound whose content doesn't match its own debug label
|
||
points at a **sound-bank navigation/indexing bug** (wrong bank, wrong
|
||
instrument, or an off-by-one somewhere in the ALBank -> ALInstrument ->
|
||
ALSound walk) rather than at "GoldenEye's SFX design reuses one asset
|
||
for two things" (M-59's charitable reading (a)). `src/snd.c`'s
|
||
`sndPlaySfx` doc comment is explicit about the resolution path:
|
||
`soundBank->instArray[0]->soundArray[soundIndex]` — i.e. `soundIndex` is
|
||
an index into **instrument 0's** sound array, not a bank-global flat
|
||
index. Worth checking directly: does `romdata.c`'s bank re-layout
|
||
(`afCtx`/`_bnkfPatchWaveTable`, `port/src/romdata.c` ~L582-780) walk
|
||
instruments/sounds in the same order the ROM's `ALBankFile` stores them,
|
||
or could the PC conversion silently shift indices by one somewhere in
|
||
that re-layout pass? Not yet checked line-by-line against the ROM bytes
|
||
for THIS specific bank/instrument.
|
||
2. **Started `scratchpad/rom_sfx_decode.py`** (untracked, WIP, not yet
|
||
working correctly) — a fresh, from-scratch offline ROM bank parser
|
||
(independent of the live game) intended to decode a whole *range* of
|
||
`soundIndex` values (44-50, 107, extensible) directly from
|
||
`_sfxctlSegmentRomStart`/`_sfxtblSegmentRomStart` and compare each one's
|
||
envelope/waveform against the new `B00I00S2D.wav` reference, to find
|
||
which soundIndex (if any) actually matches the real gunshot — which
|
||
would prove a specific off-by-N in the bank walk rather than relying on
|
||
ear-only comparisons. **This script's ADPCM predictor loop is currently
|
||
WRONG and must not be trusted as-is** — it stubs out the intra-subframe
|
||
accumulation term with a dead `if False` branch. Confirmed the *exact*
|
||
correct algorithm by re-reading `port/src/mixer.c`'s `aADPCMdecImpl`
|
||
(L185-239, ground truth, this is what the live port actually runs):
|
||
table shape `sAdpcmTable[predictor][2][8]`; per output sample `j` (0-7)
|
||
within a subframe, `acc = tbl[0][j]*prev2 + tbl[1][j]*prev1 +
|
||
(ins[j]<<11) + sum_{k=0}^{j-1} tbl[1][j-k-1]*ins[k]`, `ins[]` being the
|
||
8 sign-extended-then-shifted nibbles of that subframe, `prev1`/`prev2`
|
||
the last two output samples (carried from the previous subframe/frame).
|
||
**Next session: fix `rom_sfx_decode.py`'s decode loop to match this
|
||
exactly** (the M-58/M-59 "isolated decode" script that the user's slap
|
||
identification was based on was verified byte-exact against this same
|
||
algorithm in a prior session, so that specific result stands — only the
|
||
new range-scanning script is unverified), then run it across a wider
|
||
soundIndex range and diff each envelope against `B00I00S2D.wav`.
|
||
3. **Also flagged, not yet resolved:** the M-59 "correctpitch" resample
|
||
experiment (`scratchpad/d202_soundindex46_correctpitch.wav`, pitch=23849
|
||
from the M-58 live trace) produced *fewer* output samples (1459) than
|
||
input (3984) at a pitch ratio ~0.364. If pitch < 1.0 means "advance less
|
||
than one input sample per output sample" (slow down / stretch), output
|
||
sample count should be `input / ratio` (more samples), not `input *
|
||
ratio` (fewer) — the direction looks inverted. This resample script's
|
||
formula needs re-deriving against `port/src/mixer.c`'s actual
|
||
`aResampleImpl`/`sResampleTable` before its output is trusted either
|
||
way; it has NOT been sent to the user and should not be used as evidence
|
||
yet.
|
||
|
||
**Status at handoff: D202 is OPEN, user-confirmed-wrong-in-PC-port, actively
|
||
being re-investigated.** Do not re-close as "ground truth, not a bug" —
|
||
that conclusion was explicitly rejected by the user with a real reference
|
||
recording now in hand (`scratchpad/B00I00S2D.wav`). No code changes made
|
||
this session (diagnostic-only), nothing to revert.
|
||
|
||
**M-61 — both M-60 threads resolved (one closed, one fixed+run); the
|
||
decisive question is now "what byte is actually in the original ROM", and a
|
||
new logical constraint makes that the keystone fact.** Diagnostic-only
|
||
session again; no game/port code touched. Results:
|
||
|
||
1. **Thread 1 (bank-navigation off-by-N) CLOSED — negative.** Line-by-line
|
||
review of `port/src/romdata.c`'s bank re-layout (`afCtx` /
|
||
`_bnkfPatchWaveTable`, ~L582-780) confirms the PC conversion walks
|
||
`ALBankFile -> ALInstrumentFile -> ALSoundFile` in exact ROM order with
|
||
no index shift; combined with M-56's byte-exact runtime trace, PC
|
||
soundIndex N resolves to the same ROM sample as N64 soundIndex N. The
|
||
"wrong bank / off-by-one in the walk" hypothesis is dead. Do not
|
||
re-litigate it.
|
||
2. **Thread 2 (offline decoder) FIXED and VALIDATED.**
|
||
`scratchpad/rom_sfx_decode.py`'s `adpcm_decode()` was rewritten to match
|
||
`port/src/mixer.c`'s `aADPCMdecImpl` exactly. Three bugs fixed: (a)
|
||
tbl0/tbl1 indexing was swapped, (b) the intra-subframe accumulation term
|
||
`sum_{k<j} tbl[1][j-k-1]*ins[k]` was missing, (c) wrong `ncoef` count.
|
||
Validation: full-bank scan of all 261 sounds (gun.h L85: "There are 261
|
||
sound effects, or 0 - 105h") decoded cleanly; **index 45
|
||
(`45_DROP_GUN_SFX`) is the ONLY strong match for the user's reference
|
||
clip `B00I00S2D.wav` (envelope correlation +0.999)**; index 46
|
||
(`46_GUN_SILPPK_A_SFX`) decodes to the early-peak fast-decay "slap".
|
||
Candidates saved as `scratchpad/romsfx_44..60.wav`. The decoder is now
|
||
trustworthy for future soundIndex lookups.
|
||
3. **Fire path traced end-to-end in game code — no runtime remapping of the
|
||
index exists.** `src/game/gunfire.c` L3196/L3200:
|
||
`sndPlaySfx(g_musicSfxBufferPtr, bondwalkItemGetSound(var_s1), ...)` →
|
||
`src/game/gun.c` L1343-1345: `bondwalkItemGetSound()` returns
|
||
`get_ptr_item_statistics(item)->Sound` — the raw u16
|
||
`WeaponStats.Sound` field (offset 38; layout confirmed from
|
||
`src/game/gun.h`: 7×f32, s32 AmmoType, s16 MagSize, u8 AutoFireRate,
|
||
s8 SingleFireRate, u8 ObjectsShootThrough, u8 SoundTriggerRate, u16
|
||
Sound, ptr cartridge, …). The silenced PPK's fire sound IS
|
||
`wppksil_stats.Sound`, used verbatim. (Unsilenced PPK uses 0x6B=107.)
|
||
4. **Build wiring: both N64 and PC builds compile the same initializer from
|
||
source.** `src/game/gun.c:135` includes the aggregate
|
||
`assets/obseg/gun/gunWeaponStats.inc.c`, which defines `wppksil_stats`
|
||
inline at `//D:800326C4` with `.Sound = 0x2E` (it only #includes the
|
||
per-weapon files for fist/knife; everything else is inline). The
|
||
per-weapon file `assets/obseg/gun/wppksil/gunWeaponStat.inc.c` carries a
|
||
second, IDENTICAL copy (also `0x2E`, same `//D:800326C4`). The PC build
|
||
exclusion list covers only `assets/obseg/{bg,brief,setup,stan}`
|
||
(CMakeLists ~L473), so gun.c's compiled data is identical in both
|
||
builds. **The decompiled source is internally consistent: 0x2E in both
|
||
copies.**
|
||
5. **KEY LOGICAL CONSTRAINT (new, this is the keystone):** the project's
|
||
premise is that the N64 build byte-matches the original ROM. Compiled
|
||
data cannot silently diverge — if `gunWeaponStat.inc.c`'s `.Sound`
|
||
were a transcription error, the built ROM would differ from the
|
||
original at exactly those 2 bytes and the match would fail. Therefore
|
||
**0x2E almost certainly IS in the original ROM**, i.e. real N64 hardware
|
||
also requests sound index 46 for the silenced PPK. That contradicts the
|
||
reference clip matching index 45 at +0.999. Exactly one of these must be
|
||
false:
|
||
(a) the byte-match verification does not actually cover this data
|
||
segment (check the verify tooling/output!);
|
||
(b) `B00I00S2D.wav` is not an in-game capture from real N64 hardware —
|
||
its filename looks like a media-library asset ID, provenance was
|
||
never stated; if it's e.g. film/marketing audio, the "N64 plays 45"
|
||
premise collapses and M-59's "faithful reproduction" conclusion
|
||
returns WITH evidence;
|
||
(c) N64 runtime resolution of index 46 differs from ROM-order index 46
|
||
(Thread 1 closed *ordering* in the PC re-layout, not real-hardware
|
||
ALBank content — lower priority given (a)/(b));
|
||
(d) the +0.999 envelope correlation with index 45 is misleading (room
|
||
reverb tail can inflate envelope matches).
|
||
6. **Direct ROM-byte read of `wppksil_stats.Sound` FAILED — do NOT repeat
|
||
the same searches (they looped 40+ times this session, all 0 hits):**
|
||
- Any pattern containing a decompiled float literal is unreliable: the
|
||
literals are decimal approximations that don't round-trip to exact
|
||
IEEE-754 bits (e.g. `-20.799999` → `c1a66666`, zero occurrences in
|
||
ROM). Integer-only tail patterns (`AmmoType=1, MagSize=7, 0xFF, 0x10,
|
||
…`) also returned 0 hits — either the byte layout at that ROM location
|
||
differs from assumption or the data sits where the scan didn't look.
|
||
- The hand-computed RAM→ROM mapping produced ROM offset `0xC11934` >
|
||
file size `0xC00000` — WRONG. Note `ge007.ld` puts `.csegment` at ROM
|
||
`0xC00000` (= exactly EOF), so `wppksil_stats` (vaddr `0x800326C4`)
|
||
cannot live there; the correct vaddr→ROM mapping must come from the
|
||
N64 build's map file/symbol table, not arithmetic.
|
||
- No N64 `.map` found at maxdepth 3 (only the PC CMake tree
|
||
`build-linux/`). Search DEEPER: `assets/obseg/Makefile.*`, `ld/`,
|
||
`dist/`, or any N64 ELF in the repo; `nm`/`objdump` on it yields the
|
||
exact vaddr+section, and the ROM offset then follows from the linker
|
||
script.
|
||
7. **Next session, in priority order:**
|
||
1. **Establish what byte the original ROM holds at
|
||
`wppksil_stats.Sound`** — find the N64 build map/ELF (search deeper
|
||
than maxdepth 3) and read the 2 bytes. This single fact splits the
|
||
tree: ROM=0x2D → transcription error (then audit why byte-match
|
||
verification missed it); ROM=0x2E → data is ground truth, and the
|
||
discrepancy lives in reference-clip provenance (b) or N64 runtime
|
||
resolution (c).
|
||
2. **Check the byte-match verification tooling** — does it compare data
|
||
segments or code only? Locate the last full-verify output.
|
||
3. **Ask the user for `B00I00S2D.wav`'s provenance** (one question; see
|
||
(b) above).
|
||
4. Cheap corroboration: live PC trace logging the soundIndex actually
|
||
passed to `sndPlaySfx` on PPK fire (expected 46) — confirms the PC
|
||
side behaves exactly per its data.
|
||
|
||
**Status at handoff (M-61): D202 OPEN.** Threads 1+2 from M-60 are closed
|
||
(negative / fixed). No code changes; only `scratchpad/rom_sfx_decode.py`
|
||
modified (now correct + validated). Do not re-run the failed ROM byte-
|
||
pattern searches — go for the N64 map file instead.
|
||
|
||
### M-62 — item (2) resolved without a build: byte-match verification DOES cover this exact struct, and its checksum was captured after the source's last edit. `.Sound = 0x2E` is very likely ground truth. Pivoting to (b)/(d).
|
||
|
||
Static/read-only session. Skipped the N64-map/ELF route (item 1) — no MIPS
|
||
toolchain is installed in this environment (`mips-linux-gnu-*` absent,
|
||
`docs/building.md` says extraction needs `binutils-mips-linux-gnu`; building
|
||
the full N64 target to get a map file is a real toolchain-install task, not
|
||
attempted this session) — and went straight for the cheaper item (2) instead.
|
||
|
||
**Found: this repo's own CI (`.github/workflows/ci.yml`) never builds the
|
||
N64 target at all.** `validate`/`linux-build`/`windows-build` are all PC-port
|
||
jobs; there is no job invoking the N64 `Makefile` or
|
||
`scripts/test_files.sh`. So "byte-matches US/EU/JP ROMs" (AGENTS.md's
|
||
opening claim) is inherited from the upstream `n64decomp/007` project's own
|
||
history, not continuously re-verified here — worth knowing, but a separate
|
||
concern from D202.
|
||
|
||
**The verification tooling itself does cover `.data`, and gun.o specifically:**
|
||
`scripts/test_files_readme.md` — `test_files.sh` extracts `.text`/`.code`/
|
||
`.bss`/`.data`/`.rodata` from every `.o` under `src/`, `src/game`, and the
|
||
four asset dirs, and diffs each against a checked-in known-good md5
|
||
(`scripts/ge007.u-test_basis.csv`). Confirmed by direct grep:
|
||
`scripts/ge007.u-test_basis.csv:946`: `0289c36e967840bba7f6fbd026dda001,.data,build/u/src/game/gun.o`
|
||
— `gun.o`'s `.data` section (which is where `wppksil_stats` lives, via
|
||
`gun.c`'s `#include` of `gunWeaponStat.inc.c`) has a recorded checksum.
|
||
This directly falsifies alternative (a) from M-61 — the verification does
|
||
cover this exact data.
|
||
|
||
**Timing check, so the checksum can't be stale relative to a since-edited
|
||
source:** `git blame -L946,946 scripts/ge007.u-test_basis.csv` →
|
||
`cc14d64e7 "for england james?"` (2026-08-16, upstream sync commit).
|
||
`git log -- assets/obseg/gun/wppksil/gunWeaponStat.inc.c` → last touched
|
||
`9fbe1fd5` ("sync. run make forceextractassets..."), which is an ancestor of
|
||
`cc14d64e7` in this file's history. **So the checksum was captured AFTER
|
||
the last edit to this exact source file** — it isn't testing stale content.
|
||
Re-read the file directly: `.Sound` is still `0x2E` (14th positional field,
|
||
`assets/obseg/gun/wppksil/gunWeaponStat.inc.c:3`), unchanged.
|
||
|
||
**Conclusion (not a rebuild-confirmed pass, but strong circumstantial
|
||
evidence):** the upstream decomp project's own generated fingerprint of a
|
||
known-good `gun.o` build covers this exact struct, was captured against
|
||
this exact (still-current) source content, and this repo's non-negotiable
|
||
rule #2 (game logic/decomp source frozen, PC-port work never touches
|
||
`src/game/gun.c` or `assets/obseg/gun/**`) means nothing has drifted since.
|
||
**Actually running `test_files.sh` to get a live "pass" line still requires
|
||
installing a MIPS toolchain and doing a real N64 build — not done this
|
||
session; flagged as the one remaining way to make this airtight rather than
|
||
strongly-inferred.** Barring that, alternative (a) is now the *least* likely
|
||
of the four M-61 alternatives, not the most. Combined with M-61's already-
|
||
closed alternative (c) (traced fire path, no runtime remapping), the two
|
||
live leads are now **(b) reference-clip provenance** and **(d) envelope-
|
||
match reliability** — both point away from a port bug and toward a
|
||
misidentified reference clip.
|
||
|
||
**Next session, in order:**
|
||
1. **Ask the user the one open question: where did `scratchpad/
|
||
B00I00S2D.wav` come from?** (Real N64-hardware capture of this exact
|
||
weapon on this exact ROM region? An emulator? A sound-effects library
|
||
asset, given the filename reads like a media-asset ID, not a capture
|
||
log?) This is the cheapest possible next step and was never actually
|
||
asked — do it before any more code/data spelunking.
|
||
2. If the user can't confirm hardware provenance: run the same ROM in a
|
||
known-good N64 emulator (the M-59 handoff's original recommended
|
||
tie-breaker, never done), fire the silenced PPK, listen directly —
|
||
settles (b)/(d) without needing the reference clip at all.
|
||
3. Only if both of those come back still contradicting `.Sound = 0x2E`:
|
||
install a MIPS toolchain (`binutils-mips-linux-gnu` equivalent) and
|
||
actually run `scripts/test_files.sh` for real, to convert this
|
||
session's strong-inference close of alternative (a) into a hard pass/fail.
|
||
4. Cheap corroboration, still not done: live PC trace of the soundIndex
|
||
passed to `sndPlaySfx` on PPK fire (expected 46) — confirms PC-side
|
||
behavior matches its own data, independent of what the data *should* be.
|
||
|
||
**Status at handoff (M-62): D202 OPEN**, but the center of mass has shifted
|
||
firmly away from "port bug in the data/decode chain" (four independent
|
||
sessions, M-56 through M-62, now corroborate `.Sound = 0x2E` end to end)
|
||
and toward "reference clip may not be what it was assumed to be." No code
|
||
changes this session — read-only investigation, one `findings.md` write-up.
|
||
|
||
**Same session, follow-up: asked the user directly where `B00I00S2D.wav`
|
||
came from. Answer: "Not sure / found online"** — the user does not know its
|
||
provenance and it was sourced from the internet, not recorded from real N64
|
||
hardware or a verified emulator run against this ROM. This closes item (1)
|
||
of the M-62 next-session list and directly confirms alternative **(b)**
|
||
from M-61 (reference clip isn't a verified in-game N64 capture) as the true
|
||
explanation — an unverified internet clip was compared against a
|
||
byte-verified ROM data table and, unsurprisingly, didn't match.
|
||
|
||
**Reassessment: five independent sessions (M-56 through M-62) have now
|
||
verified every link in the actual chain** — soundIndex resolution, bank
|
||
re-layout order, the ALWaveTable/ALSound pointer chain, the ADPCM decode
|
||
math (hand-verified bit-exact against the C output), the ROM segment
|
||
offset (verified 3 ways), and now the weapon-stats source data itself
|
||
(byte-match checksum, captured post-edit) — **all say the silenced PPK
|
||
correctly requests and plays soundIndex 46 exactly as real N64 hardware
|
||
would.** The only unverified link left is the thing D202 was reopened over:
|
||
an internet-sourced reference clip of unknown origin. That is no longer a
|
||
credible basis to keep this open as a port bug.
|
||
|
||
**Recommendation: close D202 as NOT-A-BUG** (same conclusion M-59 reached,
|
||
now on much stronger evidence) **unless the user wants the belt-and-braces
|
||
emulator A/B** (fire the silenced PPK on the actual ROM in a known-good N64
|
||
emulator, listen directly) — cheap, and the only remaining way to fully
|
||
rule out a genuine transcription error without installing a MIPS toolchain
|
||
and running `scripts/test_files.sh` for a hard pass. Do not spend further
|
||
session time on the data/decode/index chain — it is exhaustively verified.
|
||
|
||
### M-63 — SUPERSEDES the M-62 "recommend close": user reports the bug is broader than a single wrong sample — real-time mixer/voice corruption, not a data-table issue. REOPENED for real, live repro attempted. Root cause NOT found; one concrete anomaly banked for next session.
|
||
|
||
**User's report (verbatim symptom, not the M-60 reference-clip framing):** the
|
||
wrong ("slap") sound always plays on the silenced PPK; the correct suppressed
|
||
shot sometimes plays **too** (both heard together, not one-or-the-other);
|
||
and over a play session sounds "pile up and spirally glitch out," eventually
|
||
leaving **no audio except a looping sound (e.g. Bunker's door) stuck playing
|
||
since level start.** This is categorically different from "wrong sample
|
||
selected" — it describes runtime mixer/voice-lifecycle corruption. The M-56
|
||
through M-62 chain (which verified the index/data/decode path bit-exact) does
|
||
**not** cover this; it only proves soundIndex 46 is the *correctly requested*
|
||
sound, not that the mixer plays it *cleanly* once requested. Reopening.
|
||
|
||
**Build+run environment IS available this session** (`export
|
||
PATH=/c/msys64/mingw64/bin:$PATH`; MSYS2 MINGW64 gcc/cmake/SDL2 all present).
|
||
No MIPS toolchain (confirmed absent again, unrelated to this thread).
|
||
|
||
**False-alarm detour (banked so it isn't repeated): a "crash in `load_bg_file`
|
||
(`bg.c:865`) on every `-level_09` boot" is NOT a real bug** — it was this
|
||
session's own test-setup gap. A throwaway `build-pc/data/` with only the
|
||
`.z64` copied in (no `pccg-ntsc-final/`, `pcmodels-ntsc-final/`) leaves
|
||
`file_resource_table` pointing at raw big-endian ROM instead of the converted
|
||
PC sidecars (`obInit()`'s own comment describes exactly this failure mode);
|
||
the two `[WARN]` lines ("pcmodels.bin not found", "pccg.bin not found —
|
||
stage loads will fault") were sitting right in the log and should have been
|
||
read before chasing a gdb backtrace. Copying the real `data/pccg-ntsc-final/`
|
||
+ `data/pcmodels-ntsc-final/` (+ the repo's real `ge007.eep`/`ge007.ini`)
|
||
into `build-pc/data/` makes it load clean, 60s+, no crash, both with and
|
||
without the uncommitted audio-diagnostic files. **Next session: always mirror
|
||
the full `data/` dir into `build-pc/data/`, not just the ROM, before treating
|
||
any level-load failure as real.**
|
||
|
||
**Repro built:** `GE_MIXERTRACE=1 GE_AUDIOTRACE=1 GE_AUDIODUMP=1
|
||
GE_INPUTSCRIPT="600:R,Z;601:R,Z;690:R,Z;691:R,Z;...;3210:R,Z;3211:R,Z"`
|
||
(30 fire pulses at ~90-frame/~0.75s spacing) `timeout 60
|
||
./ge007.x86_64.exe -level_09`, run against the real save
|
||
(`data/ge007.eep`) copied into `build-pc/data/`. Ran clean 60s, no crash.
|
||
Logs preserved: `scratchpad/d202-m63/{audiotrace.log,mixertrace.log,
|
||
audiodump.raw}` (mixertrace.log is 25MB — grep it, don't read it whole).
|
||
|
||
**Checked and RULED OUT this session, from the trace:**
|
||
- **Voice-pool exhaustion / leak:** only **16 distinct `newState` voice-slot
|
||
pointers** were used across all 153 `sndPlaySfx` calls in the 60s window
|
||
(highest reuse count: 20), and `sndDeactivate` count (159) tracks
|
||
`sndPlaySfx` count (153) almost 1:1. The pool is being recycled
|
||
continuously, not growing unbounded — the "voices never freed" theory
|
||
(the D199/M-53–M-55 lineage) does **not** reproduce in this repro.
|
||
- Firing the silenced PPK produced exactly the expected `soundIndex=46`
|
||
requests (58 of them, one burst per scripted trigger pull, occasionally
|
||
2 back-to-back — plausibly dual-wield, not investigated further) with no
|
||
other unexpected soundIndex interleaved in the same burst.
|
||
|
||
**New anomaly found, not yet explained — the most concrete lead for next
|
||
session:** `soundIndex=109` (`bondconstants.h:2546`,
|
||
`"109_GUN_B4_BOLTACTION_SFX", //used for AK47`) fires **94 times in 60
|
||
seconds** despite the player never touching an AK47 in this repro (silenced
|
||
PPK only). A chunk of those requests hit the *same* voice slot
|
||
(`newState=00000000706f4fb8`) at a suspiciously uniform **2880-byte spacing
|
||
in the mixed output stream** — at 22050Hz stereo 16-bit that's exactly
|
||
**720 samples / ~32.6ms between re-triggers**, four-in-a-row
|
||
(`dumppos=688576,691456,694336,697216,700096`). ~30 rounds/sec is far
|
||
faster than any plausible automatic-weapon cadence in this game (real
|
||
full-auto weapons here fire far slower) — this reads like something is
|
||
re-issuing `sndPlaySfx` for the same logical sound every audio tick/frame
|
||
instead of once per actual game event, which would exactly explain "sounds
|
||
piling up." **Not yet confirmed as related to the user's PPK-slap complaint
|
||
specifically** (soundIndex 109 isn't the PPK's sound) — could be a
|
||
*second*, independent SFX-retrigger bug that happens to share the same
|
||
mixer, and its presence would still explain general "audio feels broken."
|
||
|
||
**Not yet reproduced: the user's exact reported chain** (piling
|
||
up → spiraling glitch → eventual total silence except one stuck loop). This
|
||
60s/58-shot synthetic burst didn't collapse the mixer — either it needs a
|
||
longer, more realistic session (real human play, many minutes, mixed
|
||
NPC/player/ambient audio denser than this script), or the soundIndex=109
|
||
retrigger-storm anomaly above is the seed that, given more real playtime,
|
||
compounds into what the user described (worth designing a longer repro
|
||
around forcing that specific retrigger condition first, cheaper than a raw
|
||
extended random playtest).
|
||
|
||
**Next session, in order:**
|
||
1. Find what actually calls `sndPlaySfx(109, ...)` (search `src/game/*.c`
|
||
for callers keyed to soundIndex 109 / `GUN_B4_BOLTACTION_SFX`) and
|
||
determine whether the ~32.6ms retrigger cadence is a real per-shot
|
||
automatic-fire loop (legitimate, if unusually fast) or a bug re-firing
|
||
once per audio buffer/tick instead of once per game event. This is the
|
||
single most concrete, checkable lead from this session.
|
||
2. If (1) doesn't explain it: build a much longer repro (5+ minutes,
|
||
`GE_INPUTSCRIPT` walking + intermittent firing near guards, or just ask
|
||
the user to reproduce with `GE_MIXERTRACE=1 GE_AUDIOTRACE=1
|
||
GE_AUDIODUMP=1` set and hand back the logs) aimed specifically at
|
||
reaching the "eventual total silence except one stuck loop" end state,
|
||
then diff voice-slot reuse counts/timing against this session's healthy
|
||
60s baseline to see where it diverges.
|
||
3. Listen to `scratchpad/d202-m63/audiodump.raw` directly (raw s16 stereo
|
||
22050Hz PCM) for audible garbling/overlap around the soundIndex=109
|
||
retrigger-storm timestamps — not done this session (no audio playback
|
||
attempted, log-analysis only).
|
||
4. Do NOT re-litigate the index/data/decode/ROM-offset chain (M-56–M-62,
|
||
exhaustively verified) — this session's finding is orthogonal to that
|
||
one; D202 is reopened on a *different* mechanism than the M-60 reference
|
||
clip.
|
||
|
||
**Status at handoff (M-63): D202 REOPENED, root cause not found.** The
|
||
M-62 "recommend close" no longer stands — the user's actual complaint is a
|
||
runtime mixer/voice issue, not (only) a data-correctness question. One
|
||
concrete, unexplained retrigger anomaly (soundIndex 109, ~32.6ms cadence)
|
||
is banked as the top lead. No code changes this session — build+run+trace
|
||
only; the pre-existing uncommitted diagnostic files (`port/src/audio.c`,
|
||
`port/src/mixer.c`, `src/audi.c`, `src/libultra/audio/load.c`, `src/snd.c`)
|
||
were exercised, not edited.
|
||
|
||
### M-65 — Root-caused the "eventual silence" half: an ownerless `SOUND_FLAG_LOOPED` SFX permanently consumes one of only 8 voices. Guard measured. The "wrong/multiple sounds" half gains a second, independent cause (`sndCreatePostEvent` was stubbed out, killing ALL distance attenuation). The audible stuck loop is still NOT reproduced.
|
||
|
||
**User's report this session:** "wrong sound playing or multiple sounds playing
|
||
when there should be one", and "on bunker if the attract shows the door open,
|
||
it plays a sound and the sound gets stuck looping forever until you close the
|
||
game/level", later refined to "the double door sound by the computer room still
|
||
infinitely loops after they begin playing" and "a good chance it just happens
|
||
when the level starts if you get that attract intro with them opening".
|
||
|
||
#### (1) CONFIRMED + MEASURED: the voice leak that produces "eventual silence"
|
||
|
||
Chain, each link verified rather than argued:
|
||
|
||
- `soundIndex=203` (`METAL_SLIDE_CLOSE_SFX`) has `envelope->decayTime == -1`.
|
||
Verified at the **source bank level**, not just at runtime, with a new
|
||
`GE_BANKDUMP` probe walking `instArray[0]->soundArray[i]` in the ROM-layout
|
||
blob: `BANKSRC inst@0x005778 soundIndex=203 sound@0x0046B8 env@0x000008
|
||
decay=-1`. `GE_KEYMAPDUMP` additionally dumps every source `ALEnvelope`
|
||
(`ENVSRC src@0x000008 raw=00000000 FFFFFFFF 000007D0`). **Genuine ROM data,
|
||
faithfully converted** — 19 of the SFX instrument's 261 sounds are
|
||
deliberately sustained this way.
|
||
- `decayTime == -1` sets `SOUND_FLAG_LOOPED` (`sndSetupSound`, via
|
||
`decayTimeFlag`). A looped sound **never posts `AL_SNDP_STOP_EVT`**
|
||
(`snd.c` `AL_SNDP_DECAY_EVT` skips it), *and* the preemption scan
|
||
**explicitly refuses to steal looped voices** (`!(iterState->unk3e & 0x12)`).
|
||
So the only release is `sndDeactivate`, which needs an owner.
|
||
- `doorPlayCloseSound0/1` play it as `sndPlaySfx(..., METAL_SLIDE_CLOSE_SFX,
|
||
NULL)` — **no `pendingState`**, so nothing stores the state and nothing can
|
||
ever deactivate it. (NB the decomp names mislead: `doorStartClose` calls
|
||
`doorPlayOpenSound1`; `doorFinishOpen`/`doorFinishClose` call
|
||
`doorPlayCloseSound0`/`1`. The *Open* pair own their slide loop via a slot;
|
||
the *Close* pair are unowned terminators.)
|
||
- `maxSounds` is **8** (`MUSIC_SFX_SEQ_MAYBE_MAX_SOUNDS`, `music.c:76`).
|
||
|
||
**Measured** (115 s headless `-level_09`, new `[VOICE+]`/`[VOICE-]` probes
|
||
pairing every `g_sndAllocatedVoicesCount++` with its decrement):
|
||
**7 of the 8 voices permanently held, every one of them `sound=706c8e48`
|
||
(soundIndex 203) with `flags=7` (`LOOPED|PLAYING`)**, never released. The
|
||
64-entry *state* pool stays healthy (18 slots recycling, never NULL) — which is
|
||
why this is silence, not a crash: later `sndPlaySfx` calls get a state but no
|
||
voice and are dropped via `sndDisposeSound`.
|
||
|
||
**Guard (port-side deviation, `src/snd.c` preemption scan).** Allow reclaiming
|
||
a voice only when it is `SOUND_FLAG_LOOPED`, not `SOUND_FLAG_RETRIGGER`, and
|
||
`state->state == NULL` — i.e. provably ownerless, so nothing could ever have
|
||
stopped it. Owned loops keep their existing protection exactly.
|
||
|
||
| metric (115 s `-level_09`) | before | with guard |
|
||
|---|---|---|
|
||
| `g_sndAllocatedVoicesCount` | **pinned at 8** | oscillates 1–8 |
|
||
| voices acquired over the run | 171 | **629** |
|
||
| residual stuck 203 voices at exit | **7** | **1** |
|
||
|
||
User confirmed the symptom improves ("still happens but less cascading
|
||
perhaps"). **This is a game-logic change (AGENTS.md rule #2) justified by
|
||
measurement, not by ground truth — it is NOT known why the N64 does not leak
|
||
here.** Keep it flagged as a documented deviation, not a silent fix.
|
||
|
||
#### (2) FIXED, independent: `sndCreatePostEvent` was stubbed out, removing all distance attenuation
|
||
|
||
`sndCreatePostEvent` returned immediately under `#ifdef PORT` (D138), from when
|
||
"libaudio is Phase-3 parked" and nothing drained
|
||
`g_sndPlayerPtr->evtq->allocList`, so every positional tick appended an item
|
||
that was never consumed and `alEvtqPostEvent`'s ordered insert went O(n^2)
|
||
until the kernel-heartbeat watchdog tripped on Facility ambience.
|
||
|
||
**That premise expired when the Phase-3 audio thread landed (D198-D201)** —
|
||
`amMain` now drains the queue every audio frame. Every caller posts type 8 =
|
||
`AL_SNDP_VOL_EVT` (`bondview2.c`, `chrai.c`, `gunfire.c`, `propobj.c`), so the
|
||
stub had been silently removing **all distance-based volume**: every sound
|
||
played at full volume regardless of range. That is very likely a large part of
|
||
the "wrong sound / multiple sounds / too loud" impression, independent of (1).
|
||
|
||
Stub removed. Re-measured: `rt=0.996`, `drop=0`, frame times 91-329 us, zero
|
||
watchdog trips over 115 s — the O(n^2) regression does not return.
|
||
|
||
#### (3) STILL OPEN: the audible endless door loop is NOT reproduced
|
||
|
||
Not reproduced in any headless run. Everything that could make a sound audibly
|
||
repeat was measured and excluded — see the ruled-out list below.
|
||
|
||
#### RULED OUT this session (measured — do not re-walk)
|
||
|
||
- **Wave-level looping.** New `[WAVELOOP]` probe: `loop == NULL` on **every**
|
||
sound in the SFX bank. No sample can repeat itself.
|
||
- **Decoding past the end of a wave.** New `GE_PULLTRACE` `[PASTEND]` assertion
|
||
in `alAdpcmPull` (fires when `f->sample` exceeds the wave's total samples
|
||
while `nOver == 0`): **0 hits**. `aClearBuffer` zero-fill works; an exhausted
|
||
voice goes silent.
|
||
- **Stale / accumulating software DMEM.** `GE_DMEMWIPE` zeroes all 4 KB of
|
||
`sDmem` at the exact frame boundary (`alMainBusPull`'s `AL_MAIN_L_OUT`
|
||
clear is the first opcode of every frame). Audio still 99 % non-silent and
|
||
unchanged — no audio state carries across frames in DMEM.
|
||
- **Reverb feedback self-oscillating** (plausible because `aPoleFilterImpl`,
|
||
the damping in the feedback path, is an unimplemented no-op — D199).
|
||
`GE_NOWET` mutes the reverb send entirely: signal unchanged.
|
||
- **The `AL_SNDP_PLAY_SFX_EVT` retrigger machinery.** 0 retrigger posts in the
|
||
user's trace. (Also independently confirms D204's closure of the M-63
|
||
`soundIndex=109` anomaly: measured here as 184 plays, **owned 184 / orphaned
|
||
0** — a properly-owned repeated sound, not a retrigger storm.)
|
||
- **Doors oscillating.** ~4 open/close cycles per door per 110 s across 4 doors;
|
||
203 played ~once per door state change. Normal, not thrashing.
|
||
- **Doors orphaning their loop because both slots are busy.** 0 occurrences
|
||
(`[DOORSND] ... ORPHAN-both-slots-busy` never fired). The orphan is real but
|
||
arises differently: the call site passes `NULL` by design.
|
||
- **`ALKeyMap` conversion.** `GE_KEYMAPDUMP` dumps all 220 source keymaps from
|
||
the ROM-layout blob; runtime values match, including entries that are
|
||
legitimately all-zero in the fields Rare repurposed.
|
||
- **`DoorRecord` 64-bit overlap.** `sizeof(DoorRecord)=0x128`, observed
|
||
allocation stride exactly `0x128`; `openSoundState@0x110`,
|
||
`closeSoundState@0x118`. No overlap.
|
||
- **The `ALSoundState`-pointer-to-pointer pun** in `sndPlaySfx`
|
||
(`pendingState->link.next = nextState`, relied on by `doorPlayOpenSound0/1`
|
||
and `audioPlayFromProp`). Survives the 64-bit transition — `ALLink.next` is
|
||
at offset 0 and pointer-sized on both. New `[SLOTWRITE]` probe confirms the
|
||
slot receives the state.
|
||
- **`ALWaveTable::len` drifting at runtime** (observed 6939 -> 6940 etc.).
|
||
Benign: `alLoadParam` rounds `len` down to a whole number of 9-byte ADPCM
|
||
frames on first bind, so the pre-round value is simply seen once.
|
||
|
||
#### Corrections banked (mistakes made this session — do not repeat)
|
||
|
||
- **`romdataFixupMusicSeqTable: seqCount 63 exceeds blob capacity 1` is
|
||
BENIGN**, not a music failure. The function is called twice and the first
|
||
call deliberately passes a 16-byte header slice (capacity `(16-4)/8 = 1`);
|
||
the full-size second call succeeds. Music loads and plays.
|
||
- **A continuous ~1400 RMS "drone" in `GE_AUDIODUMP` captures is the
|
||
background music, not a stuck sound.** It was briefly mistaken for the bug
|
||
because only *SFX* events carry `dumppos` timestamps, so music looks like
|
||
sound with nothing playing it. Confirmed music by pointer range: the heavily
|
||
rebound wavetables (`706cbaf8`, `706ccfe8`) lie outside the SFX bank's
|
||
`706c3e70`-`706ca430` range and never appear in any `sndPlaySfx`; they are
|
||
driven by `csplayer.c:535` (`alSynStartVoiceParams`). The ~28 k
|
||
`AL_FILTER_SET_WAVETABLE` binds per 60 s are ordinary note-ons, and
|
||
`f->sample` resetting per note is correct.
|
||
- **Multiple `audiotrace.log` files exist** (repo root vs `build-pc/`) and a
|
||
shell whose cwd shifts between commands will silently analyse the wrong one.
|
||
Always use an absolute path, and check `ls -la` timestamps before trusting a
|
||
log's provenance.
|
||
|
||
#### Diagnostics added (all `#ifdef PORT` / env-gated; remove when D202 closes)
|
||
|
||
| env var | file | what it prints |
|
||
|---|---|---|
|
||
| `GE_AUDIOTRACE` | `src/snd.c` | `[ENVELOPE]`, `[WAVELOOP]`, `[VOICES]`, `[VOICE+]`/`[VOICE-]`, `[SLOTWRITE]`, `[RETRIGGER-POST]`, keymap fields on every `sndPlaySfx` |
|
||
| `GE_AUDIOTRACE` | `src/game/propobj.c` | `[DOORSND]` — every door sound state change, both slots, and an `ORPHAN-both-slots-busy` verdict |
|
||
| `GE_KEYMAPDUMP` | `port/src/romdata.c` | `KEYMAP` + `ENVSRC` — source ROM bytes of every `ALKeyMap` / `ALEnvelope` |
|
||
| `GE_BANKDUMP` | `port/src/romdata.c` | `BANKSRC` — the source bank tree per `soundIndex` |
|
||
| `GE_PULLTRACE` | `src/libultra/audio/load.c` | `[PASTEND]` — asserts no voice decodes past its wave |
|
||
| `GE_DMEMWIPE` | `port/src/mixer.c` | zeroes software DMEM at the frame boundary |
|
||
| `GE_NOWET` | `port/src/mixer.c` | mutes the reverb send |
|
||
|
||
**Caution:** the `[VOICE+]`/`[VOICE-]` probes are written from both the game
|
||
thread and `amMain` to one unbuffered `FILE*`, so occasional lines are torn.
|
||
Parse defensively (filter to well-formed hex) or add a lock before relying on
|
||
exact counts.
|
||
|
||
#### Next step
|
||
|
||
Needs a user capture at the failing door (computer-room double doors, or a
|
||
level start whose attract intro shows them opening) with **both**
|
||
`GE_AUDIOTRACE=1` and `GE_AUDIODUMP=1`. The dump allows isolating the stuck
|
||
sound numerically and separating it from music without listening, and
|
||
`dumppos` pins it to the exact door event.
|
||
|
||
**Status at handoff (M-65): D202 PARTIALLY resolved.** The "eventual silence"
|
||
cascade is root-caused and mitigated (measured); the missing distance
|
||
attenuation is fixed; the audible stuck loop is still not reproduced.
|
||
|
||
### M-66 — the audible stuck door loop IS root-caused: it is faithful N64 behaviour (an original-game quirk), not a port bug
|
||
|
||
The user capture M-65 asked for arrived (`build-pc/audiodump.raw`, 31,905,536 B
|
||
= 361.7 s stereo s16 @22050; `build-pc/audiotrace.log`, same run, `-level_09`
|
||
level start whose attract intro shows the computer-room double doors). The
|
||
stuck loop is **sound 203 (`METAL_SLIDE_CLOSE_SFX`) doing exactly what the ROM
|
||
tells it to do**, and every link of the chain is ground truth:
|
||
|
||
1. **ROM data:** wavetable loop = `(start=2471, end=7719, count=-1)`;
|
||
`src/libultra/audio/load.c` implements `count` with the explicit comment
|
||
"-1 is loop forever" (decrements only when > 0). Envelope:
|
||
`attack=0 decay=-1 release=2000 aVol=127 dVol=127`.
|
||
2. **Game code (byte-matched decompilation):** `doorPlayCloseSound0/1`
|
||
(`src/game/propobj.c`) plays 203 fire-and-forget with a `NULL` owner state.
|
||
The `[SLOTWRITE]` probe confirms the close-sound state is **never stored in
|
||
any door slot**, so the door's stop-sounds path can never reach it, and no
|
||
other code calls `sndDeactivate` on it. Nothing in the game will ever stop
|
||
it except level exit.
|
||
3. **sndp (ground truth):** the preemption scan (`src/snd.c` ~line 321) skips
|
||
any voice with flag `0x12` (`SOUND_FLAG_LOOPED|SOUND_FLAG_RETRIGGER`).
|
||
`AL_SNDP_DECAY_EVT` schedules the release→stop envelope "except for a looped
|
||
sound" (`src/snd.c:509`) — a looped voice gets no stop event at all.
|
||
With `decayTime=-1`, `sndCreateSound` sets `state->priority = 0x41`
|
||
(vs `0x40` normal), and libultra's `_allocatePVoice`
|
||
(`src/libultra/audio/synallocvoice.c`) steals only voices with
|
||
`priority <= new voice priority` — so a 0x41 leaked loop is unstealable by
|
||
any ordinary 0x40 SFX or music note.
|
||
4. **Pool geometry:** GE's soft limit is 8 (`maxSounds =
|
||
MUSIC_SFX_SEQ_MAYBE_MAX_SOUNDS`, `src/music.c:76`); the physical PVoice pool
|
||
is 24 (`MUSIC_SYN_CONFIG_MAX_P_VOICES = 0x18`, `src/music.c:67`). Leaked
|
||
loops therefore don't deadlock the synth, but they do eat GE's 8-slot soft
|
||
limit — which is where M-65's guard bites.
|
||
|
||
**Consequence (identical on N64):** every metal-door close leaks one looping
|
||
voice that persists until level exit. During attract idle no new sounds arrive,
|
||
so nothing ever evicts them and they play forever — exactly the user's report:
|
||
"the double door sound by the computer room still infinitely loops after they
|
||
begin playing". Every code path in this chain is shared original source
|
||
(`snd.c`, `propobj.c`, libultra audio); none of it is port-specific. **This is
|
||
an original-game quirk, faithfully reproduced — rule #2 says leave it.**
|
||
|
||
#### The capture, timeline (corrected)
|
||
|
||
`dumppos` is the dump-file byte offset; time = `dumppos/88200` s.
|
||
|
||
- t=0–21 s: level intro — heavy SFX, door cycles on five doors (…7d2c, …7c04,
|
||
…79b4, …7adc, …80a4). 203 plays at **1.3 / 3.3 / 11.3 / 17.2 / 21.0 s**.
|
||
- t≈39.1 s: door …80a4 closes → 203 (state …5020). Then a long quiet stretch:
|
||
the same door's next close comes at **t≈360.7 s** (another 203, state
|
||
…4f50, still looping when the capture ends at 361.7 s). The attract demo
|
||
keeps running the whole time — it just gets sparse.
|
||
- Leaked 203 voices alive by t≈40 s: four (states …4db0 from 1.3 s, …50f0 from
|
||
17.2 s, …4e80 from 21.0 s, …5020 from 39.1 s). Two earlier ones WERE
|
||
reclaimed — but only because M-65's guard fired under pool pressure:
|
||
`VOICE- snd8e48/st4e18` at t=14.8 s and `st4fb8` at t=16.4 s, each in the
|
||
same block as a new-sound play (the preemption path). Without the guard both
|
||
would persist and the 8-slot limit would fill by ~t=21 s.
|
||
- Steady state from t≈39 s: RMS ≈1560, strong periodicity at **327.0±0.1 ms**.
|
||
|
||
#### The 327 ms mystery (solved — it was the pitch)
|
||
|
||
The loop segment is 5248 samples; at the bank rate (22050) that is 238 ms, not
|
||
327. But the voice does **not** play at unity pitch: keymap `keyBase=54`,
|
||
`detune=50`, and `DEFAULT_SETUP_PITCH_SHIFT = -0x1770 = -6000` (`src/snd.c:20`)
|
||
give `alCents2Ratio(54*100 + 50 - 6000) = alCents2Ratio(-550)` = **0.72783**.
|
||
Loop period at playback = 5248 / (22050 × 0.72783) = **327.007 ms** — matching
|
||
the observed 327.0±0.1 ms exactly. The spectral fingerprint agrees: FFT peaks
|
||
of the decoded loop segment at 355.3 / 371.4 / 694.4 / 710.6 Hz × 0.7278 →
|
||
259 / 271 / 506 / 518 Hz vs observed steady-state lines 256.7 / 272 / 504.7 /
|
||
513.8 Hz. Cross-correlating the **pitch-stretched** loop template against the
|
||
t=100 s window gives r = 0.835 (the unpitched template gave ~0.10 — which is
|
||
why every earlier template test "failed"; see corrections below).
|
||
|
||
#### Corrections to M-65 (banked)
|
||
|
||
- **M-65's correction "the continuous ~1400 RMS drone in captures is the
|
||
background music, not a stuck sound" is wrong (or at best incomplete).** The
|
||
steady-state signal's spectral lines and 327 ms period are the leaked 203
|
||
loop, pitch-shifted as above; r=0.835 template match. Music is present in the
|
||
mix too (M-65's pointer-range argument that *those particular* rebound
|
||
wavetables are music-driven still stands) — the steady state is a composite,
|
||
but the *stuck* component is 203, and it dominates the fingerprint.
|
||
- **"The audible stuck loop is still not reproduced"** — it is now: user
|
||
capture + complete mechanism, every link grounded in ROM data / byte-matched
|
||
code / libultra source.
|
||
- The earlier "full library correlation: best 0.28, sfx203 only 0.10" was an
|
||
artifact of correlating **unpitched** decodes against pitched playback. Do
|
||
not re-run template tests without applying `keyBase/detune/shift` → ratio.
|
||
- M-65's guard comment "only 8 voices exist" is imprecise: there are 24
|
||
PVoice plus the 8-slot GE soft limit; the binding constraint for SFX is the
|
||
soft limit, and the guard operates on exactly that boundary.
|
||
|
||
#### Guard status and decision pending (user)
|
||
|
||
M-65's ownerless-loop preemption guard (`src/snd.c` ~line 325) fired twice in
|
||
this capture and demonstrably prevents pool exhaustion — but it can only fire
|
||
when a *new* sound arrives and the limit is hit. During attract idle nothing
|
||
arrives, so the audible loop persists with or without the guard. Options:
|
||
|
||
- **A) Pure fidelity:** remove the guard; document D202 as "faithful
|
||
reproduction of an original quirk". Consequence (also faithful): after 8
|
||
leaked loops in one level session, new SFX are silently dropped until level
|
||
exit — that is what N64 does.
|
||
- **B) Keep the guard (status quo, recommended):** a small, precisely scoped,
|
||
documented port-side deviation. Prevents the SFX-deadlock; the idle loop
|
||
remains audible exactly as on N64.
|
||
- **C) Extended deviation:** cap/expire ownerless loops in the port layer to
|
||
silence the idle loop. Fixes the user's complaint but is a larger departure
|
||
from ground truth than B.
|
||
|
||
Diagnostics: with the root cause established, the M-65/M-66 probe set
|
||
(`GE_AUDIOTRACE` lines, `[DOORSND]`, `[SLOTWRITE]`, `GE_PULLTRACE`,
|
||
`GE_DMEMWIPE`, `GE_NOWET`, `GE_BANKDUMP`, `GE_KEYMAPDUMP`) may be removed once
|
||
the A/B/C decision lands and D202 closes.
|
||
|
||
**Status (M-66): D202 root cause ESTABLISHED — the audible stuck door loop is
|
||
sound 203 behaving exactly as the ROM + ground-truth code specify; faithful
|
||
N64 behaviour, original-game quirk. Disposition: user chose C (M-66b below).**
|
||
|
||
### M-66b — disposition C implemented and measured: ownerless infinite-loop SFX now expire (fade out) on PC
|
||
|
||
The user chose **C** (extended deviation) as part of the PC port's audio
|
||
implementation. M-65's guard stays (slot reclamation under pool pressure);
|
||
this adds the audibility fix.
|
||
|
||
**Placement — why `src/snd.c` and not `port/src/mixer.c`.** The predicate that
|
||
distinguishes a *leaked* loop from a legitimate one is **ownership**
|
||
(`soundState->state == NULL`), which only sndp sees. The mixer iterates
|
||
ALVoice/PVoices with no ownership field; scoping there would force either
|
||
hardcoding region-fragile SFX-bank DRAM pointer ranges or capping *all*
|
||
infinite-loop voices (music included). snd.c already carries the two documented
|
||
D202 port-side deviations (M-65 guard, D138 un-stub), all `#ifdef PORT` — every
|
||
D202 port-side change stays in one auditable place. N64 build: zero diff.
|
||
|
||
**Mechanism.** New PC-only event `AL_SNDP_PORT_EXPIRE_EVT` (reuses the
|
||
`AL_SNDP_UNUSED_13_EVT` = 1<<13 slot, defined under `#ifdef PORT` in
|
||
`src/snd.h`; no shared-enum change). In PLAY_EVT, immediately after a voice
|
||
starts, if the state is LOOPED + FINAL_IN_SEQUENCE + not RETRIGGER +
|
||
`state->state == NULL` **and** the wave carries an ADPCM loop with `count == -1`
|
||
(finite loops self-terminate in load.c and must not be cut short), post the
|
||
expire event at `D202_EXPIRE_DELAY_US` (2 s). Handler: re-validate the full
|
||
predicate at fire time, then ramp volume to 0 over `D202_EXPIRE_FADE_US`
|
||
(500 ms) and post END_EVT — the stock fade/teardown path. Constants are in
|
||
**ALMicroTime = microseconds** (the event queue is µs-driven: `DELTA_1_MS` is
|
||
1000, `DELTA_33_MS` is 33333).
|
||
|
||
- **Not a reused STOP_EVT:** the stock STOP ramp uses the envelope's
|
||
releaseTime — ~2.7 ms for sound 203 (release 2000 / pitch ratio 0.7278) —
|
||
which would hard-cut the loop (click). The dedicated event gives a clean
|
||
500 ms fade.
|
||
- **Stale-event safety:** `sndDisposeSound` already removes *all* pending
|
||
events for a state (`sndRemoveEvents(..., 0xffff)`), and any rebind disposes
|
||
first — so an expire can never fire against a new binding. The fire-time
|
||
predicate re-check is belt-and-braces (also no-ops if the state was rebound
|
||
to the *same* sound).
|
||
- **Sequence-walk safety:** the type is added to the `isEventForSingleSound`
|
||
mask under `#ifdef PORT`, and the post-site requires FINAL_IN_SEQUENCE, so
|
||
the handler's do-while can never walk into an unrelated alloc-list neighbor.
|
||
- **Owned loops are untouched** (`state != NULL` fails the predicate) — they
|
||
behave exactly as on N64. Retrigger sounds (managed by PLAY_SFX/DEACTIVATE
|
||
chains) are excluded.
|
||
|
||
**Files:** `src/snd.h` (PORT-only event define), `src/snd.c` (two constants,
|
||
post-site in PLAY_EVT, handler case, mask token, guard-comment cross-ref).
|
||
New `[EXPIRE]` line under `GE_AUDIOTRACE` (see GE-ENV-PROBES.md).
|
||
|
||
**Measured.** 134 s headless `-level_09` run with `GE_AUDIOTRACE=1
|
||
GE_AUDIODUMP=1`. Trace: every sound-203 play posts `[EXPIRE]`; each leaked
|
||
voice gets its VOICE- ~2 s after its VOICE+; run ends at
|
||
`[VOICES] allocated=0 / max=8` (no survivors). Spectral check (per-1-s
|
||
Goertzel at 256.7 Hz, the strongest 203-loop line × 0.7278):
|
||
|
||
| capture | 256.7 Hz line |
|
||
|---|---|
|
||
| user pre-fix (361 s) | **flat at max from t≈39 s to end** — the complaint, verbatim |
|
||
| post-fix (134 s) | transient ~2–4 s bursts per door close, **full silence between** (gaps at t=60–62, 85–87, 94–98, 110–112, 128–131) |
|
||
|
||
The stuck infinite loop is gone; each close now rings ~2 s + 0.5 s fade, as a
|
||
door clunk naturally would. Scripts + archived user capture:
|
||
`scratchpad/d202-m66/` (`check_dump.py`, `line_track.py`,
|
||
`user-capture-audiodump.raw`, `user-capture-audiotrace.log`).
|
||
|
||
**Remaining:** user by-ear pass on the new binary (window is open — probes are
|
||
still in). On sign-off: remove the D202 probe set (`GE_AUDIOTRACE` additions,
|
||
`[DOORSND]`, `[SLOTWRITE]`, `GE_PULLTRACE`, `GE_DMEMWIPE`, `GE_NOWET`,
|
||
`GE_BANKDUMP`, `GE_KEYMAPDUMP`) and close D202.
|
||
|
||
**Status (M-66b): disposition C IMPLEMENTED + measured — ownerless
|
||
infinite-loop SFX fade out ~2.5 s after starting on PC; faithful N64 behaviour
|
||
otherwise. Awaiting user by-ear verification, then probe removal + close.**
|
||
|
||
---
|
||
|
||
### M-67 — Static analysis exhausted: full-bank data scan clean, DSP audit complete, reference clip REFUTED as an in-game capture; new `GE_VOICEDUMP` per-voice probe added for the decisive runtime A/B (this session)
|
||
|
||
**1. Full-bank SFX data scan — all 261 sounds clean.**
|
||
`scratchpad/sfx_bank_scan.py` walks every sound's wavetable/book/data in the
|
||
ROM: no out-of-range ADPCM table indices, all books valid (order=2, npred 1–4),
|
||
all data within segment bounds, all type-0. The old decoder's silent clamp
|
||
(`if tableIndex >= npred: tableIndex = 0`) had been masking this check — with
|
||
it removed as a hard error, the whole bank passes. No stale/corrupt-table
|
||
hypothesis is possible; the data side of "wrong sample" is closed.
|
||
|
||
**2. DSP audit complete (vs PD ground truth).** ADPCM scalar decode
|
||
(nibble→ins, predictor loop, clamp), the resample algorithm + 256-entry table,
|
||
the envelope mixer, and the pitch path (`alCents2Ratio` pure/correct; keymap
|
||
formula `keyBase*100 + detune - 6000`, snd.c:803/807) all match the PD port
|
||
line-for-line. The `A_LOOP` (flags&2) branch in `aResampleImpl` is **dead code
|
||
in GE**: the resampler filter's `first` is only ever 0 or 1 (load.c:461/530);
|
||
A_LOOP(2) is never passed to aResample. Not a bug — closed.
|
||
|
||
**3. The reference clip is refuted as an in-game capture.**
|
||
`scratchpad/ref_match_all.py`: `B00I00S2D.wav` does NOT waveform-match any of
|
||
the 261 SFX decodes (best single CC = 0.19 ≈ noise; index 45's waveform CC is
|
||
0.048), and `ref_match_mix.py` shows no PPK mixture (46+122+ricochet) matches
|
||
either (best 0.09). M-61's "+0.999 with index 45" was purely an
|
||
**envelope-correlation artifact** (two percussive sounds with similar decay
|
||
envelopes). The "N64 plays index 45 for the PPK" premise is dead; the clip's
|
||
provenance is unknown and must be asked of the user.
|
||
|
||
**4. M-63 final-mix cross-check was inconclusive, not exonerating.**
|
||
`scratchpad/m63_crosscheck.py`: door events (dumppos 23296/54976, index 203)
|
||
show CC≈0 vs offline decode, but those segments are dominated by music
|
||
(broadband RMS 2000–8000, no SFX periodicity). CC≈0 in a music-masked final
|
||
mix proves nothing about which sample the voice played. A per-voice dump (item
|
||
5) or a music-off capture is required.
|
||
|
||
**5. New probe: `GE_VOICEDUMP=1`** (`port/src/mixer.c`, temporary, D202 diag).
|
||
Writes each voice's resampled mono stream — the `aEnvMixer` input, i.e.
|
||
post-resample/pre-envelope, per-voice, no music/reverb masking — to
|
||
`voicedump.raw` as records `[u32 stateAddr][u32 nSamples][u64 us][s16 × n]`.
|
||
The `[WIRE]` lines (load.c `alLoadParam(SET_WAVETABLE)`) and the first
|
||
`[AUDIOTRACE] sndPlaySfx` line now also carry `t=<µs>` (sysGetMicroseconds)
|
||
so all three streams correlate in time. Offline matcher:
|
||
`scratchpad/voicedump_match.py` (each record vs all 261 pitch-resampled
|
||
decodes). Self-test: `scratchpad/d202-m67/` (25 s `-level_09`, 5.7 MB dump,
|
||
clean parse, records match their sources once the item-6 direction fix is
|
||
applied).
|
||
|
||
**6. Two tooling bugs found in the self-test (my scripts, not the game):**
|
||
|
||
- **Resample direction was inverted in every offline model so far.** The
|
||
hardware playhead advances `pitch/16384 = ratio` input samples per output
|
||
sample (`aResampleImpl`: `pitchAccumulator += pitch<<1; in += accum>>16`),
|
||
i.e. `y[k] = x[ratio·k]` — ratio < 1 plays SLOWER and lower. The correct
|
||
offline model is `resample_linear(native, RATE*ratio, RATE)`, not
|
||
`(native, RATE, RATE*ratio)` as used in M-63's cross-check and the first
|
||
matcher pass. **All prior CC results at pitch ≠ 1 are suspect** (the M-63
|
||
door check doubly so).
|
||
- **WIRE bases are absolute; bank offsets are segment-relative.** The
|
||
in-memory wavetables hold `base = SFXTBL_START(0x102F19A0) + bank offset`;
|
||
matching WIRE entries against raw bank offsets finds nothing.
|
||
|
||
**7. GE's "music" is an SFX-sequence system with its own sample set.**
|
||
`sndNewPlayerInit(ALSeqpSfxConfig*)` (snd.c:168; config in src/music.c:823,
|
||
`MUSIC_SFX_SEQ_MAYBE_MAX_SOUNDS = 8`): music notes are sequenced SFX played
|
||
through the same 8-voice alSynth pool. The WIRE log shows ~8 music samples
|
||
whose bases sit at ROM 0x103ed1e8–0x10407860 — 240–350 KB **past** the last
|
||
SFX-bank sample — with wavetable structs allocated contiguously after the SFX
|
||
bank's. Music notes do not pass through `sndPlaySfx`, so they are invisible to
|
||
the `[AUDIOTRACE]` request probe: persistent voices in a voicedump are usually
|
||
music, and their "best match" against the 261-SFX bank is coincidental
|
||
(music samples reuse field recordings; free-ratio sweep found e.g. CC 0.92 vs
|
||
index 122 at transposed pitch). Any per-voice analysis must subtract music
|
||
first (or use the timestamped WIRE assignments to identify each voice's sample).
|
||
|
||
**8. Open anomaly parked for the next session: sound 232 (PICKUP_GUN_SFX)
|
||
requested at `-level_09` start produced no audible audio.**
|
||
Self-test trace: `sndPlaySfx(232)` at dumppos=0 → VOICE+ (count 1/8) →
|
||
VOICE- within ~1 frame; the WIRE-assigned filter was recycled to music
|
||
immediately; no env state in the dump matches decode(232) (max CC 0.48 ≈
|
||
noise, even with the corrected resample direction). Either the level script
|
||
stops it instantly (faithful N64 behaviour — needs an N64 A/B to confirm)
|
||
or something disposes it early on PC. Not chased this session.
|
||
|
||
**Status (M-67): static analysis has hit diminishing returns — every pipeline
|
||
stage (index resolution, bank data integrity, ADPCM, resample, envelope,
|
||
pitch, loop mechanism) is verified against ROM + PD ground truth, and the
|
||
reference clip is refuted. The remaining questions (PPK/door/armor "wrong
|
||
sound" by ear) require a user runtime A/B capture with the new
|
||
`GE_VOICEDUMP` probe: `GE_AUDIOTRACE=1 GE_AUDIODUMP=1 GE_VOICEDUMP=1`,
|
||
reproduce each scenario, send `audiotrace.log` + `audiotrace_wire.log` +
|
||
`voicedump.raw`. Also owed: provenance of `B00I00S2D.wav`, and the item-8
|
||
sound-232 anomaly.**
|
||
|
||
### M-68 — Decisive runtime A/B (user captures, 3× level_09): all three complaint classes play the exact ROM samples at the exact ROM pitches; no port bug found (this session)
|
||
|
||
Three interactive user captures (`audiodebug.ps1 -Play -Trace -Dump
|
||
-VoiceDump`), analysed with full-slide correlation of `voicedump.raw`
|
||
per-voice records against `decode_sound(idx)` resampled at the keymap ratio
|
||
(correct direction, M-67 item 7: `y[k] = x[ratio·k]`). A new `[EVT] t=µs
|
||
type=N state=P` probe (PORT-gated, `sndHandleEvent` top) plus a
|
||
`[STOP-EVT] … deltaUs=N` probe (release-ramp site) were added this session;
|
||
event types decode per `src/snd.h`: 1=PLAY, 2=STOP, 8=VOL, 64=DECAY,
|
||
128=END.
|
||
|
||
**1. Doors — correct.** `METAL_SLIDE_OPEN` 202: CC **0.957** @ ratio 0.500;
|
||
`METAL_SLIDE_CLOSE` 203: CC **0.906** @ 0.728; `METAL_SLIDE_LOOP` 204: CC
|
||
**0.993** @ 0.728. Event flow is textbook: PLAY → DECAY (attack=0, fires
|
||
immediately) → self-stop at the decay-scheduled STOP (204: +154 ms =
|
||
112197 µs ÷ 0.7278; 202: ~450 ms) → release ramp (`[STOP-EVT] deltaUs` =
|
||
releaseTime ÷ ratio, e.g. 1373/3999 µs) → END → dispose.
|
||
|
||
**2. PPK shot — correct.** `GUN_SILPPK_A` 46: CC **0.90–0.97** @ ratio 0.728
|
||
across four separate shots (full-slide, any offset). Combined with M-59
|
||
(46 + 122 CART_SPENT + a ricochet is exactly what the ROM scripts for a PPK
|
||
hit), the user's "gunshot plays broken glass/slap/ricochet" is the original
|
||
design, reproduced faithfully.
|
||
|
||
**3. Body armor pickup — correct, full length.** `ARMOUR_COLLECT` 81:
|
||
voice lifetime measured **833 ms** from `[EVT]` timestamps (PLAY → decay-
|
||
scheduled STOP), against ROM prediction 469649 µs ÷ 0.5612 = **837 ms**;
|
||
release ramp 3563 µs; CC **0.828** vs decode(81) @ ratio (window contains
|
||
other concurrent voices, hence <0.95).
|
||
|
||
**The M-67-era "instant death" metric was a line-count artifact.** Log lines
|
||
≠ time: a voice stopped by the door script's `stop-sounds`
|
||
(`door7F053B10`, `src/game/propobj.c` — stock logic, identical on N64) when
|
||
the player shuts a door quickly produces only a few log lines between birth
|
||
and death even when it plays its full ~20 ms. With µs timestamps the STOPs
|
||
land exactly at their scheduled times; no voice is disposed earlier than its
|
||
decay/stop schedule in any of the three captures.
|
||
|
||
**13 `[EXPIRE]` events in the final capture:** the M-66b guard expired 13
|
||
ownerless `METAL_SLIDE_CLOSE_SFX` (203) loops, each at exactly 2.5 s (2 s
|
||
delay + 0.5 s fade). This remains the single outstanding PORT deviation;
|
||
whether the N64 lets that loop run until level exit is the open by-ear A/B
|
||
(see M-66b).
|
||
|
||
**Status (M-68): D202's "wrong sample" hypothesis is closed negative for all
|
||
three user complaint classes — PC plays exactly what the ROM specifies,
|
||
verified per-voice at runtime. Remaining: (a) N64 A/B on door-close-loop
|
||
duration to confirm or retract the M-66b cap; (b) the parked sound-232
|
||
anomaly (M-67 item 8); (c) once the user confirms by ear, remove the D202
|
||
probe set (`GE_AUDIOTRACE`/`GE_AUDIODUMP`/`GE_VOICEDUMP` probes in
|
||
`src/snd.c`, `src/libultra/audio/load.c`, `port/src/mixer.c`,
|
||
`src/game/propobj.c`) and decide the M-66b guard's fate.**
|
||
|
||
### M-69 — Full-corpus exact match (scripted `-level_09`, 320 SFX requests): 286/320 OK after tooling fixes; the remaining failures are *dropped allocations*, not corruption (this session)
|
||
|
||
New offline tool `scratchpad/exact_match.py` (gitignored scratchpad; helpers
|
||
`rom_sfx_decode.py`, `m63_crosscheck.py`) matches **every** `sndPlaySfx`
|
||
request in a full trace to its voice and slides the ROM-decoded PCM
|
||
(resampled at the keymap ratio, M-67 item 7 direction) against that voice's
|
||
`GE_VOICEDUMP` stream. Corpus: `scratchpad/exact_run/` (scripted `-level_09`,
|
||
61 input entries, `GE_AUDIOTRACE` + WIRE + `GE_VOICEDUMP`) — **320 requests**.
|
||
Chain: request → its own `[EVT] type=1` PLAY (states are recycled across
|
||
requests, so the wire window must anchor on *that* PLAY) → WIRE record with
|
||
the same wavetable in [play−10 µs, play+50 ms] → decoder index → env-state
|
||
slot address → voicedump records for that slot.
|
||
|
||
**Result: 286/320 OK (CC ≥ 0.85).** The initial 241 OK / 35 MID / 44 BAD split
|
||
was almost entirely tooling artifacts, not audio:
|
||
|
||
1. **`GE_VOICEDUMP` records are rendered ahead — the µs stamp is the END of
|
||
the block.** Each record's n samples end at its stamp, spaced 1/22050 s.
|
||
Treating stamps as start offsets misplaces content by up to one block and
|
||
produced a phantom "stale content" in pv20's slot (sound 29 looked ~180 ms
|
||
early). With per-sample record times, sound 29's onset lands at **+263.8 ms
|
||
vs its WIRE timestamp +264 ms — exact**. Cross-slot correlation confirmed
|
||
the dump attribution was never wrong (max CC vs neighbouring slots ≤ 0.076;
|
||
slot content is unique per voice).
|
||
2. **`slide()` needed step-1 refinement around the coarse best hit.** Coarse
|
||
stepping misses sharp onsets: sound 29 measured CC 0.590 at the coarse
|
||
argmax and **0.970 one sample later**. (Also fixed a bug: the best-offset
|
||
tracker was only updated in the last coarse loop, so refinement searched
|
||
around offset 0.)
|
||
3. **Clip the reference at voice death.** Requests whose voice is cut by a
|
||
DEACTIVATE/STOP after PLAY (e.g. idx=109, killed at +131 ms) cannot match
|
||
the full ROM decode (CC 0.016); clipping the ref to the live window gives
|
||
CC **0.999** at offset +0 ms. The matcher now parses `[EVT]` per state and
|
||
clips accordingly.
|
||
|
||
**The remaining ~34 failures are not corruption:**
|
||
- **"NO WIRE AT PLAY" / DROPPED (no PLAY evt):** the request was processed but
|
||
`alSynAllocVoice` never gave it a voice (no `[VOICE+]`, no DECAY) — e.g.
|
||
t=567204976 idx=46. Leading suspect: the 8-voice pool is exhausted under
|
||
scripted rapid fire and low-priority sounds are dropped by design (`snd.c`
|
||
threads `state->priority` into `config.priority`; `alSynAllocVoice`'s steal
|
||
path has a ~512-sample delay). **Not yet verified — the next step.**
|
||
- **pv10 mismatches:** consecutive requests share wavetables, so the wire
|
||
window can pick up the *next* request's wire and attribute the wrong slot.
|
||
Tighten: require the WIRE to fall between this PLAY and the next PLAY on the
|
||
same wavetable.
|
||
- Log-interleaving gotcha: AUDIOTRACE lines from multiple file handles
|
||
interleave and corrupt some lines (a missing/corrupt PLAY line sends the
|
||
matcher to its wide-window fallback).
|
||
|
||
**Status (M-69):** every request that *got* a voice plays the exact ROM sample
|
||
at the exact ROM pitch — this extends M-68's per-class verification to the
|
||
whole corpus. D202 remains "no port bug found"; open items are M-68's plus:
|
||
(a) confirm the dropped requests are pool-exhaustion drops that N64 would drop
|
||
identically (check `[VOICES] allocated=N / max=8` around each drop;
|
||
`src/snd.c:916`, `alSynAllocVoice`); (b) fix the shared-wavetable wire
|
||
attribution; then M-68's by-ear + N64 A/B, probe removal, close.
|
||
|
||
### M-70 — Decisive full-corpus exact match on a clean re-capture (209 requests, scripted `-level_09`): every voiced request plays the exact ROM sample; D202's wrong-sample hypothesis is refuted (this session)
|
||
|
||
The prior 316-request corpus was contaminated mid-capture (pause menu hit),
|
||
so a fresh 75 s scripted capture was taken (`GE_AUDIOTRACE=1 GE_VOICEDUMP=1`,
|
||
`-level_09`, `scratchpad/exact_run3_script.txt` fire burst) and the matcher
|
||
(`scratchpad/exact_match.py`) was fixed in three places before the decisive run:
|
||
|
||
1. **Death-clip both sides.** Clip *stream* as well as *ref* at the voice's
|
||
downstream death event (STOP/END/DEACTIVATE). M-69 only clipped `ref`; when a
|
||
slot is reused ~200–400 ms later, the unclipped 1.5 s stream window is mostly
|
||
the *next* occupant's samples.
|
||
2. **`slide()` sparse-tail bug (the big one).** The offset search was dense
|
||
only up to 9000 samples (~408 ms) and used step = span/40 beyond that. Delayed
|
||
voicing — `sndPlaySfx` queue wait, observed up to **+1.2 s** between request
|
||
and PLAY — pushes the onset into the sparse zone, where the narrow CC peak
|
||
falls between grid points: idx=134 (onset +532 ms) scored **CC 0.154** in the
|
||
matcher but **0.973** under a full-range step-4 scan of the identical data.
|
||
Fixed: step-1 near the window start, step-4 across the *full* span, ±8
|
||
step-1 refine around the best hit. idx=136 went 0.285 → ≥0.85 the same way.
|
||
3. **PLAY search window 600 ms → 2 s**, catching two idx=175 instances voiced
|
||
at +1196 ms (their WIREs land with the PLAY, so pairing is unambiguous).
|
||
|
||
**Result — 209 requests:**
|
||
|
||
| bucket | count | disposition |
|
||
|---|---|---|
|
||
| OK (CC ≥ 0.85) | **200** | exact sample at exact keymap pitch |
|
||
| MID (0.6–0.85) | 4 | all idx=109, CC 0.744–0.811 — see below |
|
||
| NO WIRE AT PLAY | 5 | 1 matcher artifact + 4 never voiced — see below |
|
||
|
||
- **The 4 MID (idx=109):** manual verification on the tight `[play, death]`
|
||
window with a ratio sweep gives **CC 0.946 at the nominal keymap ratio**
|
||
(0.7236 = keyBase 54, detune +40, −6000 shift) — correct sample *and*
|
||
pitch. The residual is measurement, not audio: the voicedump records are
|
||
stamped with **wall-clock time at mix time**, and the scheduler's catch-up
|
||
delivers them in ~32 ms bursts of ≤160-sample records with clustered
|
||
stamps, so bisect windowing on those stamps is fuzzy by tens of ms — and
|
||
these voices die young (killed at +67…+131 ms), leaving little clean
|
||
signal to average over.
|
||
- **NO WIRE ×5:** idx=109@610342131 was in fact voiced — its WIRE lands at
|
||
**+133 ms** after PLAY, outside the matcher's 50 ms wire window (all other
|
||
idx=109 instances match fine). The other four (idx 8/40/123/202) show no
|
||
voicing evidence anywhere: no `VOICE+` on their `newState` after t0, and no
|
||
WIRE for their wavetable within 2–3 s *in any state*. **Not pool
|
||
exhaustion** — `[VOICES] allocated` was 0–5/8 at each drop (run max 7/8).
|
||
Mechanism unverified; consistent with game-logic queue/priority timing in
|
||
code identical to the N64 build, and orthogonal to D202 (no samples were
|
||
played, so nothing could be wrong).
|
||
|
||
**Verdict:** across 204 voiced requests spanning ~30 distinct sound indices,
|
||
**zero** play a wrong sample. Combined with M-68 (per-class runtime A/B) and
|
||
M-66 (stuck door loop = faithful N64 quirk), the D202 "wrong sample / mixer
|
||
corruption" hypothesis is refuted: what the user heard is the original design
|
||
(overlapping SFX, short decays, quick-stop truncation, the ownerless infinite
|
||
door loop).
|
||
|
||
**Status (M-70):** D202 data-closed. Remaining before final close + probe
|
||
removal: user by-ear A/B on the M-66b expiration behaviour (does the stuck
|
||
drone now fade out ~2.5 s after a door close, vs N64's forever-drone). Probes
|
||
(`GE_AUDIOTRACE`, `GE_VOICEDUMP`, `[WIRE]`, `[EVT]`, `[VOICE±]`, `[SLOTWRITE]`,
|
||
`[ENVELOPE]`, `[VOICES]`) stay in place until that pass is done; they are
|
||
`#ifdef PORT`-gated and env-var-off by default.
|
||
|
||
### M-71 — User by-ear pass on M-66b: PASS. D202 ready to close. New complaint class split out as D205 (this session)
|
||
|
||
**By-ear verdict (user, PC vs N64 side-by-side):** "the metal door sound
|
||
does not loop forever now" — the M-66b ownerless-infinite-loop expiration
|
||
behaves as intended; Disposition C validated. That was the last gate on
|
||
D202: probe removal + close is all that remains (see HANDOFF).
|
||
|
||
**New user complaint class (NOT D202 — split out to D205).** With M-70's
|
||
per-voice exact-match result standing (every voiced request plays the exact
|
||
ROM sample), the user reports by ear, and confirms against an N64 A/B that
|
||
the PC is *not* faithful: (1) general gunfire has a slap/glass-shatter
|
||
layered over the correct gunshot — audible on PC, silent on N64 for the
|
||
same shot; (2) an explosion plays a scream-like sample (the soldier-death
|
||
voice); (3) armor pickup plays a wrong sample. This session exonerated the
|
||
two remaining static suspects: the D37 bank converter offline (`scratchpad/
|
||
banktest/`: real `romdataFixupAudioBank` harness + `diff_bank.py` → all 261
|
||
entries map identically N64→PC, 0 mismatches) and the D154/D135 GBI raycast
|
||
parsers + volume law (static re-audit, ABI-correct). The remaining
|
||
explanation is that PC **requests different or extra sound indices** than
|
||
N64 in the same scenario. See D205.
|
||
|
||
### M-72 — Fourth symptom reported (unarmed melee → Klobb shot idx 106); controlled `GE_AUDIOTRACE` runs exonerate every request path; mechanism (b) now the leading suspect (this session)
|
||
|
||
User added a fourth D205 symptom: Bond's unarmed slap attack plays
|
||
Klobb's shooting sound (`GUN_B1_MGUN3_3_SFX`, idx 106) instead of the slap
|
||
effect. Also fixed the test environment: the "flaky /GS crash" of earlier
|
||
attempts was `SDL2.dll` missing from PATH (it lives in
|
||
`C:\msys64\mingw64\bin`; the exe is `-mwindows`, so a failed DLL load just
|
||
dies with 0xC0000105 and no console). Launchers now prepend it.
|
||
|
||
Controlled traces (`build-pc/d205_*.ps1`, gitignored): PPK fire at
|
||
`-level_09` requests exactly {46, one of the 20-entry ricochet table,
|
||
122} per shot — no glass/slap layer; unarmed fire-presses at levels 01–08
|
||
(all start unarmed) request only 105 PUNCHING_AIR. Static: punch hit =
|
||
constant {47,48,49}, idx 106 is data-only (`skorpion_stats.Sound`), and the
|
||
player fire-sound path cannot run for a fist — so Bond's own punch cannot
|
||
request 106; an NPC guard firing its Skorpion can. No symptom reproduces in
|
||
micro-scenarios → the layering must come from **extra events during real
|
||
gameplay** (mechanism b). Next: user `GE_AUDIOTRACE=1` capture at the exact
|
||
failing moment (splits per-symptom), then controlled explosion + armor.
|
||
Full detail in D205.
|
||
|
||
## D207 — **Alarm klaxon starves combat SFX (surfaced by D206)** (M-77, root-caused, fix designed & not applied)
|
||
|
||
### How D206 caused it
|
||
|
||
`ALARM3_SFX` (SFX_ID 163) is played by `handle_alarm_gas_timer_calldamage()`
|
||
(`src/game/propobj.c:14457`) whenever `alarmIsActive()`, owner `&ptr_alarm_sfx`.
|
||
|
||
- **Pre-D206:** resolved to on-disk bank slot **163** — a **one-shot** sample
|
||
(`decayTime` finite, no ADPCM loop). Played once, released its voice. The
|
||
alarm was audibly the *wrong* sound but cost the voice pool nothing.
|
||
- **Post-D206:** correctly resolves to slot **162** — the real klaxon:
|
||
`envelope.decayTime == -1` (→ `SOUND_FLAG_LOOPED`, `sndSetupSound`
|
||
`snd.c:~836`), wavetable `adpcmWave.loop.count == -1` (infinite ADPCM loop).
|
||
It now holds a voice for the whole time the alarm is up.
|
||
|
||
### Why it dominates
|
||
|
||
The SFX player has **8 voices** (`MUSIC_SFX_SEQ_MAYBE_MAX_SOUNDS = 8`,
|
||
`src/music.c:76`; the synth has 24 physical voices but the SFX soft cap is 8
|
||
and is what `snd.c:328` gates on).
|
||
|
||
`sndSetupSound` sets `state->priority = (decayTime == -1) + 0x40` → the alarm
|
||
gets **0x41**, one-shots get **0x40**.
|
||
|
||
When `g_sndAllocatedVoicesCount == 8`, `snd.c` PLAY_EVT does **not** call
|
||
`alSynAllocVoice` (which *would* do libultra priority-stealing); it runs the
|
||
manual preemption scan at `snd.c:~347-392`. That scan:
|
||
- refuses to steal any `unk3e & 0x12` voice (LOOPED | RETRIGGER) — the M-65
|
||
carve-out only exempts *ownerless* loops (`state == NULL`), and the alarm
|
||
is **owned**;
|
||
- ignores priority entirely — it steals the first non-looped PLAYING
|
||
non-PREEMPT voice it finds;
|
||
- if it finds none → `sndDisposeSound(soundState)` → **the new SFX is
|
||
dropped** (`snd.c:391`).
|
||
|
||
So an active alarm removes one of the 8 voices, and in a firefight (gunshots +
|
||
ricochets + impacts + enemy fire + ambient door/machine loops) the remaining
|
||
non-looped voices are all either already PREEMPT-marked from the same burst or
|
||
themselves looped → gunshots drop.
|
||
|
||
### Measured (BUNKER1, `-level_09`)
|
||
|
||
Temporary probe `GE_FORCEALARM=1` (added to `handle_alarm_gas_timer_calldamage`,
|
||
`#ifdef PORT` / `getenv`) pins `alarm_timer` so the klaxon stays up, plus a
|
||
scripted `GE_INPUTSCRIPT` firing Z every 8 reads:
|
||
|
||
| condition | SFX requests | dropped (no voice) |
|
||
|---|---|---|
|
||
| no alarm | 52 | 1 (~2%) |
|
||
| alarm active | 137 | 28 (~20%) |
|
||
|
||
Dropped indices were gunshot/impact/ricochet sounds (29, 30, 41, 90, 36, 109…).
|
||
`ptr_alarm_sfx` stayed stable while active and was cleared on
|
||
`alarmDeactivate()`; the alarm voice was freed and the pool recovered — the
|
||
**"doesn't recover after the alarm stops" half of the user report was NOT
|
||
reproduced** in any trace (light BUNKER1 combat; may need a heavier level, or
|
||
it is perceptual).
|
||
|
||
### The 8-cap and the scan are faithful decomp
|
||
|
||
`MUSIC_SFX_SEQ_MAYBE_MAX_SOUNDS = 8` is unmodified game code; the manual
|
||
preemption scan is stock `sndHandleEvent` (M-65 modified it in place). Upstream
|
||
GE has the same. So this is not purely a port regression — it is partly the
|
||
open **D202** voice-pool-margin problem (ambient infinite-loops holding voices
|
||
longer than they should on the port) becoming audible because the alarm now
|
||
legitimately consumes a slot.
|
||
|
||
### Designed fix (PORT, not yet applied)
|
||
|
||
In the `snd.c` preemption scan, add a **last-resort second pass**: after the
|
||
normal pass (non-looped victims + the M-65 ownerless-loop carve-out) finds
|
||
nothing and `limitReached` is still true, and **the incoming `soundState` is
|
||
itself not LOOPED**, run one more pass that will preempt a stoppable LOOPED,
|
||
non-RETRIGGER voice (the alarm klaxon; also the D202 door loop as a bonus).
|
||
|
||
Self-healing: preempting the alarm posts `AL_SNDP_END_EVT` → `sndDisposeSound`
|
||
→ `sndUnlinkClearSound` clears `state->state->link.next`, i.e. sets
|
||
`ptr_alarm_sfx = 0`; the very next frame `handle_alarm_gas_timer_calldamage`
|
||
sees `ptr_alarm_sfx == 0` and re-issues `sndPlaySfx(ALARM3_SFX, &ptr_alarm_sfx)`,
|
||
which re-acquires a voice (or WAIT_VOICE-retries at 33 ms). Net effect: the
|
||
klaxon briefly yields to a gunfire burst and comes right back — gameplay audio
|
||
wins, which matches player expectation.
|
||
|
||
Alternatives weighed and rejected for now: (a) raise `maxSounds` > 8 on the
|
||
port — game-code change, rule #2 risk, changes mix loudness; (b) properly fix
|
||
the D202 ambient-loop lifetimes — the correct fix but the hard open problem.
|
||
|
||
### M-77b — designed fix tried, hypothesis not confirmed, reverted
|
||
|
||
The last-resort LOOPED-preemption pass was implemented in the `snd.c` scan,
|
||
plus `[D207-DROP]` counters at both no-voice `sndDisposeSound` sites and a
|
||
`[D207-YIELD]` trace on the new pass. BUNKER1 test (`GE_FORCEALARM=1` +
|
||
`GE_INPUTSCRIPT` firing every ~10 controller reads for ~85 s, alarm cycled
|
||
on/off/on):
|
||
|
||
- **`[D207-DROP]` = 0** — not a single SFX actually dropped.
|
||
- **`[D207-YIELD]` = 0** — the new pass never even ran; pass 1 always found a
|
||
non-looped voice to preempt.
|
||
- Per-frame voice count: **1** during quiet alarm, spikes to 3–5 under fire,
|
||
back to **0** after every alarm-off. No leak, no pegged pool.
|
||
- The earlier "≈20 % dropped" number was bogus: it counted requests whose
|
||
`[VOICE+]` came more than 40 log-lines later. Cross-checking "states that
|
||
never got a voice at all" = **0 / 136**. Non-looped sounds get 2 `unk38`
|
||
retries then the scan, which nearly always places them a frame or two late —
|
||
audible as slightly loose timing, not silence.
|
||
|
||
So on BUNKER1 the alarm does **not** starve the pool. The change was reverted
|
||
(`snd.c` now carries only the D206 `-1`). Either BUNKER1 combat is too light to
|
||
reach 8/8, or the user's symptom is a different mechanism.
|
||
|
||
### Owed next session — needs a real repro from the user
|
||
|
||
The BUNKER1 forced-alarm harness cannot reproduce this. Get from the user:
|
||
**which level, which weapon/situation**, and a `GE_AUDIOTRACE=1` capture (or a
|
||
short clip) of the failing alarm. Then decide between:
|
||
|
||
1. **Genuine 8/8 starvation on a heavy level** — Facility / Silo / Statue
|
||
firefights have many more guards firing. Soak one with `GE_FORCEALARM` +
|
||
sustained scripted fire (or the user's own capture) and watch for
|
||
`[D207-DROP]` > 0 / `count=8` pinned. If real, the reverted last-resort
|
||
pass is the fix — re-apply it.
|
||
2. **A leak on a specific alarm-stop path** — the `alarm_timer` auto-timeout
|
||
(`CHROBJ_GAS_TIMER`) vs `AI_AlarmOff` vs `deactivate_alarm_sound_effect`
|
||
from a cutscene. Trace `g_sndAllocatedVoicesCount` across each.
|
||
3. **Perceptual** — the klaxon is a *continuous* infinite loop (`slot 162`,
|
||
no gaps). A real GE alarm pulses. Check: is `ALARM3` supposed to retrigger
|
||
(keyMap velMax=7 was noted but `SOUND_FLAG_RETRIGGER` needs `keyMax & 0xf0`,
|
||
and keyMax=4 → not set)? And compare slot-162 playback **volume** to N64 —
|
||
if the drone is just mixed loud it will mask everything without dropping a
|
||
single voice.
|
||
|
||
**Temp probe kept:** `GE_FORCEALARM` in `handle_alarm_gas_timer_calldamage`
|
||
(`src/game/propobj.c`, `#ifdef PORT` / `getenv`).
|
||
|
||
|
||
|
||
**M-77c (from the §F row, appended 2026-09-15 ctx-opt):** user reports the alarm is acceptable in play after D206; no fix shipped. Disposition: **MONITORING**.
|
||
## D206 — **PC played every SFX one bank slot too high: `ALInstrumentAlt_s.soundArray` shifts offset 12→16 at 64-bit** (M-74 found, M-76 FIXED)
|
||
|
||
**This is the root cause of D205's melee→Klobb and armor-pickup symptoms, and
|
||
of D202's original "silenced PPK plays a slap" report.** It was invisible for
|
||
eight sessions because *the verification tooling shares the bug* — see
|
||
"Why M-68/M-70 passed" below.
|
||
|
||
### The evidence
|
||
|
||
The user supplied an independently-produced, verified rip of GE's sound
|
||
effects (`B00I00S<hex>.wav`, 186 files). Filenames are **hexadecimal** bank
|
||
indices. Cross-checked by PCM frame count against `scratchpad/rom_sfx_decode.py`
|
||
decoding the ROM's `sfxctl` bank:
|
||
|
||
```
|
||
offset -2: 0/184 matches
|
||
offset -1: 0/185 matches
|
||
offset 0: 186/186 matches <-- rip index == bank index, exactly
|
||
offset +1: 21/185 matches
|
||
offset +2: 16/185 matches
|
||
```
|
||
|
||
So the rip is ground truth for *what sound lives at each bank index*, and our
|
||
decoder's indexing agrees with it. The user then identified three by ear:
|
||
|
||
| user's statement | rip file | = bank index | game requests (from `audiotrace.log`) |
|
||
|---|---|---|---|
|
||
| correct body-armour sound | `S50` | **80** | — |
|
||
| what the PC port actually plays for armour | `S51` | **81** | **81** (`ARMOUR_COLLECT_SFX`) |
|
||
| the actual Klobb sound | `S69` | **105** | **105** (`PUNCHING_AIR_SFX`, punch whiff) |
|
||
|
||
Both are exactly `requested index → played bank[index]`, and in both cases the
|
||
**correct** sound sits at `index - 1`.
|
||
|
||
### The structural confirmation
|
||
|
||
- ROM `sfxctl` bank: `bankCount=1`, `instCount=1`, **`soundCount=261`**,
|
||
`percussion=NULL` → valid `soundArray` indices are **0…260**.
|
||
- `SFX_ID` (`src/bondconstants.h`) has **262** members, beginning
|
||
`NOTHING_SFX = 0`, which `snd.c:992` explicitly treats as "no sound"
|
||
(`if (soundIndex == 0) return NULL`).
|
||
- **262 enum members − 1 sentinel = 261 real IDs (1…261), mapping exactly onto
|
||
bank slots 0…260.** The arithmetic only closes with a `-1`.
|
||
- Corollary bug: the last ID, `BIG_CLANK_SFX` = 261, currently indexes
|
||
`soundArray[261]` — **one past the end of a 261-entry array.**
|
||
|
||
`src/snd.c:1001` is unmodified decomp code:
|
||
|
||
```c
|
||
sound = (soundBank->instArray[0]->soundArray[soundIndex]); /* needs - 1 */
|
||
```
|
||
|
||
### Independent confirmation from D202's own reference clip
|
||
|
||
`scratchpad/B00I00S2D.wav` — the silenced-PPK reference clip that drove D202
|
||
from M-60 to M-67, and whose provenance the user could only describe as "not
|
||
sure / found online" — **is a file from this same rip**: `S2D` = **bank 45**
|
||
(3264 frames, identical). And `wppksil_stats.Sound = 0x2E` = **46**.
|
||
|
||
So the silenced PPK's ROM data value 46 must resolve to bank **45**, and the
|
||
user's clip *is* bank 45. **M-61 found precisely this** ("index 45 is the only
|
||
strong match for the user's reference clip, envcorr +0.999; index 46 is the
|
||
'slap'") and it was argued away over four sessions — M-67 "refuted" the clip as
|
||
a genuine capture on a waveform-correlation test. The +0.999 was right. The
|
||
"slap" heard on every silenced PPK shot is `bank[46]`, which under the correct
|
||
mapping is `PUNCH1_SFX` — **literally a slap.**
|
||
|
||
### Why M-68/M-70's "exact match" passed anyway
|
||
|
||
`scratchpad/exact_match.py` verifies a runtime voice against
|
||
`rom_sfx_decode.py`'s decode of **the requested index**, and that decoder walks
|
||
`soundArray[i]` with the same missing `-1`. `scratchpad/banktest/diff_bank.py`
|
||
likewise compares an N64-layout walk to a PC-layout walk using the same
|
||
indexing on both sides. **Every tool built to check the mapping reproduced the
|
||
mapping.** M-68/M-70 proved the port plays *the sample it asked for, at the
|
||
right pitch* — self-consistency — and never tested whether the index asked for
|
||
was the right one. Method note added to `porting-notes.md` §E.
|
||
|
||
### What it explains
|
||
|
||
- **D202** — "silenced PPK plays a slap": requests 46, plays `bank[46]` =
|
||
`PUNCH1`; correct is `bank[45]`. ✔
|
||
- **D205 (4)** — melee plays the Klobb gun: whiff requests 105, plays
|
||
`bank[105]` = **the Klobb sample**; correct is `bank[104]`. ✔ user-confirmed
|
||
- **D205 (3)** — armour pickup wrong sample: requests 81, plays `bank[81]`;
|
||
correct is `bank[80]`. ✔ user-confirmed
|
||
- **D205 (1)** — slap/glass layered over gunfire: consistent (a gunshot ID
|
||
landing on the adjacent impact/punch sample), not yet individually confirmed.
|
||
- **D205 (2)** — explosion → scream: **NOT explained.** Explosion IDs 169–183
|
||
map to bank 168–182, all still explosions. Stays open; M-73's reading
|
||
(guards killed by the blast screaming, ±1 s) remains the likely answer.
|
||
|
||
### RESOLVED (M-76) — pointer-width layout shift in `ALInstrumentAlt_s`
|
||
|
||
The M-75 reframe below was on the right track (candidate B — a PC-side layout
|
||
mismatch, not a code or data `-1`). The exact mechanism:
|
||
|
||
`sndPlaySfx` (`snd.c:1001`) reads the on-disk sound bank — a **standard**
|
||
`ALBankFile`/`ALInstrument` (verified: `rom_sfx_decode.py` finds `soundCount`
|
||
= 261 at instrument offset 14, standard `ALSound*` table at offset 16) —
|
||
through GE's cast alias:
|
||
|
||
```c
|
||
struct ALInstrumentAlt_s { /* src/snd.h:165 */
|
||
s32 unk0; /* off 0 */
|
||
s32 unk4; /* off 4 */
|
||
s32 unk8; /* off 8 */
|
||
ALSound *soundArray[1]; /* N64: off 12 (4-byte ptr) | PC: off 16 (8-byte ptr, 8-aligned) */
|
||
};
|
||
```
|
||
|
||
- **N64:** `soundArray` at struct offset **12** — it overlaps the on-disk
|
||
instrument's `bendRange`(12) + `soundCount`(14) words. So `soundArray[0]`
|
||
is those two `s16`s (a bogus pointer, but `SFX_ID 0 = NOTHING_SFX` is
|
||
short-circuited at `snd.c:992` and never dereferenced), `soundArray[1]` is
|
||
on-disk table entry `[0]`, and in general **`soundArray[N]` == on-disk
|
||
entry `[N-1]`**. GE's `SFX_ID` enum and every `WeaponStats.Sound` byte are
|
||
therefore **1-based** into the sound table, by design.
|
||
- **PC:** 8-byte pointers + 8-byte alignment insert 4 bytes of padding after
|
||
`unk8`, so `soundArray` sits at offset **16**. `port/src/romdata.c`
|
||
`afFixupInst` rebuilds the bank to exactly this layout (16-byte header,
|
||
`8*soundCount` bytes of 64-bit pointers from offset 16, entry `i` = on-disk
|
||
entry `i`). So PC `soundArray[N]` == on-disk entry `[N]` — **one slot
|
||
higher than N64** for every SFX.
|
||
|
||
This is the D3x class (a ROM-serialized struct whose pointer-width field
|
||
shifts the layout at 64-bit). **Fix:** `src/snd.c:1001`, `#ifdef PORT` branch
|
||
→ `soundArray[soundIndex - 1]`, N64 line kept verbatim under `#else`. The one
|
||
read site covers both the initial lookup and the retrigger chain (the loop
|
||
recomputes `soundIndex` from `keyMap->velocityMin`, same 1-based space). No
|
||
game-logic change; `romdata.c` untouched (its layout is internally correct —
|
||
it just wasn't the layout the N64 struct alias assumes).
|
||
|
||
### Open before fixing — where the fix belongs (M-75 reframe) — SUPERSEDED by M-76 above
|
||
|
||
**M-75:** upstream `n64decomp/007` `src/snd.c` byte-matches the real ROM,
|
||
carries no `NON_MATCHING`/`GLOBAL_ASM` guard on `sndPlaySfx`, and its index
|
||
line is identical — `soundArray[soundIndex]`, **no `-1`**. Blame puts
|
||
`snd.c:1001` in the 2022 upstream import, not this fork. So the earlier
|
||
hypothesis "(i) real ROM does `soundArray[soundIndex-1]`, decomp dropped it"
|
||
is almost certainly **dead**: a matching decomp would contain the `-1`. The
|
||
N64 runs `soundArray[N]` for ID `N` and that game sounds correct.
|
||
|
||
That forces a reconciliation. Candidates, cheapest test first — full decision
|
||
gate and per-candidate byte-level experiments in
|
||
`docs/dev/notes/D206-IMPL-BRIEF.md`:
|
||
|
||
- **(A)** the rip `B00I00S<hex>` is keyed by `SFX_ID`, not by 0-based bank
|
||
slot → there is **no engine bug**, the PC audio path is faithful, and
|
||
D205 / D202's slap need another cause. Re-audit whether
|
||
`scratchpad/rom_sfx_decode.py`'s 0-based `sound_offsets` walk actually
|
||
models the runtime `soundArray` (leading NULL? offset-table vs `soundCount`
|
||
mismatch? sfxctl not a plain `ALBankFile`?). Cheapest — do first.
|
||
- **(B)** PC-only off-by-one in the *converted* bank — `port/src/romdata.c`
|
||
`afFixupInst` drops/adds one `soundArray` entry vs the ROM. Test: dump PC
|
||
runtime `soundArray[81]` wavetable `base`/`len` (existing `GE_AUDIOTRACE`
|
||
probe at `snd.c:1002`) and byte-compare to `rom_sfx_decode.py` bank 80 vs
|
||
81. PC-side fix, rule-#2 clean.
|
||
- **(C)** PC-only off-by-one in the `soundIndex` reaching `sndPlaySfx`
|
||
(request path / miscompiled or region-mismatched enum). Test: `GE_AUDIOTRACE`
|
||
already logs `soundIndex=` at the call — confirm armour logs `81`, not `82`.
|
||
- **(D)** genuine data-side: ROM `SFX_ID` constants + `WeaponStats.Sound`
|
||
bytes are each +1 vs a 0-based bank. Only reachable if A/B/C all fail.
|
||
~532 enum refs + debug-name table + weapon-stats asset bytes — **not** a
|
||
legal port edit under AGENTS.md rule #2; stop and escalate to the user
|
||
(possible "wontfix / faithful").
|
||
|
||
M-62's caveat still applies: this repo never builds or byte-verifies the N64
|
||
target, so "it byte-matches" cannot by itself settle (D).
|
||
|
||
### Verification done (M-76)
|
||
|
||
- Build clean (`build-pc.sh ntsc-final`).
|
||
- BUNKER1 (`-level_09`) `GE_AUDIOTRACE=1`, 60 s: crash-free, 446 `sndPlaySfx`
|
||
calls. Every one of ~90 distinct requested `soundIndex` values resolves to a
|
||
wavetable whose `len` matches `rom_sfx_decode.py` slot **`soundIndex - 1`**
|
||
(0/90 match slot `soundIndex`). Includes `ARMOUR_COLLECT_SFX`=81 → slot 80,
|
||
`GUN_B4_BOLTACTION_SFX`(AK47)=109 → slot 108, `GUN_SILPPK_A`=46 → slot 45.
|
||
- `-level_09` golden framediff (`200-440:120`): **3/3 within threshold** on two
|
||
consecutive runs (an audio-path change cannot affect rasterisation; ritual).
|
||
|
||
### Still owed
|
||
|
||
User by-ear A/B vs N64: armour pickup, unarmed melee whiff, silenced PP7 fire.
|
||
If clean → **D202 closes outright**, D205 shrinks to symptom (2) (explosion→
|
||
scream) alone. Then remove the temporary D202 `GE_AUDIOTRACE` / `[VOL]` /
|
||
`[DISTVOL]` probe set (checklist in HANDOFF).
|
||
|
||
|
||
**M-77c (from the §F row, appended 2026-09-15 ctx-opt):** user by-ear A/B vs N64 = **PASS** — armour / melee / silenced PP7 all correct now → D202 closed, D205 down to symptom (2). Committed `378386d1`.
|
||
## D205 — Wrong/extra SFX requested on PC vs N64: explosion→scream, armor pickup wrong sample, slap/glass layered over gunfire, melee→Klobb shot (M-71/M-72)
|
||
|
||
**Symptoms (user by-ear; N64 A/B confirms non-fidelity):**
|
||
1. General gunfire: a slap/glass-shatter plays *over* the correct gunshot on
|
||
PC; silent on N64 for the same shot.
|
||
2. Explosion: plays a scream-like sample (the soldier-death voice) instead
|
||
of / over the explosion.
|
||
3. Armor pickup: wrong sample.
|
||
|
||
**Exonerated — do not re-investigate:**
|
||
- Playback chain: M-68/M-70 exact match — every voiced request plays the
|
||
exact ROM sample at the exact ROM pitch (204/204 voiced, zero wrong-sample).
|
||
- D37 bank converter: offline harness runs the real `romdataFixupAudioBank`
|
||
on the same ROM bytes and diffs N64 vs PC images entry-by-entry —
|
||
261/261 identical (base/len/type/keyMap/env), 0 mismatches
|
||
(`scratchpad/banktest/harness.c` + `diff_bank.py`; gitignored, local-only).
|
||
- D154/D135 GBI raycast parsers ABI-correct; volume law
|
||
(`sub_GAME_7F0537B8`: d≤200→max, sqrt falloff to 5000, linear to 6000)
|
||
and suppression logic faithful (M-71 static audit).
|
||
|
||
**Therefore: PC requests different or extra sound indices than N64 in the
|
||
4. Unarmed melee: Bond's slap attack plays Klobb's shooting sound
|
||
(`GUN_B1_MGUN3_3_SFX`, idx 106) instead of the slap effect (M-72).
|
||
|
||
**M-72 (controlled runtime traces, this session):** environment note first —
|
||
the earlier "flaky /GS crash" was an investigator artifact: `SDL2.dll` lives
|
||
in `C:\msys64\mingw64\bin`, which is NOT on the system PATH and not copied to
|
||
`build-pc/`; launches without it die instantly (exit 0xC0000105), with or
|
||
without any env vars. Prepending mingw64/bin to PATH in the launcher
|
||
(`build-pc/d205_*.ps1`, gitignored) gives clean 40–60 s runs. Results under
|
||
`GE_AUDIOTRACE=1`: (1) PPK fire at `-level_09` (unarmed start; first Z picks
|
||
up the spawn PPK, idx 232): every shot requests exactly {46 GUN_SILPPK,
|
||
one of 27/28/39/40 — all inside the 20-entry `ricochet_sounds_small` table,
|
||
gun.c:415 — so `rnd1 % 20` is IN RANGE, not OOB, 122 CART_SPENT}. No glass
|
||
(70), no slap (47–49) layering. (2) Unarmed melee at levels 01–08 (all start
|
||
unarmed): fire-press requests ONLY 105 PUNCHING_AIR (whiff); punch-HIT path
|
||
is the compiled constant `punch_sounds` = {47,48,49} (gunfire.c:2338), whiff
|
||
= 105 (chrprop.c:1465). No 106 anywhere. (3) idx 106 is referenced by NO
|
||
code constant — it enters only via `WeaponStats.Sound` (`skorpion_stats.
|
||
Sound = 0x6A`) through `bondwalkItemGetSound`; the player fire-sound path
|
||
(gunfire.c:3192–3200) runs only in `GUN_ANIM_STATE_FIRE` and a fist sets
|
||
PUNCH state with `.Sound = 0`, so Bond's own punch cannot request 106 — an
|
||
NPC guard firing its Skorpion (`chraction.c:5588`) can. **Conclusion:
|
||
all four symptoms' request paths are faithful constants/tables, and none
|
||
reproduces in controlled micro-scenarios; the leading suspect is now
|
||
mechanism (b) — extra/different EVENTS during real gameplay (a guard firing
|
||
as you punch → 106 layered on the slap; an NPC scream at the blast; a second
|
||
surface hit adding glass/slap to a shot).**
|
||
|
||
**M-73 (user combat capture) — ROOT CAUSE WITHDRAWN by M-74 below.** The
|
||
session analysed the user's full-combat `GE_AUDIOTRACE=1` capture
|
||
(`build-pc/audiotrace.log`) and concluded that guards were stuck
|
||
re-triggering weapon fire sound idx 109 (`GUN_B4_BOLTACTION_SFX`,
|
||
AK47/Spectre) "256x over 29.7 s with no corresponding bullets", pinning it on
|
||
`stanTestLineUnobstructed` guard->Bond LOS returning clear on converted
|
||
collision geometry. **M-74 re-counted the same file and every load-bearing
|
||
number was wrong** — see below. The code walk M-73 produced is still accurate
|
||
and worth keeping as reference: `chrlvFireWeaponRelated` (`chraction.c:6530`)
|
||
passes `phi_a2 = sp27C || sp278` to `sub_GAME_7F02BFE4` (:6902), so a
|
||
non-bullet auto tick still plays the weapon sound, gated by
|
||
`SoundTriggerRate`/`field_178[hand]` (and by `CHRHIDDEN_FIRE_TRACER` 0x80,
|
||
cleared once per `chrlvTriggerFireWeapon`); sustained attack needs
|
||
`seen_bond_time >= g_GlobalTimer - CHRLV_SEEN_RECENT_CHECK` (:6593),
|
||
refreshed by `setSeenBondTimeToNow` behind LOS queries (:3917/:3972,
|
||
`chrCanSeeBond` :3945). That mechanism is real. What M-74 shows is that
|
||
**nothing in the capture indicates it ever misfired.**
|
||
|
||
**M-74 (re-count of the SAME capture — M-73's evidence does not hold, this
|
||
session):** counting only the canonical one-per-call line
|
||
(`sndPlaySfx: t=... soundIndex=...`) in `build-pc/audiotrace.log`:
|
||
|
||
| M-73 claim | Actual |
|
||
|---|---|
|
||
| idx 109 requested **256x** | **128x** — the count was doubled |
|
||
| "~11 guards each re-triggering" | 128 / 29.73 s = **4.31/s aggregate**, ~0.4/s per guard |
|
||
| "sustained roar, gate blown open" | AK47 `SoundTriggerRate` = `RATE_AK47` = 4 `g_GlobalTimer` ticks (`assets/obseg/gun/gunWeaponStats.inc.c`) => design ceiling **15/s per guard**. Observed is **~6x UNDER** the weapon's own rate — `field_178` is not even the binding constraint |
|
||
| "no corresponding bullets/impacts anywhere near that rate" | In the same 29.7 s window: **~30 ricochet / wall-hit requests** (idx 19, 20, 22-27, 29, 31-33, 35-37, 40, 41), **69 x2** (flesh hit), **123-132** (12 body-falls), **134-147** (14 guard yelps). Guards were firing *and* hitting |
|
||
| "only ~32 non-109 requests in the window" | ~150 |
|
||
|
||
Also corrected:
|
||
|
||
- **The 128-vs-256 gap is the double-log artifact D204 already documented.**
|
||
D204 corrected M-63's "idx 109 fired 94 times in 60 s" with exactly this:
|
||
"the count is doubled by the probe (it logs two lines per call)". M-73
|
||
re-made the same mistake on the same sound index. Count `sndPlaySfx: t=`
|
||
lines, never bare `soundIndex=` matches.
|
||
- **The monotone index runs are ground-truth round-robin, not a broken
|
||
selector.** The capture contains strictly increasing sweeps
|
||
`134,135,...,147` (GET_HIT_MALE) and `123,124,...,132` (BODY_FALL), one
|
||
request each, never repeating. This looks exactly like a `random() % n`
|
||
gone wrong and was checked specifically: `chraction.c:2454-2470` selects
|
||
`male_yelps[male_guard_yelp_counter]` and post-increments modulo 25 — a
|
||
deliberate cycling counter in byte-matched game code. Faithful. Do not
|
||
re-open.
|
||
- The trace file spans 1321.85 s wall (288 requests); the actual firefight is
|
||
~40 s of it (`t` in 715754-715800 s). M-73's "29.7 s" is the 109 span only.
|
||
|
||
**Net: the 109 traffic in that capture is normal, in-spec guard combat.** The
|
||
LOS / converted-geometry root cause is **withdrawn** — it was inferred from
|
||
counts that do not survive re-derivation, and M-73's Next steps 1-3 (probe
|
||
`setSeenBondTimeToNow`, decode pccg stan in Python, replay guard->Bond rays)
|
||
would have been a full session spent on a phantom. The only genuinely open
|
||
observation left from that capture is that the firefight lasted a long time,
|
||
which is a gameplay-fidelity question for an N64 A/B, not evidence of an
|
||
audio bug.
|
||
|
||
**Where that leaves D205.** Every layer upstream of presentation is now
|
||
proven correct: bank conversion (261/261 identical, M-71), requested indices
|
||
(182 explosion x1, 81 armor x1, 46 PPK, 47/48/49 punch hits, 105 whiffs — all
|
||
correct and in range, here and in M-72's controlled runs), and sample+pitch
|
||
(M-68/M-70 exact match). That leaves exactly one untested layer between "the
|
||
right sample" and "what the user hears": **spatial presentation — per-voice
|
||
volume, pan and concurrency.** It fits all four complaints in one shape: if a
|
||
sound's apparent *position* is wrong, a guard firing behind you is by ear
|
||
indistinguishable from "a gun sound came out of my punch", a nearby guard's
|
||
death scream reads as "the explosion screamed", and a ricochet reads as "a
|
||
slap over my gunshot". The user's ear reports *what and where*; only *where*
|
||
has never been verified. It is also the youngest code in the stack —
|
||
`sndCreatePostEvent` was stubbed out entirely (D138) until M-65 un-stubbed
|
||
it, so every distance/pan post-event has been live for only a few sessions
|
||
and has never been A/B'd against N64.
|
||
|
||
**Original M-71 framing (kept as background; DEMOTED by M-74 — every index
|
||
observed in the user's capture was correct, so "PC requests a different
|
||
index" is no longer the leading shape of the bug):** PC requests different or
|
||
extra sound indices than N64 in the same scenario. Two mechanisms, ranked:
|
||
(a) **Converted level/setup data** — sndID / explosion-type / item-sound
|
||
fields in the pccg sidecars differ from ROM. Explosions play
|
||
`sndPlaySfx(g_musicSfxBufferPtr, sp44->sndID, NULL)`
|
||
(`src/game/explosion.c:285`) with `sp44->sndID` =
|
||
`g_ExplosionTypes[type].sndid`; the *type* comes from level data.
|
||
Explosion SFX are idx 169–183 (EXPLOSION_2A_SFX=169 … EXPLOSION_9_SFX=
|
||
183, bondconstants.h). A scream is outside that range, so either the
|
||
type index is wrong (→ a non-explosion sndID) or a second event fires.
|
||
Armor: idx 81 verified exact in M-68 — a "wrong" armor sample means a
|
||
different index is requested in the user's scenario (or a layered
|
||
second sound).
|
||
(b) **Extra hit events** — converted collision geometry makes bullet
|
||
raycasts or explosion proximity checks hit surfaces/NPCs the N64
|
||
misses. The slap+glass-over-gunshot signature is exactly the
|
||
non-penetrating-OBJ-hit path: `sub_GAME_7F064720`
|
||
(`src/game/gunfire.c`) plays `HIT_BULLET_GLASS_SFX` (idx 70) for every
|
||
non-penetrating hit; ricochet tables classify AFDM (27–30) = prop only,
|
||
GBU/TAJ/RICO_4 (19–26, 31–33) = wall only.
|
||
|
||
**M-74 step 1 — static audit of the presentation layer: H-A FALSIFIED,
|
||
H-B narrowed to "faithful but shallow". Done this session.**
|
||
|
||
The pan machinery is complete and faithful end to end:
|
||
`src/snd.c:461/530` computes `pan = clamp(soundState->pan + sound->samplePan -
|
||
AL_PAN_CENTER)` → `alSynSetPan` (`synsetpan.c`, posts `AL_FILTER_SET_PAN`) →
|
||
`src/libultrare/audio/env.c:400-402` turns pan into independent L/R targets via
|
||
the `eqpower` table (`ltgt = volume*eqpower[pan]`, `rtgt =
|
||
volume*eqpower[LEN-pan-1]`) and emits `aSetVolume(A_LEFT|A_RATE)` /
|
||
`(A_RIGHT|A_RATE)` → `port/src/mixer.c` `aSetVolumeImpl` decodes A_LEFT
|
||
(0x02) into `volCur/volTgt[0|1]` and `aEnvMixerImpl` applies `vol[0]`/`vol[1]`
|
||
to `dry[0]`/`dry[1]` independently (:488-496). Nothing is dropped or collapsed
|
||
to mono. **The port's stereo path is correct.**
|
||
|
||
**But GE never uses it.** `AL_SNDP_PAN_EVT` (`snd.h:41`) is handled at
|
||
`snd.c:527` and **posted by nothing anywhere in the tree** — grep returns the
|
||
enum and the handler, no callers. So `soundState->pan` keeps the
|
||
`AL_PAN_CENTER` set at `snd.c:842` for every SFX's entire life, and the only
|
||
pan input is the static per-sound `samplePan` from the bank. Dumped from the
|
||
converted bank image this session: **252 of 261 sounds have `samplePan == 64`
|
||
(= `AL_PAN_CENTER`)**; the 9 exceptions are 4, 24, 44, 74, 84, 104, 123.
|
||
Every sound in the user's capture is centred — 46, 47, 48, 49, 81, 105, 106,
|
||
109, 182 all `samplePan = 64`.
|
||
|
||
**⇒ GoldenEye does not spatially pan SFX at all, on N64 or PC.** There is no
|
||
pan to get wrong. H-A is dead; do not re-open it, and do not write the
|
||
"pan varies with bearing" probe (M-74 step 2 as originally drafted) — the
|
||
answer is known and it is "never, by design".
|
||
|
||
Field-coverage gap in M-71's bank exoneration closed at the same time: the
|
||
converter writes `samplePan`/`sampleVolume`/`flags` at `+24/+25/+26`
|
||
(`port/src/romdata.c:890`), correct for the PC layout (3 × 8-byte pointers
|
||
before them), and `scratchpad/banktest/diff_bank.py` already diffs them as
|
||
`panvol` — re-run this session, **261/261, 0 mismatches**. (M-71's prose
|
||
listed only base/len/type/keyMap/env; its actual coverage was broader,
|
||
including a 24-byte sample-data fingerprint.) The volume formula
|
||
(`snd.c:453`, `:565`) also has no PC-side divergence: peak intermediate is
|
||
127 × 32767 × 127 ≈ 5.3e8, inside s32 on both platforms, and `vol` is `s16`
|
||
(`snd.h:90`) which holds the 32767 the `[VOL]` probe reports.
|
||
|
||
**What this leaves.** GE's only spatial cue for an SFX is distance volume, and
|
||
that law is shallow: the capture's `[DISTVOL]` lines show flat max out to
|
||
~200 units, then 0.60 of max still at dist 1794. So a guard firing across the
|
||
room plays **near-full volume, dead centre**, at a per-sample volume
|
||
comparable to the player's own actions (idx 109 `sampleVolume` = 90; punch
|
||
47/48 = 100, 49/105 = 110). **That is faithful, and it is also exactly the
|
||
perceptual condition that generates all four of the user's reports** — with no
|
||
spatial cue, a correct sample played at correct volume from across the room is
|
||
by ear a sound at your own position. This reframes D205: the complaints are
|
||
fully consistent with *correct* sounds overlapping, and the only remaining
|
||
question is whether the PC generates **more** such overlaps than the N64
|
||
(H-D). Nothing on the PC side can settle that; it needs the N64 A/B.
|
||
|
||
**Next (cheapest first; re-ranked by M-74 — M-73's list is withdrawn):**
|
||
0. ~~User capture~~ — DONE (M-73), but see M-74: it does NOT show a guard
|
||
re-trigger bug. 182 and 81 were both requested correctly and exactly once.
|
||
1. ~~Static audit of the presentation layer~~ — **DONE (M-74 step 1 above).
|
||
H-A falsified: the port's stereo path is correct, but GE never pans SFX at
|
||
all (no `AL_SNDP_PAN_EVT` poster exists; 252/261 bank sounds are
|
||
`samplePan == 64`). Bank `panvol` re-diffed 261/261 clean. Do not re-open.**
|
||
2. ~~Pan probe run~~ — **cancelled by step 1: pan cannot vary, by design.**
|
||
3. ~~The PC side is now exhausted~~ — **SUPERSEDED by D206.** The premise
|
||
("requested indices are correct") was wrong: the indices are correct as
|
||
*values* but are resolved one slot too high, which D206 root-causes. What
|
||
survives from this step: the pan audit (H-A dead) and the bank `panvol`
|
||
re-diff. Original text: bank, playback, requested indices,
|
||
sample+pitch, pan and per-sound volume are all verified faithful. The only
|
||
remaining hypothesis is **H-D** — that the PC generates *more* overlapping
|
||
combat events than the N64 — and nothing measurable on the PC alone can
|
||
settle it. **The N64 A/B is now the gating step, not an optional
|
||
corroboration.**
|
||
4. **User-side, batched, held until 1-3 report:** (a) ROM samples 106
|
||
(Skorpion/"Klobb") vs 109 (AK47) as WAVs via `scratchpad/rom_sfx_decode.py`
|
||
for the user to settle the Klobb identification by ear — their by-ear
|
||
identification stands either way, this decides *which* sound, not whether
|
||
they heard it; (b) N64 A/B of one moment: punch a guard while others fire —
|
||
does the same combat sustain on N64?
|
||
5. After root-cause + fix: user re-test of melee / explosion / armor pickup
|
||
vs N64.
|
||
|
||
**Ranked hypotheses after M-74:**
|
||
- **H-A (top) — pan/spatialisation wrong or absent in the mix.** Untested end
|
||
to end; youngest code in the stack (D138/M-65 lineage).
|
||
- **H-B — distance law right, scale wrong.** The capture's `[DISTVOL]` lines
|
||
do follow the documented curve (flat max to ~200, falloff after) but only
|
||
reach 0.60 of max at dist 1794. Never compared to N64 loudness at the same
|
||
distance.
|
||
- **H-C — voice-pool masking.** 8-voice soft limit vs 11 guards + music. Weak:
|
||
M-70 measured alloc <= 5/8.
|
||
- **H-D — real content difference (PC guards engage where N64 guards don't).**
|
||
Possible, but after M-74 it has *no* supporting evidence. Gated behind the
|
||
N64 A/B.
|
||
|
||
**Probes added this session (live under `GE_AUDIOTRACE`, catalogued in
|
||
GE-ENV-PROBES.md):** `[VOL] t=… state=… rawVol=… playing=…` (`src/snd.c:
|
||
556`, per-VOL-event value) and `[DISTVOL] pos=(…) player=(…) dist=… vol=…`
|
||
(`src/game/propobj.c:12780`, the distance-attenuation funnel
|
||
`sub_GAME_7F053894`).
|
||
|
||
|
||
**M-78 (from the §F row, appended 2026-09-15 ctx-opt):** **CLOSED** — root cause was D206; all four symptoms resolved + user-confirmed by ear vs N64. No separate presentation-layer fix needed.
|
||
## D204 — Audio pipeline runs ~2 % below real time forever: GE's AI feedback loop has a 3 ms setpoint that OS jitter clears, so the DAC is padded with silence (M-64, FIXED + measured)
|
||
|
||
**Found while reviewing the D202 audio state, not by chasing D202's stated
|
||
symptom.** D202 itself is NOT resolved by this — see "What this does and does
|
||
not explain" below.
|
||
|
||
### The architecture (established, not assumed)
|
||
|
||
GE's audio is a closed-loop, DAC-backpressure-driven producer:
|
||
|
||
- `amMain` (`src/audi.c:433`) blocks on `OS_SC_RETRACE_MSG`. `__scMain`
|
||
(`src/sched.c:334`) forwards a retrace to a client either every frame or,
|
||
when the client registered with a non-zero flag, every *second* frame. The
|
||
audio client registers with `1` (`audi.c:441`), so it wakes at **30 Hz**.
|
||
- Per wake, `amHandleFrameMessage` (`audi.c:531`) sizes the next block:
|
||
`frameSamples = (u16)((g_FrameSize - (osAiGetLength() >> 2) + 16 + 0x25) & ~0xf)`,
|
||
then clamps *below* at `g_MinFrameSize`.
|
||
- NTSC constants: `outputRate=22050`, `FRAMES_PER_FIELD_AS_POW2=1`,
|
||
`MAYBE_FRAME_RATE=60` → `g_FrameSize=736`, `g_MinFrameSize=720`,
|
||
`g_MaxFrameSize=789`. `info->data` is allocated exactly `g_MaxFrameSize*4`
|
||
= 3156 bytes (`audi.c:388`).
|
||
- So `osAiGetLength()` is a **±64-sample trim**, and the loop only asks for
|
||
more than 720 once the reported length falls under ~69 frames — **3 ms**.
|
||
|
||
### The defect
|
||
|
||
720 samples per 30 Hz block is **21600 samples/s against a 22050 Hz device**
|
||
— the nominal rate is structurally 2 % short, and the design depends on those
|
||
under-69-frames top-up blocks (784 samples) to make it back.
|
||
|
||
On N64 a 3 ms setpoint is fine: AI is double-buffered and the VI interrupt is
|
||
exact. On PC, `osAiGetLength()` maps onto the SDL queue, and ordinary OS
|
||
scheduling jitter empties a 3 ms cushion before the loop reacts. The queue
|
||
hits zero, SDL pads playback with silence, and **that lost time is
|
||
unrecoverable** — a queue-*depth* reading can never tell the regulator it has
|
||
already fallen behind. Measured, sustained, indefinitely:
|
||
|
||
```
|
||
GE_D204_OLD=1 rt=0.980 q=0..416 (q reaches 0 repeatedly)
|
||
```
|
||
|
||
`rt` = produced audio seconds / wall seconds. 0.980 is exactly 21600/22050.
|
||
|
||
### The fix (F5, port-side only — `src/audi.c` untouched, rule #2 clean)
|
||
|
||
`port/src/audio.c`'s `audioGetAiLengthBytes()` subtracts a target cushion
|
||
(`AUDIO_TARGET_FRAMES` = 1024 frames ≈ 46 ms) before reporting, so "the queue
|
||
holds the cushion" reads to the game as "the queue is empty". The loop then
|
||
tops up while ~46 ms of slack remains and settles just above the cushion
|
||
instead of oscillating into starvation. audi.c's own control law is unchanged.
|
||
|
||
```
|
||
after (same repro) rt=1.000 q=368..672 drop=0 (q never reaches 0)
|
||
```
|
||
|
||
Also landed, all in `port/src/audio.c` / `port/src/libultra.c`:
|
||
|
||
- **F1** — `osAiGetLength()` now reports the residue of a *single* buffer
|
||
(`min(queued, lastBufferBytes)`), matching AI_LEN_REG, instead of the whole
|
||
SDL queue depth. **Robustness only: measured A/B shows zero behavioural
|
||
change.** See the falsified hypothesis below.
|
||
- **F3** — `Audio.QueueLimit` default 8192 → 2880 frames (372 ms → 130 ms
|
||
worst-case latency), and a full queue now logs a dropped block instead of
|
||
discarding it silently. **Caveat: an existing `data/ge007.ini` pins
|
||
`QueueLimit = 8192`, so this default only reaches fresh configs.**
|
||
- **F4** — invariant guard refusing any block larger than `g_MaxFrameSize*4`.
|
||
Believed unreachable; costs one compare, versus a silent heap overrun.
|
||
- **`GE_D204=1`** — audio-health monitor, one line per 5 s: `rt=` real-time
|
||
ratio, `q=` queue depth, `drop=`, `max=` largest block vs its allocation.
|
||
Deliberately cheap (~30 clock reads/s) so it can be left on for a whole
|
||
playtest. **`GE_D204_OLD=1`** restores pre-fix behaviour for in-binary A/B.
|
||
|
||
### Hypothesis raised and FALSIFIED — do not re-open
|
||
|
||
I predicted the u32 subtraction in `audi.c:531` wraps once the queue exceeds
|
||
`g_FrameSize + 0x35` = 789 frames, truncates to u16 (e.g. 58128), and drives a
|
||
74× heap overrun of the 3156-byte `info->data`. **The queue really does exceed
|
||
789 routinely — measured high-water 2064 frames over 5 minutes — and the wrap
|
||
really does happen, but it is harmless.** `frameSamples` is declared
|
||
**`s16`** (`audi.c:145`), so the wrapped value lands negative and audi.c's own
|
||
`(s32)frameSamples < (s32)(s16)g_MinFrameSize` clamp catches it, yielding a
|
||
nominal 720-sample block. Max block observed is 3136 bytes against the
|
||
3156-byte allocation, in both old and new modes, across every run. There is no
|
||
overrun. This is why F1 measures as a no-op.
|
||
|
||
### Two M-63 conclusions corrected
|
||
|
||
1. **`soundIndex=109` firing "94 times in 60 s with no AK47 present" is a red
|
||
herring — closed, negative.** The count is doubled by the probe (it logs two
|
||
lines per call); 47 real calls. All of them fall in tight bursts *after*
|
||
t≈5.4 s, interleaved with ricochet indices 27/28/30/35/39/41 — **guards
|
||
returning fire**, correct behaviour once the player starts shooting in
|
||
Bunker. And the "suspiciously uniform ~32.6 ms cadence" is not a retrigger
|
||
artifact at all: 32.6 ms **is** the audio-block quantum (720 frames × 4
|
||
bytes = 2880 bytes), so *every* `dumppos` in the entire trace is quantised
|
||
to it. Several sounds landing in one block read as "4-in-a-row".
|
||
2. **M-63's implied "audio produced at 13 % of real time" (708608 dump bytes
|
||
= 8.03 s over a 60 s run) was a measurement artifact of its own probe.**
|
||
That run had `GE_MIXERTRACE=1`, whose unbuffered per-opcode `fprintf`
|
||
produced a 25 MB log and slowed the process enough to starve the audio
|
||
thread. The identical repro without it produces 58.0 s of audio in 60 s.
|
||
**Lesson: `GE_MIXERTRACE` is not safe for any timing-sensitive measurement**
|
||
— that is exactly why `GE_D204` was written to be cheap. M-63's
|
||
recommendation to have the user replay with `GE_MIXERTRACE=1` set would
|
||
have induced the very starvation it was looking for.
|
||
|
||
### What this does and does not explain
|
||
|
||
**Does:** a permanent low-level stutter/gap artifact in all PC audio — ~2 % of
|
||
playback was silence, in every level, for every user, from the first frame.
|
||
|
||
**Does not:** D202's reported chain (wrong sound on the silenced PPK, correct
|
||
sound sometimes heard simultaneously, sounds piling up and glitching over a
|
||
session, eventual near-silence except one stuck loop). Nothing in this
|
||
session's headless runs degraded: a 5-minute `-level_09` soak held `rt=1.000`,
|
||
`drop=0`, no voice-pool growth. D202 stays OPEN. It needs a real playtest —
|
||
now instrumentable with `GE_D204=1` (plus `GE_AUDIOTRACE=1`, and **not**
|
||
`GE_MIXERTRACE`), where `rt` falling or `q` pinning at `queueLimit` with
|
||
`drop=` climbing would localise it immediately.
|
||
|
||
### Verification
|
||
|
||
Reproduce with **`. ools_pcudiodebug.ps1 -AB -Fire`** (added this session;
|
||
`-Play -Trace` is the instrumented interactive run for D202). `-level_09`,
|
||
scripted PPK fire, MSYS2 MINGW64 build, full `data/` mirror. In-binary A/B via
|
||
`GE_D204_OLD` (same executable, no build-to-build variance):
|
||
`rt` 0.980 → 1.000, `q` min 0 → 368, `drop` 0, `max` 3136/3156 unchanged.
|
||
Not yet verified by ear — the artifact is a ~2 % silence rate, so a human
|
||
listening pass on a real playthrough is still owed before calling it closed.
|
||
|
||
### Harness gotchas found while building `audiodebug.ps1` (both cost a debug cycle)
|
||
|
||
1. **`Start-Process -RedirectStandardOutput` does not capture this game's
|
||
stdout.** `CMakeLists.txt:577` sets `WIN32_EXECUTABLE TRUE` (`-mwindows`),
|
||
so `ge007.x86_64.exe` is a GUI-subsystem binary with no console of its own;
|
||
the redirect yields a 0-byte log while the output lands on the parent
|
||
console. A shell that opens the file itself before `CreateProcess` (cmd's
|
||
`>`, or a bash redirect) does work.
|
||
2. **MSYS2 `bash.exe` launched from PowerShell arrives with a stripped
|
||
environment.** Neither variables inherited from PowerShell nor the script's
|
||
own `export` reach the child process — verified with a minimal
|
||
`export GE_PROBE=hello; env | grep GE_PROBE` probe, which prints under
|
||
`sh script.sh` from MSYS but nothing when the same script is run by
|
||
`powershell -Command "& bash.exe script.sh"`. Every `GE_*` probe silently
|
||
read as unset, which looks exactly like "the probe is broken". `cmd.exe`
|
||
inherits normally, so `audiodebug.ps1` generates a `.bat` and runs that.
|
||
|
||
### M-78b — user repro on Bunker: symptom is specific and permanent
|
||
|
||
Natural Bunker alarm trigger (spawn, go forward into the room ahead, shoot
|
||
one of the two guards → the other trips the alarm), then sustained firefight.
|
||
User by-ear result:
|
||
|
||
- Most SFX keep playing during the klaxon.
|
||
- **Eventually the *player's* gunshots stop being emitted — and stay muted
|
||
even after the alarm ends.** Enemy gunfire and all other SFX continue fine
|
||
during and after.
|
||
|
||
This is NOT generic pool starvation (that would drop random sounds
|
||
transiently). It is specific to the player-weapon fire sound and it is
|
||
*permanent* — a stuck-state bug, not a transient voice shortage.
|
||
|
||
**Structural lead (static, unconfirmed — needs the capture).** The player
|
||
fire sound is played through a **two-slot double buffer** in
|
||
`bondfirefunc` / `gunfire.c:3178-3202`: `handptr->audioHandle` and
|
||
`handptr->field_A48`. Each trigger interval: `sndDeactivate` whichever slot
|
||
is currently *playing*, then `sndPlaySfx(..., &slot)` into whichever slot is
|
||
*NULL* (`audioHandle` first, else `field_A48`). If **both** slots are ever
|
||
left non-NULL-but-not-playing at once, the `else if (field_A48 == 0)` guard
|
||
fails and **no further player gunshot can play, ever** — matches the symptom
|
||
exactly.
|
||
|
||
A slot is cleared only by `sndUnlinkClearSound` (via `sndDisposeSound`)
|
||
following the state's back-link `state->state->link.next`. `sndDeactivate`
|
||
is **asynchronous** — it posts `AL_SNDP_DEACTIVATE_EVT` to the 64-slot SFX
|
||
event queue (`MUSIC_SFX_SEQ_CONFIG_MAX_EVENTS = 0x40`). If that queue is
|
||
saturated (heavy firefight + the alarm's self-retrigger posts + every
|
||
ricochet/impact/yelp posting PLAY/PLAY_EVT/STOP/DEACTIVATE), `alEvtqPostEvent`
|
||
**drops the event silently** → the deactivate never runs → the state never
|
||
disposes → the slot never clears. Once both slots are stuck, permanent mute.
|
||
The `[VOICES] ... evtq=N/64` probe already in `snd.c` will show queue
|
||
saturation; `[SLOTWRITE]` + `[VOICE+]/[VOICE-]` + `sndPlaySfx t=` trace the
|
||
two player-gun slots. All three counts (8 voices / 64 states / 64 events) are
|
||
byte-match stock GE — so if this is the mechanism it is a port *timing* bug
|
||
(the SFX event queue drained too infrequently vs N64's RCP cadence, D204
|
||
family), not a value to bump.
|
||
|
||
**Next:** read the user's `build-pc/audiotrace.log` — confirm (1) `evtq`
|
||
approaches 64, (2) a `[AUDIOTRACE] sndDeactivate: state=X` with no following
|
||
`[VOICE-] state=X` / dispose, (3) two `[SLOTWRITE] slot=<audioHandle>` and
|
||
`slot=<field_A48>` with no clear afterwards. Then the fix is port-side: drain
|
||
the SFX event queue more often, or (cheaper, D202-family-consistent) a
|
||
bounded self-heal in the double-buffer — if a slot has pointed at a
|
||
non-playing state for > N ms, force-clear it.
|
||
|
||
## D217 — M-88 defect ① root-cause narrowing: NOT the model sidecar; the runtime TLUT-address path (session dispatched to investigate a `tools_pc/d43_*.py` fix; investigation concludes the fix does not belong there)
|
||
|
||
**Dispatch brief for this session** asked for a root-cause + fix of D217
|
||
defect ① (wrong palette bytes arrive for model-DL CI textures) confined to
|
||
`tools_pc/d43_*.py`, working theory: the model-DL/sidecar's embedded
|
||
palette-source pointer is a fixed 4 bytes / 2 entries too low. **That theory
|
||
is refuted** and the fix does not belong in the converter at all — see
|
||
below. Per the brief's own instruction ("if your investigation concludes
|
||
otherwise, stop and write up why instead of editing game code"), no code
|
||
was changed; this is a write-up only.
|
||
|
||
### Why `tools_pc/d43_emit.py` cannot be the site of this bug
|
||
|
||
Traced the full model-texture reference chain from the ROM to the draw call:
|
||
|
||
- A model's texture table (`ModelFileTextures[]`, the "texconfig" block
|
||
`d43_emit.py` emits at `8*NS .. 8*NS+12*NT`) stores each texture as a
|
||
**numeric `TextureID`** (`src/bondtypes.h:836-847`), not an embedded
|
||
pixel/palette blob. `d43_emit.py:508-516` passes this `u32` straight
|
||
through byte-order-swapped (BE→LE) — **the numeric value is never
|
||
remapped or altered**. Verified by reading the emit loop directly; there
|
||
is no code path in `d43_emit.py` that touches texture *content*.
|
||
- `texLoadFromModelFileHeader` (`src/game/image.c:2522-2536`, decomp ground
|
||
truth) resolves each `TextureID < MAX_TEXTURES` via `texLoad()` against
|
||
the **global, per-game (not per-model, not per-level) image table**
|
||
(`Globalimagetable`). That table's BE→LE fixup lives in
|
||
`port/src/gimgfixup.c` (D68) — a `port/src/` runtime file, not a
|
||
`tools_pc/` offline converter. `tools_pc/d43_lutscan.py` (read-only
|
||
analysis script, confirmed by re-reading it end to end) independently
|
||
shows every model texture reference resolves through this same global
|
||
`imagelist.u.csv`-indexed table — there is no per-model or per-level
|
||
texture/palette blob for `G*`/`P*`/`C*` model files to have a "sidecar
|
||
offset" bug in.
|
||
- Actual pixel+palette bytes are decoded **at runtime, once per level load**
|
||
by `texInflateZlib`/`texInflateNonZlib` (`src/game/image.c`). For the
|
||
zlib/CI-native path (`texInflateZlib:169-331`), the palette is decoded
|
||
from the bitstream (`texReadBits`, endian- and pointer-width-agnostic)
|
||
and **appended after the pixel data in the same `tex->data` allocation**
|
||
(lines 320-326), with `tex->unk0a = numcolours - 1` (`image.c:238`)
|
||
recording the palette's position. None of this is touched by any
|
||
`tools_pc/d43_*.py` script — it is pure runtime C, identical instructions
|
||
on both platforms.
|
||
- The actual `G_LOADTLUT`-equivalent commands are **synthesized at runtime**
|
||
by `texWriteLoadToTmemAddr` (`src/game/tex.c:494-554`), which computes a
|
||
hardware-accurate (S,T)-style byte-offset split (`uls`/`ult`) into
|
||
`tex->data` using `tex->unk0a`, and issues them via the *same*
|
||
`gDPSetTextureImage(gdl++, tex->gbiformat, depth, 1, tex->data)` call used
|
||
for the pixel LOADBLOCK moments earlier (`tex.c:503` and `tex.c:519`) —
|
||
there is no separate "model-DL TLUT source pointer" for a converter to
|
||
get wrong; the address is a live pool pointer computed fresh every level
|
||
load, in game code, not ROM-serialized data.
|
||
|
||
Conclusion: **the "4-byte / 2-entry leading pad" theory in the original
|
||
D217 row does not have a mechanism** — nothing in the offline pipeline ever
|
||
places a palette 4 bytes off from where the runtime decoder expects it,
|
||
because the offline pipeline never places a palette at all for these
|
||
textures.
|
||
|
||
### What the runtime probe actually shows (empirical, this session)
|
||
|
||
Built successfully in this worktree (`./build-pc.sh ntsc-final`, clean
|
||
build, `data/` symlinked from the main checkout — see VERIFY below) and
|
||
reproduced the exact byte values quoted in the original D217 row using the
|
||
**existing, unmodified** `GE_TEXDUMP=1` env probe already in
|
||
`port/fast3d/gfx_pc.cpp:1066-1075` (no code changes, read-only env-gated
|
||
diagnostic that ships in the tree):
|
||
|
||
```
|
||
BUNKER1 (-level_09, GE_PCDUMP=200-440:120):
|
||
GE_TEXI[125] fmt=2 siz=0 palfmt=32768 size=8 tile=1x1 pal[0..3]=0001 18c7 18c9 2107
|
||
GE_TEXI[185] fmt=2 siz=0 palfmt=32768 size=8 tile=1x1 pal[0..3]=0001 0800 0840 0842
|
||
|
||
FACILITY (-level_34, GE_PCDUMP=300-400:20):
|
||
GE_TEXI[157] fmt=2 siz=0 palfmt=32768 size=8 tile=1x1 pal[0..3]=1a00 0000 0001 0843
|
||
```
|
||
|
||
`fmt=2 siz=0` = CI4, `tile=1x1` = a flat single-texel fill (matches "grip =
|
||
solid colour block"), `pal[0]=0001` on BUNKER1 (black, matches the row's
|
||
claim exactly) vs `pal[0]=1a00` on FACILITY (matches the row's claimed
|
||
green `0x1A00` exactly, byte-for-byte). This independently confirms the
|
||
symptom is real and reproducible with zero code changes — good news for
|
||
whoever picks up the actual fix.
|
||
|
||
**Where the bug most likely actually lives (not verified further — out of
|
||
this session's scope to touch):** `port/fast3d/gfx_pc.cpp:2202-2237`
|
||
(`gfx_dp_load_tlut`) computes the palette source address as
|
||
`rdp.texture_to_load.addr + pitch*ult + uls` where
|
||
`pitch = rdp.texture_to_load.width + 1`, and `rdp.texture_to_load.width`
|
||
comes from the immediately preceding `gDPSetTextureImage` call. GE's
|
||
`texWriteLoadToTmemAddr` (`src/game/tex.c:503,519`) always issues that
|
||
`SetTextureImage` with a **literal width argument of `1`** regardless of
|
||
the texture's real dimensions — real N64 hardware LOADTLUT addressing uses
|
||
this "width" field as a DRAM line-stride for the S,T→byte-offset
|
||
computation, so fast3d's software model of that computation is only as
|
||
good as its handling of the `width=1` special case for a texture whose
|
||
real pixel-data length (`len`, hence the palette's real starting byte) can
|
||
be much larger than what a `pitch=2` u16-stride implies. This is consistent
|
||
with the finding row's "different bytes per level for the same tile" —
|
||
if this pitch/offset math is wrong, the byte read for a `count=1` TLUT load
|
||
would land in whatever the shared texpool arena happens to hold near
|
||
(but not at) the intended 2-byte palette tail, which is exactly the
|
||
level-dependent "reads adjacent garbage" signature already logged. This is
|
||
a **hypothesis with strong circumstantial support, not a confirmed
|
||
mechanism** — I did not instrument `gfx_dp_load_tlut` further because doing
|
||
so would require editing `port/fast3d/gfx_pc.cpp`, explicitly off-limits
|
||
for this dispatch.
|
||
|
||
### Confidence
|
||
|
||
- **High confidence** the bug is NOT in `tools_pc/d43_*.py` (structural
|
||
proof: the converter never sees texture/palette bytes for model-DL CI
|
||
textures; traced the complete reference chain from ROM to draw call).
|
||
- **Medium confidence** the mechanism is the `gfx_dp_load_tlut` /
|
||
`texWriteLoadToTmemAddr` width=1/pitch interaction described above — this
|
||
is the most concrete remaining hypothesis but was not instrumented or
|
||
bisected this session (would require touching the off-limits file).
|
||
- Re-affirms the M-86 finding that ② (the CI cache-key content-hash fix,
|
||
`fix/d217-model-texture-palette` / `f6fc0399`) is real, separate, and
|
||
unaffected by any of the above.
|
||
|
||
### For whoever picks this up next
|
||
|
||
A follow-up dispatch with `port/fast3d/gfx_pc.cpp` (specifically
|
||
`gfx_dp_load_tlut`) and/or `src/game/tex.c` in scope (the latter under the
|
||
narrow ABI-only exception if a genuine platform difference is found, or
|
||
`#ifdef PORT` in `port/` if the fix is a fast3d-side hardware-model
|
||
correction) is the natural next step. Suggested probe: temporarily log
|
||
`rdp.texture_to_load.width`, `pitch`, `uls`, `ult`, and the resulting `base`
|
||
pointer's offset from `tex->data` inside `gfx_dp_load_tlut` for the
|
||
`tile=1x1`/`count=1` case on both BUNKER1 and FACILITY, and compare against
|
||
what `texWriteLoadToTmemAddr`'s `len`/`tex->unk0a` actually were for that
|
||
texture (a second probe point in `tex.c`, already has a `GE_D85TEX`
|
||
env-gate precedent to extend). If `base` does not land exactly 2 bytes past
|
||
the real end of the decoded pixel data, that confirms the pitch/width=1
|
||
special case as the mechanism.
|
||
|
||
### VERIFY — what was and wasn't completed this session
|
||
|
||
- **Build:** clean (`./build-pc.sh ntsc-final` from MSYS2 MINGW64, this
|
||
worktree, `data/` symlinked read-only from the main checkout since this
|
||
worktree had none — no writes to the main checkout's `data/`).
|
||
- **BUNKER1:** ran `tools_pc/verify.sh bunker1` — PASS, frames=3, no crash.
|
||
(Note: the golden-frame-stem comparison itself reports a pre-existing,
|
||
unrelated harness mismatch — "golden frame stems don't match --dump
|
||
window" — even though the requested and golden windows are numerically
|
||
identical (200-440:120); this looks like a `verify.sh`/`framediff.py`
|
||
stem-formatting bug, not a regression from this session's zero code
|
||
changes. Not investigated further — out of scope, no code was touched
|
||
either way, and `worst_cell=0` with no crash is the relevant signal here.)
|
||
- **FACILITY:** ran the game directly (`GE_PCDUMP=300-400:20`,
|
||
`GE_TEXDUMP=1`) rather than through `verify.sh` (to keep the temp capture
|
||
dir instead of having it auto-deleted) — captured frames 300-400 cleanly,
|
||
no crash, and confirmed the green `pal[0]=1a00` grip palette entry is
|
||
still present (expected — no fix was applied). Did not do a pixel-eyeball
|
||
of the PPM captures beyond confirming they exist; the log-level TLUT
|
||
value match (`1a00` = the documented green) is the decisive signal.
|
||
- No `tools_pc/d43_*.py` file was modified; `git status` in this worktree
|
||
shows only the two docs edits (this section + the D217 table-row pointer)
|
||
plus a local `data` symlink (not tracked/committed) used for the build.
|
||
## D176(a) — M-93: human playtest of PR #43 (debug build) — Cradle default-FOV black sky CONFIRMED FIXED; Statue scroll speed improved but still off; NEW residual split out as D227 (sky renders as two independently-moving halves)
|
||
|
||
**Method:** user's own debug build (`build-linux-dbg`), branch
|
||
`land/d176a-sky-perspective-scroll` checked out. Sampled Statue
|
||
(`-level_22`) and Cradle (`-level_41`), plus a Bunker1 sanity pass.
|
||
|
||
**Confirmed fixed:** Cradle's sky at default FOV is no longer black — matches
|
||
the PR's claim exactly.
|
||
|
||
**Confirmed improved, not fully fixed:** Statue's cloud scaling reads
|
||
noticeably better than before (the PR's perspective-correct-`tc`
|
||
interpolation fix is doing real work), but the user's read is it's **still
|
||
too fast**. Not yet re-measured numerically post-verdict; could be residual
|
||
under-correction in the fix itself, or could be downstream of the D227
|
||
split-sky issue below making the whole thing harder to judge cleanly.
|
||
|
||
**Confirmed still-open, as expected:** at **max FOV**, Cradle's black-skybox
|
||
artifact reappears. This matches the PR body's own disclaimer verbatim
|
||
("the separate FOV-coupled black-sky-past-frustum-edge symptom (D218/D222
|
||
-family) ... unchanged/still open — not attempted here") — not a surprise,
|
||
not a regression, just confirmation the known gap is real and reproducible.
|
||
|
||
**New, not previously documented:** the sky visibly renders as **two
|
||
independently-moving halves** on both Statue and Cradle — user's words,
|
||
"like there's two different sky processes running at once roughly taking
|
||
half the sky each," each half scrolling differently. Split out as its own
|
||
row, **D227**, rather than folded into D176(a), since it's a distinct visual
|
||
signature (a seam + differential motion between two regions) rather than a
|
||
scroll-rate or color/black issue. Not established whether this pre-dates
|
||
PR #43 (the fix could have just made it easier to notice once the gross
|
||
scroll-speed error calmed down) or whether it's a widescreen/aspect-ratio
|
||
rendering artifact rather than a logic bug — user explicitly flagged the
|
||
latter as plausible and unconfirmed pending a side-by-side against real
|
||
N64 output at the same camera angle.
|
||
|
||
**Sanity pass:** Bunker1 (non-sky level) — clean, no regression, matches
|
||
the PR's own headless `verify.sh bunker1` PASS claim.
|
||
|
||
**Disposition:** PR #43's fix is real and should still merge — it closes
|
||
the confirmed black-sky sub-symptom on Cradle and measurably improves (if
|
||
not fully fixes) the Statue scroll-rate symptom, with no regression
|
||
elsewhere. D176(a) stays open for the residual scroll-speed-still-too-fast
|
||
report and defect 2 (horizon-band tessellation, pre-existing, unchanged).
|
||
D227 is a new, separate open item pending investigation.
|
||
|
||
## D227 — M-94: visual confirmation + a concrete mechanistic hypothesis for the "two sky processes" seam — per-primitive `wScale` quantization in D176(a)'s fix, not yet confirmed or fixed
|
||
|
||
**Method.** Headless capture, `-level_22` (Statue), `GE_PCDUMP` at frames
|
||
300/330/345/350/355/360, `land/d176a-sky-perspective-scroll` built and run
|
||
directly (not through `verify.sh`, to keep the PPM frames instead of having
|
||
them auto-deleted). Converted to PNG and cropped to the sky band with PIL for
|
||
close inspection.
|
||
|
||
**What the capture shows.** Frame 345's sky band has a clearly visible
|
||
crease: the cloud streak pattern on the right portion of the frame breaks
|
||
continuity from the pattern on the left, with a faint diagonal artifact line
|
||
right at the boundary — this is a single static frame, so it rules out "just
|
||
normal camera pan across a busy texture" as the explanation for at least this
|
||
instance. Screenshot: `docs/img/bugs/d227-sky-seam-statue.png`.
|
||
|
||
**Hypothesis.** D176(a)'s fix (`skyPortRenderPoly`, `src/game/sky.c`)
|
||
computes a per-call `wScale = maxAbsW / 30000` from the vertices passed to
|
||
*that one call*. But `skyRenderTri`/`skyRenderFull` issue **up to 5 separate
|
||
`skyPortRenderPoly` calls per frame**, one per triangle in a 5-vertex sky fan
|
||
(`sky.c:900-920` and `:1372-1393`) — so each triangle in the fan can pick a
|
||
different `wScale` depending on its own 3 vertices' camera-space `w`
|
||
(`unk0c`).
|
||
|
||
The position math itself is exact for any `wScale > 0` (re-derived
|
||
algebraically: `clip.x = ob[0]·wScale = (ndcX·w/wScale)·wScale = ndcX·w`,
|
||
independent of `wScale` — this was already verified when the D176a fix was
|
||
first reviewed this session). So this is not a bug in the fix's core
|
||
logic. The likely mechanism is **quantization**: `Vtx.ob[]` is `s16`, so
|
||
`ndcX·wv` (where `wv = w/wScale`) rounds to a 16-bit integer *at a
|
||
granularity that depends on `wScale`*. Two triangles sharing an edge each
|
||
call `skyPortRenderPoly` independently, each picking its own `wScale` from
|
||
its own 3 vertices — so a vertex that is logically shared between two
|
||
triangles can round to two slightly different quantized `ob[]` values
|
||
depending on which triangle's call computed it, cracking the shared edge by
|
||
a small but visible amount. That reads exactly like "two regions, different
|
||
pattern" without requiring two draw calls, two cloud layers, or any
|
||
screen-half-specific logic — a single quantization granularity mismatch
|
||
between neighboring primitives is sufficient.
|
||
|
||
**Not yet confirmed.** This explains the static crease. It does **not** yet
|
||
confirm the user's "each half moves at a different rate" observation — that
|
||
would require isolating camera motion from sky-texture scroll (a
|
||
static-camera, sim-time-only comparison), which this session's captures
|
||
didn't attempt. The user's original alternate theory (a widescreen/aspect-
|
||
ratio rendering artifact, unrelated to game logic) is also not ruled out —
|
||
it could coexist with or be independent of the quantization crease.
|
||
|
||
**Fix direction, if this hypothesis holds:** compute one `wScale` shared
|
||
across the *entire* sky fan for the frame (max `unk0c` over every vertex in
|
||
every poly about to be drawn, not just the 3 vertices of one
|
||
`skyPortRenderPoly` call), then pass it into each call instead of
|
||
recomputing locally. Every triangle's vertices would then quantize against
|
||
the same scale, and shared edges should match exactly. Not attempted this
|
||
session — flagging the direction for whoever picks this up, along with the
|
||
static-camera test needed to confirm before spending a build cycle on it.
|
||
|
||
**Housekeeping:** capture files (`ppm/`, a `ppm2/` scratch dir, and the run
|
||
logs) were cleaned up after pulling the one evidence screenshot; nothing
|
||
else from this session's captures is left in the tree.
|
||
|
||
**M-94 addendum — static-camera confirmation, same session.** `-level_22`'s
|
||
fixed level-title cutscene ("Statue Park, St. Petersburg") holds a genuinely
|
||
static camera (verified: the foreground statue/pedestal region differs by
|
||
only mean ~3.0/255 between frames 30 and 90 — lighting/AA noise, no
|
||
detectable pan/zoom) while the sky region above it differs by mean ~15.9/255
|
||
over the same two frames. **This confirms the sky animates over time,
|
||
independent of camera motion** — it is not merely a static geometric crease
|
||
that only *looks* like motion under a moving camera. That said, the diff
|
||
magnitude was non-monotonic across the sampled frames (30→45 measured larger
|
||
than 30→90), which doesn't cleanly fit a simple constant-rate linear scroll
|
||
either — worth another look with a denser, longer static-camera sample
|
||
before concluding *how* it's animating. Does not distinguish between "one
|
||
sky animating uniformly past a static quantization seam" (this session's
|
||
leading hypothesis) and "two separately-animating regions" (the user's
|
||
original read) — either is still consistent with a real per-frame diff in
|
||
the sky region. Capture files cleaned up after taking these measurements;
|
||
nothing added to `docs/img/bugs/` beyond the one screenshot already
|
||
committed.
|
||
|
||
## D229 — M-101: Frigate/IsWater-level water renders green and pulses blue<->green. Pre-existing (reproduces on main), mechanism partly identified, NOT root-caused
|
||
|
||
**Reported (user playtest, M-101):** on Frigate the water "turns green
|
||
randomly and goes between looking right and glitching green", later refined to
|
||
**"it changes from blue to green on an interval like pulsating"** and, at one
|
||
point under a bad intermediate build, "only green". Also reported in the same
|
||
message: green weapon textures on Frigate and Surface 2 — **that half was a
|
||
stale-branch artifact, not a bug** (see below).
|
||
|
||
**The gun half: not a bug.** `land/d176a-sky-perspective-scroll` was 2 commits
|
||
behind `main`, and those 2 commits were exactly the D217 green-texture fixes
|
||
(`7278baad` = #47 texpool 8-byte alignment, `f15a12b9` = #49 CI texture cache
|
||
keyed on palette content). The user was play-testing a branch build that
|
||
predated both. Merged main in (`f8822805`); user confirms **"gun is good on
|
||
frigate"**. No new defect. Worth remembering as a process point: a D217-family
|
||
symptom report against a feature branch should check branch-vs-main first.
|
||
|
||
**Pre-existing, and not caused by the D227 work — proven by A/B.** Built plain
|
||
`main` (none of the D227 changes) and captured `-level_26` frame 340: the
|
||
water is the **same green**. The only difference is tiling density — main
|
||
shows it 32x over-tiled (the D227/M-100b unit bug), the branch shows it
|
||
smooth. Green in both. So D229 is independent of D227.
|
||
|
||
**Mechanism, as far as it is established.** The water quad is `sky.c`'s
|
||
`IsWater` block (`skyRender` block1; Frigate confirmed `IsWater=1` via
|
||
`GE_D176`, which also answers M-99's ranked item 1 — that path IS textured and
|
||
does go through `skyPortRenderPoly`). Its draw state comes from
|
||
`sub_GAME_7F09343C` (`src/game/unk_092E50.c:278`):
|
||
|
||
- tile 0 and tile 1 are both `G_IM_FMT_RGBA`/`G_IM_SIZ_16b`, `line=4`,
|
||
**both at TMEM 0** — i.e. the same texture — with `masks = maskt = 5`
|
||
(32-texel wrap).
|
||
- tile 1 is offset: `gDPSetTileSize(1, uls=90, ult=150, lrs=0, lrt=0)`, so it
|
||
samples the same image shifted by 22.5 / 37.5 texels.
|
||
- 2-cycle combine `gDPSetCombineLERP(TEXEL1, TEXEL0, PRIM_LOD_FRAC, TEXEL0,
|
||
...)` with cycle 2 `= COMBINED * SHADE`, and the LOD fraction is
|
||
**`gDPSetPrimColor(..., sinf(flt_CODE_bss_80079E88) * 127.0f + 128.0f, ...)`
|
||
— an animated sine.**
|
||
|
||
**That sine is the "pulsating".** The effect cross-fades TEXEL0 and TEXEL1 over
|
||
time; the user seeing blue<->green on an interval means **one texunit resolves
|
||
correct (blue) and the other resolves green**, with the sine fading between
|
||
them. This is the single most useful clue in the report and it came from the
|
||
user, not from a capture — a static frame cannot see it (frame 340 samples
|
||
green on *all three* builds tested, because the sine phase happens to land
|
||
there, which is why single-frame diffs were useless here).
|
||
|
||
**Ruled out:** the shade path. Water vertex colour is
|
||
`skyChooseWaterVtxColour` = `env->RGB + env->WaterRGB * (1 - c/255) * (1 - a)`;
|
||
measured on Frigate `env RGB = 16,48,96` and `WaterRGB = 255,255,150`, so the
|
||
shade ranges from dark blue to pale yellow and **can never be green**. Measured
|
||
water pixels are ~`(5, 70..175, 20..37)` — R and B crushed, so the green comes
|
||
from the texel side, not the shade.
|
||
|
||
**Not yet established:** why one texunit decodes green. Both tiles point at the
|
||
same TMEM with the same format, so a plain "wrong texture" explanation does not
|
||
fit; the difference between them is only the uls/ult offset and whatever
|
||
fast3d does with tile 1's inverted window. One concrete oddity found and *not*
|
||
yet shown to matter: `uls/ult/lrs/lrt` are `uint16_t` and
|
||
`gfx_dp_set_tile_size` computes `width = ((lrs - uls) >> 2) + 1`, so tile 1
|
||
gets `((0 - 90) >> 2) + 1 = -22` stored into a `uint16_t` = **65514** (height
|
||
likewise 65500). `tex_width2` goes negative by the same arithmetic, which as it
|
||
happens makes the `g_wrap_fix` sub-tile-window branch skip (`tex_width2 > 0`
|
||
fails) and fall through to the mask-based 32-texel wrap — which is the correct
|
||
period — so this may be harmless. It has not been proven either way.
|
||
|
||
**FOV context (M-101):** the user's playtest ran with **FOV turned up**
|
||
(`Video.FovScale`, PR #36's slider, range 50-150). Every headless capture in
|
||
this entry ran at the ini's `FovScale = 100`, i.e. **default**, and still
|
||
reproduces the green — so raised FOV is *not required* to trigger D229. Whether
|
||
it worsens it is untested (deliberately: the config file was open in the user's
|
||
editor and not worth racing). Worth checking when this is picked up, because
|
||
FOV-coupled sky/frustum coverage is a known separate symptom family
|
||
(D218/D222), and the black bands visible at the edges of the Frigate captures
|
||
are likely that rather than D229.
|
||
|
||
**Next steps:** dump what fast3d actually decodes for each texunit on this draw
|
||
(the existing `GE_D172` multitex probe does not fire here — it filters on
|
||
`tile1.tmem != tile0.tmem`, and these are equal, so it needs widening);
|
||
capture a *sequence* across the sine period rather than one frame, since the
|
||
defect is temporal; and check `skywaterimages[WaterImageId=2]`'s real format
|
||
against the `RGBA/16b` that `sub_GAME_7F09343C` re-declares.
|
||
|
||
**D227 deliberately decoupled from this.** The M-100b safety valve now takes an
|
||
`allowShift` argument and the IsWater quad passes `FALSE`, leaving that path
|
||
byte-identical to its pre-D227 behaviour. Reason: the water quad is a
|
||
two-texunit draw sharing one set of vertex tc, so a tc rescale must be matched
|
||
by a tile shift on *both* tiles — and shifting both makes them sample
|
||
identically, which flattens the very cross-fade the effect is built on. That
|
||
was observed live: an intermediate build that shifted both tiles turned the
|
||
water from pulsating into **constantly green**. Neither variant is right, so
|
||
the water quad keeps plain 1:1 tc and tolerates the overflow on its horizon
|
||
vertex exactly as before. D227's fix stands on its own for the non-water
|
||
levels; D229 is a separate defect needing its own fix.
|
||
|
||
**ROOT CAUSE FOUND AND FIXED (port-layer, `port/fast3d/gfx_pc.cpp`).** The water
|
||
image (`skywaterimages[2]` = texnum 1509, `IMAGE_WATER_BLUE`, 32×32, level 6) is a
|
||
ZLIB+CI8 image: the texture pool holds raw 8-bit palette indices (verified:
|
||
the dumped pool block is 1400 bytes, every byte ≤ 23 — exactly the 24-colour
|
||
blue palette's index range; the full mipmap chain 32²+16²+8²+4²+2² plus row
|
||
padding). `texSelect` (`othermodemicrocode.c`, "Perfect Dark texSelect") loads it
|
||
with `gDPSetTextureImage(CI, 16b)` + `gDPLoadBlock` — the 16b "size" is only the
|
||
fast3d 4KB-per-block convention (lrs counts 16-bit units: 700 × 2 = 1400 bytes of
|
||
8-bit indices). On N64, GE's custom RSP ucode expands the indices through the
|
||
TLUT into real 16-bit pixels in TMEM at load time — which is why
|
||
`sub_GAME_7F09343C` can then re-declare TMEM slot 0 as RGBA/16b and sample blue.
|
||
fast3d has no such expansion: `import_texture_rgba16` read pairs of index bytes
|
||
as 16-bit texels, where g = 2·idx mod 32 dominates and r/b ≤ 2 → the green
|
||
mottle. The "pulsing" is the pre-existing `sinf()`-driven `PRIM_LOD_FRAC`
|
||
cross-fade between the two tiles (both mis-decoded, at different UV offsets),
|
||
not a second bug.
|
||
|
||
**Fix:** track the source format of each TMEM load (`texture_to_load.fmt` was
|
||
previously dropped; now recorded into `LoadedTexture.src_fmt` by
|
||
gfx_dp_load_block/gfx_dp_load_tile) and, in `import_texture`, when a tile is
|
||
declared RGBA/16b but the slot's last load came from a CI source, route the
|
||
import through `import_texture_ci8` (palette expansion via `rdp.palette`) — the
|
||
port-layer equivalent of the ucode's expand-at-load. Fires only on the genuine
|
||
format disagreement; real RGBA16 textures are untouched. Verified live: Frigate
|
||
water renders blue and the user confirms "looks better". Closure still wants the
|
||
standard verification ritual (sweep + by-eye on Surface 2, the other IsWater
|
||
level).
|
||
|
||
## D227 — M-100b: FIXED. The real cause is a unit error (`unk20`/`unk24` are S10.5, not texels); the s16 overflow was its symptom, and so are the over-tiling and the "scrolls too fast" complaint
|
||
|
||
**This supersedes the fix design in M-100 below.** M-100's measurement and its
|
||
overflow diagnosis were right; its *explanation* of why the numbers were so
|
||
large was not. Neither tessellation nor the tile shift turned out to be needed.
|
||
|
||
**The unit bug.** M-82 ("D176(a) Defect 1") read `unk20`/`unk24` as texel
|
||
counts and therefore baked `tc` as `(S - fold) * 32` to convert texels into
|
||
`tc`'s S10.5 units. They are not texel counts. `skyRender` sets them as
|
||
`worldpos * 0.1f` (the `sp4b4[].unk0c`/`unk10` assignments) and the N64 path
|
||
hands them to the RDP's coefficient block unscaled (`skyRenderTri`:
|
||
`S * unk34 * sp368`), so they are **already in the RDP's native S10.5
|
||
1/32-texel units**. The stray `* 32` made every sky texture coordinate 32x too
|
||
large, which produced three symptoms tracked until now as separate bugs:
|
||
|
||
1. **D227 itself.** Spans of ~30,000 *texels* against an `s16` `tc` holding
|
||
+/-1024 -> 7 of 8 coordinates overflowed and wrapped -> starburst / "two
|
||
sky processes".
|
||
2. **Over-tiling** — ~466 repeats of the 64-texel cloud across one quad
|
||
instead of ~15.
|
||
3. **"The N64 sky is nearly static, ours races"** (M-96), measured at
|
||
~7 px/tick in M-98. `g_SkyCloudOffset` advances 1 unit/tick, which is
|
||
1/32 texel — not 1 texel. **The M-96 scroll complaint and D227 are the same
|
||
bug**, which is why M-98's re-derivation of the tick path kept coming back
|
||
clean: the accumulator was never wrong, its unit was.
|
||
|
||
**The fix:** `tc = (S - fold)`, 1:1, with the fold rebasing to a whole tile
|
||
period (`SKY_TC_WRAP` = 64 texels x 32 = 2048 units). The ~30,000-unit span
|
||
then fits an `s16` unaided — which is also why the original N64 code needs no
|
||
tessellation here: it never had a range problem to solve.
|
||
|
||
**How the intermediate wrong turn read on screen, for the record.** Building
|
||
the tile-shift version first (correct overflow handling, still-wrong units)
|
||
removed the starburst and the diagonal split completely but left the sky a
|
||
dense fine moire — the *intended* mapping at 32x the intended scale. That is
|
||
what identified the unit error; the user independently flagged the same thing
|
||
on sight ("background looks like it tiles too small... sky i mean").
|
||
|
||
**Verified** (`-level_41` Cradle frame 350, the M-98 starburst repro):
|
||
`docs/img/bugs/d227-cradle-fixed-m100.png` — soft cloud bands, correct scale,
|
||
correct perspective compression toward the horizon, no starburst, no diagonal
|
||
split, no moire; matches the character of the emulator reference. `GE_D227V`
|
||
confirms `kS = kT = 0` (no shift needed) and every `tc` in range, max 31,513
|
||
of 32,767. `-level_22` Statue frame 345: clean horizon cloud band, no seam.
|
||
`tools_pc/verify.sh bunker1`: **PASS**, `worst_cell = 10.32` (the known
|
||
stale-golden-baseline number, unaffected by sky work). Surface1 frame 345 has
|
||
almost no sky in frame at that camera, so it is not evidence either way — the
|
||
user's own play-test screenshot remains the Surface evidence.
|
||
|
||
**What was kept, and why.** M-95's shared `wScale` and M-98's shared fold both
|
||
stay — they were real bugs, and per-fan consistency is still what keeps a
|
||
fan's shared vertices bit-identical between its primitives. The tile-shift
|
||
machinery (`skyPortPickShift` / `skyPortCaptureTile` / `skyPortEmitTileShift`)
|
||
also stays as a **safety valve**: the observed span is 31,513 against a 32,767
|
||
limit, only ~4% of headroom, so a camera that exceeds it is not far-fetched,
|
||
and the mechanism degrades gracefully instead of overflowing. It is not
|
||
untested code — the intermediate build above ran it at `k = 5` and it worked
|
||
correctly (coherent mapping; the wrong scale there was the unrelated unit
|
||
bug). It uses the RDP's own tile `shifts`/`shiftt`, which `port/fast3d`
|
||
already implements (`gfx_pc.cpp` ~1783), so it needs no fast3d change.
|
||
`skyPortCaptureTile` replays the exact `G_SETTILE` that `texSelect` emitted
|
||
with only the shift fields patched, rather than rebuilding it, so it cannot
|
||
drift from `texSelect`'s format/line/mask derivation.
|
||
|
||
**Still owed:** a human play-test. Every previous D227 "fix" (M-95, M-98)
|
||
passed a static headless check and then failed live, so this does not count as
|
||
closed until it is played — though unlike those two, this one has a mechanism
|
||
that explains every reported symptom, and the user has eyeballed the fixed
|
||
frame. Also worth re-measuring the scroll rate against M-98's ~7 px/tick
|
||
baseline: it should now be ~0.2 px/tick.
|
||
|
||
**Human play-test (from the §F row, appended 2026-09-15 ctx-opt; post-M-100b):** confirmed by the user on
|
||
the previously-failing levels — closes the "still owed" above.
|
||
|
||
## D227 — M-100: ROOT CAUSE FOUND AND PROVEN — `s16` texture-coordinate overflow. Not a crack, not view-dependence, not a quantization mismatch. Fix not yet implemented (design decision open).
|
||
|
||
**Status: root cause proven by direct measurement + a confirming diagnostic
|
||
build. D227, D176(a) "Defect 2", and the M-98 "Cradle tower starburst" are
|
||
all the same defect.** M-95 (shared `wScale`) and M-98 (shared S/T fold) each
|
||
fixed a real but *second-order* bug; neither could have fixed this one.
|
||
|
||
**The unblocking evidence was a human screenshot pair** (user supplied, same
|
||
level / weapon / ammo count / near-identical framing, Surface):
|
||
`docs/img/bugs/d227-surface-emulator-ref.png` (1964 emulator — soft, blobby,
|
||
near-isotropic clouds) vs `docs/img/bugs/d227-surface-starburst-ours.png`
|
||
(our build — clouds smeared into long streaks radiating from a point, with
|
||
sharp *radial* boundaries). A radial starburst is not a crack between two
|
||
mis-quantized vertices, which is what M-94→M-98 had been chasing.
|
||
|
||
**M-99's "blocked, needs a human co-session" conclusion was wrong, and cost a
|
||
session.** The defect reproduces headlessly and *statically* — M-98 had
|
||
already captured it at Cradle frame 350
|
||
(`docs/img/bugs/d176a-cradle-tower-starburst.png`) and even confirmed frames
|
||
350/380 pixel-identical, but filed it as "pre-existing Defect 2, not D227."
|
||
That misclassification is what made D227 look view-dependent-and-unreachable.
|
||
Note the Cradle capture matches the *original* D227 report almost word for
|
||
word: left half starburst, right half normal horizontal streaks, hard
|
||
boundary between them — "each half of the screen shows a different sky
|
||
pattern."
|
||
|
||
**Measurement (new `GE_D227V` probe, `skyPortRenderPoly`, env-gated in the
|
||
same style as `GE_D176`).** `-level_41`, the frame-350 camera. The sky is one
|
||
`nverts=4` quad, corners TL/TR/BL/BR:
|
||
|
||
| i | screen x,y | w (`unk0c`) | S (`unk20`) | T (`unk24`) |
|
||
|---|---|---|---|---|
|
||
| 0 | 0.0, 10.0 | 6457.0 | 236.7 | 489.3 |
|
||
| 1 | 319.8, 10.0 | 6457.0 | -658.1 | -122.8 |
|
||
| 2 | 0.0, 212.9 | 215228.8 | 1422.2 | 29806.0 |
|
||
| 3 | 319.8, 212.9 | 215277.7 | -28408.5 | 9406.1 |
|
||
|
||
All four w are **positive** — no vertex behind the camera, no sign change, so
|
||
no projective pole (a hypothesis worth ruling out; it is ruled out).
|
||
|
||
The span across this single primitive is **29,830 texels in S and 29,929 in
|
||
T**. `Vtx.tc` is S10.5 (1/32-texel units) in an `s16`, so one primitive can
|
||
represent at most **±1024 texels**. The fold (`floor(min/64)*64`) rebases the
|
||
minimum vertex but cannot compress a span 29x larger than the entire
|
||
representable range. Computing `(S - foldS) * 32` for these four vertices
|
||
(`foldS = -28416`, `foldT = -128`):
|
||
|
||
| i | tc S exact | → `s16` | tc T exact | → `s16` |
|
||
|---|---|---|---|---|
|
||
| 0 | 916,886 | **-618** | 19,754 | 19,753 ok |
|
||
| 1 | 888,253 | **-29,252** | 166 | 166 ok |
|
||
| 2 | 954,822 | **-28,218** | 957,888 | **-25,152** |
|
||
| 3 | 240 ok | 240 | 305,091 | **-22,589** |
|
||
|
||
**Seven of the eight texture coordinates overflow and wrap to essentially
|
||
arbitrary values.** The rasteriser then interpolates between wrapped
|
||
garbage — which is exactly a starburst.
|
||
|
||
**Why it reads as "two halves that differ, split by a line."** The quad is
|
||
emitted as `gSP2Triangles(0, 1, 3, 0, 3, 2)` — split on the **0-3 diagonal**.
|
||
Vertices 0 and 3 are precisely the two that each keep *one* coordinate in
|
||
range, so the two triangles rasterise different mixes of wrapped values,
|
||
divided by that diagonal. This also explains the reported view-dependence
|
||
without any view-dependent mechanism: S/T are functions of view, so the
|
||
wrap point moves as you look around, and the garbage pattern changes with it.
|
||
|
||
**Confirming diagnostic (decisive).** Rebuilt with `tc` baked at 1-texel
|
||
instead of 1/32-texel granularity (32x the range, deliberately *wrong*
|
||
texture scale, nothing else changed), same `-level_41` frame 350:
|
||
`docs/img/bugs/d227-cradle-tcscale-diagnostic.png`. The starburst and the
|
||
two-halves split are **completely gone**; the sky is smooth and coherent and
|
||
structurally matches the emulator reference. The texture is over-zoomed, as
|
||
expected for a deliberately wrong scale. **s16 tc overflow is the whole
|
||
mechanism** — there is no residual second defect hiding behind it.
|
||
|
||
**Consequences for the existing record:**
|
||
- D176(a) "Defect 2" (adaptive tessellation of wide-span sky triangles) is
|
||
not a separate cosmetic nicety — it *is* D227, and it is the dominant sky
|
||
defect on every affected level.
|
||
- The `skyPortRenderPoly` code comment predicted this exactly ("Triangles
|
||
whose S/T span exceeds ~1000 texels ... still need Defect-2's finer
|
||
tessellation") but the severity was underestimated: it is not a
|
||
horizon-only artifact, it corrupts the entire sky quad.
|
||
- M-95's and M-98's shared-`wScale`/shared-fold fixes remain correct and
|
||
should stay: once spans are small enough for tc to be in range, per-fan
|
||
consistency is exactly what keeps sub-primitive boundaries seamless.
|
||
- The M-96 "sky scrolls extremely fast" complaint and M-98's measured
|
||
~7 px/tick are plausibly the *same* bug (wrapped tc makes the apparent
|
||
pattern move at a rate unrelated to the true 1 texel/tick scroll), but
|
||
that is not yet confirmed — recheck after the fix.
|
||
|
||
**Fix not implemented — one design decision is genuinely open.** The fix is
|
||
to subdivide until each sub-primitive's S/T span is under ~960 texels, with
|
||
per-sub-primitive folds (folds differing by multiples of 64 texels are
|
||
seamless under `GL_REPEAT` at tile width 64, which is what makes subdivision
|
||
safe). Two things are already worked out:
|
||
|
||
- **Exact subdivision math.** Interpolate `(1/w, S/w, T/w, colour/w)`
|
||
bilinearly in the quad's `(u,v)` and divide through — exact for any quad,
|
||
because affine-in-screen functions commute with affine combinations whose
|
||
weights sum to 1 (true of bilinear weights). A triangle is handled as a
|
||
degenerate quad with the `v=1` edge collapsed, also exact.
|
||
- **Where to place the cuts.** Along an edge, `1/w` is affine in screen, so
|
||
`S = (a + b·(1/w))/(1/w) = b + a·w` — **S is linear in w**. Subdividing
|
||
*uniformly in w* therefore gives uniform S/T steps, and the cut count is
|
||
just `ceil(span / threshold)`. Uniform-in-*screen* subdivision is badly
|
||
wrong here: on this quad's right edge, half the screen distance carries
|
||
807 texels and the other half carries 26,943.
|
||
|
||
**The open decision is the vertex budget.** `dynAllocateVertices` is an
|
||
unchecked bump allocator over a per-frame pool of `-mvtx50` = 50 KB (3,200
|
||
`Vtx`), shared with the whole scene; overrunning it corrupts memory silently.
|
||
A pure-tessellation fix needs ~32 divisions per axis at this camera, i.e.
|
||
~1,200-1,300 verts (~20 KB, ~40% of the pool) even with vertex sharing and
|
||
2-row `gSPVertex` batches (fast3d's `MAX_VERTICES` is 128, so batching is not
|
||
the constraint). Options:
|
||
- **A. Pure tessellation.** No port/fast3d change; costs ~40% of the frame's
|
||
vertex pool on sky alone. Self-limiting via `dynGetFreeVtx()` with
|
||
graceful degradation (slightly-over-threshold cells in extreme views).
|
||
- **B. Widen the texcoord transport in `port/fast3d/`** so tc carries more
|
||
range, then only ~4x4 subdivision is needed (~100 verts). Cheap at
|
||
runtime; touches the RSP emulation, which is port-layer and therefore
|
||
permitted, but is a wider blast radius.
|
||
- **C. Hybrid** — a modest fast3d widening plus modest tessellation.
|
||
- **D. RDP tile shift (found last, and it dominates A/B/C — recommended).**
|
||
The RDP already has a native mechanism for trading texcoord precision
|
||
against range: the tile descriptor's `shifts`/`shiftt`. **`port/fast3d/`
|
||
implements it fully, including the left-shift case** (`gfx_pc.cpp` ~1783:
|
||
`shifts <= 10` divides, `shifts >= 11` multiplies by `1 << (16 - shifts)`).
|
||
So emit tc at `32 / 2^k` units instead of 32 and set `shifts = 16 - k` to
|
||
multiply it back — **2^k more range, no tessellation, no fast3d change, no
|
||
vertex-budget cost, and an authentic RDP idiom rather than a port-only
|
||
hack.** At `k = 5` tc is 1 texel per unit with a range of ±32,767 texels,
|
||
which covers the measured 29,830-texel span outright — that is exactly the
|
||
M-100 diagnostic build that already demonstrably removes the artifact, with
|
||
the scale corrected by the tile shift instead of left wrong. Precision cost
|
||
is a ≤0.5-texel rounding *at the vertices only* (≤0.8% of the 64-texel
|
||
tile); interpolation between them is float in the GPU. Implementation care
|
||
needed in two places: emitting the shift on the sky's tile without
|
||
disturbing other draws (an extra `gDPSetTile` after the existing
|
||
`texSelect`), and choosing `k` per draw from the measured span (`k = 0`
|
||
when the span is already small). A light 2x2 subdivision can still be added
|
||
on top for spans beyond ±32,767 texels if any camera produces them.
|
||
|
||
|
||
**Human playtest (same session, after M-98's fold-sharing fix was pushed):**
|
||
the seam is still visible on Statue, and — new data point, sharper than
|
||
anything in M-93/M-96 — **it visibly changes as the mouse/player view
|
||
moves.** Also reported: "still pretty bad" on Cradle, and Surface has the
|
||
same issue. M-98's fix was real (verified via a same-frame diff) but is
|
||
evidently not the dominant mechanism live play is hitting — the same
|
||
pattern as M-95's wScale fix before it.
|
||
|
||
**Ruled out this pass:** the cross-quad water/cloud texture-scale mismatch
|
||
theory (block1's below-horizon quad uses raw `unk0c`, block2's cloud quad
|
||
uses `unk0c*0.1f`, and each computes its own independent `wScale`/fold via
|
||
separate `skyPortBeginFan` calls — a plausible source of a horizon-tracking
|
||
seam). Checked via the existing `GE_D176` env probe: **both Statue and
|
||
Cradle report `IsWater=0`.** For `IsWater==0`, block1 is a flat
|
||
`gDPFillRectangle` (`sky.c` ~874-897) — no texture, no scroll, can't be the
|
||
source of a texture-pattern seam. This theory is **not ruled out in
|
||
general** — only on the two `IsWater==0` levels actually tested. It's
|
||
untested on any `IsWater==1` level (Frigate/Runway/Depot/Surface1/2 are
|
||
candidates — check `GE_D176=1` first, cheap).
|
||
|
||
**Blocked on: headless camera-pan repro doesn't work.** Tried reproducing
|
||
"changes with view" via `GE_INPUTSCRIPT` — both sustained analog-stick
|
||
(`SRIGHT`, held 90 ticks) and repeated C-button pulses (`CRIGHT`/`CUP`)
|
||
produced **zero visible camera rotation** across captured frames (identical
|
||
look direction/HUD framing throughout). The real in-game mouselook almost
|
||
certainly injects yaw/pitch through a path `GE_INPUTSCRIPT` doesn't reach —
|
||
it only emulates the N64 controller (buttons + one fake stick), and
|
||
turning during solo FPS play is evidently driven by something else
|
||
entirely (real SDL mouse deltas, likely, given D194's mouselook work).
|
||
**Any view-angle-dependent visual bug needs a human playtest to chase until
|
||
this gets a debug hook** (e.g. an env-gated `GE_FORCEVIEW=yaw,pitch` set
|
||
once per frame, or finding the actual mouse-delta injection point in
|
||
`port/src/input.c` and driving it directly instead of going through the
|
||
N64-controller emulation layer). Don't re-attempt `GE_INPUTSCRIPT`
|
||
stick/button tokens for camera turn next time — confirmed dead end.
|
||
|
||
**Ranked candidates for next session, in order of promise:**
|
||
1. Cross-block independent-fan state on an `IsWater==1` level (see above,
|
||
not yet ruled out) — cheapest to check, `GE_D176=1` first.
|
||
2. Frame-to-frame popping at the S/T fold boundary (not a same-frame
|
||
spatial crack) — `skyPortRenderPoly`'s fold recomputes every frame from
|
||
the scrolling min-S, jumping by 64 texels whenever it crosses that
|
||
boundary; never independently verified that fast3d's real texture wrap
|
||
makes that jump invisible for this combiner. **Doesn't need view
|
||
rotation to test** — a static-camera `step:1` capture bracketing a
|
||
known fold-crossing tick (from M-98's ~7px/tick rate) would show it
|
||
directly. Best headlessly-testable option if no human is available.
|
||
3. The clip-shape switch (`skyRender`'s 16-case corner-mask switch) handing
|
||
block2 a differently-*shaped* fan frame to frame as screen corners
|
||
cross the horizon, not just moved vertices — could read as "the pattern
|
||
changes as you look around" without a same-frame crack. Not
|
||
investigated.
|
||
|
||
**Concretely blocked on:** a screenshot or precise repro detail (level,
|
||
look angle/pitch, screen location) from a human. Full narrative + the
|
||
environment gotchas hit this pass: HANDOFF.md "M-99" (local, this
|
||
session — condense into this row if HANDOFF gets rotated before someone
|
||
picks this up).
|
||
|
||
## D176(a) — M-98: Cradle "tower starburst" — concrete screenshot of the already-known, still-open Defect 2 (horizon-band tessellation)
|
||
|
||
**Context:** checking the Cradle side of PR #43 (never done in M-97, per its
|
||
own "not done this session" note) for D227 regressions from the fold-sharing
|
||
fix below. It wasn't regressed, but turned up a much more visually dramatic
|
||
pre-existing artifact than anything previously screenshotted for this level.
|
||
|
||
**What it looks like:** near Cradle's spawn, looking up the suspension
|
||
tower/catwalk, roughly the left half of the sky renders as a sharp
|
||
"starburst" of radiating streaks converging toward a point near the top of
|
||
frame, directly adjacent to normal horizontal cloud streaks on the right
|
||
half. Screenshot: `docs/img/bugs/d176a-cradle-tower-starburst.png`
|
||
(`-level_41` frame 350).
|
||
|
||
**Confirmed pre-existing, not caused by this session's work:** rebuilt with
|
||
`src/game/sky.c` reverted to its pre-M-98 (and pre-M-95, since the revert
|
||
target predates both) state, same camera/frame — pixel-identical to the
|
||
post-fix capture. Also confirmed **static**, not the D227 "two halves move
|
||
at different rates" symptom: frame 350 and frame 380 (30 ticks apart, same
|
||
static camera) are pixel-identical.
|
||
|
||
**This is Defect 2, not a new bug.** The D176(a) finding row has carried
|
||
"Defect 2 (horizon-band coverage/adaptive tessellation) ... unchanged, still
|
||
open" since M-90, and `skyPortRenderPoly`'s own code comment already
|
||
predicts exactly this shape of failure: "Triangles whose S/T span exceeds
|
||
~1000 texels (only those straddling the horizon) still need Defect-2's
|
||
finer tessellation." Looking almost straight up the tower is precisely the
|
||
camera angle that produces one sky fan triangle spanning from near-camera
|
||
to horizon-grazing texel coordinates — the >1000-texel-span case the fold
|
||
logic already flags as unhandled. The perspective-correct interpolation
|
||
(M-90) gets the *shape* of the gradient right end-to-end, but with only 1-3
|
||
triangles per fan and no subdivision, a single triangle covering that much
|
||
of the texture's S/T range still shows visible linear-interpolation
|
||
artifacts across itself — reading as this radiating streak pattern rather
|
||
than smooth cloud, because the fold-and-interpolate math treats the whole
|
||
huge span as one linear ramp.
|
||
|
||
**Not fixed this pass** — tessellation (subdividing wide-span sky triangles
|
||
before the fold/perspective step) is a materially bigger change than the
|
||
fold-sharing fix below, and out of scope for "did Cradle regress." Logged
|
||
here with a concrete repro + screenshot so Defect 2 has hard evidence
|
||
instead of the prior "M-37: inconclusive, leaning tiling-density" note.
|
||
|
||
## D227 — M-98: seam residual FIXED (fold-sharing, same class as M-95's wScale-sharing) + scroll-rate quantitatively measured
|
||
|
||
**Picking up exactly where M-96/M-97 left off:** re-derive the intended N64
|
||
sky scroll rate and check the port's actual per-frame tc delta, rather than
|
||
re-attempting the seam in isolation.
|
||
|
||
**Ruled out first: the tick rate.** `skyTick()` (`src/game/sky.c`, verbatim
|
||
N64 logic, no `#ifdef PORT`) does `g_SkyCloudOffset += g_ClockTimer` once per
|
||
logic tick, wrapping at 4096. `g_ClockTimer` is set from `speedgraphframes`
|
||
(`frametiming.c`'s `updateFrameCounters`), which D193/D204 already
|
||
established runs at ~1/tick under normal load with a 6-tick catch-up clamp.
|
||
If the global tick rate were wrong, movement/AI/animation would all be sped
|
||
up too, not just the sky — that's not the report. Also checked
|
||
`sub_GAME_7F097388`'s call sites (`sky.c:862,1351`) for a second,
|
||
compounding time-based scale — both pass constant `65535.0f` args, not
|
||
anything frame-dependent. So the accumulator side is clean; per AGENTS.md
|
||
rule 2 (game logic is ground truth), any real bug has to be in the
|
||
`#ifdef PORT` D176(a) Path B substitution, not the decompiled game code.
|
||
|
||
**Found: M-95 only shared half of what needed sharing.** `skyPortBeginFan()`
|
||
(added by M-95) computes one `wScale` across the whole fan so shared-edge
|
||
vertices quantize their *position* consistently. But `skyPortRenderPoly`'s
|
||
S/T fold (`foldS`/`foldT`) — the `floorf(minS/64)*64` that keeps texel
|
||
coordinates in `s16` range — was still being computed **per call**, from
|
||
only that triangle's own 2-4 vertices, exactly the bug class M-95 fixed one
|
||
field over and never touched here. Two triangles in one fan can each
|
||
independently floor a shared vertex's S/T to a different multiple of 64
|
||
texels; the *visual* result is supposed to be identical (`GL_REPEAT` has
|
||
period 64), but each triangle bakes `(S - fold) * 32` into its own `s16`
|
||
`Vtx` buffer, and two folds 64 texels apart produce tc values exactly 2048
|
||
apart (`64 * 32`) — an s16-precision jump easily large enough to crack at
|
||
the shared edge. This is why M-96's human playtest still saw the seam after
|
||
M-95: M-95's fix was real (closed *a* crease, per its frame-345 diff) but
|
||
not the dominant one live play was hitting.
|
||
|
||
**Fix:** extended `skyPortBeginFan()`/`skyPortEndFan()` to also compute one
|
||
shared fold across the whole fan (`s_skyFanFoldS`/`s_skyFanFoldT`,
|
||
`s_skyFanFoldSet`), threaded into `skyPortRenderPoly` the same way as
|
||
`wScale` (falls back to the old per-call computation if unset — same
|
||
defensive shape as the wScale fallback). `src/game/sky.c`, `#ifdef PORT`
|
||
only, no game-logic touch.
|
||
|
||
**Verified:**
|
||
- Build clean (`./build-pc.sh ntsc-final`), no new/duplicate symbols.
|
||
- `-level_22` frame 345 (same frame M-95's before/after screenshots used) —
|
||
fresh capture shows continuous diagonal cloud streaks with no visible
|
||
crease at the ~x=1000/1280 location that's clearly cracked in the
|
||
pre-M-95 reference screenshot (`docs/img/bugs/d227-sky-seam-statue.png`).
|
||
No new screenshot committed this pass (didn't diff against an M-96-era
|
||
"still broken" capture, since none was saved) — human eyeball still owed
|
||
per the usual rule for visual fixes landed without display access.
|
||
- `verify.sh bunker1`: PASS, `worst_cell=10.3` — unchanged from before this
|
||
fix, matching M-95's note that this number is the already-known stale
|
||
golden-baseline issue (owed a re-baseline, unrelated to sky.c), not a
|
||
regression.
|
||
|
||
**Scroll-rate measurement (the actual M-96/M-97 ask):** captured 11
|
||
consecutive frames (`GE_PCDUMP=345-355:1`, static camera, Statue spawn) and
|
||
cross-correlated a sky-band row (y=60) frame-to-frame. Result: a highly
|
||
consistent **~7 px/tick horizontal shift at 640px capture width** (346→347
|
||
through 354→355 all landed at 6-7px, one outlier at the 345→346 transition
|
||
likely a cutscene/fade boundary). At 60 ticks/sec that's a full 640px
|
||
screen-width sweep roughly every 90 ticks (~1.5s) — hard quantitative
|
||
confirmation of "extremely fast," not just a subjective read, and a
|
||
concrete number for whoever chases the magnitude next.
|
||
|
||
**Not resolved this pass: *why* that magnitude is what it is.** Since the
|
||
accumulator (1 texel/tick) and the vertex geometry that turns a texel delta
|
||
into screen pixels (`sub_GAME_7F097388`) are both unmodified N64 logic, a
|
||
genuine wrong-magnitude bug (if the rate really is wrong relative to N64,
|
||
which still isn't confirmed absent a hardware/emulator reference) would
|
||
have to live in how the D176(a) Path B substitution maps a given per-vertex
|
||
S/T value to on-screen texels — e.g. camera-to-cloud-plane distance, FOV,
|
||
or a scale factor implicit in the `skyPortRenderPoly` perspective matrix —
|
||
not in the scroll-rate accumulator itself. Didn't chase this further this
|
||
pass; next session should compare the recovered per-pixel S gradient
|
||
(`tc[0]` delta between adjacent screen columns within one frame, not
|
||
frame-to-frame) against what the `130.0f`/`arg4`-driven N64 edge-setup math
|
||
in the `#else` branch of `skyRenderTri` computes for the same triangle, to
|
||
find whether the *shape* of the perspective correction (M-90) also carries
|
||
the right *magnitude*, independent of the wrap-rate question addressed here.
|
||
|
||
**Still open / human-gated:** the seam-close and the scroll-speed data
|
||
point both need a live eyeball before this can come off draft — same as
|
||
every other rendering fix landed without display access this session.
|
||
Cradle side of PR #43 still never checked (Statue only, again, this pass).
|
||
|
||
## D227 — M-96: human playtest — seam/crack STILL PRESENT after the M-95 fix; the "static-crease" theory alone does not explain the reported symptom
|
||
|
||
**Method:** user's own build, this branch (`land/d176a-sky-perspective-scroll`
|
||
at `b1733eda`, which includes the M-95 `wScale`-sharing fix). Launched
|
||
directly into Statue (`-level_22`), looked at the sky.
|
||
|
||
**Result: sky renders (the original D176a black-sky bug is fixed — sky is
|
||
visibly present, not black), but the seam/crack is still there.** This
|
||
directly contradicts the M-95 commit's "closes the visible seam" claim.
|
||
|
||
**New scroll-speed data point (user, same session): the sky should be
|
||
*almost static* on the original N64 game, and on this build it currently
|
||
scrolls *extremely fast* — a stronger complaint than M-93's "measurably
|
||
better but still reads too fast."** This bears directly on D176(a) itself
|
||
(the perspective-correct tc-interpolation fix this whole PR is about), not
|
||
just the D227 seam residual. Two live symptoms reported together on the
|
||
same Statue session: (a) scroll rate way too fast, (b) the seam. Worth
|
||
treating as possibly related — if the effective per-frame tc delta is
|
||
wrong by a large factor, that same wrong-magnitude value could plausibly
|
||
also drive the per-vertex/per-primitive divergence read as a "seam" (the
|
||
D227 M-94 hypothesis assumed *quantization* was the cause of the seam, but
|
||
never ruled out a *scroll-rate* mismatch between primitives as an
|
||
alternative explanation for "two things moving at different rates"). **Not
|
||
yet investigated — next session should re-derive the intended N64 scroll
|
||
rate from `sky.c`/the ROM env data and check what the port's actual
|
||
per-frame tc delta computes to, rather than assuming D176(a)'s fix already
|
||
has the right magnitude just because it's no longer solid black.**
|
||
|
||
**What this means for the M-94/M-95 diagnosis.** M-95's write-up was explicit
|
||
that it verified only the *static per-primitive `wScale` quantization* half
|
||
of M-94's hypothesis (via a same-frame before/after screenshot diff), and
|
||
flagged the *"each half moves at a different rate"* half of the original
|
||
user report as **not re-verified** — that measurement (M-94's static-camera
|
||
diff) was never repeated against the M-95 build. This playtest is that
|
||
missing verification, and it says the crease is still visible in live play.
|
||
Two non-exclusive readings:
|
||
|
||
1. The screenshot-diff comparison (single static frame) wasn't sensitive to
|
||
whatever actually causes the live-play seam — i.e. the M-95 fix may have
|
||
closed *a* crease (real, per the frame 345 before/after) without being
|
||
*the* crease the user is seeing, or without being the dominant one.
|
||
2. There is a second, independent seam-causing mechanism — most likely the
|
||
still-unconfirmed "two halves animate at different rates over time" theory
|
||
from the original M-93/M-94 report, which is a *temporal* divergence
|
||
(rates, not one-frame geometry) that a single-frame diff cannot detect at
|
||
all.
|
||
|
||
**Status: D227 NOT resolved. PR #43 stays in draft — do not merge on the
|
||
strength of the M-95 fix alone.** Next investigation should target the
|
||
temporal/rate theory specifically: capture the same fixed camera position
|
||
across a range of frames (not just one) on both sky "halves" and measure
|
||
whether their texture-coordinate scroll rate actually diverges over time,
|
||
per M-94's original (but never re-run post-M-95) methodology.
|
||
|
||
## D227 — M-95: FIXED (the static-crease part) — one shared `wScale` across the whole sky fan instead of one per triangle
|
||
|
||
**The fix.** `src/game/sky.c`, `#ifdef PORT` throughout, three additions:
|
||
|
||
- `skyPortBeginFan(SkyRelated38 *v, s32 n)` — scans `n` verts' `unk0c` for the
|
||
max absolute camera-space `w` and stores `wScale` in a new file-static
|
||
`s_skyFanWScale`. Forward-declared near the top of the file (its first use
|
||
is ~600 lines before its definition).
|
||
- `skyPortEndFan(void)` — resets `s_skyFanWScale` to `0.0f` (the "unset"
|
||
sentinel).
|
||
- `skyPortRenderPoly` now checks `s_skyFanWScale > 0.0f` first; if set, uses
|
||
it directly instead of recomputing `maxAbsW`/`wScale` from just its own
|
||
2-4 verts. Falls back to the original per-call computation if unset —
|
||
`skyRenderTri`/`skyRenderFull` have no callers outside this file (checked
|
||
by grep), but this keeps a safe default instead of silently reusing a
|
||
stale value if that ever changes.
|
||
- Both fan-drawing blocks (the `sp274[5]`/`sp94[5]` local arrays,
|
||
`src/game/sky.c`) call `skyPortBeginFan(array, s1)` once right after
|
||
populating the array — before dispatching to their 1-3
|
||
`skyRenderTri`/`skyRenderFull` calls — and `skyPortEndFan()` once after
|
||
the last one. For the `sp94[]` block specifically: computed from `unk0c`,
|
||
which the position-override code for the full-quad special case (`s1==4`,
|
||
the `sp94[0].unk28 = ...` assignments) does not touch, so the order is
|
||
safe regardless of whether that override path runs.
|
||
|
||
**Why this was the right diagnosis.** `skyRenderTri`/`skyRenderFull` draw up
|
||
to 5 triangles per frame from a *shared* pool of up to 5 vertices (built
|
||
once, then fan-triangulated across 1-3 calls). Each call used to compute its
|
||
own `wScale` from only its own 2-4 verts. The position math is exact for any
|
||
`wScale > 0` (re-derived algebraically in M-94), so this was never a logic
|
||
bug — but `Vtx.ob[]` is `s16`, and two triangles sharing a vertex could each
|
||
independently pick a different `wScale`, quantizing that logically-identical
|
||
shared vertex to two different 16-bit values depending on which triangle's
|
||
call computed it. That's a crack at the shared edge — exactly the visible
|
||
"seam" from M-94's screenshot.
|
||
|
||
**Verification.**
|
||
- `./build-pc.sh ntsc-final`: clean build, no new/duplicate symbols.
|
||
- Full `verify.sh sweep`: **21/21 PASS**, no crash/regression, both with and
|
||
without this change (isolated by `git stash`/`stash pop` and re-running).
|
||
- `bunker1`'s non-zero `worst_cell` framediff is unaffected by this change —
|
||
reproduced identically (~64) on a pre-fix build. Confirmed pre-existing:
|
||
the stale golden-baseline issue already noted in the M-83/M-84 memory
|
||
(owed a re-baseline), unrelated to `sky.c`.
|
||
- **Direct same-frame before/after comparison, `-level_22` frame 345:**
|
||
`docs/img/bugs/d227-sky-seam-statue.png` (pre-fix — visible kink in the
|
||
cloud streaks around x=1000/1280 in the cropped+2x-upscaled screenshot)
|
||
vs. `docs/img/bugs/d227-sky-seam-statue-fixed.png` (post-fix, same exact
|
||
frame — streaks flow continuously through that region, kink gone).
|
||
- **Cradle checked too** (`-level_41`, the other level named in the M-93
|
||
report): the large black rectangular gap visible there is confirmed
|
||
**pre-existing and unrelated** — reproduced identically on a pre-fix
|
||
build capture at the same frame. That's the already-documented
|
||
FOV-coupled black-sky-past-frustum-edge symptom (D218/D222 family, "not
|
||
attempted" per the D176(a) PR body) — a different defect, not touched by
|
||
this fix, not chased further here.
|
||
|
||
**Not re-verified post-fix:** the "each half moves at a different rate"
|
||
half of the original user report (M-94's static-camera diff measurement
|
||
was not repeated against this build) and the aspect-ratio-artifact
|
||
alternate theory (never independently confirmed or ruled out either way).
|
||
The crease visibly closing at a fixed resolution/aspect is reasonable
|
||
evidence the crease itself was code-side, not a display-scaling artifact,
|
||
but this is not a substitute for a human eyeball pass — owed before merge,
|
||
consistent with every other rendering fix landed this session without
|
||
interactive display access.
|
||
|
||
**Generalizable quirk for `porting-notes.md`:** when a decompiled routine
|
||
fans a single shared vertex set out across multiple separate perspective-
|
||
correction / projection-matrix-building calls (one call per triangle
|
||
instead of one call for the whole primitive group), and the fix stores a
|
||
derived per-call scale factor into a low-precision (`s16`) vertex field,
|
||
each call's independently-chosen scale can quantize a *logically shared*
|
||
vertex differently — producing a visible crack at the shared edge even
|
||
though the underlying position math is exact for any scale value. Fix:
|
||
compute the scale once for the whole shared-vertex group, not per call.
|
||
|
||
## D217 — M-89: the `gfx_dp_load_tlut` width=1/pitch hypothesis is REFUTED (it computes correctly by design); new lead pointed at the runtime texpool arena + image-table conversion, not the model sidecar
|
||
|
||
A local-Qwen triage dispatch (read-only, no edits) proposed hardcoding
|
||
`pitch = 1` in `gfx_dp_load_tlut` (`port/fast3d/gfx_pc.cpp:2214`,
|
||
currently `pitch = rdp.texture_to_load.width + 1`), on the theory that N64
|
||
LOADTLUT addressing never strides by the SetTextureImage width and the
|
||
port's `+1` computation was wrong. **Verified by hand and refuted before
|
||
applying it — do not repeat this fix:**
|
||
|
||
- `gDPSetTextureImage`/`gSetImage` (`include/PR/gbi.h:3097-3111`) pack the
|
||
RDP command word as `_SHIFTL((width)-1, 0, 12)` — i.e. the **raw** command
|
||
field the port reads back (`C0(0,10)` at the `G_SETTIMG` handler,
|
||
`gfx_pc.cpp:2786`) is `width - 1`, not `width`. `texWriteLoadToTmemAddr`
|
||
(`tex.c:503,519,629,643`) always calls `gDPSetTextureImage(..., width=1,
|
||
...)`, so the raw field is `1-1=0`, and `pitch = 0 + 1 = 1` **already**,
|
||
for every model-DL TLUT load in the game. The proposed patch is a no-op
|
||
for this call site — it changes nothing about the reported bug, because
|
||
the code it "fixes" already computes the value it wants.
|
||
- Continuing the derivation with `pitch=1` confirmed: `gfx_dp_load_tlut`'s
|
||
`base = addr + pitch*ult + uls` reduces, for every call from
|
||
`texWriteLoadToTmemAddr`'s `gDPLoadTLUT07(uls, ult, tex->unk0a + uls,
|
||
ult)`, to **`base = addr + len`** regardless of the internal `uls`/`ult`
|
||
split (`tex.c:530-541`; the split exists only to keep both RDP command
|
||
fields inside their 10-bit range when `len` is large — it cancels out in
|
||
the `pitch=1` address math). `addr` is `tex->data` (the `SetTextureImage`
|
||
dram pointer) and `len` is `texGetDepthAndSize`'s halfword-converted
|
||
pixel-data length. This is exactly the documented on-disk/in-arena layout
|
||
from `texInflateZlib` (`image.c:320-326`: palette bytes appended
|
||
immediately after `totalbytesout` bytes of pixel data) — **the addressing
|
||
scheme is self-consistent and correct by inspection**, assuming `len`
|
||
(computed from `tex->width`/`height`/`depth`/`maxlod` via
|
||
`texGetLineSizeInBytes`) truly equals the pixel-byte count the decoder
|
||
actually wrote (`totalbytesout` before the palette-append loop). Also
|
||
confirmed `tex->unk0a = numcolours - 1` (`image.c:238`) exactly explains
|
||
the `count = width*height = unk0a+1` term in `gfx_dp_load_tlut` — matches
|
||
the M-85 `GE_D217Z` "count=1/28/125/172/193" observations precisely (a
|
||
**genuine 1-color palette** for the grip tile, not a partial/truncated
|
||
load of a larger one).
|
||
- **New question this raises, not yet answered:** if the addressing math is
|
||
correct and `numcolours=1` is a real, intentional 1-entry palette for
|
||
this tile, then the SAME `texturenum` decoded from the SAME ROM bytes
|
||
should produce the SAME single color on every level — it does not
|
||
(`0001` on BUNKER1 vs `1a00` on FACILITY). That points away from a
|
||
decode-math bug entirely and back toward either (a) **the bytes handed to
|
||
the decoder differ per level** (i.e. an image-table/asset-resolution
|
||
issue — but a *different* one than the already-ruled-out model-DL
|
||
`TextureID`/`d43_emit.py` path; `texLoad` sources compressed bytes from
|
||
`g_Textures[texnum]` offsets into `_imagesSegmentRomStart`, a wholly
|
||
separate table/segment from the per-model `Globalimagetable` that
|
||
`port/src/gimgfixup.c`/D68 already fixes — **not yet audited for this
|
||
bug**), or (b) **the shared texpool arena serves a stale/aliased
|
||
`tex->data` pointer** for what should be a fresh per-level decode (the
|
||
arena is a two-ended allocator, `struct texpool` `leftpos`/`rightpos`,
|
||
reused across many textures per level per `texLoad`/`image.c:2414-2519`;
|
||
if `bytesout` returned by `texInflateZlib`/`NonZlib` for the LOD-limited
|
||
or shrink-paletted paths (`image.c:277-318`, the `forcenumimages >= 2`
|
||
branch with its `if (totalbytesout + imagebytesout > 0x800) { maxlod = j;
|
||
break; }` early-exit) doesn't exactly match what `texGetDepthAndSize`
|
||
later assumes was written for a *different* texture that landed nearby,
|
||
a later texture's palette read could land on an adjacent texture's
|
||
bytes — this would explain "same weapon, different levels, different
|
||
wrong colors" as a level-dependent *decode order/adjacency* effect, not
|
||
a fixed byte offset).
|
||
- **Explicitly ruled out this session (in addition to the M-88 sidecar
|
||
ruling):** `gfx_dp_load_tlut`'s pitch/address computation itself
|
||
(`gfx_pc.cpp:2202-2237`) — confirmed correct by hand-derivation against
|
||
the GBI packing macros, not just "not yet disproven."
|
||
- **Confidence:** HIGH that the proposed `pitch=1` patch is a no-op and
|
||
must not be applied/merged. MEDIUM on the two new leads above — neither
|
||
was instrumented or built this session (time-boxed to the math
|
||
derivation); the game-fed vs. arena-staleness question is the right next
|
||
probe (an env-gated log of `g_Textures[texnum]` offset + decoded byte
|
||
count + `pool->leftpos`/`rightpos` delta, on the exact FACILITY repro
|
||
frame, would disambiguate directly).
|
||
- **For whoever picks this up next:** do NOT re-propose changing
|
||
`gfx_dp_load_tlut`'s pitch formula without first checking whether the
|
||
bytes arriving at `texInflateZlib`'s `src` parameter (i.e. what
|
||
`g_Textures[g_TexNumToLoad]`/`_imagesSegmentRomStart` resolves to) are
|
||
actually identical across BUNKER1 and FACILITY for this `texturenum` —
|
||
that is the one load-bearing assumption ("same weapon, same ROM bytes")
|
||
this whole investigation has taken for granted but never directly
|
||
checked with a probe.
|
||
|
||
## D217 — M-89 cont.: decisive probe — decode is proven correct+identical across levels; the parked defect ② fix does NOT fix the visible symptom; new leading theory is a stale/dangling `tex->data` arena pointer, not a decode or cache-key bug
|
||
|
||
Ran the actual empirical test the M-89 section above called for, rather than
|
||
delegating it further. **Result answers the open question decisively and
|
||
rules out both of this row's previous leads.**
|
||
|
||
**Probe added** (`src/game/image.c`, `texInflateZlib`, `#ifdef PORT` +
|
||
`getenv("GE_D217TEX")`, follows the `GE_TEXDUMP`/`GE_D85TEX` pattern):
|
||
logs `texnum`/`format`/`numcolours`/`palette[0]`/`palette[1]` **straight off
|
||
the decode bitstream**, before any arena/pointer math — the most direct
|
||
possible check of "does this texture number decode to the same bytes on
|
||
both levels."
|
||
|
||
**Result:** ran BUNKER1 (`-level_09`) and FACILITY (`-level_34`) headless
|
||
with `GE_D217TEX=1`. `texnum=1608` is the only `numcolours=1` texture common
|
||
to both logs, and its `pal0` value (`0001`, black) matches the previously
|
||
documented BUNKER1-correct value exactly — confirming this is the PP7 grip.
|
||
**`texnum=1608` decodes to `pal0=0001` on BOTH levels, identically, every
|
||
time it's called.** This directly disproves the M-89 "different ROM bytes
|
||
per level" open question (lead a is dead — the decode is correct and
|
||
level-invariant) and also rules out any remaining decode-math theory: the
|
||
bug is not in `texInflateZlib`, `texGetDepthAndSize`, or `gfx_dp_load_tlut`
|
||
(already shown correct M-89) — it is **strictly downstream of a provably
|
||
correct decode**.
|
||
|
||
**Then tested the parked defect ② fix (`fix/d217-model-texture-palette`,
|
||
`f6fc0399` — the CI-texture-cache-key content-hash) directly against this
|
||
exact FACILITY repro**, since "correct decode, wrong render" is precisely
|
||
the signature that fix targets (a stale cache `HIT` serving a different
|
||
material's already-decoded palette). **Built it, ran `-level_34`, captured
|
||
frame 380: the grip is still solid green.** The already-written, previously
|
||
untested-against-this-repro defect ② fix does **not** fix the visible
|
||
symptom. This means defect ① and defect ② are **not the same underlying
|
||
bug** — they were right to be tracked separately, and defect ②'s fix should
|
||
still land (real, separate, still needs its 21-level sweep) but will not
|
||
close this row on its own.
|
||
|
||
**New leading theory (untested, most concrete lead yet):** since a fresh
|
||
decode is proven correct, and the fast3d-side cache-key fix doesn't help,
|
||
the render-time GBI command for this material must be reading a `tex->data`
|
||
pointer whose *arena contents have changed* between when it was decoded and
|
||
when the DL that references it is actually drawn — i.e. a **stale/dangling
|
||
pointer into the `texpool` arena**, not a decode or cache-lookup bug. GE's
|
||
`texpool` (`image.c` `texLoad`/`texInitPool`, two-ended `leftpos`/`rightpos`
|
||
allocator) is reused across many textures per level; if it resets or the
|
||
model-node/GDL that references texture 1608 caches a raw pointer/segment
|
||
address resolved once (e.g. at weapon-pickup or level-load time) rather
|
||
than re-resolving through `texFindInPool` every time it draws, then any
|
||
*later* texture decode that reuses that same arena address (plausible on a
|
||
texture-heavy level like FACILITY, less likely on the lighter BUNKER1) would
|
||
silently overwrite the grip's palette bytes with whatever the next tenant's
|
||
content is — explaining both "same weapon, different wrong color per level"
|
||
(depends on what else that level's arena happened to decode nearby/after)
|
||
and "clean on the lighter level" (less arena churn/reuse pressure).
|
||
|
||
**Confidence:** HIGH the decode itself and the fast3d/cache-key layer are
|
||
now both cleared (empirically, not just by inspection). MEDIUM-LOW on the
|
||
stale-arena-pointer mechanism — plausible and consistent with all evidence
|
||
so far, but not yet probed directly.
|
||
|
||
**For whoever picks this up next:** probe whichever code path holds the
|
||
`Gfx*`/model-node reference to this material's `SetTextureImage` address at
|
||
*draw* time (`texWriteLoadToTmemAddr`'s caller chain from the weapon
|
||
model-DL walk, not `texLoad` itself) and log the `tex->data` pointer value
|
||
+ a fresh read of the two bytes at the computed palette offset **at draw
|
||
time**, alongside a second log of what `texInflateZlib` most recently wrote
|
||
to that same address (extend the `GE_D217TEX` probe with an address field
|
||
to correlate). If the drawn address's live content no longer matches what
|
||
was last decoded there, that confirms the stale-arena-pointer theory
|
||
directly and narrows the fix to wherever the model DL caches that pointer
|
||
instead of re-resolving it. Probe left in tree (`GE_D217TEX`, env-gated,
|
||
inert by default).
|
||
|
||
**VERIFY performed this session:** `tools_pc/verify.sh bunker1` — PASS,
|
||
no regression from the (inert-by-default) probe. `-level_09`/`-level_34`
|
||
`GE_D217TEX=1` headless captures — decisive result above. Built and ran
|
||
the parked defect ② fix directly against `-level_34` frame 380 — still
|
||
green, screenshot evidence in this session's scratchpad.
|
||
|
||
## D217 — M-90: defect ① ROOT CAUSE FOUND + FIXED — texpool arena base misaligned on PC; the palette sits 4 bytes early inside every weapon-pool allocation while LoadTLUT still reads the N64 offset
|
||
|
||
**Verdict: the stale-arena-pointer theory (M-89 cont.) is REFUTED, and the
|
||
real root cause is found, fixed, and verified.** Arena bump allocations are
|
||
never overwritten in place (`leftpos` only advances), so a drawn address's
|
||
content cannot "change" after its decode. What actually happens: the palette
|
||
**was never at the address the draw reads**.
|
||
|
||
**The mechanism (all hand-derived, then probe-confirmed):**
|
||
|
||
1. `texAlignIndices` (`src/game/image.c`) pads each CI index row to an
|
||
**absolute** 8-byte boundary: `outptr = (u8 *)(((u32)outptr + 7) & ~7)`.
|
||
The palette is then written at `dst + imagebytesout`, and the whole
|
||
allocation's size (`texInflateZlib`'s return) depends on where `dst` sits
|
||
in memory.
|
||
2. Model-DL TLUT loads address the palette at `tex->data + len*2` bytes
|
||
(`texWriteLoadToTmemAddr`: `uls = len` from `texGetDepthAndSize`, and the
|
||
port's `gfx_dp_load_tlut` computes `base = settimg_addr + pitch*ult + uls`
|
||
in 16-bit units — correct RDP semantics, re-verified). `len` is a
|
||
**formula** that only matches the real layout when rows start 8-aligned.
|
||
3. The invariant that makes (1) and (2) agree: **`leftpos` stays 8-aligned
|
||
for life** — pool base 8-aligned, header `+= 8`, every inflate size a
|
||
multiple of 8 (checked: zlib aligns at return; non-zlib per-image). On
|
||
N64 the memp bump allocations feeding these pools happened to be
|
||
8-multiples, so bases were aligned and absolute ≡ relative row padding.
|
||
4. **PC broke the invariant.** `mempAllocBytesInBank` (`src/memp.c`) is a
|
||
plain bump allocator with no per-allocation alignment; PC pointer bloat
|
||
(D36/D37-class struct growth) means some STAGE-bank allocation sizes are
|
||
no longer 8-multiples, so by the time `initBondDATA.c:238` allocates the
|
||
weapon buffers, the pool position is off. Probe-confirmed: on FACILITY
|
||
both weapon-pool bases land **%8==4** (stage pool aligned), and *every*
|
||
decode in the misaligned pool has `dst%8==4`.
|
||
|
||
**The smoking gun (probe data, `GE_D217TEX` extended with a
|
||
`D217TEX-SIZE` line at `texInflateZlib`'s return):** pre-fix FACILITY,
|
||
`texnum=1608` (PP7 grip, 1x1 CI4, `numcolours=1`, `pal0=0001`) decodes to
|
||
**size=8**: image = 1 byte + absolute-align padding = **4 bytes**, palette
|
||
at `dst+4..5`. The draw's count=1 TLUT reads `dst+8` (the N64 layout: image
|
||
8, palette at +8) — which is exactly the **next tenant's 8-byte header slot**:
|
||
`texnum=26` stored as an LE s16 = bytes `1a 00` → `PD_BE16` → **`0x1A00` =
|
||
the green `(24,65,0)`**. Permanent (arena never rewrites it), per-level and
|
||
per-weapon (the "next tenant" texnum differs → any RGBA5551 value: the green,
|
||
blue, white, orange of the M-87 playtest matrix).
|
||
|
||
**This single mechanism also explains M-86's long-unexplained tell:** every
|
||
*larger* model TLUT showed `raw[0]==raw[1]==0x0000` with real colour from
|
||
index 2. In a %8==4 pool the whole palette sits **4 bytes (2 entries) early**,
|
||
so a load at the N64 offset reads two zero-pad entries then the real palette.
|
||
The "leading zero pad / pointer 4 bytes too low" suspicion was literally
|
||
true — just on the *decode* side, not the draw side.
|
||
|
||
**Fix (landed):** `texInitPool` (`src/game/image.c`, `#ifdef PORT`): round
|
||
the arena base up to 8 bytes before publishing `start`/`leftpos`; `end`
|
||
stays at the original buffer limit (pool loses ≤ 7 bytes). A no-op on N64
|
||
(bases were already aligned — the game works there, which is itself the
|
||
proof the invariant held). Chosen over aligning inside
|
||
`mempAllocBytesInBank` (would shift *every* allocation — far larger blast
|
||
radius, and D36/D37 note the STAGE bank is nearly full). This is a
|
||
semantics-preserving ABI/layout edit in the AGENTS.md narrow-exception class
|
||
(32→64-bit transition broke an implicit layout invariant); no game logic
|
||
touched.
|
||
|
||
**Verification:**
|
||
- `./build-pc.sh ntsc-final` clean; no new symbols (local var + expression).
|
||
- FACILITY `-level_34`: all 188 decodes now `%8==0`; `texnum=1608` size=16,
|
||
palette at +8; draw probe reads `addr=<base+8> pal0=0001 count=1` (black,
|
||
the decoded value) — zero `pal0=1a00` events. PPM frames 250–450: **0**
|
||
exact-green `(24,65,0)` pixels (pre-fix: ~1300 in frames 300–410) and 0
|
||
strong-green (`g>90, r<70, b<70`) across all 21 frames.
|
||
- BUNKER1 `-level_09` (the clean control): still clean — 183/183 decodes
|
||
aligned, 0 CHANGED draw events, 0 green pixels in the capture window.
|
||
- DEPOT `-level_30` (had green pre-fix): 229/229 aligned, 0 exact/strong
|
||
green in the capture window.
|
||
- Full 21-level `verify.sh sweep`: **21/21 PASS**, no CRASH/REGRESSION/
|
||
STALLED (`sweep_d217.log`, repo-root scratch).
|
||
|
||
**Status change:** defect ① = **FIXED, pending the sweep + a human
|
||
playtest eyeball** (the M-87 matrix weapons: PP7/PPK/AK47/M16 grip+body,
|
||
watch face, NPC-held guns). Defect ② (CI texture-cache content-hash key,
|
||
branch `fix/d217-model-texture-palette` @ `f6fc0399`) remains a real,
|
||
separate, parked fix — still owes its own sweep before merge; M-89 already
|
||
proved it does not fix the visible symptom.
|
||
|
||
**Probes:** stripped before commit — the fix ships clean. The committed
|
||
M-89 decode probe (`GE_D217TEX` in `texInflateZlib`) remains as left by that
|
||
session; if the playtest eyeball surfaces a residual, re-add the
|
||
`D217TEX-SIZE`/`D217TEX-DRAW` probes exactly as documented above.
|
||
|
||
## D217 — M-92: human playtest eyeball of PR #47 (debug build) — PP7/PPK/watch CONFIRMED FIXED; AK47 and NPC/world-model weapons have a DISTINCT, still-open residual
|
||
|
||
**Method:** user's own debug build (`build-linux-dbg`, their preferred manual
|
||
test flow, not the headless `verify.sh` capture), `fix/d217-texpool-alignment`
|
||
checked out. Sampled Dam, Facility, Jungle, Frigate.
|
||
|
||
**Confirmed fixed by this PR:** PP7/PPK (grip + body) clean on every level
|
||
tested; Bond's watch face clean where it was previously green. Matches the
|
||
probe/pixel-count evidence in M-90 — the %8==4 texpool-alignment fix is
|
||
real and closes the exact symptom it targeted.
|
||
|
||
**NOT fixed — residual, still open:**
|
||
- **AK47** — both **1P viewmodel and 3rd-person world model** still show
|
||
white (missing-texture) patches.
|
||
- **NPC-held weapon world models** — white patches on parts that vary per
|
||
gun (magazine, receiver, etc. depending on weapon), **consistent across
|
||
guns/levels** (not intermittent). Frigate was called out as especially bad.
|
||
|
||
**Reading this against the M-87 raw notes above:** the M-87 matrix already
|
||
recorded AK47 white-patch symptoms (viewmodel) pre-fix; M-92 shows the
|
||
texpool-base-alignment fix (M-90) did not touch that mechanism at all — it
|
||
only fixed the PP7/PPK/watch "wrong solid colour from a misread palette
|
||
slot" failure mode. AK47 + NPC-world-model "white/missing" is a different
|
||
failure signature (white usually means "texture never bound / fell back to
|
||
a default," not "bound the wrong palette entry" — cf. the D161 family this
|
||
row already flagged). **Likely a second, distinct D217 mechanism** — not
|
||
yet root-caused. Candidates to check first, cheapest to most expensive:
|
||
whether the AK47/NPC weapon model DLs route through a *different* texpool
|
||
(not the one `texInitPool`'s 8-align fix touches — note `gun.c:930-950`
|
||
hand multiple distinct offset/pool pairs per weapon, and `bondview2.c:504`/
|
||
`initmttex.c:15` are separate `texInitPool` call sites entirely from the
|
||
per-weapon ones); or a genuinely separate "texture never loaded for this
|
||
DL" bug (D161-class G_TT_NONE / missing-TLUT-emission) rather than a
|
||
misaligned-read one.
|
||
|
||
**Status:** PR #47 fix is real and should still merge — it closes a
|
||
confirmed sub-defect (PP7/PPK/watch) with no regressions. **Do not close
|
||
D217 defect ① on this merge** — reopen/retitle as the AK47 + NPC-world-model
|
||
white-texture residual once root-caused, since the M-87 matrix already
|
||
described it as a separate colour-vs-white failure mode from the start.
|
||
|
||
|
||
**Closing status (from the §F row, appended 2026-09-15 ctx-opt):** FIXED and merged — PR #47 (texpool alignment) + PR #49 (CI-cache palette key, commit `f6fc0399`); confirmed by two independent human playtests (M-92, M-97). The white missing-texture residual is split out as D228. (The row's status had gone stale at OPEN; corrected M-105.)
|
||
## D221 — M-91: SELECT FILE mouse hit-box offset — root cause is a compiler stack-layout dependency, not a coordinate/origin mismatch; fixed by passing explicit `{min,max}` pairs (`#ifdef PORT`)
|
||
**Symptom (M-87 user QA):** on the SELECT FILE screen (default
|
||
`MouseCaptureMode=1`, free OS cursor), clicking directly over a visible
|
||
file wallet does nothing; you have to click *below* it to select.
|
||
|
||
**The hit-test.** `interface_menu05_fileselect` (`front.c`) loops the four
|
||
folders, gets each wallet model's world-space extents
|
||
(`modelGetXYExtents(walletinst[n], &xmax, &xmin, &ymax, &ymin)` — four
|
||
separate f32 locals) and projects them with
|
||
`projectRectCornersTo2D(&folderpositions_camspace[n], &xmin, &ymin,
|
||
&folderbbox.right, &folderbbox.left)`. The function's signature takes
|
||
`struct coord2d *` **pairs** — `{f[0]=min, f[1]=max}` — and projects
|
||
`arg1->f[0]`, `arg1->f[1]` (horizontal) and `arg2->f[0]`, `arg2->f[1]`
|
||
(vertical). Passing `&xmin` therefore *silently reads the stack float
|
||
physically after `xmin`* as "xmax", and `&ymin` reads the float after
|
||
`ymin` as "ymax". The decompiled C only reproduces the N64 binary's
|
||
behavior if the N64 compiler happened to lay the four locals out with each
|
||
max adjacent to its min (reverse declaration order) — which it evidently
|
||
did, since file select works on N64.
|
||
|
||
**The PC build breaks that assumption.** Probe (`GE_D221`, temp,
|
||
removed before commit) logged the actual adjacency in the MinGW build:
|
||
`((f32*)&xmin)[1] == ymax` (declaration-order locals: `xmax,xmin,ymax,ymin`) and
|
||
`((f32*)&ymin)[1] == 2.25734`, later `nan` — an unrelated stack slot. So the
|
||
projected hit box used `(x=xmin, x=ymax-as-X)` horizontally (a valid but
|
||
over-wide band) and `(y=<garbage/NaN>, y=ymin)` vertically — a ~14 px strip
|
||
at the *bottom edge* of each wallet (f=0: `[164.8, 179.0]` vs the true
|
||
folder extent `[36.7, 179.0]`), and NaN in other frames (the band check
|
||
`down <= v && v <= up` is false against NaN → unselectable that frame).
|
||
A ~14 px bottom-edge strip is exactly "hovering over the file does nothing;
|
||
click below it works." The horizontal over-width is why left/right felt
|
||
fine. The other caller, `objective_status.c:525`, passes genuine
|
||
`struct coord2d` locals (`sp7C`/`sp74`) and was never affected.
|
||
|
||
**The fix (game code, ABI/layout exception per AGENTS.md rule 2).** In
|
||
`interface_menu05_fileselect`, under `#ifdef PORT`: declare
|
||
`struct coord2d xminmax, yminmax;`, fill them from the four extents, pass
|
||
those to `projectRectCornersTo2D`. The N64 path keeps the original call
|
||
byte-for-byte. This is the same class as the D3x pointer-width edits:
|
||
the decompiled source encodes a memory-layout invariant the PC toolchain
|
||
does not honor; the edit makes the C express what the N64 binary already
|
||
did, changing no logic or values on either platform.
|
||
|
||
**Verification (headless, controlled).** Title screen → any button reaches
|
||
file select (`GE_INPUTSCRIPT="300:A;900:A"`). The default cursor sits at
|
||
(154.7, 127.8), inside folder 0's *correct* box but **outside** its broken
|
||
band (y=127.8 ∉ [164.8, 179.0]). Pre-fix: A-press there never selected
|
||
(the probe kept printing for the whole 60 s run — 4392 lines). Post-fix:
|
||
same script, folder 0's box is `[x 96.5..214.9] × [y 36.7..179.0]`, the
|
||
A-press selects folder 0 and the file-select loop exits (probe output
|
||
stops after ~59 frames; no crash/BUG lines). Identical cursor position and
|
||
button press on both sides — only the box changed.
|
||
|
||
**Status:** fixed in `front.c` (PR pending), probe stripped. Human
|
||
eyeball owed: normal mouse play through file select (hover highlight,
|
||
select/copy/delete options) — the headless test proves the hit-test fires
|
||
where the visible wallet is, but the highlight/selection *feel* is a human
|
||
check. **Human mouse-playtest (M-91 session, from the §F row, appended 2026-09-15 ctx-opt):** confirmed
|
||
clicking directly over each visible file wallet selects it immediately, no
|
||
aiming low required; consistent across all slots and the Copy/Erase icons;
|
||
no crash. D192 (mode-0 clamp range) is a separate bug and remains open.
|
||
|
||
## D228 — M-97: AK47 white missing-texture patches survive D217's texpool-alignment fix — split out as a distinct defect (white ≠ wrong-colour)
|
||
|
||
**Method.** PR #47's branch (`fix/d217-texpool-alignment`) merged up to date
|
||
with `main`, rebuilt clean, launched directly into FACILITY (`-level_34`) —
|
||
the level the D217 log names as the sharpest repro. Human eyeball across the
|
||
M-87 weapon matrix.
|
||
|
||
**Result — a clean split:**
|
||
|
||
| Symptom (M-87 matrix) | After PR #47 |
|
||
|---|---|
|
||
| PP7/PPK grip + hammer green | **FIXED** — correct colour |
|
||
| Bond watch face green | **FIXED** |
|
||
| Green cast on hands / 3rd-person world model | **FIXED** — none seen |
|
||
| **NPC-held AK47 white patches (magazine/receiver)** | **STILL PRESENT** |
|
||
| General world/HUD textures | fine, no regressions, no crash |
|
||
|
||
This is the second independent human playtest to land on exactly this split
|
||
(M-92 was the first) — and it is the split M-90's write-up predicted in
|
||
advance, which is good evidence it is a real boundary between two mechanisms
|
||
rather than an artifact of one incomplete fix.
|
||
|
||
**Why these are different mechanisms, not degrees of one.** D217's root
|
||
cause (M-90) is *a wrong palette being bound*: the texpool arena base landed
|
||
`%8==4`, so every palette in the affected pools sat 4 bytes early and
|
||
`G_LOADTLUT` read the neighbouring tenant's header bytes as colour — the
|
||
tile decodes fine, it just decodes against the wrong 512-byte TLUT, which
|
||
necessarily produces a **wrong colour** (the documented green `0x1A00`).
|
||
A **white** patch is a different signature entirely: it means the tile is
|
||
sampling a texture that is missing, never uploaded, or cleared — not one
|
||
that is present with wrong contents. Aligning the arena base cannot fix a
|
||
texture that was never decoded into the arena in the first place, which is
|
||
precisely what the playtest shows.
|
||
|
||
**Why the label split.** Leaving this inside D217 would mean the row can
|
||
never close cleanly: its own stated mechanism *is* fixed and sweep-verified,
|
||
and carrying an unrelated open failure mode under the same label is how a
|
||
finding log rots (cf. the D170→D193 subsumption that had to be untangled
|
||
later). D228 takes the white-texture residual; D217 closes on the green
|
||
mechanism it actually describes.
|
||
|
||
**Next (not started).** Run `-level_34` with `GE_D217TEX=1` / `GE_TEXDUMP`
|
||
while an AK47-carrying guard is on screen and determine which of two cases
|
||
holds:
|
||
|
||
1. the AK47's affected tiles **never reach `import_texture`** — a decode
|
||
that never ran, pointing at the weapon-model texture *load* path rather
|
||
than anything palette-related; or
|
||
2. they reach it with **empty/zeroed source bytes** — a decode that ran
|
||
against a cleared or exhausted texpool slot, pointing back at the
|
||
two-ended `leftpos`/`rightpos` allocator in `image.c` `texLoad`.
|
||
|
||
Useful control: the AK47 is not a level-start weapon on Facility, so its
|
||
textures load later than the PP7's — compare against a level where the AK47
|
||
*is* an early/starting weapon. If it renders clean there, load ordering /
|
||
pool exhaustion is implicated directly. D217 · D161 lineage.
|
||
|
||
## D266 — M-125: re-applied the two reverted D265 port fixes (I2:A2 IA4 decode + AC-aware edge handling); new tri-time census shows **zero IA4 draws from the boot camera** — the D265 "IA4 tree strip" class does not reproduce at the wall
|
||
|
||
Re-applied, on top of the committed baseline: (a) `import_texture_ia4` I3:A1→I2:A2 decode (`intensity = part>>2`, `alpha = part&3`, new `SCALE_2_8`) — hardware-correct RDP IA4 layout, necessary per D265; (b) `gfx_opengl.cpp` edge path: the inherited hardcoded `if (texel.a > 0.19) texel.a = 1.0; else discard;` replaced with alphacompare-keyed semantics — AC_DITHER (`opt_noise`) → noise-dithered discard; AC_THRESHOLD → discard only fully-transparent; AC_NONE → discard below 0.5, survivors keep alpha for blending (the old line forced every survivor opaque, defeating ALPHA_CVG_SEL); the plain `opt_alpha && opt_noise` dither line is now gated off when edge is also set. `gfx_pc.cpp`: `texture_edge` no longer set for AC_DECAL tiles. Both are port-only; identical latent lines exist in the PD port (PD's art never exercises them — PD has **no billboards at all**, full audit this session).
|
||
|
||
New env-gated census probe (`GE_D266`, in `gfx_sp_tri1`) logging per-draw tile fmt/line-width/RM class for alpha-bearing draws. **Decisive result: over 1.5M tris from the Surface 1 boot camera, ZERO IA4 draws.** The dominant alpha classes are IA8 line=32B/64B XLU 2-cycle cards. D265's census (class (b) = "IA4 128×16 tree strip") either came from a different camera position or was misattributed — the visible wall band is not drawn by IA4 at boot (see D269 for what actually draws it).
|
||
|
||
**State: both fixes remain in-tree, UNCOMMITTED, with the probes.** They are necessary-but-unverified for the wall (the wall turned out to be a different texture class); the I2:A2 decode still owes a game-wide IA4 regression sweep before any commit. Nothing user-visible changed at the wall — consistent with D265's negative re-test.
|
||
|
||
**LANDED (M-140, R2 extraction, `fix/d266-d271-extraction` off `release/v0.3.0`):** the two real fixes only (IA4 I2:A2 decode + AC_DECAL-excluded texedge) extracted out of the parked D236 probe tree, stripped of every D267–D280 debug probe (census counters, PPM dumpers, per-tri logging — none of it behavior-affecting, but none of it belongs in a landed commit either). Full 21-level `verify.sh sweep` clean (20/21 PASS on first pass; the 21st, `dam`, came back NO-FRAMES/pixcount-0 on the sweep run but reproduced PASS on an immediate solo re-run — a load-sensitivity flake per the D117/D134 class, not a regression from this change). Build: 242/242, clean link. The D267–D280 probe code + everything else in the D236 investigation stays parked on `park/d236-probes-m139` for whenever the tree-instantiation thread resumes.
|
||
|
||
## D267 — M-126: IA8 dump probe + per-tile-slot upload-sequence tracking — the three IA8 images in play are an opaque noise card and two sparse mast sprites; none of them is the wall band
|
||
|
||
Probe infra (`GE_D267`, `gfx_pc.cpp`): PPM dumper + alpha histogram for 32px-wide IA8 strips in `import_texture_ia8`; per-tile-slot upload sequence tracking (`d267_seq` counter + `d267_slot_img[128]` stamped in the `import_texture()` dispatch) so tri-time probes can name which uploaded image a draw uses. Caveat discovered: `gfx_texture_cache_lookup()` returns early **before** the stamp on cache hits (key = fmt+addr+size+pal), so a draw's logged imgseq can be stale — the actual image is the earlier import that populated the cache (traceable via the GE_TEXI census log).
|
||
|
||
Results (level_36, 15 s): three distinct IA8 images — seq 113 (32×32, **fully opaque**, 1023/1024 texels at A=F; blocky noise pattern), seq 165 (32×64, sparse mast/pole sprite: 1344 transparent / 592 opaque / ~112 mid-alpha), seq 170 (32×64, another sparse sprite). Dumps in `scratch/d267_ia8_tile0_seq*.ppm`. None of these matches the horizon stripe band → the wall is not an IA8 card either. Combined with D266: **the entire IA-focused framing of D236/D265 was examining the wrong texture class.**
|
||
|
||
## D268 — M-127: force-alpha A/B test proves the texel-alpha blend pipeline works end-to-end — the wall is NOT a broken blend path
|
||
|
||
`GE_D268_FORCEA=0|15` in `import_texture_ia8` forces every IA8 texel alpha to 0 (transparent) or F (opaque). Three captures at frame 730 (`scratch/d268/{normal,alpha0,alphaF}_730.ppm`): the horizon band region vanishes with alpha=0 and becomes a solid bright block with alpha=15. The GL blend state, texel-alpha path, and shader generation all composite correctly — whatever renders the wall does so with *correct* blending of *something*. (Side note: the user's 9/14 BSOD 0x119 was HWiNFO64/nvml.dll, not the game — game log clean.)
|
||
|
||
## D269 — M-128: **the wall is CI8 32×32, not IA at all** — band-gated all-format probe identifies two draw classes per room stream (OPA + XLU), both BILERP/LOD/2-cycle/**FOG-on**; the cards are soft gradient blobs, and the stripe period ≈ one card per visible room
|
||
|
||
`GE_D266W` v4 (`gfx_sp_tri1`, env-gated): tracks **all** draw classes whose projected vertices fall in the top 3–9% of the viewport (the measured stripe band), logging fmt/line-width/oml/omh/zmode/cyc/blender/imgseq + first-vertex screen position. Results per room stream (imgseq cycling ~174→179→264→269→282…, shared ROM addresses 0x70173840 / 0x70173de8):
|
||
|
||
- **Class A (OPA):** CI8 32px, oml=0xc4112078, omh=0x0099ac20, first vertex screen(1.30, 0.07) — off-screen right
|
||
- **Class B (XLU):** CI8 32px, oml=0xc41049d8 (m1=CLR_IN·A_IN / m2=CLR_MEM·1MA = standard straight-alpha), omh=0x0099ac60, zmode=XLU, first vertex screen(0.61, 0.08)
|
||
|
||
**omh decode (GE gbi.h field layout: TEXTFILT@12, TEXTLUT@14, TEXTLOD@16, TEXTPERSP@19, CYCLETYPE@20):** both classes are **BILERP + TEXTLOD-on + perspective-corrected + 2-cycle**, and **bit 13 = FOG enabled**. The band is ~19 repeats across a 1957px frame (period ≈101px) ≈ one card per visible room in the row.
|
||
|
||
Texture content (`GE_TEXDUMP`/`GE_TEXRAW` + offline decode of `texdump/r*.bin`): the CI8 cards are **soft vertical gradients / sparse blobs** (index histograms concentrated at low indices, e.g. idx 6–14; ASCII renders show gradients and small clumps, not tree silhouettes). NCC correlation of every dumped card against the frame-730 capture at scales 1/2/4: nothing above 0.35 — the cards are not visible as rigid copies (consistent with bilerp magnification + fog wash).
|
||
|
||
**Reframed hypotheses, ranked:**
|
||
1. **Fog.** Both classes have FOG on. On N64 these distant cards are presumably washed toward the sky colour into a smooth pale band; if the PC's fog range (`rsp.fog_mul`/`fog_offset`; per-vertex fog computed at `gfx_pc.cpp:1591`, `fog_z = z*winv*rsp.fog_mul + rsp.fog_offset`) or its shader application differs, the raw dark gradient cards show through as hard stripes. D218 family ("fog/far-clip tint"). **Cheapest test:** extend the band probe to log per-vertex fog for class A/B tris (if vFog≈1 but screen shows dark → fog not applied in shader; if vFog small → range mismatch), or env-gate a fog disable and compare.
|
||
2. **TEXTLOD / mipmap sampling.** N64 samples mip levels when minifying; check whether the port generates GL mipmaps for these tiles (tex_lod path) and which min/mag filter is used — and first measure the *projected quad extent* (the probe only logs one vertex; if cards are minified rather than magnified, mips matter directly).
|
||
3. **Draw density / portal culling.** PC may draw cards from rooms N64 culls → denser wall. Compare total band-tri count vs expected visible-room count.
|
||
4. **Palette (D217 family).** GE_TEXRAW now also writes `.pal` (512 B = 256 RGB555) for CI formats; the aborted run never produced them. Decode class A/B cards as RGB and check colours against the N64 ref (`docs/img/bugs/d227-surface-emulator-ref.png`: smooth bright band + organic clusters centred x≈0.35–0.75).
|
||
|
||
**Owed next (concrete):** (1) re-run `rm -rf texdump && mkdir texdump && GE_TEXDUMP=1 GE_TEXRAW=1 GE_D267=1 timeout 15 ./build-pc/ge007.x86_64.exe -level_36` and confirm `.pal` files land; (2) decode `texdump/r173..r282_f2_*` CI8 cards + palettes offline to RGB PNGs for the user's eyeball (this model cannot view images — programmatic analysis only; a vision-capable session could do the N64-ref comparison directly); (3) add projected quad extent (min/max screen x over the 3 vertices) + per-vertex fog to the `GE_D266W` band probe — one more run decides hypothesis 1 vs 2.
|
||
|
||
## D271 — M-129: **the wall, third pass — suspect #3 (draw density / portal culling) confirmed as a real over-draw; the D106 guard was the cause.** Near-plane-straddling portals produce huge *finite* bounds that N64 clamps/culls in `bgRectIntersect`; the PC guard mapped them all to full screen, force-drawing whole rooms N64 never submits. Guard narrowed to non-finite-only; culling now matches N64
|
||
|
||
**Context.** D269's ranked suspects for the Surface 1 tree wall ended with "#3 draw density / portal culling — PC may draw cards from rooms N64 culls → denser wall". This pass tests exactly that, using the boot camera (`-level_36`), where the wall is most prominent and the scene is (nearly) static.
|
||
|
||
**Instrumentation (`#ifdef PORT`, `src/game/bg.c`, all env-gated, kept in tree).**
|
||
- `GE_D271` in `sub_GAME_7F0B5864` (the portal-bounds builder): per-point capture of every clip point's `z` and its `transform3Dto2DWithZScaling` projection; on each D106-guard hit, dumps the portal number, connected rooms, flags, scale, and all points (`D271 d106 …` / `D271 ptN …` lines). A second probe logs **finite** out-of-range bounds that now flow through to `bgRectIntersect` (`D271 oor-finite …`, budget 8) so their clamp outcome can be checked against N64 semantics.
|
||
- `GE_D104` print line extended with `hasPortals=… ordDegen=… d106Fallback=…` — the two counters separate the *ordinary* degenerate case (genuinely edge-on portal, min>max on a real axis — expected, N64 does the same) from the D106-fallback case.
|
||
|
||
**Findings.**
|
||
1. It is not one portal: at the Surface 1 boot camera, **11 different portals around room 13** straddle the camera near plane every frame (a clip point lands exactly at z==0; `transform3Dto2DWithZScaling`'s `inv_z = -1e20f` sends it to ±1e20–1e26-scale screen coords). The D106 guard (`lim=1e5`) fired on **every frame** (~396–781 hits per 600-tick print window) — occlusion culling was effectively defeated for that viewpoint.
|
||
2. **The N64 semantics the guard got wrong:** there is no such guard in the N64 code. The huge values are *finite*, and `bgRectIntersect` is pure min/max comparisons: a bound pair that brackets the screen clamps to the parent/screen box (portal covers the view → room kept); a pair whose finite portion falls off-screen yields an **empty** box (room culled). The original guard collapsed both cases to "full screen" — correct for the first, **over-inclusive for the second**.
|
||
3. Per-portal check of the over-included case (`D271 oor-finite`): every culled portal's finite portion is far off-screen — e.g. Surface 1 p54 (r22) min.x=997 (>319, entirely right), p68 (r8) max=(-282,-391) (entirely left/below), p73 (r14) min.x=500; BUNKER1 p28 (r25) y-range 339.85..2.9e24 (entirely below the 10..230 screen). With identical f32 values N64 produces the identical empty clamp → **N64 culls these rooms from these viewpoints; the old guard was drawing them.**
|
||
|
||
**Fix (one constant, `src/game/bg.c` `sub_GAME_7F0B5864`, `#ifdef PORT`).** D106 guard `lim`: `100000.0f` → `1e38f`. Rationale: this projection can reach ~1e26 at most, so 1e38 catches only non-finite values (NaN/inf), which are genuinely unsafe to let through (NaN poisons the downstream comparisons). Finite out-of-range bounds now flow into `bgRectIntersect` exactly as on N64. The finite min>max case is untouched — it still hits the original `degenerate` check (the N64 code path itself), so D106's actual regression (BUNKER1 sky-fill voids from *discarded* portals) stays fixed.
|
||
|
||
**Verification.**
|
||
- `d106Fallback`: ~396–781/600 ticks → **0** on both Surface 1 (`-level_36`) and BUNKER1 (`-level_09`); `ordDegen` unchanged (ordinary edge-on portals still map to full screen, as on N64).
|
||
- roomsDrawn at the Surface 1 boot view: **17–18 → 10–13**; per-room boxes now narrowed correctly (e.g. r8 = right sliver `246,10-319,230`, r12 full-screen via a genuine bracketing clamp) instead of ~18 full-screen boxes.
|
||
- BUNKER1: roomsDrawn 4→3 (r6 state, lost r5) and 3→1–2 (r29 state, lost r25/r28). Traced per portal above — the losses are the N64-matching empty clamps, i.e. moves *toward* N64 fidelity, not regressions; the D106 void case (discarded min>max portals) is untouched.
|
||
- Long unattended runs (`timeout 90+`): no crashes on either level; intro camera progresses normally to later states (curRoom=18, roomsDrawn=4).
|
||
- **Pixel-level A/B — inconclusive, owed to the user.** Frame captures at matched `curRoom` state (frame 1500, room-13 state: `scratch/d271_1500_sbs.png`, baseline left / fixed right) show a 14.7% changed-pixel difference — but a same-build/same-state pair differs by 14.8% with an *identical* spatial grid (top-heavy band = animation/camera micro-motion at this viewpoint, not the fix). The boot view is not static enough for programmatic A/B; the user's eyeball on the tree wall is the deciding check.
|
||
|
||
**OUTCOME (user re-test 2026-09-14): NEGATIVE.** With the fix in place the trees still render as one big wall — and the user's read is that **the texture itself isn't being mapped correctly**, not merely an over-draw/alpha problem. So suspect #3 was a real N64-divergent over-draw (kept — it is a fidelity fix, do not revert) but it is NOT the wall's root cause. The D265 note gains weight: *"all captured cards sampled the same u:0..64/v:0..16 window — confirm against N64"* — if every card samples the same region of a shared texture, the result is exactly a repeating sheet. Top suspect for the next pass: **per-card S/T coordinates / UV windowing** (model DL vertex S/T, tile setup foldS/foldT/tcShift, or `d43_emit.py`'s S/T conversion), then TEXTLOD/mip sampling, then palette.
|
||
|
||
**LANDED (M-140, R2 extraction, `fix/d266-d271-extraction` off `release/v0.3.0`):** just the one-constant fix (`lim = 100000.0f` → `1e38f`), stripped of the `GE_D271`/`GE_D104` debug probes (per-portal capture, hit counters) — none of it changes behaviour, none belongs in a landed commit. Checked against **D249** (distant-geometry drop-out on Streets/Egyptian, per M-139's R2 overlap concern) before landing: no overlap — D249 is a far-plane/LOD symptom on a different code path (open, unrelated), this fix is a near-plane portal-straddling guard; D249 stays open. Full 21-level `verify.sh sweep` clean post-extraction (see D266's landing note for the one dam/NO-FRAMES flake, confirmed non-regression on retry).
|
||
|
||
**Owed / next.**
|
||
1. **Per-card S/T audit** — dump the actual vertex S/T + tile for each wall card at draw time (extend `GE_D266W`) and check whether distinct cards carry distinct S/T windows; compare against the N64 ref (`docs/img/bugs/d227-surface-emulator-ref.png`: smooth bright band + sparse organic clusters, not a continuous sheet).
|
||
2. Regression sweep of other levels' room counts is cheap insurance (`GE_D104=1` spot-checks); BUNKER1 already re-verified above.
|
||
3. Probes are budget-limited and env-gated; keep until the user check lands, then remove with the D266–D269 probe set if the wall closes.
|
||
|
||
## D272 — M-130: **the wall, fourth pass — per-card S/T audit (D271's owed next-step #1).** Distinct cards carry distinct (not identical) UV windows; the XLU class wraps its CI8 tile ~4.5× horizontally; traced to genuinely-authored ROM vertex data, not a `d43_emit.py` conversion bug — hypothesis shifts back to TEXTLOD/mip handling of a heavily-wrapped, highly-minified tile
|
||
|
||
**Context.** D271's fix (portal-culling guard) was verified but the user's re-test was negative: "trees are a big wall rather than the texture being mapped correctly." HANDOFF's next-steps for this thread ranked **per-card S/T / UV windowing** as the top suspect, driven by a D265 note that "all captured cards sampled the same u:0..64/v:0..16 window." This pass builds the audit HANDOFF asked for and answers the decisive question it posed: do distinct card instances carry distinct S/T windows, or identical ones?
|
||
|
||
**Instrumentation (`#ifdef PORT`, `port/fast3d/gfx_pc.cpp`, `gfx_sp_tri1`, env-gated `GE_D272`, budget 200 lines, kept in tree).** A new raw per-triangle dump, filtered to D269's exact two wall-card classes by low-24-bit `other_mode_l` (`0x112078` OPA / `0x1049d8` XLU — matching regardless of the top-byte pipeline bits, which vary between otherwise-identical-looking draws sharing the same blend equation). For each matching tri inside the measured wall band, logs: `oml`/`omh`, the bound texture's `seq` (image identity), tile `fmt`/`siz`/`line`/`width`×`height`/`mask`/`shift`, the tile's own UV window (U10.2 → texels, `/4`), and all three vertices' UV (vertex ST is s10.5 → texels, `/32`).
|
||
|
||
**Findings.**
|
||
1. **Both classes confirmed CI8** (`fmt=2 siz=1`), matching D269 exactly — the earlier unfiltered run of this same probe (before the oml filter was narrowed) picked up an unrelated `fmt=3` (IA) class sharing the same low-24-bit blend bits under a different top byte; that class is NOT one of D269's wall cards and is excluded by construction once the filter checks the full pair of known `oml` values.
|
||
2. **UV windows are NOT identical across instances of the same class/texture.** OPA class (`seq=4`, 8 samples): first-vertex U ranges 5.25→5.56 (spread ≈0.3 texel), consistent quad shape (u≈0..32, v≈0..11) each time but never byte-identical. XLU class (`seq=168/169`, multiple samples): U range spans ~143.16–144.81 (spread ≈1.6 texel) across different physical card instances. This is genuine per-vertex variation, not a converter collapsing every card onto one hardcoded window — **D265's "identical window" observation did not come from these cards**; it most likely came from a different draw class caught by the old unfiltered/undifferentiated band probe (D266W has no oml filter and this screen band is crossed by several unrelated classes — see the `fmt=3`/`oml=0x0f0a4000`/`0x00504340` families logged in this session's first (unfiltered) D272 run, several of which *do* show identical `(0,0)` UV across every instance — degenerate/filler geometry, not wall cards).
|
||
3. **The XLU class's U range is ~143–145 texels wide — ~4.5× its own tile's 32-texel wrap period** (`mask=5` ⇒ `1<<5=32`). The OPA class stays within roughly one tile width (u≈0..32). This is a large, consistent multi-wrap pattern, not noise.
|
||
4. **Traced to `tools_pc/d43_emit.py`'s vertex emit path — no converter bug found.** `emit_main_vtx` (the function that writes every model's `Vtx` array) copies `tc[0]`/`tc[1]` (the S/T fields, offsets 8/10 of the 16-byte N64 `Vtx`) via `put_s16(q+8, be16(src, o+8))` / `put_s16(q+10, be16(src, o+0xA))` — a plain big-endian→little-endian 16-bit byteswap, **no scale, no offset, no reinterpretation**. Every other field in the same struct (`ob[3]`, `flag`, `cn[4]`) goes through the identical plain-copy pattern. There is no code path here that could inflate, rescale, or corrupt a small authored S/T into a ~4.5×-wrap value — the ~144-texel figure must already exist in the ROM's ilbytes at that vertex. **This rules out the converter as the source of the wide wrap** (cf. the D108–D112 `put_f32` byte-reversal precedent this thread was checking against — that bug class is not present here).
|
||
|
||
**Reframed hypothesis (supersedes D271's "top suspect").** Since the wide UV wrap is real, ROM-authored data (Rare's own design: tile one CI8 texture ~4.5× across a long background quad), the wall is not a windowing/converter bug. The candidate mechanism shifts to *how* the port renders a heavily-wrapped, heavily-minified (TEXTLOD-on) tile: GL's `GL_REPEAT` + whatever mip level(s) OpenGL selects/generates for this tile vs. the RDP's own per-scanline TMEM address wrap + LOD blending. D269's hypothesis #2 (TEXTLOD/mip sampling) is now the leading suspect again, ahead of fog range/application and palette.
|
||
|
||
**Owed / next.**
|
||
1. Measure the actual on-screen minification factor for these cards (projected quad extent vs. tile texel count) — if the quad is only a handful of screen pixels wide while sampling 144+ texels, GL's automatic mip selection is likely picking a coarse level, and any difference from the RDP's own LOD blend range would show up exactly as "hard repeating pattern" vs. "soft distant band."
|
||
2. Check whether `glGenerateMipmap` (`gfx_opengl.cpp:748`) is actually invoked for these specific CI8+`GL_REPEAT` tiles, and whether the generated mip chain wraps correctly at the tile's `1<<mask` period rather than at the raw 32×32 upload (should be identical for a power-of-two tile, but confirm — this is the one open question RC3/D167 didn't fully close).
|
||
3. Cross-check against the N64 reference (`docs/img/bugs/d227-surface-emulator-ref.png` / `mupen64/`) to confirm whether the intended look really is "soft blended repeats" (mip-driven) rather than a different UV entirely — do not assume without a direct look.
|
||
4. Probe is budget-limited (200 lines) and env-gated (`GE_D272`); keep until the wall closes, then remove with the D266–D272 probe set.
|
||
|
||
## D273 — M-131: **N64 ground-truth screenshot obtained; palette-decode A/B (pre-existing, undocumented `GE_D273` probe found in the uncommitted tree) tested against it and RULED OUT** — the standard ARGB1555 palette layout does not change the wall's appearance at all, but corrupts unrelated CI8 textures (path, viewmodel skin tone)
|
||
|
||
**Context.** The user supplied two reference screenshots (`docs/img/screenshots/gepd-surface.PNG` — 1964 N64 emulator, GEPD edition ROM, same Surface 1 corridor; `docs/img/screenshots/port-surface.PNG` — our PC port, same location) for a direct side-by-side the thread had been missing (D271/D272 both flagged "no direct N64 look yet" as an open risk). **N64 reference: discrete conifer silhouettes (trunk+canopy visible) lining both sides of a snow corridor, blending softly into a dusk sky — sparse, not a sheet.** **Our port: a continuous, uniform, fine-grained diagonal herringbone/houndstooth weave covering the same wall area, flat-topped, no tree silhouettes at all** (`scratch/d273_base_1500.png`, `-level_36` frame 1500, matches the user's `port-surface.PNG` framing).
|
||
|
||
**Also found this session (not written up by whoever added it): an existing, uncommitted `GE_D273` A/B probe already in `port/fast3d/gfx_pc.cpp`'s `palette_to_rgba32`** (env-gated, default off, part of the pre-session diff — not authored this session, and not mentioned in HANDOFF.md or findings.md before now). Its comment hypothesizes the inherited PD `RGBA16`/CI-palette decode (`a=bit0, r=15:11, g=10:6, b=5:1`) is one bit off the N64 hardware `ARGB1555` TLUT layout (`a=15, r=14:10, g=9:5, b=4:0`) that GE's RSP ucode actually uses for CI palette expansion, and that for one sampled wall-card palette entry (`0x0011`) this flips the AC_NONE alpha-test outcome from discard (sparse, N64-correct) to keep (opaque sheet, the wall bug) — a plausible, concrete mechanism, matching the HANDOFF's long-parked "side suspect: palette" line, but never actually tested against a live frame.
|
||
|
||
**Test performed this session.** Captured matched-tick frames (`GE_PCDUMP=1500-1500`, `-level_36`) with and without `GE_D273=1`.
|
||
- **Wall region: visually unchanged.** Same herringbone pattern, same density, same silhouette (none). A pixel diff of the wall crop shows a non-trivial mean delta (~28/255), but this is consistent with D271's already-documented run-to-run boot-camera/room-streaming jitter (~14–15% baseline noise between two *identical*-build runs at this same viewpoint) — not a content change; eyeballing both frames side by side shows the identical weave.
|
||
- **Everything else got worse.** The snow path (also CI8-textured) turned into a garish cyan/magenta moiré, and the first-person hand/viewmodel skin tone rendered teal-green instead of flesh tone — both regressions exactly where you'd expect if the "standard" ARGB1555 reading is in fact the wrong one for these particular palette sources, or the reinterpretation is simply not selective enough (the probe applies unconditionally to every CI4/CI8 palette lookup, `ignoring palette_fmt` per its own comment, which is a blunt instrument for testing one specific texture's TLUT).
|
||
|
||
**Conclusion.** The palette-bit-layout swap is **not** the wall's root cause: it does not visibly touch the wall at all, while breaking unrelated known-good CI8 content. `GE_D273` should stay default-off; do not enable it or pursue this specific decode-layout change further for the wall. The underlying question the probe's comment raised (is GE's RSP CI-palette expansion really "standard" ARGB1555 regardless of `palette_fmt`, per `gmain.s`) may still be worth resolving someday for its own sake, but it is decoupled from D236 now.
|
||
|
||
**Process note.** This probe existed in the working tree with a full, specific, plausible root-cause theory and was never mentioned in `docs/HANDOFF.md`'s CURRENT TASK or `docs/dev/findings.md` — a live investigative lead sat undocumented across at least one handoff boundary. Whenever picking up an in-flight investigation, `git diff` the suspect files (not just what the day's handoff narrates) before assuming the handoff is a complete inventory of uncommitted work.
|
||
|
||
**Owed / next.**
|
||
1. Get an actual reference capture from `mupen64/` (this session used a third-party 1964-based build the user had on hand, GEPD edition — a second, known-clean N64 emulator source would strengthen the ground truth) for a pixel-level comparison, not just an eyeball read.
|
||
2. D272's TEXTLOD/mip hypothesis is still the live lead — this session only closed off the palette branch. Proceed with D272's owed next-steps (minification measurement, confirm `glGenerateMipmap` actually fires for this tile).
|
||
3. Given the N64 reference shows genuinely discrete tree shapes (not merely "softer" versions of the same repeating pattern), also keep open the possibility that the port is sampling entirely wrong texture content for this tile (not just filtering it differently) — the herringbone weave does not resemble D269's offline-decoded "soft gradient / sparse blob" content at all, which is itself a discrepancy worth reconciling before committing further to the mip-filtering theory.
|
||
|
||
## D274 — M-132: **seq/addr identity is unreliable across runs (both are runtime-arena, not stable ROM, identifiers) — fixed with an unconditional per-slot address stamp; the "content vs. decode" discrepancy D273 flagged is RESOLVED as an offline-analysis bug in this session, not a port bug — mips confirmed generated + enabled for the real wall-card tile, narrowing the live suspect to mip-blend *quality*, not presence**
|
||
|
||
**Context.** An external review of the D273 write-up (this session) flagged that all three passes analyzing "the wall cards" (D269, D272, and a `gfx_pc.cpp` code comment) reported different `seq` ranges, and correctly identified the cause: `d267_slot_img[tile]` (D267) is stamped in `import_texture()` *after* the texture-cache-hit early return (`gfx_texture_cache_lookup`, line 1131) — on a cache hit the stamp is never refreshed, so it can read back the identity of whatever last triggered a real upload into that TMEM slot, not the texture actually bound for the current draw. The review recommended anchoring identity on the ROM source address instead.
|
||
|
||
**Fix (`port/fast3d/gfx_pc.cpp`, `#ifdef PORT`).** Added `d267_slot_addr[128]`, a companion array stamped **unconditionally**, immediately after `orig_addr` is known and *before* the cache-lookup early return — so it always reflects the real source address of whatever is currently bound to that tile slot, cache hit or miss. Threaded into every D272-family log line (`D272[...]`, `D272S`, `D272U`, `D272P`) as `addr=%p`.
|
||
|
||
**This fix immediately surfaced a second, independent identity hazard: the runtime address itself is not a stable cross-run identifier either.** Two hard confirmations from live runs against the same build/level:
|
||
1. **Same `seq`, different `addr`, same run.** Isolating the XLU wall class alone (`GE_D270KEEPOML=1049d8 GE_D272=1`), `seq=65` was logged against **two different addresses** (`0x701587c8` ×161, `0x70155738` ×17) in one 15 s capture — direct proof the seq stamp is stale mid-run, not just across runs.
|
||
2. **Same `addr`, different declared format, different run.** `0x70155738` (one of the two addresses `seq=65` produced) showed up in a *separate* run (with `GE_TEXDUMP=1`) as `GE_TEXI[4] fmt=3 siz=1 tile=54x54` (IA8) — a completely different format/size than the CI8 32×32 the tri probe recorded for it moments earlier in a different run. GE's texture/model pipeline decodes into a shared runtime arena (the M-88 lesson already in `docs/porting-notes.md`: "GE's model and image pipelines resolve almost everything through small numeric IDs into shared, runtime-decoded pools"); load-order timing (already documented as non-deterministic across runs by D271) evidently reuses the same arena address for different content run-to-run. **Neither `seq` nor the raw runtime `addr` is a stable identifier across separate process runs — only within a single run, and even then only outside a cache-hit window for `seq`.** D269's recorded "shared ROM addresses 0x70173840/0x70173de8" should be read as *that run's* addresses, not persistent identifiers to search for in a fresh capture.
|
||
|
||
**Practical consequence for this pass:** identify and decode the real wall-card texture **within one run**, correlating `D272U`'s `addr=` (confirmed CI8, confirmed actively drawn as a wall card in that same run) against that same run's `GE_TEXI[N]`/`texdump/rNNN_*.bin(.pal)` dump by address match, not by `seq`. Done: `GE_TEXDUMP=1 GE_TEXRAW=1 GE_D270KEEPOML=1049d8 GE_D272=1` in one combined pass (per the review's efficiency suggestion) → `GE_TEXI[11] addr=0x701587c8 fmt=2 siz=1 tile=32x32`, matching `D272U`'s live ci8-upload log for the same address in the same run → `texdump/r011_f2_s1_32x32.bin(.pal)` is unambiguously the real, currently-drawn wall-card texture.
|
||
|
||
**Decoding it exposed a bug in this session's own offline analysis, not a port bug.** First decode attempt (`struct.unpack('>256H', ...)` — big-endian) produced multi-hued rainbow noise under *both* candidate palette layouts (port's shifted decode and the standard ARGB1555 from D273) — which looked like a genuine new "wrong content" finding until traced back: `rdp.palette` is an in-process `uint16_t[256]` array, and `GE_TEXRAW`'s `.pal` dump (`fwrite(rdp.palette, 1, 512, pf)`) writes it as **raw native-endian bytes** — little-endian on x86-64, not the big-endian the ROM's own on-disk TLUT would be. Re-decoding with `'<256H'` (little-endian) immediately produced a coherent image under the port's existing default (shifted) decode: a smooth gray/brown diagonal gradient wedge, closely matching D269's original "soft vertical gradients" description. The **same ARGB1555 alternate that D273 already ruled out for the live scene** also decodes this specific real texture into a broken, speckled result (mostly-transparent with scattered noise along the edges) — a second, independent confirmation that D273's conclusion was correct and this session's earlier "content vs. decode" open question (from the D273/D236-sixth-pass HANDOFF entry) is now **resolved: there is no decode discrepancy.** The port's existing palette decode is correct for this texture; D269's "soft gradient" description was accurate.
|
||
|
||
**Mip/LOD state confirmed nominal for the real tile** (closes D272's owed next-step #2 for this specific texture): `D272U[4] ci8 upload 32x32 genmip=1` (mip generation *is* requested at upload) and `D272S ... addr=0x701587c8 ... cms=0 cmt=0 tex_lod=1` (LOD/mip sampling *is* enabled at draw time; `cms=cmt=0` = `G_TX_NOMIRROR|G_TX_WRAP` = `GL_REPEAT`, as expected for the ~4.5× wrap D272 measured). **Rules out "mips are simply missing/disabled" as the mechanism.** The remaining candidate narrows to *quality*, not *presence*: whether GL's automatic trilinear mip chain, sampling a small (32×32) diagonal-gradient tile repeated ~4.5× within a small screen-space quad, actually reproduces the RDP's explicit `TEXTLOD`/lod-frac blend closely enough — a smooth gradient wedge tiled many times, if under-blended at the wrap seams, is a plausible mechanism for a regular diagonal herringbone/houndstooth artifact (the wedge's own diagonal edge, repeated, is exactly that shape).
|
||
|
||
**Attempted, inconclusive: the two-class moiré A/B** the review also asked for (render with `GE_D270KEEPOML` isolating first the OPA class, then the XLU class, to see whether the weave is a single-class tiling artifact or an interference beat between the two overlapping classes). `GE_D270KEEPOML=112078` at the boot-camera frame 1500 produced a **fully black frame** — not just "wall cards missing," *nothing* drew, including geometry that should be unrelated to the wall (this needs its own triage: either the low-24 match genuinely excludes everything in view at this exact tick, or the keep-list interacts with something upstream of the wall class in a way that blanks the frame). Not investigated further this pass; flagged as owed, not blocking — the palette/mip findings above stand independent of it.
|
||
|
||
**Owed / next.**
|
||
1. **Debug the `GE_D270KEEPOML` all-black result** before relying on it — log which `oml` values actually cross the screen at the target tick (the existing unfiltered `GE_D266W` can do this) and confirm `112078`/`1049d8` are really present, then redo the class-isolation A/B.
|
||
2. **Mip-blend quality, not presence, is now the question.** Compare a forced-coarse-mip render (LOD bias, or temporarily forcing `GL_NEAREST_MIPMAP_NEAREST`) against the current trilinear result at the wall — if a coarser/blurrier mip visibly smooths the weave into something closer to the N64 reference's soft band, that confirms the mechanism and points at LOD selection/bias tuning as the fix; if it doesn't change materially, mips are a dead end and the remaining candidates are GL_REPEAT-wrap seam continuity across mip levels, or something in how the two overlapping (OPA+XLU) classes composite.
|
||
3. Regenerate `docs/dev/findings-index.csv` (`tools_pc/gen_findings_index.py`) — done this pass, but re-run again after any further findings.md edits.
|
||
4. **Process note, separate from the palette-decode-byte-order bug above**: `D270` (the skip/keep-oml hooks, the `D270P`/`D270KEEPOML`/`D270SKIPOML` machinery, and the `tdc` cap raise 400→600) has never had its own findings.md row — only code comments and HANDOFF-archive mentions. Same undocumented-lead class D273 flagged. A one-line backfill row is owed whenever someone next touches that thread; not done here to stay focused on D236. **Now backfilled as its own index row, see below.**
|
||
|
||
## D275 — M-133: probe upgrades for the eighth pass — `GE_D270PIX` extended from one point to a list, `GE_D270KEEPOML` gained a "kept N tris" counter (an external review's step 1/2 groundwork)
|
||
|
||
**Context.** A second external review of the D274 write-up proposed a four-step eighth pass: (1) multi-point pixel attribution + fog state inside the weave to name the actual front-most painter and close D269's never-done "log per-vertex fog" step; (2) an aligned class-isolation A/B with a visible "kept" counter so a black capture can be told apart from a genuine render bug; (3) an LOD-quality A/B; (4) stay alert to the framing risk that the confirmed wall-card content (a gradient wedge, D274) cannot by itself explain the N64 reference's discrete conifer silhouettes — a second, unattributed draw class is likely responsible for those, and D236 may split into two separate defects. This entry covers the mechanical probe work for steps 1–2; D276 covers what step 1 actually found.
|
||
|
||
**Changes (`port/fast3d/gfx_pc.cpp`, `#ifdef PORT`, both still env-gated/opt-in).**
|
||
- `GE_D270PIX="x0,y0[;x1,y1;...]"` (was single-point `"x,y"`): up to 8 normalized screen points, semicolon-separated. Each `D270P[ptN][...]` line now also carries `pix=(x,y)` (which point) and `addr=%p` (D274's reliable per-slot address, not `seq`).
|
||
- `GE_D270KEEPOML`: was silent. Now logs `D270KEEPOML: kept tri #N (oml24=0x...)` for the first 4 kept tris and every 5000th thereafter — a black capture with a nonzero kept-count is a genuine render-interaction bug; a black capture with `kept==0` at that tick is tick misalignment or an empty keep-set, not a probe failure.
|
||
|
||
**Offline groundwork before the live run:** picked 4 candidate weave pixels directly from `scratch/d273_base_1500.png` (the confirmed reproduction of the user's reported wall) by eyeball + sampled RGB to confirm they land in the gray weave band, not the snow floor or the black letterbox bars: `(0.05,0.10)`, `(0.25,0.15)`, `(0.85,0.12)`, `(0.50,0.06)` (the last one turned out to be sky, not wall — a miss, harmless, see D276).
|
||
|
||
**Attempted, still open:** the class-isolation A/B itself (review's step 2) was not completed this pass — D276's step-1 finding reframed the whole "which class is the wall" question first, which the review itself said should gate everything else ("step 1 first because it's the only step that can invalidate the current framing"). Re-run step 2 with the *correct* class identified in D276, not the D269-named OPA/XLU pair.
|
||
|
||
## D276 — M-134: **the true painter of the visible weave, at the confirmed weave pixels, is a fully-opaque IA8 blocky-noise/static texture (`oml=0xc81049d8` low bits, `fmt=3 siz=1`) sampled with a fixed, identical UV window and comparatively low fog — NOT either of D269's named CI8 classes, which don't even appear at two of the four sampled points.** D265's very first "all cards sample the same u:0..64/v:0..16 window" observation matches this class exactly; D269 reframed away from it prematurely.
|
||
|
||
**Method.** Ran the D275-extended `GE_D270PIX` at the 4 confirmed weave pixels with tick-aligned captures (`GE_PCDUMP=1300-1700:25`, 17 frames, all visually confirmed non-black/weave-consistent — ruling out tick misalignment as an explanation this time), logging every triangle covering each point across the whole 35 s run, then grouped by `oml` and summarized `z/w` and fog ranges per point offline.
|
||
|
||
**Findings.**
|
||
1. **At the two points confirmed inside the weave band** (`(0.05,0.10)`, `(0.25,0.15)`), **neither D269-named class (`0xc4112078` OPA, `0xc41049d8` XLU) appears at all.** The dominant, front-most (`z/w≈0.98–0.998`, excluding the sky quad's construction-mandated `z/w=0`), consistently-drawn class is `oml=0xc81049d8` (note: **low bits `0x1049d8` match the XLU class's low-24 bits, but the full oml/top-byte and — critically — `fmt=3` (IA) differ**; this is the same false-friend class D272/M-130 already had to filter out of its budget) with **average fog 39.5–63.3 out of 255** — mostly-unfogged, so its raw texel color reaches the screen largely unmodified.
|
||
2. **Decoded that exact texture within the same run** (`GE_TEXI[106] addr=0x70118d68 fmt=3 siz=1 tile=32x32` → `texdump/r106_f3_s1_32x32.bin`, IA8: high nibble intensity, low nibble alpha per `import_texture_ia8`): **a fully-opaque (alpha nibble mostly 0xf), high-frequency blocky gray static/noise pattern** — visually nothing like D274's gradient wedge, and nothing like a tree. This is almost certainly the same texture D267 already dumped and dismissed ("seq 113, 32×32, fully opaque, 1023/1024 texels at A=F, blocky noise pattern... none of these matches the horizon stripe band") — D267's dismissal was a single-instance visual judgement; it did not test what this content looks like *tiled*.
|
||
3. **Every single instance of this class, across every screen position sampled in an earlier unfiltered capture (`scratch/d274_run.log`), uses the byte-identical UV window** `u:0..64, v:0..16` (a 2× horizontal wrap of the 32-wide tile, sampling only its top half vertically) — **this is D265's original "all captured cards sampled the same u:0..64/v:0..16 window" observation, verified to the exact texel, and it belongs to this IA8 noise class, not the CI8 classes D269 and D272 spent three passes on.** D269's second-pass reframing away from D265's own lead was premature: it correctly proved the CI8 classes it found don't share one window, but never established those CI8 classes were the ones D265 was describing in the first place.
|
||
4. **A third class, `oml=0x0c184b50` (CI8, `fmt=2 siz=1`) — the "C-cards" the reviewing pass explicitly flagged as unattributed and a candidate for the N64 reference's actual discrete tree silhouettes — is present at every sampled point, with `z/w` ranging wildly from `-0.0381` to `0.9940`** (most other classes stay in a tight 0.96–1.00 band). Where it does land in the normal distant-geometry range, its fog is **high** (up to 255, i.e. fully fog-washed — plausible tree-fading-into-haze behavior) — but the erratic near-camera/behind-camera excursions suggest it is not reliably reaching the screen where it should, consistent with the review's flagged risk: a second, separate class may be responsible for the reference's actual tree shapes, and it may be missing or occluded rather than merely mis-filtered.
|
||
|
||
**Reframed conclusion.** The visible "wall/weave" the user reported is most directly explained by: a tiled, high-frequency, mostly-opaque IA8 noise texture (2× UV wrap, fixed window, low fog) reaching the screen essentially raw. D274's gradient-wedge CI8 texture was real and correctly analyzed, but is very likely a secondary/different layer, not the one producing the dominant visual artifact. The `0x0c184b50` CI8 class remains an open, unattributed thread that may be the reference's actual "trees" and may itself have a missing/occlusion bug — do not assume D236 has one root cause.
|
||
|
||
**Not yet done:** confirm this IA8 class's own UV-wrap-vs-fog behavior against the N64 reference directly (does N64 also wrap this noise 2× with low fog, or should the fog be much higher here, matching the `0c184b50` class's 193–255 range, which would wash it to near-invisible as intended); the class-isolation A/B (D275's still-owed step 2) should now isolate `0xc81049d8`/`fmt=3` vs. `0x0c184b50`, not the D269 CI8 pair. The mip-blend-quality experiment (D274's owed next-step, this pass's proposed step 3) is now lower priority — the newly-identified content (blocky noise, not a smooth gradient) makes a filtering-quality explanation less necessary than it seemed against D274's wedge; a wrong/insufficient fog blend is now at least as plausible a single-mechanism explanation and is cheaper to test (it's already logged, no new probe needed).
|
||
|
||
**Owed / next.**
|
||
1. **Fog-correctness check for the `0xc81049d8`/IA8 class**, now that D269's owed "log per-vertex fog" step is finally done: is `39.5–63.3` average fog the *correct* N64 value for this vertex position (compute what `fog_mul`/`fog_offset`/world-z *should* give and compare), or is it a computation bug specific to this draw path? If N64's own fog math would also produce a low value here, the wall is arguably N64-faithful in isolation and the real bug is elsewhere (e.g. the `0c184b50` occluder not painting over it); if the port's fog is wrong, that is a direct, cheap fix.
|
||
2. **Attribute and fix-scope the `0x0c184b50` CI8 class's erratic `z/w`** (`-0.0381` to `0.9940` at a single screen point) — likely candidate for a billboard/orientation bug (camera-facing quad computed with a bad or momentarily-invalid basis), separate from the noise-texture fog question. If this class is meant to draw the discrete tree shapes, understand why it doesn't reliably reach these pixels before concluding it's simply "the wrong layer."
|
||
3. Re-run the class-isolation A/B (D275) on the newly-identified classes, with the D275 kept-counter, at the D275-established tick-aligned window (`GE_PCDUMP=1300-1700:25`) so a black frame is diagnosable.
|
||
4. Regenerate `docs/dev/findings-index.csv` after this edit (done).
|
||
|
||
## D280 — M-135: **D236 wall, ninth pass — fog math VERIFIED N64-faithful (owed step #1 closed), the noise class VISUALLY confirmed as the weave's painter, and the "trees" question reframed: `0x0c184b50` is in FRONT of the noise quad (depth-race read inverted), its fog is CPU-baked bimodal 25/255, and it barely draws at all under deterministic `-level_36` boot — no quick win for trees; the remaining work is CPU-side instantiation/room-streaming, not a port fix**
|
||
|
||
1. **Fog math verified (D276 owed step #1, closed).** Computed the expected per-vertex fog from the game's own constants: RSP fog ramp 0..154 → `fmul=32000`, `foff=-31744`; the noise class's z/w ∈ [0.992, 1.0] maps to expected fog ≈ 26–28. Compared against the port's actual per-vertex fog for all **2505 vertices** of `0xc81049d8`: mean abs deviation ±2 (integer truncation only). D276's "low fog" is therefore the *correct* N64 value — on hardware this quad would also reach the screen largely unfogged. **Fog is not a bug; no port-side fix exists here.**
|
||
|
||
2. **A/B visual confirmation of the painter (D276 owed step #3, closed).** `GE_D278C=1` (collapse `0xc81049d8` to a point) + `GE_PCDUMP=1500-1500:1`: the weave band's high-frequency energy drops **14.8 → 2.9**, mean 108 → 121; the band becomes a smooth bright haze (the fully-fogged CI8 cards behind it). The pixel-attribution-only evidence from D276 is now corroborated visually — `0xc81049d8`/IA8 is definitively what paints the visible weave. **No trees appeared with it removed** — the first clean test of that question, unconfounded by the noise quad (all prior A/Bs, D268/D273, were behind it).
|
||
|
||
3. **Full class census at the confirmed weave pixels** (`(0.05,0.10)`, `(0.25,0.15)`): only three real classes reach either point — `0xc81049d8` fmt=3 IA8 (the noise painter), `0x0c184b50` fmt=2 CI8 (720 tris; fog bimodal 25/255, **gfog=0** → CPU-baked per-vertex fog), and `0xc8102078` fmt=2 CI8 (69 tris). Two further "classes" in the logs are probe artifacts: `0x00552048` (z/w=0, dl=1 placeholder) and `0x00504340` (z/w=-1, behind camera).
|
||
|
||
4. **Depth-race correction — D276's "occluder" framing was inverted.** N64 z/w: *smaller = closer*. At pt0, **278/356** `0x0c184b50` tris are CLOSER than the nearest noise tri (z/w < 0.9908); at pt1, 310/356. The tree cards are IN FRONT of the noise quad, not behind it. The open question is therefore "why don't the closer textured cards show?", not "does the noise win the depth race?".
|
||
|
||
5. **`0xc8102078` ruled out as a tree layer.** `-level_36` + `GE_D270KEEPOML=c8102078`: **1.4M triangles** fill the lower half of the screen (y ≥ 0.35) — terrain/ground, not conifers.
|
||
|
||
6. **The tree class barely draws under deterministic boot.** `-level_36` + `GE_D270KEEPOML=0c184b50` + `GE_TEXRAW`: ~3–4 tris/frame at tick 1500, zero visible pixels, empty `texdump/` (no CI8 upload). Meanwhile the non-`-level_36` fog run logged 720 covering tris. **`0x0c184b50`'s instantiation is strongly run-to-run dependent and `-level_36` does not pin it** — the room-streaming state that feeds this class varies even with the boot flag.
|
||
|
||
**Conclusion: no quick win for trees.** The visible weave is the IA8 noise class (N64-faithful fog, confirmed painter — arguably N64-faithful *in isolation*). The discrete conifers of the reference come from `0x0c184b50`, which (a) barely instantiates in deterministic runs and (b), when it does instantiate, sits in front of the noise yet still doesn't visibly win. Making trees appear requires understanding this class's CPU-side placement/instantiation (room streaming / billboard emission) — multi-step game-logic investigation, not a port-layer fix.
|
||
|
||
**Owed / next.**
|
||
1. Single instrumented run: `-level_36` + `GE_D270PIX` (both weave points) + `GE_TEXRAW` + `GE_PCDUMP=1400-1600:1`; correlate per tick — does a fog=25 `0x0c184b50` tri cover the point AND is the pixel dark?
|
||
2. Log `curRoom`/`roomsDrawn` (D271 infra) alongside `GE_D270PIX` to correlate `0x0c184b50` presence with room-streaming state.
|
||
3. Decode `0x0c184b50`'s CI8 texture offline — needs a run in which it actually uploads (TEXRAW has not captured it yet).
|
||
|
||
## D284 — M-140: `port/src/random.c`'s MIPS64 `dsll32`/`dsrl32` shift helpers were backwards — modelled as 32-bit shifts instead of "shift by n+32 on the full 64-bit register" — desyncing the PRNG stream from N64 from the very first draw, in every release to date
|
||
|
||
**Origin: M-138's security/decomp-fidelity review** flagged `random.c` as documented bit-exact to the N64 but not actually verified as such; a forked review agent's numeric trace and a hand trace of `src/random.s` in that same session both found the same divergence. Held open pending the user's fix-vs-disclose call; **M-139b: fix it.**
|
||
|
||
**Root cause.** `dsll32 rd,rs,n` / `dsrl32 rd,rs,n` are real MIPS64 instructions that shift the **full 64-bit register** by `n+32` — they exist specifically to encode a 32–63 shift amount in a 5-bit immediate field (`dsll`/`dsrl` alone only cover 0–31). The original port modelled them instead as ordinary 32-bit shift-by-`n` operations, masked to 32 bits after: `(x << n) & 0xFFFFFFFF` / `(x >> n) & 0xFFFFFFFF`. Both the shift amount (missing the implicit `+32`) and the masking (real hardware does not truncate — the destination register keeps whatever the 64-bit shift produces) were wrong. This is a "bit-exact-looking but wrong" bug: it compiles, runs, and produces a deterministic, self-consistent stream — it is just not *the N64's* stream, from the very first call.
|
||
|
||
**Verified two ways.**
|
||
1. **Hand trace of `src/random.s`** (`randomGetNext`, `randomGetNextFrom`, and the identical transform in `src/game/chrObjRandom.s` — three call sites, same bug, same fix) against the corrected semantics.
|
||
2. **A standalone C reproduction** (compiled and run outside the game, this session) comparing the buggy helpers against the corrected ones from the same seed (`0xAB8D9F7781280783`, `random.s`'s initial value): first five draws diverge completely (buggy: `0x783006, 0x83006830, 0x6830068, ...`; corrected: `0x40ec37cf, 0x630aedd7, 0x1f58071e, ...`) — confirms the bug is real and the fix changes the actual output stream, not just the code path.
|
||
|
||
**Fix (`port/src/random.c`, mechanical, `#ifdef PORT` not needed — this file only exists on PC, the N64 build uses `src/random.s`/`src/game/chrObjRandom.s` directly and is untouched):**
|
||
```c
|
||
static inline u64 dsll32(u64 x, int n) { return x << (n + 32); }
|
||
static inline u64 dsrl32(u64 x, int n) { return x >> (n + 32); }
|
||
```
|
||
(was `(x << n) & 0xFFFFFFFFULL` / `(x >> n) & 0xFFFFFFFFULL`). No other line in the file changes — every call site (`randomGetNext`, `randomSetSeed`, `randomGetNextFrom`, `chrObjRandomGetNext`, `chrObjRandomSetSeed`) already mirrors `random.s`/`chrObjRandom.s` line-by-line using these two helpers plus plain `<<`/`>>` (which already matched `dsll`/`dsrl` correctly — only the `32`-suffixed pair was wrong). One side effect worth flagging: `g_randomSeed`/`g_chrObjRandomSeed`/the `randomGetNextFrom` caller-owned seed now genuinely carry values beyond 32 bits between calls (matching the real `sd $a0, g_randomSeed` full-64-bit store) — callers only ever consumed the `u32`-truncated return value, so this is inert for them, but don't "simplify" the stored seed back to 32 bits.
|
||
|
||
**Verified:** 242/242 clean build; full 21-level `verify.sh sweep` PASS, no crashes (loot/AI RNG touches every level, so a sweep is the right breadth check here, not just one level). Not (and cannot be) verified: the corrected stream against a live N64/emulator capture of `random.s`'s real output — the hand trace + independent numeric reproduction is the practical ceiling of verification available in this repo without ROM-attached hardware/emulator tooling.
|
||
|
||
**User-facing note:** this silently changes every RNG-derived sequence (loot placement/rolls, AI behavioural variance, anything seeded from `randomGetNext`/`randomGetNextFrom`) from the first draw, in every build going forward — savegames remain compatible (the seed is not part of save state), but **existing replays recorded against the old stream will diverge** (replay state persists `g_randomSeed` itself, so a replay recorded pre-fix and played back post-fix, or vice versa, will not reproduce the same run). Called out in the v0.3.0 release notes per M-139c.
|
||
|
||
**CORRECTION (M-148, 2026-09-16, via D295 re-verification): "savegames remain compatible" is FALSE.** `fileGenerateCRC` (`src/game/crc.c`) — the save-slot validation checksum in `fileValidateSaves` (`file2.c:544`) — runs `randomGetNextFrom` off its own **local constant seed `0x8F809F473108B3C1`**, not `g_randomSeed`. The stored slot CRCs are therefore a fingerprint of the PRNG implementation that wrote them. Verified with compiled C from both sides of this fix against a real v0.2.x save (GitHub #87's `ge007.eep`): pre-fix PRNG reproduces all five stored slot CRCs bit-exact; post-fix PRNG matches zero → `fileValidateSaves` resets every slot. **Net effect: the first launch of any post-fix build (i.e. v0.3.0) silently erases every existing player's entire campaign progress.** This is a release-blocker-class regression distinct from the replay divergence above — decision owed (dual-CRC acceptance / one-time migration / loud release note), own D entry per D295 M-148.
|
||
|
||
## D285 — M-140: a real Steam Deck SIGSEGV during D255 live-testing turned out to be a SEPARATE, previously-undocumented bug — an unprotected shared-list read in `sndHandleEvent`'s preempt-scan (`src/snd.c:366`), classic D4-class main-thread-vs-audio-thread race, case identified but NOT fixed
|
||
|
||
**Context.** While setting up a live Deck test session for D255 (SSH access, `GE_D255=1`), found a fresh `ge007.crash.log` on the user's Deck (mtime inside the user's active testing window, confirmed by the user as "any crashes from the last hour or two on that build is my testing") from the **published v0.2.1 binary**, which still had its own debug symbols (`with debug_info, not stripped`). Symbolized directly on-device with `addr2line` against the exact crashing binary — not a rebuild, not a guess:
|
||
|
||
```
|
||
SIGNAL: 11 FAULT ADDR: 0x62
|
||
#00 crashStackTrace port/src/crash.c:332
|
||
#01 sndHandleEvent src/snd.c:366 <- fault site
|
||
#02 sndPlayerVoiceHandler src/snd.c:253
|
||
#03 _timeToSamplesNoRound src/libultra/audio/synthesizer.c:330
|
||
#04 amHandleFrameMessage src/audi.c:551
|
||
#05 amMain src/audi.c:452
|
||
#06 portThreadWrapper port/src/libultra.c:387
|
||
```
|
||
|
||
**This is a different crash from D255.** D255's known signature is `FAULT ADDR: 0x10`, PC inside `vtxstore_fix_refs` (render/game-logic thread, via `bossMainloop`). This one is `FAULT ADDR: 0x62`, entirely inside the **audio thread** (`portThreadWrapper` → `amMain`), a different subsystem and a different thread. The user's testing session (explosions/terminal-destroy repro) apparently hit *this* bug at least once, not necessarily D255 — the two may have been conflated in earlier reports because both surface as a SIGSEGV during the same kind of combat/explosion testing.
|
||
|
||
**Root cause (static, code-read only — not yet gdb-confirmed on a live crash).** `src/snd.c:366` is `iterState->unk3e` inside `sndHandleEvent`'s pool-exhaustion preempt-scan:
|
||
```c
|
||
ALSoundState *iterState = (ALSoundState *) D_800243E4.node.prev;
|
||
do {
|
||
s32 ownerlessLoop = (iterState->unk3e & SOUND_FLAG_LOOPED) && ... // line 366: the fault
|
||
...
|
||
iterState = (ALSoundState *) iterState->link.prev;
|
||
} while (limitReached && iterState != NULL);
|
||
```
|
||
`D_800243E4` (`struct D_800243E4_s D_800243E4 = { {NULL, NULL}, NULL };`) is the sentinel head of the active-`ALSoundState` linked list; `node.prev` is genuinely `NULL` when the list is empty (verified: `sndDisposeSound`, `snd.c:868-875`, resets `node.next`/`node.prev` to `NULL`-adjacent state as the list empties; `sndSetupSound`, `snd.c:802-824`, is the only inserter, always under a lock — see below). This do-while dereferences `iterState` **before** its own `!= NULL` check ever runs (do-while always executes the body once) — safe *only* if the caller can guarantee the list is non-empty. The scan is gated on `limitReached` (the 8-voice pool is full), which *should* imply at least 8 live nodes — so a `NULL` here means the list state and the pool-full accounting have desynced.
|
||
|
||
**Prime suspect: unprotected concurrent access, the established D4/D147/D152 race class** (`docs/porting-notes.md` §D4 — "N64 `osSetIntMask(OS_IM_NONE)` is their only mutual-exclusion primitive; on PC the 'audio interrupt' is a real preemptible thread, so a no-op shim = no mutual exclusion = concurrent linked-list mutation" — already fixed generally via a process-wide recursive mutex, D4/D152). Checked directly this session: `sndSetupSound` (the `D_800243E4` list-insert path, reached via `sndPlaySfx`, which **15 different `src/game/*.c` files call directly** including `explosion.c` — i.e. from whatever thread the caller runs on, not queued through the audio thread) wraps its list mutation in `osSetIntMask(OS_IM_NONE)`/restore (`snd.c:807,827`). **`sndHandleEvent`'s entire body (`snd.c:263-668`) — including the preempt-scan that reads/walks/mutates the same `D_800243E4` list — never calls `osSetIntMask` at all.** A writer (`sndSetupSound`, potentially running on the main game thread via a direct `sndPlaySfx` call) is lock-protected; this reader (`sndHandleEvent`, running on the audio thread) is not — a classic asymmetric-locking gap, exactly the shape that lets one thread observe another's list mutation mid-update.
|
||
|
||
**Fits the reported repro pattern.** Explosions synthesize a burst of near-simultaneous SFX triggers (explosion + debris/glass + metal), i.e. a burst of `sndPlaySfx` calls in a tight window — the highest-contention case for a race that needs the audio thread's preempt-scan (only entered when the 8-voice pool is *exhausted*, itself a busy/high-SFX-load state) to overlap a main-thread insert/remove. This matches the user's report of explosions/terminal-destruction reproducing it and Frigate (presumably lower simultaneous SFX load in that test) not reproducing it — a timing race, not a deterministic trigger, fits both observations.
|
||
|
||
**NOT fixed — two open questions before it should be.**
|
||
1. **Is the missing lock a genuine N64-decomp-fidelity gap, or ground-truth-faithful?** Unlike D4's already-fixed cases, this would be a *new* `osSetIntMask` call site in decompiled game code (`src/snd.c`), not a shim-implementation change. If the real N64 assembly at this function's address (comment header cites `9630 70008A30`-style N64 addresses elsewhere in this file — check `sndHandleEvent`'s own) already has an interrupt-mask pair around the equivalent scan that the decompiler dropped, restoring it is a decomp-fidelity fix (expected, not a rule-#2 case). If N64's real code genuinely never masks interrupts there (plausible if the real audio ISR's preempt-scan only ever runs from within the ISR itself, inherently serialized against the main CPU by hardware, with no real race possible on N64), then adding a new lock here is the same class of exception D4 already established (a PC-only concurrency fix, not a behavior change) — but that determination needs an MIPS-disassembly check against the real ROM, not a code read of the decomp alone.
|
||
2. **Not yet reproduced/confirmed via gdb on a live crash** — this is a strong static lead from one real crash's symbolized backtrace plus a code read, not a confirmed root cause. The next Deck session should `gdb attach` (per D253's established recipe) while reproducing via a burst of simultaneous SFX (an explosion is the natural trigger) and confirm `D_800243E4.node.prev == NULL` (or a torn/partially-updated node) at the fault, and check the exact other thread's state at that moment if possible.
|
||
|
||
**Do NOT conflate with D255** — different fault address, different thread, different subsystem, different code path. Both may be independently real; testing one does not rule out the other, and a fix for one will not affect the other.
|
||
|
||
**Next, concrete:** (1) settle question 1 above (N64 disassembly check or accept the D4-class port-layer exception); (2) if a lock is warranted, wrap `sndHandleEvent`'s preempt-scan (and audit the rest of the function for the same gap against every other `D_800243E4` touch point in the file) in `osSetIntMask(OS_IM_NONE)`/restore, matching `sndSetupSound`'s existing pattern; (3) live-verify via gdb on a real reproduced crash, not just the static read.
|
||
|
||
**M-141: FIXED, per the accepted D4-class port-layer exception; not yet live-verified.** Question 1 is settled by precedent rather than a fresh N64 disassembly read: `sndSetupSound` (the paired writer) already takes `osSetIntMask(OS_IM_NONE)`/restore completely unconditionally — **no `#ifdef PORT` at all** — around the identical `D_800243E4` list mutation, in this same file, with no N64-behavior caveat noted anywhere near it. Since `osSetIntMask` is genuine N64 API (not a port shim call added out of nowhere) and the port's own `osSetIntMask` implementation (`port/src/libultra.c`, "Interrupt mask -> global recursive lock", D147/D152) is a **process-wide recursive** critical section — safe to nest inside `alEvtqPostEvent`/`alSynSetVol` calls without deadlock risk — adding the identical lock/unlock pair around `sndHandleEvent`'s preempt-scan follows the exact same already-accepted shape as its sibling writer, unconditionally, no `#ifdef PORT` needed (matches the surrounding code's own style). Audited the rest of `sndHandleEvent` (`snd.c:263-680`) for other `D_800243E4` touch points per the open item above: **the preempt-scan is the only one** — nothing else in the function reads or writes that list. Fix: `src/snd.c`, wraps `ALSoundState *iterState = ...` through the closing `} while (limitReached && iterState != NULL);` in `OSIntMask d285Mask = osSetIntMask(OS_IM_NONE);` / `osSetIntMask(d285Mask);`. **Verified:** build green; `-level_09`/`-level_20` boot 1400+ frames each with zero crashes and zero `D152: osSetIntMask lock stuck` warnings (which would fire if this new lock ever wedged). **Not verified:** a `GE_INPUTSCRIPT`-driven rapid-fire stress test (modelled on D219/M-90's scripted-Z-pulse approach) was attempted to force the 8-voice pool exhaustion this scan requires, but the script's own parse-confirmation log line never appeared in this run's output — the scripted-input harness did not visibly activate for a reason not root-caused this session (worth a quick look next time `GE_INPUTSCRIPT` is used: confirm it still fires on current `main`/`release/v0.3.0` before trusting a "no crash" result from it as a real stress test). No live Deck/gdb reproduction attempted this session (no Deck SSH session active). Given the D4/D152 precedent's exact-shape match, confidence in the fix is HIGH; confidence that it's been exercised under real load is LOW — the honest bar met here is "correct by precedent, build/sweep-clean," not "reproduced-then-fixed." Next: an interactive session (ideally the same live Steam Deck setup as D255/M-140, explosion-heavy combat, e.g. Facility/Frigate) should confirm the crash no longer reproduces.
|
||
|
||
**Live-verified, 2026-09-16, real Steam Deck hardware, v0.2.2 hotfix build.** User ran Archives with dual grenade launchers plus guards spawned with the rocket-launcher cheat — sustained, dense overlapping-explosion combat, exactly the 8-voice-pool-exhaustion / SFX-burst scenario this fix targets — with no crash. This is user-reported (not gdb-attached or `D_800243E4`-state-captured at the moment of stress), so it does not pin the exact race window the way a live-fault capture would, but it is a real, on-hardware, high-load stress test of the fix's actual trigger condition, not just the build/sweep-clean bar noted above. Confidence upgraded from "correct by precedent, unexercised under real load" to "correct by precedent, exercised under real load with no failure."
|
||
|
||
## D286 — M-140: user-confirmed, real (not system-side) ~8s frame freeze on Caverns, Steam Deck, same live session as D255/D285 — self-recovered, not a crash; case not yet root-caused
|
||
|
||
**Context.** Caught live during the same Deck SSH session used to fix D255: the game's own hang watchdog (`portHeartbeatCheck`, `port/src/libultra.c`) fired once, logging `kernel heartbeat: no frame rendered for 8008866 us (frames=46404); state:` (unit bug in the log line, see below — the raw value is *microseconds*, printed with a `ms` suffix and no `/1000`; fixed this session, `port/src/libultra.c`) followed by a per-thread/per-queue state dump and a `crashDumpThreads` backtrace of the watchdog thread itself (`portTickThread` → `portHeartbeatCheck` → `crashDumpThreads`, `port/src/libultra.c:266/315`, `port/src/crash.c:458` — this is the watchdog reporting on *itself*, a diagnostic dump, not a fault). **User confirmed by direct observation: this was a real, visible freeze** (not a display/system hiccup — the Deck's own WiFi had one unrelated sleep/wake event earlier in the session, so system-side causes were a live alternative hypothesis, now ruled out by the user's own eyes on the screen), happening "pretty early" in a Caverns playthrough, not tied to an explosion or terminal (ruling out D255/D285 as the cause — this is a third, distinct issue).
|
||
|
||
**Outcome: self-recovered.** VI-post/frame-render logging resumed normally within the same log stream immediately after the watchdog fired, and the session continued (the user later closed the game normally, no crash log, all 5 game threads still `started=1`/`exited=0` at the time of the dump — nothing was permanently deadlocked). This is a genuine ~8s *stall*, not a hang or a crash.
|
||
|
||
**Not root-caused.** No distinguishing log line immediately precedes the stall (just routine `D51 vi post` lines) — nothing points at a specific subsystem yet. Caverns is one of the larger/more complex levels (cross-ref the D250 "FPS inconsistent" lineage, though D250's own symptom was sustained sub-60fps, not a multi-second total stall, so likely a different mechanism — a one-time heavy room/asset stream-in stall is the leading guess, unconfirmed). A single occurrence with no repro trigger identified; **do not spend session time trying to force a repro** without a better lead. If it recurs, the next capture should note (a) exact in-level location/trigger if any pattern emerges, (b) whether `Rooms`/`roomsDrawn` or a texture/model load was in flight via existing debug logging, and (c) whether it's Caverns-specific or level-general (test other large/open levels — Streets, Egyptian — the D249/D250 neighbourhood).
|
||
|
||
**Also fixed in passing (cosmetic, no behavior change):** the heartbeat log's unit label — `now - g_lastFrameUs` is computed in microseconds but was printed as `%llu ms` without converting; now divided by 1000 so the log actually reads correct milliseconds. | OPEN — one user-confirmed real freeze on Caverns (~8s, self-recovered, no crash), not root-caused, no repro trigger identified; distinct from D255/D285. Log-unit cosmetic fix landed alongside.
|
||
|
||
---
|
||
|
||
## Entries migrated from §F table (2026-09-15)
|
||
|
||
> Migrated verbatim from the §F index table by `migrate_bucket_a.py` (ctx-opt P1, bucket A): topic/status cell
|
||
> content moved unchanged; the table row now carries a one-line
|
||
> summary + pointer. Nothing deleted.
|
||
## D137 — right-mouse (aim-sight) crash:
|
||
|
||
right-mouse (aim-sight) crash: `gunDrawSight` (`gunfire.c:6235`) `s32 sp54` holds a `Gfx*` the whole function (`sp54 = *gdl`; `texSelect(&sp54,…)`/`display_image_at_position(&sp54,…)` take `Gfx**`). As N64 `s32` it is 4 B, so `*(Gfx**)&sp54` reads 4 B of the adjacent stack float as the pointer high word → `_g = 0x03e4fca0_70081220` → wild DL write in `texSetRenderMode` (`othermodemicrocode.c:177`) the moment the crosshair raises. §A.
|
||
|
||
**Status (from §F table):** FIXED (`src/game/gunfire.c` `#ifdef PORT` — `sp54` is `Gfx *`)
|
||
|
||
## D123 — crash class C1: `chrIsNotDeadOrShot` NULL deref on 6 levels (Dam/Runway/Frigate/Statue/Streets/Cradle).
|
||
|
||
crash class C1: `chrIsNotDeadOrShot` NULL deref on 6 levels (Dam/Runway/Frigate/Statue/Streets/Cradle). D122's `OBJ_TAIL_DESC` zeroed the widened `VehichleRecord/AircraftRecord.ailist` slot (w32), but `prop.c:1764/1786` reads a pre-populated int AI-list id there → `ailistFindById(0)` → `GAILIST_AIM_AT_BOND` → `ai()` runs a CHR aim list with `ChrEntityp==NULL`
|
||
|
||
**Status (from §F table):** converter fixed (`OBJ_ID_WORDS`); C1 cleared on all 6, residual crashes are fast3d (C2)
|
||
|
||
## D125 — crash classes C3+C6 (Aztec/Bunker2/Surface2):
|
||
|
||
crash classes C3+C6 (Aztec/Bunker2/Surface2): **root cause found (M-16)** — `tools_pc/d88_emit.py:374` assigns a 4-byte literal to an 8-byte slice (`out[dst_o+0x30:dst_o+0x38] = b"\x00…"`, the stan zero-fill in `emit_pad`); every pad/boundpad record silently shrinks the output bytearray by 4 B, and once the tail falls below the boundpad plink string blob, later verbatim leaf writes hit Python out-of-range slice semantics (insert at current end, not relocated offset) → boundpad names drift/truncate (Aztec pad33 `p138d2`→`8d2`) → `stanPackId()` reject → `getposstan()` NULL stan → `setupDoor` leaves door `model=NULL` → crash `propobj.c:13601`. M-14's "propDefs zeros in RAM" was a misread; sidecar propdefs were always correct (M-15) and post-load RAM matches the sidecar byte-for-byte (M-16)
|
||
|
||
**Status (from §F table):** **resolved (M-16b)** — line 374 → 8 NULs; all 21 sidecars regen'd; **Aztec `-level_28` now PASSES** (was C3 CRASH). Bunker2 falls through to a separate DOOR-tail `linkedDoor` layout bug (C3 residual)
|
||
|
||
## D126 — crash classes C3r/C4/C6 (Bunker2 `-level_27` `door7F054FB4` propobj.c:13523, Depot `-level_30` prop.c:902, Surface2 `-level_43` loadobjectmodel.c:393):
|
||
|
||
crash classes C3r/C4/C6 (Bunker2 `-level_27` `door7F054FB4` propobj.c:13523, Depot `-level_30` prop.c:902, Surface2 `-level_43` loadobjectmodel.c:393): the objective sub-records `criteria_picture` (30), `criteria_roomentered` (32), `criteria_deposit` (33), `setup_objective_text` (35) each end in a `T *next` list pointer that the setup walk (`set_parent_cur_obj_*` / `setup_briefing_text_entry_parent`) writes unconditionally. On PC that pointer widens 4→8B and lands 8-aligned at offset 16 → struct is 24B/6w (N64 16/20). `d88_propdefs.py` emitted them at N64 size via the generic arm → the runtime 8-byte `->next` write clobbered the *next* record's header → propdef walk desynced, command indices drifted ~100, `linkedDoorOffset + arg2` resolved to the wrong record → door `linkedDoor` chain walked into garbage.
|
||
|
||
**Status (from §F table):** resolved — `d88_propdefs.py` PROPDEF_PC_BYTES[30/32/33/35]=24 + typed handler; `loadobjectmodel.c` sizepropdef PORT returns 6. Bunker2/Depot/Surface2 now PASS (13→16/21)
|
||
|
||
## D124 — crash class C2: fast3d bad texture pointer.
|
||
|
||
crash class C2: fast3d bad texture pointer. **Jungle** (`0xabcd0824`): `gimgSyncCompiledGlobalDLs()` slot-detect keyed on the post-fixup marker, which `texLoad()` had already overwritten → compiled `globalDL_0xNNN` explosion DLs kept link-time `IMAGESEG` words → latent on every level, tripped by the first explosion-DL draw. **Facility** (`0x72181ee8`): see **D130** — the model-GDL-relocation hypothesis (M-14 addendum) was WRONG; real cause was `romdataFixupFont`
|
||
|
||
**Status (from §F table):** Jungle fixed (`port/src/gimgfixup.c`); Facility → D130
|
||
|
||
## D131 — crash class C2m (Jungle `-level_37`):
|
||
|
||
crash class C2m (Jungle `-level_37`): fast3d `gfx_sp_matrix` AV on a wild matrix pointer `0x401c68e0` (`gfx_pc.cpp:1046`) ~frame 300, when the first explosion/smoke prop renders. `explosionRenderPropSmoke` builds `gSPMatrix(gdl++, osVirtualToPhysical((void*)&dword_CODE_bss_8007A100), …MODELVIEW)` (+ `applyRoomMatrixToDisplayList`). `osVirtualToPhysical()` is a `u32`-returning shim (`libultra.c:1207`) → truncates the compiled `.bss` symbol's `0x1_00000000` module high word → w1 = `0x40xxxxxx` → `seg_addr()` returns it raw. Same class as D94 (chraction 32-bit ptr truncation), but in a GBI DL word. The projection matrix in the same DL is fine (`get_BONDdata_field_10E0()` is a runtime `0x70xxxxxx` ptr, fits u32); the `gSPDisplayList(&globalDL_0xNNN)` refs are fine (port `Gwords.w1` is 64-bit `uintptr_t`, no macro truncation). ~30 `osVirtualToPhysical(<compiled matrix/vtx symbol>)` sites exist (`explosion.c`, `glass*.c`, `blood_animation.c`, `bondview2.c`) — all latent until that effect first draws.
|
||
|
||
**Status (from §F table):** resolved — `seg_addr()` restores the module high word for any fallthrough w1 in `[0x40000000, 0x70000000)` (module fixed-based at `0x140000000`, no ASLR; DRAM/KSEG0/segmented/phys all handled earlier). Jungle renders to frame 2400+ clean; `-level_20`/`-level_24` unregressed. **18→19/21.** (`-level_09` has a separate pre-existing boot crash, see below.)
|
||
|
||
## D133 — intro render triage (M-20):
|
||
|
||
intro render triage (M-20): the "intro renders mostly black" item in the M-18/M-19 handoff is **NOT a regression** — it is the D75/D76 parked-cosmetic steady state. M-17 (`9ec6121e`), whose handoff claimed "the entire intro renders — logos → gun barrel → cast", was built in a scratch worktree and captured with the same `GE_PCDUMP="20-900:20"` window: coverage is **pixel-identical** to HEAD `0b5f5d1a` (legal screen 6677 non-clear px / 2.17% on both; centred-logo frames ~7% on both; near-black between). 2D/text/texture layers draw; animated character-model layers (Nintendo-logo transform, gun-barrel Bond, cast models) never appear = D75 exactly. No cheap regression to fix; parked. Secondary: Facility `-level_34` + Jungle `-level_37` re-verify FAILED this pass (boot crash / frame-2 hang) — flagged in LEVEL-STATUS for a clean-machine re-check, not investigated (scope).
|
||
|
||
**Status (from §F table):** RESOLVED — not a regression, parked (D75/D76, `GRAPHICS-BACKLOG.md`)
|
||
|
||
## D134 — Frame-2 boot hang / sweep "NO-FRAMES" flakiness — root-caused and fixed (M-22).
|
||
|
||
**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).
|
||
|
||
**Status (from §F table):** FIXED (`port/src/libultra.c`)
|
||
|
||
## D135 — Firefight crash — `bgTestHitOnObj` (`propobj.c:8446`) is an unported N64 GBI parser (M-22, WS6 playtest).
|
||
|
||
**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.
|
||
|
||
**Status (from §F table):** 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).
|
||
|
||
**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.
|
||
|
||
**Status (from §F table):** 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).
|
||
|
||
**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.
|
||
|
||
**Status (from §F table):** 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).
|
||
|
||
**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.
|
||
|
||
**Status (from §F table):** 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.
|
||
|
||
## D141 — Watch-menu item-model crash (M-27), exposed by D140.
|
||
|
||
**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)]`.
|
||
|
||
**Status (from §F table):** 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).
|
||
|
||
**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.
|
||
|
||
**Status (from §F table):** 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.
|
||
|
||
## D151 — Watch data all blank — objectives / mission background / M / Q / Moneypenny (M-28, user bug report).
|
||
|
||
**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`).
|
||
|
||
**Status (from §F table):** 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`):
|
||
|
||
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.
|
||
|
||
**Status (from §F table):** resolved — stage all 6 N64 fields in a local `f[6]` before writing any. Facility + Runway now PASS (16→18/21). Probes reverted.
|
||
|
||
## D168 — `GE_PCDUMP` / F12 PPM captures were vertically flipped — the entire source of the bogus D114/D116 "HUD/text X-mirror" (M-33, developer-confirmed).
|
||
|
||
**`GE_PCDUMP` / F12 PPM captures were vertically flipped — the entire source of the bogus D114/D116 "HUD/text X-mirror" (M-33, developer-confirmed).** `gfx_opengl_dump_bound_fbo` (`port/fast3d/gfx_opengl.cpp`) wrote `glReadPixels` output straight to a P6 PPM. GL framebuffer origin is bottom-left; PPM P6 is top-row-first — so every capture (and the `tools_pc/golden/` set, and every screenshot pasted into the finding log since M-6) was upside-down. On real hardware / an actual screen the game renders correctly (developer confirmed). The successive D114→D116 "shared fast3d mirror" investigations — each of which found *every probed stage clean yet the output "mirrored"* — were reading an inverted capture and pattern-matching upside-down asymmetric content (text, guards, the Nintendo logo) as "mirrored". Fix: emit PPM rows bottom-to-top. `tools_pc/golden/*.png` flipped in place to match (see `tools_pc/golden/README.md`); regenerate from a real run when convenient. **Not runtime-verified this session** (no ROM / toolchain in the migrated tree — see the migration note) — needs a fresh capture to confirm text reads normally. Confidence: **high** (mechanism is unambiguous; developer has hardware confirmation the screen is correct). porting-notes.md §D2.
|
||
|
||
**Status (from §F table):** FIXED (`port/fast3d/gfx_opengl.cpp`); D114 / D116 reclassified as capture-orientation artifacts (below).
|
||
|
||
## D157 — Campaign never unlocks the next level (M-30, user bug — "complete Dam on Agent, Facility stays locked, no save").
|
||
|
||
**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).
|
||
|
||
**Status (from §F table):** 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).
|
||
|
||
**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).
|
||
|
||
**Status (from §F table):** 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. **M-106 static re-audit (no build/runtime access this session — see note below): hypothesis (a) independently RE-CONFIRMED RULED OUT.** `CutsceneRecord` (`src/bondtypes.h:3785-3792`, propDef type 46) is **all-scalar** — `PropDefHeaderRecord` (4B header) + `coord3d pos` (12B, 3×f32) + `theta`/`verta`/`pad` (4B each) = 28B exactly, zero pointer/union fields, so it cannot exhibit the D122/D126/D132/D209 pointer-width-growth pattern at all (nothing to widen); this matches `porting-notes.md`'s already-recorded M-31 conclusion (`§ "D160 — a propDef command-index walk desync..."`) that the stride cross-check is clean for all 48 types. Traced the `AI_CameraSwitch` path end-to-end (`chrai.c:3990-4013`): `tagGetCommandIndex`/`setupGetPtrToCommandByIndex` resolve `cdef`, then `gBondViewCutscene = cdef` and `bondviewSetCameraMode(CAMERAMODE_POSEND)`; the render side (`bondview2.c:1558-1590`) reads `gBondViewCutscene->pad`/`pos`/`theta`/`verta` directly off that pointer with no intermediate copy/struct-punning step that could introduce a *new* aliasing bug — this leg of the pipeline is clean. **Did not get far on hypotheses (b)/(c)** (AI trigger-fire vs invisible-puppet-render) — no interactive session / no build toolchain available in this worktree (`gcc`/`cmake` not on `PATH`; MSYS2 MINGW64 environment not active), so the `GE_D160=1` Dam-to-exit playthrough this row has needed since M-31 still has not been driven. **Confidence update: hypothesis (a) — HIGH (ruled out, independently re-derived, not just cited); (b)/(c) — unchanged, still need the live trace.** No code changes made (nothing to fix once (a) is ruled out); this is a documentation-only confirmation pass. See also D173 below — if the Cradle "Bond floating/spinning" symptom filed under this label is the puppet-render family (D75/D156), closing D173 first may explain the residual `#4 GOTO` here too. **M-107 — the live trace finally happened; (b)/(c) both REFUTED, real root cause moved to D243.** `GE_D160=1` on `-level_33` (Dam) plus a fresh-save normal playthrough all show the AI trigger firing and all three `camera_switch` commands executing (`bondviewSetCameraMode(7)` × 2-3, then `bossReturnTitleStage`) — the cutscene is NOT skipped and the puppet is NOT invisible; it renders, but corrupted (violent camera-shake artifact during each angle cut). Root-caused no further than "stale/freed display-list memory read, likely a render-pipeline race" — full writeup and evidence under **D243**, which now owns this bug going forward. This row's own title ("cutscene never plays") is superseded; kept for history.
|
||
|
||
## 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…
|
||
|
||
**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).
|
||
|
||
**Status (from §F table):** FIXED (`port/fast3d/gfx_pc.cpp` `import_texture`, `port/fast3d/gfx_opengl.cpp` diag). porting-notes.md §D.
|
||
|
||
## D152 — Mission-failed / objective-failed → permanent black screen (M-28, user bug report — LIVE process inspected, not yet fixed).
|
||
|
||
**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.
|
||
|
||
**Status (from §F table):** 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).
|
||
|
||
## D154 — `bg.c` room-geometry ray/hit-test GBI parser port (M-28, WRITTEN / UNVERIFIED / UNCOMMITTED).
|
||
|
||
**`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).
|
||
|
||
**Status (from §F table):** **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).
|
||
|
||
**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.
|
||
|
||
**Status (from §F table):** 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).
|
||
|
||
**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.
|
||
|
||
**Status (from §F table):** 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).
|
||
|
||
**`-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.**
|
||
|
||
**Status (from §F table):** NOT A BUG — closed. Run anchors on a quiet machine.
|
||
|
||
## D164 — Disclaimer / legal screen only draws the first line (D76 root cause, M-31).
|
||
|
||
**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.
|
||
|
||
**Status (from §F table):** **FIXED.** Corrected 2026-09-18 (during an unrelated "easy wins" scan): the proposed one-line `#ifdef PORT` fix — `legal_text_end = legalpage_text_array + (sizeof(legalpage_text_array) / sizeof(legalpage_text_array[0]));` (N64 `&legalscreen_MRD` cast kept verbatim under `#else`) — was already committed `8dfc578f4` (2026-08-31), confirmed live in the current tree via `git blame -L 1581,1588 src/game/front.c`. The "front.c owned by another agent, not applied" status was accurate at the time it was written but never updated after the merge landed; no code change was needed this session, this is a docs-only correction. 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).
|
||
|
||
**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.
|
||
|
||
**Status (from §F table):** **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).
|
||
|
||
**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.
|
||
|
||
**Status (from §F table):** 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").
|
||
|
||
**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.
|
||
|
||
**Status (from §F table):** FIXED (`port/src/input.c`). SMALL-FIXES B7. **M-87: likely SUPERSEDABLE — see the "SOLITAIRE natural-pitch" note below.** This pulse hack was necessary because the port forces a control scheme (HONEY, the 1.1 default) whose hipfire pitch is digital-only. GE's own **SOLITAIRE (1.2) / GOODNIGHT (1.4)** schemes don't have that constraint at all (see D194) — under those, the *entire* primary analog stick is free for continuous, unclamped pitch+yaw ("natural pitch/turn"), because movement is redirected onto digital C-buttons instead. If the port switches its forced `controldef` to SOLITAIRE/GOODNIGHT (also the right move for D223's wheel-cycling design and D194's mouse-feel ask), this whole digital-pulse emulation becomes unnecessary — mouse-Y would drive the stick's Y axis directly and continuously, same as X already does, with **zero digital-button emulation for pitch at all**. Don't remove D166's code without first confirming the scheme switch; keep as a fallback for whichever legacy path still needs it.
|
||
|
||
## D169 — Front-end mouse pointer can't reach the outer cells of the mission/level-select grid — only an inner ~3×3 selectable (M-33, developer bug report).
|
||
|
||
**Front-end mouse pointer can't reach the outer cells of the mission/level-select grid — only an inner ~3×3 selectable (M-33, developer bug report).** The D165 pointer P-controller in `port/src/input.c` is hard-coded to a **320×240** virtual field (`MENU_CURSOR_HI_H`/`_HI_V` = 300/220, `MENU_CURSOR_MID_H`/`_MID_V` = 160/120, `input.c:145-149`), but GE's front end runs at **440×330** (`front.c:8570` `viSetViewSize(440,330)`; default cursor home 220/165 `front.c:285`). `frontUpdateControlStickPosition` clamps the real cursor to `[20,420]×[20,310]` (`front.c:1195-1217`); the mission-select hit-test grid spans x 73…352 / y 62…270 with outer split points 317 / 235.5 (`front.c:516,519,3180,3194`). The port clamps its pointer *target* and *estimate* to 300/220 (`input.c:553-564`) → the stick zeroes out once both saturate → the game's `cursor_h_pos`/`cursor_v_pos` park at ≈300/220, short of the two outer columns and the bottom row. File-select / mode-select / main-menu are unaffected because their hit targets (centred folder boxes, the `x=126` mode list, the `x=106` difficulty list) all sit inside the 320×240 sub-box. Not the D118d `joyGetStickY`-threshold class — this is a virtual-resolution constant mismatch, closer to D164 (a front-end layout constant wrong on the PC path). Also note: the "re-syncs whenever the target is held at a screen edge" comment at `input.c:141-143` describes behaviour the code doesn't implement — the only estimator reset is the activation re-home (`input.c:545-549`), so once pinned at the clamp the estimate never recovers. Confidence: **medium-high** (constants + clamp math unambiguous in source; static-only, no build/run this session; exact reachable block "~4×3" vs the reported "3×3" within tolerance).
|
||
|
||
**Status (from §F table):** **FIXED (M-33, `port/src/input.c`, port-only, no `#ifdef PORT`).** The `menuMode` pointer branch now derives its clamp bounds from the live virtual screen — `[screenleft+20, screenleft+screenwidth-20] × [screentop+20, screentop+screenheight-20]` via `getPlayer_c_screenwidth/height/left/top()` (`src/game/bondview.c:880-895`) — falling back to the old 320×240 constants when the front-end screen isn't set (`sw` outside 200…2000). The estimator seed on activation is now the real `cursor_h_pos`/`cursor_v_pos` (`front.c:285`, externed in `front.h`) instead of the 160/120 centre guess. `MENU_CURSOR_HI_H/_V` / `MID_H/_V` kept only as the fallback. **Verified:** builds + links clean (`getPlayer_c_screen*` and `cursor_[hv]_pos` all resolve — non-static engine symbols, as expected); `GE_STARTMENU=7` mission-select boots crash-free 600+ frames; `-level_09` unregressed (framediff 3/3 within threshold, 91.6% nonclear). The in-level path is untouched (menu-only branch). **Interactive feel-check still owed** — headless input can't drive the mouse pointer, so "every grid tile is now reachable" is inferred from the corrected clamp math, not observed. `Input.MenuPointerMode=0` (legacy velocity) and the constant fallback remain as escape hatches.
|
||
|
||
## D178 — Pre-mission briefing screen: objectives blank / missing, briefing pages blank (also the D143 "briefing text blank" side effect) — FIXED (M-36).
|
||
|
||
**Pre-mission briefing screen: objectives blank / missing, briefing pages blank (also the D143 "briefing text blank" side effect) — FIXED (M-36).** Root cause: the briefing segment (`Ubrief*Z`, source `assets/obseg/brief/*.c`) is a raw ROM image of `struct BriefStruct` = `{ u16 brief[4]; struct { u16 textid; u16 enabled_difficulty; } objective[10]; }` (48 bytes), loaded by `front.c load_briefing_text_for_stage()` via `_fileNameLoadToAddr()` — **no converter and no BE→LE fixup anywhere in the chain**. On the LE host all 24 `u16` read byte-swapped. Measured on Dam (`GE_D178=1`): raw `brief=002c,012c,022c,032c obj0=042c/0100 obj3=072c/0000` vs correct `2c00,2c01,2c02,2c03 / 2c04/0001 / 2c07/0000`. Two independent symptoms follow: (a) `textid` `0x2C04` (= `getStringID(LDAM,4)`, bank 11 slot 4) reads as `0x042C` → bank 1 slot 44, never loaded → `langGet()` NULL → **blank text** (this is exactly the D143 residual, `front.c:6732` and `brief[0..3]` at `front.c:6855-6867`); (b) `enabled_difficulty` `0x0001` (Secret Agent) reads as `0x0100 = 256`, so `selected_difficulty >= enabled_difficulty` (`front.c:6729`) is false for every difficulty-gated objective — on Agent only the one `DIFFICULTY_AGENT`(0) objective survived the filter, which is why a single bare "a." bullet printed. **Fix:** `romdataFixupBriefing()` in `port/src/romdata.c` (+ `port/include/romdata.h`), called from a `#ifdef PORT` block in `load_briefing_text_for_stage()` right after the load — the same shape as `langFixupLoadedBank()` in `language.c` (BE-serialized-struct reconciliation, semantics-preserving, no game logic touched). `GE_D178=1` prints the raw and fixed words. **Verified:** Dam briefing on Agent now shows "a. Bungee jump from platform"; on `GE_STARTMENU_DIFF=3` (00 Agent) all four (Neutralize all alarms / Install covert modem / Intercept data backup / Bungee jump from platform), correct difficulty gating. `-level_09` framediff 3/3; `GE_STARTMENU=7`/`=13` crash-free.
|
||
|
||
**Status (from §F table):** **FIXED (M-36)** — high confidence
|
||
|
||
## D175 — In-game stutter / brief hang during normal play
|
||
|
||
**In-game stutter / brief hang during normal play** (user QA report): opening a door on Runway (`-level_35`, mission 3) and also observed on Surface. Self-recovered; no backtrace captured. Likely one of the known transient-hang classes (D155 catch-up spiral / D156 anim NaN loop / D134 task-done event / D147-D152 audio-lock steal) or a benign new-room texture-import frame spike on door open. See `GRAPHICS-BACKLOG.md` D175 for the gdb pattern-match sheet.
|
||
|
||
**Status (from §F table):** OPEN — observed, not investigated
|
||
|
||
**Steam Deck (Linux / SteamOS, v0.4.0-pre, 2026-09-27/28 — user playtest):** the same intermittent frame-timing hitches reproduce on the Deck, but with a *different* utilisation profile than #92/D339: both CPU and GPU sit at only ~15–30 % (nowhere near a throughput bottleneck) yet the user perceives occasional "weird frame timing." Low-utilisation hitches like this are a **frame-pacing / present** problem, not a throughput problem. Candidates: the D186 `Video.FpsCap` throttle (the D186 row notes it throttles the *sim*, not just presentation), the D248/D250 pacing path (fixed for the 30-vs-60 case), or the Deck's Wayland/GBM + AMD present/vsync behaviour. Next (if pursued): a `GE_PERFSTAT=1` run plus a per-frame `dl`/`swap-pace` time histogram on the Deck, to see whether the hitches are a pacing-wait spike or a present stall. **Log-only this pass — not investigated yet.**
|
||
|
||
## D172 — Bullet-impact / blood / spark particles render magenta or cyan instead of dark red (M-82, ROOT CAUSE FOUND + FIXED).
|
||
|
||
**Bullet-impact / blood / spark particles render magenta or cyan instead of dark red (M-82, ROOT CAUSE FOUND + FIXED).** Particle billboards (`explosion.c` `explosionRenderPart`, `gSPVertex`+`gSP2Triangles`) take their CC/tile state from ROM state records replayed just before them (`g_ExplosionDisplayLists[]` = `assets/oddtextures.c` `globalDL_0x078..`). The dominant record sets `gsDPSetCycleType(G_CYC_2CYCLE)` + `gsDPSetTextureLOD(G_TL_TILE)` + `gsDPSetCombineMode(G_CC_INTERFERENCE, G_CC_MODULATEIA2)` and binds **two** tiles: tile 0 = IA8 "smoke" @ TMEM 0, tile 1 = RGBA16 "fire" @ TMEM 0x188. `G_CC_INTERFERENCE` cycle-0 = `TEXEL0 * TEXEL1`. **fast3d's `gfx_lod_tile_offset()` returns `0` unconditionally** on the `!gfx_detail_textures_enabled` branch — a D107 fix for GE's mip textures (whole chain loaded in one LOADBLOCK at TMEM 0; `port/src/video.c:225` sets the flag `false`). So texunit 1's sampled tile = `first_tile_index + 0` = tile 0 → **TEXEL1 samples the smoke texture, not fire** → `smoke * smoke` → wrong colour. Two earlier M-82 candidates were false trails: (1) "0xB9 unhandled → D146 abort" — 0xB9 is `G_SETOTHERMODE_L` in this non-F3DEX2 build, handled; zero D146 aborts in a sustained-fire run. (2) "records never set 2-cycle" — they do (`0xBA`), and fast3d applies it (probe-confirmed `cycletype=2CYC`). **Probe evidence (`GE_D172=1`, user Silo firefight, `d172_silo.log`):** 40/40 particle tris → `cfg1[tile=1 tmem=392 fmt=RGBA siz=16b]` but `SAMPLED1 = tile0 (tmem=0 fmt=IA)`, `tex_lod=0`.
|
||
|
||
**Status (from §F table):** **FIXED (`port/fast3d/gfx_pc.cpp` `gfx_lod_tile_offset`): `return rdp.tex_lod ? 0 : i;`** — fold to the base tile only when LOD is actually active. D107's mip case (`tex_lod=1`, blurry ceilings) unchanged; a genuine non-LOD 2-texture combine now samples tile `i`. `bunker1` verify PASS (the D107 repro level), no framediff regression. PR #34. **Owed: in-game eyeball — blood/sparks should read dark red.** Env-gated `GE_D172` probes left in tree (`#ifdef PORT`, inert).
|
||
|
||
## D174 — "No blood effect" (user QA report).
|
||
|
||
**"No blood effect" (user QA report).** Likely the unverified D120 decal fix: `d43_emit.py` now emits the opcode-0x18 `PointUsage[]` chain, but it was never interactively verified and requires a full sidecar regen to take effect (`debug.ps1` does not regen). Spray path is D172 (draws, wrong colour) — total absence would be new. See `GRAPHICS-BACKLOG.md` D174.
|
||
|
||
**Status (from §F table):** OPEN — observed; first step = BUNKER1 firefight with regenerated sidecars
|
||
|
||
## D173 — Third-person Bond model spawns too high
|
||
|
||
**Third-person Bond model spawns too high** (user QA report): the player-representing figure at level start floats well above the ground "a lot of the time" while the actual player spawn is correct; same on the Cuba end credits — JB ~6 ft in the air above Natalya. D75 animated-model family but a *position* defect, not absence (M-33: gun-barrel Bond renders fine). Suspects: intro/credits puppet chr spawn position vs model base transform; anim root-motion accumulation (`modelSetAnimFrame2WithChrStuff`, cf. D156); packed-float `coord3d` decode in intro setup. "Player spawning OK" localises it to the render-side model, not `g_CurrentPlayer`. See `GRAPHICS-BACKLOG.md` D173.
|
||
|
||
**Status (from §F table):** OPEN — observed, not investigated. **M-87 user playtest: still present** — "Bond floating in the sky / cutscene glitch thing still exists." Related, newly reported: **in some cutscenes Bond spins in circles** (named instance: end of Cradle, when Trevelyan falls) — likely the same actor-position/anim-root class rather than a new bug; logged under D148/D160 (`GRAPHICS-BACKLOG.md`) since it's cutscene-triggered. **M-106 investigation (static only — no build/runtime access in this worktree, see note below):** checked the two most D209-shaped candidates for a puppet Y-position aliasing bug and RULED OUT both. (1) `chrSpawnAtPad`/`chrSpawnAtCoord`/`chrAdjustPosForSpawn` (`src/game/chraction.c:10653-10794`) — the puppet-spawn path a cutscene `guard_play_animation`-style AI cmd would use. `PadRecord.stan` (`src/bondtypes.h:1744-1751`) is a ROM-serialized *pointer* field (`StandTile *stan` at offset 0x28) — exactly the tail-pointer shape D122/D126/D132 warn about — and `tools_pc/d88_emit.py`'s `emit_pad()` zeroes it on conversion with a comment claiming it's "dead-on-load, always set before use." **Verified this claim is TRUE, not a converter bug**: every `PadRecord` literal in the N64 source asset tables (checked `assets/obseg/setup/UsetupdamZ.c:37-51`) initialises `stan` to the literal `0` — the ROM binary never carries a live pointer there on N64 either, so zeroing it on PC is byte-identical behaviour, not a regression. `chrAdjustPosForSpawn` receiving a NULL seed `StandTile*` is therefore the original game's normal case, not a port defect. (2) The anim-root-motion Y-accumulation chain (`modelTickAnim`→`modelSetAnimFrame2WithChrStuff`, `model.c:3060+`, `pos24 = pos34 + rotate(pos)` at `model.c:3322`) was already exhaustively audited for exactly this class of bug during D193/D209 (M-80/M-81) and found self-consistent, with the `padding[]`-union sweep that found D209/D210 explicitly noting `act_anim`/`act_dead` (the union arms active during a played-animation/dead puppet) are "layout-stable" (`porting-notes.md` §A, D210 entry) — i.e. that sweep already covers the cutscene-puppet actiontype, and it came back clean. So the two most probable D209-style aliasing sites for this specific symptom are both cold. **Did not find the actual root cause.** Did not get to: the *separate* Cuba end-credits case (`front.c` `init_menu18_displaycast`/`constructor_menu18_displaycast`, `front.c:7821-8320`+ — a **different**, single-actor "cast roster" display model shared by the intro roster and the credits roster, using `getsuboffset`/`subcalcpos`/`cast_rootpos_smoothed` root-motion smoothing, not the in-level chr path at all) — spot-checked `getsuboffset` (`model.c:684-700`) and it is `#MATCH`-verified clean, but did not trace the smoothing math or confirm this is even the code path the "JB above Natalya" report hits (that report implies two actors on screen at once, which this single-`cast_model` code doesn't obviously support — may be a distinct, not-yet-located two-actor cutscene-puppet path). **Confidence: LOW-MEDIUM that this is the D209-style struct-aliasing family at all** — the two most likely aliasing sites check out clean, which argues *against* the shared-root-cause theory for D173 specifically (unlike D160, where the analogous check also came back clean but for a different reason — no pointer fields to alias in the first place). Next investigator: get a live Cradle (`-level_41`) capture of Trevelyan's chr during the death sequence with a position-logging probe (none shipped yet — would need a new `#ifdef PORT` env-gated trace, not added this session per the no-build-verification constraint), and separately locate the actual two-actor Cuba credits code path (grep for wherever `intro_char_table`/cast display is invoked with two simultaneous models, if it exists — not found this session). **M-141: no new lead this session** (see D243's M-141 entry) — checked whether D243's stale-memory front-end/menu bug could share a mechanism with this puppet-position bug; no code-level link found, kept as an unconfirmed hypothesis to watch for during the next live cutscene capture, not a new suspect to chase on its own. **M-141: Steam Deck playtest confirmation** — user reports Bond floating at level start on both Dam and Facility ("chemical plant"), "but fine when it moves to first person" (matches this row's own framing exactly: player spawn/first-person is correct, only the third-person render-side puppet floats). No new mechanism; corroborates the bug is general across levels, not Dam-specific. **User confirms D292** (Bond sunk into the floor on death) **is the same underlying bug**, a third instance alongside level-start floating and the Cradle cutscene spin — see D292 for the consolidated cross-reference. **M-145 (see D243):** the Dam end-cutscene double-Bond + camera shake is now traced to the same family — the exit script moves Bond's own third-person model via `CHR_BOND_CINEMA` (`chr_try_teleporting_to_pad(0xFD, …)`, `bondconstants.h:4604`) while its rappel animation commands target a model-less BG driver chr; level-start floating (this row), death sinking (D292), and the cutscene double-image (D243) are one "third-person world model transform/lifecycle" bug class.
|
||
|
||
**M-172 (2026-09-17, this session, autonomous/no live human needed): first-ever quantified, reproducible measurement of the level-start float, isolated to the render side and ruled independent of which animation is attached.** The intro/pre-control puppet cutscene runs unattended (`CAMERAMODE_INTRO`/`SWIRL`, no player input required to progress), so `GE_D243M`'s existing tick/onscreen probes — built for D243, this session — could just be pointed at it directly: launched `-level_33` (Dam) with `GE_D243M=1` only (no X3/X4), captured the whole intro-puppet lifetime headlessly (`scratch/d173/m172_dam_intro_capture.log`, CREATE frame=427 → REMOVE frame=924, 499 `onscreen` samples for the tracked chr). **Result: `prop->pos.y` is rock-steady at 60.3 for the entire window (never changes) — this is the correct ground-level Y — while the `onscreen` probe's actual rendered root Y sits at 384–389 the whole time, a consistent ~324–328 unit gap.** Camera-side fields (`f488pos`/`f3C4`, the tick probe) track `prop->pos` exactly (`20197.7,60.3,16902.1`, correct) — so **the camera/game-logic position is right; only the model's own rendered skeleton root is floating**, and by a near-constant amount. Critically, **the puppet cycles through three distinct animations during this window** (`aidx=61`→`106`→`99`, `actiontype` 23→1→3) **and the Y-gap barely moves (388.4 → 388.8 → 384.3)** — this argues against "wrong/stale animation root motion" (M-160/M-164/M-165's leading theory below) as the primary driver here, since a genuinely animation-root-motion-driven offset should track *which* animation is attached, not stay flat across three different ones. `getsuboffset`/`setsuboffset` themselves are `#MATCH`-verified decomp-verbatim (`model.c:684/705`) — not the bug site. **Leading candidate now: a fixed root-joint/base-transform offset baked into how the puppet's skeleton is set up at spawn** (`solo_char_load`, `bondview2.c:401`, not yet traced in full — a long function, stopped after confirming the accessor functions are clean rather than push further without a specific lead to chase). **Cross-level replication (same session, same technique): Facility (`-level_34`) reproduces an almost identical offset.** `prop->pos.y` constant at 392.8 for the puppet's whole lifetime (frames 242–374); rendered `onscreen` Y settles at 721.9 — a gap of **~329.1 units**, versus Dam's ~324–328. Two different levels, completely different ground heights (60.3 vs 392.8) and world positions, landing on essentially the **same** offset magnitude — this is strong evidence the bug is a fixed constant somewhere in the puppet's spawn/setup path, not something that scales with level geometry, camera angle, or which animation happens to be attached. Archived alongside the Dam capture (`scratch/d173/m172_facility_intro_capture.log`).
|
||
|
||
**Local-delegate (Qwen `triage`) spot-check, one candidate raised then correctly self-qualified as unconfirmed:** dispatched a read-only investigation into `solo_char_load` for the root-joint/animation init order. It flagged `chr_b.c:98`'s `modelSetAnimTranslationScale(model, pov)` (`pov = c_item_entries[body].pov`, `chr_b.c:37`) as a mechanism that scales decoded root-motion translation — but noted itself that this predicts an **animation-dependent** effect, which contradicts the now-two-level-confirmed near-constant offset, so it should not be treated as confirmed. Also honestly flagged that its own read of `solo_char_load` was partially evicted from context mid-session (lower confidence on the parts not directly quoted). Not spot-verified further this session — logged as a ruled-unlikely-but-not-fully-eliminated candidate, not a lead to chase first.
|
||
|
||
**Own follow-up static trace (same session): the puppet's first animation and its nonzero start frame are both legitimate, table-driven decomp behavior, not garbage/uninitialized state — ruling out two more candidates.** Traced `solo_char_load`'s setup: `init_GUARDdata_with_set_values(...)` (`bondview2.c:629`) then `setsuboffset(bodyModel, &prop->pos)` (`bondview2.c:634`) — position is set correctly, matching the normal guard init path (`chr.c:1719-1720`) exactly, per the earlier delegate spot-check. The puppet's first observed animation (`aidx=61`, `animframe≈96` from its very first rendered frame, *not* 0) comes from the same per-tick body-animation driver every player uses (`bondview2.c:10845-10880`, `firing_animation_groups[group][sub]`, a decomp table indexed by weapon-hold/movement state) — `startframe = fa->y`, a **table constant**, not carried over from stale/uninitialized prior-animation state (ruling out a `bheadAdjustAnimation`-style phase-carryover bug for this specific symptom). This means the ~328-unit elevation is very likely baked into the *animation's own keyframe data* at that starting frame — i.e. the model's root/pelvis joint legitimately sits ~328 units above ground in this pose, and something that should compensate (translate the root down to align with `prop->pos`'s ground-level Y, the way a normal in-level guard chr's third-person render ends up correctly grounded) isn't being applied to this specific puppet's render path.
|
||
|
||
**M-173 (2026-09-17, same session, autonomous, decisive): the control experiment — normal in-level guards do NOT have this gap. The bug is specific to the player-puppet's own draw/setup path, not a universal chr-rendering issue.** Added a second probe (`chr.c`, `chrTick`'s very first line, before ANY position-update logic runs) logging `prop->pos` for **every** ticked chr, gated on the same `d243mProbeActive()`. Relaunched Dam (`-level_33`, `GE_D243M=1`, no player input needed — background guards animate on their own) and diffed the new "before" line against the existing `onscreen` "after" line for the same chr at the same frame. **Checked four different background guards, different actiontypes/animations (`aidx` 0/40/106), different world positions: all four show a before/after gap of ~0** (a one-time ≤1.6-unit settle on the very first tick or two, then flush thereafter) — nothing remotely like the player puppet's ~328-unit gap. This **rules out** "every chr's root joint sits above its true position by design, normal chr rendering just hides it" (the leading theory after M-172) — **normal chr rendering is correctly grounded.** The bug is isolated to whatever is different about the player's own third-person puppet (`solo_char_load`'s setup, or the player-specific body-render call site, `bondview2.c`) versus a normal guard's `chrTick`/`chrRenderProp` path. Archived: `scratch/d173/m173_guard_compare_dam.log`.
|
||
|
||
**Correction while writing this up (checked before committing to the claim): `chrTick` — where `subcalcmatrices`/the render-position computation actually happens (`chr.c:2777`) — is NOT a separate player-vs-guard code path; it's called identically for the player's own `prop` (`bondviewMoveAllPlayers`, `bondview2.c:10493`, `ret = chrTick(prop)`) and every guard (`chrlvActionTick`'s caller).** So the divergence M-173 found is not a distinct "player draw path skips a step" — it's that the *same* shared machinery is fed a different animation.
|
||
|
||
**M-174 (same session): tried to name that animation-selection call statically via `firing_animation_groups[group][sub]` (`bondview2.c:10831`) — added a probe there, got ZERO hits for the intro puppet.** Useful negative result, not a dead end: that whole per-tick firing-animation selector is apparently unreached during `CAMERAMODE_SWIRL`/`INTRO`, ruling it out as the source (a second static guess that turned out wrong — logged so the next investigator doesn't retry it).
|
||
|
||
**M-175 (same session): stopped guessing and instrumented the actual choke point instead — every path that sets the player's `bodyModel` animation funnels through `modelSetAnimation` (`model.c:2857`), so added a probe there (filtered to `model == g_CurrentPlayer->bodyModel`, `#ifdef PORT`) logging `__builtin_return_address(0)`, then resolved the logged addresses with `addr2line` against the built exe. Found the exact call site on the first try this way: `bondviewSetCameraMode`, `bondview2.c:936-942`, inside the `CAMERAMODE_SWIRL` branch's intro-fly-in setup (`bondview2.c:910-957`).** This code reads a **decomp ground-truth table**, `stage_intro_anim_table[]` (`bondview.c:355-365`, zero `#ifdef PORT` anywhere in this call chain — completely unmodified decomp): entry `[0]` is `{PTR_ANIM_extending_left_hand, 95.0, -1.0, 0.02}` — animation **"extending_left_hand"**, **start frame 95.0**, play-to-end, speed **0.02** (near-frozen, a held pose not a moving loop). `PTR_ANIM_extending_left_hand` is confirmed index **61** in `animation_table_ptrs1[]` (`initanitable.c`) — an exact match to the `aidx=61` measured live in M-172 on both Dam and Facility, and the `startframe=95.0`/near-zero speed exactly explains the captured `animframe≈96-97` (a few ticks' worth of playback at 0.02/tick past the 95.0 start) staying essentially flat for the puppet's whole visible lifetime.
|
||
|
||
**M-176 (same session, live user comparison against real N64): user directly compared this port's level-start sequence against their memory of real N64's — confirms the float is a genuine divergence, not faithful behavior, and reveals a second, deeper mechanism.** Real N64 (per the user, described in detail): attract screen → flyover camera → after 1-2s, Bond appears **standing on the ground, arms at his side** → ~0.25-0.5s later he **draws his gun and surveils left-to-right** as the camera moves into first-person → control passes to the player. **On this port, per the user's direct observation: Bond is floating from the very first frame his model appears — never grounded, not even briefly.**
|
||
|
||
Traced the code this triggered: `chrlvTickBondIntro` (`chraction.c:7663`, also fully decomp-verbatim) is a **second-phase watchdog**, confirming the two-part sequence the user described is genuinely implemented: once the held "extending_left_hand" pose (frame 95, near-frozen) wraps back below frame 86, it switches Bond to `fire_standing_draw_one_handed_weapon_fast` (frames 86→131) — this is almost certainly the "draws his gun and surveils" phase, and fired exactly as expected in a live capture (`chrlvPerformAnimationForActor`, `chraction.c:605`, one hit ~200 ticks after CREATE). **So the two-phase structure matches the user's description — but the FIRST phase (which should show Bond grounded, arms at sides) never visibly looks that way on PC; it's elevated from frame 1.**
|
||
|
||
Checked one more concrete candidate (the local-delegate-flagged, previously-dismissed `modelSetAnimTranslationScale`/`pov` theory): `c_item_entries[BODY_Formal_Wear].pov` (`assets/obseg/chr/suitbond/chrModelFileRecord.inc.c`) = **1.058** — a small, plausible-looking decomp constant, not an obvious corruption (compare `propobj.c`'s unrelated `10.438f`/`1.0438f` uses for actual anomalous-looking scale values). If the raw pre-scale root-motion translation.y this port decodes at frame 95 is genuinely ~310 units, ×1.058 ≈ 328 — matching the measured figure almost exactly. This makes `pov` *consistent with* the measured number rather than an obvious smoking gun — it doesn't distinguish "asset decode is wrong" from "asset decode is right and N64 genuinely has ~310 units of raw translation baked into this keyframe" (in which case the scale isn't the bug, whatever produced the ~310 raw value is, or nothing is and N64 shows this too — which the user's direct comparison now argues against).
|
||
|
||
**Conclusion (updated after M-176's live comparison): the mechanism setting the puppet's pose is fully decomp-verbatim — not a bug in the *selection* logic — and the user's direct live comparison against real N64 (M-176) rules out theory (b) (genuinely faithful N64 behavior).** Real N64 shows Bond grounded during this exact phase; this port shows him elevated from the first frame. Since the selection code, the table data, and the root-motion computation call (`sub_GAME_7F06D3F4`) are all unmodified decomp, and the scale multiplier applied (`pov=1.058`) is a small, plausible constant rather than an obvious corruption, **the remaining candidate is bucket (a): the `PTR_ANIM_extending_left_hand` clip's own keyframe/root-motion data is mis-decoded for this port at/around frame 95** — an asset-pipeline bug, the same class of investigation D252 used (dump and hand-decode the raw ROM bytes for this one clip's frame-95 root translation, compare against what `model.c`'s bitstream decoder computes at runtime). **This is now the concrete, high-confidence next step — not more source-code tracing, and not a rule-2 sign-off case (bucket (a) is allowed without one). This thread is source-tracing-exhausted for this session; the next session's work is an asset/data-level check.**
|
||
|
||
**M-160/M-164/M-165 (2026-09-17, see D243): the shared mechanism is now understood in much more depth, though still not confirmed as the actual PC/N64 divergence.** D243's investigation this session traced the general pattern this whole family plausibly shares: `chrUpdateAnim` (`chr.c:2398-2424`) doesn't move a puppet's world position directly — it advances whatever animation is currently attached (`modelTickAnim`), then reads the model's root-joint transform back into `prop->pos` via `getsuboffset` (`model.c:684`). When something else (a teleport, a spawn, a death transition) moves the puppet's *logical* position without also resetting/updating its *animation state* to match, the next `chrUpdateAnim` call overwrites the new position with wherever the stale animation's root motion happens to be — producing exactly "floats/sinks/shakes relative to where it should be, in a way that tracks the animation, not the intended position." This is a strong candidate mechanism for D173's level-start float (an animation not yet settled/attached correctly at spawn) and D292's death-sink (the death-pose transition possibly not resetting the model's root anchor the way the collision/death logic expects) — **but D243's own parallel investigation (M-165) found this same mechanism may be entirely decomp-faithful**, with the actual PC-specific divergence being *which phase* of an animation a chr happens to be on at the moment of transition (inherited from real-time-dependent prior gameplay, not deterministic) rather than a code-path difference. **Do not treat this as solved** — it reframes what to look for (an animation-identity/phase mismatch at the transition moment, not a position-write bug per se) and gives a concrete technique (`GE_D193A`'s `aidx=` reverse-resolve + a `model->animframe1` phase read, see D243 M-166) for whoever gets live access to test D173/D292 the same way D243's `GE_D243M`/`GE_D243X2` probes now do.
|
||
|
||
## D173 — CONSOLIDATED NEXT STEPS (as of M-176, 2026-09-17)
|
||
|
||
This entry is long and multi-pass (M-87 through M-176, spanning several sessions). This block is the single place to read for "what's actually known and what to do next."
|
||
|
||
### What's established (high confidence, live-verified this session)
|
||
|
||
1. **The float is real and reproducible, not measurement noise.** `prop->pos.y` (game-logic position) stays exactly at true ground level for the entire on-screen lifetime of the level-start puppet; the *rendered* root sits ~324-329 units higher, flat regardless of which of three different animations is attached. Measured independently on Dam (`-level_33`) and Facility (`-level_34`, setup file `UsetuparkZ.c` — "ark" is simply Facility's internal decomp codename, confirmed by multiple independent, already-established findings elsewhere in this file: D130, D203, D217, D255, and the M-139-era Trevelyan/Ourumov investigation's own full static decode of `UsetuparkZ.c`; a same-session doubt raised while writing this section, based on a hand-count of `setup_text_pointers[]` alone, was checked against those and was wrong — retracted).
|
||
2. **The bug is isolated to the player's own third-person puppet, not chr rendering in general.** A control experiment (M-173) checked four background guards (different actiontypes/animations/positions) with a before/after `prop->pos` probe spanning all of `chrTick` — all four showed ~zero gap. Normal chr rendering is correctly grounded.
|
||
3. **The exact mechanism is now named, down to the source line and the specific animation clip.** `bondviewSetCameraMode` (`bondview2.c:936-942`, inside the `CAMERAMODE_SWIRL` branch) sets the puppet's animation from `stage_intro_anim_table[g_IntroAnimationIndex]` (`bondview.c:355`) — entry `[0]`, `{PTR_ANIM_extending_left_hand, 95.0, -1.0, 0.02}` (animation "extending_left_hand", starting at frame 95, near-frozen). `g_IntroAnimationIndex` defaults to 0 and is only overridden by a level's own `INTROTYPE_ANIM` setup record if one exists — **Dam's setup script has no such record**, so index 0 is Dam's only possible choice, by design. (Several *other* levels — Archives, Park, Aztec, Caverns, Depot, Runway, Bunker2, and the three Severnaya variants — do have their own override; not yet checked whether those levels show the same float or a different one.)
|
||
4. **A second, later phase exists and works correctly (or at least fires as expected).** `chrlvTickBondIntro` (`chraction.c:7663`, also decomp-verbatim) watches for the held pose to wrap, then switches Bond to `fire_standing_draw_one_handed_weapon_fast` (frames 86-131) — confirmed firing live, matching the user's own memory of real N64's "draws his gun and surveils left-to-right" moment almost exactly.
|
||
5. **The user directly compared this port's sequence against their memory of real N64 and confirmed a genuine divergence** (M-176): N64 shows Bond grounded/arms-at-sides during phase 1; this port shows him floating from the very first visible frame, never briefly grounded. This rules out "this is just how N64 looks too" as an explanation.
|
||
6. **Two candidate mechanisms checked and both look clean:** the selection/table-read code (100% decomp-verbatim, no `#ifdef PORT` anywhere in the whole call chain) and the applied scale multiplier (`c_item_entries[BODY_Formal_Wear].pov = 1.058`, small and plausible, not an obvious corruption like other unrelated scale constants seen elsewhere in the codebase, e.g. `propobj.c`'s `10.438f`).
|
||
|
||
### What to actually try next (none of these attempted yet)
|
||
|
||
- **(a) The real next step: a D252-style raw ROM byte dump of the `PTR_ANIM_extending_left_hand` clip, hand-decoded at frame 95, compared against what `model.c`'s `sub_GAME_7F06D3F4` bitstream decoder computes at runtime for the same frame.** This is a data-inspection task, not code tracing — the code path setting/computing this pose is fully decomp-verbatim and has been read end-to-end this session. If the hand-decoded raw value disagrees with the runtime-computed value, that names an asset-pipeline/converter bug (bucket a, no rule-2 sign-off needed). If they agree, the bug is somewhere not yet found (re-open the search — but don't re-trace the code path already covered by M-174/M-175/M-176, it's clean).
|
||
- **(b) Optional, lower priority:** check whether a level that *does* have its own `INTROTYPE_ANIM` override (e.g. Archives, `UsetuparchZ.c`) shows the same float or a different one — this would help confirm whether the bug is specific to entry `[0]`'s clip or general to the whole `stage_intro_anim_table` mechanism.
|
||
|
||
### Applies to the wider family too
|
||
|
||
D243 (cutscene shake/duplication, its own CONSOLIDATED NEXT STEPS block appears earlier in this file) and D292 (death sinking) are cross-referenced as the same general "third-person world model transform" family, though this session's D173 work found its specific root cause (a likely asset-decode issue in one named clip) rather than a shared code-level mechanism — don't assume D243/D292 share this exact cause without separately verifying each.
|
||
|
||
## D179 — Packaged build crashes after the logos — the D43/D69 model + bg sidecars are ROM-derived and absent from any build that has no ROM (M-34, alpha-release QA).
|
||
|
||
**Packaged build crashes after the logos — the D43/D69 model + bg sidecars are ROM-derived and absent from any build that has no ROM (M-34, alpha-release QA).** The first `goldeneye-pc-port-*-win64.zip` from CI (bundle-win.sh) shows the Rare/Nintendo logos (compiled-in `assets/rarewarelogo.c`) then AVs at `0x6b157a88`/`0x70157a88` — symbolised: `modelPromoteNodeOffsetsToPointers` (`model.c`) ← `load_object_fill_header` (`objecthandler_2.c`) ← first prop/item model load. Root cause is **not** a code regression: the port loads PC-layout model geometry from `data/pcmodels-<region>/{pcmodels.bin,manifest.csv}` and stage bg/stan from `data/pccg-<region>/{pccg.bin,manifest.csv}`, both produced offline by `tools_pc/d43_emit.py` / `d69_emit.py` from the ROM (see `port/src/pcmodels.c` / `pccg.c`; `pcmodelsReserveSize` logs *"pcmodels.bin not found — model loads will fail"* and returns 0). CI has no ROM so it never runs the emit scripts, and the two `data/` dirs are (correctly) gitignored ROM-derived game data — **cannot be committed or shipped** (converted Nintendo/Rare geometry, DLs, collision, stan nav; = distributing assets). A local build with those dirs present runs clean (verified M-34: 1741 frames, `romdataInit ... mapped at 0x10000000`, `pcmodels: 512 sidecars`, `pccg: 73 sidecars`, no crash — so `0x10000000` is *not* the problem here). The `docs/building.md` sidecar-gen step was also missing entirely. **Fix = release-side, backlogged** (`docs/BACKLOG.md` → "Alpha release"): bundle the emit scripts + their committed inputs as a pure-stdlib-Python asset-prep tool the user runs once against their own ROM (~5 MB output, no MIPS toolchain). Secondary/latent: the fixed-address `VirtualAlloc((LPVOID)0x10000000)` in `romdata.c` has no working fallback (`"using heap copy — direct ROM reads will fail"` then limps into the same crash) — didn't bite M-34 but will on a machine where something occupies `0x10000000`; harden separately (reserve earliest in `main`, or retry low bases and derive all segment math from the base obtained).
|
||
|
||
**Status (from §F table):** Diagnosed, not fixed — release packaging gap + latent `romdata.c` fallback
|
||
|
||
## D180 — Native-PC input pass (M-XX QoL run, `qol/native-pc-input-menu`, port-only).
|
||
|
||
**Native-PC input pass (M-XX QoL run, `qol/native-pc-input-menu`, port-only).** Three parts, all config-gated with default = prior behaviour: **(WI-1) `Input.MouseCaptureMode`** (0 = legacy always-grab; 1 = Quake-style click-to-lock) in `port/src/input.c` + `port/src/video.c` — in mode 1 the OS cursor is free until a click lands in the game window (`SDL_MOUSEBUTTONDOWN` → `inputNotifyClick`), and ESC / focus-loss / entering any front-end menu (`current_menu != MENU_RUN_STAGE`) frees it again; re-entering a stage while still "armed" re-locks so unpausing needs no click. `reconcileGrab()` runs once per controller-0 poll. Mouse buttons are suppressed from the game while the cursor is free in-stage (no phantom fire). Controller paths untouched. **(WI-2) absolute-cursor menu tracking** — when capture mode is on and the cursor is free in a menu, the D165/D169 pointer P-controller takes its target from the real cursor's absolute window position mapped onto the live virtual front-end rect (`getPlayer_c_screen*`), giving true 1:1 tracking instead of the relative-delta estimator. Relative path unchanged for legacy/grabbed. **(B3) `Input.MouseAimSpeed` default 25 → 16** (aim mode still overshot at 25 per the backlog).
|
||
|
||
**Status (from §F table):** LANDED, port-only, no `#ifdef PORT`. Build 241/241, `-level_09` framediff 3/3 within threshold (unregressed), `GE_STARTMENU` menu boot crash-free. **Feel-checks owed** (headless can't drive the mouse): click-to-lock ergonomics, absolute menu tracking, the new aim-speed default.
|
||
|
||
## D181 — `Game.ScreenShakeIntensity` — first route-(b) `src/` gameplay-cosmetic hook (M-XX QoL run).
|
||
|
||
**`Game.ScreenShakeIntensity` — first route-(b) `src/` gameplay-cosmetic hook (M-XX QoL run).** `src/fr.c viShake()` gains a single `#ifdef PORT` line — `intensity *= portScreenShakeScale;` (extern `f32`, defined + `configRegisterFloat`'d 0.0–10.0 in `port/src/video.c`) — before the existing clamp. Default `1.0f` ⇒ exact no-op, every headless golden dump byte-unaffected; `0.0` disables explosion/effect screen shake, up to `10.0` exaggerates it. N64 build (no `-DPORT`) keeps the original line verbatim. Precedent per `docs/BACKLOG.md` "Fun features → screen-shake intensity slider" and AGENTS.md #2's documented-exception path. Same policy class as a future FOV hook.
|
||
|
||
**Status (from §F table):** **Menu row pulled (v0.2.1, user testing).** The hook is correct on its own narrow terms — `viShake()` is called only from `explosion.c`, so the slider does exactly what it says (scales explosion/effect shake) — but that's a much narrower scope than the "Screen shake" label implies to a player: it does not touch the always-on walking head-bob or any getting-shot reaction, so a user swinging it from 0 to max during ordinary play sees nothing and reasonably calls it broken. Removed from `port/src/optionsoverlay.c`'s row table until it covers all screen-shake/view-bob sources, not just explosions; config var (`portScreenShakeScale`) and the `fr.c` hook are untouched. Build green.
|
||
|
||
## D187 — HUD ammo-type/weapon icon vanished at `-O2` (M-46 root-cause, M-47 fix + verify).
|
||
|
||
**HUD ammo-type/weapon icon vanished at `-O2` (M-46 root-cause, M-47 fix + verify).** `set_rgba_redirect_generate_microcode()` (`src/game/gunfire.c:5929`, sole emitter of the bottom-right magazine icon; call sites 6057/6123/6217/6219, all `gdl = f(...)`) is `Gfx *` with **no `return`** — the decomp banner above it flags exactly this. Harmless under `-Og` (GCC left the tail-call result in `$v0`); commit `34885535` (Sep 2) switched release to `-O2` → undefined return reg → callers resume the DL from the icon's start offset and overwrite its `texSelect`/`display_image` commands with the following `gunDrawHudInteger` output. Ammo *numbers* use a separate correctly-returning path — hence the exact symptom split. Fix = one line, `return` under `#ifdef AVOID_UB` (already `-DAVOID_UB=1`, `CMakeLists.txt:207`); N64 byte-match path unchanged under `#else`. **Verified M-47 (interactive console):** `-O2` build, `-level_09` `GE_PCDUMP` 200–440 — orange clip glyph absent pre-fix, present post-fix; `framediff.py` frame 320 worst-cell dmean **21.8 → 4.0**, frame 440 **21.7 → 4.6** (converges to the pre-regression golden); other levels unaffected (change is one HUD function). Do **not** revert `34885535`.
|
||
|
||
**Status (from §F table):** FIXED (`src/game/gunfire.c`, `#ifdef AVOID_UB`); PR #19
|
||
|
||
## D188 — Linux `-O2` boot crash: `_Printf` passes `&args` of a by-value `va_list` param (M-48).
|
||
|
||
**Linux `-O2` boot crash: `_Printf` passes `&args` of a by-value `va_list` param (M-48).** On the x86-64 System V ABI `va_list` is an array type → a by-value `va_list` parameter decays to a pointer, so `_Printf`'s `_Putfld(&x, &args, …)` handed `_Putfld` the address of its own local pointer slot, not the arg list; `_Putfld`'s `va_arg(*args, …)` then walked garbage. Latent at `-Og`, SIGSEGV at `-O2` (PR #9) — crash path `bossInitMainthreadData` (`boss.c:247`, D121 per-level `-m` sprintf) → `sprintf("-level_%c%c %s", …)` → `_Putfld` @ `xprintf.c`. Same UB class as D187. Windows x64 `va_list` is a scalar so `&args` worked there (Windows build unaffected — `-level_09` framediff 3/3). Fix: `#ifdef PORT` — `_Printf` takes `va_list *`, threads it straight to `_Putfld`; `src/sprintf.c` (sole PC caller; `rmon.c` excluded) passes `&args` of a real object. N64 build untouched (`#ifdef PORT`; file byte-matches ROM).
|
||
|
||
**Status (from §F table):** FIXED (`src/libultrare/libc/xprintf.c`, `src/sprintf.c`, `#ifdef PORT`); PR #22
|
||
|
||
## D189 — Linux `-fstack-protector` aborts on the decomp's latent stack-buffer overruns (M-48) — FIXED.
|
||
|
||
**Linux `-fstack-protector` aborts on the decomp's latent stack-buffer overruns (M-48) — FIXED.** Two hit on `-level_09`: (1) `sub_GAME_7F0B1DDC` (`stan.c`) — local `StandTile *tileStack[39]`, bail check `if (cat >= 41)` runs only once per outer iteration so `cat` reaches `40 + pointCount` and the append writes past the end; BUNKER1 prop room-list setup legitimately visits ~40 tiles (confirmed in GDB: 30 valid distinct tile ptrs and climbing; `standTileStart=0x701ae160`, so **not** a pointer-truncation bug — the earlier "blob >4 GiB" triage premise was wrong). (2) `bondviewCalcIntroSwirlCamera` (`bondview2.c`) — `f32 pointbuf[10]` written `[-3 .. 11]` via `&pointbuf[i*3]`, i∈[-1..2]. Both harmless on N64 + the MinGW build (no stack protector); only Ubuntu gcc's `-fstack-protector-strong` default makes them fatal `__stack_chk_fail`. **Fix:** `-fno-stack-protector` globally (`CMakeLists.txt`) to match the N64/MinGW builds, + `tileStack[64]` under `#ifdef PORT` for the one cleanly-bounded case, + `stan.c:2225` link-resolve moved to the D177 `PORT_PTRADD` macro for consistency. A *hardened* Linux build would need the whole overrun class swept. **Verified:** Windows `-level_09` framediff 3/3; Linux `-O2` `-level_09` runs to frame 2100+ no crash. porting-notes.md §C.
|
||
|
||
**Status (from §F table):** FIXED (`CMakeLists.txt`, `src/game/stan.c` `#ifdef PORT`); PR #23
|
||
|
||
## D190 — Linux `-level_45` SIGSEGV at load in `add_ptr_to_objective` (`objective.c:64`) (M-48, open).
|
||
|
||
**Linux `-level_45` SIGSEGV at load in `add_ptr_to_objective` (`objective.c:64`) (M-48, open).** `objective_ptrs[objective->menu] = objective;` faults — `objective` wild or `objective->menu` a garbage index → OOB write. Almost certainly the D122/D126/D132 propDef-stride / ROM-serialized-struct-widening family (`struct objective_entry` / the type-23/type-1E setup-text record chain read at the wrong stride on PC). Found in the M-48 Linux level sweep: **5/6 spot-checked levels (`-level_09/20/27/34/37`) load + render fine; `-level_45` is the one failure.** Not a v0.1.0-alpha blocker (alpha is explicitly "expect crashes"). Windows: `-level_45` load status not re-checked this session.
|
||
|
||
**Status (from §F table):** **CLOSED — not a port bug; level 45 is MP-only and has no solo setup file in the ROM (root-caused M-105, reproduced on Windows).** Full chain: `setup_text_pointers[45]` = `"UsetupimpZ"` (`chraidata.c:824`), but that name is **not in the ROM file table** (`assets/obseg/file_resource_table.inc.c`) — only the MP variant `"Ump_setupimpZ"` is. BASEMENT is a 4-player MP level (`front.c:557` `multi_stage_setups[]`, alongside STACK/TEMPLE/COMPLEX/CAVES/LIBRARY — exactly the other missing solo names); MP play prepends `mp_` to the name (`prop.c:1261-1269`), so the solo name was never shipped. A solo `-level_45` boot (debug flag only; no solo mission references stage 45) requests a nonexistent file: on PC the inert rmon stubs (`osReadHost`/`osWriteHost`, `n64stubs.c`) leave the CheckFileExists response in `fileGetIndex`'s dynamic path (`ob.c:421`) **uninitialized** → fresh table entry with size 0 → `fileIndexLoadToBank` allocates bank memory, loads 0 bytes → the setup rebase + propDef walk in `proplvreset2` runs over **stale pool memory** → garbage records (M-105 Windows crash: `modelLoad` via `domakedefaultobj`, `obj=0x784f`; M-48 Linux hit `add_ptr_to_objective` — same garbage walk, different stale contents). N64 parity: the file is genuinely absent there too (indy host answers not-found → index-0 load) — this boot is broken in the original as well. No fix taken: a port-layer guard cannot synthesize a valid setup (the walks need `-1`/`PROPDEF_END` terminators no zeroed buffer provides), and fabricating ROM content for an out-of-scope MP level has no ground truth. The M-48 "Linux-specific" framing was wrong — the failure is platform-independent; it was merely found on Linux first.
|
||
|
||
## D191 — Windows `-O2` release-bundle crash: SIGSEGV in `modelGetNodeRwData` (`src/game/model.c:478`) — the `root->Parent` node-tree walk (M-49, user playtests of the…
|
||
|
||
**Windows `-O2` release-bundle crash: SIGSEGV in `modelGetNodeRwData` (`src/game/model.c:478`) — the `root->Parent` node-tree walk (M-49, user playtests of the v0.1.0 win64 bundle).** **Two instances, identical fault PC `0x1400801b1`:** (1) fresh save, killed the first guard right after escaping the cell in Bunker ii; (2) Statue (`-level_49`?), right after the Trevelyan meeting cutscene. `ge007.crash.log` (auto-dump; symbols intact in the shipped exe, `addr2line` resolves): PC = `modelGetNodeRwData` at `model.c:478` — the `switch (root->Opcode & 0xff)`, i.e. `root` (a `ModelNode *`) is bad; `addr2line` puts it in the inlined self-recursion at `:532`, so the `while (root->Parent) { root = root->Parent; … }` walk (`model.c:527-540`) handed a garbage node into the recursive call. Instance 1: `Rdx = 0x70267bc000000000` — a `~0x70266xxx` heap address in the **high 32 bits, zero low half** = 32-bit pointer field read as 64-bit (or a byte-swapped `u32` pair). Instance 2: `Rdx = 0`, FAULT ADDR `0x0` — `root` (or `root->Parent`) resolved to NULL. Both: `Rcx=Rdi` = the `~0x7008xxxx`/`~0x7007xxxx` object ptr, `R8 = 0x1d` (29). So the `Parent` chain in some model contains a **truncated or NULL link** — pointer-width / byte-swap family (D3x, D119 `weapons_held[]->chr` type-pun, D122/D126/D132 propDef-stride). NOT weapon-fire-specific — common factor is `modelGetNodeRwData` running on a character/cutscene model whose node tree has a bad `Parent`. Crash logs saved: `scratchpad/crashlogs/`. **Repro aid:** shipped exe has full symbols; `debug-crash.ps1` in the bundle dir runs it under gdb, `GE_D51=1` → `d52rw.log` rwdata-pool trace. The crash.c EBP backtrace breaks immediately (`#01: 0x1d`) — need a real `bt` + `p *root` + `p *Objinst` at the fault. Not yet reproduced under a debugger; no fix.
|
||
|
||
**Status (from §F table):** FIXED + PLAYTEST-VERIFIED (M-79). User replayed Bunker ii (first-guard kill) and Statue (post-Trevelyan cutscene) on the fix build — **both no longer crash** → 21/21 solo levels crash-free (v0.2.0 gate met; Cradle's remaining issue is D193, not a crash). Build clean; bunker1 golden framediff 3/3. **Real gdb backtrace (M-79):** the fault is NOT in the `root->Parent` walk — `root` arrives *already* garbage (`0x70267a9000000000` = a half-word-swapped `u32` `0x70267a90`) from frame #1 `bondviewSelectCuff(model=0x70076808, header=0x700763a0, switchindex=29)`, called from `gunUpdateAndFire(GUNRIGHT)` on the frame the player fires. `bondviewSelectCuff` byte-indexes `header->Switches` (a `ModelNode *` array — **8-byte stride on PC**, widened per D43/D45) with `offset = switchindex << 2` (N64 4-byte stride), so every `base[N]` deref lands mid-slot and yields a misaligned half-pointer that is non-NULL by luck → `modelGetNodeRwData(root=garbage)`. **Exactly the D140 bug class** — D140 fixed the identical pattern in `gunfire.c:1740`; this call site was missed. Fix: PORT-only `offset = switchindex * (s32) sizeof(ModelNode *)`; the `base = (u8*)switches + offset` math and the `#else` N64 line are unchanged. ABI/pointer-width only (D3x class), no game-logic change. The Statue instance (`Rdx=0`, NULL fault addr) is the same site landing on a slot whose bytes read as 0.
|
||
|
||
## D193 — NPC AI locomotion is too slow — characters don't move to their destinations at the original game's rate (M-49, user playtest; clarified after the FULL 21-level…
|
||
|
||
**NPC AI locomotion is too slow — characters don't move to their destinations at the original game's rate (M-49, user playtest; clarified after the FULL 21-level playthrough — "the biggest overall flaw").** Not (primarily) an animation-playback bug — the *travel speed* of scripted/AI movement is wrong: guards, named NPCs (Trevelyan, Ourumov) and friendlies (Natalya) all take noticeably longer to run/walk to their goal positions than on N64. General, not chr-specific. **Leading hypothesis — the D117/D134/D155/D156 wall-clock-timing family:** `chr.c` / `chrai.c` integrate movement as `pos += speed * g_GlobalTimerDelta` and step sim loops `while (i < g_ClockTimer)` / `-= g_ClockTimer` (`chr.c:1437,1446,1491-1502,1862,2359,2569`; `chrai.c:76`). On the port `osGetCount()` is free-running wall-clock (D117), `g_GlobalTimerDelta`/`g_ClockTimer` are derived from it, and D155's `FRAMETIMING_PORT_MAX_CATCHUP = 6` clamps `nextFrameTime`. If the effective per-second sum of `g_GlobalTimerDelta` (or the `g_ClockTimer` tick count) runs low — quantization, the catch-up clamp biting during normal play, or a units mismatch (`g_JP_GlobalTimerDelta` vs `g_GlobalTimerDelta`, `chr.c:1435/1437`) — every delta-scaled AI motion integrates slow while **player** movement (input-driven per frame) still feels right, which is exactly the reported split. Related: **D170** (Ourumov/Trevelyan "flee" looks wrong — flagged there as possibly the same locomotion-speed issue rather than an AI divergence). Also possible: `chrGoToBond/Chr(SPEED_RUN/WALK/SPRINT)` speed enum → path-follow step, or pathfinding advancing ≤1 waypoint per frame-quantized tick. **Instances seen (M-49 full playthrough):** (a) Facility — the post-scripted-scene beat with Ourumov + the soldier squad: the soldiers **walk in super slowly and take a long time to path into firing positions** before they start shooting (this is regular squad combat AI, not a named-NPC script — good evidence the slowdown is general delta-scaled `chr` locomotion / path-following, not one ailist); (b) the named-NPC flee behaviour on Silo (Ourumov) and Cradle (Trevelyan) — see D170; (c) **Natalya (the escort NPC) always walks really slow** — on every escort level (Bunker ii, Statue, Control, Archives) she trails far behind, forcing the player to wait. Escort follow-AI is the same `chrGoToChr`/path-follow path as (a). **WORST CASE — Cradle (`-level_51`) is "bugged out completely" due to Trevelyan's behaviour (M-49).** The final level is Trevelyan-fleeing-and-scripted end to end (he runs the antenna cradle, climbs, drops to the cradle floor, the helicopter beat); if his locomotion timing / path-following is off he never hits the trigger points and the level's scripted progression stalls or goes haywire. This makes D193 effectively a **can't-properly-finish-the-game** bug, not just cosmetic sluggishness. **Next:** log `g_GlobalTimerDelta` + `g_ClockTimer` per frame during normal play and check the 1-second sum vs the N64's ~60; time a guard's run across a known distance vs reference footage; then watch Trevelyan's chr state / current AI command on Cradle.
|
||
|
||
**Status (from §F table):** **FIXED + user-playtest-verified (M-81) — root cause is D209**: `act_ubytes.padding[45]`, a raw-byte union alias for `act_gopos.unk59` (the SPEED tier), lands on offset 45 which is `unk59` on N64 but is byte 5 of `waypoints[1]` at 64-bit — always `0x00`. So the locomotion-animation selector `get_sound_at_range()` always received tier 0 and bound `ANIM_DATA_walking`/`walking_unarmed` for **every AI chr in the game**, and since travel is anim-root-motion driven they all moved at walk pace no matter what the ailist commanded. User verified on Cradle: Trevelyan and the guards run. Full elimination trail below — **steady-state timing keystone MEASURED and the "sim sums low" hypothesis is CONTRADICTED (M-80).** Added a `GE_D193=1` per-wall-second probe in `waitForNextFrame()` (`src/game/frametiming.c`, `#ifdef PORT`, env-gated): logs render frames, sim ticks delivered (= sum of the `deltaFrames` fed to `updateFrameCounters` = sum of `g_ClockTimer`), catch-up clamp hits, and max raw pre-clamp delta. Built ntsc-final, ran `-level_09 / _20 / _27 / _34` idle-after-load, ~20 s each. **Every level, rock-steady: render = 30.0 fps, sim = 60.0 ticks/s, clamped = 0, maxraw = 2.** i.e. each rendered frame carries `g_ClockTimer = 2` and the per-second sum is *exactly* the N64's ~60 — the wall-clock timer is NOT running low in steady state, and `FRAMETIMING_PORT_MAX_CATCHUP` never bites in normal play. VSync on/off made no difference (still 30/60). So the B1 premise ("`g_GlobalTimerDelta`/`g_ClockTimer` sum low over a real second") is wrong for the common case; D193 must come from elsewhere. **Residual quantization risk (not yet observed):** `nextFrameTime = (dt_counts + 387937) / 775875` is integer-rounded per frame with no remainder carry, so a scene that renders at a *steady* rate between 30 and 60 that is not an integer divisor of 60 (e.g. a solid 50 fps → dt≈20 ms → rounds to 1 → sim 50/s, ~17 % slow) would under-advance; the fix there is a fractional accumulator in the `#ifdef PORT` path. But the port renders a hard 30 (software RSP ≈ 60 µs/frame — not GPU-bound; the cap is the `pendingGfx < 2` double-buffer + sched round-trip), so this only matters if a real AI-heavy scripted scene drops into the 20–30 band. **Next (needs input, interactive session):** drive Bond into the Facility Ourumov/squad beat (D193a) and Cradle Trevelyan run with `GE_D193=1` — confirm render/sim hold at 30/60 under real AI load; if they do, pivot off timing entirely and instrument a single guard's world position vs. distance/time (anim root-motion rate, `chrGoToChr` SPEED_* enum → step size, path-node spacing) — the travel-speed bug is likely in the locomotion/anim path, not the frame clock. `GE_D193` catalogued in `docs/dev/GE-ENV-PROBES.md`. **M-80 cont. — LIVE-LOAD CONFIRMED, pivot is final.** User ran Cradle (`GE_D193=1`, `-level_41` — see the level-id note below) and drove Bond through the Trevelyan sequence while Trevelyan was visibly "walking really slow". Probe throughout: **`render=30 sim=60 clamped=0 maxraw=2`, dead steady** — no dips, no clamp hits, no `[D156]` guard fires. So under the *exact* failing scene the sim clock delivers a perfect 60 ticks/s. **D193 is NOT a timing/scheduler bug — it is in the character locomotion/animation path.** Best current hypothesis: GE character *travel* is animation-root-motion driven — `modelTickAnim` advances `frame += playspeed*speed` per tick (numticks = g_ClockTimer, correct), then `modelSetAnimFrame2WithChrStuff` (`model.c:3060+`) extracts per-anim-frame translation via `sub_GAME_7F06D3F4(...&pos)` and applies `scale = model->scale * model->anim_translation_scale`. The **player does NOT use anim root motion** (explicit speedgo/speedstrafe physics in bondview2.c), which explains the "player fine / all AI slow" split precisely. Suspects, in order: (1) the anim translation keyframe data is decoded with wrong units/scale by its sidecar converter (D43/model family); (2) `model->anim_translation_scale` or `model->scale` misread (ABI/struct-stride — D3x/D99 class); (3) `model->animrate`/`playspeed`/`speed` wrong. **M-80 cont. — port side instrumented (`GE_D193A` in `chrTick`, `GE_D193B` in `sub_GAME_7F06D3F4`; both `#ifdef PORT`, catalogued in GE-ENV-PROBES.md). Findings:** (i) `g_GlobalTimerDelta = 2.0`, `model->playspeed = 1.0`, `model->scale = 0.1`, `model->anim_translation_scale = 1.0` for every ticked chr on Silo + Facility — the scalars that feed the root-motion accumulation are all nominal, matching the identical N64 C (no `#ifdef PORT` in this hot path bar the non-firing D156 NaN guards). `model->animrate = 0.0` universally but that is the default — only written by `modelSetAnimPlaySpeed` on a blended transition (`startframe > 0`), so not the bug. (ii) Patrolling guards (`act=14 ACT_PATROL`, `model->speed=0.5`) travel **~60–99 world-u/s** on the port — no N64 number to compare against yet. (iii) `GE_D193B` (Facility, walk anim `706af4d8`): the decoded root-motion triple `tmp` ≈ (±0-3, ~1080-1094 hip height, ±0-50) with **`base = 0` and `angle = 0` on every call** (could be legit for a straight-line walk — joint 0 channel A at index 0, no per-frame facing change — or could be a mis-selected/zeroed channel; can't tell without the N64 side). **Bottom line: every port-side input to the locomotion math reads nominal**, so the divergence is either (a) subtle wrong magnitudes in the decoded anim bitstream values themselves (`tmp` triple / the descriptor decode), or (b) something in the `pos34` accumulation / `header->unk*` state — neither visible without a reference. `ModelSkeleton` **ruled out** — the skeletons are statically defined in C (`New_ModelSkeleton`/`MODELSKELETON` macros, `chrobjdata.c` etc.), not ROM-serialized, so `Joints` is a native x86-64 pointer and `ModelJoint` is 6 B on both platforms; `base` is correct. That leaves the ROM-serialized `ModelAnimation` bitstream (`bitDescriptors`/`bitStream`, D32-widened) and the anim-table load path as where a magnitude error could enter. **M-80 cont. — anim-data endianness (D33) audited, looks complete for this path.** `ptr_animation_table` is `romCopy`'d then byte-swapped by `romdataFixupAnimationData` (`port/src/romdata.c`, D33): per 20-byte record it bswaps `+0x00/+0x04/+0x08/+0x0C/+0x0E/+0x10` and per 6-byte `ModelAnimBitField` descriptor bswaps `+0 bitOffset` / `+4 valueOffset` (bitCount/pad identity); the bit*stream* is left raw (correct — `modelAnimReadRootMotionValue` walks it byte-by-byte, MSB-first, endianness-neutral, same as PD's `anim_read_bits`). `PTR_ANIM_walking/running/sprinting` **are** in `animation_table_ptrs1` so guard-locomotion records do get fixed up; `_ptrs2` is only 3 helicopter/plane anims (no overlap → no double-swap). No `bad descriptor range` / `bad record offset` errors in any run. Every `anim->unkNN` the locomotion path reads (`unk04`, `unk06`, `unk07`, `unk0C`) is within the fixed-up 0x14 bytes. `sizeof(ModelAnimation)` = 64 B both platforms (D32 kept the embedded ptrs as `u32`); `sizeof(ModelAnimBitField)` = 6 B both. So the anim decode is structurally sound as far as static review goes — **a magnitude error, if any, needs the N64 reference to see.** **Next — two viable paths (needs a fresh session; toolchain not present here — no `mips-linux-gnu-*`, no `qemu-irix`, no emulator):** (A) set up the MIPS toolchain + IDO-recomp + an N64 emulator with `osSyncPrintf` capture, build the ROM with `#ifndef PORT` `GE_D193B`-equivalent logging, navigate to the same scene, diff `tmp`/`base`/`angle`/travel-speed against the port capture; (B) user records N64 footage (real HW or their emulator) of one guard traversing a measured distance, frame-count it → gives the port-vs-N64 speed ratio (a clean 2× would point at a per-frame vs per-tick doubling; a fractional ratio at a scale/data error). Recommend (B) first — cheap, and the ratio narrows (A) massively. **Highest-impact playtest finding — breaks Cradle.** **Level-id note:** the earlier "Cradle = `-level_51`" in this row and HANDOFF was WRONG — `LEVELID_CRADLE = 41` (`src/bondconstants.h:1662`; STATUE=22, so CRADLE = 22+19). `-level_51` = `LEVELID_EAR` (unused slot) and SIGSEGVs at load in `stanBuildRoomData` (`stan.c:281`, NULL deref) — a non-issue (bad level id), noted here only so it isn't re-investigated as a real crash. **M-81 — SOLVED, and none of the M-80 suspects were the cause.** Re-verified the whole timing chain independently (`waitForNextFrame` -> `speedgraphframes` -> `g_ClockTimer` -> `g_GlobalTimerDelta`; `g_GlobalTimer += g_ClockTimer` at `lv.c:1022` is **tick**-based, so the ~75 AI timeout comparisons against it are frame-rate independent) and re-checked the D33 fixup *widths* against the real structs (they match exactly, and `(bs-bd) % 6 == 0` passing is independent evidence the two embedded pointers decode correctly). Root-motion accumulation is self-consistent (`pos24 = pos34 + rotate(pos)` at `model.c:3322`, so `pos` is a per-frame delta in x/z and an absolute hip height in y — matching the observed `tmp` shape). Extending `GE_D193A` with the visibility-gate counters (`tick`/`move`, i.e. chrTick entries vs `chrUpdateAnim` calls) killed the culling hypothesis too — chr 0 is `31/31` every second. The tier was then shown correct (`tier=1 SPEED_RUN`) and the *animation* wrong (`aidx=40/107`, never 42/41), which is **D209**. Note for future readers: `chrlvApplySpeed`'s `speedPtr` out-param is a **turn** rate, not a travel rate (`chraction.c:8070` passes `&act_runpos.turnspeed` into it), so `act_gopos.speed == 0` just means "running in a straight line" — it is not evidence of a stalled character.
|
||
|
||
## D194 — Mouse input needs another tuning pass — RMB aim over-sensitive + sensitivity couples to frame/sim rate (M-49, user playtest).
|
||
|
||
**Mouse input needs another tuning pass — RMB aim over-sensitive + sensitivity couples to frame/sim rate (M-49, user playtest).** Two parts. **(a) Aim mode (RMB) is near bang-bang, not proportional** (`port/src/input.c:697-707`): any per-poll delta past `AIM_MOVE_THRESH` emits stick `m = clamp(61 + |Δ|·(MouseAimSpeed/100)·AIM_GAIN, 61, 60+AimBand)` = `[61,80]` — with the M-29 defaults (`MouseAimSpeed=16`, `AIM_GAIN=4.0`, `AimBand=20`) `m` is already 61 (fast) at ~1.5 px/poll and saturates at 80 by ~30 px/poll, so the usable proportional band is tiny and sits near the top of GE's aim range → "way too sensitive," turn rate barely tracks how fast you actually move the mouse. Needs a real response curve (low floor, wider proportional band, maybe a gamma), not a threshold+clamp. **(b) Overall sensitivity correlates with movement/frame rate** (user's words: "correlated with movement speed, does not feel good"): `input.c` emits raw stick counts **per controller poll** with no `g_GlobalTimerDelta` / polls-per-frame normalization (hipfire yaw `sx += edx·(MouseTurnSpeed/100)·6.0` at `:709`; aim as above). GE integrates the stick scaled by its sim delta, and the port's poll cadence vs render/sim cadence drifts with scene load — so effective sensitivity shifts when the framerate does (which reads as "when I'm moving"). Fix direction: accumulate mouse delta and convert to stick using a fixed reference dt (or feed the game a dt-consistent value), so a given hand motion = a given view rotation regardless of fps. Lineage: D118a (hipfire pitch digital vs analog), D165 (menu pointer), D166 (hipfire pitch pulses), D180-B3 (`MouseAimSpeed` 25→16). New adjacent QoL ask, root-caused → **D223** (mouse-wheel direction should reverse weapon cycle direction, currently both directions cycle forward). Config knobs exist (`Input.MouseAimSpeed/AimBand/MouseTurnSpeed/HipfirePitchSpeed`) but the *shape* of the mapping is the problem. **M-87 design-goal note (user):** overall mouse feel should move toward **Quake Remaster / the Perfect Dark PC port** — raw, direct, responsive 1:1 mouse-look — while explicitly **not losing GE's original aiming nuance** (the analog-stick aim-mode vs digital-hipfire split this whole D118/D194 lineage exists to preserve). Reframes the fix direction from "just widen the proportional band" to: normalize by a fixed reference dt first (part (b), the frame-rate coupling — this alone should fix most of "feels unresponsive"), then re-derive the aim-mode response curve, and treat raw hipfire mouse-look (PD/Quake-style, bypassing the N64 digital-stick emulation entirely when the player is *not* in aim mode) as a real design option to evaluate against "keep GE's aim-mode feel," not assume the N64 stick-emulation path is mandatory everywhere. **M-87 — found it: GE already has exactly this control scheme, unused by the port.** User asked why vertical mouse feels less responsive than horizontal; investigation found the port forces `controldef = CONTROLLER_CONFIG_HONEY` (1.1), under which hipfire pitch is genuinely digital-only (C-up/C-down) while yaw is analog — an intentional N64-era asymmetry (D166), not a port bug, but avoidable. **`CONTROLLER_CONFIG_SOLITARE` (1.2) / `GOODNIGHT` (1.4)** — user's own recollection, verified in code — redirect forward/back/strafe-L/R onto digital C-buttons/D-pad (`bondview2.c:5195-5253`, `moveData.digitalStep{Forward,Back,Left,Right}`), which frees the **entire primary analog stick** for continuous, unclamped pitch **and** yaw: `moveData.canNaturalPitch`/`canNaturalTurn` (set `!insightaimmode`, i.e. active outside aim mode, not gated behind holding aim) drive `analogPitch/70.0f` / `analogTurn/70.0f` directly (`bondview2.c:5950`/`6008`) — no threshold, no digital step, no `[61,80]`-style clamp band (that clamp is specific to the *aim-mode* zoom-turn path, a separate codepath, `insightaimmode && stick_y>60`). `analogPitch`/`analogTurn` default to the primary stick's raw X/Y for every 1.x scheme (`bondview2.c:4881-4882`) unless a 2.x dual-controller scheme overrides them from a second controller. **This is architecturally identical to a modern FPS mouselook**: continuous analog look on the stick, discrete step movement on buttons — a legitimate, unmodified original GE control scheme, not a port invention. Recommended fix: force the port's `controldef` to SOLITAIRE or GOODNIGHT instead of HONEY, map WASD to the digital step-movement buttons (already close to how keyboard hipfire movement works today) and mouse X/Y directly to the primary stick's continuous X/Y — this should resolve the vertical-vs-horizontal asymmetry completely (no more digital-pulse emulation needed at all, D166 becomes largely moot) and likely fixes much of "feels unresponsive" too, since it's direct continuous stick input rather than any thresholded emulation. The frame-rate-coupling issue (part b) is orthogonal and still needs fixing regardless of scheme. Cross-ref D166, D223 (control-preset forcing — this gives a concrete "which preset" answer). **Aim-mode note:** the port's aim-mode code (`input.c:777-784`) already applies the same gain formula to both mouse axes symmetrically — if vertical still feels worse *while aiming* (RMB held), that's a separate, unconfirmed issue, not explained by any of the above. **M-87: user independently confirms part (a)** ("right mouse aim is way too sensitive") and suggests the design fix — aim sensitivity should scale off the **same base per-pixel sensitivity as normal (hipfire-turn) mouse movement**, not its own disconnected curve. Concretely: today hipfire turn is a direct accumulation (`sx += edx·(MouseTurnSpeed/100)·6.0`, `input.c:709`) while aim is the separate floored+clamped `[61,80]` stick-value synthesis (`AIM_GAIN`/`AimBand`, unrelated constants) — two independently-tuned systems that don't agree on what "1 inch of mouse" should mean. Aim mode is a distinct codepath from hipfire (gated by `insightaimmode`, **untouched by the SOLITAIRE scheme change above** — natural-pitch/turn is explicitly `!insightaimmode`), so this needs its own fix regardless of the control-scheme switch. Fix direction: derive the aim-mode mapping from the same base sensitivity constant as hipfire (`MouseTurnSpeed`-equivalent), scaled down only by the aim-zoom's FOV narrowing if any adjustment is wanted at all, rather than the separate `AIM_GAIN`/`AimBand` floor+clamp system — so moving the mouse the same physical distance feels the same whether or not aim is held, without losing GE's own aim-precision character.
|
||
|
||
**Status (from §F table):** **PARTIAL — M-120 human playtest: hipfire/primary look is GOOD now, aim-mode (RMB/Shift) is STILL too sensitive, unusable for precision aiming.** Part (b) (dt-decouple) + the SOLITAIRE natural-pitch scheme switch (part of this PR's combined pass) are user-confirmed working well on PR #62 — normal look feels right. **Part (a) (aim-mode response curve) does not deliver the intended fix despite being "implemented."** User's words: "right mouse / shift aiming with crosshair is still way too sensitive and does not feel right / is not usable in game." Code-level re-read of `port/src/input.c`'s `aimHeld` branch (~line 939) finds a likely concrete cause the original D194(a) design didn't eliminate: `m = AIM_STICK_MIN + pow(normX, gamma) * (AIM_STICK_GAME_MAX - AIM_STICK_MIN)` — the response curve (`aimCurveGamma`, gamma=1.6 default) only shapes the range **above** the `AIM_STICK_MIN=61` floor; the instant `|aimEdx| >= AIM_MOVE_THRESH` (a tiny 0.3 px/poll), stick jumps straight to 61, i.e. **10% of the game's max aim-turn speed (`(stick-60)/10` clamped 1.0), with no ramp from zero.** For hipfire comparison, the non-aim path (`hipEdx * hipSens * MOUSE_TURN_GAIN`, no floor, no threshold-snap) is fully proportional to zero — structurally different, and the one the user says feels good. Aim mode's floor+threshold-snap behavior reads exactly like the original "near bang-bang" M-49 complaint the curve was meant to fix, just now confined to the bottom of the range instead of the top. **Not yet build/runtime-verified** — this is a static code-read hypothesis, not a confirmed root cause; needs an env-gated probe or a tuning pass (e.g., drop `AIM_MOVE_THRESH` toward 0 and make the curve span `[0, AIM_STICK_GAME_MAX-AIM_STICK_MIN]` from a true zero floor, or lower `AIM_STICK_MIN` itself for a genuine near-zero low end) before another playtest. **PR #62 (`fix/d194-mouse-dt-decouple`) stays held, unmerged** — part (a) needs another iteration; parts (b)/SOLITAIRE are ready to land whenever the aim-mode fix is folded in and re-tested together. **M-122: all four stick-synthesis models above were abandoned.** GEPD's own source (`MouseInjectorPlugin/games/goldeneye.c`) was found and read: it never synthesizes a stick for aim at all — it directly overwrites `crosshair_x/y_pos`, `gun_azimuth_angle/turning` (its formulas, RATIOFACTOR=1, basefov) and edge-scrolls the camera past 72% of the way to the screen edge. The port now mirrors that exactly in `port/src/input.c aimGepdCompute()` (knobs `Input.AimAbsolute`, `Input.GepdSens`); no look stick is emitted while aiming, and the game's own damped crosshair update (`caclulate_gun_crosshair_position_rotation`, gunfire.c:4819) is left running — GEPD patches nothing there either, its per-frame overwrite simply wins. **User verdict: "mechanically it works... the aim mode works how we'd want."** Committed `ef49d9e6`. Residuals: ① crosshair/arm spazz glitch, ② aim-mode FPS drop, ③ sensitivity tuning (see M-122 in HANDOFF). **M-123: playtest log (`d194_gepd.err`, 1964 gepdaim lines) mined for ①.** Result: the port's own writes are rock-stable — zero crosshair/camera jumps while aim is held; every large camera delta in the log turned out to be user hipfire stick swings between polls. Exactly ONE true anomaly: a +359° one-tick spike in raw `vv_theta` (cam 0.5 → 359.7, log line 534) — the port's own `[0,360)` wrap of the edge-scroll accumulator firing when the game held a small positive angle and a leftward scroll crossed zero. GEPD does a bare `camx += ...` with no wrap, and the game itself never wraps `vv_theta` in on-foot play (`bondviewApplyVertaTheta` only sin/cos's it; tank code is the sole wraper), so the wrap was removed (port-only). Note: the handoff's theory that this feeds the crosshair via `speedtheta` does not hold — `speedtheta` comes from stick input (`bondview2.c:6056`), never a `vv_theta` delta; all solo-play consumers of `vv_theta` are sin/cos or `(360−θ)` rotations (raw-delta consumers at bondview2.c:1361/9397 are MP-only). The spazz must therefore live in the game-side terms added on top of our overwrite each tick (`turn_x = autoaimx | speedtheta*0.3`, `turn_y = autoaimy | −speedverta*0.1`, gunfire.c:4819 loop) — prime suspect: auto-aim pull oscillating as lock-on jitters, worst near enemies (matches the user's report). The gepdaim log line now also prints the pre-overwrite residue (`game=` = last tick's `pos·damp+turn`), `aa=(autoaimx,autoaimy)`, `st/sv` (speedtheta/speedverta) so a live capture can confirm or kill that. Tick ordering checked: our write runs BEFORE `bondviewProcessInput` each tick (osContStartReadData → inputComputePad, libultra.c:1094), vs GEPD writing after game code — but the displayed fixed point (`X·damp+turn`) is identical either way in steady state, so ordering is not a spazz source by itself. Suspect (c) 2px deadband does not apply to the GEPD path (deadband lives only in the legacy fallback); suspect (d) centre-spring nudge is ~0.1–0.3° over its 2 ticks — negligible, kept (it clears `docentreupdown`, which must not fight aim). **M-123 user playtest (post wrap-fix + extended logging, `GepdSens` 25): "looks good... other than sensitivity we're good, 95%"** — residual arm/gun/crosshair glitching accepted as playable; ① closed as acceptable, remaining D194 work is ③ live sensitivity co-tune (`Input.GepdSens`, ini at 30 at session end) and the still-uncontrolled ② aim-mode FPS retest (logging off). **D194 CLOSED (post-merge):** ③ done (hipfire/aim aligned, defaults `AimModeSens=38`/`MouseTurnSpeed=50`, merged `f239adf5`) and ② resolved by user report -- "no performance [issue] is good now" during the calibrated playtest sessions; the formal logging-off control was never run but the original ≤10fps complaint did not recur, so it is closed on user evidence (revisit only if an FPS regression appears later).
|
||
|
||
## D195 — Transparency / alpha surfaces broken on Control (M-49, user playtest)
|
||
|
||
**Transparency / alpha surfaces broken on Control (M-49, user playtest)** — Natalya's console room, the glass partitions + translucent projected wall displays. Originally reported as a blend-state defect. Reinvestigated this session (M-116) after the user reported the glass rendering **solid black** — traced to a real, separate, much bigger bug: `G_TEXTURE_GEN` (RSP environment-mapped texture coordinates, used by shiny/reflective materials game-wide — gold/silver weapon skins, chrome trim, and this glass) was never implemented in fast3d. D72.1 (an earlier session) removed the PD-inherited envmap UV-generation block wholesale to fix the Rareware logo, reasoning "GE never generates texture coordinates from vertex normals" — true for the logo (lit, but does not set `G_TEXTURE_GEN`) but false in general: every envmap draw was left sampling whatever raw `tc[]` happened to be authored (near 0), landing on a fixed corner texel of the reflection map every frame regardless of view angle — for a typically dark-edged reflection texture, a solid near-black surface.
|
||
|
||
**Status (from §F table):** **FIXED (`port/fast3d/gfx_pc.cpp`, `gfx_sp_vertex`) — restored envmap UV generation gated on the real `G_TEXTURE_GEN` bit** (ported from the reference Perfect Dark PC port's already-correct implementation), so D72.1's actual fix is untouched (the logo never sets this bit) while every other envmap draw gets real reflection coordinates. **User-verified live:** console glass and shiny gold/silver guns are now visibly reflective instead of flat black. Confirmed crash-free on a full 21-level sweep. **The remaining "not translucent" complaint is NOT a bug**: user confirmed after seeing the fixed reflective glass that it was never see-through on the original N64 either — it's a reflective (mirror-like) surface by design, not transparent glass. Traced (before the user's confirmation settled it) to the material's own combine formula, `alpha = (TEXEL0/1 LOD-crossfade alpha) * SHADE_alpha + PRIMITIVE_alpha` — an additive constant term that saturates alpha near 1.0 by design, matching "reflective, mostly opaque" intent, not a blend-state defect. **Closed.** Cross-ref: this same envmap fix likely also explains part of D228 (white NPC-weapon patches) if any of those are chrome/shiny weapon variants — worth checking before further D228 investigation.
|
||
|
||
## D197 — Character face/head textures wrap around the head, seen on Silo (M-49, user playtest).
|
||
|
||
**Character face/head textures wrap around the head, seen on Silo (M-49, user playtest).** Reads as `G_TX_CLAMP` not honoured (or a non-power-of-two wrap period) on a head/face texture tile — the texture repeats around the mesh instead of clamping at the UV edge. Not yet isolated to a specific character or texture; no `GE_PCDUMP`/`GE_TEXDUMP` capture taken. Likely fast3d texture-tile setup (clamp/wrap mode bits from the tile descriptor not round-tripped) rather than a data bug, given it's cosmetic and geometry-independent. **Next:** `GE_TEXDUMP` the offending head texture's tile descriptor on Silo, diff clamp/mirror bits against `gmain.s`'s expectations for that material. **v0.2.0-pre playtest (user):** no longer observed on Silo — closed, dropped from release known-issues.
|
||
|
||
**Status (from §F table):** CLOSED — user playtest v0.2.0-pre
|
||
|
||
## D198 — `alAdpcmPull`/`_decodeChunk` used `K0_TO_PHYS` (an unconditional `& 0x1FFFFFFF` mask) on 64-bit heap pointers (`src/libultra/audio/load.c`).
|
||
|
||
**`alAdpcmPull`/`_decodeChunk` used `K0_TO_PHYS` (an unconditional `& 0x1FFFFFFF` mask) on 64-bit heap pointers (`src/libultra/audio/load.c`).** `K0_TO_PHYS` is the N64 kseg0→physical mask, valid only because N64 pointers fit in 32 bits; on PC's 64-bit heap it truncates any pointer whose value exceeds 512MB, corrupting the `aLoadADPCM`/`aSetLoop`/`aADPCMdec` DRAM addresses at 3 call sites (`load.c:71`, `:468`, `:474`). Every sibling call site in `env.c`/`resample.c`/`reverb.c` already resolves state pointers via `osVirtualToPhysical` (the port shim, `port/src/libultra.c`), which is bit-identical to `K0_TO_PHYS` for real N64 kseg0 addresses but correctly PC-shimmed. **Fix (ABI-layer, `#ifdef`-free — same call resolves correctly on both platforms):** replace the 3 `K0_TO_PHYS(...)` calls with `osVirtualToPhysical(...)`. No behavior change on N64 (identical result for kseg0 pointers); fixes PC. Surfaced while building the Phase-3 software mixer (D199) — the acmd list these opcodes feed now actually executes on PC instead of being ignored.
|
||
|
||
**Status (from §F table):** FIXED (`src/libultra/audio/load.c`) — part of the Phase-3 mixer landing, not yet build/runtime-verified in isolation
|
||
|
||
## D199 — Phase-3 software audio mixer: macro-swap `aXxx` opcode execution + reverse-engineered `aSetBuffer` persistent-context semantics (`port/src/mixer.c`…
|
||
|
||
**Phase-3 software audio mixer: macro-swap `aXxx` opcode execution + reverse-engineered `aSetBuffer` persistent-context semantics (`port/src/mixer.c`, `port/include/mixer.h`, `include/PR/abi.h`).** The RSP audio ucode (`aspMain`) that would normally execute GE's acmd list never runs on PC (`port/src/ucode.c`); adapting the Perfect Dark PC port's macro-swap trick (`docs/dev/AUDIO-PLAN.md`), `include/PR/abi.h` now `#include`s `port/include/mixer.h` under `#ifdef PORT`, which redefines every `aXxx` macro to call an `Impl` function in `mixer.c` immediately against a small software "DMEM" scratch buffer, instead of packing RSP command words for later execution. GE uses the classic IDO libaudio ABI (verified against every call site in `src/libultra/audio/*.c` + `src/libultrare/audio/{env,reverb}.c`), not PD's differently-shaped "naudio New" ABI — DSP math (ADPCM decode, linear resample, linear envelope mix) is the same Nintendo/SGI ucode PD's `mixer.c` already ported; only the ABI/addressing layer is GE-specific and new here. Several opcodes (`aADPCMdec`, `aResample`, `aEnvMixer`, `aLoadBuffer`, `aSaveBuffer`) take only a state/DRAM pointer — their DMEM source/dest addresses and byte counts come from the most recent `aSetBuffer` call(s), which there is no RSP disassembly to check against; the persistent-context shape (`sCtx.{in,out,count,dryR,wetL,wetR}`) was reconstructed purely from reading every `aSetBuffer`+opcode call-site pair across `resample.c`/`load.c`/`reverb.c`/`mainbus.c`/`save.c`/`env.c` (see the field comments on `sCtx` in `mixer.c:52-82`). `aSegmentImpl` is a no-op — GE's audio DRAM addresses are already resolved via `osVirtualToPhysical` before reaching the mixer (see D198), so segment/base never factor into DMEM addressing here. **Risk:** the `aSetBuffer` context reconstruction is an inference, not a verified spec — if a call site this session didn't audit sets the context differently, audio for that path will mix from the wrong DMEM offsets. **Verified:** `-level_09` now runs full music + SFX 120s+ crash-free (post-D200 fix); no cross-level or opcode-level audio-correctness verification done yet (no golden-audio comparison tooling exists).
|
||
|
||
**Status (from §F table):** LANDED (`port/src/mixer.c`, `port/include/mixer.h`, `include/PR/abi.h`) — functional on `-level_09`, context-inference risk noted, needs a wider level sweep
|
||
|
||
## D196 — OS mouse cursor stays visible after closing the F10 options overlay in click-to-lock mode (M-49, user playtest).
|
||
|
||
**OS mouse cursor stays visible after closing the F10 options overlay in click-to-lock mode (M-49, user playtest).** `Input.MouseCaptureMode=1`. Open F10 overlay → `optionsOverlayToggle()` (`optionsoverlay.c:391`) calls `inputSuspendForOverlay()` which forces `SDL_ShowCursor(SDL_ENABLE)` + drops the grab. While open, `inputComputePad(0)` early-returns at `input.c:512` (overlay owns controller 0) **before** `reconcileGrab()` / any cursor-visibility call. On close: the next poll runs `reconcileGrab(menuMode)` (`input.c:522`) which re-grabs + hides the cursor **only if `captureArmed && !menuMode`** — i.e. only if you had already clicked-to-lock in a stage before opening F10. If you opened the overlay from a menu, or in a stage you hadn't clicked-locked yet, `want` computes 0, `mouseGrabbed` stays 0, and **nothing calls `applyCursorVisibility()`** — so the cursor `inputSuspendForOverlay()` made visible never gets re-hidden (correct free-but-focused state per `applyCursorVisibility()` `input.c:842-846` is *hidden*). **Fix (port-only, ~1 line):** call `applyCursorVisibility()` right after the `reconcileGrab(menuMode)` at `input.c:522` (self-heals every poll once the overlay-open early-return stops firing), or add an `inputResumeFromOverlay()` that runs `reconcileGrab` + `applyCursorVisibility` and call it from `optionsOverlayToggle()` on close. D180/D184/D165 input lineage. Minor.
|
||
|
||
**Status (from §F table):** **FIXED (`6e758f1d`, merged to `main`) — the first option above was applied:** `applyCursorVisibility()` now runs right after `reconcileGrab(menuMode)` in the controller-0 poll, so once the overlay-open early-return stops firing the cursor visibility self-heals on the next poll. Status cell was left stale at OPEN; corrected M-105.
|
||
|
||
## D192 — Front-end mission/level-select grid pointer still can't reach the outer cells — but only under `Input.MouseCaptureMode=0` (legacy always-grab) (M-49, user…
|
||
|
||
**Front-end mission/level-select grid pointer still can't reach the outer cells — but only under `Input.MouseCaptureMode=0` (legacy always-grab) (M-49, user playtest).** D169 was marked FIXED (M-33) by replacing the hard-coded 320×240 pointer clamp with the live `getPlayer_c_screenwidth/height/left/top()` rect; the feel-check was flagged "owed" on both D169 and D180 and never done. It now reproduces: with `MouseCaptureMode=0` the menu pointer runs the relative-delta P-controller path (`port/src/input.c:659-690`), which clamps target+estimate to `getPlayer_c_screen*` — but that accessor returns `g_CurrentPlayer->c_screenwidth` (`src/game/bondview.c:882`), which in the front end is the ~320×240 stage viewport, **not** the front end's actual 440×330 (`front.c:8570` `viSetViewSize(440,330)`). So the effective clamp is still ~300/220 while the mission-select grid's outer split points sit at 317 / 235.5 (`front.c:516/519/3180/3194`) — outer columns + bottom row unreachable, P-controller winds up and never settles. `MouseCaptureMode=1` (the shipped default, click-to-lock) writes `cursor_h_pos`/`cursor_v_pos` directly from the absolute OS cursor (`input.c:629-657`) and *feels* fine, though it shares the bad `hiH` so the last few px at the right/bottom edge may still be short. **Not a v0.1.0 blocker** — default mode is acceptable, always-grab is opt-in via INI, front-end roughness is already in the release notes. **Fix (port-only, small):** in the `menuMode` branch, when `current_menu` is a front-end menu use the real front-end rect (hard-code `front.c`'s cursor clamp `[20,420]×[20,310]`, or read the front-end viewport global) instead of routing through `g_CurrentPlayer`. Confirm with one `GE_INPUTLOG=1` capture in mission-select — sweep the mouse to each corner, watch where `cursor=(x,y)` plateaus. D165 · D169 · D180 lineage.
|
||
|
||
**Status (from §F table):** CLOSED — the buggy mode 0 was removed entirely in the v0.2.0 prep pass (see D239); click-to-lock is now the only capture mode, so the unreachable-cells path no longer exists
|
||
|
||
## D184 — F10 port-layer options overlay — the approach-(C) surface from `OPTIONS-MENU-PLAN.md` (M-37).
|
||
|
||
**F10 port-layer options overlay — the approach-(C) surface from `OPTIONS-MENU-PLAN.md` (M-37).** New `port/src/optionsoverlay.c` + `port/include/optionsoverlay.h`: an immediate-mode overlay drawn as its own fast3d 2D DL, appended after the game DL in `gfx_pc.cpp gfx_run()` (`optionsOverlayEmit()` → NULL when closed ⇒ **zero bytes appended, golden dumps byte-identical**). F10 toggles (`video.c videoPumpEvents`, next to F12); while open, `input.c inputComputePad(0)` returns a neutral pad and routes arrows/enter/wheel/mouse-buttons to `optionsOverlayHandleInput()` (mirrors the WI-1/D180 swallow). ESC closes; `configSave()` on close. `config.c` gains `configForEachOption()` + a `configSetOptionMeta()` label/step/enum side table (no per-key knowledge in config.c). Live knobs (VSync/FpsCap/TextureFilter via `videoRequestLiveConfig()`; mouse + screen-shake via the registered pointer) apply immediately; MSAA tagged "(restart)". v1 rows per plan §3: VSync, FpsCap, MSAA, TextureFilter, MouseAimSpeed, MouseTurnSpeed, MouseInvertY, MouseCaptureMode, ScreenShakeIntensity.
|
||
|
||
**Status (from §F table):** **LANDED, port-only, no `#ifdef PORT` in `src/`.** Build links 241/241. **Runtime verification NOT run this session** — the headless env kills the GUI process after ~8 s and `gfx_opengl_dump_bound_fbo` writes 0-byte PPMs here, so framediff / 60 s soak / overlay-frame eyeball are all owed on an interactive machine. Overlay-closed no-op is code-evident (emit returns NULL before touching the DL buffer).
|
||
|
||
## D203 — Steam Deck (SteamOS, x86_64 Linux) v0.1.0 release bundle: Facility (`-level_34`) crashes "after loading in as James Bond" (user bug report).
|
||
|
||
**Steam Deck (SteamOS, x86_64 Linux) v0.1.0 release bundle: Facility (`-level_34`) crashes "after loading in as James Bond" (user bug report).** Idle repro attempts on WSL Ubuntu with the actual `dist/goldeneye-pc-port-0.1.0-linux-x86_64.tar.gz` are **negative**: 120 s idle → frame 3300+ clean exit (the intro auto-advances, so "sitting as Bond" is covered), and a ~4-min stick-forward-only run → frame 6900+ also survived. So the trigger needs real gameplay input (fire/action) or is Deck-environment-specific. **Leading suspect: the D191 family** — both known v0.1.0 field crashes are SIGSEGVs in `modelGetNodeRwData` (`model.c:478`) over a truncated/NULL `root->Parent` node link, and both were triggered by *gameplay state* (first guard kill on Bunker ii; post-cutscene on Statue), never by idling; Facility opens into the Ourumov + soldier-squad scripted beat (D193a instance), which fits the trigger profile. Alternatives: Deck GL driver path (RADV/zink vs llvmpipe) or 40/50/60 Hz timing (D193 family). **Next:** (1) WSL run under gdb with a walk+fire `GE_INPUTSCRIPT` — generate the script into a file first (earlier attempts were blocked by a `bash -lc` quoting quirk that emptied the loop var, so no fire buttons ever went out); on SIGSEGV compare the fault PC against `modelGetNodeRwData`; (2) if not reproducible on WSL, get `ge007.crash.log` from the Deck (the Linux build writes it — proven by a stale one in the smoke tree) + ask what Bond was doing at crash time; **RESOLVED (D253):** reproduced on WSL Ubuntu by walking Bond into the crouch-forcing linked tile near the Facility opening; gdb capture showed SIGSEGV in `stanCheckLinkedSpecialTile` (`stan.c:2441`) storing to a stale `%r9` (0x3) while the caller's `record` local was valid — an x86-64 SysV int/float argument-class mismatch, not the D191 family. Fixed in D253; verified on WSL (same route no longer crashes).
|
||
|
||
**Status (from §F table):** CLOSED — root cause + fix in D253.
|
||
|
||
## D201 — `src/libultra/audio/bnkf.c` bank/instrument/sound/wavetable relocation offsets were `s32`, truncating the real 64-bit base pointer.
|
||
|
||
**`src/libultra/audio/bnkf.c` bank/instrument/sound/wavetable relocation offsets were `s32`, truncating the real 64-bit base pointer.** `alSeqFileNew`/`alBnkfNew` compute `offset = (s32) base` from a heap pointer and thread it through `_bnkfPatchBank/Inst/Sound/WaveTable` as `s32 offset, table` parameters, used to rebase every embedded pointer field in a loaded bank/inst/sound/wavetable/book/loop record. On N64 (32-bit pointers) the truncation is a no-op; on PC, sign-extension of the truncated `s32` during the rebase arithmetic reconstructs the wrong 64-bit address whenever the base allocation's low 32 bits have the top bit set (~50% of allocations, allocator-dependent) — corrupting every relocated pointer in the bank file, a plausible contributor to bad model/audio pointers surfacing downstream (D191-family symptoms are a node-tree walk, not this, but the class is the same). **Fix (ABI-layer, no logic change):** widen `offset`/`table`/`woffset` and all four `_bnkfPatch*` parameters from `s32` to `uintptr_t` (`<stdint.h>`) — already this codebase's idiom for pointer-safe integers in `src/game/*.c`. No behavior change on the 32-bit N64 build.
|
||
|
||
**Status (from §F table):** FIXED (`src/libultra/audio/bnkf.c`) — part of the Phase-3 mixer landing, not yet build/runtime-verified in isolation
|
||
|
||
## D208 — Player weapon-fire sound goes permanently silent mid-firefight (all other SFX unaffected) — `struct` field `bondview.h:200 s32 field_A48` is used as a second…
|
||
|
||
**Player weapon-fire sound goes permanently silent mid-firefight (all other SFX unaffected) — `struct` field `bondview.h:200 s32 field_A48` is used as a second `ALSoundState *` sound-handle slot, but is only 32 bits wide on PC (M-78c, ROOT CAUSE FOUND, D3x pointer-width class).** This is the "doesn't recover after the alarm stops" half of the user's D207 report — a distinct bug from D207's voice-pool question. The player's per-hand fire sound is double-buffered across two handle slots: `hands[h].audioHandle` (`ALSoundState *`, correctly pointer-width) and `hands[h].field_A48` (`s32`). `gunfire.c:3186-3200` (`bondfirefunc`) each trigger interval calls `sndPlaySfx(bank, idx, (ALSoundState *)&handptr->field_A48)`, and `sndPlaySfx` writes the new state through it as `pendingState->link.next = nextState` — an **8-byte pointer store** into a 4-byte field, overrunning into the adjacent `field_A4C` (which is the auto-fire sound-cadence timer, written back every frame at `gunfire.c:3168`). `sndUnlinkClearSound` likewise does an 8-byte `state->state->link.next = NULL` there. The two writers stomp each other; after enough double-buffered fire cycles `field_A48` holds a value that is neither `0` nor a valid pointer, so the `else if ((ALSoundState *)handptr->field_A48 == 0)` re-arm gate in the double buffer is **never true again → no further player gunshot is ever issued.** `audioHandle` alone can't cover because the code only replays into it `if (audioHandle == NULL)` and the async `sndDeactivate(audioHandle)` leaves it briefly non-NULL, deferring to the jammed `field_A48` branch. **Evidence (user Bunker capture, `audiotrace.log`, silenced PP7 = idx 46):** idx-46 `sndPlaySfx` requests stop dead at t=776609203515 (log line 18930 of 25086) while guard fire (109), pickups (232), impacts, doors all continue to the end; at line 19159 the player-gun slot `0x70076630` is caught holding `was 0x000001db00000000` — a torn 64-bit value = `field_A48=0x00000000` + `field_A4C=0x1db` (475, a `g_GlobalTimer`-relative cadence stamp) read as one word. The alarm is not required — it just raises `sndPlaySfx` churn so the tear is hit sooner. On N64 both fields are 4 bytes, `audioHandle` is a 4-byte pointer, and `&field_A48` punned as `ALLink*` is a 4-byte `link.next` write — all consistent, no overrun. **`bondview` / `hands[]` is runtime-only state (`g_CurrentPlayer`), never ROM-serialized**, so widening `field_A48` to pointer width is layout-safe (no by-offset or ROM-cast access — grep-verified; only `field_A50`/`field_A4C` sit after it and are plain `s32` timers).
|
||
|
||
**Status (from §F table):** FIXED + user playtest-verified (M-78d) — Bunker firefight: player gunshots persist through the whole engagement and survive the klaxon stopping. Committed 7601b9db (PR #29, merged).
|
||
|
||
## D209 — Every AI character in the game is locked to a *walk* animation regardless of the speed the ailist commands — `act_ubytes.padding[45]`, a raw-byte alias into…
|
||
|
||
**Every AI character in the game is locked to a *walk* animation regardless of the speed the ailist commands — `act_ubytes.padding[45]`, a raw-byte alias into the action union for `act_gopos.unk59` (the SPEED tier), shifts from offset 45 to 81 at 64-bit (M-81, ROOT CAUSE of D193, D3x pointer-width class).** GE character *travel* is animation-root-motion driven, so the bound locomotion animation sets the speed. `get_sound_at_range()` (`chraction.c:3573`, the locomotion-anim selector — misleading name) picks `ANIM_DATA_sprinting`/`running`/`walking` (and the one-handed/female variants) from its `arg1` tier. Its **only** caller, `play_hit_soundeffect_and_proper_volume()` (`chraction.c:3665`), passes the tier as `self->act_ubytes.padding[45]` — a raw byte read out of the `ChrRecord` action union rather than the named field. On N64 that alias is exact: `struct act_gopos` = `{coord3d targetpos@0, StandTile *target@12, waypoint *target_path@16, waypoint *waypoints[6]@20, u8 curindex@44, u8 unk59@45}`, so `padding[45]` **is** `unk59`. On x86-64 the three pointer members widen 4→8 B (`target@16, target_path@24, waypoints@32`), pushing `curindex`→80 and `unk59`→**81**, while the literal `45` now lands inside `waypoints[1]` (spanning 40..47) — specifically **byte 5 of a heap pointer**. The port's arena is low-4GB (`0x00000000_70xxxxxx`), so byte 5 is **always `0x00`** → `arg1 = 0` → `ANIM_DATA_walking` / `ANIM_DATA_walking_unarmed`, unconditionally, for every AI chr on every level since the 64-bit transition. The named-field reads (`chrlvApplySpeed(self, ..., (s32) self->act_gopos.unk59, ...)` at `:9104`, and the `unk59 == 2` / `== 1` `modelSetAnimSpeed` tiers at `:9106-9130`) resolve correctly, which is why the tier *looked* right everywhere it was inspected — only the alias was broken. **Runtime evidence (Cradle `-level_41`, `GE_D193A` extended with `tier`/`aidx`):** all six chrs report `act=15 ACT_GOPOS tier=1 SPEED_RUN` on 420/440 samples, yet `aidx` (live `model->anim` reverse-resolved against `animation_table_ptrs1`) is **40 = `PTR_ANIM_walking`** for Trevelyan (chr 0) and **107 = `PTR_ANIM_walking_unarmed`** for the guards on every single sample — `aidx 42 (running)` / `41 (sprinting)` never appear. Travel speed sat in a flat 140–155 u/s band for all of them, independent of animation, which is the walk-cycle stride. `act_ubytes` has exactly one use in the whole tree, so this is a single site. Both callers of `play_hit_soundeffect_and_proper_volume` reach it with `actiontype == ACT_GOPOS` (`:3723`, immediately after `actiontype = ACT_GOPOS; act_gopos.unk59 = speed;`; and `:9087`, inside the explicit `else` of `actiontype == ACT_PATROL`, mirroring the `chrlvApplySpeed` split directly below it), so reading the named field is exactly the byte N64 reads — no behavioural change.
|
||
|
||
**Status (from §F table):** **FIXED + user-playtest-verified (M-81).** `chraction.c:3665`, `#ifdef PORT` → `get_sound_at_range(self, self->act_gopos.unk59, ...)`; the N64 line kept verbatim under `#else`. ABI/pointer-width only, no game-logic change (rule-2 narrow exception, D3x class). User ran Cradle on the fix build: **Trevelyan and the guards run.** Also corrected the `// guess: room` comment on `unk59` (`src/bondtypes.h:2191`) — it is the SPEED tier.
|
||
|
||
## D210 — `chrToPatrol()` leaves `act_patrol.lastvisible60` uninitialised on PC — a raw-byte union alias (`act_init.padding[0x13]`) that shifts when `act_patrol` widens…
|
||
|
||
**`chrToPatrol()` leaves `act_patrol.lastvisible60` uninitialised on PC — a raw-byte union alias (`act_init.padding[0x13]`) that shifts when `act_patrol` widens (M-82, A1 class, sibling of D209).** `chrToPatrol` (`chraction.c:3872`, sets `ACT_PATROL`) does `self->act_init.padding[0x13] = -1;` — union-relative byte `0x4c`. On N64 `struct act_patrol` = `{patrol_path *path@0, s32 nextstep@4, bool forward@8, waydata@0xc (0x40 B), s32 lastvisible60@0x4c, f32 speed@0x50}`, so byte `0x4c` **is** `lastvisible60`, initialised to `-1`. On x86-64 the leading `path` pointer widens 4→8 B, so everything after shifts +4: `lastvisible60` is now at `0x50`, and the literal `0x13`/byte-`0x4c` write lands in the last 4 bytes of `waydata` (`segdisttotal`). Net PC effect: `lastvisible60` keeps whatever garbage the previous union arm left, and the "chr hasn't seen the player recently → break off / resume patrol" gate at `chraction.c:~9347` (`(act_patrol.lastvisible60 + CHRLV_DEFAULT_TIMER) < g_GlobalTimer`) reads that garbage on the first tick(s) after entering PATROL — until `chr.c:2569` / `chraction.c:3482` / `:9359` overwrite it (only when the chr actually sees the player). Same root class as D209 (raw index into a union arm whose pointer members widened); found by the post-D209 A1 sweep. Only new instance in the sweep — `act_init.padding[0..3]` in `chrlvTickAnim`/`chrlvTickDead` alias `act_anim`/`act_dead`, both pointer-free, so those are layout-stable.
|
||
|
||
**Status (from §F table):** **FIXED (`chraction.c:3872`, `#ifdef PORT` → `self->act_patrol.lastvisible60 = -1;`, N64 line under `#else`). ABI/pointer-width only (D3x/A1 class), no game-logic change.** Build clean; bunker1/facility/archives/dam verify PASS, no crash. **Owed:** a playtest eyeball of patrol-guard behaviour on entry to patrol (do they over/under-react to a player they haven't seen). Low individual impact but exactly the class the D170/D193 "AI behaviour looks off" reports keep landing in.
|
||
|
||
## D212 — Anisotropic-filtering config wiring — `Video.Anisotropy` (M-83 QoL wave, port-only).
|
||
|
||
**Anisotropic-filtering config wiring — `Video.Anisotropy` (M-83 QoL wave, port-only).** The fast3d GL backend already implemented `set_anisotropy_level` / `get_max_anisotropy_level` and applied `GL_TEXTURE_MAX_ANISOTROPY` on mipmapped tiles with a hardcoded `current_anisotropy_level = 4`; there was no way to configure it. Added the `gfx_set_anisotropy_level()` extern-C wrapper in `gfx_pc.cpp` (clamps to `[1, GL max]`, `reset_texture_state()` so live changes take), declared in `gfx_api.h`, and `Video.Anisotropy` (1–16) in `port/src/video.c` wired through `videoApplyImageOptions()` on init + live-config. **Default 4 = the value fast3d already applied ⇒ no visual delta at defaults.** F10 overlay row "Anisotropic".
|
||
|
||
**Status (from §F table):** **LANDED, port-only.** Build clean. **Owed:** eyeball at 16× on a grazing surface (Depot roof / Dam walkway) vs 1×.
|
||
|
||
## D213 — On-screen FPS readout — `Video.DisplayFPS` (M-83 QoL wave, port-only, PD parity).
|
||
|
||
**On-screen FPS readout — `Video.DisplayFPS` (M-83 QoL wave, port-only, PD parity).** `port/src/optionsoverlay.c` samples frame time once per emitted frame (`fpsTick()` via `sysGetMicroseconds()`, ~0.5 s window) and, when enabled, emits a small top-right green "N FPS" DL even while the F10 panel is closed. Config-only knob (kept off the 13-row panel, which is at its documented layout limit — matches PD, whose `DisplayFPS` is also file-only). **Default 0 ⇒ `optionsOverlayEmit()` still returns NULL when the panel is closed ⇒ golden dumps byte-identical.**
|
||
|
||
**Status (from §F table):** **LANDED, port-only.** Build clean; `bunker1` no-op confirmed. **Owed:** eyeball that the counter reads a sane ~30 and doesn't overlap the HUD.
|
||
|
||
## D214 — Keyboard rebinding — `[Input.Bind]` config section (M-83, port-only, PD parity, Phase 4 debt).
|
||
|
||
**Keyboard rebinding — `[Input.Bind]` config section (M-83, port-only, PD parity, Phase 4 debt).** `port/src/input.c` gains 12 gameplay actions (Forward/Back/StrafeLeft/StrafeRight/TurnLeft/TurnRight/Fire/Aim/Action/Cancel/LeanLeft/Start), each a comma-separated list of up to 4 SDL scancode names (`Input.Bind.Forward = W,Up` …). `inputRebuildBinds()` (called from `inputInit()`, after `configLoad()`) parses them via `SDL_GetScancodeFromName`; unknown names log a warning and are skipped. The old hardcoded `keyDown(ks, SDL_SCANCODE_*)` block in `inputComputePad` is replaced by `actHeld(ks, IA_*)`. Mouse buttons (fire = LMB, aim = RMB) stay hardwired; the mouse-wheel weapon-cycle A-pulse is untouched. **Defaults reproduce the previous FPS layout exactly — verified: all 20 default key names round-trip to the identical `SDL_Scancode` the old code used (`SDL_GetScancodeFromName` test, 0 mismatches).** Config-file only (no in-overlay text entry — matches PD, whose rebinding is also file-driven); a fresh `ge007.ini` gets the full `[Input.Bind]` block with the defaults visible.
|
||
|
||
**Status (from §F table):** **PARTIAL — M-84: code PASS (alt/ctrl); one doc-only fix.** LANDED, port-only, no `#ifdef PORT`; build clean. One doc bug fixed (no code change): `config.c` groups keys by the substring *before the last `.`*, so the section header is **`[Input.Bind]`**, not `[Bind]` — earlier finding/commit/QOL text was wrong. Working syntax: put `Fire = Left Alt` under `[Input.Bind]`, or write a fully-qualified `Input.Bind.Fire = Left Alt` line anywhere (`config.c:307`). Separately: the pause→**watch menu** does not wire Esc→back (still Enter to open/close/back) — pre-existing, `options.c`, minor.
|
||
|
||
## D215 — 1P weapon viewmodel never drew in-level — `gunRenderFirstPersonGunModels` builds its `ModelRenderData` from a reinterpret across separately-declared adjacent…
|
||
|
||
**1P weapon viewmodel never drew in-level — `gunRenderFirstPersonGunModels` builds its `ModelRenderData` from a reinterpret across separately-declared adjacent globals that only works on N64 (M-83, D3x/ABI class; retires VIEWMODEL-RESEARCH.md).** The N64 line `renderdata = *(ModelRenderData *)&D_80035CC0;` (`gunfire.c`) casts the lone `u32 D_80035CC0 = 0;` to a whole `ModelRenderData` and reads on into the next declared global, `u32 D_80035CC4[] = {1, 3, 0, 0, …}` (`gun.c`) — yielding `{ basemtx=0, zbufferenabled=1, flags=3, rest=0 }` **only because the N64 linker lays the two globals out contiguously in declaration order and pointers are 4 B**. On x86-64 it breaks twice: `ModelRenderData`'s three pointer members (`basemtx`/`gdl`/`mtxlist`) widen 4→8 B so `flags` is no longer at struct offset 8, and C guarantees nothing about the two globals' relative placement. Measured (`GE_DVM=1`, BUNKER1): `renderdata.flags == 0`, so **every** weapon-model DL node was gated off at `modelRenderNodeGundl` (`(renderdata->flags & 1)`), and `subdraw()` for the weapon emitted just its `gSPSegment` — 1 gfx command instead of ~150. All three VIEWMODEL-RESEARCH.md ranked suspects were ruled out by the same probe first: `field_87F==1` (gate open), `RootNode` valid, `numRecords 22 < 192` (pool fine), node walk visits 24+ valid `DL`/`SWITCH`/`BSP` nodes. **Fix:** `#ifdef PORT` → set the three fields explicitly (`zbufferenabled=TRUE; flags=3;`), N64 line verbatim under `#else`. ABI/layout only, no logic change.
|
||
|
||
**Status (from §F table):** **FIXED (`src/game/gunfire.c`, `#ifdef PORT`).** Build clean. `GE_DVM=1` post-fix: `flags=0x3`, weapon `subdraw()` now emits **153** gfx commands (was 1). Regression sweep crash-free. `GE_DVM` gate+render probes left in, env-gated (cf. `GE_D193*`). **Owed:** one BUNKER1 eyeball that the gun model is actually visible + correctly posed (headless can't see pixels; the DL is now provably generated). The committed `bunker1` golden will need re-baselining — the gun now renders in-frame.
|
||
|
||
## D216 — `Game.SkipIntro` — boot straight to the file-select menu (M-83, port + one `#ifdef PORT` game-code line, PD parity, Phase 4 quick win).
|
||
|
||
**`Game.SkipIntro` — boot straight to the file-select menu (M-83, port + one `#ifdef PORT` game-code line, PD parity, Phase 4 quick win).** New `s32 portSkipIntro` (`port/src/video.c`, `configRegisterInt("Game.SkipIntro", 0, 1)`), read once in `src/game/lv.c` at `LEVELID_TITLE` stage load — in the `else` of the existing `GE_STARTMENU` `#ifdef PORT` block. When set it does what every intro stage's own `!is_first_time_on_main_menu` branch does (`front.c`): `is_first_time_on_main_menu = FALSE; prev_keypresses = TRUE; maybe_is_in_menu = TRUE; menu_update = MENU_FILE_SELECT;` — i.e. it reuses the game's own post-intro route rather than inventing one, so the legal screen + Nintendo/Rare/GoldenEye logo attract loop is skipped. `GE_STARTMENU` still wins when both are set. Default 0 ⇒ untouched.
|
||
|
||
**Status (from §F table):** **LANDED + VERIFIED this session.** Build clean. Headless: default boot frame 90 = the TWYCROSS classification screen (10% non-black), `SkipIntro=1` frame 90 = the **SELECT FILE menu** (folders/photos/cursor all render, 89% non-black), no crash across repeated boots. Screenshots captured + eyeballed. **Owed:** nothing critical — a human boot to confirm the menu is fully interactive (cursor moves, a save slot opens). **REGRESSED (v0.2.1 user testing, 2026-09-15): user reports enabling `Skip intro` breaks audio.** Not root-caused this session — leading suspect is that the intro/attract stages perform some audio-subsystem init (title/attract music kickoff, or a first-touch of the mixer) that `portSkipIntro`'s jump to `MENU_FILE_SELECT` bypasses. **Mitigation applied:** the `Game.SkipIntro` row was pulled from the F10 options overlay (`port/src/optionsoverlay.c`) so players can no longer enable it from the menu; the config var, default (0/off), and `lv.c` hook are untouched (so an existing `ge007.ini` with it already set would still trigger the bug — worth a follow-up hard-disable if that turns out to matter). Do not re-add the menu row without root-causing the audio interaction first.
|
||
|
||
## D222 — High `Video.FovScale` breaks culling — NPCs and some world objects stop rendering near the screen edges (M-87, user QA).
|
||
|
||
**High `Video.FovScale` breaks culling — NPCs and some world objects stop rendering near the screen edges (M-87, user QA).** Split out of D211's noted residual: `currentPlayerSetCameraScale()` / the game's frustum-cull and screen↔world math still run at the **nominal, unscaled** fovy while the actual render projection is widened by `portFovScale` (`fr.c:737`) — so objects that are on-screen in the widened view but outside the nominal-FOV frustum get culled before they reach the renderer. Confirmed **not cosmetic** — a real, noticeable functional regression at high FovScale, not just "culls a hair early."
|
||
|
||
**Status (from §F table):** **FIXED, awaiting visual verification (M-121).** Root cause traced one step further than the original write-up: `currentPlayerSetCameraScale()` (`game/bondview.c`) derives both the frustum-cull plane normals (`c_cameratopnorm`/`c_cameraleftnorm`) *and* the fog/LOD distance-visibility-fade scale (`c_scalelod`/`c_lodscalez`, consumed by `chrobjFogVisRangeRelated()` et al. in `propobj.c`) from `c_perspfovy`, which every `viSetFovY`/`viSetAspect`/`viSetFov`/`viSetZRange` call in `fr.c` was setting from the **nominal** `g_ViBackData->fovy` — never the `portFovScale`-widened value D211 already computes for the actual render projection two lines away in `viSetupCurrentPlayerView`. Fix: factored the existing widen-FOV math out of `fr.c` into `f32 portScaleFovY(f32 fovy, s32 isTitleScreen)` (`port/src/video.c`, D222 — identical logic, same `LEVELID_TITLE` guard, no game-code duplication) and fed it into all 4 `currentPlayerSetPerspective()` call sites via a small `#ifdef PORT` local helper in `fr.c` (`frCullFovY`), same as the existing D211 projection-matrix chokepoint now also calls the shared helper instead of its own inline copy. Identity (bit-for-bit no game-code-visible change) at `FovScale=100` by construction — `portScaleFovY` returns `fovy` unchanged whenever `portFovScale == 1.0f`. Audited per the original risk note: `c_cameratopnorm`/`c_cameraleftnorm`/`c_scalelod`/`c_lodscalez` are consumed only in `propobj.c`'s fog-visibility-fade math and `bondview.c`'s own screen-space transform — no AI-visibility/awareness call sites found reading these fields, so `portFovScale` still only touches rendering-adjacent visibility, not NPC behavior. Rebuilt clean, crash-free at `FovScale=150` on `-level_33` (Dam). `bunker1` golden framediff was inconclusive in this dev box — the *unmodified* baseline build also fails it by a similar or larger margin (confirmed via an A/B stash/rebuild), matching the documented pre-existing frame nondeterminism (`porting-notes.md` §E), not a regression from this change. **Needs a human's eyes**: does high-`FovScale` screen-edge NPC/object culling actually look fixed now.
|
||
|
||
## D223 — QoL ask: mouse-wheel weapon cycling should be directional like a normal PC shooter — wheel up = next weapon, wheel down = previous — instead of both directions…
|
||
|
||
**QoL ask: mouse-wheel weapon cycling should be directional like a normal PC shooter — wheel up = next weapon, wheel down = previous — instead of both directions cycling forward (M-87, user QA).** **Root-caused, port-only fix, no game-logic change needed** — GE's engine already has separate forward/backward weapon-cycle signals: `bondview2.c` computes `moveData.weaponForwardOffset = edge(invButtons) && !(shootButtons held)` and `moveData.weaponBackOffset = (invButtons held) && edge(shootButtons)` (`bondview2.c:5338-5347`, the classic N64 "hold A, tap Z to cycle backward" trick). Default control scheme (the `else` branch used unless a Domino/Goodhead/Galore/Plenty preset is active, `bondview2.c:5177-5179`): `invButtons = A_BUTTON` (= port `GE_CONT_A`, `input.c:86`), `shootButtons = Z_TRIG` (= port `GE_CONT_G`, `input.c:88`, the fire button). `moveData.triggerOn` is explicitly suppressed while `invButtons` is held, so holding A never causes an accidental shot when the Z edge lands. The port's `inputPostWheel()` (`input.c:977`) currently does `if (notches < 0) notches = -notches` — **discards wheel direction** — and only ever synthesizes a bare `GE_CONT_A` pulse (`wheelPulse`, `input.c:631-634`), i.e. always "forward."
|
||
|
||
**Status (from §F table):** **FIXED (M-105, `port/src/input.c`) — directional wheel cycling implemented per the plan below.** Wheel-up keeps the existing 2-poll `A` pulse (`weaponForwardOffset`: fresh A edge, Z released). Wheel-down runs a 2-poll backward sequence: poll 1 presents `A` only, poll 2 presents `A`+`Z_TRIG` — the fresh-Z-edge-while-A-held combo `weaponBackOffset` requires (`bondview2.c:5337-5340`); ordering matters because the edge is computed against the game's own previous poll. `moveData.triggerOn` (`bondview2.c:5419`) requires A to be released, so the synthesized Z edge can never fire a shot. Roles are stable: the port forces `CONTROLLER_CONFIG_HONEY` (`bondview.c:1484`), whose scheme gives `invButtons=A_BUTTON`/`shootButtons=Z_TRIG` — the same roles the pre-fix forward pulse already relied on. Known limitation (pre-existing, not a regression): while RMB aim is held the game flips `controldef` to KISSY (`bondview2.c:6106-6112`), under which *neither* direction cycles — exactly as the old forward-only pulse behaved in that state. Owes a human play-test (wheel up = next weapon, wheel down = previous, no accidental shots). Plan: keep `wheelPulse` (or a new `wheelPulseFwd`) for wheel-up as today; add a `wheelPulseBack` path for wheel-down that instead holds `GE_CONT_A` across ≥2 polls while presenting a fresh `GE_CONT_G` edge partway through (mirroring the invButtons-held + shootButtons-edge combo) — sequence matters (A must already read "held" `oldbuttons`-wise before G's edge, per `weaponBackOffset`'s check order). Must confirm which N64 control preset (`controldef`) the port's save data actually carries — `invButtons`/`shootButtons` differ under KISSY/GOODNIGHT/SOLITARE/etc; if the port doesn't force one, the synthesized combo needs to match whatever's active, or the port should just force the default (HONEY-equivalent) preset since its own `Input.Bind` system already replaces N64-style scheme selection. Port-only, `#ifdef PORT` in `input.c`, no `src/` game-logic touch. **M-87: PD-PC-port precedent checked (user asked what PD did about N64 control presets).** PD does **not** route mouse/gamepad through the N64 preset system at all — it defines its own logical control-key layer (`CK_*` in `port/src/input.c`/`input.h`, e.g. `CK_DPAD_L`/`CK_Y`) fully decoupled from the N64 controller-config enum, and even treats mouse-wheel-up/-down as **two independent bindable virtual keys** (`VK_MOUSE_WHEEL_UP`/`_DN`, defaulted to distinct `CK_*` actions) rather than one "wheel = cycle" event — exactly the shape of the fix this row wants. PD's `controldef` field is hardcoded (`player.c`/`playermgr.c` `controldef = 2`), not left to the original in-game control-style picker. **This GE port already has the equivalent layer for this purpose: D214's `Input.Bind` system.** Recommendation for D223 (and the user's broader ask — N64 presets aren't meaningful for mouse/keyboard, and modern-gamepad (Xbox/PS) support should be a first-class port concept, not routed through N64 presets either): add two new `Input.Bind`-able logical actions (e.g. `IA_WEAPON_NEXT` / `IA_WEAPON_PREV`) that synthesize the invButtons/shootButtons combos under the hood, default-bound to wheel-up/-down (keyboard/mouse) and left open for a future bumper/shoulder-button default under real gamepad support; and have the port force a single fixed `controldef` (matching PD's approach) so the synthesis target never depends on the original N64 control-style menu. That menu itself becomes a candidate for hiding/no-op in a future QoL pass once `Input.Bind` fully supersedes it — not proposed as a change here, just noted as the natural conclusion. **M-87 — D194 gives a concrete answer for *which* preset: `CONTROLLER_CONFIG_SOLITARE` (1.2).** It's the scheme that gives free continuous full-stick mouselook (see D194) — and conveniently, `SOLITARE` is **not** in the `KISSY`/`GOODNIGHT` button-role group (`bondview2.c:5168-5179`), so it keeps the **same** `shootButtons=Z_TRIG`/`invButtons=A_BUTTON` roles this row's plan already assumed — no change needed to the wheel-cycle synthesis. (`GOODNIGHT` (1.4) also gets natural pitch/turn but groups with `KISSY` for button roles — `shootButtons=A_BUTTON`, `invButtons=L_TRIG|R_TRIG` — so it would need this row's combo re-derived; prefer SOLITARE unless something else needs GOODNIGHT specifically.) **M-87 survey note, filed for later (candidate 4, no action needed now):** GE's port has no per-player control-*mode* concept — keyboard/mouse is always controller 0 (`inputOpenPads()`, `connectedMask=0x1`), covering the common "P1 mouse+kb, P2+ pads" local-MP case fine. PD adds a 5th per-player control-mode entry ("PC controls") with a save-compatibility shim (written as an existing N64 mode so the save format never changes, overridden back to PC mode on read by config) to let *any* player be on mouse+keyboard, not just P1. Only relevant if "P1 on pad, P2 on keyboard" (or more than one keyboard player) is ever a real ask — bigger feature than anything currently planned; not worth a Dxx yet.
|
||
|
||
## D224 — QoL gap (M-87, from a PD-PC-port survey): the N64 Rumble Pak is fully dropped instead of modernized.
|
||
|
||
**DONE via D401 (2026-09-27).** Re-promoted from DEPRIORITIZED; implemented per
|
||
`docs/dev/RUMBLE-PLAN.md` exactly as the M-87 addendum prescribed — both
|
||
upstream blockers (`CONT_CARD_ON` gate + `osPfsInit` → `PFS_ERR_DEVICE`) landed
|
||
with the `SDL_GameControllerRumble` wiring, plus `Input.PlayerN.RumbleScale`
|
||
config (options-menu slider deferred). Real-pad playtest + `RumbleScale=0`
|
||
silence check owed. See `## D401` (file tail) for the full record.
|
||
|
||
**QoL gap (M-87, from a PD-PC-port survey): the N64 Rumble Pak is fully dropped instead of modernized.** GE genuinely has a rumble subsystem (`src/motor.c`, `src/joy.c`) but the port's `osMotorInit/Start/Stop` (`port/src/libultra.c:1220-1223`) are all hard stubs — "no accessories on the PC," `osMotorStart`/`osMotorStop` return `-1` unconditionally, `osPfsFindFile`-family returns `PFS_ERR_NOPACK`. So every in-game rumble event (weapon recoil, explosions, etc.) computes and calls into a dead path. **PD's PC port wires the equivalent straight through to real gamepad rumble:** `inputRumbleSupported(idx)` checks `SDL_GameControllerHasRumble`/`SDL_JoystickIsHaptic` (with a Windows fallback: some pads report no haptics but rumble anyway), `inputRumble(idx, strength, time)` calls `SDL_GameControllerRumble()` scaled by a per-pad `RumbleScale` config value (options-menu slider, `Input.PadN.RumbleScale`), and `osPfsFindFile` reports a "Rumble Pak" present exactly when a real pad supports rumble (`libultra.c` — same "pretend the N64 accessory is there" pattern GE's port already uses for `osMemSize`/Expansion Pak, just applied to a real capability instead of faked). Cross-ref `QOL-INVENTORY.md` (folded into the existing "per-pad tuning" row).
|
||
|
||
**Status (from §F table):** **DEPRIORITIZED (M-87, user call).** Gamepad-only — irrelevant to mouse/keyboard, which is the primary target for this next release; no keyboard/mouse play is affected by NOT doing this. Root cause + fix still valid whenever gamepad support becomes a priority (no `src/` game-logic touch: GE's `motor.c`/`joy.c` call sites already exist and already call the `osMotor*` shims; the fix is entirely in `port/src/libultra.c` + `input.c` — add `inputRumbleSupported`/`inputRumble` per the PD shape, wire `osMotorStart`/`osMotorStop` to them, add `Input.PadN.RumbleScale` config). **M-87 PD-legacy-survey addendum (`docs/dev/notes/PD-LEGACY-SURVEY.md` candidate 1, high confidence) — the D224 fix as originally stated is NOT sufficient by itself.** Two more upstream blockers sit before the stubbed `osMotorInit/Start/Stop` ever gets reached: (1) `port/src/libultra.c:1096-1098` sets `g_contStatus[i].status = 0` for every pad — `CONT_CARD_ON` is never set, and `src/joy.c:184` gates the *entire* rumble-pak init path on that bit, so `joyRumblePakInit()` never even calls `osPfsInit`. (2) `src/joy.c:186-190` only attempts `osMotorInit` if `osPfsInit`'s return is `PFS_ERR_ID_FATAL` or `PFS_ERR_DEVICE` — but the port's stub (`libultra.c:1214`) returns `PFS_ERR_NOPACK`, so motor init is never attempted even once (1) is fixed. PD's exact trick (`libultra.c:213-227,349-351`): set `CONT_CARD_ON` for connected pads, and make `osPfsInitPak()` return `PFS_ERR_DEVICE` specifically when the pad supports rumble. **Whenever D224 gets implemented, both of these must land alongside the `SDL_GameControllerRumble` wiring** or the game's state machine parks at `RUMBLEPAKINITSTATE_NOT_READY` forever and nothing audibly changes despite looking wired up correctly.
|
||
|
||
## D225 — Dev-tooling QoL (M-87, user ask): a debug-build/env toggle that unlocks all missions + all cheats, for testing.
|
||
|
||
**Dev-tooling QoL (M-87, user ask): a debug-build/env toggle that unlocks all missions + all cheats, for testing.** Root-caused a clean single choke point — **both** gates route through the same function: `bool fileGetIsCheatUnlocked(save_data *save, s32 cheat)` (`src/game/file2.c:391`) reads the save's `unlocked_cheats_1/2/3` bitfields and returns whether bit `cheat` is set. Mission-select lock (`src/game/file.c:59`, gates whether a briefing page's mission is selectable) and the cheat-menu availability check (`frontCheckIfCheatIsUnlocked`, `front.c:1009`, itself calling `fileGetIsCheatUnlocked` for the per-level `SP_LEVEL_*` cheat IDs) **both** call it — it's a pure, side-effect-free bitfield read (no save-data mutation), so short-circuiting it to always-true under a debug env var unlocks mission-select *and* the cheat menu in one change, matching exactly what was asked ("those two go a long way"). **Bonus, free once cheats unlock:** the ROM already contains real Rare-era developer cheats that are otherwise unreachable in normal play — `CHEAT_LINEMODE` (wireframe render — would make D222-style culling bugs trivial to see), `CHEAT_BONDPHASE`, `CHEAT_DEBUG_POS`, `CHEAT_DEBUG_UNK5` (`cheat.c` ~L687-707, `CHEAT_MASK_GLOBAL`) — worth trying once this lands, may be useful dev tools in their own right.
|
||
|
||
**Status (from §F table):** **FIXED (2026-09-18), implementing the plan as proposed.** `fileGetIsCheatUnlocked()` (`src/game/file2.c:391`) now has an `#ifdef PORT` block right after the function's existing D301 NULL-guard: `GE_DEBUG_UNLOCKALL` is read once via the established cached-getenv pattern (`static int s_unlockAll = -1; if (s_unlockAll < 0) s_unlockAll = getenv(...) != NULL;`) and, if set, returns `TRUE` unconditionally before the real bitfield read. Default off ⇒ zero behavior change; when set, purely a query-time override — the save file is never written, so it can't corrupt or leak into a shipped save. **Verified:** clean rebuild; headless boot with `GE_DEBUG_UNLOCKALL=1 GE_STARTMENU=7` reaches mission select crash-free (visual confirmation of "everything shown as unlocked" not captured this session — the mission-select filmstrip's non-selected thumbnails always render dimmed regardless of lock state, so a static frame can't distinguish locked-dim from just-not-hovered; mechanism is correct by code read). The bonus developer-cheat suggestion (`CHEAT_LINEMODE` etc.) from the original write-up is unexplored, not part of this fix.
|
||
|
||
## D226 — QoL ask: player-adjustable HUD scale for the ammo counter, bottom-left pickup/item status text, and top-of-screen dialogue/subtitle text.
|
||
|
||
**QoL ask: player-adjustable HUD scale for the ammo counter, bottom-left pickup/item status text, and top-of-screen dialogue/subtitle text.** All three route through the same primitive, `textRenderOutlined()` (`src/game/textrelated.c:688`, doc comment: *"Used for ammo counter, bottom left HUD messages, countdown timers"*), called from two specific in-game (not menu) sites in `bondview2.c`: **ammo + bottom-left pickup/status** at `bondview2.c:10005` (`stringbuffer_lowerleft[...]`, anchored at `view_left`/`view_vert`), and **top-of-screen dialogue** at `bondview2.c:10163`/`10165` (`stringbuffer_top[...]`, anchored at `msg.x`/`msg.y`). `textRenderOutlined` → `textDrawGlyphQuad` (`textrelated.c:615`) emits each glyph as a plain RDP `gSPTextureRectangle` at the glyph's native font-metric pixel size, `dsdx`/`dsdy` = `0x400` (1:1 texel:pixel, no scale factor anywhere in the call chain). Same function is also used by menus/options/multiplayer UI (`front.c`, `options.c`, `mpmenu.c`) — **do not scale generically**, those have their own layout at fixed screen fractions and D211 already established HUD anchors must stay put; scale only the 3 in-game HUD call sites named above, about their existing anchor (so bottom-left stays bottom-left, top stays top — same anchor-preserving principle as D211's HUD-static check).
|
||
|
||
**Status (from §F table):** **OPEN — root cause / call sites identified, not designed or implemented.** Two viable directions: (a) a `#ifdef PORT` wrapper at just these 2-3 `bondview2.c` call sites that scales the emitted `gSPTextureRectangle` geometry (`xl/yl/xh/yh`) by a new `f32 portHudScale` (default 1.0 ⇒ byte-identical) while inversely scaling `dsdx/dsdy` to keep the same source glyph texels mapped onto the larger/smaller rect, re-anchored so the opposite edge (bottom for the lower-left block, top for dialogue) doesn't move; or (b) thread a scale parameter into `textRenderOutlined`/`textDrawGlyphQuad` itself, defaulted to 1.0 at every other call site. (a) is more surgical (matches the "don't touch shared menu text" constraint) but duplicates a little geometry math; (b) is more central but touches a much more widely shared function. No PD precedent found (PD's port has no equivalent HUD-scale option). F10 overlay slider candidate once implemented, alongside `Video.FovScale`.
|
||
|
||
**Design refinement (2026-09-23, not implemented).** Per-glyph scaling in `textDrawGlyphQuad` (option a/b above) is insufficient. Both HUD text sites first draw a background box sized to the measured *unscaled* text (lower-left: `draw_blackbox_to_screen`; top dialogue: `microcode_constructor_related_to_menus` over `msg.y..msg.bottom`), NTSC's dialogue path is `textRender`, not `textRenderOutlined`, and the ammo counter pairs text with an icon image (`set_rgba_redirect_generate_microcode`). Scaling glyphs alone would overflow the boxes and split the ammo readout. **Recommended:** a port-side span transform in fast3d, analogous to D335's aspect modes: a new EXT command carrying (scale, anchor x/y) that scales *all* rect and vertex positions (x and y, plus texrect dsdx/dsdy) about the anchor until cleared. The game-side edit is then just a wrap around each HUD call (box + text + icon together), anchored bottom-left for status text, top-centre for dialogue and bottom-right for ammo. The D335 LEFT/RIGHT/CENTER anchoring composes with it. Slider `Game.HudScale` 75–200%, default 100 (no emission, byte-identical).
|
||
|
||
**IMPLEMENTED (2026-09-24, v0.4.0) as designed above: `Game.HudScale` (F10 "HUD scale %", 75–150, default 100; capped at 150 after the user playtest: "past that not super effective").** fast3d: new extension command `G_HUDSCALE_EXT` (opcode 0x46, free in GE's command space; w0 low 16 = scale×256, w1 = anchor x/y in 10.2): while active, `gfx_dp_texture_rectangle` / `gfx_dp_fill_rectangle` scale rect coordinates about the anchor and divide texrect dsdx/dtdy by the scale (glyphs stay crisp, same texels). Rects clip at the canvas origin (negative rects aren't drawn). The state resets every frame. It composes with the D335 aspect anchors (scaling happens in logical space before the aspect mapping). Game (`#ifdef PORT`, Rule-2 approved presentation, `port/include/hudaspect.h` macros): bottom messages / title card anchored at the view's bottom-left; top dialogue anchored at its text origin (`msg.x` = view left + 0x1e); ammo per hand at the bottom-right / bottom-left. **Width cap:** dialogue and bottom-message line breaks are authored into the text (no runtime wrap), so those two scale only as far as their widest line still fits (`textMeasure` with the renderer's fonts; long lines stay at ~100% instead of clipping). At 100% nothing is emitted (byte-identical; golden 3/3). **Verified (1280×720 captures):** title card and ammo at 150%/200% correctly enlarged about their corners; Archives dialogue no longer clips (an earlier centre-anchor attempt cut off "Guard:", and an origin anchor without the cap overflowed the right edge). Windows + Linux builds clean. Commit `30720354`.
|
||
|
||
## D230 — In-level music quality: a bass instrument sounds corrupted on PC, correct on N64 reference (M-105, user reports; repro refined M-106).
|
||
|
||
**In-level music quality: a bass instrument sounds corrupted on PC, correct on N64 reference (M-105, user reports; repro refined M-106).** Filed to give the post-D77 residual its own label — D77 (in-level music *absence*) is RESOLVED (PR #42, missing `return` in `sub_GAME_7F0C0BF0` zeroed the seqp volume on the only in-level trigger path), so "music doesn't play" claims in tracked docs are stale; what actually remains is this quality gap. **M-106 repro, deterministic (not intermittent):** Control's elevator track and its regular in-level track both carry the bad instrument throughout; Cavern's elevator track has it too; Runway's track is otherwise fine but the same instrument enters ~5–7s after the track starts and sounds bad from then on. User confirmed against original N64 reference audio that a bass instrument belongs there on Runway — this is **not** a wrong-instrument/wrong-bank selection (rules out the D77 trigger-path family and the seqp-volume fix's neighborhood); the instrument choice is correct, only its *sound* is wrong on PC. Determinism (same track, same spot, every time) points at a static decode/synthesis bug for this instrument's wavetable/ADPCM data — most likely low-frequency-specific (pitch-shift/downsample artifacting, a wrap/loop-point error, or a clipping/gain issue that only bites bass-range samples) — rather than a live mixing/voice-stealing issue (D147/D152/D202 family), which would be intermittent. The mixer layer and the trigger layer are both proven working by every track that sounds right, so the suspect neighborhood is the seq/instrument-bank decode path (`csplayer.c`/`synthesizer.c`, bank loading), not `port/` audio plumbing.
|
||
|
||
**Status (from §F table):** **FIXED (M-121, v0.2.0) — see the M-121 paragraph below for the actual root cause and fix; the M-108/M-120 material immediately following this line is superseded investigation history, kept for the record.** Original M-108 static-analysis pass, repro: Control (elevator + regular track, throughout), Cavern (elevator, throughout), Runway (~5–7s in). Traced the suspect neighborhood to the software ADPCM decoder in `port/src/mixer.c` — the PC replacement for the RSP's `aLoadADPCM`/`aADPCMdec` ucode ops (`csplayer.c`/`synthesizer.c` only emit ucode command lists; they don't decode samples themselves). Found two hardcoded assumptions that don't hold for the general ADPCM-book format: (1) `sAdpcmTable` (`mixer.c:174`) is declared `[8][2][8]`, hardcoding predictor `order=2`, but `order`/`npredictors` are per-instrument authored fields (`include/PR/libaudio.h` `ALADPCMBook.order`; `src/libultra/audio/load.c:428-429` computes `bookSize` from them) that the bank-relayout code (`port/src/romdata.c:718-736`) already handles generically — if this bass instrument's authored book uses `order != 2`, the 2-tap recursion at `mixer.c:223-240` would decode it wrong; (2) even at `order=2`, `aLoadADPCMImpl` (`mixer.c:177-192`) silently truncates any book over 256 bytes (`mixer.c:183`, `npredictors > 8`), while the decode loop's 4-bit predictor-select nibble (`mixer.c:219`) can legitimately address up to 16 rows — an instrument with `npredictors > 8` would read truncated/garbage rows for some frames. **Caveat weakening confidence:** N64 SDK ADPCM in practice is conventionally always `order=2` (the RSP ucode's own decode algorithm is essentially fixed to a 2-tap recursion industry-wide), so candidate (1) may not apply to a real N64 title's data; candidate (2) (`npredictors > 8`) is the more plausible of the two absent contrary evidence. Not confirmed against GE's actual ROM-authored book data (no live capture taken this pass). Next: extend the existing `GE_MIXERTRACE=1` trace's `[LOADADPCM]` line (`mixer.c:187`, already logs `count` — `count/16 = order*npredictors`) to flag any book where `count > 256` (proves truncation) during a Control run, and correlate the anomalous book to its channel/instrument via `seqplayer.c:1120`'s program-change. If no anomaly shows, this whole neighborhood is likely a dead end and the next suspect is the resample (`mixer.c:295-341`) or envelope/gain (`mixer.c:379+`) paths for bass-range-specific behavior. **M-120 (local-Qwen triage dispatch): a third hypothesis proposed and CHECKED, RULED OUT.** The dispatched read-only pass proposed that `afFixupBook`'s `port/src/romdata.c:733-737` conversion of the ADPCM book coefficients (BE ROM → LE host, via `afRd16`/`afWr16`) is backwards and should stay big-endian. Verified against the actual code and disproven: (1) that conversion is the identical BE→LE pattern used for every other field in the same function (`order`/`npredictors` two lines above) and in the sibling `afFixupAdpcmLoop`'s ADPCM state array a few lines down — not something special-cased for this one book; (2) `port/src/mixer.c`'s `aLoadADPCMImpl` does a raw `memcpy` of the already-converted book straight into a host-native `s16[8][2][8]` table with no further byte-swap, consistent with the coefficients being intentionally pre-converted to host order; (3) decisively, `afFixupBook` is one shared code path applied uniformly to every ADPCM book in the game — if this conversion were actually backwards, every ADPCM-encoded sound would decode wrong, not one bass instrument on three tracks, and the vast majority of the game's audio is confirmed correct. Do not re-propose this specific byte-order theory without new evidence; the standing M-108 leads above (predictor-count truncation / `order` mismatch) remain the live ones. **FIXED (M-121, v0.2.0 finalization) — root cause was a plain byte-order bug in a corner neither M-108 nor M-120 looked at: RAW16 wavetables.** Offline decode of the local NTSC ROM's instrument bank found exactly two `AL_RAW16_WAVE` (type 1) wavetables — instruments 2 and 51, both sharing base 0x2118 / len 790 in the `_instrumentstbl` segment — which decode as a clean ~110 Hz soft-attack bass synth sample when read as **big-endian** s16. That is the missing bass instrument on Control/Cavern/Runway. On N64 the CPU reads those bytes BE, so it plays fine there; on PC the mixer's resampler (`port/src/mixer.c` `alRaw16Pull` path) consumes host-native (little-endian) s16, so every sample arrives byte-swapped — a deterministic per-instrument corruption, exactly matching the M-106 repro (and explaining why every ADPCM-based instrument in the game sounds right: nibble data is byte-order agnostic). Both M-108 candidates are moot — every ADPCM book in both bank trees (scanned offline from the ROM) is `order=2, npredictors∈{1,4}`, so neither the `[8][2][8]` shape nor the 256-byte truncation can ever bite GE's data. **Fix (port-only, D55 in-place-fixup idiom):** `romdataFinishCartMap()` (`port/src/romdata.c`) now walks both bank trees (SFX + instruments; same ALBankFile layout as `romdataFixupAudioBank`) and bswap16s each RAW16 wavetable's `[tbl+base, +len)` range in place in the cart map — where the sample data lives for the mixer (`alBnkfNew()` points at the tbl segments directly; only the bank structs are re-laid out, D37). Shared ranges are deduped (swapping twice would undo the swap); every read is bounds-checked against the ROM size. Startup log: `romdataInit: D230 byte-swapped 1 RAW16 wavetable range(s) in place` (one range: the two instruments share the sample data). Byte-level verification done (BE decode of the ROM range = clean tone; post-swap LE decode identical by construction); **owes a by-ear pass** on Control (elevator + level track), Cavern elevator, and Runway ~5–7 s in.
|
||
|
||
## D218 — Wide FOV exposes the per-level draw-distance / fog boundary — "blue artifacting" down long peripheral sightlines (M-84, user QA on the D211 FOV slider).
|
||
|
||
**Wide FOV exposes the per-level draw-distance / fog boundary — "blue artifacting" down long peripheral sightlines (M-84, user QA on the D211 FOV slider).** BUNKER→**Dam main tunnel** at `FovScale` ~150: geometry / backdrop past the level's fog-end distance shows at the widened frustum edges (blue = Dam's fog tint). Not a render bug — `fogLoadCurrentEnvironment` (`bgfog.c:305`) does `viSetZRange(Visibility.BlendMultiplier, Visibility.FarFog)`, so **`Visibility.FarFog` is both the far clip plane and the fog-saturation distance**; levels tune it for the stock ~60° FOV.
|
||
|
||
**Status (from §F table):** **Logged (M-84), fix proposed + parked.** Plan: `Video.DrawDistance` (percent, 100–400, default 100 ⇒ no-op) `#ifdef PORT` multiplier on `FarFog` at `bgfog.c:305` (+ the direct `arg0->Visibility.FarFog` reads just below, so fog stays consistent) plus `Video.DrawDistanceAutoFov` (0/1, default 1 — couples draw distance to `FovScale` unless `DrawDistance` is set explicitly). Clamp effective multiplier ≲2.5× (small znear ⇒ far-field z-fighting risk). **M-87: user confirms still occurring** at high FOV, and — reversing the earlier "not implemented by user request" — **now wants `Video.DrawDistance` implemented as a real QoL feature**, plus scope it to cover **LOD/detail-distance in general**, not just the fog/far-clip boundary this row root-caused. Open question for that broader ask: does GE have a *separate* geometry/object LOD-swap or draw-culling distance mechanism (distinct from `Visibility.FarFog`) worth exposing too, or is fog-distance the only meaningful "how far can you see" knob in this engine? Check for a per-object visibility-radius / simple-model-swap system before assuming `FarFog` is the whole story. **FIXED as designed, awaiting visual verification (M-121).** Implemented exactly the parked plan: `f32 portDrawDistanceMultiplier(void)` (`port/src/video.c`) — an explicit `Video.DrawDistance != 100` wins outright, otherwise `Video.DrawDistanceAutoFov` (default on) couples the multiplier 1:1 to `Video.FovScale`, clamped to `[1.0, 2.5]` (never shrinks draw distance; the 2.5x ceiling is the finding's own far-field z-fighting caution). Applied in `fogLoadCurrentEnvironment` (`bgfog.c`) to a new local (`scaledFarFog`) used at *both* the `viSetZRange` far-clip call and the `g_CurFogDetails.scaled_far_fog_dist` fog-blend read just below it, so the clip plane and the fog math stay consistent per the original plan's own note — the level's `EnvironmentRecord` itself is never mutated (it's shared/reloaded, not a per-frame copy). Both new config keys added to the F10 overlay (`Video.DrawDistance` slider, `Video.DrawDistanceAutoFov` toggle) alongside the existing `FovScale` row. Identity at both defaults (`DrawDistance=100`, `FovScale=100` ⇒ `portFovScale==1.0f`) by construction. **Broader LOD-swap question from M-87 ANSWERED — yes, and now exposed too (same session, M-121 continued).** `src/game/model.c` has a real, distinct geometry-LOD system: `MODELNODE_OPCODE_LOD` nodes whose children are shown/hidden per-frame by `modelUpdateDistanceRelations()` testing a view-space `distance` (scaled by `getPlayer_c_lodscalez()`, itself now FOV-consistent per this session's D222 fix) against each node's `MinDistance`/`MaxDistance` — a genuine detail-swap mechanism, separate from the fog-visibility-fade this row already fixed. It even has an existing ground-truth multiplier hook, `g_ModelDistanceScale`, already used transiently by the game itself (`chr.c`'s 0.3125f close-up override, `front.c`/`title.c`'s menu-model `modelSetDistanceDisabled`). Rather than write to that shared global (would fight those existing overrides), added a second, composed multiplier at the same call site: `f32 portLodDistanceMultiplier(void)` (`port/src/video.c`), driven by new `Video.LodDistance` (percent, 25-400, default 100) and `Video.LodDistanceAutoFov` (**default OFF**, unlike `DrawDistanceAutoFov` — this is meant as a standalone performance lever the user can pull *down*, not something that silently adds cost as FovScale widens) — `distance *= portLodDistanceMultiplier()`, inverse-scaled so `LodDistance=200` keeps full detail twice as far (more cost) and `LodDistance=50` drops to the lower LOD twice as soon (less cost). Both new keys in the F10 overlay alongside `DrawDistance`. Identity at `LodDistance=100`. Rebuilt clean, crash-free at `LodDistance=40` (aggressive) on Dam and at defaults on bunker1. **M-121 live playtest, iterated to a working state.** Two real issues found and fixed while testing live on Dam with the user: (1) **regression** — the D222 fix (above) correctly widened `c_lodscalez` for the screen-edge cull planes, but that same value is *also* read directly by `propobj.c`'s `chrobjFogVisRangeRelated()`/`sub_GAME_7F054C58()` (the character/prop fog-visibility-fade cutoff) — uncompensated, this made guards fade/disappear *sooner* at wide FOV than before D222, the opposite of the intent. Fixed by dividing that same `c_lodscalez` reading by `portDrawDistanceMultiplier()` at both call sites (`#ifdef PORT`), so the "how far can you see" question these two share is answered consistently. (2) **tuning** — a straight 1:1 `Video.FovScale` auto-coupling on `portDrawDistanceMultiplier()` was not aggressive enough; live-tested up twice (1x → 2x → 4x `FovScale`, clamp ceiling raised 2.5x → 4.0x → 6.0x each time) before guards held up and the Dam tunnel's blue-glow artifact receded to the user's satisfaction. Re-verified end-to-end that the fog *ramp* itself (not just the far-clip cutoff) scales with the multiplier — `g_ScaledFarFogIntensity`/`scaled_far_fog_dist` both derive from the same scaled far value — so if a similar artifact resurfaces on another level, the multiplier magnitude or a level-specific tunnel-length edge case are the first things to check, not this math. **User confirms this is good for now.** All three (D222 cull fix, D218 draw-distance + fog-visibility-fade fix, D249 LOD-distance) verified crash-free and landing in the same PR.
|
||
|
||
## D220 — 1P muzzle flash position shifts upward at higher `Video.FovScale` (M-87, user QA, Jungle/M16).
|
||
|
||
**1P muzzle flash position shifts upward at higher `Video.FovScale` (M-87, user QA, Jungle/M16).** Likely shares D211's noted residual: the viewmodel draws through `c_perspfovy`/unscaled fovy while the world view is FovScale-scaled (`fr.c:737`), so a flash sprite anchored to viewmodel-space vs. screen-space math can drift as the two diverge more at higher scale.
|
||
|
||
**Status (from §F table):** **ANALYZED (M-91) — the suspected unscaled-viewmodel root does NOT hold; this is correct perspective behavior, likely cosmetic/QoL.** The 1P hand + flash are model nodes in ONE gun DL (`gunfire.c` builds `rwmtx[1..4]` for hammer/flash/billboard nodes) drawn through the single per-frame projection from `viSetupCurrentPlayerView` — which IS FovScale-scaled on PC. So the flash and the muzzle it attaches to share projection and matrix chain: their relative position is geometrically preserved. What moves is the whole camera-anchored viewmodel: under perspective, screen offset from center ∝ 1/tan(fovy/2), so widening the FOV migrates every camera-space point (gun body included) toward screen center — a muzzle sitting slightly below/right of center visibly drifts up-left at FovScale 150. Same family as the M-85 accepted "viewmodel scales down, stays bottom-right anchored". `c_perspfovy`/`currentPlayerSetCameraScale` do stay unscaled (the D211 note) but that feeds frustum-cull/screen↔world math (→ D222), not the flash's render path. **Disposition: human call** — if it bothers at high FovScale, the fix is a QoL viewmodel re-anchor/scale-at-FOV (VIEWMODEL-RESEARCH.md family), not a fidelity bug; no game-logic change either way. Optional headless confirm: Jungle M16 fire at FovScale 100 vs 150, measure flash centroid vs muzzle in PPM pairs (expected: both shift toward center together).
|
||
|
||
## D235 — Wrong textures load after dying/restarting a level — not Facility-specific, seen on multiple levels (M-106, user QA report; clarified same session).
|
||
|
||
**Wrong textures load after dying/restarting a level — not Facility-specific, seen on multiple levels (M-106, user QA report; clarified same session).** Original report ("Facility: wrong textures now") read as a build regression; user clarified the actual trigger is **death/restart** (or a similar level-reload path), and it has recurred on other levels too — this is a state-leak on level re-entry, not a one-level texture-decode bug and likely not caused by #47/#49 at all. Prime suspect: the texture pool / CI-cache (D217/D228 neighbourhood) not being fully reset between a level teardown and its reload — a stale arena base, a leftover palette-cache entry keyed by an address that gets reused with different content, or a texpool index not rewound — surfacing as "wrong texture" only after the reload path runs, never on a fresh level boot. Logged in `GRAPHICS-BACKLOG.md`.
|
||
|
||
**Status (from §F table):** OPEN — needs: does it repro on a *fresh* level boot (rules out reload-state) vs only after death/restart (confirms it); which surfaces; a screenshot. **M-108 static-analysis pass: the leading "stale CI-cache not reset on restart" hypothesis is likely REFUTED, don't re-propose it as-is.** The theory was that `g_TexCacheItems`/`g_TexCacheCount` (`src/game/image.c:38-40`, cleared only in `set_mt_tex_alloc()`, `src/game/initmttex.c:8`) survives a death/restart because the restart path skips `set_mt_tex_alloc`. But tracing the actual call graph: `set_mt_tex_alloc()` runs inside `lvlStageLoad()` (`src/game/lv.c:369`), which has exactly **one** call site (`src/boss.c:485`) inside `bossMainloop`'s outer `while (!done)` loop; that loop re-runs on *every* stage transition, driven by `g_StageNum = g_MainStageNum` (`boss.c:693`) which is set by `bossSetLoadedStage()` (`boss.c:722`) — the same function the front-end's level-select/continue path calls (`front.c:6947`). Nothing in this call graph is level-select-specific vs. restart-specific; both look like they go through the identical full reload (including the `set_mt_tex_alloc` cache reset). Have not yet found the actual death/restart trigger's call site to be 100% certain it doesn't have its own bypass, so this isn't fully closed out — but the single-reload-path structure makes the "cache survives restart" mechanism unlikely as stated. Next: find the actual death-handler call site (search for where a life/continue/retry decision is made, likely `chr.c`/a game-state machine, not yet located) and confirm whether it calls `bossSetLoadedStage`/goes through `bossMainloop`'s reload path same as normal transitions, or has a distinct lighter-weight path — if it's the same path, the real D235 mechanism is still unknown and this CI-cache theory should be dropped.
|
||
|
||
**M-193 (v0.4.0 Phase 1, 2026-09-21): port-side GL texture cache found — the M-108 refutation stands for the *game-side* CI cache, but the port has its own cache with the exact same structural hazard, and it was never invalidated on stage transitions.** The PC renderer keeps a `TextureCacheMap` (`port/fast3d/gfx_pc.cpp`) mapping `(source address, palette addrs, fmt, siz, palette_index, size_bytes, palette_hash)` → uploaded GL texture, with **no content check** — a hit trusts the key. Stage textures' source addresses live in the `MEMPOOL_STAGE` arena, which the per-stage `while (!done)` block in `boss.c` resets via `mempResetBank`/`memaReset` on *every* transition (death/restart included — M-108's call-graph trace already established both paths re-run that block). After the reset, any cache entry keyed into that arena is stale; if the texpool places a different image at an address that held another image in the previous run of the level, the lookup hits and returns the old pixels = "wrong textures after dying/restarting", never on a cold boot (empty cache). The N64 has no such cache (the RSP re-reads DRAM every frame), so this is a pure port-side defect — fixable in `port/`-adjacent code with no rule-2 sign-off. **Fix applied:** the orphaned `videoResetTextureCache()` (`port/src/video.c`, zero callers) is now wired into that per-stage block right after `memaReset`/`reset_play_data_ptrs` (`#ifdef PORT`) — a full clear on every stage load. Re-uploads are pixel-identical (same content → same GL upload), cost is one-time during the loading screen; ROM-segment textures (menu/HUD) just re-upload too, harmlessly. `GE_D235=1` logs the purged entry count per transition (cold boot = 0, death/restart > 0) as direct mechanism evidence. **Remaining:** live death/restart confirmation at playtest (wrong textures gone?) + a `GE_D235` capture showing >0 on a restart; if symptoms persist after this, the next suspect is intra-level texpool eviction reusing addresses mid-play (nothing emits `G_INVALTEXCACHE_EXT`, so per-address staleness is possible there too — the handler exists at `gfx_pc.cpp:3624` but has no emitter).
|
||
|
||
**v0.4.0 verification pass (2026-09-23, native Windows, headless): fix wired and clean on reload; the bug itself did NOT reproduce, even with the fix disabled. Needs the user's original trigger to close.** Harness: `-level_09`, scripted watch Abort → title stage → new TEMP `GE_D235_RELOAD=<frames>` re-enters Bunker 1 via `bossSetLoadedStage()` (the front end's mission-start call) after a front-end dwell; new TEMP `GE_D235_OFF=1` skips the reset for an A/B. Results:
|
||
- Fix ON: cold boot purges 0, the abort into the title purges 527–833, and re-entry purges 63 (front-end entries). The reloaded level renders correctly; settled gameplay matches the fix-OFF run 7/9 frames, and the 2 fails are intro-fade/camera timing (D117), not textures.
|
||
- Fix OFF, identical load order (intro both times, 300-frame legal-screen dwell): 588–788 stale entries survive into the reload; **all textures correct**.
|
||
- Fix OFF, divergent load order (first load skips the intro, so only the shaft is seen; the reload plays the full control-room flyby): 589 stale entries survive; **all textures correct** at full resolution (`scratch/d235_1750.png`).
|
||
|
||
So on this path, stale keys either miss or hit the same image. Either GE's placement of Bunker 1's bg textures is order-independent here, or the key (`palette_hash`, `size_bytes`, fmt) disambiguates. The fix stays correct by construction: an address-keyed cache with no content check outlives an arena reset, and clearing it at every stage load costs only re-uploads during the loading screen. What's untested is the reported trigger: a real **death → mission failed → mission select → restart** (the front end's image-heavy briefing/folder screens and 3D models go through the stage arena in between) on the level where it was seen (Facility first). **Human test:** reproduce with `GE_D235_OFF=1` (expect wrong textures) and without (expect clean); note the level and surfaces. Confidence that the fix addresses the report: medium, since the mechanism is real but the trigger is not captured.
|
||
|
||
**2026-09-25 playtest (user, Windows, one ~46-min session Silo → Caverns, `GE_D322=1 GE_D204=1`, build `7baf4900`):** die → mission select → restart did not reproduce wrong textures (fix on). Still unreproduced in every configuration; stays OPEN only because the original trigger was never observed locally.
|
||
|
||
## D236 — Surface 1: 2D billboard trees render as a wall/sheet of tree texture instead of discrete camera-facing tree sprites (M-106, user QA report).
|
||
|
||
**Surface 1: 2D billboard trees render as a wall/sheet of tree texture instead of discrete camera-facing tree sprites (M-106, user QA report).** Eight passes so far (D265, D266–D270, D271, D272, D273, D274, D275–D276). Ruled out: IA4/IA8 decode (as originally scoped), the blend pipeline, fog combine (as originally scoped), portal-culling over-draw (D271, real bug, fixed, not the cause), converter/UV-conversion bugs (D272, code-read clean), the CI palette bit-layout (D273/D274, tested twice against real content), "mips absent" (D274, confirmed present/enabled), "identical UV window" *for the CI8 classes* (D272). **Current leading picture (D276, M-134): the actual visible weave at confirmed weave pixels is painted by a different, previously-dismissed class — an IA8 blocky-noise texture (`oml=0xc81049d8`/`fmt=3`) sampled with a fixed, byte-identical 2×-wrapped UV window and low fog, reproducing D265's original "same window" observation exactly (D269's CI8-classes framing had reframed away from that lead prematurely).** A third, unattributed CI8 class (`0x0c184b50`) may be the reference's actual discrete-tree layer and shows an erratic z/w suggesting its own occlusion/billboard bug — D236 may split into two root causes. **Ninth pass (D280, M-135): fog math verified N64-faithful (±2 truncation over 2505 verts), the IA8 noise class visually confirmed as the weave's painter (HF 14.8→2.9 when collapsed), `0xc8102078` ruled out (terrain, 1.4M tris), and the depth-race read inverted — `0x0c184b50` tree cards are IN FRONT of the noise quad (z/w smaller=closer) yet still don't visibly win; under deterministic `-level_36` boot they barely instantiate at all (~4 tris vs 720 in a free run). No quick win: remaining work is CPU-side instantiation/room-streaming, not a port fix.** **Tenth pass (M-155): that CPU-side-instantiation framing itself is REFUTED — three independent deterministic re-runs of the same `-level_36` boot (one with sustained camera rotation) proved the class's per-frame count is byte-identical, not run-to-run dependent; the earlier "~4 vs 720" comparison was an outdoor-vs-indoor room mismatch (the boot's scripted intro walks the camera out of the tree-bearing room at a fixed frame), not nondeterminism. No fix; the original wall-vs-discrete-sprites symptom is unresolved and untouched by this pass.** Distinct from D176(b)'s painted tree-line backdrop. Logged in `GRAPHICS-BACKLOG.md`.
|
||
|
||
**Status (from §F table):** OPEN — **pass 24 (2026-09-21, static) DECODED THE TREE TEXTURE** (new `tools_pc/texdecode.py` ports image.c's LOOKUP/HUFFLOOKUP/RLELOOKUP codecs). **`tex1198` LOD 0 = 64×17 RGBA5551, 44.2% opaque, and its 1-bit alpha renders as an unmistakable treeline silhouette** — pass 23's identification confirmed by decode, not inference. **Rare authored a six-level LOD pyramid that PRESERVES the silhouette and its alpha coverage (44.2% → 43.1% → 38.8%), each level a separately-authored image with its own compmethod.** Since `XLU_SURF` has no alpha test (pass 22), averaging that 0/1 alpha into fractional mip alpha would smear the minified treeline into exactly the reported band — so 'authored LOD chain replaced by driver-generated mips' is now the leading mechanism, with hard ground truth behind it. Corrects pass 23: only `tex1198` is a treeline (`tex1199` 89.4% opaque denser foliage; `tex1200`/`tex1201` 100% opaque detail textures sharing the class). Two decoder bugs found while porting (missing per-image byte-align, `image.c:1096`; a GUESSED rather than ported RLE fudge) — both produced confident wrong pictures. Next: one `-level_36` run filtered on **`fmt=0 siz=2`**, diffing the live import against this offline reference on (1) 64×17 RGBA5551 dims/format, (2) ~44% alpha transparency, (3) whether all six authored LODs reach GL or only LOD 0.
|
||
|
||
**Next pass framing (post rule-2-clarification):**
|
||
|
||
```
|
||
TASK: D236 pass 10 — determine why the 0x0c184b50 discrete tree-card class
|
||
instantiates inconsistently (~4 tris on deterministic -level_36 boot vs
|
||
~720 in a free run) despite being geometrically in front of the confirmed
|
||
N64-faithful noise-quad "wall" (D280).
|
||
READ FIRST: docs/porting-notes.md §A1 (pattern + tells, now including D255);
|
||
docs/dev-process.md's "Diagnosis is unrestricted" section and §7 sign-off
|
||
procedure; docs/dev/findings.md D236 full entry (D265, D271, D272,
|
||
D273/D274, D276, D280) plus D209/D210/D255 as worked A1 examples.
|
||
FILES YOU MAY TOUCH: none pre-committed — this pass is diagnosis-first.
|
||
Touching src/game requires first clearing buckets (a) and (b) below, with
|
||
evidence, per CONSTRAINTS.
|
||
KNOWN-GOOD / RULED OUT (do not re-investigate): IA4/IA8 decode, blend
|
||
pipeline, fog combine (verified N64-faithful, ±2 truncation over 2505
|
||
verts), portal-culling over-draw (separately fixed, not the cause),
|
||
converter/UV-conversion, CI palette bit-layout, mip presence, "identical UV
|
||
window" for the CI8 classes.
|
||
PRE-FLIGHT ATTACHED: D280's z/w depth-race data (0x0c184b50 confirmed in
|
||
front of the noise quad); the instantiation tri-count delta (4 vs 720)
|
||
between deterministic and free boot.
|
||
BUDGET: <=6 build->run cycles / ~90 min. On expiry: revert probes, write up
|
||
with confidence, no forced fix.
|
||
CONSTRAINTS: Trace the instantiation-count/gating decision into src/game
|
||
(prop-spawn, room-streaming, or distance-cull logic — likely bg.c) as far
|
||
as the evidence leads; name the exact struct/field/counter responsible
|
||
before concluding anything. Specifically, before writing this up as bucket
|
||
(c) (a genuine timing/streaming behavior difference worth a rule-2 sign-off
|
||
request):
|
||
- Check bucket (a) first: is the count/visibility-gating field read at a
|
||
struct offset that shifted under 32->64-bit pointer widening? (§A1
|
||
tells: deterministic-but-wrong output; a raw/literal offset or narrow
|
||
field read into a struct/union with any pointer member anywhere in its
|
||
layout.)
|
||
- Check bucket (b) next: is a port-layer timing/order-of-operations
|
||
difference (frame pacing, load order, an uninitialized or
|
||
differently-timed port-owned counter) feeding src/game a different
|
||
input than N64 would have produced upstream of the gating logic?
|
||
- Only once both are actively ruled out with cited evidence (not
|
||
assumption) does this qualify for a rule-2 sign-off write-up under
|
||
docs/dev-process.md §7. Do not request sign-off preemptively or as a
|
||
fallback for "didn't find an ABI/port explanation in the time budget."
|
||
VERIFY: the deterministic -level_36 boot repro + tri-count probe already
|
||
used in D280; attach before/after instantiation counts for any change.
|
||
REPORT: (a) the exact struct/field/counter gating instantiation, file:line,
|
||
and which bucket (a/b/c) it falls in, with the evidence for that
|
||
classification (b) fix diff if (a) or (b), or a full rule-2
|
||
sign-off request per §7 if (c), never a partial/ambiguous one
|
||
(c) probes left in tree (d) confidence.
|
||
Append any generalisable quirk to docs/porting-notes.md — extend
|
||
§A1 if this turns out to be another instance of the pattern.
|
||
```
|
||
|
||
**Pass 10 result (M-155, 2026-09-16): D280's "instantiation is run-to-run
|
||
dependent" premise is REFUTED — it's ordinary room-based occlusion, not a
|
||
CPU-side gating bug. No fix; this closes the specific mystery the pass-10
|
||
brief posed, D236 itself stays OPEN on the original "wall not discrete
|
||
sprites" symptom.**
|
||
|
||
Added a small env-gated diagnostic (`GE_D236RM`, `port/fast3d/gfx_pc.cpp`
|
||
`gfx_sp_tri1`, see `GE-ENV-PROBES.md`) that counts triangles per frame
|
||
(`num_dls`) whose `other_mode_l == 0x0c184b50` (the discrete-tree-card
|
||
render-mode signature from D280), heartbeat-logging every 300 frames even
|
||
at zero so a class going silent is visible, not just absent.
|
||
|
||
Ran the deterministic `-level_36` boot **three times independently**
|
||
(two static, one with `GE_INPUTSCRIPT="1:SRIGHT"` sustained rotation) —
|
||
**all three produced byte-identical per-frame counts**: 4 tris/frame for
|
||
frames 1-536, 2 tris/frame for frames 537-747, then **zero for the rest
|
||
of a 55-60s run** (verified to frame 3299, well past D280's tick-1500
|
||
sample point). This alone falsifies "not pinned by the boot flag" as
|
||
stated — the boot IS fully deterministic frame-by-frame; D280's
|
||
comparison must have been against session state, not process determinism.
|
||
|
||
Cross-referencing with the already-in-tree `GE_D104` room-census probe
|
||
(`bg.c:654`, unrelated finding, still live) in the same run pinpoints the
|
||
mechanism exactly: **the class's count drops to zero at the same tick
|
||
(~9601 controller-reads ≈ frame ~740-748) that `curRoom` transitions from
|
||
12 to 18, and `roomsDrawn` collapses from 17-20 rooms to 1-5.** Room 12's
|
||
neighbour set (17-20 rooms, several with small portal-window bboxes —
|
||
consistent with a big outdoor vista) is where the tree-card class draws;
|
||
room 18 (near-fullscreen bbox, 1-5 rooms visible — consistent with an
|
||
enclosed indoor space) is where it permanently stops, and stays at zero
|
||
through the rest of the capture regardless of continuous `SRIGHT`
|
||
rotation. This is the deterministic `-level_36` boot's own scripted
|
||
intro camera walking from an outdoor establishing shot into the player's
|
||
actual indoor start point — the class legitimately has nothing to draw
|
||
once indoors, the same way any outdoor-only geometry would correctly stop
|
||
rendering behind walls on real N64. D280's "~4 tris on deterministic
|
||
boot vs ~720 in a free run" was comparing this boot's post-intro *indoor*
|
||
sample against a live session's *outdoor* one — an apples-to-oranges
|
||
comparison, not evidence of nondeterministic instantiation.
|
||
|
||
**Bucket classification: none of (a)/(b)/(c) — the room-transition
|
||
explanation accounts for the entire observed variance without invoking
|
||
an ABI misread, a port-layer timing bug, or a genuine N64 divergence.**
|
||
No `src/game` or `port/` edit made or warranted by this pass.
|
||
|
||
**What this does NOT resolve:** whether, from *inside* room 12 looking
|
||
directly at the tree line, the discrete tree cards draw enough
|
||
individual, correctly-placed triangles to look like trees rather than
|
||
merging into D276's IA8 noise "wall" — this pass never got the camera
|
||
into that outdoor room's tree-facing view (the scripted intro's own path
|
||
through room 12 stays at low counts, 4→2, never approaching D280's 720
|
||
figure). **Next pass, concrete:** find (or script) a camera path that
|
||
stays in room 12 and faces the tree line directly — e.g. `GE_D104` to
|
||
locate room 12's screen-space bbox windows facing the trees, then a
|
||
`GE_INPUTSCRIPT` sequence that moves (not just rotates) the player
|
||
toward one — and re-run `GE_D236RM` there; a healthy count scaling up
|
||
toward the ~720 range as the camera closes on the tree line would confirm
|
||
the CPU-side instantiation is fine and the remaining D236 mystery is
|
||
purely a rendering/blend-order question (D276's territory); a count that
|
||
stays anomalously low even facing the trees head-on from inside room 12
|
||
would be the first real evidence of a genuine instantiation bug and
|
||
should restart the bucket-(a)/(b)/(c) check from there.
|
||
|
||
Probe left in tree (`GE_D236RM`, env-gated, zero cost unset, cataloged in
|
||
`GE-ENV-PROBES.md`). Confidence: HIGH on the room-transition mechanism
|
||
(three independent deterministic reproductions, direct room-index
|
||
correlation); LOW-to-none on the original "wall vs discrete sprites"
|
||
symptom, which this pass didn't reach.
|
||
|
||
**Pass 11 (same day): partial nav progress via `GE_INPUTSCRIPT`, still
|
||
short of D280's ~720 baseline — no bucket resolved, wrote up as
|
||
directed.** Attempted to keep the camera in the outdoor room and facing
|
||
the tree line using `GE_INPUTSCRIPT` stick holds from frame 0:
|
||
|
||
- `GE_INPUTSCRIPT="0:SDOWN"` (sustained backward): `curRoom=7` at the
|
||
first sample (vs pass 10's baseline `curRoom=12`), meaning held stick
|
||
input during the pre-transition window *does* change which room the
|
||
player/camera occupies — this is not a fully input-inert cutscene.
|
||
Tree-card count stayed flat (4 for 535 samples, then 2, then 0) —
|
||
never exceeded pass 10's baseline.
|
||
- `GE_INPUTSCRIPT="0:SUP"` (sustained forward): `curRoom=13` at the
|
||
first sample — a third distinct starting room, confirming input
|
||
changes the pre-transition room but not in an obviously useful
|
||
direction. Count stayed flat at 2-3, again never scaling up.
|
||
- `GE_INPUTSCRIPT="0:START;60:START;900:SUP"` (skip the intro faster
|
||
with two `START` presses, then try to walk back out): `curRoom=12`
|
||
again (pass 10's exact baseline room) and the room-18 transition
|
||
happened noticeably *earlier* (D104 tick ~1801 vs ~6601 in the other
|
||
two runs) — `START` does shorten the pre-transition window rather than
|
||
extending it, the opposite of what's wanted. **But this run's very
|
||
first `GE_D236RM` samples hit `count=10` then `count=6`** (dl=1, dl=2)
|
||
before immediately collapsing to 2 by dl=95 and 0 by dl=599 — the
|
||
highest counts seen in any of the four runs to date (pass 10's three +
|
||
this one), all occurring in the first couple of rendered frames.
|
||
|
||
**Interpretation:** all three attempts confirm player stick input *does*
|
||
influence the pre-transition room (curRoom varies 7/12/13 depending on
|
||
direction), refuting a fully-scripted-camera-only model — but none found
|
||
a way to *hold* the camera in the outdoor room past the transition, and
|
||
the transition itself appears to be timer/event-driven (not
|
||
distance-to-a-trigger), since `START`-skipping made it fire sooner, not
|
||
later. The one genuinely new data point — a brief `count=10` at the very
|
||
first sampled frame of the `START`-skip run — shows the discrete
|
||
tree-card class CAN render more than pass 10's flat 4, but still nowhere
|
||
near D280's ~720 figure, and only for 1-2 frames before dropping back
|
||
down. **This is the strongest evidence yet that D280's ~720-triangle
|
||
sample was taken from a materially different vantage (later in the level,
|
||
in a genuine free-roam session, likely much closer to the tree line or
|
||
facing a wider arc of it) than anything reachable from this deterministic
|
||
`-level_36` boot's opening seconds** — not evidence of a CPU-side
|
||
instantiation bug at this vantage. No src/game or port/ file touched;
|
||
`GE_D236RM` unmodified from pass 10. Confidence: MEDIUM that the
|
||
transition is timer-driven rather than position-driven (3 consistent
|
||
data points); LOW-to-none on why D280's original ~720 figure was so much
|
||
higher — that needs either a `GE_INPUTSCRIPT` script that survives well
|
||
past the mission-start hand-off into free movement (not attempted this
|
||
pass — budget spent), or a live human session with `GE_D236RM` set,
|
||
walking toward the tree line and comparing against this deterministic
|
||
boot's numbers directly.
|
||
|
||
**Next pass, concrete:** don't fight the scripted intro further — instead
|
||
run `-level_36` with `GE_D236RM=1` interactively (human input, no
|
||
`GE_INPUTSCRIPT`) *or* extend the script well past the mission hand-off
|
||
(needs `GE_INPUTLOG` calibration of the frame-vs-real-time ratio first,
|
||
not done this pass) and walk toward the tree line from inside the level
|
||
proper, not during the opening establishing shot. Correlate against
|
||
D280's own capture conditions (what level state/position produced 720)
|
||
if that's recorded anywhere in the D280 write-up above.
|
||
|
||
**Pass 12 (2026-09-18, this session): a genuinely new mechanism found and confirmed real, but the exact visually-bugged view still has too little tree-class geometry to test it against — not a fix, narrows the next pass.**
|
||
|
||
**New mechanism, not considered by passes 1-11 (all of which reasoned from z/w comparisons):** decoded both classes' full `other_mode_l` words offline — `0xc81049d8` (noise) and `0x0c184b50` (tree cards) are **both `Z_CMP=1`/`Z_UPD=0`/`ZMODE=DEC`** (decal mode, depth-tested but not depth-written). Two non-depth-writing decals never actually depth-test against each other — each tests only against the static background's z-buffer — so on-screen precedence between them is decided by **draw order alone** (whichever is submitted later in the frame wins the color buffer), not by which is geometrically closer. This means the z/w comparisons all 11 prior passes made were measuring something that doesn't determine the visual winner between these two specific classes.
|
||
|
||
Built `GE_D236ORDER` (`port/fast3d/gfx_pc.cpp`, `gfx_sp_tri1`, env-gated, zero cost unset) to measure this directly: a per-frame submission-order counter, logging the min/max order index for each class whenever `num_dls` (frame) changes. Also built `GE_D236ZFIX` (same file), a diagnostic-only experiment forcing `depth_update=true` for the tree-card class alone, to test whether giving it real z-write lets it correctly block a later-drawn noise quad — **not proposed as a fix** (decal-mode classes aren't supposed to write depth; this would need its own rule-2 justification if it ever became a real fix candidate).
|
||
|
||
**Headless repro finally landed on the actual bug, no live session needed this pass.** `GE_PCDUMP=1300-1700:100` on `-level_36` (a snowy corridor level, not literally "Surface 1/2" by debug number but visually and mechanically the same wall-of-static symptom) captured the exact reported look: a solid black-and-white static-noise "wall" on both sides of a snow corridor, `docs/dev/GRAPHICS-BACKLOG.md`-consistent with the user's screenshots. **`GE_D236ORDER` at this exact captured frame range shows `tree_n=0` throughout** — the discrete tree-card class has **zero triangles present at all** in the view that shows the bug, not merely "in the wrong order." A few hundred frames earlier in the same deterministic run (dl≈609-634), 2 tree triangles ARE present and (in that window) drawn **after** the noise class — the theoretically-correct order for them to win — but 2 triangles is nowhere near enough geometry to visually register against a whole wall of noise texture, and a screenshot at that exact frame (`scratch/ppm_620/frame_000620.png`) confirms it looks identical to the bugged view (no visible trees).
|
||
|
||
**Conclusion: the draw-order/decal mechanism is real and worth keeping as a documented candidate, but it isn't provably the active cause of the specific reported visual** — the deterministic boot simply never has enough tree-class geometry present at the bugged camera position to test the theory against, the same wall every pass since D280 has hit. This is unchanged from pass 9-11's own conclusion despite the new mechanism: **the real blocker is still CPU-side tree-prop instantiation/room-streaming at this exact camera position**, not (as far as this pass can tell) a port-side rendering bug. Confidence: HIGH that the decal-mode draw-order mechanism is real and correctly understood; LOW-to-none that it's the dominant cause of the reported symptom, since the necessary competing geometry isn't present to observe it losing or winning.
|
||
|
||
**Next pass, concrete (supersedes the "correlate against D280" step above, still valid but now sharper):** a live human session, `GE_D236ORDER=1` set, walking directly toward the tree line from inside the level (not the deterministic boot's opening corridor) is now the single most informative next capture — it would show, in one run: (a) does the tree-card count reach anything near D280's ~720 figure at this camera position, and if so (b) is `GE_D236ORDER`'s per-frame order relationship consistently noise-after-tree (bad) or tree-after-noise (fine) once real geometry is present. That single data point would finally either confirm or rule out the decal draw-order mechanism as the actual fix target, something no headless capture has been able to test in three sessions running. **Not v0.3.0-blocking, per standing user priority** — recommend parking again rather than rushing an unverified fix under release-timeline pressure. `GE_D236ORDER`/`GE_D236ZFIX` left in tree, env-gated, zero behavior change when unset.
|
||
|
||
**Pass 13 (2026-09-18, same session): the requested live capture landed, and it demotes the draw-order mechanism rather than confirming it — the more promising remaining lead is the tree class's own fog, not draw order.**
|
||
|
||
User played live with `GE_D236ORDER=1` set, walked toward Surface1's tree line, and confirmed the wall bug still reproduces (`scratch/d236_live_capture.log`, 2024 `D236ORDER` lines). Real data at last: tree-card count reached up to **121 triangles** (still short of D280's old ~720, but far beyond the deterministic boot's 2-4 — confirms the boot genuinely undersamples this class, not that it's absent from the level). More importantly: **the draw-order relationship is distance-dependent** — at low tree counts (6-12 tris, farther away) the noise class is consistently drawn *after* the tree class (bad order, matches pass 12's theory); at high tree counts (100+, closer) this **flips** — the tree class is consistently drawn *after* the noise class (good order, trees should win the color buffer).
|
||
|
||
**The decisive fact: the user still saw the wall bug while walking through the zone where tree count was high AND draw order favored the trees.** If pass 12's decal-ordering mechanism were the dominant cause, the bug should have resolved (or visibly improved) exactly in that zone — it didn't. **Conclusion: draw order is real (confirmed twice now, in two different measurement contexts) but is NOT the dominant cause of the reported symptom** — something else is preventing the tree cards from reading as discrete trees even when they have substantial triangle count and favorable draw order.
|
||
|
||
**Redirects the investigation to a lead D280 flagged but never closed: the tree class's own fog computation, not the noise class's.** D280's fog-math verification (`## D280` above, step 1) proved the *noise* class (`0xc81049d8`)'s fog is N64-faithful — it never checked the *tree* class (`0x0c184b50`)'s own fog, which D280's own census separately found to be a strange bimodal 25/255 split per vertex (`gfog=0`, CPU-baked, not RSP-computed). A tree card drawn correctly on top of the noise (as the live data now shows happens at close range) but rendered with fog=255 (fully white-washed) would look exactly like a blank/hazy patch of "wall," not a tree — consistent with the reported symptom persisting despite correct geometry and order.
|
||
|
||
**Not attempted this pass — genuinely the next step, not a rehash:** trace `0x0c184b50`'s own per-vertex fog computation (where it's set, CPU-side vs RSP, and against what reference distance) and compare it to what N64 would compute for the same vertex positions. This is a different code path than D280's already-verified noise-class fog check and hasn't been looked at directly. Given the depth of this investigation (13 passes) and the release timeline, **recommend keeping this parked for v0.3.0** — the mechanism is narrowed further than ever, but not yet a safe, confident fix. `GE_D236ORDER`/`GE_D236ZFIX` remain in tree, env-gated, zero cost when unset; the live capture log is preserved at `scratch/d236_live_capture.log` for whoever picks this up next.
|
||
|
||
**Pass 14 (2026-09-18, later session, no timeline pressure — user asked to keep going): sharpened pass 13's fog lead into a falsifiable mechanism and built the probe to test it; not yet live-captured.**
|
||
|
||
**Two things checked by source review before touching code, both clean:** (1) `port/fast3d/gfx_pc.cpp`'s `gfx_sp_vertex` fog fallback (`if (rsp.geometry_mode & G_FOG) {...} else { d->fog = rdp.fog_color.a; } d->color.a = vcn->a;`) is **byte-identical to the local Perfect Dark PC port reference** (`pd_port/port/fast3d/gfx_pc.cpp:1177-1194`) — this exact branch is not a GE-specific bug, it's shared, more-battle-tested fast3d ground truth. (2) `rsp/graphics/gmain.s` (GE's own RSP ucode, the AGENTS.md ground truth for GBI semantics) has **zero fog-related code** — GE's ucode doesn't customize fog generation at all, so there's nothing GE-specific to cross-check there either. Both rule out "our RSP fog emulation is wrong" as the mechanism.
|
||
|
||
**Decoded the tree class's actual render mode/blend equation** (`0x0c184b50`, matched against `include/PR/gbi.h`'s `RM_AA_ZB_XLU_DECAL(clk)` macro: `AA_EN|Z_CMP|IM_RD|CVG_DST_WRAP|CLR_ON_CVG|FORCE_BL|ZMODE_DEC`, blend `GBL_c(G_BL_CLR_IN, G_BL_A_IN, G_BL_CLR_MEM, G_BL_1MA)` — exactly matches D280/D272's Z_CMP=1/Z_UPD=0/ZMODE=DEC finding). This is an ordinary **alpha-blend decal**: `out = src_color*src_alpha + dst_color*(1-src_alpha)`. Critically, the blend equation does **not** reference `G_BL_CLR_FOG`/`G_BL_A_FOG` at all — whatever ends up as this triangle's alpha (SHADE alpha, i.e. `vcn->a`, the same raw per-vertex value D280's census found bimodal at 25/255) directly controls how *transparent* the tree card is over whatever was drawn underneath it (the noise class, confirmed drawn first in the close/high-count zone by pass 13's own data).
|
||
|
||
**New hypothesis, not proposed by passes 1-13:** if a meaningful fraction of a close tree card's vertices carry the low end of that bimodal split (`~25`, i.e. ~10% opacity) rather than the high end (`255`, opaque), the card would blend almost invisibly over the noise texture even when it has correct geometry, correct triangle count, and correct (tree-after-noise) draw order — reproducing exactly the "still looks like a wall" result pass 13 reported in its own favorable zone. This reframes the open question from "is the *fog value* right" (D280's framing, ambiguous — this render mode doesn't consume `d->fog`/RSP fog at all) to the narrower, directly testable one: **is vertex alpha low specifically for the close/high-count trees the live capture walked through, or does it correlate sanely with distance (opaque near, faded far, as a billboard fade LOD would legitimately do)?** Neither this pass nor any prior one has the data to answer that yet — D280's "bimodal 25/255" finding was a class-wide census, not correlated against per-instance camera distance at the specific bugged vantage.
|
||
|
||
**Built, not yet run live:** `GE_D236ALPHA` (`port/fast3d/gfx_pc.cpp`, `gfx_sp_tri1`, env-gated, zero cost unset, same pattern as `GE_D236ORDER`/`GE_D236RM`). For every triangle of the `0x0c184b50` class (capped at 4000 hits to bound log size): logs the combine_mode word once whenever it changes (to confirm alpha really is sourced from SHADE and not, say, ENVIRONMENT or a constant, before trusting the hypothesis further), then each triangle's three vertex alphas (`v1/v2/v3->color.a`) alongside `1/w` for each vertex (a cheap camera-distance proxy, smaller w = closer) so alpha can be correlated against distance the same way pass 13's live capture correlated tree count/draw-order against distance. Build verified clean (`./build-pc.sh ntsc-final`, 243/243, only pre-existing unrelated warnings); `tools_pc/verify.sh dam` PASS, crash-free with the probe compiled in and unset.
|
||
|
||
**Next step, concrete: a live capture identical in method to pass 13's** — `GE_D236ALPHA=1`, walk toward Surface1's tree line from far to close, same as before. Read the resulting log for: (a) does `v->color.a` trend toward 255 as `1/w` grows (closer)? If yes, the port is faithfully reproducing an N64-authored near-opaque/far-faded billboard fade and this hypothesis is refuted, redirecting back to an as-yet-unfound mechanism. If alpha stays low (~25) even at high `1/w` (close range, matching the zone where pass 13's draw order was already favorable), that's a concrete, falsifiable bug signature — the next question after that would be *where* that low value is coming from (baked ROM vertex data being read wrong vs. a legitimate baked-low value that N64's own combiner/blend path compensates for in some other way this port doesn't reproduce). **Not v0.3.0-blocking per standing user priority, but the user asked to keep working it regardless of release timing this session.** `GE_D236ORDER`/`GE_D236ZFIX`/`GE_D236ALPHA` all remain in tree, env-gated, zero cost when unset.
|
||
|
||
**Pass 15 (2026-09-18, same later session — the requested live capture landed, decisively quantifying the mechanism, but still stops short of a fix): confirmed the tree class's vertex alpha genuinely never reaches anywhere near opaque, and confirmed exactly how it drives the final transparency — but couldn't establish whether the low values are faithful ROM data or a decode bug.**
|
||
|
||
**Launched the game directly** (`GE_D236ALPHA=1 ./build-pc/ge007.x86_64.exe -level_36`, MSYS2 PATH for the SDL2 runtime dep) so the user could walk the exact same far→close tree-line approach as pass 13 without a separate build step; 1500 `D236ALPHA` lines captured (500 triangles × 3 vertices = 4500 alpha samples), well under the probe's 4000-triangle cap.
|
||
|
||
**Result 1 — alpha never gets close to opaque, anywhere in the walk.** Across the *entire* session, `v->color.a` ranged **0–36** (out of 255, i.e. ≤14% opacity) — not the 25/255 bimodal split D280's older class-wide census described. Bucketing all 4500 samples into deciles by `1/w` (camera-distance proxy, larger = closer) shows a real, monotonic trend — mean alpha rises from 6.2 in the farthest decile to 27.1 in the closest — but the ceiling never lifts: even the single closest samples captured topped out at 36. Two readings are consistent with this data and neither is ruled out yet: (a) the player never got quite close enough in this session to reach whatever distance a card would need to hit 255 (the closest `1/w` bucket, 0.0011–0.0018, has no calibration reference in this codebase to say whether that's "close" in engine units), or (b) the alpha value is capped/scaled wrong regardless of true distance.
|
||
|
||
**Result 2 — decoded the actual combine equation, resolving pass 14's open "which alpha slot" question.** The single `combine_mode` word logged this session (`0x00f39e4f1f39e4f1`, unchanged for the whole class) decodes via this file's own shader-synthesis extraction (`gfx_pc.cpp` ~L359-366) to alpha cycle `(a=1,b=7,c=4,d=7)` = `(TEXEL0 − 0) × SHADE + 0` in both cycles — i.e. **final alpha = texture alpha × vertex(SHADE) alpha**, the standard `G_CC_MODULATEIA` idiom. This confirms pass 14's hypothesis outright: whatever the CI8 tree texture's own per-texel alpha is (presumably near-255 for opaque tree pixels, going by the format), the vertex alpha multiplies it back down — so a vertex alpha capped at 36/255 caps *every* tree pixel drawn through that vertex at ≤14% opacity, full stop, regardless of the texture's own content. This is now a settled fact about the render pipeline, not a hypothesis.
|
||
|
||
**Traced the vertex-color path as far as source review allows this pass, found no swap/decode bug in it, but couldn't identify what actually computes/bakes this specific class's alpha.** `bgLoadRoomVtxData`/`bgSwapRoomVtx` (`bg.c:2353-2369`, room-level static vertex table load) explicitly documents and correctly implements "swap the 6 leading u16 fields (position/flag/uv), **leave the 4 colour bytes alone**" — if this room's tree-card geometry comes through this path, the raw ROM-authored alpha byte passes through completely unmodified, byte-for-byte, and this port has no bug in that specific step. Went looking for a CPU-side per-frame alpha *computation* next (the mechanism that would explain the real, non-flat 1/w correlation the data shows) and found one real candidate, `fogGetPropDistColor`/`chrobjFogVisRangeRelated` (`bgfog.c:695`, `propobj.c:7420+`) — a genuine decomp-verbatim distance-fog alpha blend used for `PropRecord`/`ObjectRecord` instances (doors, tinted glass, damage-decal props) — but confirmed by grep that `bg.c`/`bgapply.c` (the actual room-background-geometry load path this tree class's render-mode signature points to, per D280's census) **never reference** `fogGetPropDistColor` or its `far_fog_dist_scaled`/`near_fog_dist_scaled` fields at all, so that specific function is not this class's mechanism — a dead end, not a fit, ruled out this pass. **Did not find the actual code that determines whether this tree class is static ROM-baked vertex data (in which case N64 would show identical values, and any bug is downstream of the raw bytes, not in reading them) or is computed by some other per-frame CPU path not yet located** (the smooth 1/w correlation in the live data argues against "flat static bytes coincidentally increasing with distance across unrelated tree instances," but isn't proof).
|
||
|
||
**Numeric aside, unconfirmed, flagged for whoever picks this up:** 36 ≈ 255/7 (36.43). If a genuinely different underlying quantity here is an unsigned 3-bit "fog/light level" (0-7) later expanded to a byte via `level * (255/7)` — a common N64-era bit-depth-expansion idiom — the observed 0-36 range would mean this session's walk only ever reached **level 0 or 1 of a possible 0-7**, i.e. the mechanism computing the level may be stuck near its floor rather than the byte itself being wrong. Not chased down this pass (no `* 7`/`/ 7`/level-table hits found in a quick `bg*.c`/`bgfog.c` grep) — worth a targeted look at whatever function ultimately writes this specific room's tree-card `Vtx.cn[3]` if the next pass wants to keep pulling this thread, but flagged as a numerical coincidence, not a confirmed mechanism.
|
||
|
||
**Not v0.3.0-blocking, user is continuing anyway.** Next step, concrete: find the actual write site for this room-geometry class's baked/computed alpha (search the ROM room-decompression path more specifically for this level's data, or instrument `bgLoadRoomVtxData` itself to dump the raw pre-swap bytes for this room and compare against what the running probe reports post-load — if they match exactly, the value is confirmed ROM-faithful and the investigation should pivot to "is this actually correct N64 behavior, i.e. is the real bug elsewhere (wrong texture selected, wrong room's data loaded, etc.)" rather than continuing to chase the alpha value itself). `GE_D236ALPHA` log preserved at `scratch/d236_pass14_live_capture.log` (filename predates this being pass 15's data) for reference.
|
||
|
||
**Pass 16 (2026-09-18, same later session, user said "go for it" — a decisive negative result plus a still-open "where does this data actually come from" question): ruled out room-background static vertex data as the source of the low alpha, and ruled out the two most obvious dynamic-quad-builder candidates in `model.c`, but did not find the actual write site.**
|
||
|
||
**Built `GE_D236RAW`** (`src/game/bg.c`, `bgLoadRoomVtxData`, right after `bgSwapRoomVtx`, env-gated): dumps a histogram of every vertex's raw `cn[3]` alpha byte for each room's *entire* static vertex table the moment it's decompressed and swapped — independent of camera position or which triangles happen to be drawn, unlike `GE_D236ALPHA`. Ran headless 25s on `-level_36` (no live session needed for this step): **64 room-load events across ~40 distinct rooms, and every single one reported `amin=255 amax=255`** — literally every vertex in every room's raw table is fully opaque. **This conclusively rules out "static ROM-authored per-vertex alpha" as the source of the ≤36/255 values pass 15 measured at render time** — `bgSwapRoomVtx` was already confirmed not to touch colour bytes (pass 15), and now the pre-render data itself is confirmed uniformly opaque across the whole level. Whatever produces the low alpha happens **after** room load, and is not baked into this table at all.
|
||
|
||
**Extended `GE_D236ALPHA`** (`port/fast3d/gfx_pc.cpp`) to also report which segment the tree class's vertices were loaded from, by recording the top byte of the raw `G_VTX` command's segmented address (`g_d236_last_vtx_seg`, set in the `G_VTX` case of the main DL dispatch, read back in the existing per-triangle probe). Live capture in the same session as pass 15's walk was not repeated; a headless run instead confirmed the class reports **`vtxseg=0x70`** consistently. Per `seg_addr()`'s own documented convention (`gfx_pc.cpp:3260-3294`) and `port/src/dram.c`'s `DRAM_V1_BASE=0x70000000` comment ("ALL game RAM symbols live" in this view starting at that address), `0x70` is **not** one of the game's named symbolic segments (`SPSEGMENT_MODEL_VTX=4`, `SPSEGMENT_BG_VTX=14`, etc., `bondconstants.h:2897-2909`) — it means this particular `G_VTX` command references a **raw, unsegmented host pointer** into the general RAM arena, the same pattern used by every dynamically-built quad in `model.c` that calls `osVirtualToPhysical(freshly_allocated_buffer)` directly instead of going through `gSPSegment`. This is consistent with (though doesn't newly prove beyond) pass 15's "not static data" framing, and additionally rules out the two *specific* addressing schemes (segment 4, segment 14) a static per-model or per-room table would use.
|
||
|
||
**Went looking for the actual CPU-side quad-builder and came up empty.** `model.c` has exactly four functions that dynamically allocate and write `Vtx`/`Vertex` arrays via `vtxallocator()` for `ModelNode` rendering: `dogfnegx` (`MODELNODE_OPCODE_GUNFIRE` — PD name `modelRenderNodeChrGunfire`, muzzle flash), `doshadow` (`MODELNODE_OPCODE_SHADOW`, character/prop drop shadows), `dorottex` (`MODELNODE_OPCODE_DLPRIMARY`, "star" muzzle-flash flare arms per its own header comment), and `dotube` (`MODELNODE_OPCODE_OP07`, a two-vertex-per-iteration rotating-ring interpolator, gun-barrel/rope-like geometry). Read all four in full: none match "outdoor decorative camera-facing billboard sprite" by content, and the two that use segment-tagged addressing (`dorottex` explicitly calls `gSPSegment(..., SPSEGMENT_MODEL_VTX, ...)`) would report `vtxseg=0x04`, not `0x70`, ruling them out on the addressing evidence too. The rest of `model.c`'s `MODELNODE_OPCODE_OP*` dispatch table (`OP03/OP05/OP06/OP11/OP14/OP16/OP17/OP20`) resolves to literal `return;` stubs in this decomp — dead opcodes, not candidates. Checked `propobj.c` again for any other `vtxallocator`-style builder beyond the already-examined "screen" (in-game TV monitor image) quad system — found none.
|
||
|
||
**Status: genuinely stuck on locating the write site, not just deferring.** The mechanism is now well-characterized (raw-pointer-addressed, dynamically-built quad batch, confirmed *not* room-static or model-static data, confirmed *not* any of the four known `model.c` dynamic-quad builders) but the actual function remains unidentified after a real search, not a skipped one. **Next step, concrete and different from anything tried in 16 passes:** rather than continuing to guess-and-grep by content/name, instrument at the point of certainty — add a probe to the `G_VTX` case itself (where `g_d236_last_vtx_seg` is now set) that also captures and prints a short run of bytes *around* the raw pointer's neighborhood, or (better, if the harness supports it) print a host stack backtrace via `execinfo.h`/`backtrace()` the first time `rdp.other_mode_l == 0x0c184b50u` is seen after a `G_VTX` load — pinpointing the actual C call site directly instead of inferring it from render-mode/segment signatures. That would settle in one capture what six passes (11 through 16) of signature-matching have not.
|
||
|
||
**Not v0.3.0-blocking, user is continuing anyway.** `GE_D236RAW` and the `vtxseg` extension to `GE_D236ALPHA` are both in tree, env-gated, zero cost unset. Raw headless dump preserved at `scratch/d236_raw_headless.log`.
|
||
|
||
**Pass 17 (2026-09-21, fresh session) — WRITER IDENTIFIED: `doshadow`. Plus a false alarm resolved.**
|
||
|
||
1. **Vertex struct is 16 bytes, not 32**: GE's RSP uses a custom vertex format `{s16 ob[3]; u16 flag; s16 tc[2]; cn[4]}` (alpha at byte 0xF) — no screen-space sx/sy/st/ct. `vtxallocator(n)` = `dynAllocate(n<<4)` = n×16B; the observed +0x40 arena increments are doshadow/dogfnegx's 4-vertex (64B) allocations, not 2×32B Vtx.
|
||
2. **Decisive probe**: env-gated backtrace at allocation time (`GE_D236BT`, `get_ptr_allocated_block_for_vertices` in `chr.c`, calling a new C-linkage `port_d236bt_dump()` extracted from the RSP-side unwind logic in `gfx_pc.cpp`). All allocator hits resolve to **`doshadow`** (`model.c:5397`) via `drawjointlist → chrRenderProp → chrpropRender → chrpropsRenderPass → lvlRender`. Zero dotube activity. First allocation's physical address exactly matches the RSP-side rawvtx capture. Pass 16's exclusion of doshadow was based on a wrong claim ("dotube/doshadow use segment-tagged addressing") — only `dorottex` tags G_VTX; both use raw pointers.
|
||
3. **doshadow alpha mechanism**: `cn[3] = (envcolour.a * D_800363F0)/255` for CHR/SMOKE props, else `= D_800363F0` — per-frame global shadow intensity, explains the varying low alphas (observed 1→2→12→13→14; probe saw 13 and 80 alternating). All 4 vertices are copies of a constant template (`D_800363F8`). Textured quad via `shadow->image` → `texSelect`.
|
||
4. **FALSE ALARM (pass 17c) — the d43_emit.py op-13 "ShadowRecord bug" does not exist.** A ROM scan of all 42 op-13 records (all in C*Z character files, none in P*Z props) initially suggested the converter writes HeaderNode to r+24 / Scale to r+32 / omits BaseAddr — misaligned vs the C struct's *commented* offsets (image@0x10, HeaderNode@0x14, Scale@0x18, BaseAddr@0x1C). But a runtime dump of the live record (`GE_D236BT` probe in `doshadow`) shows **HeaderNode@0x14 = 0 and the valid pointer at 0x18** — because on PC (64-bit) `image` widens to 8 bytes, shifting HeaderNode→0x18, Scale→0x20, BaseAddr→0x28. The converter's r+16/r+24/r+32 layout **matches the PC struct exactly**; the C comments describe the N64 32-bit layout (the D52/A1 pointer-widening class). Live values all sane: image→valid tconfig (32×32, fmt=0), HeaderNode valid, height≈110, size≈(389,413), BaseAddr set by the loader's promotion pass (`modelPromoteNodeOffsetsToPointers` SHADOW case overwrites it with fileramaddr anyway). **Do not "fix" d43_emit.py op-13.**
|
||
5. **Conclusion so far**: the faint textured sheets ARE `doshadow`'s drop-shadow quads — large (≈390×413 world units) flat ground quads sampling a tiny 32×32 texture at low per-frame alpha. N64 draws the identical quads (same code, same data). **The user-visible bug is therefore almost certainly that the trees' actual sprite geometry is missing/broken on PC**, leaving only the shadow sheets visible — not a shadow bug.
|
||
|
||
**Next steps (fresh session):** (a) look at the dumped frames (`ppm/frame_0003xx.ppm`, converted PNGs in /tmp) to characterize what's actually on screen; (b) identify which prop/character model owns the shadow node (node 0x7020ef44, file image base 0x7020eda0 — all 42 op-13 records are in C*Z character files, so check which characters are present on Surface 1 and whether *their* main geometry renders); (c) if the tree sprites are a separate prop class, verify that file's GDL/vertex emission through `d43_emit.py` (D75 blob-bug class is the standing suspect for missing geometry). **TEMP probes in tree (env-gated, zero cost unset):** `GE_D236BT` backtrace probe in `chr.c` + live-record dump in `model.c:doshadow` (writes `d236sh.bin` to CWD on first hit — delete both when D236 concludes), `port_d236bt_dump()` helper in `gfx_pc.cpp`. Untracked scan script: `tools_pc/scan_op13.py`.
|
||
|
||
**Pass 18 (2026-09-21, static ROM analysis session) — the tree-class RM word is a runtime LUT rewrite; exact source pair identified.**
|
||
|
||
1. **All tree/plant prop files carry zero renderable geometry — confirmed with the real node parser, not just an opcode histogram.** `PpalmtreeZ`, `PpalmZ`, `Pjungle3_treeZ`, `Pjungle5_treeZ`, `Pplant1`–`Pplant11Z`, `Pgas_plant_*Z`: node set is only {Group(2), BBox(10), DLCollision(24)} — **no op-4 DisplayListRecord anywhere**. Same on N64. The visible tree sprites are therefore drawn by the room background (`bg_sevx_all_p.seg`, ROM 0x66F700, 116176 B; Surface1=level_36, Surface2=level_43), not by prop models.
|
||
2. **The measured tree-class RM word `0x0c184b50` exists in no ROM data.** Scanned all 25 non-empty bg files (decompressed RZ GDLs) and all 512 model GDLs: zero hits. The only `0x0c18xxxx` word in ROM is `0x0c182048` (menu logo bg). It is generated at runtime.
|
||
3. **Exact source found — `DL_LUT_CLOUD`.** Compiled GE's LUT pairs (`scratch/d236/lutdump.c`, output in `scratch/d236/lutvals.txt`): CLOUD = from `G_RM_PASS|G_RM_AA_ZB_XLU_SURF2` = **`0x0c1849d8`** → to `G_RM_PASS|G_RM_ZB_CLD_SURF2` = **`0x0c184b50`** — exact match with the measured tree-class word. The "from" word is one of the six bg RM words present in ROM (×24 in sevx). `G_RM_PASS = 0x0c080000` explains the high bits: `0x0c08 | 0x0010 (ZB_CLD_SURF2 hi) = 0x0c18`. So: room GDLs contain `SETOTHERMODE_L 0x0c1849d8`, and a runtime `bgApplyDynamicCCRMLUT` pass rewrites them to `0x0c184b50` before the RSP sees them. Note `DL_LUT_BILLBOARD` shares the same "from" word (`0x0c1849d8`) but maps to `0x0c193078` (TEX_EDGE2) — which LUT wins depends on the applied index.
|
||
4. **Pass 17's doshadow finding stands but is a different sheet.** doshadow emits `G_RM_ZB_CLD_SURF|G_RM_ZB_CLD_SURF2 = 0x00504b50` (no PASS bits) — a *different* word from the measured tree class. The faint character drop-shadow quads (doshadow, 32×32 shadow texture) and the tree-texture billboards (bg CLOUD-rewritten quads) are two separate draw sources; the user-visible "wall of tree texture" is the latter.
|
||
5. **N64 cutout mechanism (hypothesis to verify):** `0x0c184b50` = ZMODE_XLU + FORCE_BL + CVG_WRAP/FULL + IM_RD, alphacompare bits 0–2 = 000 (NONE), no CVG_X_ALPHA. No alpha *test* — the silhouette must come from plain alpha **blending** of the IA texture (`out = src*A + dst*(1-A)`; transparent texels have A=0). The port's `use_alpha` gate in `gfx_pc.cpp` (~line 2386: requires `(oml & (3<<20)) == G_BL_CLR_MEM<<20 && (oml & (3<<16)) == G_BL_1MA<<16`) must be checked against this word's blender bits (bits 16–17 = 00, bits 20–21 = 01) — if the gate rejects it, fast3d draws the quad opaque → exactly the reported "wall/sheet" symptom.
|
||
|
||
**Next steps (fresh session):** (a) read `bgApplyDynamicCCRMLUT`'s implementation + the `enum CCRMLUT` in `src/game/bg.h`; map the literal indices 1/5/6/7 used at `bg.c:2797–2819` and `CCRMLUT_WALLETBOND` (`front.c:2177`) to tables, and find which index applies `DL_LUT_CLOUD` plus **all** call sites of that index (the room-load path uses 1/5/6/7 only — the CLOUD application may live in a second call path); (b) verify the port's `use_alpha`/blend handling in `port/fast3d/gfx_pc.cpp` for RM `0x0c184b50` (it is already special-cased at ~lines 2166/2217/2259/2279/2356 — D236 probes + zfix; review what they do); (c) if the gate rejects this RM, fix in `port/fast3d` (port layer — rule 2 allows it); (d) when concluded, remove TEMP probes listed above. Scratch tools: `scratch/d236/{lutdump.c,lutbuild.bat,lutvals.txt,scan_bg_rm.py,rmvals.txt}`, `tools_pc/scan_op13.py`.
|
||
|
||
**Pass 19 (2026-09-22, static session) — both pass-18 leads REFUTED; complete runtime-emitter map for `0x0c184b50` established; the discrete-sprite mechanism identified as `dogfnegx` (op-12 GUNFIRE node).**
|
||
|
||
1. **`CCRMLUT_CLOUD` (index 4) has ZERO call sites — pass 18's "runtime LUT rewrite" mechanism is dead.** Complete call-site map for `bgApplyDynamicCCRMLUT` (`src/game/bgapply.c:77`): `bg.c:2797/2804/2812/2819` (room load; literal indices 1=`CCRMLUT_PRIMARY_ADDFOG`, 5=`CCRMLUT_SECONDARY_ADDFOG` when `g_FogSkyIsEnabled`, else 6=`CCRMLUT_PRIMARY`, 7=`CCRMLUT_SECONDARY`) and `front.c:2177` (`CCRMLUT_WALLETBOND`). `CCRMLUT_BILLBOARD` (2), `CCRMLUT_WATER` (3) and `CCRMLUT_CLOUD` (4) are defined (`bondconstants.h:2931-2942`) but never applied anywhere in the compiled set — the `DL_LUT_CLOUD` table itself exists (`bg.c:2174`, comment: "Transparent to Cloud (Saves AA - Stops Jaggies from appearing behind BillBoard)") mapping exactly `0x0c1849d8 → 0x0c184b50`, but its enum index is never passed to `bgApplyDynamicCCRMLUT`. Corroborating: the ADDFOG LUTs that ARE applied map the same "from" word `0x0c1849d8` → `G_RM_FOG_SHADE_A|G_RM_AA_ZB_XLU_SURF2` = **`0xc81049d8`** (`bg.c:2039-2040`, `2061-2062`) — exactly D276's confirmed noise-weave class. So the sevx room-bg quads that carry `0x0c1849d8` in ROM end up as `0xc81049d8` at runtime (fog enabled), NOT as tree cards.
|
||
2. **The only code that emits `0x0c184b50` is `texSetRenderMode` mode 4 ("cloud"), 2-cycle, zbuffer-on** (`othermodemicrocode.c:300`: `gDPSetRenderMode(G_RM_PASS, G_RM_ZB_CLD_SURF2)` = `0x0c184b50` exactly), reached from its callers via `sub_GAME_7F073038` (`model.c:4940-4942`) → `texSelect(..., arg2=4, ...)` → `texSetRenderMode`. Mode-4 `texSelect` call sites: **`model.c:5369` `dogfnegx`** (op-12 `MODELNODE_OPCODE_GUNFIRE`), **`model.c:5545` `doshadow`** (op-13 `MODELNODE_OPCODE_SHADOW`), `glass2.c:732` bullet sparks, `gunfire.c:1512/1518/1524` flares, `explosion.c:1890` smoke (zbuf=1), `front.c` menu/crosshair (zbuf=0 → emits `0x0c184340` instead). Note the 1-cycle variant (emitted when `tconfig==NULL` or `tconfig->level==0`, `othermodemicrocode.c:415`) emits `G_RM_ZB_CLD_SURF|G_RM_ZB_CLD_SURF2` = `0x00504b50` with zbuffer on (`0x00504340` without) — so pass 18's "doshadow emits `0x00504b50`" holds only for the 1-cycle case; with a mipmapped texture + zbuffer on, **both doshadow and dogfnegx emit exactly `0x0c184b50`** (2-cycle combine: `G_CC_TRILERP`/`G_CC_MODULATEIA2` for RGBA/IA → A = texel.a × shade.a). Pass 17's backtrace attributed the tree class's vertex allocations to `doshadow`, but `dogfnegx` is a co-emitter through the identical `drawjointlist → chrRenderProp` path and the same `vtxallocator(4)` + raw-pointer pattern — the attribution may have resolved only the more frequent of the two.
|
||
3. **`dogfnegx` IS the discrete camera-facing sprite mechanism** (`model.c:5225-5380`): it computes the view direction from `rodata->Offset` through the node's model matrix, builds a rotation aligning the quad to face the camera, applies per-frame random scale (0.75–1.25) and random UV rotation around the texture centre, and draws a `rodata->Size`-sized textured quad with mode-4 cloud RM. Gated on `(renderdata->flags & 2) && rwdata->Gunfire.visible`. `ModelRoData_GunfireRecord` (`bondtypes.h:1151`): Offset@0x0, Size@0xC, Image@0x18, Scale@0x1C, RwDataIndex@0x20. This is almost certainly what N64 shows as the "discrete tree sprites".
|
||
4. **The port's `use_alpha` gate ACCEPTS `0x0c184b50` — pass 18's prime suspect is refuted by static decode.** Against this repo's gbi.h blender layout (`include/PR/gbi.h:720-723`: `GBL_c2` puts c2-modifier at bits 16-17, c2-secondary-colour at bits 20-21 of other_mode_l): the gate (`gfx_pc.cpp` ~line 2386) requires bits 16-17 == `G_BL_1MA`(0) and bits 20-21 == `G_BL_CLR_MEM`(1); the word has byte2=`0x18` → bits 16-17=0 ✓, bits 20-21=1 ✓ → `use_alpha=true`. Full decode: ALPHACOMPARE=NONE, no CVG_X_ALPHA/ALPHA_CVG_SEL, FORCE_BL set, c1=(IN, A=0), c2=(IN, A_IN over MEM, 1MA) = standard src-over alpha blend with A from the combiner (`texSelect` mode-4 2-cycle sets `G_CC_TRILERP`/`G_CC_MODULATEIA2` for IA → A = texel.a × shade.a). No port-side opaque-draw gate explains the wall.
|
||
5. **Implication / reframed picture:** with the tree class alpha-blended correctly in the port, the consistent story across D276 (visible weave painted by the `0xc81049d8` room-bg noise class), D280 (tree cards in front yet not visibly winning) and pass 15 (tree-class vertex alpha capped ≤36/255 ≈ 14%, bimodal 25/255) is that the tree cards blend at ~10% opacity over the opaque noise wall — nearly invisible, exactly "the wall shows through". The remaining open questions are (a) whether that low alpha is ROM-faithful (doshadow's `cn[3] = envcolour.a × D_800363F0/255`, `D_800363F0` set per-chr from shade alpha at `chr.c:3319`, init 0x50; dogfnegx uses template `D_800363E0`) or a port-fed wrong input, and (b) whether the discrete billboards (`dogfnegx`) actually instantiate on PC at all — its gate inputs (`renderdata->flags & 2`, `rwdata->Gunfire.visible`) are unverified.
|
||
|
||
**Next steps (fresh session):** (a) ROM-holding session: run new `tools_pc/scan_op12.py` (added this pass; counts op-12/op-13 nodes per model file, dumps GunfireRecord Offset/Size/Image) to identify which models own Surface 1's tree billboards/shadows and cross-ref against pass 17's D236SH live-record data (32×32 fmt=0 shadow texture); (b) runtime: `GE_D236ALPHA` + `GE_D236RM` on `-level_36`, correlate the tree class's per-vertex alpha with `D_800363F0`/envcolour.a, and compare against N64 reference footage (faint vs solid sprites decides faithful-faint vs port bug); (c) verify `dogfnegx`'s gate inputs on PC (`renderdata->flags & 2`, `rwdata->Gunfire.visible`) — if billboards never fire, the discrete sprites are missing entirely (rule-2 bucket (a)/(b) question); (d) on conclusion remove TEMP probes: `GE_D236RM/ORDER/ALPHA/ZFIX/BT` in `gfx_pc.cpp`, `GE_D236BT` + D236SH dump in `model.c:doshadow`, `port_d236bt_dump()`, `tools_pc/scan_op1{2,3}.py`. Scratch tools: `scratch/d236/{lutdump.c,lutbuild.bat,lutvals.txt,scan_bg_rm.py,rmvals.txt,rmdump.c,rmbuild.bat,rmnames.txt}`.
|
||
|
||
**Pass 20 (2026-09-22, ROM-holding session) — pass-19 step (a) executed: full 512-file `scan_op12.py` run; the `dogfnegx`=trees identification is REFUTED by the asset data.**
|
||
|
||
1. **Verified scan result** (re-ran `tools_pc/scan_op12.py ntsc-final` this pass). *Note: the script's final line "total files with op12: 62" is a reporting artifact — that counter accumulates every file with EITHER opcode, so it equals the op13 file count; the real per-opcode totals are below.* Across all 512 converted model files:
|
||
- **op-12 (`MODELNODE_OPCODE_GUNFIRE` / `dogfnegx`) — 21 nodes total, ALL in weapon/gun/tank models.** 16 one-node weapon files (`PchrautoshotZ`, `Pchrfnp90Z`, `PchrgoldenZ`, `PchrgrenadelaunchZ`, `PchrkalashZ`, `Pchrm16Z`, `Pchrmp5kZ`, `Pchrmp5ksilZ`, `PchrrugerZ`, `PchrshotgunZ`, `PchrskorpionZ`, `PchrspectreZ`, `Pchrtt33Z`, `PchruziZ`, `PchrwppkZ`, `PchrwppksilZ`) + `PgroundgunZ`(2), `Pgun_runway1Z`(1), `ProofgunZ`(1), `PtankZ`(1). The dumped records confirm muzzle-flash semantics: barrel-relative offsets (e.g. shotgun `(-375,0,16)`), modest sizes, small embedded-image indices (`img` 0x18–0x6c, `vma?False`). `PtankZ`'s single node is a large smoke/flash quad (`(0,5,0)`, size 2143×1026).
|
||
- **op-13 (`MODELNODE_OPCODE_SHADOW` / `doshadow`) — 42 nodes total, ALL in C*Z character files** (one per character; matches pass 17's "42 op-13 records, all in C*Z").
|
||
- **No tree/plant model carries either node.** The 16 foliage models present (`Pjungle3_treeZ`, `Pjungle5_treeZ`, `PpalmZ`, `PpalmtreeZ`, `Pplant1Z/2Z/2bZ/3Z/11Z`, `Pgas_plant_*Z`, `Pgasplant_clear_doorZ`) contain **only** `{GROUP(2), BBOX(10), DLCOLLISION(24)}` nodes — static geometry + collision, zero billboard/shadow nodes.
|
||
|
||
2. **Refutation of pass 19's central identification.** Pass 19 concluded "`dogfnegx` (op-12 GUNFIRE) is the discrete camera-facing billboard [that draws the trees]" and that the `0x0c184b50` class is emitted by dogfnegx/doshadow. But `dogfnegx` has **zero tree instances** in the entire asset set — every one of its 21 nodes is a weapon muzzle flash or tank effect. So the `0x0c184b50` class that passes 17/19 chased (pass 17's backtrace attributed its vertex allocations to `doshadow`) is, on this level set, most plausibly **character drop-shadows + weapon muzzle flashes**, *not* the trees. Passes 17–19 appear to have been instrumenting the wrong draw class for the reported symptom.
|
||
|
||
3. **Redirection: the visible trees are static geometry, not dynamic billboards.** Given (1), the Surface 1 trees must be drawn from static data — either (a) the foliage PROP models (`PpalmZ`/`Pjungle3_treeZ`/etc., which DO load and contain GROUP/BBOX/DLCOLLISION nodes) or (b) the level background file `bg_sevx_all_p.bin`. Both are static; neither uses the dogfnegx/doshadow path. **Honest caveat — not yet disambiguated:** my offline decode of `bg_sevx_all_p`'s room secondary-DL streams found billboard-shaped cards (G_VTX+G_TRI4 with a 0xC0/G_SETTEX texture-select), but their OML words (`0x0c1849d8`/`0x0c184dd8`) do *not* match the runtime `0x0c184b50`, and pass 19 established those exact bg quads become the **noise wall** (`0xc81049d8`) at runtime (fog-enabled ADDFOG LUT). So it is NOT yet confirmed whether the visible tree sprites come from the foliage prop models or the bg file — that needs a runtime attribution.
|
||
|
||
4. **Next step, concrete (supersedes pass 19's b/c):** a single runtime probe that attributes the on-screen tree-region triangles to their draw source — tag `gfx_sp_tri*` triangles by whether their vertices came from a **model-node GDL** (prop/model path) vs a **bg room DL** (`g_BgRoomInfo[].ptr_*_expanded_mapping_info`, SPSEGMENT_BG_VTX=14 / the bg-widened arena), and for the visible tree pixels log the active RM + texture/tile. That one capture decides (a)-vs-(b) and ends the "which class is actually the trees" question 19 passes have been circling. If it's the foliage prop models, the standing D75 blob-conversion suspect (missing/mis-converted mesh or texture in `d43_emit.py`) becomes the prime fix target; if it's the bg file, the D85/D276 bg-DL + noise-wall thread resumes.
|
||
|
||
**TEMP probes still in tree (env-gated, zero cost unset) — remove when D236 concludes:** `GE_D236RM/ORDER/ALPHA/ZFIX/BT` (`gfx_pc.cpp`), `GE_D236BT` backtrace + D236SH dump (`model.c:doshadow`, writes `d236sh.bin`), `port_d236bt_dump()` helper, `tools_pc/scan_op1{2,3}.py`.
|
||
|
||
|
||
**Pass 21 (2026-09-21, static session) — pass-20's step (a)-vs-(b) ANSWERED STATICALLY, no runtime probe needed: the Surface 1 trees cannot be prop models, because the level places none. Prop/`d43_emit.py` conversion is retired as a D236 fix target; the bg-file branch is the only one left.**
|
||
|
||
Pass 20 closed by proposing a runtime draw-source attribution probe (tag `gfx_sp_tri*` by model-node GDL vs bg room DL) to decide whether the visible trees are (a) foliage prop models or (b) static `bg_sevx_all_p` geometry. That probe is **not necessary** — the setup data answers it, and two independent static lines of evidence agree.
|
||
|
||
1. **No Severnaya-type tree model exists in the asset set at all.** All 16 foliage models in `data/pcmodels-ntsc-final/manifest.csv` are jungle-themed (`Pjungle3_treeZ`, `Pjungle5_treeZ`, `PpalmZ`, `PpalmtreeZ`, `Pplant1Z/2Z/2bZ/3Z/11Z`, `Pgas_plant_*Z`), and the `PROP` enum in `src/bondconstants.h` describes each one as "Jungle …" (`PROP_JUNGLE3_TREE` = "Jungle Large Tree", `PROP_PALM` = "Jungle Palm Tree", `PROP_PLANT2B` = "Jungle Plant, Low Shrub", …). There is no pine/fir/snow-tree prop anywhere in the 512-model set.
|
||
|
||
2. **Surface 1's setup places zero foliage props — and neither does any other setup in the repo.** New tool `tools_pc/scan_setup_props.py` parses the `PROP` enum out of `src/bondconstants.h` and the object records out of `assets/obseg/setup/Usetup*Z.c`, then reports which props each setup instantiates. For `UsetupsevxZ.c` (Surface 1 — `bg.c:201` maps `LEVELID_SURFACE` → `bg/bg_sevx_all_p.seg` + this setup) it matches **219 object records**, of which **all 83 `StandardProp` records are individually accounted for** (`PROP_WOOD_SM_CRATE4`×31, `PROP_BOOK1`×16, `PROP_OIL_DRUM7`×12, `PROP_WOODEN_TABLE1`×4, `PROP_BOOKSHELF1`×4, `PROP_PADLOCK`×4, `PROP_DESK_LAMP2`×2, `PROP_METAL_CHAIR1`×2, `PROP_JERRY_CAN1`×2, plus one each of `PROP_MOTORBIKE`, `PROP_CARBMW`, `PROP_STOOL1`, `PROP_HATPEAKED`, `PROP_KEYBOARD1`, `PROP_CHRMAP`) — **not one foliage prop**. Same result for all 14 setups present in the repo (`arch ark azt cave control cryp dam depo pete run sevbunker sevb sevxb sevx`), including Dam/Runway/Streets, which also have visible outdoor greenery. GE's outdoor trees are never props.
|
||
|
||
*Positive control (the zero is meaningful).* A "0 foliage" count is only evidence if the detector can fire. The tool now self-tests and prints it: `PROP enum: 341 entries; foliage props recognised (9): PROP_JUNGLE3_TREE, PROP_JUNGLE5_TREE, PROP_PALM, PROP_PALMTREE, PROP_PLANT1, PROP_PLANT11, PROP_PLANT2, PROP_PLANT2B, PROP_PLANT3`. The matcher resolves every foliage name in the enum and still reports none placed. The record parser is independently sanity-checked by the distinctive props it *does* find in Surface 1 — motorbike, safe + safe door, the map objective item, the Yale and bolt keys, `PROP_HATCHSEVX`, `PROP_SEVDISH` — all of which really are in that level.
|
||
|
||
*Tooling bug caught during this pass, worth recording:* the first version of `load_prop_enum()` located the enum with `l.strip().startswith("typedef enum PROP")`, which matches `typedef enum PROPFLAG` (`bondconstants.h:294`) hundreds of lines earlier. That yields an enum containing **no `PROP_*` members at all**, so every `obj` resolved to `?` and every foliage count was a meaningless 0. The run *looked* exactly like a clean negative result. Fixed to an exact `l.strip() == "typedef enum PROP"` match, and the self-test above exists specifically so this failure mode cannot masquerade as a finding again. This is the D219 probe lesson in a different costume (**a diagnostic must be shown capable of producing the positive before its negative is trusted**).
|
||
|
||
3. **What this settles.** Pass 20's alternative (a) is dead: **the visible Surface 1 trees are static background geometry in `bg_sevx_all_p.seg`**, reached through `bgLoadRoomPrimaryGdl` / `bgLoadRoomSecondaryGdl`. The standing D75 blob-conversion suspect (`tools_pc/d43_emit.py` mis-converting a prop mesh or texture) is therefore **not** a D236 fix target and should not be pursued for this bug. This independently corroborates D280's ninth-pass reframing ("the remaining work is CPU-side instantiation/room-streaming, not a port fix") and explains why 19 passes of model-node instrumentation (op-12/op-13, `dogfnegx`/`doshadow`) found nothing tree-shaped: **there was never a tree model to find.**
|
||
|
||
4. **Render-mode path re-checked while here (no bug found; recorded so pass 22 does not re-derive it).** The bg-authored foliage/billboard cards carry `G_RM_AA_ZB_XLU_SURF` (`0x…49d8`), and `DL_LUT_BILLBOARD` (`bg.c:2151`, `CCRMLUT_BILLBOARD` = index 2) exists precisely to rewrite `XLU_SURF` → `G_RM_AA_ZB_TEX_EDGE` ("Transparent 1Cycle to BillBoard 1Cycle") — the alpha-cutout mode that would give discrete silhouettes instead of a blended sheet. **But index 2 is never applied**, on N64 or PC: the only call sites are `bg.c:2797/2804/2812/2819` (room load, literals 1/5/6/7 = `PRIMARY_ADDFOG`/`SECONDARY_ADDFOG`/`PRIMARY`/`SECONDARY` by `g_FogSkyIsEnabled`) and `front.c:2177` (`WALLETBOND`). So `BILLBOARD`, `WATER` and `CLOUD` are all dead LUTs in the original game too — this is **not** the PC/N64 divergence, and "apply the BILLBOARD LUT" is not a legitimate fix (it would be a game-logic change, rule 2).
|
||
|
||
Also checked and **cleared**: `bgApplyDynamicCCRMLUT`'s scan bound. `Gfx` is 16 bytes on PC (`include/PR/gbi.h`: `Gwords { uintptr_t w0, w1; }`), and `end` is computed as `ptr + usize_primary_DL_binary` in *bytes* — but `usize_*` is assigned from `expanded_size` **after** `bgWidenRoomGdl()` has already rewritten it to the widened PC byte count (`bg.c:2526`/`2559`), so the scan covers the whole room DL, not half of it. No 32→64-bit truncation here. The LUT tables themselves are layout-safe too: their scalar initializers (`DL_LUT_WATER`'s raw `0xB900031D, 0x00552078, …`) brace-elide into `Gfx.words.w0/w1` and zero-extend correctly at either width.
|
||
|
||
5. **Next step for pass 22 (bg branch only).** The question is now narrow: *within `bg_sevx_all_p`'s room display lists, which draw class carries the tree cards, and why does it not reach the screen?* D280 established that the `0x0c184b50` class sits in front of the noise quad, has CPU-baked bimodal fog (25/255) and "barely draws at all" under a deterministic `-level_36` boot — but pass 20 proved that word is only ever emitted at runtime by `texSetRenderMode` mode 4 (shadows/muzzle flashes/sparks), so it is **not** the bg tree class either. The remaining candidates are the bg-authored classes D269/D272 catalogued (`oml=0xc4112078` OPA and `0xc41049d8` XLU, both CI8) versus D276's `0xc81049d8` IA8 noise painter. Concretely: dump the Surface 1 room GDLs with the existing `GE_D85DUMP` probe (no new code) and census the distinct `oml`/texture pairs per room, then match against a room the user can stand in front of trees in. That is a static-plus-one-run step, not a new instrumentation build.
|
||
|
||
**Tooling added this pass:** `tools_pc/scan_setup_props.py` (read-only, no ROM needed, runs against the repo's own sources). Unlike the `GE_D236*` env probes it costs nothing at runtime and is generally useful — keep it rather than removing it when D236 concludes.
|
||
|
||
|
||
**Pass 22 (2026-09-21, static session) — pass-21's owed step done OFFLINE, no run: complete render-mode census of Surface 1's bg room DLs, straight out of the ROM. D276's wall painter `0xc81049d8` is now tied to its authored source (bg *secondary* DL, `G_RM_AA_ZB_XLU_SURF`, 24 of 42 rooms). No bg file in any level authors the TEX_EDGE "billboard cut-out" class, so the tree cards are plain alpha-blended quads and the remaining suspicion falls on their texture's alpha, not on geometry, culling, fog or billboard orientation.**
|
||
|
||
Pass 21 proposed dumping the Surface 1 room GDLs with `GE_D85DUMP` and censusing the `oml`/texture classes — a build plus one run. That turned out to be unnecessary too: the bg `.seg` files are stored **uncompressed** in the ROM (they have to be — `obLoadBGFileBytesAtOffset` random-accesses them by byte offset), so the whole structure is readable offline.
|
||
|
||
**Two new tools, both read-only, no ROM-dependent build, no C compiler:**
|
||
|
||
* `tools_pc/bg_gdl_census.py` — decodes `bg_<level>_all_p` out of the ROM, walks every room's primary and secondary GDL, and censuses the distinct render modes per room. It reproduces `bg.c`'s own structure exactly: file word[1] → the 24-byte `bg_room_data` array (`bg.c:865`); a room's compressed blob size = distance to the next non-zero mapping-bin offset with primary and secondary treated as one interleaved stream (`bg.c:944-990` + `getPri/SecMappingBinCount`); each blob raw-deflate behind its own 2-byte header (`bgDecompress` → `decompressdata`, which is just "skip 2 bytes, inflate").
|
||
*Validation:* **every** blob in every level tested inflates cleanly with zero failures. Since the sizes are derived, not stored, a single off-by-one in that derivation would corrupt the stream and fail inflate — so a clean sweep is a real check on the derivation, not a vacuous one.
|
||
* `tools_pc/rendermode.py` — evaluates the `G_RM_*`/`RM_*(clk)`/`GBL_c1`/`GBL_c2` macros out of `include/PR/gbi.h` and the `DL_LUT_*` tables out of `src/game/bg.c` in pure Python (93 `G_RM_*` composites resolve). It decodes any `oml` word into flags + blender equation, and produces the (from → to) rewrite maps for the LUTs `bg.c` actually applies. *Cross-check:* it independently reproduces pass 19's `0x0c1849d8 → 0xc81049d8` ADDFOG mapping, which pass 19 derived by hand.
|
||
|
||
1. **Surface 1 (`bg_sevx_all_p`, 42 rooms) authors exactly six render-mode classes** — the complete set, with their fog-on LUT images:
|
||
|
||
| authored | fog-on (LUT 1 pri / 5 sec) | setups | rooms | mode |
|
||
|---|---|---|---|---|
|
||
| `0x0c182078` | `0xc8102078` | 39 | 39 | `AA_ZB_OPA_SURF` (primary DLs) |
|
||
| `0x0c1849d8` | `0xc81049d8` | 24 | 24 | `AA_ZB_XLU_SURF` (secondary DLs) |
|
||
| `0x0c184dd8` | `0xc8104dd8` | 15 | 15 | `AA_ZB_XLU_DECAL` |
|
||
| `0x0c192d58` | `0xc8112d58` | 7 | 7 | `AA_ZB_OPA_DECAL` |
|
||
| `0x00502078` | (unchanged) | 2 | 2 | OPA_SURF, no-fog blender |
|
||
| `0x005049d8` | (unchanged) | 1 | 1 | XLU_SURF, no-fog blender |
|
||
|
||
2. **D276's wall painter is now attributed to authored data.** `0xc81049d8` — the class D276 identified as the thing actually painting the visible weave — is **exactly** the LUT image of the bg *secondary* DL class `0x0c1849d8` (`G_RM_AA_ZB_XLU_SURF`), present in 24 of Surface 1's 42 rooms. Nineteen passes have logged that word live; this is the first time it has been traced back to a specific authored source in a specific file. It is ordinary bg transparent-surface geometry, and there is a lot of it.
|
||
|
||
3. **`LEVELID_SURFACE` == 36**, so the `-level_36` deterministic boot used by D269/D272/D276/D280 really was Surface 1 (`LEVELID_STATUE = 22` + 14 sequential entries; `bg.c:201` then maps it to `bg/bg_sevx_all_p.seg`). Worth stating explicitly — several passes' conclusions rest on it and none of them checked.
|
||
|
||
4. **No bg file in ANY level authors the TEX_EDGE "billboard cut-out" class `0x0c193078`.** Censused `sevx sevb sev dam jun run depo azt` — the union of their authored render modes is `{0c182078, 0c182048, 0c1849d8, 0c184dd8, 0c184e50, 0c192d58, 00502078, 005049d8, 00504dd8, 00552d58}`, and `0x0c193078` appears in none of them, **including Jungle and Depot**. That matters because `DL_LUT_PRIMARY_ADDFOG` carries a dedicated rewrite pair for it with Rare's own comment: *"Billboard Cut-out to FOG Billboard Cut-out - eg, Mario Tree or Depot lamp"*. So the engine's TEX_EDGE billboard path is real but is **never fed from bg geometry** — it belongs to the prop/model path (which is where the jungle foliage props live, per pass 21). Combined with pass 21, Surface 1 has neither tree props nor TEX_EDGE bg cards.
|
||
|
||
5. **Consequence — the mechanism is narrowed to texture alpha.** Surface 1's tree cards can only be plain `XLU_SURF` textured quads in the secondary DLs. `XLU_SURF` is `GBL_c2(CLR_IN, A_IN, CLR_MEM, 1MA)` — a straight src-over blend with **no alpha test and no coverage-from-alpha**. Under that mode a card is discrete only if its texture's alpha is transparent outside the silhouette. So "sheet instead of discrete trees" is, mechanically, "the alpha of this class's texture is opaque where it should be transparent" — which is precisely what D276 found and measured (*"fully opaque, alpha nibble mostly 0xf"*, 1023/1024 texels at A=F per D267). **This retires, for this class, the geometry/culling/fog/billboard-orientation branches** that D269→D280 cycled through: none of them can turn a correct-alpha XLU_SURF card into a sheet, and none of them are needed to explain it.
|
||
|
||
The open sub-question is no longer *"which draw class"* but *"is this texture's format/alpha being decoded correctly at all"*. D265 tried IA4 `I3:A1`→`I2:A2` (reverted), D267 dumped the IA8 set and dismissed it on a single-instance visual judgement, D273 ruled out the CI palette layout, D276 re-identified the IA8 class but never questioned whether `fmt=3 siz=1` is the *right* interpretation of those bytes. A 32×32 image that decodes as "high-frequency blocky gray static with opaque alpha" is exactly what a tree texture looks like when read at the **wrong format or wrong bit-split** — that specific possibility has never been tested.
|
||
|
||
6. **Anomaly to resolve before trusting the older logs.** D269 (M-128) recorded the two live Surface 1 classes as `oml=0xc4112078` (OPA) and `0xc41049d8` (XLU). The LUT images derived here are `0xc8112078` and `0xc81049d8`. The difference is exactly one field — cycle-1's fog alpha source, bits 26-27: `G_BL_A_SHADE` (2, what the LUT writes via `G_RM_FOG_SHADE_A`) vs `G_BL_A_FOG` (1, what D269 logged). D276 and D280 logged the `c8` (A_SHADE) form, matching the LUT. So either something rewrites the fog alpha source after `bgApplyDynamicCCRMLUT`, or D269's words were mis-transcribed and three passes of "two draw classes per room stream" rest on them. Decide this by decoding a live log with `python tools_pc/rendermode.py <word>` rather than by eye — it is a one-bit-field difference that is very easy to read past, and it changes the fog blend if real.
|
||
|
||
7. **Also recorded (fog-off path).** `DL_LUT_PRIMARY` (6) and `DL_LUT_SECONDARY` (7) contain **zero** render-mode rewrite pairs — their bodies are entirely `gsDPSetCombineMode`/`gsDPSetCombineLERP`. So when `g_FogSkyIsEnabled` is false, room render modes are left exactly as authored and only combiners are patched. (This is a correct reading of the tables, not a parser gap: the tool finds 8 pairs in LUT 1 and 3 in LUT 5 from the same code path.)
|
||
|
||
**Next step for pass 23 (one run, or possibly none):** re-dump the Surface 1 textures for the `0xc81049d8` class with the existing `GE_TEXDUMP`/`GE_TEXRAW`/`GE_D270KEEPOML=1049d8` probe combination (the current `texdump/` in-tree is from a D75 logo capture, not a Surface run), then decode the dumped bytes offline under *every* plausible format hypothesis — IA8 as currently assumed, IA4 both bit-splits, I8, I4, CI4/CI8 with the room's TLUT, RGBA5551 — and look for the one that yields a tree silhouette with a sensible alpha channel. Cheap, offline, and it directly tests the one mechanism left standing. Resolve item 6's `c4`/`c8` discrepancy from the same run's log.
|
||
|
||
**Tooling added this pass (keep — read-only, no runtime cost):** `tools_pc/bg_gdl_census.py`, `tools_pc/rendermode.py`.
|
||
|
||
|
||
**Pass 23 (2026-09-21, static session) — THE TREE CARDS ARE IDENTIFIED. Surface 1's `0x0c1849d8`/`0xc81049d8` class binds 13 distinct textures, and four of them — `tex1198`–`tex1201`, `RGBA16` (RGBA5551, ONE bit of alpha), 64×17, `HUFFLOOKUP`, six EXPLICIT LODs — are each bound in 21 of the level's 42 rooms, 3.5× the next most widespread. They are the only explicit-LOD textures in the entire Surface 1 bg. They would log as `fmt=0 siz=2`, which is why D265's IA hunt, D269/D272/D273/D274's CI8 hunt and D276's IA8 hunt all missed them: every probe filter in this investigation has been looking at minority members of the right render-mode class.**
|
||
|
||
Pass 22 left the census with `tex=0` — Surface 1's room DLs contain no `G_SETTIMG` at all. That turned out to be the key, not a dead end.
|
||
|
||
1. **bg room DLs reference textures by NUMBER, not address.** `texLoadFromGdl` (`tex.c:779`) has `case G_NOOP: texnum = in->words.w1 & 0xfff;` — opcode `0xC0`, 329 occurrences in Surface 1's room DLs — and resolves each into real texture commands at room load. So the texture identity of every bg draw is readable offline, by tracking the active render mode across the command stream and recording the `G_NOOP` texnums bound under it. `bg_gdl_census.py` now does this.
|
||
|
||
2. **Reading the texture headers offline.** A texture's ROM blob begins with a control byte (`image.c:2568`): bit 7 `hasExplicitLods`, bit 6 `iszlib`, bits 0-5 `lod` (image count); the bitstream starts at **byte 1**. `iszlib=0` → `texInflateNonZlib`'s 24-bit `ffff wwwwwwww hhhhhhhh cccc` header; `iszlib=1` → `texInflateZlib`'s `format u8, numcolours-1 u8, palette[], w/h per image`. New tool `tools_pc/texheader.py`; offsets/sizes come from the existing `imagelist.u.csv`.
|
||
*Sanity check, and a near-miss worth recording:* the first attempt forgot the control byte. The resulting parse **still looked plausible** — it produced four "formats" and confident dimensions — but 1020 of 2699 entries were implausible and the format nibble only ever took the values 0/4/8/12, the tell that the field was reading across a boundary. With the byte-1 offset, **1** of 2699 is implausible and the dimension histogram is dominated by 64×32, 64×64, 32×64, 32×32. The tool prints that count every run so the offset can't silently drift again ([[porting-notes §E positive-control rule]] — same class as pass 21's enum bug).
|
||
|
||
3. **The full texture list for the wall class** (`oml=0x0c1849d8` → live `0xc81049d8`), by room coverage:
|
||
|
||
| texture | rooms | ROM format | dims | compmethod | explicit LODs | logs live as |
|
||
|---|---|---|---|---|---|---|
|
||
| `tex1201` | **21** | `RGBA16` | 64×17 | HUFFLOOKUP | **yes (6)** | `fmt=0 siz=2` |
|
||
| `tex1200` | **21** | `RGBA16` | 64×17 | HUFFLOOKUP | **yes (6)** | `fmt=0 siz=2` |
|
||
| `tex1199` | **21** | `RGBA16` | 64×17 | HUFFLOOKUP | **yes (6)** | `fmt=0 siz=2` |
|
||
| `tex1198` | **21** | `RGBA16` | 64×17 | HUFFLOOKUP | **yes (6)** | `fmt=0 siz=2` |
|
||
| `tex149` | 6 | `IA8` | 32×64 | RLELOOKUP | no | `fmt=3 siz=1` |
|
||
| `tex806` | 5 | `IA8` | 64×32 | HUFFLOOKUP | no | `fmt=3 siz=1` |
|
||
| `tex844` | 4 | `RGBA16_CI8` | (zlib, 180 col) | — | no | `fmt=2 siz=1` |
|
||
| `tex1373` | 3 | `IA8` | 64×32 | RLE | no | `fmt=3 siz=1` |
|
||
| `tex239` | 2 | `IA8` | 64×32 | HUFFBLUR | no | `fmt=3 siz=1` |
|
||
| `tex241`/`tex151` | 1 | `RGBA16_CI8` | (zlib) | — | no | `fmt=2 siz=1` |
|
||
| `tex619` | 1 | `IA8` | 32×64 | RLELOOKUP | no | `fmt=3 siz=1` |
|
||
| `tex238` | 1 | `IA8` | 64×32 | RLE | no | `fmt=3 siz=1` |
|
||
|
||
4. **This resolves the D269-vs-D276 contradiction.** Three passes argued about whether "the wall" is CI8 (D269/D272: `fmt=2 siz=1`) or IA8 (D276: `fmt=3 siz=1`). **Both are members of this one render-mode class, and both are minority members.** D269 sampled a CI8 card and generalised; D276 sampled an IA8 card and generalised and called D269's reframing premature. Neither was wrong about what it sampled; both were wrong to treat their sample as *the* class. The dominant members — four textures covering half the level — are `fmt=0 siz=2` **RGBA16**, a format nobody has filtered for or dumped in 22 passes.
|
||
|
||
5. **Why these four are the tree cards** (inference, stated as such — see the honesty note below). Four consecutive texture numbers; near-identical compressed sizes (1569/2035/1902/1964 bytes); identical geometry (64×17) and codec; each bound in exactly 21 of 42 rooms — i.e. every outdoor room, four variants apiece; drawn in the *secondary* (transparent) pass. That is precisely the signature of a small set of foliage/treeline billboard variants scattered across an outdoor level. Nothing else in the level looks like this.
|
||
|
||
6. **Mechanism hypothesis, now concrete for the first time.** `RGBA16` is RGBA**5551** — exactly **one bit** of alpha. A 64×17 strip is a wide, short card: a treeline band whose alpha bit is 0 for sky above the silhouette and 1 for tree. It is drawn under `G_RM_AA_ZB_XLU_SURF`, which pass 22 established is a straight src-over blend with **no alpha test and no coverage-from-alpha**. So the silhouette exists *only* in that one alpha bit, and the card is minified hard at distance — which is why Rare authored **six explicit LOD images** for it, the only explicit-LOD textures in this entire bg. Two ways that becomes a sheet on PC, both untested:
|
||
* **the 1-bit alpha is decoded as always-opaque** → a solid rectangle of tree-coloured texture tiled across the horizon; or
|
||
* **the authored LOD chain is dropped and GL generates mips by averaging** → the 0/1 alpha becomes fractional, and with no alpha test a minified treeline smears into a translucent band instead of crisp cutouts.
|
||
|
||
The second is the concrete, asset-grounded version of the "TEXTLOD/mip sampling" suspicion D269 raised, D272 reframed to, and D274 parked for lack of a mechanism. It now has one.
|
||
|
||
7. **Honesty note — what is NOT established.** The pixels have not been decoded: `HUFFLOOKUP` is one of Rare's huffman/RLE/lookup codecs and none of them are implemented offline, so "these are trees" is a strong inference from format, geometry, count and room coverage, **not** a decode. It should be confirmed before any fix is built on it. Equally, no claim is made here that either mechanism in (6) is the actual bug — both are hypotheses that now have a specific, cheap test.
|
||
|
||
**Next step for pass 24 — one run, tightly specified:** boot `-level_36` and dump the textures for this class with the probe filter set to **`fmt=0 siz=2`** (RGBA16) rather than the IA/CI filters every previous pass used — `GE_TEXDUMP=1 GE_TEXRAW=1 GE_D270KEEPOML=1049d8`, then match `GE_TEXI[N] addr=` against the dump by address as D275 did. Three things to read off that one capture: (a) do `tex1198`–`tex1201` arrive as 64×17 RGBA5551, or has the width/height/format been mangled on the way in (D265 already found one transposed-dimensions bug in this area); (b) is the alpha bit non-trivial, or opaque everywhere; (c) do all six explicit LOD images arrive, or only LOD 0. Also resolve pass 22's item 6 (`c4` vs `c8` fog alpha) from the same log using `python tools_pc/rendermode.py <word>`.
|
||
|
||
**Tooling added/extended this pass (keep — read-only, no runtime cost):** `tools_pc/texheader.py` (new); `tools_pc/bg_gdl_census.py` now tracks `G_NOOP` texnums and prints the decoded ROM header for every texture each render-mode class binds.
|
||
|
||
|
||
**Pass 24 (2026-09-21, static session) — THE TREE TEXTURE IS DECODED. `tex1198` LOD 0 is a 64×17 RGBA5551 treeline strip, 44.2 % opaque, and its alpha channel renders as an unmistakable row of trees. Rare authored a six-level LOD pyramid for it that PRESERVES the silhouette and its alpha coverage all the way down (44.2 % → 43.1 % → 38.8 %). Pass 23's mechanism (b) — authored LOD chain replaced by averaged mips — now has hard ground truth behind it, and the reference image to diff a live capture against exists offline. Still no build, still no run.**
|
||
|
||
Pass 23 identified the candidate cards but could not decode them (`HUFFLOOKUP` unimplemented). That is now done: `tools_pc/texdecode.py` ports `texReadBits` (`image_bank.c:99`), `texInflateNonZlib`'s image loop, `texBuildLookup`, `texInflateHuffman`, `texInflateRle` and `texInflateLookupFromBuffer` from `src/game/image.c`, covering the `LOOKUP` / `HUFFLOOKUP` / `RLELOOKUP` compmethods.
|
||
|
||
1. **`tex1198` LOD 0 — 64×17 RGBA16 (RGBA5551), HUFFLOOKUP, 481/1088 texels opaque (44.2 %).** Its 1-bit alpha channel:
|
||
|
||
```
|
||
###....###############...############...########....########.###
|
||
###.....##############...############....#.#####.....######..###
|
||
###.....#############....###########......######.....######...##
|
||
##.....#############.....######.####.......#####....######.#..##
|
||
###.....#############....###########.......###......#######...##
|
||
###.....####.########....###########......######.....######...##
|
||
#.......############.....###########.......#####.....#####.....#
|
||
##.......###########.....#######..##.......####......#####.....#
|
||
##......#######.####......#..###..#........####......#####.....#
|
||
#.......########.###......#..###..#........####......####......#
|
||
##.......###########........####...........#.#.......#####.....#
|
||
##.......###########.........###...........####......#####.....#
|
||
##.......##.###..###.........###...........####.......####.....#
|
||
##........#.###..##..........###............#.#.......###......#
|
||
.#...........#...##..........##.............##........###.......
|
||
.................##...........#.............##..........#.......
|
||
.............................................#..................
|
||
```
|
||
|
||
Vertical trunks tapering to points, transparent sky between them, thinning to isolated tips at the bottom edge. **These are the trees.** Pass 23's identification is confirmed by decode, not inference. 522 of the 1088 texels are literally `0x0000` (black, alpha 0).
|
||
|
||
2. **The authored LOD pyramid preserves the silhouette** — this is the load-bearing new fact:
|
||
|
||
| LOD | dims | compmethod | alpha opaque |
|
||
|---|---|---|---|
|
||
| 0 | 64×17 | HUFFLOOKUP | 481/1088 = **44.2 %** |
|
||
| 1 | 32×9 | RLELOOKUP | 124/288 = **43.1 %** |
|
||
| 2 | 16×5 | LOOKUP | 31/80 = **38.8 %** |
|
||
| 3 | 8×3 | RLE | (channel codec, not ported) |
|
||
|
||
LOD 1 and LOD 2 still read as recognisable tree silhouettes, and the opaque fraction holds near 40 % instead of collapsing. Each level uses a *different* compression method, i.e. these are six separately authored images, not a generated chain. **That is exactly what you do to keep a 1-bit-alpha cutout crisp under minification, and exactly what naive box-filter mip generation destroys** — averaging a 0/1 alpha yields fractional alpha, and under `XLU_SURF` (no alpha test, no coverage-from-alpha — pass 22) fractional alpha does not cut out, it blends. A minified treeline then smears into a translucent band across the horizon: the reported "wall/sheet of tree texture".
|
||
|
||
3. **Correction to pass 23.** Pass 23 called `tex1198`–`tex1201` "the tree cards". The decode refines that: only **`tex1198`** is unambiguously a treeline strip (44.2 % alpha). **`tex1199`** is the same geometry and codec but 89.4 % opaque with thin vertical gaps — a denser foliage/branch mass or a treed cliff face. **`tex1200` and `tex1201` are 100 % opaque** dense detail textures (138–162 distinct colours, no alpha structure at all) that merely share the render-mode class. The "four textures in 21 of 42 rooms each" observation stands; "all four are trees" does not.
|
||
|
||
4. **Two decoder bugs found and fixed while porting — both would have produced confident, wrong pictures.**
|
||
* **Per-image byte alignment.** `image.c:1096` ends each LOD image with `if (img_bitcount == 0) img_curpos++; else img_bitcount = 0;` — discard the partial byte, and skip a whole byte when already aligned. Omitting it desynchronised every LOD after the first. It surfaced as LOD 1 parsing as `fmt=0 1x32 cm=0`; a scan of neighbouring bit offsets showed a constant **+8-bit** drift, which pointed straight at the align.
|
||
* **`texInflateRle`'s fudge was guessed, not ported.** The first draft invented `fudge = ((1<<bt) + (1<<rl) + 1 + blocksize) // (blocksize+1)`. The real derivation (`image.c:1605`) is a loop: `cost = bt + rl + blocksize + 1; while (cost > 0) { cost -= blocksize + 1; fudge++; }`. The guess still decoded *something* — it gave `tex1198` LOD 1 as 66.7 % opaque and `tex149` as 48.3 % — and only the corrected port gives the self-consistent 43.1 % and 34.4 %. **Every RLELOOKUP figure in this entry is from the corrected port; any earlier session notes quoting 48.3 % for `tex149` are wrong.** (Same lesson as passes 21 and 23, third time in four passes: in this codebase a half-right bitstream parse produces plausible output, never an obvious failure. Port the derivation, don't reconstruct it.)
|
||
|
||
5. **What this gives pass 25.** There is now an offline, N64-correct reference for the exact texture at the centre of D236: format, dimensions, per-LOD alpha coverage, and the silhouette itself, reproducible with `python tools_pc/texdecode.py 1198`. A live capture can be diffed against it field by field instead of being interpreted on its own.
|
||
|
||
**Next step (pass 25) — one run, three questions, all now answerable by direct comparison:**
|
||
1. Does `tex1198` arrive as **64×17 RGBA5551**, or are the dimensions/format mangled? (D265 already found one transposed-dimensions bug in this area.)
|
||
2. Is the imported alpha still **~44 % transparent**, or opaque everywhere? If opaque, the bug is in the 5551 alpha-bit decode and mechanism (a) is confirmed.
|
||
3. Do **all six authored LOD images** reach the GL texture, or only LOD 0 with driver-generated mips? If the latter, mechanism (b) is confirmed and the fix is to upload the authored chain (`glTexImage2D` per level with `GL_TEXTURE_MAX_LEVEL`) rather than letting the driver filter a 1-bit alpha.
|
||
Capture with `GE_TEXDUMP=1 GE_TEXRAW=1 GE_D270KEEPOML=1049d8` on `-level_36`, filtering **`fmt=0 siz=2`**. Note `texSwapAltRowBytes` (`image.c:1088`) runs on explicit-LOD textures and swaps bytes on alternate rows — expect that in the raw dump; `texdecode.py` decodes logically and does not apply it.
|
||
|
||
**Tooling added this pass (keep — read-only, no runtime cost):** `tools_pc/texdecode.py` (`<texnum>`, `--lod N`, `--ppm out.ppm`; PPM renders transparent texels magenta).
|
||
|
||
**Pass 25 (2026-09-21, live session) — all three pass-24 questions answered by direct comparison; the mip mechanism is now located to a line. Q1 YES, Q2 YES, Q3 NO — and the "wall" turns out not to be painted by `tex1198` at all from an idle spawn.**
|
||
|
||
**Setup:** live runs of `-level_36` (idle boot, 4800 frames) with `GE_TEXDUMP=1 GE_TEXRAW=1 GE_D219RAW=1`, plus one A/B run flipping the in-tree config key `Video.FixMipTextures` (default 1 → 0) to observe unclipped LOADBLOCK sizes. `texdecode.py` was first verified function-by-function against `image.c`/`image_bank.c` (all pass-24 numbers reproduced: 44.2/43.1/38.8 %, 89.4 %, 100 %, tex149 34.375 %). One latent decoder bug fixed on the way: a `max(1, …)` clamp on `(ncol-1).bit_length()` in the plain-LOOKUP path that C's `texGetBitSize(1)=0` does not have (unreachable for GE assets; no output change).
|
||
|
||
**Q1 — YES, dimensions/format intact.** D219RAW shows all six `tex1198` LODs pool-decoded at level load with the exact offline dims/compmethods (64×17 HUFFLOOKUP, 32×9 RLELOOKUP, 16×5 LOOKUP, 8×3/4×2/2×2 RLE), format=1 (RGBA16) throughout. The live C decoder's first row of LOD 0 is **pixel-identical** to the offline decode after the documented `texSwapAltRowBytes` byte-swap — and that swap is a no-op on PORT (D159 early-return), so raw dumps are plain linear pixels.
|
||
|
||
**Q2 — YES, alpha survives intact.** Pool bytes ≡ ROM-decoded bytes ⇒ 44.2 % opaque at the pool stage. `import_texture_rgba16` (`gfx_pc.cpp:791`) maps bit0→alpha 255/0; `import_texture_ia8` maps upper nibble=intensity, lower=alpha — both preserve alpha by construction. Data check on the one drawn member of the class: `GE_TEXI[101]` (IA8 32×64, `tex149`) is **byte-for-byte identical** to the offline ROM decode (2048/2048).
|
||
|
||
**Q3 — NO, structurally.** `gfx_opengl_upload_texture` (`gfx_opengl.cpp:713`) does `glTexImage2D(level 0)` + optional `glGenerateMipmap`; there is no code path that uploads an authored chain as GL mip levels. Each LOADBLOCK becomes one GL texture; the driver box-filters level 0.
|
||
|
||
**New finding N1 — `tex1198`–`tex1201` are never drawn from an idle spawn.** Across all 4800 frames there is not a single `fmt=0 siz=2` (RGBA16) import while CI8/IA8 background draws continue every frame. The tree-like cover visible at Bond's spawn point is therefore painted by the *other* classes — consistent with D276/D280 (the IA8 noise class paints the weave). Pass 24's "leading mechanism" applies to `tex1198`'s cards, which only draw when the player reaches their rooms; the spawn-visible wall is a different painter.
|
||
|
||
**New finding N2 — the drawn tree-class texture is `tex149`: IA8 32×64 hard cutout, 34.4 % opaque** (alpha nibble F for opaque pixels, 0 for holes — no ramp). Its ROM blob is **329 bytes = exactly one image**; there is no authored mip chain.
|
||
|
||
**New finding N3 — the mip mechanism, located precisely.** `texGetDepthAndSize` (`tex.c:330`) sizes the LOADBLOCK from `maxlod`, and `texInflateNonZlib` sets `maxlod = forcenumimages` = the header's `lods` field (**7** for `tex149`) **even when `explicit_lods=0` and only one image was decoded**. So GE's LOADBLOCK is deliberately larger than the real pool data: live A/B with `Video.FixMipTextures=0` shows the same 32×64 cards at **size=2808** (base 2048 + 760 B "mip region") vs 2048 clipped. On N64 those extra bytes are whatever adjacent stage-pool memory follows the allocation — deterministic, likely neighbouring level textures. The port's RC2 clip (`g_fix_mip_textures`, `gfx_pc.cpp:1089/1142`, default ON) truncates to base height and `glGenerateMipmap` + trilinear minification replaces that region with driver box-filtered mips — **observed live on the 32×64 upload: `gen_mip=1 filter=LINEAR mipmap_filter_mode=MIPMAP_LINEAR`; 380/400 sampled uploads generate mips.** For a hard 0/F alpha cutout, box filtering turns every mip level into fractional alpha, and the XLU blend then smears minified silhouettes — the reported wall/sheet. (For `tex1198`-class cards with real six-level chains, the same clip+regenerate path replaces the authored pyramid — pass 24's mechanism (b), now confirmed at the line level.)
|
||
|
||
**Honesty note.** The N64 side of N3 is established for the *load* (block size, pool contents) but not for the *sample*: what the RDP actually read out of that 760 B mip region for minified cards (adjacent-pool bytes vs effectively level-0) cannot be resolved without hardware or an RDP sampler model. Both candidate port fixes below are therefore approximations to be judged by playtest A/B, not by derivation.
|
||
|
||
**Fix directions for pass 26 (port-only; no game logic):**
|
||
1. **No-chain textures (`explicit_lods=0`, single-image blob — `tex149` class):** do not `glGenerateMipmap`; sample level 0 only (`GL_LINEAR`, non-mip min filter). Matches "RDP with no real mips" and kills the fractional-alpha smear at zero cost. This is the cheapest test of the whole hypothesis: if the spawn wall sharpens into discrete trees, mechanism confirmed end-to-end.
|
||
2. **Chained textures (`tex1198` class):** upload the authored levels as real GL mip levels (needs per-LOD dims from the pool header; GE's authored dims are *not* exact halvings — 17→9→5→3→2 vs GL's required 17→8→4→2 — so levels must be padded/cropped to legal sizes, or uploaded into a custom layout). Bigger job; only after (1) is judged.
|
||
|
||
**Probe added this pass (TEMPORARY — remove once the mip question is closed):** `GE_D236P25` env-gated block in `gfx_opengl_upload_texture` logging width/height/gen_mipmaps/filter per upload (capped 400 lines). **Process note:** Git-Bash `kill` does not terminate the game here — use `taskkill //F //IM ge007.x86_64.exe` (two idle runs were left alive this session).
|
||
|
||
**Pass 26 (2026-09-21, live session) — D236 ROOT-CAUSED AND FIXED. The tree cards were never a decode, alpha or mip problem: the port was binding the wrong texture. Surface 1 now renders discrete snow-covered pines; verified live.**
|
||
|
||
**The pass-25 fix direction was tested first and REFUTED.** Implemented cut-out
|
||
mip suppression (skip `glGenerateMipmap` + `GL_TEXTURE_MAX_LEVEL 0` for
|
||
textures whose alpha is hard 0/255 with real holes), config-gated, and A/B'd it
|
||
live on `-level_36` at the idle spawn. **The wall did not change.** Two things
|
||
came out of that run and both mattered:
|
||
|
||
- The per-upload alpha census showed the spawn view is dominated by **248
|
||
uploads of a fully-opaque 32×32 texture** (`clear=0 solid=1024 mid=0`) — the
|
||
"wall" is opaque, so no alpha/blend mechanism could have produced it. That
|
||
retires the whole mip / 1-bit-alpha / XLU-smear branch (passes 22–25).
|
||
- Correction to pass 25's **N3**: the 760 B "mip region" beyond a no-chain
|
||
texture's base image is **not adjacent pool garbage**. `texInflateNonZlib`
|
||
(`image.c:1123-1153`) and `texInflateZlib` (`image.c:324-360`) *generate* the
|
||
chain themselves via `texShrinkNonPaletted`/`texShrinkPaletted` when
|
||
`explicit_lods == 0 && forcenumimages >= 2`. Those shrinkers also re-quantise
|
||
alpha to the source's bit depth — for RGBA5551, `a = (a0+a1+a2+a3 + 2) >> 2`
|
||
keeps every level a hard 0/1 cut-out, unlike a GL box filter. Worth knowing
|
||
for any future mip work; irrelevant to this bug.
|
||
|
||
**Root cause (`gfx_lod_tile_offset`, `port/fast3d/gfx_pc.cpp`).** GE's
|
||
`texLoadFromGdl` picks a handler per binding from `in->words.w0 & 7`:
|
||
|
||
- `TEXTURETYPE_LOD`(0) / `TEXTURETYPE_MIPMAP`(2) — `texHandleType0`/`Type2`:
|
||
ONE load block at TMEM 0, tiles 0..n are LOD levels of that one image.
|
||
- `TEXTURETYPE_DETAIL`(1) — `texHandleType1` (`tex.c`): **TWO** load blocks —
|
||
the *detail* texture at TMEM 0 (`texWriteLoadToTmemZero(tex2)`) and the
|
||
**base image** at TMEM `texGetSizeInBytes(tex2,0)` on tiles 1+
|
||
(`texWriteLoadToTmemAddr(tex1,size)` + `texWriteTileLods(tex1,…,1,size)`).
|
||
|
||
D107 folded every `G_TL_LOD` draw to tile 0 on the premise "GE loads the whole
|
||
mip chain at TMEM 0". True for types 0/2, **false for type 1** — there tile 0 is
|
||
the detail texture. So the port drew the detail texture instead of the base
|
||
image, and never imported the base image at all.
|
||
|
||
**Confirmed against the data, offline and live:**
|
||
|
||
1. `GE_D85TEX` on `-level_36`: `tex1198`–`tex1201` each resolve 28× with a
|
||
non-NULL pool pointer and **`type=1`** — they were being bound all along.
|
||
Pass 25's N1 ("zero `fmt=0 siz=2` imports in 4800 frames") was the symptom,
|
||
not evidence the cards weren't referenced.
|
||
2. Offline scan of every `G_NOOP` in `bg_sevx_all_p`: the only `type=1`
|
||
bindings in Surface 1 are exactly the four tree cards, **21 rooms each, all
|
||
sharing `texnum2 = 2465`**. Everything else in the level is `type=2` with
|
||
`detail=0`.
|
||
3. `tex2465` = **IA8 32×32 HUFFLOOKUP, `lods=0`, fully opaque** — precisely the
|
||
248-upload opaque 32×32 class above, and the same texture D267/D276 measured
|
||
at "1023/1024 texels at A=F" and called the noise painter. D276's painter was
|
||
never a mystery texture; it is the tree cards' detail tile.
|
||
|
||
**Fix (port-layer only, `gfx_lod_tile_offset`).** When `rdp.tex_lod` is set and
|
||
tile `fi+1` sits at a different TMEM address *and* declares a different
|
||
`fmt`/`siz` than tile `fi`, return tile offset 1 (the base image) instead of 0.
|
||
Deliberately asymmetric: LOD levels of one image always share a format, so a
|
||
mip chain can never trip it, and a detail pair that happened to share a format
|
||
would simply keep today's behaviour. Config `Video.DetailBaseTile`, default on.
|
||
|
||
**Two weaker discriminators were tried live and both failed — recorded so they
|
||
are not retried:** (a) "tile fi+1 is at a different TMEM address" alone — mip
|
||
levels are at different TMEM offsets too, so every ordinary surface started
|
||
sampling its first mip (trees correct, all ground/snow textures wrecked; the
|
||
user caught this in the same build); (b) additionally requiring
|
||
`rdp.loaded_texture[tmem].addr` to be set — same failure, because
|
||
`rdp.loaded_texture[]` is persistent state never cleared between binds, so a
|
||
stale entry from an earlier type-1 draw satisfies it.
|
||
|
||
**Verification.**
|
||
- Live `-level_36` idle spawn, frame 560: opaque tiled noise wall →
|
||
**discrete snow-covered pines lining the valley**, ground/snow unchanged
|
||
against the pre-fix capture. Surface 2 (`-level_43`) likewise shows a discrete
|
||
night treeline; Depot (`-level_30`, the only other level with `type=1`
|
||
bindings) renders clean.
|
||
- `bunker1` golden framediff is **bit-identical with the fix on and off**
|
||
(`worst_cell=189.741875` both ways — that delta is pre-existing, from the
|
||
other uncommitted work in the tree, not from this change).
|
||
- Offline blast-radius scan of every level's bg: `type=1` bindings exist **only**
|
||
in `sevx` (84), `depo` (39), `sevb` (4), `sev` (4). Every other level has zero,
|
||
so the change is provably inert on them.
|
||
|
||
**Generalised to `docs/porting-notes.md` §D12.**
|
||
|
||
**Housekeeping:** with D236 closed, every D236 diagnostic is out of the tree
|
||
-- the pass-25 `GE_D236P25` probe and the pass-26 cut-out experiment, plus
|
||
`GE_D236RM`/`ORDER`/`ALPHA`/`ZFIX` (`gfx_pc.cpp` `gfx_sp_tri1`), `GE_D236RAW`
|
||
(`bg.c`), `GE_D236BT` (`chr.c` + `model.c`), the `port_d236bt_dump()` host
|
||
backtrace dumper and the `windows.h` include it needed, and the
|
||
`g_d236_last_vtx_seg`/`_raw` G_VTX trackers. Their rows are gone from
|
||
`GE-ENV-PROBES.md`; `gen_env_probes.py --check` is in sync. Build clean and
|
||
the Surface 1 spawn capture is unchanged across the strip.
|
||
|
||
## D237 — QoL ask (M-106, user QA): F10 options overlay — make it scrollable with the mouse wheel, expose more settings into it (e.g. screen-flash / no-hit-flash…
|
||
|
||
**QoL ask (M-106, user QA): F10 options overlay — make it scrollable with the mouse wheel, expose more settings into it (e.g. screen-flash / no-hit-flash `Game.NoHitFlash`, and other easily-exposed config keys), and organise rows by category (mouse settings together, video together, etc.).** The overlay (`port/src/optionsoverlay.c`) already renders over the registered `config.c` option list; what's missing is wheel-scroll nav, a broader row set, and grouping headers. Cross-ref `OPTIONS-MENU-PLAN.md` §3 (v1 minimum set) + QOL-INVENTORY "PD has, GE lacks".
|
||
|
||
**Status (from §F table):** OPEN — not designed; port-only
|
||
|
||
**IMPLEMENTED (2026-09-23, v0.4.0; port-only, `port/src/optionsoverlay.c`).** Wheel scroll already existed (D304 era). Added: (1) **category headers**, a new `ROW_HEADER` kind (not config-backed, never selectable; up/down/wheel step over headers via `overlayStepSel`, clicks on headers ignored, `rowAdjust` no-ops) drawn in the title accent: DISPLAY / VIEW / MOUSE / AIM / CONTROLLER / GAME; (2) **more settings**: `Game.SkipIntro`, `Video.DisplayFPS`, `Input.PadLookInvertY`, `Input.PadDeadzone` (slider, step 500), `Input.PadTriggerPct`. `Game.NoHitFlash` was already exposed. Verified: overlay capture shows the headers and the selection starting on the first real row; no unregistered-key warnings; Windows + Linux builds clean. Owed: a hands-on pass (wheel/arrows skip headers, the new sliders feel right).
|
||
|
||
## D238 — QoL ask (M-106, user QA): mouse sensitivity rework toward normal PC-shooter feel.
|
||
|
||
**QoL ask (M-106, user QA): mouse sensitivity rework toward normal PC-shooter feel.** Three parts: (1) a *separate* RMB aim-mode sensitivity in addition to hipfire turn; (2) ideally ONE single master mouse-sensitivity setting driving both, **linked by default**, with an option to set them independently later (today `Input.MouseAimSpeed` and `Input.MouseTurnSpeed` are two independent keys, defaults 16/100 — see `input.c:188-190`); (3) up/down should feel mostly the same as left/right (`Input.MouseYScale` exists but the axis asymmetry in the gain constants `AIM_GAIN`/`MOUSE_TURN_GAIN` + smoothing path is the likely culprit). User's reference for "feels better": GEPD Mouse Injector. Lump with the overall mouse/input fixes (D194/D165/D166 lineage; SOLITAIRE natural-pitch scheme switch may subsume part of this — see D166 M-87 note).
|
||
|
||
**Status (from §F table):** OPEN — not designed; port-only (`port/src/input.c` + `config.c`)
|
||
|
||
## D239 — QoL ask (M-106, user QA): click-to-lock vs always-grab parity.
|
||
|
||
**QoL ask (M-106, user QA): click-to-lock vs always-grab parity.** User: make click-to-lock the default *if it isn't already* — it IS: `mouseCaptureMode = 1` is the shipped default (`input.c:185`). The real content: "always grab" feels bad on the file-select menu, not 1:1 with click-to-lock; if making them feel identical is overly complicated, **drop always-grab entirely and remove the setting**. This re-confirms the existing M-82 user decision to rip out `MouseCaptureMode=0` (see `docs/BACKLOG.md` "M-82 playtest feedback" + D192: mode 0's P-controller path can't reach the outer front-end grid cells — a known mode-0-only defect). Recommendation on this new report: take the user's fallback — remove mode 0 and the `Input.MouseCaptureMode` key, keep click-to-lock only.
|
||
|
||
**Status (from §F table):** CLOSED — executed in the v0.2.0 prep pass (port-only): `mouseCaptureMode` + the config key, `reconcileGrab`'s legacy branch, the `!haveAbs` relative menu path and its now-dead P-controller state (`menuEst/TgtH/V`, `menuCursorStep`, `MENU_P_GAIN`/`MENU_EST_DELTA`/`MENU_MOUSE_TO_PX`), the F10 "Mouse capture" row, and the unused `inputMouseCaptureActive()` all removed from `port/src/input.c` / `optionsoverlay.c` / `port/include/input.h`. Click-to-lock is the only mode; existing ini files setting the old key are ignored. Closes D192 with it
|
||
|
||
## D240 — Player gunshot SFX timing/cadence reportedly does not match the N64 original's rate (M-106, user QA report).
|
||
|
||
**Player gunshot SFX timing/cadence reportedly does not match the N64 original's rate (M-106, user QA report).** Static trace only (no ROM/build/display in this worktree). Weapon fire re-triggers its SFX through `bondwalkItemGetSoundTriggerRate()`/`bondwalkItemGetSound()`, gated on `g_GlobalTimer` (`src/game/gunfire.c:3246-3286`: `handptr->field_A4C = bondwalkItemGetSoundTriggerRate(var_s1) + g_GlobalTimer`), alternating between two per-hand `ALSoundState` slots (`audioHandle`/`field_A48`, same block) so a fast-firing weapon's report can overlap itself once before being retriggered. `g_GlobalTimer` advances by `g_ClockTimer = speedgraphframes` every tick (`src/game/lv.c:1023,1037`), and `speedgraphframes` is set from `deltaFrames` in `updateFrameCounters()` (`src/game/frametiming.c:64-76`) — the exact tick-delta plumbing already investigated (and partly fixed) under **D155** (catch-up-spiral clamp, same file), **D186** (`Video.FpsCap` sim-coupling clamp), **D193/D209** (AI-locomotion timing keystone — D209 the actual root cause, a 64-bit union-aliasing bug, now fixed and merged), and **D204** (audio pipeline running ~2% below real time forever, fixed by padding silence at the DAC). Since D209 and D204 both merged before this report, the SFX-cadence complaint is either (a) a genuinely distinct, audio-specific residual downstream of those fixes — e.g. a mismatch between the sim-tick-gated *trigger* (above) and the software mixer's own playback clock (`port/src/audio.c:93,155,221-222` — the synth produces samples at a measured ~21600/s against the 22050 Hz output device, the same gap D204 had to compensate for elsewhere) that specifically desyncs how *often* a shot's sample starts rather than the absolute wall-clock rate D204 measured, or (b) a stale pre-D209/pre-D204 build report. Per-weapon rate constants (`AutomaticFiringRate`/`SingleFiringRate`/`SoundTriggerRate`, `src/game/gun.h:60-82`, values in `assets/obseg/gun/*/gunWeaponStat.inc.c`) are plain compiled C literals, not ROM-serialized data, so no pointer-width/ABI reconciliation issue applies here.
|
||
|
||
**Status (from §F table):** **CLOSED (v0.4.0 campaign sign-off 2026-09-28, user: cadence drift not heard over the full campaign -- fixed; never root-caused -- if re-reported, the timing-keystone family above is the prime suspect).** Superseded: OPEN — not root-caused; static call-site tracing only, no runtime verification possible in this environment. Confidence LOW-MEDIUM that this is a *new* bug distinct from the D155/D186/D193/D204/D209 timing-keystone family (all of which gate the identical `deltaFrames→speedgraphframes→g_ClockTimer→g_GlobalTimer` chain this SFX trigger consumes). Raise confidence by: (1) confirming the user's build/commit postdates the D209 merge; (2) a `GE_AUDIOTRACE`-style capture logging the tick (`g_GlobalTimer`) and/or wall-clock timestamp of consecutive same-weapon fire-SFX requests during sustained auto-fire (AK47/Klobb, `AutomaticFiringRate=0x03`), diffed against the weapon's rate constant and against N64 reference footage's frame-count between shots.
|
||
|
||
## D241 — Player gunshot SFX goes silent under some conditions — reported on Bunker with the alarm sounding, "or some other factor" — but only for PP7 and AK47, Klobb…
|
||
|
||
**Player gunshot SFX goes silent under some conditions — reported on Bunker with the alarm sounding, "or some other factor" — but only for PP7 and AK47, Klobb unaffected (M-106, user QA report, concrete repro).** This is very likely **D207 finally reproduced.** D207 (M-77) root-caused that GE's SFX player has a hard **8-voice cap** (`MUSIC_SFX_SEQ_MAYBE_MAX_SOUNDS`, `src/music.c:76`) and that D206's fix made the alarm klaxon (`ALARM3_SFX`=163, triggered from `handle_alarm_gas_timer_calldamage()`, `src/game/propobj.c:14457`) a genuine **infinite ADPCM loop** that now legitimately occupies one of those 8 voices for the alarm's whole duration; the manual preemption scan (`snd.c:~347-392`) refuses to steal any *owned* LOOPED voice (the M-65 carve-out only exempts *ownerless* loops), so once 8/8 voices are held, a new one-shot request is silently dropped via `sndDisposeSound` (`snd.c:391`). D207's own M-77b session built a `GE_FORCEALARM=1` BUNKER1 harness and could **not** reproduce audible drops (`[D207-DROP]=0`), and explicitly left as "owed": *"needs a real repro from the user: which level, which weapon/situation."* This report supplies exactly that (Bunker, alarm). **Investigated why PP7/AK47 and not Klobb, inconclusive at the code level:** Klobb (`ITEM_SKORPION` — confirmed GE's internal name for the Klobb via `src/game/player.c:576`, `skorpion_stats` in `assets/obseg/gun/skorpion/gunWeaponStat.inc.c`) and AK47 (`ak47_stats`, `assets/obseg/gun/ak47/gunWeaponStat.inc.c`) share the **same** `AutomaticFiringRate=0x03` (fast auto), while PP7 (`wppk_stats`, `assets/obseg/gun/wppk/gunWeaponStat.inc.c`) is semi-auto only (`AutomaticFiringRate=0xFF`) — so a naive "fires faster ⇒ more self-inflicted voice contention" theory does not cleanly explain grouping PP7 with AK47 against Klobb. All three weapons' fire SFX (`GUN_B1_MGUN3_3_SFX`=Klobb, `GUN_B2_HEAVY_SFX`=PP7, `GUN_B4_BOLTACTION_SFX`=AK47 — `src/bondconstants.h:2277-2280`) go through the identical one-shot, priority-0x40 `sndPlaySfx` path (`gunfire.c:3246-3286`); no per-weapon distinction exists in the trigger or priority *code*. A real distinction, if one exists, most likely lives in the **sample bank data itself** (per-sample envelope/decay length — a longer-tailed sample stays `PLAYING` and stealable/held longer, changing its odds in the preemption scan) — that data is ROM-derived binary content, not inspectable without the extracted ROM (`./data/`, absent in this worktree). Confirmed the voice-allocation/preemption code is unmodified `src/snd.c` game logic, not a `port/` mixer bug: `port/src/mixer.c`/`port/src/audio.c` implement DSP synthesis and device output only (per D199), with no SDL `Mix_*`-style channel-priority layer in the port to have introduced a new bug. Per AGENTS.md non-negotiable #2, any real fix belongs in D207's already-designed (reverted, M-77b) last-resort LOOPED-preemption pass in `snd.c`, not in `port/`.
|
||
|
||
**Status (from §F table):** **CLOSED as the D207 family (v0.4.0 campaign sign-off 2026-09-28, user: no silence under heavy fire + alarms -- fixed).** Superseded: OPEN — not newly root-caused; high confidence this is D207 reproduced, not an independent bug. Recommend folding this repro into D207 (or closing D241 as a duplicate once D207 is reopened) rather than tracking separately. Next: re-run M-77's `GE_FORCEALARM=1` harness on `-level_09` with scripted PP7 and AK47 fire (not just generic fire) against Klobb as a control, watching `g_sndAllocatedVoicesCount` / the (currently reverted) `[D207-DROP]` counters to see whether the 8-cap is actually hit for two of the three weapons and not the third — or whether it is really the sample-envelope-length distinction above. No runtime verification possible in this environment (no ROM/display).
|
||
|
||
**2026-09-26 (M-201, v0.4.0): user still hears gunshot drop-outs "with rapid fire guns while a lot of other sound effects occur" (D322 garbling itself is gone). Headless measurements, no drop reproduced.**
|
||
|
||
- **Static:** player shots (`gunfire.c:3296-3340`) and guard shots (`chraction.c:5630`) both stop the previous shot sound and start a new one, using at most 2 handles per hand, rate-limited by `SoundTriggerRate` against `g_GlobalTimer`. At 1 tick/frame a shot sound can start every `rate+1` ticks; at the N64's 2–3 ticks/frame the check rounds up to frame boundaries (rate 3: every 6 ticks at 3 ticks/frame vs 4 on PC), so the port plays up to ~1.5x more shot sounds (a D13-class divergence) but does not hold more simultaneous voices. `sndDeactivate` only posts `AL_SNDP_DEACTIVATE_EVT`; the slot frees in `sndDisposeSound`.
|
||
- **Runtime (Dam, invincibility + all guns, `GE_FORCEALARM=1`, PP7 held in bursts via new `GE_INPUTSCRIPT` `ZHOLD`/`ZREL`, ~100 s each):** `GE_AUDIOTRACE` `[D207-DROP]` = 0 at both FpsCap 60 and 30 (peak SFX slots 8/8 and 7/8); `GE_D322` at 60 fps: worst sample `sfx` 7/8, `pv free` 9/24, `evtq` 9/64, audio `q` 720/2880, `drop=0`. Both drop paths (`preempt-scan` = 8-cap, `no-limit` = physical pool) are covered by `[D207-DROP]`; failing sounds first retry every 33 ms (audible as late, not missing).
|
||
- **Harness limits:** `gunRequestHandWeaponChange` / the level-start equip call did not switch weapons mid-level from the harness, so the automatic-weapon case (AK47/Klobb) is untested headless.
|
||
- **Next:** capture the user's real scenario with `GE_AUDIOTRACE=1 GE_D322=1 GE_D204=1` (`scratch/playtest_audio.bat`), noting level, weapon and time of each drop-out; any `[D207-DROP]` line names the layer. If none appear, the "drop-out" is the faithful stop-then-restart of the per-hand shot sound, not a lost voice.
|
||
|
||
**2026-09-26 (M-201, cont.): live captures. Not reproducible with lightweight logging; the one captured drop-out ran with the heavy GE_AUDIOTRACE probe.**
|
||
|
||
- **Run 1 (user, Bunker, AK47, alarm active, `GE_AUDIOTRACE`+D322+D204):** drop-out heard. `[D207-DROP]`=0, pools healthy (sfx <= 6/8, pv free >= 11/24). The per-sound `sndPlaySfx` trace is capped (~630 events) and stopped before the AK47 section, so it could not show the moment. `GE_AUDIOTRACE` does an uncached `getenv` plus a file write from the audio path on nearly every sound event (D250/D302 class), so this drop-out may be probe-induced.
|
||
- **New TEMP `GE_D207S`** (`src/game/gunfire.c`, `#ifdef PORT`, read-only): logs every player shot-sound decision (`H1`/`H2`/`SKIP`, where SKIP = both per-hand handles still held because the stop is async) with `g_GlobalTimer`. Uncapped, cheap.
|
||
- **Run 2 (FpsCap 30):** 315 decisions, 0 SKIP, no drop-outs heard. AK47 gaps 5–7 ticks.
|
||
- **Run 3 (FpsCap 60):** 408 decisions, 0 SKIP, no drop-outs heard. AK47 exactly every 5 ticks (`SoundTriggerRate` 4 in the BUGFIX_R0 build).
|
||
- **Run 4 (FpsCap 60, stress test, 7 weapons incl. AK47/ZMG/D5K/RC-P90/Klobb):** 2073 decisions, **0 SKIP**; SFX cap frequently 8/8 but `drop=0`; evtq max 21/64; D204 `rt` 0.999–1.000 throughout, audio queue never empty. User: "seemed okay".
|
||
- **Refuted:** voice-cap drops, physical-pool exhaustion, per-hand handle starvation (the 60 fps shot-rate divergence is real, 5 vs 6 ticks for the AK47, but never starves the handles), output underrun during these runs.
|
||
- **Open:** the drop-outs the user heard in the untraced 21:9 session remain unexplained and intermittent. Standing capture: `scratch/playtest_audio.bat` (`GE_D207S` + D322 + D204, no audio-thread file I/O); press F12 on a drop-out.
|
||
|
||
**Status: OPEN (not reproducible) — drop paths and handle starvation refuted by measurement; awaiting a natural occurrence under lightweight logging.**
|
||
|
||
## D242 — Player-death sequence UX regression vs N64 original: PC requires an LMB click to proceed to the mission-failed/file-select screen; user recalls N64 auto-plays…
|
||
|
||
**Player-death sequence UX regression vs N64 original: PC requires an LMB click to proceed to the mission-failed/file-select screen; user recalls N64 auto-plays 3 death-camera angles then transitions with no input needed (M-106, user QA report).** **The 3-angle auto-replay and its no-input auto-transition both exist, unmodified, in the decomp — confirmed, not a design difference removed for the port.** `bondviewSetCameraMode(CAMERAMODE_DEATH_CAM_SP)` (`src/game/bondview2.c:901-1010`) calls `pickDeathCameraAngles()` (`bondview2.c:641-782`, picks a fresh random valid camera angle each call) to stage each replay; when it returns 0 — no further valid angle found — the code calls `bossRunTitleStage()` directly, and the source comment at `bondview2.c:1006-1007` literally reads *"...or when the three death replays are finished."* The 3-cycle count is driven by `bondviewAdvanceCameraMode()` (`bondview2.c:1041-1078`): `CAMERAMODE_DEATH_CAM_SP` → `CAMERAMODE_DEATH_CAM_MP` → `camera_mode++`, looping back to `DEATH_CAM_SP` while `camera_mode < CAMERAMODE_SWIRL` (three iterations). Each individual replay's own advance is driven purely by animation/timer state (`bondview2.c:1467-1490`): the `g_CameraAfterCinema` flag that `bondviewAdvanceCameraMode()` acts on is set automatically once `modelGetAnimFrame(bodyModel) >= modelGetAnimEndFrame(bodyModel)`, or after a `camera_transition_timer >= 180.0f` tick-timeout fallback when there is no body model — **no button check in that branch; fully automatic.** **The button-press the user is hitting is a real, intentional N64 skip-ahead shortcut, not a port-added gate:** the same code block (`bondview2.c:1483-1489`) *also* fires the identical advance immediately on `(buttons & ~oldbuttons & (CONT_A | B_BUTTON | Z_TRIG | START_BUTTON))` whenever `bonddead && redbloodfinished && deathanimfinished` — i.e. on real N64 hardware, pressing A/B/Z/Start at any point during a death replay skips straight to the end, by design. **On the port, LMB (mouse fire) is mapped to `GE_CONT_G`, which is Z_TRIG** (`port/src/input.c:664-665`: `(mb & SDL_BUTTON(SDL_BUTTON_LEFT)) ... button \|= GE_CONT_G`; binding table `input.c:351`: `{"Z",GE_CONT_G}`) — exactly one of the four original skip buttons. So an LMB click during the death sequence is GE's own original cutscene-skip input, faithfully wired through the port's control remap, not a port-invented "click to continue" prompt. **What this investigation could not determine** (no build/runtime access in this worktree): whether the *automatic*, no-input path actually completes on PC in a reasonable time. `camera_transition_timer` accumulates via `g_GlobalTimerDelta` (`bondview2.c:1470,1493`), which is set from the same `g_ClockTimer`/`speedgraphframes`/`deltaFrames` chain flagged in D240 above and investigated under D155/D186/D193/D204/D209 — if any residual in that chain stalls or mis-scales specifically in this state, the auto-advance could take far longer on PC than on N64 (or effectively never complete before the player gives up and clicks), which would make an LMB click *feel* mandatory even though the ground-truth logic never requires one.
|
||
|
||
**Status (from §F table):** **NOT A PORT-LOGIC BUG as far as static analysis can tell.** Confidence HIGH that the LMB-click skip path is faithful original behavior (Z_TRIG-mapped, exists unmodified in `bondview2.c`), not a defect to remove. Confidence LOW (untested) on whether the no-click automatic path is actually healthy on PC. Needs a human playtest: die, then **deliberately touch no input** during the death sequence and time whether/how long until the mission-failed screen appears on its own. If it never arrives without a click, the real bug is a `g_GlobalTimerDelta`/`camera_transition_timer` stall in the D240 clock family — cross-reference D240 — not a wrongly-added input gate, so there is still no `#ifdef PORT` skip-gate to remove per AGENTS.md #2. No runtime verification possible in this environment (no ROM/display).
|
||
|
||
**M-201 (2026-09-26): headless check, no input after death.** Dam, `GE_D320R="1000:explode=13,0"` (grenade at Bond's feet, no invincibility), `GE_INPUTSCRIPT` with no events: the death camera auto-played its angles (frames ~1400–1900) and the stage exited on its own ~15 s later (stage 33 → 90). **The "PC requires an LMB click" report does not hold on the current build.** A direct `-level_XX` launch has no front-end session, so it then fell into the title/attract loop (stage 90 → 25 → 90) — not comparable with the N64 reference (user on 1964: returns to mission select and shows "Killed in Action"). Owed: user check from a normal menu launch that death lands on mission select + KIA.
|
||
|
||
## D243 — Dam end-of-level cutscene (the abseil/bungee sequence, D148/D160's subject): the camera violently shakes/"spazzes" during each of the scripted camera-angle…
|
||
|
||
**Dam end-of-level cutscene (the abseil/bungee sequence, D148/D160's subject): the camera violently shakes/"spazzes" during each of the scripted camera-angle cuts (M-107, live `GE_D160=1` trace, two interactive `-level_33` runs + one fresh-save normal-progression run).** **First, corrects D160's own premise: the cutscene is NOT skipped on the current build.** Both debug-loaded runs show exactly the AI-script-scripted sequence firing: `bondviewSetCameraMode(7)` (`CAMERAMODE_POSEND`, the cutscene camera-lock state) called 2-3 times, matching `ai_17`'s three `camera_switch` commands in `UsetupdamZ.c`'s abseil ailist (`assets/obseg/setup/UsetupdamZ.c:2656`+), followed by `bossReturnTitleStage`. So D160's original (b)/(c) hypotheses (AI trigger never fires / puppet renders invisibly) are **refuted on this build** — the mechanism runs; what's broken is what it renders. **Root cause candidate, directly observed:** right in the camera-cut window, fast3d's D146 catch-all (`port/fast3d/gfx_pc.cpp:3037`, "unknown GBI opcode … ending DL") fired a tight burst — same 4 memory addresses repeating every frame for ~10 frames, then clean — with payload bytes `0xfafbfbfcfcfbfdfc`/`0xfcfafbfbfafbfafb`: a **freed/uninitialized-memory fill pattern**, not real GBI commands. This reads as a stale-pointer display-list read, not a decode bug — some segment/scene buffer backing the outgoing camera shot's geometry got freed or recycled while a still-in-flight display list (queued ahead in the port's double-buffered render pipeline — see D134's "pendingGfx" note, `port/src/libultra.c:733-746`, on how many frames of latency the sched round-trip can carry) still referenced it. **Independent evidence of a race, not a fixed-order bug:** the two `-level_33` debug-load runs show the camera_switch(7) calls landing in a *different order relative to* `bossReturnTitleStage` between runs (run 1: three switches, then the stage-return call; run 2: two switches, the stage-return call, *then* a third switch arriving after) — and the D146 corruption burst appeared in run 1 but not run 2 at all, non-deterministically. That pattern (symptom present/absent, call order shuffling) is the signature of a genuine timing race between the AI-list's cutscene commands and the level-exit/report-transition code, not a deterministic logic bug. **User-confirmed on real (non-debug-boot) play, not just the debug harness:** a fresh-save normal playthrough into Dam still hit the camera glitch on this same cutscene — ruling out "artifact of the `-level_33` debug boot" for this specific symptom (unlike the secondary "no report screen, straight to file-select" symptom seen only on the debug-loaded runs, which did NOT reproduce on the fresh-save run and is therefore most likely a `-level_N` debug-boot artifact per the D190 precedent, not filed as its own bug here).
|
||
|
||
**M-142 update: live playthrough with both probes on, real signal, still not root-caused.** User played Dam to the abseil cutscene twice this session with `GE_D243=1 GE_D160=1` set (first run), then again with a new `GE_D243CAM` probe added. **Key new fact: the visible "violent shake" symptom reproduced on BOTH runs, but the `D146` stale-memory burst fired on NEITHER run** (log captured well past the historical 1600-3900-frame window both times, zero `D146:` lines) — this decouples the two symptoms M-107/M-109 had been treating as related. The shake is real, reproduces every time, and has nothing observable to do with the D146 mechanism; whatever drives it must be a silent bad-value computation (camera position/angle math), not a logged error. **Camera code path narrowed (partially):** `bondviewFrozenCameraTick`'s `CAMERAMODE_POSEND` branch (`bondview2.c:1537+`) has three sub-paths — `g_CameraLookAtBondPad` look-at, a `gBondViewCutscene`-struct spherical-angle path, and a pad-orbit fallback (`flt_CODE_bss_80079A00` angle accumulated by `g_GlobalTimerDelta` each tick). Added a `GE_D243CAM` probe (`#ifdef PORT`) to the orbit-fallback branch to check whether `g_GlobalTimerDelta` jitter there explains the shake — **it fired zero times**, meaning the Dam abseil cutscene does NOT use that branch; the real driver is one of the two earlier `return`-before-my-probe branches (`g_CameraLookAtBondPad` or `gBondViewCutscene`), not yet instrumented. Next session: add the same style of probe to those two branches (log `pos`/`pos2`/`gBondViewCutscene->theta,verta` every call) and re-capture — same repro (Dam, Agent, to the abseil cutscene, `GE_D160=1` to confirm mode(7) still fires 3x) works reliably and takes only a few minutes end to end.
|
||
|
||
**M-143: live capture with the two new probes names the mechanism — a leaky-integrator position filter not reset across a scripted position warp; likely decomp-faithful, open question is whether it's a real port bug at all.** User replayed the same Dam-abseil repro with `GE_D243=1 GE_D160=1 GE_D243CAM=1` (probes from the M-142 next-step now added to both previously-uninstrumented `CAMERAMODE_POSEND` sub-paths: `g_CameraLookAtBondPad` look-at and the `gBondViewCutscene` spherical-angle branch). **The `gBondViewCutscene` branch fires** (confirmed live — the pad-orbit branch stays at zero hits, third confirmation). Within it, `pos` (the eye) stays exactly fixed for the whole cutscene segment while `pos2` (the look-at target) is the thing moving — and tracing which of the branch's two `pos2` assignments actually ran (the `dword_CODE_bss_80079A18 == CAMERAMODE_INTRO` case reads `g_CurrentPlayer->field_3C4/3C8/3CC` directly; the `else` case derives `pos2` from `pos` + a `theta`/`verta` trig offset) rules out the trig path by arithmetic: with `theta`/`verta` observed constant across a stretch of frames, the trig formula only allows `pos2` to vary by a fraction of a unit, but the log shows `pos2.y` sweeping from **-22832 to +691 over roughly the first 60 frames** of the branch, then settling into a small ~110-unit 4-frame-period wobble — only the raw-field-read (`CAMERAMODE_INTRO`) path is consistent with a swing that large. **`dword_CODE_bss_80079A18` is scripted, not a mode flag Claude findings mislabeled** — it's set only from `ai->LOOK_AT_BOND_FLAG` in `chrai.c:4007`, i.e. the Dam level's own AI/cutscene script explicitly requests "look at Bond" framing for this segment; this is by design, not a bug in the flag itself. **`field_3C4/3C8/3CC` is not a raw position — it's a leaky-integrator low-pass filter of Bond's real collision position:** `bondviewUpdatePlayerCollisionPositionFields()` (`bondview2.c:4702`, called from three sites including `bondviewFrozenMoveBond` — the puppet/cutscene-movement driver used while Bond is "frozen" for a scripted sequence) runs `field_3B8 = FACTOR_1*field_3B8 + field_488.pos` **`g_ClockTimer` times per call** (already delta-scaled, one of porting-notes' documented `g_ClockTimer`-iteration sites — this specific update looks decomp-faithful, not a naive per-frame-not-per-tick bug), then `field_3C4/8/C = field_3B8 * FACTOR_2` where `FACTOR_1`/`FACTOR_2` are complementary constants (`0.9`/`0.100000024` non-EU, `0.8812`/`0.1188` EU) chosen so the filter's steady state converges back to the real `field_488.pos` — i.e. `field_3C4/8/C` is a **lagged, smoothed shadow of Bond's actual position**, not his position itself. **The mechanism this explains:** the instant the abseil sequence's scripted camera engages `LOOK_AT_BOND_FLAG`, Bond's actual `field_488.pos` (driven by `bondviewFrozenMoveBond`'s animation/offset-based puppet movement) jumps to wherever the cutscene's puppet animation starts — but the `field_3B8` accumulator was still converged on wherever Bond had been standing/walking a moment before (a very different location, hence the ~20,000-unit initial gap), and it takes on the order of a couple dozen ticks of the geometric decay to catch up — visually, a fixed eye with a look-at target that starts wildly displaced and snaps toward the puppet over about a second, i.e. exactly the reported "violent shake." **Open question, not yet answerable from this repo alone: is this also what real N64 hardware shows?** The filter's own update is `g_ClockTimer`-scaled (frame-rate-correct by the same standard porting-notes.md already applies to other delta-scaled code), so nothing found this session points at a PC-specific timing divergence in *this* filter — if the N64 original also snaps Bond's `field_488.pos` for this scripted segment without resetting `field_3B8` (it's only ever zero-initialized once, at player creation in `player.c:271-273`, never re-seeded on a cutscene-mode entry anywhere in `src/game`), the same transient would play out identically on real hardware, which would make this **not a port bug at all** — an original-game quirk that happens to be more noticeable now, or previously undocumented on N64 itself. Confirms (third run in a row): **zero `D146:` hits** — the shake and the D146 stale-memory burst remain fully decoupled; treat them as two separate, unrelated findings going forward, not two symptoms of one cause. **Next, concrete (superseded by M-144 below — kept for the reasoning trail):** (1) the decisive test is an N64 emulator/hardware capture of the identical Dam-abseil moment to see if the same look-at snap is visible there — if yes, close this as "faithful to original, not a port defect" and move D148/D173 (the broader cutscene-bug umbrella) up instead; (2) if no N64 access is available, the next-best port-side check is logging `field_488.pos` and `field_3B8` (not just the already-smoothed `field_3C4/8/C`) at the exact tick `dword_CODE_bss_80079A18` flips to `CAMERAMODE_INTRO`, to confirm the pre-cutscene/post-cutscene position gap is really as large as inferred rather than something else entirely coincidentally producing the same swing shape.
|
||
|
||
**M-144: CONFIRMED — real N64 does NOT show this shake (user, direct comparison).** This settles the open question M-143 ended on: the abseil cutscene's look-at snap is a genuine PC-only divergence, not an original-game quirk. Since the filter math itself (`bondviewUpdatePlayerCollisionPositionFields`) is decompiled/byte-exact and its update loop is already `g_ClockTimer`-scaled, the bug is not "the filter is unreset" in isolation — that property is presumably just as true in the original binary, since nothing in `src/game` differs from what N64 would execute at this call site. **Reframed root-cause question:** something upstream of the filter must differ between N64 and PC — either (a) the actual position jump the filter is asked to absorb is larger/different on PC than on N64 (i.e. `field_488.pos` itself warps to a wrong or differently-timed value at cutscene entry — a genuine bug in the warp, with the filter just faithfully rendering visible whatever bad delta it's fed), or (b) something about call *ordering/frequency* around the `CAMERAMODE_INTRO` transition differs — e.g. `bondviewUpdatePlayerCollisionPositionFields()` (three call sites: two in normal/frozen movement ticks, one in the `DEATH_CAM_SP` branch) running an extra or missing time relative to when `g_CameraMode`/`dword_CODE_bss_80079A18` actually flips, which would desync the filter's internal state from what N64's single deterministic tick loop produces even though the per-call math matches. **Next, concrete (single next step, supersedes the N64-comparison item above since that question is now closed):** add one more `#ifdef PORT` probe logging `g_CurrentPlayer->field_488.pos` (the raw, pre-filter position) and `g_CurrentPlayer->field_3B8` (the filter's raw accumulator, pre-`FACTOR_2` scale) every tick for a few seconds either side of the moment `dword_CODE_bss_80079A18` becomes `CAMERAMODE_INTRO` — this directly shows (i) how large the real position jump is at the exact warp tick (confirms or refutes candidate (a)) and (ii) whether `field_3B8` is already desynced from `field_488.pos` *before* the warp even happens, which would point at candidate (b) instead. Do not touch `src/game/*.c` behavior yet (rule #2) — this is still diagnosis; if the eventual root cause is call-ordering in the port's scheduler (`port/src/gesched.c`) rather than game logic, that's a legitimate port-layer fix with no `#ifdef PORT` needed in `src/game` at all.
|
||
|
||
**M-145: independent review pass on the user's new "two Bonds" observation — the user's third-person-world-model hypothesis is confirmed architecturally; the camera bug and the model lifecycle are one mechanism.** User reports that during the Dam abseil, **two instances of Bond are visible at once — one standing on the dam, one floating in the air playing a jump/fall animation; neither animates correctly** — and suspects all the cutscene bugs (D148 umbrella) relate to a bug with Bond's third-person (world) model. Static trace this pass (no build): **(1) The exit script runs on an invisible, model-less "false GUARD" BG chr.** `alloc_false_GUARDdata_to_exec_global_action()` (`src/game/deb_loadallmodels.c:38`, called unconditionally from level setup at `prop.c:2054`; file IS compiled — `SRC_GAME` glob) binds every ailist with ID ≥ 0x1000 to a **zeroed** `ChrRecord` (chrnum 0xFE, model/prop NULL) ticked by `chrlvActionTick` (`chraction.c:9516`). Dam's exit chain — ai_24 (ID 0x1004: Bond auto-walks off the edge via `bond_set_locked_velocity`, falls until `if_bond_y_pos_less_than(0x98bd)` ≈ s16 −24771, fade, `chr_hide_all`, `camera_switch`) → ai_17 (ID 0x0412: the abseil) — runs on such a driver. Consequence: ai_17's `guard_play_animation(0xb100…)` rappel commands act on the **empty** driver record — `check_if_able_to_then_perform_animation` (`chraction.c:10263`) has no model to animate (D122's C1 NULL-deref crash class hit exactly this call on Dam). **(2) The visible Bond is the player's own third-person model, moved by `chr_try_teleporting_to_pad(0xFD, 0x4b01)`** — 0xFD = `CHR_BOND_CINEMA` (`bondconstants.h:4604`, comment: *"only works when bond has a third person model (intro/exit cutscene)"*), resolved by `chrResolveId` (`chraction.c:9792`) to `g_CurrentPlayer->prop->chr`. The teleport handler (`chrai.c:4064`) sets `prop->pos`, `setsuboffset(model, &pos)`, and `field_488.collision_position`/`current_tile_ptr` — but **not** `field_488.pos` and **not** the filter `field_3B8`. **(3) This reconciles M-143's sweep direction.** For `pos2.y` to rise from −22832 (the void, where ai_24's fall ends) up to +691 (dam top), the filter's input `field_488.pos` must be re-seeded to the post-teleport position — but under `CAMERAMODE_POSEND`, `bondviewUpdatePlayerCollisionPositionFields` (`bondview2.c:~4720`) **skips** the `pos ← collision_position` copy *unless `bodyModel == 0`*. The observed sweep therefore implies a window where Bond's third-person model was **removed** (bodyModel==0) after the teleport — tying the camera shake directly to the model lifecycle, exactly the user's intuition. **(4) Nothing in the script hides Bond:** `chr_hide_all` (`chrai.c:4164`) iterates guard slots only. The double image is most plausibly two live Bond models (stale pre-cutscene model still drawn at its fall position playing the fall anim + teleported cinema model standing on the dam with no rappel anim to play) or a stale draw of a removed model (D75 family) — not yet distinguishable statically. **Confidence: MEDIUM-HIGH** that D243's shake and the double image share the third-person-model-lifecycle root (arithmetic consistency with M-143's log + code trace; the bodyModel==0 window is inferred, not directly observed). **Next (supersedes M-144's single step):** one `#ifdef PORT` probe pass logging per tick around cutscene entry: `field_488.pos`, `field_3B8`, `prop->pos`, `bodyModel` (NULL/non-NULL), `g_CameraMode`, `dword_CODE_bss_80079A18`; plus call-trace on `bondviewRemovePlayerBody` and any `bodyModel` reassignment; plus a per-frame count of Bond-model draws during the cutscene to identify the second instance. That single capture discriminates (a) the re-seed window timing, (b) the double-draw source, (c) whether `guard_play_animation` is a PC no-op. No game-logic changes yet (rule #2). Cross-ref D173/D292: all three are now the same "third-person world model transform/lifecycle" family.
|
||
|
||
**M-151 (2026-09-16): the M-145 probe pass is LANDED and build-verified — `GE_D243M=1`, all in `src/game/bondview2.c` (`#ifdef PORT`, D250-cached getenv, zero behaviour change when unset).** Three probes: (a) `D243M: CREATE`/`REMOVE` at the file's only two `bodyModel` assignment sites (`solo_char_load` / `bondviewRemovePlayerBody`), with `g_CameraMode` + `dword_CODE_bss_80079A18` at the moment of the transition; (b) `D243M: tick` once per `playerTick()` call while the third-person model exists OR a cutscene camera mode (`CAMERAMODE_POSEND`/`INTRO`) is active — idx, model pointer, both camera modes, `prop->pos`, `field_488.pos`, and the filter fields `field_3B8` + `field_3C4/8/C`; (c) `D243M: draw` once per on-screen `render_pos` submission — a second Bond instance shows up as two lines (different idx or model pointer) at the same frame number. Frame counter is local to `playerTick()` calls (solo = per-frame), cross-referenced by elapsed offset, same convention as front.c's `GE_D243` trace. Smoke-tested on a bare `-level_33` boot (50 s, 2900+ frames, no crash): the level *intro* cutscene exercises the full lifecycle cleanly — frames 1-533 `cam=1` (INTRO) model=NULL, `CREATE frame=534 cam=3` (SWIRL), one tick+draw pair per frame while the model exists, `REMOVE frame=1127 had_model=1 cam=0` (NONE); FPS play afterwards is silent. Two incidental data points from that capture: during SWIRL `f488pos` tracks `prop` exactly and `f3B8`'s x/z read at exactly 10× the prop values (y differs) — i.e. `field_3B8` is not in the same units as world position, treat its magnitude accordingly when reading the live capture. **Next: the user's interactive Dam run with `GE_D243M=1` (plus `GE_D160=1`, optionally `GE_D243CAM=1`) to the abseil — single capture should discriminate (a) re-seed window timing, (b) double-draw source, (c) whether the model-remove/recreate window M-145 suspects actually occurs on PC.**
|
||
|
||
**M-153 (2026-09-16): the live `GE_D243M` capture (full Dam playthrough → abseil; 351 KB log, 2,991 probe lines, preserved at `scratch/d243/m153_dam_abseil_capture.log`) discriminates M-145's three questions — the double-instance hypothesis is REFUTED, and the model's rendered position turns out to be a period-4 cycle through four fixed world points that diverges from the scripted `prop` sweep; the camera shake is now fully explained mechanically.** **(1) There is no second Bond model.** Exactly one (idx, model-pointer) pair for the entire capture — idx=0, single `bodyModel`, exactly one `draw` line per frame. No REMOVE/CREATE window around the abseil: the model is created at tick 4818 **directly at the post-teleport position** (12.6, −23744, −1330) with `cam=7` POSEND + subcam=1 (INTRO) already set (the D160 `bondviewSetCameraMode(7)` line immediately precedes CREATE), and it persists to the last tick — no REMOVE is logged even through mission-complete/title return (the stage-unload path evidently doesn't route idx 0's body through `bondviewRemovePlayerBody`, or removal happens after the final `playerTick`; minor). M-145's double-image candidates — "two live Bond models" and "stale draw of a removed model via a bodyModel==0 re-seed window" — are both refuted on PC. **(2) Two divergent position streams.** (a) `prop->pos` executes the smooth scripted bungee sweep through the void: y −23859 → +175 (tick ~5000) → −10724 (tick 5910, mission end). (b) The model's *actual rendered position* — `field_488.pos`, which under POSEND+bodyModel≠NULL is written **only** by the on-screen render readback (`bondview2.c:10452`; the `pos ← collision_position` copy at ~4773 is skipped in POSEND, and the other filter re-seed site at 8912 is `mp_respawn_handler`, not this path) — behaves as: tick 4819 = true void position (== prop); ticks 4820–4880 frozen at (y=729.7, z≈−3111); ticks 4881–5041 frozen at (y=229.1, z≈−3106); ticks 5042–5045 a transition burst containing one frame that matches `prop` exactly (y=−12874.7); tick 5046→end: a strict **period-4 cycle through four fixed world points** P1(228.7, −3111) P2(229.2, −3107) P3(79.2, −2512) P4(−1214.8, −2221). The model transform is decoupled from the script in all three axes (by tick 5910 `prop.z`=−1797 while the cycle's z-range is −3111…−2221); it is not terrain-following (the same four values repeat exactly for ~800 ticks while x/z drift). **(3) The camera shake closes mechanically.** `field_3C4/8/C` (= 0.1 × leaky integral of `field_488.pos`) starts at ≈ −22832 — the *exact* `pos2.y` start M-143 observed live — sweeps to +229 over ~180 ticks as the filter converges onto the frozen surface point, then tracks the period-4 cycle. The "violent shake/spazz" = a fixed-eye camera whose look-at anchor (i) starts ~20k units displaced and (ii) teleports between four points at 15 Hz. D146 remains fully decoupled (zero hits, fourth consecutive run). **(4) "Two Bonds" — not present as two instances in this capture.** If the user still saw a double image on this run, the leading candidate is stale `render_pos` submissions lingering in the draw queue while the model teleports between the four cycle points (D75 family): P4 sits ~1,400 units below the dam surface ("floating in the air"), P1/P2 are on it ("standing on the dam"), and each copy would be a frozen snapshot → "neither animates correctly." Not verifiable from this log (the draw probe counts submissions — one per frame — not on-screen images). **Open question for the user: did two Bonds appear in *this* run?** **(5) Cross-ref:** D173 (level-start floating, Cradle spin) and D292 (death sinking) are the same "third-person world model transform" family — the period-4 fixed-point cycle is now a concrete shared-mechanism candidate for all three. **Next:** code dive into where the model's render transform gets its translation during POSENT (`chrTick` → `render_pos`, chraction.c/objecthandler): what state produces the four fixed points (a 4-keyframe anim attached to Bond's model on PC only? a pad table?), and why the smooth `prop` sweep never reaches the model transform after spawn. If the double image is a stale-queue artifact, that is a port/render-layer fix (fast3d draw list) — no game-logic change needed.
|
||
|
||
**M-154 (2026-09-16): user symptom sharpened to "one new frozen Bond per camera shot"; the M-153 log reading is corrected (the rendered model is NOT frozen in phase 3 — `prop->pos` itself cycles); a per-frame model-draw census probe (`D243M: chrdraw`) is implemented and smoke-tested to discriminate the two remaining hypotheses.** **(1) User observation (reproducible every time so far):** during the abseil, one *new frozen* Bond appears with each camera shot — shot 1 standing, shot 2 mid-jump, shot 3 rappelling; the old copies persist and never animate; the camera glitches when the second Bond appears. **(2) Corrected log reading (supersedes M-153's "period-4 cycle through four fixed points").** The POSENT window (frames 4819–5910) has three phases: **P1** (4820–4880): `prop.y` rises −19791→+175 while `f488` stays frozen at (−4.4, 729.7, −3111.6) — the rendered model is decoupled from prop during the ascent; **P2** (4881–5041): both frozen (prop (−4.3, 175.9, −3107.6), f488 (−4.4, 229.1, −3106.7)); **P3** (5042→end): **`prop->pos` itself cycles period-4 through three z-lanes** — A(≈−31xx), A, B(≈−25xx), C(≈−22xx) — while y falls smoothly at ~30 units/frame. `f488` tracks the *same* cycle exactly with per-lane y offsets (A: +229, B: +79, C: −1215 = per-pad model pose), so in P3 the rendered model is **not frozen** — it teleports between three fixed world points every frame, following prop. M-153's "four fixed points" was lane A (two near-identical samples) + B + C. Structural tell: y stays perfectly continuous across lane boundaries while x/z rotate → either one prop whose x/z come from a cycling source (AI-script GOTO loop setting position to different pads one command per frame — cf. the teleport fall-through at chrai.c:4064 where a failed `chrAdjustPosForSpawn` drops into the next command; or keyframe sampling without interpolation), or `pp->prop` rotating among three PropRecords with synchronized y. **(3) Hypotheses for the visible double/triple image:** **H1** multiple Model instances — each shot spawns a frozen Bond, all drawn every frame (the M-145 family, but spawned via a path invisible to the playerTick probe); **H2** single model + framebuffer accumulation — `subcam=1` snapshot buffer never cleared, so the per-frame teleporting model leaves a trail of frozen ghosts at all three lanes; "old copies persist and never animate" fits H2 naturally (ghosts are frozen by construction). M-153's stale-draw-queue candidate is subsumed by H2. **(4) Probe (implemented, smoke-tested).** `D243M: tick` extended with `propptr=`/`chrptr=` (a changing pointer proves prop rotation; constant → cycling x/z source), `rp=` (the render_pos pointer — a change between phases = matrix-buffer reassignment/aliasing), `raw=` (render_pos[0] translation BEFORE the view transform — distinguishes model-matrix cycling from view-matrix cycling). New `D243M: chrdraw` census in `drawjointlist` (both region variants, objecthandler.c): one line per CHR render submission while a scripted camera mode is active (POSEND/INTRO/SWIRL/FADESWIRL), logging Model* + raw root translation. All CHR draws funnel through it (`chrRenderProp`; player path bondview2.c:10883, generic chrprop.c:443). H1 = extra lines with *distinct* model pointers at the same frame; note the same model is drawn twice per frame (two passes) — dedupe by (frame, model). Accessors `d243mProbeActive()`/`d243mGetFrameCounter()` live in bondview2.c (the camera gate is folded into `active()` so objecthandler.c needs no bondview.h include); D250 cached-getenv kept. Smoke: 45 s bare `-level_33` boot → 4,053 chrdraw + 1,066 tick lines, formats verified, silent in FPS play (cam=FP/NONE gate). **(5) Next:** user re-runs Dam→abseil with `GE_D243M=1` (`cmd /c` capture — see HANDOFF); analysis: (a) distinct-Model* count per frame during POSENT → H1 vs H2; (b) propptr/chrptr stability → prop rotation vs cycling source; (c) raw vs f488pos → model-matrix vs view-matrix origin of the cycle. **M-154 fix (same day): the first live abseil run CRASHED inside the new tick probe itself** — access violation, fault addr `0xFFFFFFFFFFFFFFFF`, PC in `playerTick` (bondview2.c:10419), on the very first tick after `D243M: CREATE frame=4109 … cam=7 subcam=1`. Cause: `bodyModel->render_pos` is assigned by `instcalcmatrices()` **during the draw pass** (`model.c:2453`: `arg1->render_pos = arg0->mtxlist`), i.e. *after* the probe point — on the first tick post-CREATE it still holds the reused `AnimModelSlot`'s stale value, observed as the 0xFF tombstone `(RenderPosView*)-1`. The game only ever dereferences `render_pos` post-`chrTick`, so it is legitimately unset at the probe; the M-153-era tick fields never touched it, which is why the earlier capture survived. Fix: guard the `raw=` read against NULL **and** the `-1` tombstone; `rp=%p` is still logged unconditionally, so the exact frame where `render_pos` transitions -1 → real pointer is now captured (itself a D243 data point: the body model's matrix buffer is unset between CREATE and the first draw pass). Probe-only change; no game-code impact.
|
||
|
||
**M-154 fix 2 (second abseil run, same day): the sentinel guard was not enough — the probe crashed again at the identical PC/fault address.** Second capture (244 KB, longer run than run 1) died at `D243M: CREATE … cam=7 subcam=1` with AV fault addr `0xFFFFFFFFFFFFFFFF`, PC `playerTick+0x64c` = bondview2.c:10427 — the same `raw=` read, now *past* the NULL/-1 guard (disassembly confirms the guard compiled correctly: `lea -1(%r10),%rdx; cmp $-3,%rdx; jbe <read block>` passes everything except exactly NULL and -1). Conclusion: between CREATE and the first draw pass, `render_pos` holds **arbitrary stale slot contents**, not a uniform tombstone — run 1 drew -1, run 2 drew some other bad value (the exact fault addr of -1 with the `0x30(%r10)` displacement implies r10 ≈ -48 at fault time; the crash-handler register dump is not fully trustworthy, but the PC/line are). **Sentinel-whack-a-mole is the wrong shape for a probe.** Final fix: the tick probe **never dereferences `render_pos` at all** — `raw=` is dropped from the tick line entirely; `rp=%p` stays (logging a pointer value costs no deref, and the -1 → real transition frame is still captured). No data is lost: the pre-view root translation is already logged by the `D243M: chrdraw` census in `drawjointlist`, where validity is guaranteed because the game's own `gSPSegment(render_pos)` runs moments later — and draw-time is the *more accurate* sample anyway (a tick-time read would return the previous frame's matrix buffer). The chrdraw probe also gained the same -1 rejection as belt-and-braces. Smoke: 30 s bare boot, 4,051 chrdraw lines, no crash. Re-capture pending.
|
||
|
||
**M-159 (2026-09-17): static code-dive names a concrete, evidence-consistent mechanism for the period-4/lane cycling itself — the model's render transform only refreshes on ticks that pass the game's own on-screen visibility test, and during the void sweep most ticks fail it.** Traced `chrTick` (`src/game/chr.c:2442`) end to end for the path that actually recomputes `bodyModel->render_pos`: at `chr.c:2580/2593/2615`, `headSwitchVisible = posIsOnScreen(prop, &prop->pos, getinstsize(model), 1)` gates a large block (`chr.c:2633-2681`) that only runs `subcalcmatrices()` (→ `instcalcmatrices()`, `model.c:2453`, the sole site that assigns `render_pos`) **when `headSwitchVisible` is true**; when false, `render_pos` — and therefore `field_488.pos`, which `playerTick` (`bondview2.c:10493-10497`) reads back from `render_pos` every tick `prop->flags & PROPFLAG_ONSCREEN` is set — is simply not touched that tick, so the model's rendered position freezes wherever it last was. `posIsOnScreen` (`propobj.c:13584`) itself reduces to a same-room check (`getROOMID_isRendered`) plus `camIsPosInScreen`/`camIsPosInScreenBox` (`bondview.c:1090`/`1131`) — a straight point-vs-frustum-plane test against `g_CamFrustum{Near,Left,Right,Top,Bottom}{Normal,Offset}`, which are recomputed once per frame in `bondviewUpdateFrustumPlanes()` (`bondview.c:949`, called from the camera-matrix-build function at `bondview2.c:8401`) purely from `g_CurrentPlayer->viewtoworldmtxf` and the player's FOV-derived half-width/half-height. **This mechanically fits every M-153/M-154 observation:** `prop->pos` genuinely sweeps a huge range through the void during the abseil (y −23859→+175, M-153); most of that sweep is far outside the fixed cutscene camera's frustum (the void is below/behind the dam, off-screen from this shot), so `posIsOnScreen(prop->pos, ...)` is false on nearly every tick, and `render_pos`/`field_488.pos` only update on the rare ticks where the live position happens to cross back inside frustum — producing exactly the "frozen, then teleports to a new fixed point, then frozen again" pattern M-154 documented (P1/P2 static, P3 a small cycling set of points) without needing a second model instance, a stale draw-queue entry, or a cycling prop/pointer at all. **This is decomp-exact game code — `posIsOnScreen`/`camIsPosInScreen`/`bondviewUpdateFrustumPlanes` are ordinary, unmodified `src/game` functions with no ABI/pointer-width surface (no ROM-serialized struct, no union aliasing) — so per M-144 (real N64 does NOT show this shake), the divergence must be in an INPUT to this test differing between platforms, not the test itself.** Two live candidates, neither confirmed yet: **(a)** `g_CurrentPlayer->viewtoworldmtxf` — and hence the whole frustum — is itself built from the filtered look-at chain (`bondviewFrozenCameraTick`'s `gBondViewCutscene` branch → `field_3C4/8/C`, the same leaky-integrator filter M-143 named) that in turn is fed by `field_488.pos`, which this very gate controls — a **feedback loop**: if call/update ordering between `bondviewUpdateFrustumPlanes()`/the camera-matrix build and `chrTick`'s visibility test differs by even one frame or one tick-count between PC and N64 (a scheduler/`port/src/gesched.c` timing question, not game logic — bucket (b) if confirmed), the frustum a given tick tests against would differ, changing which ticks pass `posIsOnScreen` and thus which fixed points get sampled; **(b)** `getinstsize(model)`/`c_halfheight`/`c_halfwidth`-derived frustum margins, or the FOV/aspect the port's `viewtoworldmtxf` construction uses, could differ from N64's in a way that shrinks the effective on-screen box during this specific low/behind-camera shot. **Not yet distinguished; no `src/game` or `port/` code changed this pass — still diagnosis only per rule #2.** **Next, concrete:** an `#ifdef PORT` probe on `posIsOnScreen`'s return value + `prop->pos` + the current frustum offsets, logged every `chrTick` call during POSEND for the abseil window, would directly show whether the pass/fail pattern lines up with the observed freeze/teleport boundaries (confirms the gate is the mechanism) and whether `viewtoworldmtxf`/frustum values are stale relative to `field_488.pos`'s own last-updated tick (tests candidate (a)). If confirmed, the fix is either a `port/`-layer scheduling/ordering correction (no `src/game` touch) or, if N64's own `posIsOnScreen` genuinely also fails most of these ticks but the *filter* happens to converge invisibly fast enough there not to be noticed (i.e. this really is decomp-faithful and only more visible on PC due to unrelated frame-pacing), a case for treating D243 as closed-not-a-bug and folding it into D148's general umbrella instead — that determination needs the probe capture, not more static reading.
|
||
|
||
**M-159 probe (`D243M: onscreen`, `chr.c`) landed and build/smoke-verified:** compiles clean, links, and a 1200+-frame bare `-level_33` boot with `GE_D243M=1` produced 41,165 `D243M: onscreen` lines (plus the existing 1,067 `D243M: tick` lines) with zero crashes/exceptions — confirms the probe fires correctly for every ticked chr (not just Bond; sample shows multiple `chr=` pointers per frame, `vis=` flipping 0/1 per chr's position), format `D243M: onscreen frame=%d chr=%p vis=%d pos=%.1f,%.1f,%.1f`.
|
||
|
||
**M-160 (2026-09-17, same day): the user played a live Dam→abseil capture (`ge007_d243_onscreen_capture.log`, 3.7 MB, 44,728 probe lines) with the new `D243M: onscreen` probe — M-159's frustum-culling hypothesis is REFUTED by the data, and a stronger, more specific mechanism is now directly evidenced: `chrTick` overwrites `prop->pos` with a stale/stepped value every tick regardless of on-screen visibility.** Player-body CREATE for the abseil: `frame=9352 ... cam=7 subcam=1`, `chrptr=00000000701dbebc`. Cross-referencing `D243M: tick` (bondview2.c's `playerTick`, fires BEFORE `chrTick` runs each frame) against the new `D243M: onscreen` (fires INSIDE `chrTick`, at `after_position_update:`, i.e. AFTER `chrTick`'s own position-update logic runs) for the **identical frame numbers**: `tick`'s `prop=` sweeps smoothly (frame 9353 y=-23859.7 → frame 9369 y=-1130.0, matching the AI script's scripted void descent, consistent with M-153/M-154), while `onscreen`'s `pos=` for the same frames is **frozen at exactly (-4.3, 676.5, -3111.6)** for all of frames 9353-9413, then steps to a new frozen value (-4.3, 176.2, -3111.6) at frame 9414 — **and `vis=1` (on-screen/frustum test PASSES) on every single one of these frames, no exceptions.** This directly refutes M-159: the model's render transform is not failing to update because it's off-camera — `posIsOnScreen` is true throughout, `subcalcmatrices`/`instcalcmatrices` run every tick (confirmed by the varying `rp=` render_pos pointer values in the `tick` lines across these frames — different buffer each tick, i.e. actively reallocating/redrawing, not stalled). **The freeze is `prop->pos` itself being overwritten inside `chrTick`, downstream of the AI script's write, before the model transform is computed from it.** `chr.c`'s `after_position_update:` label is reached via one of three branches gated on `chr->actiontype` (`ACT_ANIM`/`ACT_STAND`/else, `chr.c:2578-2616`), each of which calls `chrUpdateAnim`/`modelTickAnim` — ordinary per-chr animation/locomotion logic that has no POSEND/cutscene-mode check anywhere in this function. The player's chr (`chrptr=701dbebc`) is the same chr the CHR_BOND_CINEMA teleports move (M-145) but is otherwise still being ticked as a normal, un-scripted character every frame — its own `chrUpdateAnim`/`modelTickAnim` position write is competing with, and winning against, the AI script's `prop->pos` write for what ends up rendered. `field_488.pos`'s value in the same `tick` lines (frozen at (-4.4, 727.8, -3111.6), ≈7 units off the `onscreen` reading — matches the `mtx[4]*7.0f` view-transform offset in `bondview2.c:10495-10497`) confirms this same frozen post-chrTick value is exactly what later gets read back into the render position, closing the loop from M-153's original observation to this mechanism.
|
||
|
||
**Not yet determined: which of the three `chrTick` branches is taken (needs `chr->actiontype` logged — not in the current probe), and whether N64 hardware exhibits the same overwrite** (if GE's own scripted-cutscene state is supposed to freeze/skip a chr's normal animation-position tick via some flag this port isn't honoring, that's a port-layer or narrow ABI-class fix; if N64 genuinely runs the same competing writers and the visual result there is different for an unrelated platform reason — e.g. the two writes interleave differently under N64's single-threaded deterministic tick vs. this port's own tick/render cadence — that needs establishing before any fix). **Next, concrete:** extend the `D243M: onscreen` probe (or add a sibling) to log `chr->actiontype` and whether the branch actually called `chrUpdateAnim` vs `modelTickAnim` vs neither, for the same window — this directly names which branch is responsible and whether it's gated by anything checkable (e.g. `CHRFLAG_HIDDEN`, `docentreupdown`, or a `g_CameraMode`/POSEND check elsewhere in the call chain that should also cover this path but doesn't). Cross-ref M-145's false-GUARD/CHR_BOND_CINEMA architecture — this is now the concrete mechanism that architecture predicted but didn't pin down.
|
||
|
||
**User confirms (live playtest, this session): the visible cutscene glitch is unchanged** — this probe pass is diagnosis-only (no behavior change, `#ifdef PORT`/env-gated), so an unchanged symptom is expected and does not weigh against the finding above.
|
||
|
||
**M-161 (2026-09-17, same session): decisive experiment — NEGATIVE result. M-160's mechanism, while real, is NOT the (or not the whole) visible cause.** To test M-160 without more diagnosis, added a throwaway `#ifdef PORT` hack in `playerTick` (`bondview2.c:10474`, env-gated `GE_D243X`, off by default): save `prop->pos` immediately before the `chrTick(prop)` call, restore it immediately after, **only** while `g_CameraMode == CAMERAMODE_POSEND` — directly neutralizing the competing write M-160 identified. Compiled clean, linked, 1380-frame bare-boot smoke test with zero crashes, then a full live Dam→abseil playthrough with `GE_D243X=1`. **User's report: "still happened, looked the same as it always does."** No perceptible change to the glitch. Since M-160's own capture already established `g_CameraMode == CAMERAMODE_POSEND` holds throughout the freeze window (same session, `D243M: tick`'s `cam=7` field), there's good reason to trust the experiment's condition was actually satisfied and not silently inert — though this run did not have `GE_D243M=1` set alongside `GE_D243X=1` to directly confirm the branch fired on this specific playthrough (a residual gap, noted for completeness, not chased further given the clear negative result). **Conclusion:** `chrTick`'s competing overwrite of `prop->pos` (M-160) is a real, independently-evidenced bug — worth keeping in `porting-notes.md` §D11 as a documented pattern — but restoring the AI script's position after the overwrite does not fix what the player actually sees, so either (a) the visible glitch's dominant cause is elsewhere in the render/filter chain (M-143's `field_3C4/8/C` leaky-integrator, or something not yet identified), or (b) M-160's mechanism matters but only as one contributor among several, and neutralizing it alone isn't sufficient. Experiment reverted in full (`git diff` clean on `bondview2.c` bar one pre-existing whitespace byte); no lasting code change from this pass.
|
||
|
||
**Status assessment after M-161:** this bug has now had ~15 session-passes (M-107 through M-161) across multiple sessions without a landed fix, each pass narrowing the mechanism further without closing it. Per the user's own call this session (diminishing returns, "going at this without much luck for a while"), **recommend parking D243 under the D148 umbrella** (general cutscene-bug tracking entry, `docs/dev/GRAPHICS-BACKLOG.md`) rather than continuing further open-ended diagnostic passes, unless a specific new lead or live capture surfaces. The mechanism knowledge gained (M-143's filter, M-145's teleport architecture, M-160's competing-writer bug) remains valid and should inform any *future* pass — this is a park, not a reset — but the next session should default to other backlog items unless the user explicitly wants another D243 cycle.
|
||
|
||
**UNPARKED same session: a third-party review found a real methodological flaw in M-161, and the corrected re-test (M-162) produced a genuinely new, decisive result — see below.**
|
||
|
||
**M-162 (2026-09-17, same session, after a second third-party review): M-161's negative result was invalid — the restore was placed AFTER `chrTick()` returned, but `render_pos`/`field_488.pos` are computed INSIDE that same call, from whatever `prop->pos` was at that point. So M-161 never actually prevented the stale value from reaching the render.** Verified directly (not just accepted from the review): compared `D243M: tick`'s pre-`chrTick` z-read against `D243M: onscreen`'s post-`chrTick` z-read for the **identical frame number** across the cycling window (`m160_dam_abseil_onscreen_capture.log`, frames 9600-9611) — the pattern is a clean, deterministic period-4 offset applied *within* the same tick: `-4.0, +543.8, +305.5, -855.2`, repeating exactly. This proves the lane-advance happens **inside** `chrTick`, every tick, unconditionally — the strongest evidence yet for M-160's mechanism.
|
||
|
||
Corrected the experiment: added `d243x2SavedPos`/`d243x2Active` (top-of-function locals, `chr.c:2451-2453`) — save `prop->pos.f[2]` (Z only, since M-160's capture showed X stable and Y the smooth AI-script descent) right before the `chrTick` position-update dispatch (`chr.c:2541-2617`, inside the `CHRFLAG_HIDDEN`-else block), restore it at `after_position_update:` — **before** the `D243M: onscreen` log line and before anything else in the tick can see the cycled value, unlike M-161. Also extended the probe with `actiontype=`/`anim=` (which of `chrTick`'s branches fired, model's current animation pointer) and `x2active=`/`x2rawz=` (whether suppression ran, and the raw cycled Z it overrode) so the result could be read from the log, not just judged by eye — per the reviewer's explicit "bake this in or risk another inconclusive negative" advice. Gated `GE_D243X2`, `#ifdef PORT`. Compiled clean, linked, 37,823-line bare-boot smoke test with zero crashes.
|
||
|
||
**Live playtest result: MIXED — confirms the mechanism, but reveals the fix (as scoped) was incomplete.** User played two full Dam runs with `GE_D243M=1 GE_D243X2=1`; visible glitch looked "about the same" both times. But the log (`scratch/d243/m162_dam_abseil_playtest.log`, second abseil CREATE at frame 11878) shows the suppression **worked exactly as designed this time**: `x2rawz` still shows the same violent period-4 Z-lane cycling (e.g. frames 12100-12130: raw z jumping between ≈-3111, ≈-2500-2570, ≈-1930-2260), but the final rendered `pos.z` is now smooth and monotonic (-2227.6 → -3111.6 → -3107.6 → ... declining ~4 units/frame, no lane jumps at all) — the Z suppression genuinely neutralized the Z-axis cycling this time, unlike M-161.
|
||
|
||
**But `pos.y` is violently cycling instead, completely untouched** (same window: `176.2 → -1174.6 → 175.6 → 176.2` repeating, amplitude ~1350 units, with an early transition spike to -12823.6) — this run's chr logged `actiontype=3` (not logged in the original M-160 capture, so no direct comparison, but evidently a different branch/state than whatever produced M-160's "Y descends smoothly, Z cycles" pattern). **This fully explains the unchanged visible glitch**: the Z-only suppression fixed exactly the axis that wasn't dominant in this particular playthrough. The underlying mechanism (chrTick overwriting `prop->pos` with a cycling value every tick, independent of any external input) is now confirmed on a second axis too — not refuted, just incompletely tested.
|
||
|
||
**Next (M-163, in progress): extend the suppression to the whole `prop->pos` vector** (not just Z) to test whether stopping cycling on every axis finally kills the visible shake. This will also freeze whatever legitimate motion exists (accepted for a decisive test, not viable as a real fix — see the M-162 code comment). If positive: the mechanism is airtight-confirmed, and the real work becomes root-causing *why* this decomp-faithful write path emits cycling values on PC (candidates per M-154/M-160: an attached keyframe animation without interpolation, a pad-table lookup, an actiontype/POSEND gate the port isn't honoring — `chr->actiontype`/`model->anim` are now logged and should narrow this). If the shake persists even with the whole vector pinned: the mechanism, while real, still isn't the (full) visible cause, and D243 goes back to parked with this ruled out more completely than M-161 managed.
|
||
|
||
**Both third-party reviews net assessment:** the first (local Qwen `triage` agent) made one fabricated/confused claim (conflating `g_CameraMode` with a same-numbered `subcam` flag) that I verified and refuted directly against capture data before acting on it — see the now-superseded framing above. The second (unspecified third-party agent) made a data-grounded, directly-verified, and ultimately correct critique of M-161's methodology that materially advanced the investigation. Both were checked against real captures before being trusted, per this project's own standing discipline (`docs/porting-notes.md` §E: verify a cited scratch/log actually contains what a claim says before trusting it) — worth noting as a working example of that discipline paying off in both directions (catching a wrong claim, and correctly accepting a right one).
|
||
|
||
**M-164 (2026-09-17, same session, static-only — no build, while the exe was locked by the user's own playtest): a complete mechanical theory tying M-145/M-154/M-160/M-162 together, naming a specific fix candidate.** M-162's capture logged `actiontype=3` for the visible Bond chr during the cycling — per `bondconstants.h:3945-3962`'s `ACT_TYPE` enum (`ACT_INIT`=0, `ACT_STAND`=1, `ACT_KNEEL`=2, `ACT_ANIM`=3), this is **`ACT_ANIM`**, one of the three branches M-160 traced the overwrite to (`chr.c:2578`: `else if ((chr->actiontype == ACT_ANIM) && ...)`, calling `chrUpdateAnim`). Traced `chrUpdateAnim` itself (`chr.c:2398-2424`): it is not an arbitrary overwrite — it calls `modelTickAnim` (advance the currently-attached animation), `subcalcpos` (recompute joint world transforms from that animation), then `getsuboffset(model, &prop->pos)` (`model.c:684`, `getpartoffset(objinst, objinst->obj->RootNode, offset)`) — i.e. it **faithfully reads the model's root-joint world position back into `prop->pos`**, which is exactly what this function is supposed to do for a normally-animating character. The "overwrite" is `chrUpdateAnim` doing its ordinary job on a chr whose animation state was never told about the cutscene.
|
||
|
||
Cross-referencing M-145's already-established architecture: `AI_TRYTeleportingChrToPad` (`chrai.c:4064-4119`, the `CHR_BOND_CINEMA` mechanism) writes `chr->prop->pos` **and** calls `setsubroty`/`setsuboffset(chr->model, &pos)` (`chrai.c:4105-4106`) — so the model's root-joint anchor genuinely is re-anchored to the new pad position at each teleport, confirmed by direct code read. **But nothing in that teleport touches `chr->act_anim`/`model->anim`.** M-145 already established that the actual rappel/exit `guard_play_animation` commands run on a separate, invisible "false GUARD" driver chr (`alloc_false_GUARDdata_to_exec_global_action`), not this visible one. **Putting these together: the visible Bond chr keeps ticking whatever animation it already had equipped from before the cutscene hijacked its position** (a locomotion/idle loop, going by `ACT_ANIM` + a real `model->anim` pointer being logged) — every tick, `chrUpdateAnim` advances that stale animation and reads its own root-motion cycle back into `prop->pos`, anchored at wherever the last teleport left the root joint. A short-period root-motion track (a walk-in-place or idle-sway loop) read back on top of a fixed anchor produces exactly the observed "period-4 cycling in place" — mechanically explaining M-153/M-154's lane pattern and M-162's Y-axis finding as two faces of the same thing (whichever axis that specific animation's root motion moves on).
|
||
|
||
**This reframes M-162/M-163's suppression experiments correctly: they are not "randomly neutralizing a bug," they are directly testing this exact theory** (block the stale-animation readback from reaching the render, see if the shake stops) — a positive M-163 result would be strong additional confirmation, not merely "the write stops," of this complete mechanism. **The real fix candidate this points to (not yet attempted, needs its own investigation pass):** find where/whether the cutscene AI script is supposed to change `chr->actiontype`/`chr->act_anim` (or otherwise silence `chrUpdateAnim`'s position write) for the *visible* chr when `AI_TRYTeleportingChrToPad` fires — the decomp symbol for `guard_play_animation` referenced in M-145 was not located this pass (grepped, no match for that exact name — likely a different actual symbol in `bondaicommands.c`/`aicommands2.c`; the AI command name is probably decoded/human-friendly, not literal). If N64 has a code path that either (a) redirects that animation command to the *visible* chr instead of the false-GUARD driver, or (b) sets `noTranslate`/freezes root motion on the visible chr during POSEND, and PC is silently skipping it — that is the actual bug, likely bucket (a)/(b) per the diagnosis framework, not a game-logic difference. **Next:** locate the real `guard_play_animation`/animation-assignment AI command handler and trace whether it's meant to target the chr that `AI_TRYTeleportingChrToPad` just moved.
|
||
|
||
**M-165 (2026-09-17, same session, static-only, continuing while the exe was locked): found the animation-command handler M-164 flagged as unlocated — it confirms the "wrong chr" targeting is a structural, likely decomp-faithful property of this AI-script design, which reframes where the real PC/N64 divergence must be.** `check_if_able_to_then_perform_animation` (`chraction.c:10263`, called from both `chrai.c:1084` and `aicommands.def:750`) is passed `ChrEntityp` — the AI list's own driver chr — as its `self`/target, not a resolved `CHR_NUM` lookup. Per M-145, the abseil's AI list runs on the invisible false-GUARD driver (`alloc_false_GUARDdata_to_exec_global_action`), so this command (and by extension `guard_play_animation`) **structurally can never reach the visible Bond chr `AI_TRYTeleportingChrToPad` teleports** — this is how the script is written, and since both are byte-identical decomp code, this mismatch is almost certainly present on N64 too, not a PC-only defect. Also checked whether any game-code path gates the visible chr's own `chrTick` animation dispatch during a scripted camera mode: grepped `chr.c` for `CAMERAMODE_POSEND`/`CAMERAMODE_INTRO` (zero hits — `chr.c` has no camera-mode awareness at all) and checked `PLAYERFLAG_NOCONTROL` (set by `AI_BondDisableControl`, `chrai.c:4040`) — it only drives HUD/UI display (`bondviewSetUpperTextDisplayFlag`, `hudmsgsSetOn`, `countdownTimerSetVisible`), never referenced in `chr.c`/`chraction.c`'s position-update path. **There is no game-code mechanism anywhere that suppresses the visible chr's ordinary locomotion-animation tick during a cutscene** — `chrTick` runs its normal dispatch on the player's chr throughout POSEND regardless.
|
||
|
||
**This reframes the open question, honestly: if the "wrong-chr animation targeting" and "no cutscene suppression" are both decomp-faithful (high confidence, direct code reads, not conjecture), then the mechanism itself may be completely harmless on N64 — simply because N64's chr happens to have a near-static animation attached (e.g. an idle stand pose with negligible root motion) at the exact moment the cutscene grabs it, while PC's chr has ended up with a different, more mobile animation attached due to some earlier, unrelated divergence in animation-state history** (input timing, dt/tick-count differences, or a genuinely separate bug upstream of the cutscene entirely) — not a bug in the cutscene/teleport code itself. This is a real, open possibility that M-160 through M-164's investigation has not yet ruled in or out, and would require either (a) knowing what animation SHOULD be attached at this moment (checking `ai_24`'s auto-walk-off-the-edge trigger, `chrai.c` — does it explicitly set a specific animation via `chrUpdateAnim`/`modelSetAnimation` as Bond is walked off the ledge, making it deterministic regardless of platform?), or (b) a live capture logging the actual animation ID (not just the `model->anim` pointer, which is opaque without a lookup) attached at the moment `g_CameraMode` flips to `POSEND`, cross-referenced against the animation-table index the same way `GE_D193A`'s `aidx=` field already does elsewhere in this codebase (`docs/dev/GE-ENV-PROBES.md`, `GE_D193A` row) — reusing that established technique here (reverse-resolve `model->anim` against `animation_table_ptrs1`) would let a future capture name the exact animation, not just its address, and settle whether it's a stand-still pose or something with real root motion.
|
||
|
||
**Status: genuinely open, not resolved by static reading alone.** This does not change M-162/M-163's experimental value (they still directly test whether suppressing the readback stops the visible symptom) but does mean a **positive M-163 result would not, by itself, prove this is a PC bug** — it would prove the mechanism, which M-164/M-165 have now shown could plausibly be decomp-faithful and only different in its *practical effect* (which animation happens to be attached) rather than its *code path*. Whoever picks this up next should treat M-163's live result as confirming/refuting the *mechanism*, and treat "is this actually a PC-vs-N64 divergence" as a still-separate, still-open question needing either N64 reference footage of this exact moment or the animation-ID capture described above.
|
||
|
||
**M-163 live result (2026-09-17, live playtest): NEGATIVE — camera shake unchanged, but the reason why is itself the breakthrough (see M-167).** User played the full-vector-suppression build live: "Camera still freaking out." Verified from the log (`ge007_d243_m163_playtest.log`, second abseil CREATE at frame 5175, `chrptr=701dbebc`) that the suppression genuinely worked at the `prop->pos` level this time — `D243M: onscreen`'s `pos=` field is now smooth and monotonic across the whole cycling window (e.g. frames 5400-5430: y climbs steadily -3390.0 → 61.3, z steadily -3107.6 → -3043.6, no lane jumps), while `x2raw=` in the same lines still shows the same violent period-4 cycling underneath (confirming `chrTick` still tries to write it every tick — the suppression is genuinely neutralizing it, not just failing to engage). **But `field_488.pos` — a DIFFERENT variable, logged in the same-frame `D243M: tick` lines — kept cycling exactly as before** (`228.8, 229.3, 79.3, -1214.7` repeating, frames 5403-5420), completely unaffected by the `prop->pos` suppression. This is the real finding: `field_488.pos` (`bondview2.c:10495-10497`, written from `render_pos` via a view-transform) and `prop->pos` (written inside `chrTick`) are **two independent data paths that both happened to look like they were "the same overwrite" through M-160's original lens, but are not** — `render_pos` is computed by `subcalcmatrices`/`instcalcmatrices` from the model's own animated skeleton state (advanced by `chrUpdateAnim`'s internal `modelTickAnim`/`subcalcpos` calls, upstream of and independent from the `prop->pos` write `getsuboffset` performs at the end of that same function). M-162/M-163 spent two live-playtest cycles smoothing the wrong copy — a real, useful, but ultimately inconclusive-for-the-visible-symptom result, corrected immediately by M-167 below.
|
||
|
||
**M-167 (2026-09-17, same session, live playtest): POSITIVE — the camera shake is FIXED.** Retargeted the suppression at the actual variable the camera's look-at filter reads: `field_488.pos`'s one write site under POSEND (`bondview2.c:10493-10497`, `if (prop->flags & PROPFLAG_ONSCREEN)`). New experiment, `GE_D243X3` env-gated, `#ifdef PORT`: skip the `field_488.pos.x/y/z` assignment entirely while `g_CameraMode == CAMERAMODE_POSEND`, freezing it at whatever value it held the instant suppression engaged (added a `D243M: f488write` probe line alongside, logging `x3active=`/the resulting `f488pos=` so the result is log-verifiable, not just by-eye). Compiled clean, linked, 39,594-line bare-boot smoke test with zero crashes, `x3active=0` correctly outside POSEND. **Live playtest: user confirms — "yep camera shake is gone now."** Verified from the log (`ge007_d243_m167_playtest.log`): `field_488.pos` is perfectly frozen once suppression engages (e.g. `-13.4,-23744.6,-1416.0` held identical across at least 5 consecutive logged frames, 1093 total `x3active=1` lines) — the freeze held throughout the capture, matching the user's live observation. **This is the first positive result in the entire D243 investigation (M-107 through M-167) and directly confirms the mechanism this session's investigation converged on**: the camera-shake symptom is driven by `field_488.pos` cycling through the model's animated-skeleton readback, fed through the `field_3B8`/`field_3C4-8-C` leaky-integrator filter (M-143) into the cutscene's look-at target.
|
||
|
||
**User also reports, same playtest: "Bond actor still glitching/duped at least."** Expected and unsurprising — this experiment only freezes the *camera's copy* of the position (`field_488.pos`); it does not touch `render_pos` itself or the actual per-frame model-draw call (`drawjointlist`/`chrRenderProp`), so Bond's own rendered body still visibly reflects whatever the cycling skeleton state produces. **This is the same "two Bonds"/duplicate-image symptom M-145 named and M-153 later refuted as a literal second model instance** — with M-167's finding, the far more likely explanation is now: a single model, but its `render_pos`-driven draw position cycles every frame (same underlying skeleton-animation mechanism, just observed directly in the draw pass instead of through the camera filter), which at speed reads visually as "duplicated"/"glitching" even though `chrdraw` census data (M-153) already showed only one draw call per frame. **Not yet fixed or further isolated this pass** — next step, if continuing: apply the same kind of targeted freeze to whatever reads `render_pos` for the actual draw submission (`drawjointlist`, `objecthandler.c`) and see whether the model-duplication artifact also stops, which would confirm the two symptoms (camera shake, actor duplication) share one root mechanism differing only in which downstream consumer of the cycling skeleton state you're looking at.
|
||
|
||
**M-168 (2026-09-17, same session): three attempts to extend the freeze technique to Bond's own rendered body (the "duplicated/glitching actor" symptom M-167 didn't fix) — two crashed live, the third holds.** Applying M-167's technique to `render_pos` (the actual draw-path consumer, read by `objecthandler.c`'s `drawjointlist` via `gSPSegment`) seemed structurally analogous, but wasn't: `render_pos` is a pointer into a per-frame dynamically-allocated arena, reassigned every tick by `subcalcmatrices`/`instcalcmatrices` (`model.c:2453`), not a fixed field like `field_488.pos`.
|
||
|
||
*Attempt 1* (a local-delegate-reviewed suggestion, initially promising): skip the `subcalcmatrices()` call entirely, on the reasoning that leaving `render_pos` untouched reuses last tick's valid buffer. Guarded by checking `render_pos != NULL && != (RenderPosView*)-1` before deciding it was safe to skip. **Crashed on the very first smoke test** — fault addr exactly `-1`, at the identical class of bug M-154 already catalogued: a freshly-`CREATE`d model's `render_pos` starts as whatever garbage an AnimModelSlot's reused memory holds, not necessarily the clean `-1` sentinel or `NULL` — inspecting the *value* can never reliably distinguish "already validated" from "fresh tombstone that happens to look plausible."
|
||
|
||
*Attempt 2*: replaced the value-inspection with tracking our own history — skip only once *this code* had already run `subcalcmatrices` for this exact `Model*` pointer at least once (`s_d243x4Model` static, compared by identity). Smoke-tested clean (never reached a second model creation in the short bare-boot window) — **but crashed on the live playtest**, at the exact abseil transition. Symbolized the crash properly this time (`addr2line` against the `RelWithDebInfo` build): `chr.c:2822` (`sub_GAME_7F06C768`) → `model.c:459` (`return -mtx->m[3][2]`) → `getsubmatrix`/`modelFindNodeMtx` (`model.c:403-411`, `&model->render_pos[index].pos`) — a completely different consumer of `render_pos` than the draw path, one that runs unconditionally every tick regardless of my skip logic, to compute the model's z-sort depth. Root cause: **model POINTERS get reused by the allocator across genuinely different logical model instances** — the abseil's model happened to land at the same address a previously-removed model had occupied, so `s_d243x4Model`'s identity check wrongly concluded "already validated" for a brand-new instance whose `render_pos` was still the fresh, uninitialized `-1` tombstone. Skipping `subcalcmatrices` left it that way; `sub_GAME_7F06C768` dereferenced it and crashed at fault address exactly `-1` again — same failure class as attempt 1, reached through a different code path, because **there is no way to tell "is this the same logical instance" from a pointer value or from `render_pos`'s own content** — this is the same sentinel-guessing trap M-154 already named, encountered a second time from a different angle.
|
||
|
||
*Attempt 3* (current): stopped trying to skip `subcalcmatrices()` at all — it now **always** runs, unconditionally, guaranteeing `render_pos` is never left invalid by this experiment regardless of model lifecycle/memory reuse. The freeze is purely a *contents* overwrite applied after: cache the buffer for the first 2 ticks after the `GE_D243X4` gate engages (letting the pose settle past the transition moment), then copy that cached snapshot back over each subsequent tick's freshly (and validly) computed buffer. The active→inactive→active edge resets the warm-up counter so a later, different model under the same gate re-warms instead of inheriting a stale snapshot. Compiled clean, linked, a 2700+-frame bare-boot smoke test with zero crashes.
|
||
|
||
**Live playtest (attempt 3): NO CRASH — stable this time.** Full session, 7681+ frames, clean window close, abseil model created and ticked normally (`D243M: CREATE frame=4027 ... cam=7 subcam=1`). Structural fix (always-run + contents-only freeze) holds under real gameplay, unlike attempts 1/2.
|
||
|
||
**But the result is informative-negative, not a win — freezing unconditionally breaks legitimate repositioning, not just the bug.** User's report: no more shake (M-167 still holds, X3 unaffected by this pass), but (a) the camera angle now looks down/wrong for parts of the cutscene, and (b) Bond's own model still appears stuck/duplicated. **This makes sense and is a real, useful finding**: the abseil cutscene is not one continuous shot — `AI_TRYTeleportingChrToPad` (M-145/M-164) re-teleports Bond to a new pad for each of several shots, and each of those teleports is a *legitimate* position change the camera/render SHOULD track. `GE_D243X3`/`GE_D243X4` freeze `field_488.pos`/`render_pos` at whatever they held **the instant the gate first engaged** and never update again for the rest of POSEND — correctly killing the illegitimate *intra-shot* cycling (M-160's mechanism), but also incorrectly suppressing the legitimate *inter-shot* repositioning, so later shots render/frame from a stale, too-early position. This explains both symptoms without needing a new hypothesis: "camera looks down/wrong angle" = frozen at an early shot's framing; "Bond stuck in place" = same freeze applied to the model's own draw position.
|
||
|
||
**This sharpens what a real fix needs to do, distinct from what these two blunt experiments do:** distinguish the two kinds of position change — legitimate per-shot teleports (should update `field_488.pos`/`render_pos`) from the illegitimate per-tick cycling within a shot (should not) — rather than freezing everything indiscriminately. Concrete next-step candidates, neither attempted yet: (a) re-baseline the freeze on a large-delta threshold (a jump far bigger than the observed intra-shot cycling amplitude is very likely a real teleport, not cycling — M-153/M-160's data already characterizes the cycling amplitude, so a threshold is derivable from existing captures), or (b) hook the freeze/re-baseline directly to `AI_TRYTeleportingChrToPad` firing (`chrai.c:4064`), which is the actual, precise signal for "a legitimate reposition just happened" rather than inferring it from position deltas.
|
||
|
||
**Generalizable lesson (added to `porting-notes.md` alongside the existing D11 material):** freezing a *value* (M-167's `field_488.pos`, a plain field) and freezing a *pointer target* (M-168's `render_pos`, a per-tick-reallocated buffer) are not the same kind of operation — the latter requires either (a) never skipping the call that keeps the pointer valid and only touching contents afterward, or (b) extremely careful instance-identity tracking that accounts for allocator address reuse, which is easy to get wrong twice (as this session did) before landing on (a).
|
||
|
||
**Neither M-163 nor M-167 is a real fix — both are diagnostic experiments** (`GE_D243X2`/`GE_D243X3`, off by default, `#ifdef PORT`) that unconditionally freeze state rather than correcting whatever makes the model's skeleton animation cycle in the first place. Per M-164/M-165's still-open question: it is not yet established whether the underlying skeleton-cycling is itself decomp-faithful (with the real PC/N64 divergence being *which* animation/phase Bond inherits at the cutscene boundary) or a genuine PC-only bug in how the animation advances. **A real fix requires answering that question first** (the `aidx=`/`animframe=` fields from M-166 are already wired into the `D243M: onscreen` probe for this) before deciding whether the correct fix is (a) a port-layer/timing correction, (b) an ABI/layout-class fix under the narrow exception, or (c) a rule-2 sign-off request for a genuine behavioral difference. `GE_D243X2`/`GE_D243X3` should stay in the tree as diagnostic tools (already documented in `GE-ENV-PROBES.md`), not be mistaken for the fix, until that root cause is named.
|
||
|
||
**M-166 (2026-09-17, same session, while the exe stayed locked by the user's own playtest — local Qwen `triage` delegate dispatched to answer M-165's open question, findings spot-checked directly): the visible chr's animation *identity* during the level-exit walk-off is script-forced, but its *phase* is not — narrowing M-165's open question to a specific, testable claim.** Traced the Dam exit AI script directly (`assets/obseg/setup/UsetupdamZ.c`): `ai_24[]` (`:2622+`) — after `hud_hide_and_lock_controls_and_pause_mission_time`/`bond_equip_item`, calls `bond_set_locked_velocity(0x00, 0x04)` then loops on `if_bond_y_pos_less_than(0x98bd, ...)`/`if_local_timer_greater_than` until the fall completes, then `chr_hide_all` and on into the exit chain — **confirmed no animation-set command anywhere in this list** (spot-checked directly; the delegate's report cited slightly wrong hex parameters for `bond_set_locked_velocity`, `(0x0f,0xff)` vs the real `(0x00,0x04)`, a minor transcription error, but the structural claim — no animation command present — checked out on direct read). `AI_BondSetLockedVelocity` (`chrai.c:4259-4271` per the delegate, not independently re-verified this pass) only writes `g_ForceBondMoveOffset`; consumed by `bondviewFrozenMoveBond` (`bondview2.c:7868`+), which the delegate traced as calling `bondviewMoveAnimationTick(0,0,0)` → `bheadAdjustAnimation(0)` every frozen-walk frame. **Spot-checked:** `bheadAdjustAnimation` exists at `bondhead.c:396`, and `PTR_ANIM_bond_eye_walk` is a real symbol at `bondhead.c:21` (part of a breathing/head-animation table, `{PTR_ANIM_bond_eye_walk, 9.5f, 27.0f, 0.0f, 0.0f, 1.5f}`) — both confirmed to exist as cited. Per the delegate's (unverified-past-this-point) trace: at zero speed this function re-attaches `PTR_ANIM_bond_eye_walk` to the head model if it wasn't already the active animation, but computes the re-attach **startframe by phase-interpolating from whatever frame the previous animation was on** (`bondhead.c:411-416`) — i.e. the animation *identity* Bond ends up with during the fall is deterministic (always the idle/walk loop), but the exact **frame/phase** within that loop depends on real-time elapsed (`g_GlobalTimerDelta`-driven fall duration) and whatever animation state existed the instant `ai_24` grabbed control — neither guaranteed identical between N64 and PC.
|
||
|
||
**This sharpens M-165's open question into something a single targeted capture can answer.** If the visible shake tracks with animation *phase* drift (a few-frame difference in where the idle/walk loop happens to be when POSEND engages), that would explain a PC-specific divergence entirely upstream of the cutscene/teleport code, consistent with everything else being decomp-faithful. **Concrete next step, ready for the next live session:** extend the `D243M: onscreen` probe (`chr.c`) to log the animation's current frame/phase, not just the `model->anim` pointer — reuse the reverse-resolve-against-`animation_table_ptrs1` technique `GE_D193A`'s `aidx=` field already established elsewhere in this codebase (`docs/dev/GE-ENV-PROBES.md`, `GE_D193A` row) so the capture names an actual frame number, not an opaque address. This was not implemented this pass (probe-design work only, no code changed for this specific addition) — left for the next build/probe cycle alongside M-163's own live re-test.
|
||
|
||
**Status (from §F table):** **OPEN — M-109 re-analysis of the same session's raw logs corrects the timing/pattern claims above; the D134/pendingGfx race hypothesis is likely wrong.** Confidence MEDIUM on the correction, LOW-MEDIUM on the new lead. Re-reading `data/playtest_d160.log`/`_run2.log` at the `g_framesRendered` level (not just grepping for `D146`) shows the D146 burst fires **~1600-3900 frames (tens of real seconds) *after*** `bossReturnTitleStage`/the `camera_switch(7)` calls, not during them, in **both** runs — "right in the camera-cut window" and "run 1 only" are both wrong as stated above. This also weakens the D134/`pendingGfx` citation: `osSpTaskStartGo` runs `gfx_run()` synchronously and inline (`port/src/libultra.c:1232-1258`), so nothing from the cutscene's own DL submission is still in flight by the time any teardown code runs — the port doesn't have the async-RSP race the hypothesis above assumes. **New lead:** whatever screen renders in that multi-thousand-frame gap after mission-complete is the more likely site — probably `MENU_MISSION_COMPLETE` (`front.c:7267`, `constructor_menu0D_missioncomplete`) or an adjacent briefing/file-select screen in that chain, same general class as D75 (front-end model absence) / D141 (menu bad-pointer bug), not a cutscene-render race. Of the 4 repeating fault addresses, 3 read as literal zero words and only 1 matches the `0xfafbfbfc…` pattern, arguing against a deliberate poison-fill (none found anywhere in `port/`) and for either genuinely-unwritten memory or a decode landing inside unrelated valid data. Next: an env-gated `front.c` menu-transition trace (`MENU_*` id + `g_framesRendered` on every transition after `bossReturnTitleStage`) plus a `GE_PCDUMP` window over frames ~4500-8000 (not the camera-cut window) to name the exact screen/segment. Cross-ref D160 (this supersedes its stale "cutscene skipped" framing with a "cutscene renders corrupted" one, now further corrected to "corruption isn't in the cutscene render at all") and D173 (Cradle's Bond-floating/spinning cutscene bug — same subsystem family, unconfirmed shared root cause). **M-120: the `GE_D243` trace above was BUILT (statically, i.e. written) on branch `investigate/d243-cutscene-race` (`764a1f26`, pushed, not merged) — `src/game/front.c` only, `#ifdef PORT`, logs the target `MENU_*` name + a local per-tick counter on every `menu_init()` state transition. NOT build-verified: two independent sessions (the dispatched subagent, then this session re-verifying in a fresh worktree) both hit the same reproducible failure — `cmake`'s C-compiler ABI check fails with "Cannot create temporary file in C:\Windows\: Permission denied" when gcc is invoked *through* CMake/ninja, even though a bare `gcc -c` invocation from the same shell, same session, succeeds and correctly resolves its temp dir to `%TEMP%`. This is a genuine, reproducible environment issue distinct from the diff's content (static review of the diff found nothing wrong: enum cases all verified to exist in `bondconstants.h`, brace/`#ifdef` balance checked, follows the existing `GE_D160`/`GE_D178` env-gated trace idiom used elsewhere in the same file) — worth a session with a working toolchain investigating the CMake/ninja-vs-bare-gcc environment discrepancy itself as its own item, separate from D243. Next: build `investigate/d243-cutscene-race` on a session where `./build-pc.sh` actually completes, then run the `-level_33` Dam repro with `GE_D243=1` (and the existing `GE_D160`/`GE_PCDUMP` mechanisms) to correlate the trace against the D146 burst's frame range. **M-141: the M-120 toolchain blocker is EXPLAINED and worked around, GE_D243 is now build-verified.** The "Cannot create temporary file in C:\Windows\" failure isn't specific to CMake/ninja vs. bare `gcc -c` — it's this Claude Code session's `Bash` tool shell (Git-for-Windows Bash, not a real MSYS2 MINGW64 window) not exposing a usable `TMP`/`TEMP` to child processes the way an interactive MSYS2 shell does; a bare `gcc -c` from the same shell happened to not hit the temp-file path M-120 tested, which is why it looked clean while the full build wasn't. Fix: explicitly `export TMP=... TEMP=...` (Windows-style path) alongside the already-known `PATH=/c/msys64/mingw64/bin:/c/msys64/usr/bin:$PATH` fix, both before invoking `./build-pc.sh`/`ninja`/`cmake` — now also recorded in `porting-notes.md` §E as a general session-start gotcha, not just a D243-specific note. Cherry-picked the `GE_D243` trace commit (`764a1f26`) cleanly onto `release/v0.3.0` (branch `investigate/d243-fresh`), fixed one thing the original diff got wrong per this project's own D250 rule (`menu_init()` runs ~once/frame during front-end states — the diff's three `getenv("GE_D243")` calls were uncached; now a single cached `static int s_d243` check), and confirmed it builds clean. **Smoke-tested, not fully exercised:** a bare `-level_33` boot runs 1100+ frames with zero crashes and (correctly) zero `D243:` log lines — bare `-level_XX` skips the front end entirely (per this doc's own M-24-era note), so `menu_init()` never runs; this only proves the probe doesn't regress normal boot, not that it captures anything useful yet. **Still blocked on the same thing M-107/M-109/M-120 all were: an actual interactive playthrough** (main menu → Dam → abseil cutscene → mission-complete → whatever screen the D146 burst fires on, ~1600-3900 frames later) with both `GE_D243=1` and `GE_D160=1` set, which needs a human with input, not available in this session. **Possible connection to D173 worth checking in that same playthrough** (per user, M-141): D173 (floating/spinning Bond puppet in cutscenes, e.g. Cradle/Trevelyan) and D243 (stale-memory DL read some seconds after a cutscene-driven `bossReturnTitleStage`) both sit in the scripted-cutscene/level-transition neighborhood and both ultimately touch "Bond's (or a puppet's) in-world model" — no code-level link found this session (D173's own M-106 pass already ruled out the two most likely struct-aliasing sites, and D243's mechanism looks like a stale/freed buffer read in a *different* subsystem, front-end/menu memory rather than a chr/puppet transform), so treat this as an unconfirmed hypothesis to keep in mind while capturing the next live trace, not an established shared root cause. **Steam Deck playtest confirmation, same M-141 session** — user independently reported "cutscene at end of dam level is badly glitched" without prompting, matching this row's own "camera violently shakes" symptom exactly. Second independent confirmation this is real and reproducible, not a one-off; still needs the interactive `GE_D243=1`+`GE_D160=1` capture this row already calls for.
|
||
|
||
---
|
||
|
||
## D243 — CONSOLIDATED NEXT STEPS (as of M-168, 2026-09-17)
|
||
|
||
This entry is long and multi-pass (M-107 through M-168). This block is the single place to read for "what's actually known and what to do next" — everything else above is the reasoning trail that got here, kept for provenance, not required reading to continue the work.
|
||
|
||
### What's established (high confidence, code-verified)
|
||
|
||
1. **Two independent render-consumer paths exist for "where is this chr's third-person body rendered," and they can diverge.** `prop->pos` (game-logic position, written by AI scripts) is NOT what gets rendered. What gets rendered is `render_pos` (the model's animated-skeleton transform, recomputed every tick by `subcalcmatrices`/`instcalcmatrices`, `model.c:2453`) and `field_488.pos` (derived from `render_pos`, read by the cutscene camera's look-at filter, `bondview2.c:10495`). A probe or fix that only touches `prop->pos` will not affect what's on screen — this cost two live-playtest cycles to learn (M-162/M-163 → M-167).
|
||
2. **The camera shake's mechanism:** `chrTick`'s normal per-chr animation-locomotion dispatch (`chr.c:2541-2617`, the `ACT_PATROL`/`ACT_ANIM`/`ACT_STAND`/else branches) has no cutscene-mode awareness and keeps running throughout the scripted sequence. `chrUpdateAnim` (`chr.c:2398`) advances whatever animation is currently attached and reads the model's resulting root-joint position back into the render path (`getsuboffset`, `model.c:684`) every tick — a small, repeating root-motion cycle from that stale animation is what shows up as "shaking."
|
||
3. **Why the animation is stale:** the cutscene's `AI_TRYTeleportingChrToPad` (`chrai.c:4064`) correctly moves the *visible* Bond chr's position between shots, but the actual rappel animation commands (`guard_play_animation`) run on a separate, invisible driver chr (M-145) — the visible chr is never told to play the cutscene animation, so it just keeps ticking whichever animation it had equipped before the cutscene grabbed control.
|
||
4. **Camera shake: diagnostically FIXED, live-confirmed.** Freezing `field_488.pos` during POSEND (`GE_D243X3`, `bondview2.c:10495`) stops the shake completely — user-confirmed. Not shippable as-is (see below).
|
||
5. **Bond's model duplication: diagnostically addressed but incomplete.** The same freeze technique applied to `render_pos` (`GE_D243X4`, `chr.c`) is now crash-free (two real crashes fixed — see `porting-notes.md`'s two D11 updates for the postmortem: model pointers get reused by the allocator across different logical model instances, so any validity check based on a pointer's value or cached identity can be fooled) but **freezing unconditionally also suppresses the cutscene's legitimate per-shot repositioning** (each `AI_TRYTeleportingChrToPad` call is a real, intentional camera/model cut to a new shot) — so the camera now looks the wrong direction for later shots and Bond still looks stuck.
|
||
6. **Whether the root mechanism (item 2/3) is itself decomp-faithful is still OPEN.** `ai_24` (the level-exit trigger) contains no animation-set command for Bond (verified directly against `assets/obseg/setup/UsetupdamZ.c`); `bondviewFrozenMoveBond`'s own tick forces Bond's animation *identity* back to an idle/walk loop deterministically, but the *phase* within that loop is inherited from real-time-dependent prior gameplay, not reset to a fixed value — this phase is the most likely PC/N64 divergence point, not yet confirmed.
|
||
|
||
### What to actually try next
|
||
|
||
- **(a) Delta-threshold re-baseline.** NOT attempted, superseded by (b) below — no longer needed as the primary mechanism, though could still be a fallback if (b)'s single-teleported-chr assumption ever breaks.
|
||
- **(b) Hook the real signal directly — IMPLEMENTED this session (M-169), untested live.** Re-baseline the freeze exactly when `AI_TRYTeleportingChrToPad` (`chrai.c:4064`) fires for this chr, via a new `d243NotifyTeleport()`/`d243GetTeleportEpoch()` pair (`bondview2.c`) called from `chrai.c` right after its real `setsuboffset()` call. `GE_D243X3`/`GE_D243X4` both now consume it. Build/link/crash-safety verified headlessly; needs a live Dam→abseil playtest to confirm it actually fixes the visible symptom (see M-169 below for the exact next capture to run).
|
||
- **(c) Find the actual root-cause divergence (item 6 above) — RESOLVED by static analysis this session (M-169), no live capture needed.** The root mechanism (items 2/3) is unmodified decomp code with no cutscene-mode gate on either platform, so it's decomp-faithful by construction; the actual PC/N64 divergence is narrower (animation *phase* at the cutscene-entry instant, inherently player-input-history-dependent on both platforms, not a meaningful frame-exact N64-match target) and doesn't block shipping a bucket-(b) port-layer fix like (b) above. Full reasoning in the M-169 entry below.
|
||
|
||
### M-169 (2026-09-17, this session): static determination on item 6 (root-mechanism decomp-faithfulness) + next-step (b) implemented, untested live
|
||
|
||
**Item 6 resolved by static analysis, no live capture needed.** Items 2/3 above already established, code-verified, that `chrTick`'s per-chr animation-locomotion dispatch (`chr.c:2541-2617`) "has no cutscene-mode awareness" — it is **unmodified decomp code, no `#ifdef PORT` anywhere in it**. Since the decomp is byte-matched to the N64 ROM, the same dispatch — with no cutscene gate — runs identically on real N64 hardware given the same character/animation state. **This makes the root render_pos-cycling mechanism (items 2/3) itself decomp-faithful by construction**: N64 has no special-casing here either, so this is not a PC-introduced code bug in the mechanism itself. The likely PC/N64 divergence is narrower than "the mechanism runs at all" — it's *which* animation phase the visible chr happens to be on the instant the cutscene's `AI_TRYTeleportingChrToPad` grabs it, which is downstream of real-time player-input history before the trigger (`bheadAdjustAnimation`, `bondhead.c:396` — also unmodified decomp, phase-interpolates across the two head-animation sets from `g_CurrentPlayer->field_5C0`, driven purely by movement `speed`). That phase is inherently non-deterministic input-history state on **either** platform — matching a specific N64 reference frame-for-frame isn't a meaningful target — so per rule #2's taxonomy this settles in bucket (b): a port-layer fix built *on top of* the decomp-verbatim mechanism, not a rule-2-sign-off-worthy behavioral change to it.
|
||
|
||
**Implemented next-step (b) from the list above** (the "hook the real signal directly" option, not (a)'s threshold guess): `chrai.c`'s `AI_TRYTeleportingChrToPad` case (`chrai.c:4064-4119`) already contains the real signal for "a legitimate shot-change just happened" — its own `setsuboffset(chr->model, &pos)` call (line 4106, unmodified decomp), which re-anchors the model's root joint for the new pad position. Added a one-line `#ifdef PORT` notify call right after it (`d243NotifyTeleport()`, new function in `bondview2.c` next to the other `d243mXxx` accessors) that bumps a monotonic epoch counter (`d243GetTeleportEpoch()`). `GE_D243X3` (`bondview2.c`, `field_488.pos` freeze) and `GE_D243X4` (`chr.c`, `render_pos` freeze) both now re-baseline (skip the freeze, let the real write through) exactly on the tick the epoch changes since their last check, instead of freezing forever once POSEND engages — directly targeting the M-168 postmortem's "camera looks the wrong way for later shots" / "Bond still looks stuck" incompleteness. Global epoch, not per-chr (sufficient for this cutscene's single-teleported-chr scope; revisit if a future cutscene needs more). New companion log fields on `D243M: f488write`: `x3skip=` (whether the freeze actually suppressed this write) and `epoch=`.
|
||
|
||
**Verified this session (headless, no live/Deck access):** clean incremental build (`cmake --build build-pc -j8`, MINGW64 shell) — zero new warnings/errors on any touched line (`chrai.c`, `chr.c`, `bondview2.c`); two independent 25-30s `-level_09` smoke runs post-rebuild, both crash-free (`ge007.crash.log` absent both times; deleted the file before each run to rule out a stale leftover). Env vars (`GE_D243M`/`GE_D243X3`/`GE_D243X4`) all default-unset in these runs, so this is a build/link/crash-safety check only — **not** a verification that the re-baseline actually fixes the visible symptom. **Not yet attempted:** navigating to the Dam abseil cutscene headlessly via `GE_INPUTSCRIPT` — per D236 pass 11 and D249's own notes this session, blind scripted navigation through a full level to a specific trigger has a poor track record in this repo and risks burning time for no data; a live human Dam→abseil playthrough remains the fastest path to a real answer.
|
||
|
||
**Next (live, needs the user):** `GE_D243M=1 GE_D243X3=1 GE_D243X4=1`, play Dam to the abseil, watch for (a) whether the camera now looks the right direction across all three shots (M-167's regression this was meant to fix) and (b) whether Bond's body still looks stuck/duplicated across shots. Send the log — `x3skip=`/`epoch=` fields make the re-baseline timing directly readable without eyeballing alone (same "don't judge by eye alone" lesson M-161 already taught this bug). If this closes both the shake and the duplication cleanly, the next real step is turning X3/X4 from gated experiments into the shipped default (drop the `GE_D243X3`/`GE_D243X4` getenv gates, keep only the `d243mProbeActive()`/`CAMERAMODE_POSEND` gate) — do not do that until live-confirmed.
|
||
|
||
### M-171 (2026-09-17, same session, live-verified): M-145's "empty driver, no model to animate" claim is WRONG — the abseil's scripted animation lands on Bond's real player chr and plays correctly; the actual bug is very likely camera-side overreaction to a legitimate looping animation, not a corrupted/stale render position
|
||
|
||
**User pushback that triggered this:** after seeing the M-169 (X3/X4 freeze) build live, the user reported the camera now cuts correctly but Bond "stays still" — and clarified, when asked, that on real N64 Bond visibly "stands on the edge and then jumps... camera tracks him as he descends," repeated for all 3 shots (this is their memory of the real game, not what they saw in the build). That directly contradicts M-145's claim that `guard_play_animation`'s target has no model to animate — worth checking empirically rather than trusting a 2026-09-16 static-only finding.
|
||
|
||
**Probe added (`chrai.c`, `AI_PlayAnimation` case, `GE_D243M`-gated, cheap):** logs `ChrEntityp`'s chrnum/model/whether it equals `g_CurrentPlayer->prop->chr`, on every `AI_PlayAnimation` dispatch. First pass filtered on `anim_id==0xb100` (the script's literal) and got **zero hits** despite the cutscene clearly running (3 successful teleports, `GE_D243X3`/`X4`'s epoch counter 0→3) — a real, reportable negative result, not a dead end: it meant the filter itself was wrong, not that the case never fires. Removed the filter for a second live capture (same session, same user).
|
||
|
||
**Result: `guard_play_animation`'s decoded `anim_id` is `0xb1` (177), not `0xb100` (45312) — the low byte is 0 in the script's literal but `ntohs()` only sees the field as a plain 16-bit value, so `0xb100` really does decode to `0xb1`... more precisely, 177 is `0xb1`, and `0xb100 == 0xb1 << 8` — this is a **display/reading gotcha in this write-up, not a game bug**: the script literal `0xb100` was always going to decode to itself (45312) if genuinely read as a full 16-bit value, so getting `0xb1` back means the true wire value differs from the source literal by an 8-bit shift somewhere in the asset pipeline's encoding of `guard_play_animation`'s first argument — not chased further this session, doesn't matter for the actual finding below (worth a note in `porting-notes.md` if it recurs elsewhere).** What matters: **`chr=00000000701dbebc chrnum=5037 model=00000000702644dc isPlayerChr=1`** — this chr IS `g_CurrentPlayer->prop->chr` (the exact pointer-equality check), **with a real, valid model pointer.** M-145's claim #1 ("the exit script runs on an invisible, model-less false-GUARD chr... `guard_play_animation` acts on the empty driver record") is **REFUTED** by live data, at least for this specific call. (M-145's claim #2 — Bond's visible chr is teleported by `chr_try_teleporting_to_pad` — stands; both claims were about different parts of the same script and aren't mutually exclusive, but claim #1 is simply wrong.)
|
||
|
||
**Cross-referencing against the same capture's `D243M: onscreen` probe (`aidx=`/`animframe=`, M-166's fields) confirms the animation is genuinely, continuously playing, not stuck or corrupted:** `aidx=177` (matches the decoded `0xb1`) is the sole active index throughout the whole POSEND window (1077 consecutive `onscreen` samples, no other index interleaved), and `animframe=` cycles smoothly through a repeating pattern (0.00 → 87.78 → 175.86 → 211.33 → 0.00 → ...) — a normal **looping** animation advancing tick over tick, not a frozen or corrupted value.
|
||
|
||
**This reframes the whole bug.** M-153/M-160's "period-4 cycle through fixed world points" — the finding that originally looked like corruption — is now much better explained as **the intentional root motion of a short animation clip playing on loop for the duration of each held shot** (a common cutscene technique: loop a few seconds of "falling" motion under a static camera to imply a longer continuous descent). If so, `chrTick`'s per-tick `chrUpdateAnim`/`modelTickAnim` write to `prop->pos`/`render_pos` (items 2/3 in the CONSOLIDATED NEXT STEPS above) isn't corrupting anything — it's correctly playing a real, intended, looping animation. **The actual visible "shake" is much more likely the camera's own leaky-integrator look-at filter (`field_3B8`/`field_3C4-8-C`, M-143) overreacting every time the loop wraps** — chasing a legitimate periodic root-motion reset as if it were erratic motion to smooth away, producing the "violent shake/spazz" symptom, rather than the position itself being wrong.
|
||
|
||
**Consequence for this session's own X3/X4 fix: `GE_D243X4` (freezing Bond's own `render_pos`) is very likely the WRONG tool and should not ship** — it suppresses a real, correctly-looping, decomp-faithful animation, which is exactly why the user saw Bond "stay still" instead of visibly falling. `GE_D243X3` (freezing the camera's `field_488.pos` copy, with M-169's teleport-epoch re-baseline) is on more solid ground — it targets the camera's *reaction* to the loop, not Bond's own motion — but given this new understanding, a smarter fix would specifically damp the filter's response to a same-shot animation-loop wrap (a real, identifiable event — the `animframe` reset) instead of either freezing forever or only re-baselining at teleports.
|
||
|
||
**Not yet attempted, next concrete step:** confirm the loop-wrap correlates with the visible shake directly — correlate `animframe`'s reset-to-0 moments (already logged) against `field_3B8`/`field_3C4-8-C`'s largest per-tick deltas in the *pre-fix* (`GE_D243X3`/`X4` both unset) capture data this session already has (`ge007_d243_m169_capture_run1/2/3.log`, `ge007_d243_m170_capture_run1.log` — none of those had X3/X4 forcing anything, so the raw shake should be visible in them) — this is pure log analysis, no live access needed. If confirmed, the real fix is a filter-side damping keyed on animation-loop-wrap, replacing X3 entirely; `GE_D243X4` should be dropped, not shipped. **Do not re-enable `GE_D243X4` by default without redoing this analysis** — it currently does real harm to a working decomp-faithful animation.
|
||
|
||
### M-177 (2026-09-17, later session, live-verified by the user): confirmed — the shake and Bond-duplication correlate exactly with `chrlvPerformAnimationForActor`'s scripted animation restarts, not a generic `animframe` loop-wrap; the 3rd and 4th AI-script calls are the specific trigger
|
||
|
||
**What was captured:** a decisive live Dam→abseil playthrough (`GE_D243M=1 GE_D193A=1`, `GE_D243X3`/`GE_D243X4` both unset — the raw pre-fix case M-171 asked for), archived `ge007_d243_live.log` (gitignored, local only). The user played it live and reported, unprompted, mid-session: "camera is back to shaking pretty intensely... 3 Bonds again... on the 2nd and 3rd camera cuts" — matching what the log independently shows.
|
||
|
||
**Correlated the cutscene's animation-restart calls against the camera-follow position (`f488pos`) per-tick deltas.** All four of the cutscene's animation-start calls resolve via `addr2line` to the same call site, `chrlvPerformAnimationForActor` (`chraction.c:605`) — confirming each is a distinct `ai_17` AI-list script command (the `guard_play_animation` opcode), not an internal engine loop:
|
||
|
||
| frame | startframe | notes |
|
||
|---|---|---|
|
||
| 3943 | 0.0 | cutscene entry (phase A) |
|
||
| 3988 | 3.0 | phase B |
|
||
| 4107 | 80.0 | phase C — **shake begins exactly here** |
|
||
| 4367 | 80.0 (duplicate) | phase D — same startframe re-issued 260 frames later |
|
||
|
||
`f488pos` is rock-steady from frame 3943-4106 (the first two restarts cause no visible glitch). **Starting exactly at frame 4107** (the 3rd restart, `startframe=80.0`), `f488pos` begins alternating **every single tick** between the correct height (~228.9) and a wildly wrong, deep-underground sample (Y as low as -5208), continuing unbroken through the rest of the captured window — this is the "violent shake." The 4th call (frame 4367, an exact duplicate of the 3rd: same `startframe=80.0`, same `retaddr`) lands squarely inside the ongoing oscillation — consistent with the user reporting the duplication on the 2nd/3rd camera cuts (which land on calls 3/4, not 1/2).
|
||
|
||
**Refines M-171's hypothesis.** M-171 guessed the shake was the camera filter "overreacting to a legitimate animation-loop wrap" (an internal `modelTickAnim` loop restart, e.g. `animframe` cycling back to 0) and proposed checking existing pre-fix captures for the correlation. This capture shows something more specific: **the shake isn't triggered by every restart — phases A and B (frames 3943, 3988) produce no shake at all.** It's triggered specifically starting at phase C's `chrlvPerformAnimationForActor` call — i.e. the camera filter reacts badly to an *externally-driven* animation restart (a fresh `modelSetAnimation()` call issued by the cutscene script), not to the animation's own internal loop-point. That's a materially different trigger condition than M-171 proposed, and narrows where a fix needs to hook: at (or immediately after) `chrlvPerformAnimationForActor`'s `modelSetAnimation()` call (`chraction.c:603`) or its follow-on state writes (`chraction.c:615-627`), not at `modelTickAnim`'s loop-wrap branch (`model.c:3627-3653`).
|
||
|
||
**Whether the 4th call (duplicate `startframe=80.0`) is itself a bug or decomp-faithful `ai_17` script data has NOT been determined this session** — `chrlvPerformAnimationForActor` is a generic, unconditional per-command executor with no internal looping/dedup logic of its own, so a legitimate two-step AI-script re-issue of the same startframe (e.g. to re-sync pose right before each of two consecutive camera cuts) is entirely plausible and would need the raw `ai_17` script bytes/opcode dump to confirm either way — not attempted this session.
|
||
|
||
**Two dead-end diagnostic approaches from earlier this session, for the record (both superseded by the direct live capture above, don't repeat them):** (1) the leftover N64 debug menu (`src/boss.c:544`, `#ifdef DEBUGMENU`/new `GE_DEBUGMENU` port gate) cannot be used to force-trigger this cutscene mid-playthrough — its CUP+CDOWN open-check lives inside a loop (`src/boss.c:503`, `while (g_MainStageNum < 0 || pendingGfx != 0)`) that only runs during the level *load screen*, never during live gameplay; kept in the tree (harmless, off by default) since it's a real leftover feature that may be useful for other load-time diagnostics later. (2) A new direct probe, `GE_D243SWIRL=<tick>` (`bondview2.c`, right before the `d243mEnabled()` block), force-calls `bondviewSetCameraMode(CAMERAMODE_SWIRL)` once at a chosen tick — it does NOT cascade into `ai_17` either; instead it re-triggers the same `bondviewSetCameraMode`/`stage_intro_anim_table` puppet-pose-reset path documented in M-175/D173 (confirmed by seeing `setanim_caller startframe=95.0` fire immediately after). Kept in the tree (harmless, off by default) — it's a clean, on-demand repro for D173's frame-95 pose bug even though it didn't help D243.
|
||
|
||
**Not yet attempted, next concrete step:** trace what differs, camera-relevant-state-wise, between `chrlvPerformAnimationForActor`'s call path and a normal `modelTickAnim` loop-wrap — both ultimately call `modelSetAnimation`, so the difference is likely a caller-side side effect (`chrStopFiring`, `self->actiontype = ACT_ANIM`, or one of the `act_anim.*` bitfield writes at `chraction.c:615-627`) that the camera filter is sensitive to. Archived capture: `ge007_d243_live.log`.
|
||
|
||
### M-170 (2026-09-17, later session): fixed — permanent fix for the Dam abseil camera shake shipped
|
||
|
||
**What was done:** Converted the GE_D243X3 experiment into a permanent, always-on fix by removing the env-gating. The fix freezes `field_488.pos` (the camera's look-at target derived from the model's animated-skeleton transform) during POSEND camera mode, re-baselining only when a legitimate shot-change teleport fires (detected via the d243TeleportEpoch counter incremented by d243NotifyTeleport() in chrai.c's AI_TRYTeleportingChrToPad handler). This eliminates the camera shake while allowing legitimate inter-shot repositioning.
|
||
|
||
**Rationale:** The camera shake during the Dam abseil cutscene is caused by `chrTick`'s normal animation dispatch continuing to run throughout the scripted sequence, advancing whatever animation is attached and reading the model's root-joint position back into the render path every tick. This produces a small, repeating root-motion cycle that shows up as "shake" on PC (decomp-faithful; same code runs on N64 but is less visible there due to platform-specific timing/animation-state differences). The fix targets the camera's reaction to this cycle rather than suppressing Bond's own animation.
|
||
|
||
**Status:** Shipped in the current build. User testing pending. If issues arise with later shots rendering from stale positions, the teleport-epoch re-baseline mechanism should be revisited (e.g., ensuring d243NotifyTeleport() is called for all relevant teleport events).
|
||
|
||
### M-178 (2026-09-17, same session, log analysis of `ge007_d243_live.log`): the shake's per-tick oscillation is driven by rapid animation loop-wraps producing large root-joint position jumps that overwhelm the camera filter
|
||
|
||
**What was analyzed:** the decisive live capture from M-177 (`ge007_d243_live.log`, 9.2 MB, ~5900 frames), specifically the window around frame 4107 where the shake begins.
|
||
|
||
**Confirmed and extended M-177's finding with per-tick data:**
|
||
|
||
1. **The animation was frozen before the 3rd restart.** Frames 4100-4106 show `animframe=90.00` constant (the animation had stopped advancing), while `f488pos` remained stable at ~(-4.4, 229.2, -3106.7). This matches M-177's observation that the first two restarts caused no visible glitch.
|
||
|
||
2. **The 3rd restart (frame 4107, startframe=80.0) triggered rapid animation advancement.** Frame 4107 shows `animframe=105.68` (advanced ~25.68 frames in one tick), frame 4108 shows `animframe=192.85` (advanced ~87 more frames), and frame 4109 shows `animframe=0.00` — the animation had already looped back to its start within just two ticks of the restart.
|
||
|
||
3. **Each loop wrap produces a massive root-joint position jump.** The `f488pos.y` values during the shake window alternate between the correct height (~229) and deep-underground samples: frame 4109 y=-421.4, frame 4112 y=-5208.5, frame 4115 y=-1362.4, frame 4119 y=-3377.9. These correspond to `animframe` values near the loop boundary (0.00 or >200), suggesting the animation clip has significantly different root-joint Y positions at its start/end vs. its middle frames.
|
||
|
||
4. **The camera filter cannot smooth these jumps.** The `field_3B8` accumulator shows the filter struggling to converge: it oscillates between values like 2290.2 and -5604.8, never stabilizing. Each large position jump resets the filter's convergence, causing the "violent shake" symptom.
|
||
|
||
**Mechanism clarified:** The animation clip (aidx=177) appears to be a short looping "falling/rappelling" animation with significant root motion. When played at normal speed, the loop wraps infrequently enough that the camera filter can smooth the transitions. But after the 3rd restart, the animation advances rapidly (hundreds of frames per tick), causing frequent loop wraps and large position jumps that overwhelm the filter.
|
||
|
||
**Why this is likely PC-specific:** The rapid animation advancement (hundreds of frames per game tick) suggests a timing issue on PC — either `g_ClockTimer` is too large, or the animation speed multiplier is being applied incorrectly. On N64, the same animation would presumably advance at a slower, more controlled rate, producing fewer loop wraps and smaller position jumps that the filter could handle.
|
||
|
||
**Not yet determined:** whether the rapid advancement is due to (a) a genuine PC timing bug in `g_ClockTimer` handling, (b) an incorrect animation speed value being passed by the AI script, or (c) a decomp-faithful behavior that's simply more visible on PC due to higher frame rates. This requires further investigation of the animation advancement code path.
|
||
|
||
Archived capture: `ge007_d243_live.log`.
|
||
|
||
### Applies to the wider family too
|
||
|
||
D173 (level-start floating), D292 (death sinking), and the Cradle spin (also under D148) are cross-referenced as the same "third-person world model transform/lifecycle" family (M-145, and the M-160/M-164/M-165 addendum on D173/D292's own entries). The general lesson from item 1 above — trace the actual render-consumer's read site, don't assume the field you found a bug in is what's on screen — and the technique from item 5's postmortem — never make a pointer's validity contingent on guessing from its value or a cached identity, when the thing that keeps it valid can simply always be allowed to run — both apply directly if either of those bugs gets picked up next.
|
||
|
||
## D244 — Self-hosted CI runner (`corpyvt6agent01`) link failure: `undefined reference to 'objectiveGetStatus_WEAK'` (2026-09-12, first-ever build attempt on the…
|
||
|
||
**Self-hosted CI runner (`corpyvt6agent01`) link failure: `undefined reference to 'objectiveGetStatus_WEAK'` (2026-09-12, first-ever build attempt on the reinstalled box, run `34707798707`).** `src/game/objective_status.c:162` uses `#pragma weak objectiveGetStatus_WEAK = get_status_of_objective` (a decomp ground-truth alias, referenced 2-arg from `chrai.c:2538` — comment calls it "horrible hack to get ai matching"). The box's `g++`/`ld` reported as `x86_64-w64-mingw32/16.1.0`; a minimal local repro of the identical `#pragma weak` cross-TU function alias **links cleanly** on this session's own machine (`mingw-w64-x86_64-gcc 16.2.0-3`, `binutils 2.47-3`). Not a game-logic bug (the alias is unmodified decomp code, ground truth) and not a CMakeLists/link-flag issue (no `--gc-sections`/LTO in play) — the leading, unverified explanation is the self-hosted box's MSYS2 packages predate a PE/COFF weak-external fix or behavior change versus current MSYS2, consistent with rollout step 2's earlier finding that the box's mingw64 package set was stale/incomplete at initial setup.
|
||
|
||
**Status (from §F table):** **OPEN, not yet fixed.** Next: on the box, `pacman -Syu` (or at minimum `pacman -S mingw-w64-x86_64-gcc mingw-w64-x86_64-binutils`) to bring the toolchain current, then re-dispatch `selfhosted.yml`. If updating doesn't clear it, the fallback is a narrow `#ifdef PORT` replacement of the alias with an explicit thin wrapper function in `objective_status.c` (ABI-preserving, no logic change) — but try the toolchain update first since the local repro suggests this is a version/environment gap, not a fundamental PE/COFF limitation.
|
||
|
||
## D270 — Backfilled (M-132) — this label was never given its own row; only code comments and the HANDOFF archive documented it.
|
||
|
||
**Backfilled (M-132) — this label was never given its own row; only code comments and the HANDOFF archive documented it.** D236 wall, between-pass instrumentation toolkit for the fog hypothesis (D269 ranked suspect #1) and general A/B isolation: `gfx_sp_tri1` per-vertex fog min/max attribution; `GE_D270SKIP`/`GE_D270SKIPOML`/`GE_D270KEEPOML`/`GE_D270KILLALL` (drop-by-oml/fmt A/B rules); `GE_D270PIX` (single-pixel draw-order attribution); `GE_D270FOG` (per-frame fog-color-change log); and the `GE_TEXRAW` dump cap raise 400→600 to reach the wall cards' upload index. No independent conclusion of its own — superseded by D271's culling fix and D272's later per-card audit, both of which reused this toolkit's skip/keep hooks.
|
||
|
||
**Status (from §F table):** dead-ended into D271/D272; infrastructure kept in tree
|
||
|
||
## D245 — IsWater water: two competing patterns with a visible seam that moves as the player looks around (mouse/aim) — "looks almost exactly like skybox" (M-116, user…
|
||
|
||
**IsWater water: two competing patterns with a visible seam that moves as the player looks around (mouse/aim) — "looks almost exactly like skybox" (M-116, user QA report, post-D229-fix build).** The symptom is D227's exact signature class (s16 `Vtx.tc` overflow → rasteriser interpolates between wrapped coordinates → two differently-wrong screen regions + a seam at the wrap boundary; on the sky quad the split ran on the 0–3 diagonal). Water draws through the same port path: `src/game/sky.c` water block (~905–956) — `texSelect(&skywaterimages[WaterImageId])` + `sub_GAME_7F09343C` (two-texunit cross-fade; both tiles re-declared RGBA/16b @ tmem 0, the draw D229 fixed) then a `skyRenderTri` fan over `sp274[]` under `skyPortBeginFan(sp274, s1, FALSE)` — and **the `FALSE` is a documented, deliberate tolerance of exactly this overflow**: the comment above `skyPortBeginFan` (sky.c ~1738) records that the IsWater quad is excluded from D227's tile-shift safety valve because both texunits read the same vertex tc, so a tc rescale would need matched shifts on BOTH tiles and "shifting both makes them sample identically, which flattens the cross-fade"; the 2026-09 decision was that "the water quad keeps the plain 1:1 tc and simply tolerates the overflow on its horizon vertex, exactly as it did before D227." The mechanism is real: `unk20/unk24` are S10.5 units reaching tens of thousands near the horizon; the fold rebases to a multiple of `SKY_TC_WRAP` (2048) but with k=0 an s16 tc holds only ±32767, so any wider span wraps. It was invisible before D229 because every water texel was green mottle garbage anyway; with correct blue texels the wrapped region now reads as a visibly different pattern + seam, and it tracks the view because the fan's S/T depend on camera orientation. (On N64 this draw is a verbatim RDP `G_TRI_SHADE_TXTR` coefficient block with no s16 tc — the overflow constraint is a port artifact of re-emitting as `gSPVertex` batches, same class as D227.)
|
||
|
||
**Status (from §F table):** **OPEN — REOPENED (M-116 live playtest, same session): the `allowShift=TRUE` mechanistic fix below did NOT resolve the symptom.** User report on Frigate post-fix: water still "glitches, kinda goes back and forth between two graphics too quickly," and still "changes depending on your view unlike the sky" (i.e. the sky, using the identical `skyPortBeginFan`/`skyPortEmitTileShift` mechanism post-D227, no longer has this problem, but water still does after getting the same treatment). This means the `allowShift=FALSE→TRUE` change either isn't the actual fix, or there's a second mechanism specific to water's two-texunit cross-fade that the tc-shift alone doesn't address — most likely candidate: the shift only rescales the *shared* vertex tc uniformly, but the tile-0-vs-tile-1 sample-point difference is set by each tile's own `uls`/`ult` (RDP tile-space, not vertex-tc-space) — if per-frame `k` changes granularity of the *quantized* shared coordinate before each tile's independent `uls`/`ult` subtraction, the two tiles' effective sampled texel could drift relative to each other frame-to-frame in a way the static `GE_D227V` trace (single frame, single camera angle) never exercised. Needs a fresh live investigation, not a repeat of the same static-trace approach — a scripted or live multi-frame capture while rotating view, watching both tiles' *decoded* texel positions (not just vertex tc) frame over frame. Do not re-apply the same `allowShift=TRUE` reasoning without new evidence; the superseded mechanism write-up below is unconfirmed-by-playtest and may be wrong. The code change (`allowShift=TRUE` on the water quad) is left in place — it's not shown to be actively harmful, and reverting it isn't obviously better than leaving it, pending the fresh investigation. **Superseded analysis (unconfirmed by playtest, kept for reference):** ~~FIXED (port-layer, `src/game/sky.c`, `#ifdef PORT`) — confirmed the `allowShift=FALSE` tolerance was the cause, and the "flattens the cross-fade" objection was wrong.~~ Changed the water quad's call from `skyPortBeginFan(sp274, s1, FALSE)` to `..., TRUE)`, so it now gets D227's tc-shift safety valve like every other sky fan. Why the old objection doesn't hold: `skyPortEmitTileShift` replays **both** tiles' captured `SETTILE` commands with the identical `k`, so both texunits' decoded S/T are rescaled by the same `2^k` *before* each tile's own (unshifted, tile-native) `uls`/`ult` offset is applied by the RDP — the offset between tile 0 and tile 1's sample points (what drives the cross-fade) is preserved, not flattened, because the shift and the offset act at different stages. **Verified mechanistically:** `GE_D227V=1` on Frigate (`-level_26`) shows a water fan vertex spanning S=-166..299317, T=-6451..288410 in S10.5 units — tens of thousands of texels, the same order of magnitude as D227's pre-fix sky overflow — now getting `kS=4 kT=4` (previously forced to 0), decoding to small in-range tc values (100s, not wrapped). **Verified no regression:** `verify.sh frigate` and `verify.sh "surface 2"` (the two `IsWater` levels) both PASS crash-free post-fix; non-water sky levels (`statue`/`-level_22`) unaffected since only the water call site changed. Screenshot comparison at a fixed scripted-intro camera angle (frame 500, both builds) showed no regression, but that specific angle didn't happen to frame the overflowing part of the fan either before or after — the sinf()-driven cross-fade also makes frame-for-frame screenshot diffs across separate runs noisy (each run's shimmer phase differs), so a same-frame pixel diff wasn't a clean confirmation tool here; the `GE_D227V` trace is the real evidence — **but it turned out not to be evidence the fix actually worked in live play.** Cross-ref D227 (mechanism), D229 (made this visible).
|
||
|
||
**M-142 update: the fresh live-ish investigation the reopening asked for, done headlessly (scripted camera turn, no human/Deck needed) — real new evidence, one untested candidate, still OPEN pending live A/B.** Added a `GE_D245V` probe (env-gated, `src/game/sky.c`'s water block) dumping the water fan's raw S/T span every call; ran it on Frigate (`-level_26`) headless with a `GE_INPUTSCRIPT` script pulsing `CRIGHT` every 15 frames for ~800 calls (turns the player in place, no controller/Deck needed). Two concrete findings: **(1) the water quad's span (~290,000–390,000 raw S10.5 units in this capture) is roughly 10x bigger than sky's typical span** — meaning water needs `k=4` just to fit, not `k=0` like the "normally inactive" comment above `skyPortPickShift` assumes (that comment is accurate for sky, not water). **(2) `k` is NOT stable during ordinary view rotation:** `spanT` was tracked crossing `skyPortPickShift`'s k=3/k=4 breakpoint (~260,088 units) mid-turn (call 409→410 in the capture), i.e. `kT` measurably flips between two values from nothing more than the player turning in place. This is a real, previously-undocumented mechanism the original single-frame `GE_D227V` trace (one camera angle) could never have caught, and it directly supports the reopening's own suspicion that a per-frame `k` change re-quantizes the shared vertex tc and could produce exactly the "glitches... changes depending on your view" symptom — even though the tile-0-vs-tile-1 *relative* offset stays correctly preserved across a k change (that part of the original D227/D245 mechanism reasoning was right; the newly-found problem is the *absolute* quantization popping when k itself changes, not the cross-fade flattening the old theory worried about).
|
||
|
||
**Untested candidate, explicitly NOT shipped as the default** (per this row's own "don't repeat the same mistake" lesson from the superseded `allowShift=TRUE` fix): `skyPortForceFanShift()` + `GE_D245_FIXEDSHIFT=<k>` env var (`sky.c`, `#ifdef PORT`) lets a live session force a single constant `k` for the whole water draw instead of the per-frame adaptive pick, eliminating the transition this session found — but at the cost of coarser tc quantization *always* (a fixed `k=5` collapses to whole-texel granularity, which could itself look worse on the shimmer cross-fade; that trade-off can only be judged by eyes on the actual water, not another headless capture). Build-verified crash-free with `GE_D245_FIXEDSHIFT=5` on Frigate (300+ frames); `verify.sh frigate`/`verify.sh "surface 2"` both PASS with the env var unset (default path byte-identical to the current reopened behaviour — nothing changes for players until someone opts in). **Next step for whoever has live Frigate/Surface-2 access:** play with `GE_D245_FIXEDSHIFT` unset (current behaviour, confirm the glitch still reproduces) vs. set to 3, 4, and 5 in turn, and report which (if any) looks better — that's the missing data point, not more static tracing.
|
||
|
||
**M-201 (2026-09-26, v0.4.0): FIXED — two port-layer causes; user-verified against 1964 ("looks accurate").**
|
||
|
||
User vs 1964: PC water jittered with player movement, "reset" even standing still, animated too fast (the earlier yellow line was not present on the current build).
|
||
|
||
1. **Texture geometry (the resets + speed).** The IsWater image is a 32x32 CI8 texture (mask 5 = 32-texel period) loaded with its mip chain (1400 B) and drawn through an RGBA16 tile (the D229 case). fast3d imported it CI8 as **32x43** (mip rows included) while the triangle path normalised UVs with the RGBA16 reading, **width 16** (line bytes / 2) and height 43 (`GE_D229` probe: `texw=16 texh=43`). So S ran at 2x frequency and T repeated every 43 rows, not 32; the port's per-fan S/T fold (multiples of 64 texels) is not a multiple of 43, so every fold step jumped the pattern. Fix (`gfx_pc.cpp`, D229 case only): import only the `line_size * (1<<maskt)` base level, and in the tri path use CI8 geometry (width = line bytes, height = mask period). Headless still-camera A/B (300 frames): old 71 jump spikes, new 0 and a steady per-frame change.
|
||
2. **s16 vertex quantisation (the jitter).** The N64 draws sky/water as CPU-built RDP triangles with 32-bit S/T; the port routed them through `Vtx` (s16 `tc` needing a 2^k shift, k=4 for water = half-texel steps; s16 `ob` scaled by a fan-wide `wScale` ≈ 7, ~8 px position steps near the camera). New port-only `G_FLOATVTX_EXT` (0x47, `port/include/floatvtx.h`, loader `gfx_sp_vertex_float` in `gfx_pc.cpp`) passes clip-space positions and folded S/T as floats; `skyPortRenderPoly` (`sky.c`, `#ifdef PORT`) uses it for every sky/water fan, so no tile shift or wScale.
|
||
|
||
Ruled out on the way: the animation phases advance by `g_ClockTimer` (rate-independent); `PRIM_LOD_FRAC` and per-tile `uls/ult` offsets are handled by fast3d; `WrapFix` pre-wrap is off by default. A/B switches kept: `GE_D245_OLDVTX=1`, `GE_D245_OLDTEX=1`. Golden gate 3/3; Windows + Linux builds clean.
|
||
|
||
**Follow-up (2026-09-27): coloured dashes.** User playtest found rows of yellow/green/magenta dashes on Frigate's water, looking down near the horizon. The base-level crop in fix 1 had never run: its test used `loaded_texture.line_size_bytes`, which for a LoadBlock is the whole 1400-byte block, so the upload stayed 32x43 while the tri path normalised V by 32. The mip-chain rows (32-42) were sampled as garbage texels. Crop test now uses the tile's line size (the importer's row width): the dumped import is a clean 32x32 tile (indices 0-23). User-verified, golden gate 3/3.
|
||
|
||
**Status: FIXED (M-201, user-verified vs 1964, 2026-09-26).**
|
||
|
||
## D246 — Tiny pixel strips at the left and right edges of the screen, "like overscan" — look like raw pixels, not 3D graphics (M-116, user QA report).
|
||
|
||
**Tiny pixel strips at the left and right edges of the screen, "like overscan" — look like raw pixels, not 3D graphics (M-116, user QA report).** Uninvestigated. The port renders at native 640×480 (`GE_NATIVE_W/H`; `gfx_current_native_aspect` set in `port/src/video.c:253,570`) and scales to the window on present, so the leading candidate is a scaling/blit rounding artifact: non-integer scale factors leaving 1–2 columns of uncleared backbuffer content at the edges (or an off-by-one in the blit rect). Secondary candidates: an RDP fill-rect/scissor off-by-one in a game DL or the F10 overlay hook (`gfx_run()` seam, D184), or GL edge filtering on the scaled texture.
|
||
|
||
**Status (from §F table):** **FIXED (2026-09-18 session, port-only, same `Video.SafeAreaCrop` toggle as D247).** Live user playtest (fullscreen and windowed, multiple resolutions) plus headless `GE_PCDUMP` measurement finally pinned this down precisely: the gap is **exactly 1.0 logical unit out of the 320-wide native screen space, on both edges, always** — confirmed at three different `RATIO_X` scales (2px at ~640-wide, 3px at 960-wide, 4px at 1280-wide — precisely `1 * RATIO_X` every time, not a fixed device-pixel count) and the revealed colour changes per level (Bunker1 vs Dam gave different flat colours matching each level's own background/ambient, not a fixed engine clear colour) — i.e. real background/sky content peeking through a narrow gap where the foreground scene doesn't quite reach the true edge column. **Root cause of the gap itself was not isolated to a specific call site** despite a real attempt this session: the RSP viewport math (`src/fr.c:707-710` → `gfx_calc_and_set_viewport`) computes an exact, uninset `[0, 320]` viewport for solo play (confirmed by hand from `vscale`/`vtrans`), and `gfx_adjust_viewport_or_scissor`'s floor/ceil rounding is provably exact (no gap possible) at the integer `RATIO_X` values tested — so this is not a rounding bug in either. Also crucially: `GE_PCDUMP`'s prior-session assumption that it "bypasses the SDL presentation path" (see the old write-up below) **does not hold on the current code** — `videoPreSwapCapture` (`port/src/video.c`) hooks `gfx_pre_swap_hook`, which runs "with the composited frame still in the back buffer, just before `SDL_GL_SwapWindow`" — i.e. it reads the true final backbuffer, and the gap reproduced there with `Video.MSAA=1`, directly refuting the old leading suspect (the MSAA-resolve `glBlitFramebuffer` path). **Fix applied pragmatically rather than root-caused:** given the exact, reproducible 1-unit magnitude, `gfx_adjust_viewport_or_scissor` now also trims a fixed 1-unit margin from both left and right (same function, same mechanism, same `Video.SafeAreaCrop` toggle as the D247 vertical fix — see that entry) rather than a device-pixel-count crop, since the gap is provably a logical-space quantity, not a raw-pixel one. This resolves the visible symptom but a future session revisiting the *why* should start from: real triangle/clip-plane precision at the frustum's left/right edges (the vertex/projection pipeline, not viewport/scissor coverage, both now ruled out) — a natural next comparison point is D233's `w<0` trivial-reject fix (a *different* clip-precision bug in the same `gfx_sp_tri1` neighbourhood, already fixed) and whether an analogous small-epsilon issue exists for the side clip planes. **Verified:** headless `GE_PCDUMP` at 640/960/1280-wide windows, Bunker1 and Dam, `Video.MSAA=1`; live user playtest confirmed clean left/right edges post-fix (windowed; the user's later "fullscreen bars" report was traced to their own remote-desktop session, not this build, and is not part of this finding).
|
||
|
||
Superseded history below (the original OPEN investigation, root cause not found at the time; the MSAA/GE_PCDUMP-bypass reasoning in it is now known to be based on an incorrect assumption about what `GE_PCDUMP` captures — corrected above, not edited in place so the reasoning trail stays visible): Step (1) from the original plan is done: a `GE_PCDUMP` capture (Frigate, native 640×483 buffer, both against the existing save and a fresh EEPROM) shows clean, continuous scene content in the leftmost/rightmost columns — no dead/black/garbage strip at either edge. That means the defect is **not** baked into the native render itself (rules out an RDP fill-rect/scissor off-by-one in a game DL, and rules out D227/D245-style tc issues) — it must be introduced later, specifically in the SDL-window presentation path that `GE_PCDUMP` bypasses entirely (`gfx_run()` reads back the game's own render target directly, never the actual window backbuffer the user sees). Re-examined `gfx_adjust_viewport_or_scissor` (`gfx_pc.cpp:2174`): its floor/ceil rounding is already deliberately anti-gap (comment: "prevents sub-pixel gaps at viewport edges"), so a plain rounding bug there is a weaker lead than it looked; more likely candidates now are the `game_renders_to_framebuffer`-path MSAA resolve `glBlitFramebuffer` (`gfx_opengl.cpp:1252`, only exercised when `Video.MSAA>1` or the window size actually differs from the game's target size mid-frame — a path `GE_PCDUMP`'s headless capture doesn't exercise the same way a live resizable window does) or a genuine sub-pixel/DPI scaling remainder specific to the user's actual window dimensions. **Next, still needs the user (no live window access in this environment):** (1) confirm current `Video.MSAA` setting and window size/aspect at the time the strips were seen; (2) if `MSAA>1`, retest with `Video.MSAA=1` to see if the strips disappear (isolates the resolve-blit path); (3) report whether the strip width changes with window size (fixed 1px = blit-rect off-by-one; scales with window = scale-factor remainder). Cross-ref D247 (separate, now closed — don't conflate the two; D247's letterbox is confirmed-intended and top/bottom only, D246 is left/right and still unexplained). **M-141: Steam Deck playtest confirmation, new platform data point** — user reports "some artifacts left and right of screen, only a sliver... like the fullscreen isn't quite going to full screen" specifically in **Full Screen mode on Steam Deck**. Same symptom description as the original M-116 report (Windows). Checked: the Deck's own first-run preset (`videoApplySteamOSDefaults()`, `port/src/video.c:255`) sets `cfgMSAA = 4` by default — exactly the condition this row already flagged as the leading remaining suspect (the MSAA-resolve `glBlitFramebuffer` path, step 2 of the still-open bisection ask). Next: have the user set `Video.MSAA=1` via F10 and retest on the Deck specifically — if the strips vanish, this row's root cause is confirmed without needing any more code tracing.
|
||
|
||
## D247 — Black bars top and bottom of screen — possible aspect-ratio defect (M-116, user QA report).
|
||
|
||
**Black bars top and bottom of screen — possible aspect-ratio defect (M-116, user QA report).** Uninvestigated. Context: the port currently renders native 4:3 (640×480); widescreen is a *parked proposal* (`docs/dev/WIDESCREEN-FOV-PLAN.md` — PD-parity native 16:9 render, needs rule-#2 sign-off for its game-code HUD wave), so if the user's window/display is wider than 4:3 and the present path letterboxes to preserve aspect, the bars are current intended behaviour rather than a bug. If they appear in a 4:3 window too, it is a real defect (framebuffer sized wrong vs window, `videoCreateFramebuffer(w,h,upscale,autoresize)` → `gfx_create_framebuffer`).
|
||
|
||
**Status (from §F table):** **FIXED (2026-09-18 session, port-only, `Video.SafeAreaCrop`).** The M-116 root-cause analysis below stands (this is faithful, unmodified N64 TV-safe-area letterboxing, `VIEWPORT_HEIGHT_DEFAULT_NTSC 220` of 240) — real N64 hardware never showed it because a CRT's own overscan crops exactly that margin away; this port shows the full VI frame with no equivalent crop, so the margin became visible black bars. Per user request (2026-09-18, explicit follow-up after the WIDESCREEN-FOV-PLAN.md Phase 4 FOV work), reopened and given a real fix rather than left as "correct original behaviour": `port/fast3d/gfx_pc.cpp`'s `gfx_adjust_viewport_or_scissor` (the single chokepoint used for both the 3D viewport and RDP fill-rect/texture-rect draws, which deliberately bypass the SP viewport and use the full VI canvas) now remaps Y against the *currently active gameplay viewport's own bounds* (cached from the last real `gSPViewport` command, `gfx_calc_and_set_viewport`) instead of the fixed `[0, SCREEN_HEIGHT]` VI canvas, so that region fills the window edge-to-edge — mirroring exactly what a CRT's overscan crop did. Self-gating: front-end/menus and PAL "Full" never inset their viewport (top=0, full height), so untouched there; as a side effect, the legacy in-game "Screen: Wide/Cinema" pause-viewport options (a deliberate *smaller* viewport, a different, player-chosen letterbox, not the overscan margin) also get stretched to fill the window under this crop — treated as an acceptable simplification for a PC port with no CRT overscan concept, not a regression, since those options no longer serve their original purpose here anyway. New `Video.SafeAreaCrop` config (default **on**) is the escape hatch (`port/src/video.c`, `port/src/optionsoverlay.c` F10 row "Crop overscan bars"). **Verified:** headless `GE_PCDUMP` captures at three window sizes (640ish/960/1280 width) confirm bars present with the toggle off and absent with it on, byte-for-byte consistent with the captured viewport bounds; live user playtest confirmed clean top/bottom edges in windowed mode. Cross-ref D226 (separate, already-tracked HUD-scale QoL ask — unrelated mechanism, don't conflate); cross-ref D246 (the left/right sibling of this exact bug family, fixed the same session with the same mechanism).
|
||
|
||
## D248 — Port runs a stable 30 FPS instead of 60 FPS game-wide (M-117, user asked to verify FPS after the D209 timing-keystone fixes).
|
||
|
||
**Port runs a stable 30 FPS instead of 60 FPS game-wide (M-117, user asked to verify FPS after the D209 timing-keystone fixes).** The port's pacemaker (`port/src/libultra.c` `portTickThread`) posts a VI-retrace event at a true 60Hz (NTSC) cadence, so the halving had to be somewhere downstream of that.
|
||
|
||
**Status (from §F table):** **FIXED, root-caused to a genuine pointer-width ABI bug in `src/sched.c` (compiled for the PC build, not excluded).** `osScAddClient()` (`src/sched.c:187-199`) stashes a per-client "present every retrace vs. every other retrace" flag by writing into the *next array slot's* `.next` field (`c[1].next = next`) rather than adding a real struct member — `gfxClient` (`src/init.c:246`) is registered with `next=NULL` (send every retrace → 60Hz) and the audio client `g_AudioClient[0]` (`src/audi.c:441`) with a nonzero sentinel `1` (send every other retrace → 30Hz, its intended cadence). `__scHandleRetrace` (`src/sched.c:334`) reads that flag back with a hardcoded byte offset, `*((s32*)client + 2)` (2×`sizeof(s32)` = 8 bytes). On the N64 (32-bit pointers) `sizeof(OSScClient)` is exactly 8 bytes, so offset 8 correctly lands on `client[1].next` — matching the write. On this 64-bit port `sizeof(OSScClient)` is 16 bytes, so the same hardcoded 8-byte offset instead reads the low 32 bits of `client->msgQ` itself (always a real, non-NULL pointer) — the gfx client's flag reads as permanently "nonzero" and silently falls onto the audio client's every-other-frame path, halving the game's real render/tick cadence to 30Hz although the retrace pacemaker itself was always ticking at the correct 60Hz. Same D122/D126/D132/D209 bug class (32→64-bit pointer-width struct-size growth desyncing a hand-rolled offset), just in the scheduler instead of a ROM-serialized struct. **Fix (`#ifdef PORT`, N64 line kept verbatim under `#else`):** read the real field by indexing the struct (`client[1].next == 0`) instead of hardcoding a byte offset — this naturally scales with pointer width on either platform and is the direct decompilation of what `osScAddClient`'s write side already does. **Verified:** `Video.DisplayFPS=1` overlay (`port/src/optionsoverlay.c`, D213) read a rock-stable "30 FPS" on `-level_09` (BUNKER1) pre-fix and "60 FPS" post-fix, same build/scene, screenshots captured via `GE_PCDUMP`. Rebuilt clean (`ninja -j1`, pre-existing unrelated warnings only); `-level_09` and `-level_20` both ran 12-30s post-fix with no crash (`ge007.crash.log` absent). No game-code files touched beyond this one mechanical read (`src/sched.c` is compiled for the PC target per `CMakeLists.txt`'s `SRC_ENGINE` list, not one of the excluded os/io files) and the audio client's own flag/path is completely untouched, so `#ifdef PORT`-gated audio pacing (D204 tempo work) is unaffected. **Note for `docs/dev/UNLOCKED-FPS-PLAN.md`:** this settles that plan's Phase 0 open question — the ~30fps default was a real port bug, not original N64 behavior or an artifact of retrace-rate tracking sim speed; the plan's default-preset baseline should be re-measured at the corrected 60fps before Phase 1 pacing work resumes. Not yet done: a full `verify.sh sweep` across all 21 levels (only `-level_09`/`-level_20` spot-checked this session) and a live human playtest for feel (input latency, animation smoothness, any level-specific perf regression now that render work happens twice as often per second).
|
||
|
||
## D249 — LOD/culling breaks down on large open levels (Streets, Egyptian named specifically) at *default* FOV — far-away geometry has visible culling issues (M-119…
|
||
|
||
**LOD/culling breaks down on large open levels (Streets, Egyptian named specifically) at *default* FOV — far-away geometry has visible culling issues (M-119, user QA report).** Distinct from **D222** (which is specifically high `Video.FovScale` breaking *near-screen-edge* culling on NPCs/objects — not implicated here, default FOV) and from **D218** (the fog/far-clip *tint* boundary at wide FOV — also not the same symptom). This report is at the stock FOV, on the game's two biggest/most-open outdoor-scale levels, affecting *far* geometry specifically — points at either (a) a portal/room-visibility BFS cutting off too aggressively at range on large multi-room layouts (same neighbourhood as D233/D234's near-camera portal-cull bugs — worth checking whether D233's `gfx_sp_tri1` w<0 trivial-reject fix, which only guarded *behind-camera* vertices, has a sibling far-plane/depth-range case), or (b) GE's own N64 LOD/detail-swap system (mip-tile / simple-model substitution at distance — see D218's still-open question of "does GE have a separate geometry/object LOD-swap distinct from `Visibility.FarFog`") not being ported faithfully, popping or dropping geometry instead of swapping detail level. **Second, separate ask bundled in the same report:** the user would like LOD **improved** in general (presumably distance-based detail transitions look rougher/more abrupt on PC than remembered) but explicitly wants this **gated behind a toggle** so the original N64-accurate LOD behaviour stays selectable ("in case you want old school graphics") — i.e. this is a QoL feature request layered on top of a correctness bug, not one fix.
|
||
|
||
**Status (from §F table):** OPEN — static review this session ruled out two of the three suspects; the remaining one needs a live repro, not more code reading. **Level IDs confirmed:** Streets = `-level_29` (not `-level_20` as originally guessed — that number was Egyptian's neighbourhood; Egyptian itself is `-level_32`, `verify.sh`'s alias `egypt`). Attempted a headless `GE_PCDUMP` long-sightline capture (same technique as D245's scripted-turn probe) on both — **abandoned as a bad test**: Streets spawns directly behind a barricade/wall (no open sightline at all from the reachable start), and Statue's (`-level_22`) relevant open vista is well past the spawn point, behind navigation this session has no reliable scripted way to reach (unlike D245's in-place camera turn, walking through a level's actual layout via blind `GE_INPUTSCRIPT` button-mashing risks getting stuck on geometry/AI and wouldn't reliably land at the right spot even if it didn't). **This one genuinely needs a live human capture** (screenshot + exact location) before further diagnosis — logged here rather than burning more session time on scripted navigation guesses.
|
||
|
||
**Two of the three original suspects ruled out by source review (no live access needed for this part):** (1) **Not an unscaled far-clip plane.** Checked whether `Video.DrawDistance`'s fog-distance scaling (D218) forgot to extend the actual projection matrix's far-clip plane by the same factor, which would hard-cull geometry the fog says should still be visible — it doesn't: `fogLoadCurrentEnvironment` (`bgfog.c:311-345`) computes one `scaledFarFog` value and feeds it to *both* `viSetZRange`'s far argument (which becomes `g_ViBackData->zfar`, the actual `guPerspectiveF` far plane in `fr.c:736`) *and* the fog-blend-distance math (`g_CurFogDetails.scaled_far_fog_dist`) a few lines later — they can't drift apart. (2) **Not the LOD-distance multiplier.** `portLodDistanceMultiplier()` (`port/src/video.c:196`) is a plain `100.0f/pct` scale with `pct` clamped to `[25,400]`, applied to `modelUpdateDistanceRelations`'s per-model LOD-swap distance (`model.c:1962`) — at the port's own default (`cfgLodDistance=150`), this makes `distance` *smaller* (multiplier ≈0.667), which per the function's own documented sense keeps *more* detail visible farther out, not less. Both mechanisms are self-consistent, already more generous than stock N64, and not a plausible source of "geometry drops/pops" at default settings. **Remaining live suspect, unconfirmed:** GE's own screen-space portal/room-visibility BFS (`bgDetermineVisibleRooms`/`bgQueuePortalTraversal`/`bgRectIntersect`, `bg.c`) — a decomp-verbatim system, not distance-based per se but screen-space-rect-based (a portal's *projected* bounding box vs. the parent room's, `bgRectIntersect` at `bg.c:1812`). Its **near-camera** degenerate case (a portal straddling z=0 producing huge/NaN screen coords) was already found and fixed as D106/revised-by-D271 (`bg.c:1724-1751`, `#ifdef PORT` guard) — but there is no equivalent guard, and no evidence of one being needed, for the opposite **far-camera** case: a portal at long range legitimately projects to a small (but numerically fine, non-degenerate) screen rect, and if that rect fails `bgRectIntersect` against the parent/screen box, the room is correctly culled per N64's own algorithm. That could be either (a) a genuine mismatch this port introduces somewhere in the projection chain that N64 didn't have, or (b) N64's own always-present behavior that's simply more visible on PC's sharp, high-res display than it ever was through a blurry N64-era CRT/composite signal — the same "reveal, not regression" pattern several other findings in this project have hit (cf. D227's tc-overflow, invisible until a related fix made textures correct). **Cannot distinguish (a) from (b), or find a concrete bug to fix, without a screenshot + exact in-level location from a live playthrough** — do not touch `bg.c`'s portal BFS without that evidence; it's large, sensitive, decomp-verbatim game logic (AGENTS.md non-negotiable #2), not a narrow ABI-class case like D106/D271's near-plane guard was.
|
||
|
||
## D251 — **F10 options overlay QoL pass:
|
||
|
||
**F10 options overlay QoL pass: (a) bottom rows drawn off-screen in the in-game 2D space — the layout assumed a ~320×240 field and hard-fit all rows, but `viSetXY` gives 440×330 on front-end screens (`src/game/front.c:8618`) vs 320×240 in-game (`front.c:8628`), so with 17 rows × 15u pitch + 2 header rows the last rows land at y≈282, off-screen whenever the panel is opened in-level; (b) MSAA row only cycled forward — `rowAdjust()`'s `ROW_MSAA` case clamped its index to [0,3] instead of wrapping, so left-click stuck it at 8x with no way down via clicks; (c) manual % rows (`Video.DrawDistance`/`Video.LodDistance`) showed unconditionally even while their `*AutoFov` toggles were on, where the percent is inert/misleading.
|
||
|
||
**Status (from §F table):** **FIXED (port-only, `port/src/optionsoverlay.c`).** (a) The panel now computes how many rows fit the *actual* current 2D height (`maxVisibleRows()` from `viGetY()`) and scrolls: a visible-row window (`s_scroll`) follows the selection, driven by wheel (`optionsOverlayScroll`) and arrow/D-pad at the list edges; layout, mouse hit-testing (`overlayRowAtY` now returns a visible position) and keyboard nav all index the visible list (`s_visIdx`/`s_visN`, `s_sel` is now a visible-list index), so it fits both 240- and 330-tall spaces with no per-space tuning. (b) `ROW_MSAA` wraps `(idx + dir + 4) % 4` — OFF→2x→4x→8x→OFF in both directions, matching the already-wrapping `ROW_ENUM`/`ROW_RES`; sliders keep clamping (standard for drag-able values). (c) New `Row.hiddenIfOn`/`hidePtr` fields: a row is hidden while the named option is nonzero — `Draw distance %` hides under `Video.DrawDistanceAutoFov`, `LOD distance %` under `Video.LodDistanceAutoFov`; rebuilt every frame in `overlayUpdateVisible()` (selection clamped into range when its row vanishes). Hint line updated to "click / drag / arrows / wheel". **Verified:** compiles clean in the full 241-target build; final link of this session was blocked only by a running game instance holding the exe (re-link pending). Not yet eyeballed live: scroll feel at 320×240, hover/scroll interaction, and the auto/% row appear-vanish transitions. Cross-ref D213 (FPS readout in the same file), D218/D249 (the auto-coupling these rows expose). **Follow-up after live test:** (d) list navigation (wheel + arrows) wrapped at both ends, so scrolling past the bottom made the highlight "repeat" from the top — now clamped at both edges like a normal PC settings list; (e) new `Input.SensLink` toggle (default ON, registered in `port/src/input.c`) links the aim/turn sensitivity knobs: moving either rescales the other to hold the stock 38 (`Input.AimModeSens`) : 50 (`Input.MouseTurnSpeed`) ratio, via a re-entrancy-guarded pair-write in `rowSet()`; (f) the "Mouse aim speed" row now edits `Input.AimModeSens` (the knob the default GEPD aim path actually uses — it previously edited `Input.MouseAimSpeed`, which only feeds the legacy velocity-stick fallback and was inert under default settings). **Second follow-up:** hover-to-highlight removed entirely -- moving the mouse over the panel no longer moves the selection or scrolls; near a list edge the hover->select->scroll-shift->new-row-under-cursor feedback loop made the bottom of the list twitch/echo (the persistent "duplicate row" report). Selection now moves only by explicit click (which also selects the clicked row), wheel, or arrows. **Reassessment of the 4K-fullscreen ghost-row bug:** the presentation-UV-wrap theory is DEAD — `gfxFramebuffer` has zero readers (the textured-quad present never happens); presentation is a full-frame `glBlitFramebuffer` from the same-size MSAA FBO to the default framebuffer (fb index 0 is reserved for the window, `gfx_opengl_init`), which has no UVs and cannot wrap. The overlay DL draws each visible row exactly once, in-bounds, with its own scissor. Local repro (maximized 16:9 window + MSAA=4 + `GE_OPTIONSOVERLAY=1` + `GE_PCDUMP`) shows NO ghost — the FBO/MSAA path alone is not the trigger; the remaining variable is true fullscreen on the user's 3072×1728 display (driver/display-level suspect). Next: user bisection — Fullscreen ON + MSAA OFF (restart) → does the ghost persist? **User follow-up (logged, not fixed):** the 4K-fullscreen ghost is *intermittent* across launches, and the user suspects the `hiddenIfOn` appear/vanish transitions of the Draw/LOD distance % rows (visible-list resize while the panel is open) — unverified; note `overlayUpdateVisible()`+`overlayUpdateScroll()` are re-run together in both emit and input each frame, so a stale-state window would have to come from elsewhere (e.g. the one frame a row count changes). MSAA-off bisection still pending. User: in the sessions seen so far the ghost is present on *first open* of F10 (not only after an auto-toggle flip) — weakens but does not kill the appear/vanish theory (toggles had flipped in earlier sessions before a restart). **v0.2.0-pre playtest (user, latest build):** the symptom has changed — the panel's *bottom row now deterministically duplicates whatever item is currently selected* (e.g. MSAA appears both on its own row and again at the bottom), not just an intermittent 4K/MSAA ghost of the top row; suggests the visible-row window can contain the selected row twice (off-by-one in `overlayUpdateScroll`/`maxVisibleRows` bookkeeping) — investigate post-release. **QoL follow-up:** make the auto-linked % rows visually subordinate to their toggle (indent and/or dimmer value colour) so the pairing is obvious in the panel; hint line also reworded to "select: scroll/click, change: mouse/arrows".
|
||
|
||
**Closed (2026-09-18):** the last open symptom — the bottom row deterministically duplicating the currently selected item — no longer occurs per user live playtest of the D304 build (D304's `overlayUpdateScroll()` minimal-scroll rewrite is the fix; the old unconditional bottom-anchor forced the selected row into the last visible slot on every click, which is exactly the duplication mechanism the v0.2.0-pre playtest suspected). No separate D251 code change was needed.
|
||
|
||
## D253 — Facility (and any level with a FORCECROUCH/LADDER linked special tile) SIGSEGV on Linux/Steam Deck — `stanCheckLinkedSpecialTile` stores to a garbage…
|
||
|
||
**Facility (and any level with a FORCECROUCH/LADDER linked special tile) SIGSEGV on Linux/Steam Deck — `stanCheckLinkedSpecialTile` stores to a garbage `outFlags` pointer (D203 root cause).** The B-callback typedef `standTileLocusCallback_B_t` (`bondtypes.h:573`) declares the middle three params as `f32 f32 f32`, so on x86-64 SysV the caller (`sub_GAME_7F0B1DDC`, `stan.c:2228`) passes the 6th arg (`record`) in **`%rdx`** (3rd integer slot; the floats take xmm0–2). But the actual implementation `stanCheckLinkedSpecialTile` was decompiled with an all-`s32` prototype, so it reads `outFlags` from **`%r9`** (6th integer slot) — a stale scratch value left by earlier callees. On the N64 (MIPS o32) both prototypes agree: everything flows through GPRs a0–a5 in order, 6th arg = a5 either way, so the original is fine and the decompilation looked consistent. The mismatch stays dormant until a tile actually hits the FORCECROUCH or LADDER branch and *stores* through `outFlags` → SEGV; that's why it was level-specific (Facility's opening crouch tile) and input-dependent, and why idle repros were negative. The sibling B-callback `stanLocusEdgeIsAboveY` already had the correct float prototype, which is why only this path was booby-trapped. **Fix (ABI-class, semantics-preserving):** declare the three middle params of `stanCheckLinkedSpecialTile` as `f32` in both the forward decl and definition (`stan.c:152`, `stan.c:2407`) to match the typedef; the body never uses them, and on N64 o32 f32 args occupy the same GPRs, so N64 codegen is unaffected. **Verified:** gdb run on WSL (gcc 15) — pre-fix, SIGSEGV at `stan.c:2441` with `r9=0x3` and valid `record=0x409fea00` in the caller frame; post-fix, the same walk route completes with no crash. Side benefit: the FORCECROUCH/LADDER branches were effectively dead-on-arrival on PC (any hit = crash); they now function. **Also observed post-fix (separate, WSLg-only):** a segfault *inside libgallium* during `gfx_opengl_upload_texture` (32×48 RGBA + mipmaps) while walking further — GL-driver-side under WSLg's Mesa; not reproducible on Windows; park as a new finding if it recurs on real hardware.
|
||
|
||
**Status (from §F table):** FIXED — Deck-verified: user got past the crouch tile with the re-cut bundle (a *new*, later crash is D255).
|
||
|
||
## D254 — Linux crash handler destroyed its own logs: `ge007.crash.log` only ever contained the SIGABRT block, never the original SEGV.
|
||
|
||
**Linux crash handler destroyed its own logs: `ge007.crash.log` only ever contained the SIGABRT block, never the original SEGV.** The POSIX `crashHandler` (`port/src/crash.c`) finished by calling `sysFatalError()` → `abort()` → SIGABRT, which re-entered the *same* handler (SIGABRT was one of the installed signals); the second pass reopened the log with `"wb"` and overwrote the first pass's SEGV block — faulting PC, signal, everything. The Deck firefight crash (D255) came back as a bare `SIGNAL: 6` abort record for exactly this reason, forcing offline disassembly of the CI binary to recover what the log should have said. **Fix (port-only):** re-entrancy guard (`inCrashHandler`, second pass `_exit(128+sig)` immediately) + terminate directly with `_exit()` instead of `sysFatalError()` (the Windows path already terminated directly for the same reason) + the Linux block now dumps the faulting context: `FAULT ADDR` (`si_addr`) and the full general register set (Rax/Rcx/Rdx/Rsi/Rdi/R8–R11, Rsp/Rbp) from the `ucontext`, so a field crash now names the bad pointer on its face. **Field-verified 2026-09-13:** the D255 Deck crash came back as `SIGNAL: 11` + `FAULT ADDR: 0x10` + full register set — exactly the format designed here; it directly identified the NULL-model case (see D255).
|
||
|
||
**Status (from §F table):** FIXED — field-verified.
|
||
|
||
## D256 — Saves (and config) silently failed to persist on Linux whenever the game was not launched from the directory containing `data/` — reported as "saves do not…
|
||
|
||
**Saves (and config) silently failed to persist on Linux whenever the game was not launched from the directory containing `data/` — reported as "saves do not work at all" on Steam Deck.** `romdataInit` finds the ROM in `$S/` (CWD `data/`) *or* `$E/` (exe dir) *or* `./`, but `sysResolvePath`'s POSIX `$S` branch (used by `ge007.eep` and `ge007.ini`) only ever produced a CWD-relative path — its "fallback" was `./data/`, identical to the primary. Launch from any other CWD (Steam non-steam-game shortcut, terminal in `~`): ROM loads via the exe-dir candidate, game runs, but every EEPROM/config write hits `fopen(..., "wb")` on a nonexistent `./data/` and fails with a stderr-only "cannot write" — saves never persist, and **every launch also starts with default settings** (F10 changes don't stick), which presents as "draw distance/LOD is low" on levels like Dam where the user had raised `Video.DrawDistance` on another machine. The Windows branch already did CWD-else-exedir; the POSIX branch now mirrors it (`readlink("/proc/self/exe")`) and creates the directory best-effort so first-run writes don't fail. **Verified on WSL:** with exe+`data/` in `/tmp/fb2` and CWD=`/`, config/eeprom/ROM all resolve into `/tmp/fb2/data/` and a 25 s run completes clean; the pre-fix binary in the same layout fails to find the ROM at all. **Caveat:** if the user's Deck launch *was* from the bundle dir (CWD had `data/`), this fix does not explain their save failure — need the save-flow detail (does the memory-card screen appear? slot confirm? "saved" message?) before closing the report. Side note found while testing: a `data/` without the generated sidecars (`pccg-*/`, `pcmodels-*/` from `tools_pc/d69_emit.py`/`d43_emit.py`) starts fine but faults in `load_bg_file` (`bg.c:865`) on level load — the reserve functions warn "stage loads will fault" and return 0; expected degraded mode, not a new bug.
|
||
|
||
**Status (from §F table):** FIXED (path half) — save-flow detail still owed from the user.
|
||
|
||
## D257 — New `Game.AllUnlocked` option (F10; default flipped OFF for v0.2.0 — see D259): skip N64 progression entirely — all 21 solo levels selectable at every…
|
||
|
||
**New `Game.AllUnlocked` option (F10; default flipped OFF for v0.2.0 — see D259): skip N64 progression entirely — all 21 solo levels selectable at every difficulty, 007 mode available, and the cheat menu fully populated. Port-layer only; no game-code edits.** Three mechanisms, all in `port/`: (1) `main.c` seeds the two live `LEFTOVERDEBUG` RAM flags after `configLoad()` — `debug_enable_all_levels_flag` (all levels) and `debug_007_unlock_flag` (007 mode; both in `src/game/debugmenu_handler.c`, same class as the existing `GE_UNLOCK_ALL` getenv hook in the getter); (2) the eep shim (`libultra.c geEepromPatchAllCheats`) patches the save block at read time — special-casing `fileValidateSaves`'s exact pattern (block 4, 5×96 bytes) — setting all 20 progression-gated cheat-unlock bits per slot (`unlocked_cheats_1/2/3`; the cheat menu row on mode-select only renders when ≥1 is set, `front.c interface_menu06_modesel`) **and** filling every *empty* stage/difficulty completion-time field with the max time 0x3FF (existing player times preserved); both are then CRC'd per slot with the game's own `fileGenerateCRC` or `fileValidateSaves` would wipe the slots. (3) `optionsoverlay.c` row so users can toggle OFF for faithful progression. **Why the completion-time half is required (found by user repro):** when ANY single-player cheat is active, `g_AppendCheatSinglePlayer` makes mission-select (`get_highest_unlocked_difficulty_for_level`, front.c) accept only levels with status `STAGESTATUS_COMPLETED (3)`, not merely `UNLOCKED (1)` — AllUnlocked's debug-flag path returns 1, so enabling a cheat locked every level ("could only do Silo"). Reading the save as a fully-completed campaign satisfies both branches. Time bit-math mirrors `fileGet/SetSaveStageDifficultyTime` verbatim (10-bit fields, difficulties 0–2; 007 times are virtual). **Pitfall hit during implementation:** the first mirror of `save_data` used `times[80]` instead of `(SP_LEVEL_MAX-1)*4 = 76`, making `sizeof` 100≠96 so the block-read size check silently never matched — now enforced by a compile-time `ge_save_slot_size_check`. **Semantics:** unlocked cheats/completions persist to `ge007.eep` on any subsequent save write (self-consistent; mirrors N64 where completing a level permanently unlocks its cheat). No *active* cheats are enabled (invincibility/all-guns stay OFF); weapons remain per-mission pickups. **Verified:** fresh eep — all 5 slots validate `stored==calc` under `GE_SAVELOG=1`; user playtest: all levels selectable at each difficulty, 007 mode present, cheat menu populated, cheats toggle and work (time-modifying cheats untested by user), mission select intact with all cheats ON.
|
||
|
||
**Status (from §F table):** FIXED — verified by user playtest (PR #79 merged); default flipped OFF for v0.2.0 (D259).
|
||
|
||
## D259 — `Game.AllUnlocked` (D257) breaks audio on a fresh save: with it ON, music and SFX load at volume 0 (silence). v0.2.0 ships with `AllUnlocked` default OFF.
|
||
|
||
**`Game.AllUnlocked` (D257) breaks audio on a fresh save: with it ON, music and SFX load at volume 0 (silence). v0.2.0 ships with `AllUnlocked` default OFF.** User-observed during v0.2.0 artifact testing ("all unlocked seems to break sound"). **Mechanism:** a fresh `ge007.eep` is zero-filled, so every save slot is all-zero. On N64 such a slot fails `fileValidateSaves`' CRC check and is rewritten as `BLANKSAVEDATA` (`src/bondconstants.h:2148`), whose `music_vol`/`sfx_vol` are `0xFF` (max). With AllUnlocked ON, `geEepromPatchAllCheats` (`port/src/libultra.c`) patches the zeroed slots (cheat bits + completion times) and recomputes a **valid** per-slot CRC, so `fileValidateSaves` accepts them as-is and never calls `fileResetSave`. The volume bytes therefore stay 0, and `fileLoadSettingsForFolder` (`src/game/file2.c:1289-1290`) loads them verbatim: `set_mTrack2Vol((save->music_vol << 7) | (save->music_vol >> 1))` = 0 → silence. With AllUnlocked OFF the zeroed slots fail CRC and reset to `BLANKSAVEDATA`, which is why sound works in the default configuration. **Disposition for v0.2.0:** flip `portAllUnlocked` default to 0 (`port/src/video.c`; `SkipIntro` was already 0) — faithful N64 progression out of the box, F10 *All unlocked* still enables the goodie. The quirk then only affects users who deliberately toggle it ON with a fresh save (recoverable: raising the in-game volume writes a nonzero slot, after which the patch leaves volumes alone). **Deferred fix candidate (port-only, ~6 lines):** in `geEepromPatchAllCheats`, detect all-zero slots and seed `music_vol`/`sfx_vol = 0xFF` before the CRC recompute, emulating what `fileResetSave` would have done; real (nonzero) saves incl. a user-muted one stay untouched.
|
||
|
||
**Status (from §F table):** **FIXED in v0.2.1** — the deferred fix was applied: `geEepromPatchAllCheats` now detects all-zero slots and seeds `music_vol`/`sfx_vol = 0xFF` before the CRC recompute, emulating `fileResetSave`; real saves (incl. a deliberately muted one) stay untouched. **By-ear confirmed by the user** (v0.2.1 test pass: fresh save, AllUnlocked ON — audio plays correctly).
|
||
|
||
## D260 — v0.2.1 (user request): mouse-wheel weapon-select direction swapped.
|
||
|
||
**v0.2.1 (user request): mouse-wheel weapon-select direction swapped.** Wheel-up now selects the *previous* weapon and wheel-down the *next*, matching the in-game wheel menu's on-screen layout (the port had them inverted vs the user's expectation). Port-only: `inputPostWheel()` (`port/src/input.c`) — the notches>0 / notches<0 branches were swapped so scroll-up emits the back-notch and scroll-down the forward-notch. N64 build untouched; no game-code edits.
|
||
|
||
**Status (from §F table):** FIXED — build green; one-line branch swap, trivially reviewable.
|
||
|
||
## D261 — v0.2.1 (user request: "fix watch menu sensitivity for wasd/controller buttons"): holding W/S (or a fully-pressed stick axis) on the watch inventory page…
|
||
|
||
**v0.2.1 (user request: "fix watch menu sensitivity for wasd/controller buttons"): holding W/S (or a fully-pressed stick axis) on the watch inventory page crawled one item per tap — no repeat.** Root cause is the interaction of two existing behaviours: keyboard W/S map to `stickY = ±STICK_MAX (0x50)` and SDL pads peg at 0x80, both *outside* the smooth-scroll band (0x1f..0x45) that D118d intentionally left for the port — so a held press only ever hit the latched single-step path (`watch_stick_y_pressed_*`, one step per press edge, no repeat). On N64 the same stick level was the *fast-scroll* band (one step per frame), which D118d removed because digital sources peg there and it became runaway scroll. **Fix (port-only, `#ifdef PORT` in `src/game/options.c`):** a bounded auto-repeat helper `geWatchStickFastStep()` — while the stick is held past the smooth band (≥ 0x46 up / ≤ −0x45 down), step one extra item every `GE_WATCH_NAV_RATE` (6) frames after an initial `GE_WATCH_NAV_DELAY` (15)-frame hold; the first press still goes through the untouched latched path, so a tap behaves exactly as before, and the smooth band + D-pad/C-button edge-repeat are unchanged. Called from `game_options_inventory_navigation()` only. **Verified:** build green; headless smoke (`GE_INPUTSCRIPT` long-W-hold on the inventory page) runs clean. Feel-check (repeat rate) owed to the user — both constants are at the top of the helper for a one-number tune. **User feel-check (v0.2.1 test pass): repeat "feels fine for now", but W/S is still too sensitive overall — desired behaviour is tactile one-press-one-item with no risk of scrolling the whole list on an over-held key; candidate tweaks (not yet done): longer `GE_WATCH_NAV_DELAY`, slower `GE_WATCH_NAV_RATE`, or gating the auto-repeat off for keyboard taps entirely (repeat only for analogue stick). Logged, no code change.
|
||
|
||
**Status (from §F table):** FIXED — feel-check received; W/S sensitivity follow-up logged above.
|
||
|
||
## D262 — v0.2.1 (user report): "3D models missing on watch menu screen item select" — the selected-item 3D preview does not appear on the watch inventory page.
|
||
|
||
**v0.2.1 (user report): "3D models missing on watch menu screen item select" — the selected-item 3D preview does not appear on the watch inventory page.** User visually confirmed this session: no 3D model on the item-select page where one should be (default selection is *unarmed*, which has no model — a weapon must be scrolled to with S/DOWN). Headless repro (`GE_INPUTSCRIPT` pulse train + `GE_PCDUMP`, Dam `-level_33`): watch can't open during the intro freeze (`bondviewFrozenMoveBond` zeroes input while `g_CameraMode == CAMERAMODE_INTRO`; freeze ends ~frame 1400–1500 on Dam, and controller reads run faster than sim ticks, so scripts are timed by trial); once open + paged to INVENTORY (R_JPAD), the watch UI renders but the centre of the face is empty for every item. **Root cause found and fixed as D264** (`renderdata.flags == 0` in the watch subdraw pass — the cross-global ModelRenderData template read zeroes on PC; `subdraw()` then gated *every* geometry node, emitting exactly 1 word instead of the model's commands). Residual: with flags restored, the preview emits matrix/light-setup words (71 for item 5) but dump frames still show no visible centre geometry — a possible second layer (LOD/distance gating or fast3d rasterisation of the sub-DLs) remains unverified; user visual re-check pending.
|
||
|
||
**Status (from §F table):** FIXED (primary cause, D264) — pixel-level confirmation pending user run.
|
||
|
||
## D264 — D262's root cause (ABI/layout class, cf. D140/D52): the watch item preview's `ModelRenderData` template is read across two adjacent globals that no longer sit…
|
||
|
||
**D262's root cause (ABI/layout class, cf. D140/D52): the watch item preview's `ModelRenderData` template is read across two adjacent globals that no longer sit adjacent on PC.** `set_enviro_fog_for_items_in_solo_watch_menu()` (`src/game/gunfire.c`) starts with `ModelRenderData renderdata = *((ModelRenderData *) (&D_80035D00));` — a 64-byte copy that on N64 spans `D_80035D00` (a zeroed u32, .bss) *and* `D_80035D04[] = {1, 3, 0…}` (.data): the effective template is the one documented in the comment above `D_80035D00` in `gun.c` — `basemtx=NULL, zbufferenabled=TRUE, flags=3, rest zero` (`CULLMODE_BOTH` is 0). The PC link places .bss and .data in separate sections, so the read is all zeros: **`flags==0`**, and every geometry emitter in the subdraw node dispatch gates on `renderdata->flags & 1/2` (e.g. `modelRenderNodeDl`) — so `subdraw()` emitted exactly one word (`gSPSegment`) per preview, no matter the item. Probes this session: entry gates all pass (hand has item, model header non-NULL), node tree present (~400 nodes, isLoaded=1), world-path geometry nodes carry valid built DLs — but in the watch pass `flags=0` on every node. **Fix (`#ifdef PORT`, values identical to the N64 ROM bytes):** explicit template — `ModelRenderData renderdata = {0}; renderdata.zbufferenabled = TRUE; renderdata.flags = 3;`. Verified at Gfx level: preview emission went 1 → 71 words (item 5) / 81 (mid-scroll) / 9 (item 47, knife-class). Note for follow-up: the 71-word dump is a repeating {matrix/segment/light} unit with no `gSPDisplayList` in the first 24 words — if the user's re-check still shows no visible model, next suspects are LOD/distance gating of the local watch-space matrices (`modelUpdateDistanceRelations`, cf. 7c48f664) or fast3d handling of the preview sub-DLs (segment 3 / render modes).
|
||
|
||
**Status (from §F table):** FIXED — build green, probes removed; visual confirmation pending.
|
||
|
||
## D263 — v0.2.1 (user report): Facility — "Ourumov not shooting Trevelyan"; the scripted opening beat never triggers. RESOLVED: not a port bug — the beat is…
|
||
|
||
**v0.2.1 (user report): Facility — "Ourumov not shooting Trevelyan"; the scripted opening beat never triggers. RESOLVED: not a port bug — the beat is proximity/LOS-triggered exactly as on N64, and the full chain (incl. the execution shot) was verified firing end-to-end on PC.** The triage suspects (tick pacing, camera-mode gate, silent anim lookup) were all ruled out at runtime. **The beat, fully mapped** (static decode of `UsetuparkZ.c` — remember every DBYTE/TBYTE in the dump is bswap16/32'd relative to game interpretation): Trevelyan = guard chr 67 (body 9 = Trevelyan_006, pad 131) on ai_18: walks to pad 124, wanders, then loops a sees-Bond check; **only when Bond is in his vision** (range <10k, facing ±110°, fog, random gate ≈1/49/tick, LOS + *same stand tile* via `chrCanSeeBond`) does he jump to ai_19: runs at Bond, alarm + dialogue timers, sets an objective bit; BG list ai_41 (ID 0x1000, auto-started) polls that bit and spawns **Ourumov (body 7) at pad 88 with ai_23** plus a 6-soldier squad; ai_23 renumbers him to chrnum 78 and arms him → ai_22: runs to pad 123, waits for Trevelyan within 0x1400 of it, ~95 s dialogue cascade, then the **execution shot** (`HitChrWithItem` from 78 on 67). It is a multi-minute sequence that *starts* only when the player walks Bond west and down a floor into Trevelyan's room — Bond spawns 8900 units away on the upper floor with LOS blocked (probed directly: `stanTestLineUnobstructed` = obstructed, different stan tiles), identical geometry to N64. **Runtime verification** (`-level_34`, `#ifdef PORT` probes in the AI executor/vision path, all since removed): all 15 BG lists + guards start; Trevelyan alive on ai_18; every vision gate passes except the geometric ones at spawn distance; player movement confirmed working (note for future headless tests: under the port's default SOLITARE/natural-pitch scheme, walking is C-button/D-pad fed — W→C-up — and the analog stick carries pitch, so `GE_INPUTSCRIPT` SUP/SDOWN tokens do *not* walk Bond there; use UP/DOWN pulses). **Decisive test:** a temporary PORT-only teleport probe dropped Bond 65 units from Trevelyan at t≈12 s → seconds later: vision fires (chr 67 → ai_19), objective bit set, `SpawnChr body=7 pad=88` + squad, chrnum→78 on ai_23; ~2½ min later **`HitChrWithItem from_chr=78 target=67 part=8 item=6`** — Ourumov executes Trevelyan. Most likely explanation of the report: the tester never walked Bond into Trevelyan's sightline (or pre-D253 crash masked it). No game-code or port-code change required. **Kept from this session (test-harness only):** `port/src/input.c` — `GE_INPUTSCRIPT` analog-stick tokens were applied *after* the stick was already written out, so SUP/SDOWN/SLEFT/SRIGHT were silently discarded; the script override now runs before the clamp/write.
|
||
|
||
**Status (from §F table):** RESOLVED (not a bug), but refined (v0.2.1 further user testing, 2026-09-15) — beat verified firing end-to-end on PC incl. execution shot; **user playtest confirmed the sequence plays normally.** **Refinement: the exact trigger behavior is significantly sensitive to Bond's precise location and does not fire 1:1 identically to the N64 original across different approach paths/positions** — consistent with the beat's own documented sensitivity (range/facing/LOS/same-stand-tile + a ≈1/49-per-tick random gate, see the mapped mechanism above), so this is likely the same finicky-trigger behavior rather than a new bug, but not confirmed byte-identical to N64 for every approach. **User confirms this does NOT block level progression** either way — logged for awareness, not tracked as an open defect. Probes removed; input-script stick fix kept. **M-141: re-confirmed by user, one more mechanism now documented — the standoff has a hard TIMEOUT fallback, not just the shot-miss/objective-bit gates.** User reported (misread at first as a new freeze) reaching the Ourumov/Trevelyan standoff, waiting without shooting or triggering anything else, and after a couple of minutes Ourumov delivered his lines and executed Trevelyan on his own — **this is by design**: `ai_22`'s label `0x05` OR-gate (four early-exit conditions: two objective bits, a "Bond's shot missed" check, and a third objective bit set by an unrelated guard's surrender elsewhere in the level) is not the only path forward — falling through the whole dialogue-timer cascade (`label 0x42`'s chain of `if_local_timer_greater_than` checks) reaches `label 0x13`, which unconditionally fires `chr_hit_body_part_with_item_damage(0x43, 0x08, 0x06)` (the execution) once enough real time has elapsed, no player action required. Matches this row's own earlier "~2½ min later" measurement. **Do not re-flag "stuck standoff" as a bug without confirming the user waited the full timeout window first.**
|
||
|
||
## D265 — D236 investigation (port-layer, `port/fast3d/gfx_pc.cpp`): the tree cards are IA8/IA4, not IA16 — and `import_texture_ia4` decoded the RDP's I2:A2 texel as…
|
||
|
||
**D236 investigation (port-layer, `port/fast3d/gfx_pc.cpp`): the tree cards are IA8/IA4, not IA16 — and `import_texture_ia4` decoded the RDP's I2:A2 texel as I3:A1, inverting the alpha ramp. Fixed; awaiting user visual re-test.** The earlier sessions misidentified the format: a live tri-time tile census (probe in `gfx_sp_tri1`, since removed) shows level_36's tree-card classes are (a) **IA8, line=64B → 64×16** (texdump r107), drawn OPA/bilerp/1-cycle/AC_NONE — *genuinely fully opaque* (A nibble F on 1023/1024 texels; opaque on N64 too) — and (b) **IA4, line=64B → 128×16** (texdump r117), drawn ZMODE_XLU/point/2-cycle with m2a=CLR_MEM/m2b=1MA, i.e. texel-alpha blended. There are no 32-wide IA tiles at tri time; the prior "32×32 tile" reading was wrong, and the whole earlier IA16 byte-pair/I11:A5 debate (r107) was analyzing an IA8 image as byte-pairs. **The wall's root cause:** r117 decoded as I2:A2 is a strip of ~13 discrete conifer silhouettes (trunk+canopy shapes visible in the ASCII render, `scratch/r117_ia4_alpha.png`) with a 3-level alpha ramp {0: 95% of texels, 85, 170} — faint dithered distant trees on N64. But `import_texture_ia4` read each nibble as intensity=`part>>1` (3-bit, SCALE_3_8) + alpha=`part&1`: A2=1 canopy pixels became **fully opaque**, A2=2 edge dithering became **invisible** → opaque repeating tree silhouettes = the "wall". The bug is inherited verbatim from the PD port (`pd_port/port/fast3d/gfx_pc.cpp` has the identical I3:A1 read — latent there, fatal for GE's tree strip). **Fix (applied):** `intensity = part>>2`, `alpha = part&3`, both via new `SCALE_2_8`; this is the hardware-correct RDP IA4 layout. Also cleaned up: the ia16 importer comment now states plain I8:A8 (the byte-pair read *is* correct IA16; the old "r107 proof" was misidentified data), and the earlier part-1 dimension fix stays (harmless/correct for real IA16 tiles). **Caveats/owed:** (1) user visual re-test of Surface 1 trees — headless A/B was in flight when work halted; a cross-run region diff was inconclusive (camera drift between captures); (2) the decode change affects *every* IA4 tile game-wide — regression-sweep other levels for dithered/alpha IA4 art that now renders with correct 3-level alpha; (3) if a hard repeating band remains after the fix, it is the opaque IA8 strip (a), which matches hardware and D236 needs re-scoping. **OUTCOME (user re-test): NEGATIVE — trees still render as the wall with the I2:A2 decode applied; the change was fully reverted (`git checkout` of `port/fast3d/gfx_pc.cpp`, back to the original PD-derived importers) and work is parked.** The census/RM/texture data above stands as evidence for the next attempt: the decode was *necessary* (I2:A2 is the hardware layout and r117 is unambiguously an authored tree strip under it) but *not sufficient* — remaining suspects, in order: (i) fast3d's blending of these XLU/2-cycle cards (texel alpha reaching the GL blend at all — the `texture_edge`/`SHADER_OPT_TEXTURE_EDGE` path forces `a>0.19 ? 1 : discard`, which for a 3-level ramp quantizes to opaque/discard, i.e. still hard-edged), (ii) per-card UV windowing/placement (all captured cards sampled the same u:0..64/v:0..16 window — confirm against N64), (iii) fog/distance. Note for the next session: verify what the N64 actually shows in that region before more port-side changes (the opaque IA8 strip is faithful to hardware either way).
|
||
|
||
**Status (from §F table):** PARKED — reverted to baseline; evidence + suspect list above.
|
||
|
||
## D258 — PAL/JP asset conversion is broken at the source-data level — `scripts/filelist.e.csv` and `filelist.j.csv` carry naming defects, so `gen_romassets.py e/j` (and…
|
||
|
||
**PAL/JP asset conversion is broken at the source-data level — `scripts/filelist.e.csv` and `filelist.j.csv` carry naming defects, so `gen_romassets.py e/j` (and the d43/d69/d88 emitters' `find_row`) cannot resolve PAL/JP builds. v0.2.0's drop-in ROM feature therefore ships NTSC-U only.** Discovered while scoping issue #6 (drop-in ROM). **PAL:** 31 of the 512 model rows in `filelist.e.csv` have truncated/renamed basenames (an extraction artifact — e.g. `Pbridge_console` ×6 where the resource table says `Pbridge_console1aZ…3bZ`, `Pchrgrenadelauncher` for `PchrgrenadelaunchZ`). **JP:** one font row is mislabeled — `fontBankGothic_fontchartable.bin` @3044276 is named `fontZurichBold_fontchartable.bin` (a duplicate label). Both were proven without a PAL/JP ROM: (a) greedy two-pointer sequence alignment of the 512-row `file_resource_table.inc.c` model order against each CSV's model rows gives an exact 1:1 match (481 by-name + 31 positional for PAL), offsets strictly non-decreasing, and zero size anomalies vs US (all within [0.5×, 2×]); (b) JP font row proven by the contiguity invariant — Gothic kerning @3043600+676 = 3044276 exactly hits the mislabeled row, whose size (8716) equals the US Gothic chartable, while Zurich's own chain is contiguous one row later. **Consequence:** no PAL or JP PC exe has ever been buildable from this tree (latent — only NTSC was ever exercised); the CSV `file` column is a label only (emitters look up by basename, never read the path), so the repair is a pure data fix: rename the 31 PAL model rows + 1 JP font row to the exact table symbols. **Scope decision (v0.2.0):** ship drop-in ROM support for NTSC-U only — `prepare-assets.py`/the frozen `ge007-convert` gate non-US ROMs with a clear message after SHA-1 region detection; the CSV repair + multi-region matrix + per-region exes are deferred to the Part B follow-up.
|
||
|
||
**Status (from §F table):** DEFERRED (user call 2026-09-28: v0.4.0 ships NTSC-only, README-disclosed; the verified repair path is post-v0.4 backlog). Superseded: OPEN — repair path verified, not applied (out of v0.2.0 scope).
|
||
|
||
## D279 — Backfilled — this label was consumed by an undocumented partial session (2026-09-14 ~23:00) that left artifacts in `scratch/` (`d279_run.log`…
|
||
|
||
**Backfilled — this label was consumed by an undocumented partial session (2026-09-14 ~23:00) that left artifacts in `scratch/` (`d279_run.log`, `d279_imgmap_run.log`, `d279_g110.png`, `d279_g220*.png`, `d279_gray_1500.png`) but no written conclusion; the logs carry no D279-tagged lines, so its exact probe/finding cannot be reconstructed from the tree.** Next available label after it: D280.
|
||
|
||
**Status (from §F table):** undocumented — artifacts only
|
||
|
||
## D281 — `Game.AllUnlocked` (D257) still breaks audio, and can also break right-mouse aim, when enabled before any save has ever been written (v0.2.1 user testing…
|
||
|
||
**`Game.AllUnlocked` (D257) still breaks audio, and can also break right-mouse aim, when enabled before any save has ever been written (v0.2.1 user testing, 2026-09-15) — a distinct regression from D259, which only fixed the all-zero-slot volume case.** User's empirical workaround: complete a level normally first (e.g. Dam on Agent, which writes a real save), THEN enable `All unlocked`; enabling it on a completely fresh install (no save yet) still reproduces both symptoms. Not root-caused this session — D259's `geEepromPatchAllCheats` all-zero-slot seeding was verified against exactly the "fresh eep" case, so either that fix doesn't cover every code path a truly-first-launch toggle can hit (e.g. a race between the F10 toggle taking effect and the very first save-slot creation/read), or there's a second, independent mechanism. The right-mouse-aim symptom is new information with no prior lead — cross-ref D211/D194 (aim-mode sensitivity/mode family) for where a save-state-dependent aim regression could originate.
|
||
|
||
**Status (from §F table):** OPEN — user-reported regression, not root-caused; mitigation is user-facing guidance only (release notes: don't enable until a save exists, treat as experimental).
|
||
|
||
**Root cause + fix (2026-09-23, v0.4.0):** D259 emulated only part of the fresh-slot path. Without AllUnlocked, an all-zero slot fails `fileValidateSaves`' CRC → `fileResetSave` → a *free* `BLANKSAVEDATA` slot (DORESET, vol 0xFF, `options = DEFAULT_OPTIONS` 0x3A), and then `fileBuildWriteNewSave` fills the first free slot for each folder without a save. The AllUnlocked read patch re-CRCs every slot, so all-zero slots instead **survived** as five non-free slots all claiming folder 1, with `options = 0` (sight-on-screen OFF = no crosshair when aiming, which is the "RMB aim broken" symptom; auto-aim, look-ahead and ammo display also off). The game then reset/rebuilt the duplicate folder slots mid-validate. **Fix (`port/src/libultra.c` `geEepromPatchAllCheats`, port-only):** all-zero slots take the folders no real slot holds, in order, built exactly as `fileBuildWriteNewSave` does (`BLANKSAVEDATA` + `fileSetSaveFoldernum`/`fileSetSaveFlagDoReset(FALSE)`/`fileSetSelectedBond`, the game's own setters); leftovers become free `BLANKSAVEDATA`. Real saves keep their bytes. **Verified (native Windows, fresh zero eep, `AllUnlocked=1`, `SkipIntro=1`, `GE_SAVELOG=1`):** `fileValidateSaves` now sees `00/21/42/63/80` (folders 1–4 + one free slot, the layout a normal fresh install produces), all CRC OK and no repair writes; the eep on disk stays pristine (read-time patch). Before: five `00` slots. Golden 3/3 (AllUnlocked off by default, so the change is inert there); Windows + Linux builds clean. **Owed:** the audio half of the report was never mechanised. The volumes were already right after D259, and the folder-collision churn this removes is the most plausible remaining cause. User check: fresh install (move `ge007.eep` aside) → enable All unlocked → play a level; audio and RMB-aim crosshair should both work.
|
||
|
||
## D282 — QoL ask (v0.2.1 user testing, 2026-09-15): front-end menu navigation (main menu, file select, mission-select map) currently requires the RIGHT analog stick —…
|
||
|
||
**QoL ask (v0.2.1 user testing, 2026-09-15): front-end menu navigation (main menu, file select, mission-select map) currently requires the RIGHT analog stick — matches N64 default control schemes but is unintuitive on a modern controller/Steam Deck, where the left stick is the expected primary-navigation input.** Distinct from the F10 options overlay, which is already left-stick/D-pad navigable (`port/src/optionsoverlay.c`). Scope: front-end menu input in `src/game/front.c` (or wherever N64 stick reads are gated by the active `controldef`) — likely wants a port-layer input remap for these menu contexts specifically, not a general control-scheme change. **M-141: root cause found + fixed, port-only.** `front.c`'s menu-nav code reads `joyGetStickX/Y` — the same emulated N64 "Control Stick" channel (`stick_x`/`stick_y`) that in-level gameplay uses. `port/src/input.c`'s gamepad poll routes that channel to the LEFT physical stick by default, but `naturalPitchMode` (D194/D238's SOLITARE-style scheme, **default-on**) reroutes it to the RIGHT stick for continuous look — and since menus share the channel, they silently inherited that reroute too. The poll already computed an unused `padMenuMode` flag (`current_menu != GE_MENU_RUN_STAGE/GE_MENU_INVALID`, previously only gating shoulder-button weapon-cycle edges); moved it earlier and added a `padMenuMode` branch that always sources `stick_x`/`stick_y` from the left stick outside a running stage, regardless of `naturalPitchMode` — matching the F10 overlay's existing left-stick convention (`optionsoverlay.c`). Zero effect on in-level control feel (the branch is inert whenever `current_menu == GE_MENU_RUN_STAGE`). Verified: build green; `-level_09`/`-level_20` unregressed (1100+ frames each, no crash) — bare `-level_XX` launches skip the front end entirely, so this only proves the gameplay path (unaffected) stays clean. **In-menu feel not interactively verified this session** (no physical gamepad available in this environment) — needs a human playtest with a controller to confirm the menu now navigates on the left stick as expected.
|
||
|
||
**Status (from §F table):** FIXED (`port/src/input.c`, `#ifdef` not needed — port-layer-only file), feel unconfirmed.
|
||
|
||
## D283 — Steam Deck first-run preset (D-unnumbered in `port/src/video.c`/`main.c`, `videoApplySteamOSDefaults()`) did not apply for a user who added the game via Steam…
|
||
|
||
**Steam Deck first-run preset (D-unnumbered in `port/src/video.c`/`main.c`, `videoApplySteamOSDefaults()`) did not apply for a user who added the game via Steam in Desktop Mode and switched to Game Mode afterward — resolution stayed at some non-native value instead of the Deck's native 1280×800 (v0.2.1 user testing, 2026-09-15).** Root mechanism, confirmed by code inspection: the preset only applies when `getenv("STEAMOS")` is truthy AND no `ge007.ini` exists yet (`main.c:126-129`, before `configLoad()`); once any ini exists, it always wins over the preset on every future launch (by design, so user F10 changes persist). **Leading hypothesis (not verified on real hardware in this environment):** SteamOS's Desktop Mode (a normal Plasma/X11 session) likely does not export the same `STEAMOS` env var that the Game Mode/gamescope session does, so a Desktop Mode test-launch (a natural step when adding a non-Steam game via its file browser) creates `ge007.ini` with ordinary Linux defaults *before* the user ever reaches Game Mode — permanently skipping the preset for that install, since the ini already exists on the first real Game Mode launch. This exact caveat was already known internally (`docs/HANDOFF.md`: "a Deck that already has an ini needs `-fresh` or F10 to pick the preset up") but was never surfaced in user-facing docs (README/release notes) — same class of gap as the stale D230 release-notes reference. **Workaround (lighter than `-fresh`, which also wipes the save):** F10 → Resolution → 1280×800 manually; takes seconds, no data lost.
|
||
|
||
**Status (from §F table):** OPEN — mechanism understood, root cause (Desktop-vs-Game-Mode `STEAMOS` env var difference) unverified on real hardware; user-facing workaround documented in README/release notes.
|
||
|
||
## D287 — User-confirmed real, non-self-recovering freeze on Windows (M-141, 2026-09-15/16) after ~15-20 minutes with no input, triggered by clicking back into the…
|
||
|
||
**User-confirmed real, non-self-recovering freeze on Windows (M-141, 2026-09-15/16) after ~15-20 minutes with no input, triggered by clicking back into the window.** Caught live under `tools_pc/debug.ps1` (gdb-attached), evidence preserved at `scratch/d287_freeze_gdb.txt`. Last real `frame N rendered` log line: frame 17616. The heartbeat (`portHeartbeatCheck`) then fired 3 times with a **growing** stall (8001ms → 13017ms → 18021ms, `frames=17616` unchanged all 3 times) and then stopped logging entirely, while the VI-retrace pacemaker (`D51 vi post #N`) kept incrementing at its normal ~60 Hz rate for well over 100 more real seconds until the user closed the window — i.e. the pacemaker/OS-timer side is alive, the sim/render side is genuinely wedged, not the D286 shape (which self-recovered after one dump). **Different from D286**: no explosion/level-streaming context (idle + refocus, not combat), and the growing-then-silent heartbeat pattern (vs. D286's single fire-then-recover) points at a different mechanism. **Leading lead, not yet confirmed:** at freeze time, `gfxFrameMsgQ` (`src/init.c`, the scheduler's 32-slot client completion queue, registered via `osScAddClient`) shows `valid=32/32` — completely full — while the VI-retrace queue (8-slot) is not (`valid=1/8`). A full *client* queue with the producer (scheduler) still ticking implies the **consumer** (the main thread's `osRecvMesg(&gfxFrameMsgQ, ...)` in the frame loop) stopped draining it — consistent with the main/render thread blocking on something else (a lock, a GL call, a Windows message-pump stall) during or after a long-idle refocus, not with the scheduler itself stalling. Not yet identified what that something is. A pre-existing, currently-unused diagnostic (`GE_D60`, `port/src/libultra.c:630+`) already watches `gfxFrameMsgQ` receives and looks like the right next probe to enable. **Next, concrete:** reproduce with `GE_D60=1` (and ideally `GE_INPUTLOG=1`) running under `debug.ps1`, leave the window idle+unfocused 15-20 min, refocus, and once frozen `bt full` the actual game/render thread (not just the auto-dump's generic `WaitForMultipleObjects` frames for `shedThread`/`audioThread`) to find what it's blocked on. Cross-ref D134's superficially-similar-sounding-but-different mechanism (a different, 8-slot queue, already fixed) — do not assume the same fix applies here without confirming the actual blocked call site.
|
||
|
||
**M-152 correction (2026-09-16, static-only pass — verified directly, don't skip this): `scratch/d287_freeze_gdb.txt` does NOT contain the evidence this entry describes.** Grepped the file on disk for `17616`/`8001ms`/`13017ms`/`18021ms`/`portHeartbeatCheck` — zero hits. What the file actually holds is a **clean normal-exit capture**: boot through `frame 20700 rendered` / `D51 vi post #20821`, then `[INFO] video: window closed`, all threads exiting with code 0, `Inferior 1 exited normally`, and gdb's post-mortem `No stack.`/`The program has no registers now.` under `BACKTRACE`/`REGISTERS` — i.e. the user closed the window normally in whatever run produced this specific file; it is not a freeze capture at all. The frame-17616/heartbeat numbers cited above must come from a different run that was never saved, or got overwritten by this file under the same name. **Practical upshot: no real freeze-time thread state has actually been analyzed yet** — everything upstream in this entry citing `d287_freeze_gdb.txt` as "evidence" is describing a file that no longer supports that claim; treat the freeze mechanism as still fully open, not "narrowed and just needs confirming." Architecture traced (still useful, static-only, no game execution): the sole `OS_MESG_BLOCK` **send** anywhere in mainThread's per-frame path is `osSendMesg(sched_cmdQ, ...)` at `src/game/rsp.c:271` (end of `rspGfxTaskStart`), which per `port/src/libultra.c:605-628` does an unbounded `pthread_cond_wait` if the scheduler's 8-slot `cmdQ` is full; the scheduler thread (`__scMain`, `src/sched.c:245-310`) drains that queue unconditionally every retrace tick (`__scHandleRetrace`, `sched.c:323-325`) and separately stuffs `gfxFrameMsgQ` via NOBLOCK sends every retrace (`sched.c:352`) — consistent with the "queue fills because the consumer stopped receiving" shape, but a missed-wakeup race on the `cmdQ` condvar is a hypothesis, not evidence; no second "idle+refocus"-shaped blocking call was found in mainThread's path (SDL event pumping runs on a separate host thread, `port/src/main.c:243-246`, ruling out message-pump starvation as a mainThread blocker). Also noted: `portHeartbeatCheck` (`port/src/libultra.c:218`) hard-caps at **3 reports total per process lifetime** by design — "heartbeat stopped logging" (cited above as evidence the mechanism differs from D286) is that cap firing, not a signal the hang changed shape; don't read silence from this diagnostic as meaningful without accounting for the cap. `GE_D60` (`port/src/libultra.c:630-642`) only validates `gfxFrameMsgQ` receive *content* isn't clobbered — it does not instrument sends or `sc->cmdQ`, so it alone won't catch a `cmdQ`-side stall. Proposed for the next real capture (not yet added to tree): a `GE_D287` env-gated variant of `portHeartbeatCheck` that keeps reporting (with a full thread/queue dump via the existing `crashDumpThreads`) for the whole stall instead of stopping after 3, run alongside `GE_D60` under `debug.ps1` for the actual 15-20 min idle+refocus repro — this is the only way to get mainThread's real blocked call site.
|
||
|
||
**Status (from §F table):** OPEN — the `d287_freeze_gdb.txt` file this entry cited as evidence turned out to be a normal-exit capture, not a freeze capture (M-152 correction); no real freeze-time thread state has been analyzed yet. `gfxFrameMsgQ` full remains an architecturally-plausible lead (traced statically: consumer-side stall via an unbounded `pthread_cond_wait` in `rsp.c:271`'s `osSendMesg`, if the scheduler's `cmdQ` fills), not a confirmed cause. Needs a fresh live capture — ideally with a raised/env-gated heartbeat-report cap (see above) plus `GE_D60` — to get mainThread's actual backtrace at freeze time.
|
||
|
||
**2026-09-25: likely mechanism (code review) + fix applied, pending a re-test.**
|
||
|
||
- **Queue-deadlock leads ruled out statically.** The only blocking sends are `rsp.c:271` (main → sched `cmdQ`, 8 slots), `sched.c:428` (task-done → `gfxFrameMsgQ`) and PI completions. The main loop caps work in flight at `pendingGfx < 2`, so it can never fill `cmdQ` and block there; a sched↔main circular wait cannot close. The shared-condvar `pthread_cond_signal` pattern in `osSendMesg`/`osRecvMesg` needs two blocking waiters of the same kind on one queue plus an opposite waiter; no queue in the game has that shape. `joyPoll`'s blocking receive (joy.c:438) is balanced by `osContStartReadData`'s immediate completion post.
|
||
- **What does fit "idle, then click back into the window":** `port/src/input.c` called `SDL_SetRelativeMouseMode` and `SDL_ShowCursor` from **two threads**. The scheduler thread did so on every controller poll (`osContStartReadData` → `contSnapshotFromKeyboard` → `inputComputePad` → `reconcileGrab`/`applyCursorVisibility`); the host thread, which created the window and pumps its events (`port/src/main.c` loop → `videoPumpEvents`), did so from focus/click handling (`inputSetMouseGrab`, `inputNotifyClick`). SDL's mouse/window API is not thread-safe and belongs on the window's thread. On Windows, relative mode starts/stops SDL 2.30+'s raw-input thread and re-clips the cursor. Refocus + click is precisely when both threads flip the grab state at the same moment. The port already had this rule for F10 window changes (`videoDrainWindowRequests`), but mouse mode was never routed that way.
|
||
- **Fix (port-only, `port/src/input.c`, `port/src/video.c`, `port/include/input.h`):** `applyGrab`, `applyCursorVisibility` and `inputSuspendForOverlay` still update the logical state immediately, but only *record* the wanted relative-mode/cursor state (spinlock, last writer wins). `inputApplyMouseRequests()` applies it, deduplicated, on the host thread at the start and end of every `videoPumpEvents()` (and once in `inputInit`, which runs on the host thread). `inputUpdate` skips relative deltas until relative mode is actually on, so the ≤8 ms gap reads no absolute motion.
|
||
- **Verified:** Windows + Linux builds clean; golden gate 3/3. **Not verified:** the freeze itself (needs the idle 15–20 min + refocus repro) and mouse feel. **User checks:** click-to-lock, mouse-look, ESC release, alt-tab away/back, F10 overlay cursor, cursor hidden in menus; then leave the window unfocused ~20 min and click back in.
|
||
|
||
**2026-09-26 (v0.4.0, M-201): user-confirmed FIXED.** Mouse checks (click-to-lock, Esc, alt-tab, F10 cursor, front-end pointer) all fine; the real repro, the game left unfocused with no input past the ~15–20 min window and then clicked back into, resumed normally with no freeze.
|
||
|
||
**Status: FIXED (user playtest, 2026-09-26).**
|
||
|
||
## D288 — Silo intro: a huge, flat-shaded, untextured brown/tan triangle sweeps across the frame during the camera pan down the missile shaft (M-141, user report —…
|
||
|
||
**Silo intro: a huge, flat-shaded, untextured brown/tan triangle sweeps across the frame during the camera pan down the missile shaft (M-141, user report — "weird triangle drawn on silo intro randomly when the camera pans around the rocket"; previously catalogued as `GRAPHICS-BACKLOG.md` D171, "observed, not investigated" since M-34, never given a full findings.md entry until now).** Reproduced headlessly this session: `GE_PCDUMP=1-2000:10 ./build-pc/ge007.x86_64.exe -level_20` (run backgrounded, not foregrounded — see the environment note below), captured to `./ppm/`. Visual sweep (contact-sheet montages, `docs/img/bugs/D288-silo-stray-triangle-sheet.png` covers frames 571-751; `-closeup.png` is a full-res crop of frame 691) shows: **clean at frames 481-561** (no triangle), **first appears between frame ~562-601**, grows continuously in screen size through frame ~711 (by which point it covers roughly a third of the frame), then **shrinks and is gone by ~731-741** as the scene cuts to the first-person shaft-corridor view. Sampled pixel color is **exactly uniform** across the whole triangle (`RGB(83,62,44)` / `#533E2C` at two different sample points) — no gradient, no texture detail — i.e. this is a flat-shaded (no per-vertex color variance, no texture bound) single triangle, not garbage/noisy vertex-color data. **Reading of the evidence:** the grow-then-shrink-then-vanish arc across ~15 real seconds of camera motion is the classic signature of a genuine 3D triangle in world space that the camera flies close to/through during the scripted pan — not a 2D HUD/UI element (which would stay roughly fixed in screen size) and not obviously a `D146`-class freed-memory read (which reads as noisy garbage bytes, not a clean uniform flat color). The flat uniform color argues this is a REAL, correctly-colored asset triangle whose position/transform is wrong, which points at the same neighborhood as D106/D233 (near-plane / w-clip mishandling in `port/fast3d/gfx_pc.cpp`) rather than a game-code or converter data bug — but this is a read of the visual evidence, not a code-level confirmation; no probe has been run yet. **Not yet identified:** which model/prop this triangle belongs to (nothing in `UsetupsiloZ.c` was grepped for a matching solid-brown material this session), and no `port/fast3d/` tracing was done to catch the actual triangle at draw time. **Environment note (new, useful beyond this bug):** launching `ge007.x86_64.exe` in the **foreground** under this session's Bash tool with `timeout N ...` produced **zero** `ppm/` output despite `GE_PCDUMP` being set and verified exported — the `ppm/` directory was never even created (`GE_MKDIR` never ran or `frames`-gated capture never fired). Backgrounding the same invocation (`... &`, `sleep N`, `kill`) worked immediately and reliably. Not root-caused (didn't block this investigation), but worth remembering for the next session that needs `GE_PCDUMP` from this same tool. **Next, concrete:** (1) grep `assets/obseg/setup/UsetupsiloZ.c` / the missile-shaft model's material table for a solid `RGB(83,62,44)`-ish prim/vertex color to identify the source asset; (2) add a temporary `#ifdef PORT` probe in `gfx_sp_tri1` logging any triangle whose post-transform screen-space bounding box exceeds some fraction of the viewport together with each vertex's `w`, gated to `-level_20` frames ~560-740, to catch the culprit triangle's `w` sign/magnitude at the moment it blows up — same style as the existing `GE_D271`/`GE_D104` probes; (3) cross-check against D233's `w<0` trivial-reject fix and D106/D271's near-plane portal-bound clamp to see if this is a third site with the same underlying class of bug, or a genuinely new mechanism.
|
||
|
||
**M-152 (2026-09-16, static-only pass, no game execution this session — user was mid-playtest):** read `gfx_sp_tri1` (`port/fast3d/gfx_pc.cpp:1586-1651`) against D233/D106/D271/D234 to see what's already guarded. **D233's trivial-reject fix is ruled out as the direct cause** — it only ever adds triangles GL's own hardware clipper would legitimately draw from well-formed clip-space geometry (`gfx_opengl.cpp:320` hands GL raw `x,y,z,w` and lets GL clip natively), so a well-formed w<0/straddling triangle can't come out screen-filling from that guard alone. **New unguarded site found, not previously catalogued:** the backface-cull test right below it (`gfx_pc.cpp:1618-1629`) divides raw vertex `w` with no epsilon guard when computing the cull cross-product; a vertex at/near `w≈0` during a near-plane crossing can NaN/Inf the cross product, which silently fails both `<=0`/`>=0` cull branches and lets a triangle through that should have been backface-culled. Asset search (`UsetupsiloZ.c`, `bg_silo_all_p.c` — no `SetPrimColor`/`SetEnvColor`/`SetFillColor` at all, so the flat color must be vertex-RGBA or lighting-derived, not a literal stored triple; a byte-triple grep for `0x53,0x3e,0x2c` ±3 fuzzy across all of `assets/` found zero matches) did not identify the source model — GE's binary-included model blobs aren't literal hex in the `.c` sources, so this needs a runtime probe, not more grepping. **Confidence: LOW-MEDIUM, still not root-caused.** Two live candidates: (1) the new backface-cull NaN/Inf leak above, (2) the D106/D271 portal/room-visibility BFS family in `bg.c` (less likely given the smooth 150-frame grow/shrink arc, which doesn't match that family's usual abrupt full-screen-clamp signature, but not traced through the silo intro's specific camera/portal path so not ruled out). A `GE_D288=1` env-gated probe (cached-getenv, D250-safe) is now in tree at `gfx_pc.cpp` right after the D233 guard (~line 1616): logs any triangle whose clip-space NDC bbox covers >15% of the screen or goes non-finite, together with each vertex's `w` and `any_behind_camera`. **Next:** `GE_D288=1 GE_PCDUMP=560-745:5 ./build-pc/ge007.x86_64.exe -level_20 2> scratch/d288_probe.log` (background it, not foreground — see the environment note above), grep around frames 562-741 for `color≈(83,62,44)`. `any_behind=1` with one `w` very close to (not deeply past) 0 → candidate 1; `any_behind=0` with comfortably-positive `w`s but still a large bbox → a genuine asset/transform bug outside fast3d entirely, rules out both candidates above.
|
||
|
||
**M-155 (2026-09-16, this session): the M-152 probe run happened, result is inconclusive — the probe itself needs frame-gating before it can disambiguate.** Ran `GE_D288=1 GE_PCDUMP=560-745:5 ./build-pc/ge007.x86_64.exe -level_20 2> scratch/d288_probe.log` (backgrounded, per the environment note above) for a 25s bare boot. **Two problems with the result, not with the underlying bug:**
|
||
- The probe fires **116,308 times** in 25s (roughly 1500-2000 frames) — i.e. dozens of times per frame throughout the *entire* run, not bounded to the reported ~562-741 window. `bbox_frac > 0.15` (covering >15% of the screen) turns out to be a common, unremarkable occurrence for ordinary near-camera geometry (walls, floors, large props), not a distinguishing signature of the stray triangle — the threshold alone can't isolate the bug.
|
||
- The target signature from M-152's own next-step note, `color≈(83,62,44)`, **never appears** in the log (checked via `color=\([789][0-9],[567][0-9],[34][0-9]\)`, zero matches) — either the boot never actually reached the frame window where the bug fires in this run, or the flat vertex color isn't quantized the way the visual sample suggested. No `GE_PCDUMP` PPM frames were produced either (the `ppm/`-style output dir wasn't created — likely a missing/different output-dir env var beyond just the frame-range spec — so there was no visual cross-check available this pass).
|
||
- Of the `any_behind=0` subset (comfortably-positive `w`, ruling out candidate 1 for those specific triangles) with very large bboxes, all sampled instances had ordinary-looking greyscale colors (`(68,68,68)`, `(179,179,179)`, etc.) consistent with normal level geometry, not the flat brown culprit — further evidence the probe is catching routine triangles, not the bug.
|
||
- **Confidence: unchanged from M-152 (LOW-MEDIUM), no candidate confirmed or refuted.** This pass is a probe-refinement finding, not a root-cause finding.
|
||
- **Next, concrete:** gate the probe to the actual bug window before re-running — extend `GE_D288` with a `num_dls` (frame counter) check bounding it to roughly 560-745 (same pattern `GE_D236RM` already uses for its own frame gating, `gfx_pc.cpp` ~line 1648), and/or tighten the bbox threshold well above the apparent ~0.15 "normal" baseline this run established. Confirm the correct `GE_PCDUMP` output-directory env var (see `docs/dev/GE-ENV-PROBES.md`'s `GE_PCDUMP` row) before relying on visual frame dumps again.
|
||
- **Environment note, reinforcing the existing one:** the launched `ge007.x86_64.exe` process does **not** reliably die with `pkill -f ge007.x86_64.exe` from this Bash tool (process shows in `ps aux` as bare `ge007.x86_64`, no `.exe` suffix) — a stray instance ran ~90s longer than intended this session before being caught via `taskkill //F //IM ge007.x86_64.exe`. Use `taskkill //F //IM ge007.x86_64.exe` (not `pkill`) to reliably terminate a backgrounded launch from this shell.
|
||
|
||
**M-155 follow-up (same session): frame-gated re-run also inconclusive — bbox_frac is not a meaningful signal when `any_behind_camera=1`.** Gated `GE_D288` to `num_dls` 550-755 (matching the reported window) and raised the threshold to `bbox_frac > 0.5`, then re-ran the same 25s bare `-level_20` boot. Volume dropped from 116k to 28,883 hits — still far too many to be the single reported triangle. Root cause of the remaining noise: for `any_behind_camera=1` triangles (a vertex behind the camera plane, `w<0`), the perspective-divide math `x/w` this probe uses to estimate screen coverage is **not meaningful in the first place** — dividing by a near-zero or negative `w` produces arbitrarily large/nonsensical "bbox_frac" values for perfectly ordinary near-camera geometry, which is exactly why the surrounding code already defers to GL's own clipper for these triangles rather than trusting a raw NDC divide (see the D233 comment just above this block). This isn't evidence of the bug; it's the probe's own math breaking down in a case it was never valid for. Restricting to `any_behind_camera=0` (2,128 of the 28,883 hits) still found **no match for the target color** `(83,62,44)`; the largest/most persistent hits (`(82,94,89)`, `(70,70,70)`, repeating identically across every sampled frame in the window) look like static level geometry, not the transient grow/shrink/vanish triangle. **Working theory, unconfirmed:** the bare `-level_20` boot's scripted intro camera may not reach the same in-game state the visual bug report was captured from — the same "deterministic boot flag ≠ deterministic/representative camera path" caveat this session's D236 passes (10/11) independently ran into on a different level. **Confidence: unchanged, LOW-MEDIUM; probe volume is now sane but still hasn't isolated the signal.** **Next, concrete, revised:** (1) restrict the probe to `any_behind_camera==0` only (the `w<0` case needs a different, GL-clip-aware approach or should be dropped entirely as a false lead); (2) get a real `GE_PCDUMP` PPM capture working (confirm the output-dir env var) to visually confirm which `num_dls`/frame the boot's camera is actually at when the bug is visible, rather than assuming the frame-range label transfers directly to `num_dls`; (3) if headless deterministic-boot probing keeps missing it, this may need a live human repro with `GE_PCDUMP`/F12 at the moment the triangle is visible, same as D290's falsifier plan.
|
||
|
||
**Status (from §F table):** OPEN — visually confirmed and precisely bounded (frames ~562-741 of a bare `-level_20` boot); M-152 narrowed to two live candidates (fast3d backface-cull NaN leak, or the D106/D271 portal-BFS family) via static review; two M-155 probe runs (ungated, then frame-gated) were both inconclusive — the `any_behind_camera=1` case's bbox math is a false lead, and the `any_behind_camera=0` subset found no match for the target color. Root cause still not identified; a live human repro is now the more promising next step over further headless probing. Promoted from the long-parked `GRAPHICS-BACKLOG.md` D171 entry with real evidence.
|
||
|
||
**v0.4.0 re-check (2026-09-23, native Windows, current `release/v0.4.0` build): does NOT reproduce.** Bare `-level_20` at 640x480 with a defaults ini, `GE_PCDUMP` 400–1200 every 25 frames, then 500–950 every 5 (91 frames covering the whole rocket pan and the cut to first person). A pixel scan for the reported flat `RGB(83,62,44)` ±6 finds only frames 825–840, and those are Bond's own head fading out as the camera pushes into first person (normal GE behaviour, eyeballed). No stray triangle anywhere in the window. **D271 A/B:** temporarily restoring the pre-D271 portal-bounds limit (1e5 instead of 1e38, `bg.c`) gives the identical result, so the D271 portal-culling fix is not what removed it. The M-155 probe runs (2026-09-16) had already missed the colour, so it has not reproduced headlessly since it was written up. Either it was fixed incidentally before 09-16 (not identified), or it is intermittent, which fits the user's "randomly". **Next:** the playtest brief item (play Silo normally, F12 if seen). If it isn't seen, close as not reproducible.
|
||
|
||
**2026-09-25 playtest (user, Windows, one ~46-min session Silo → Caverns, `GE_D322=1 GE_D204=1`, build `7baf4900`):** user reports the Silo intro clean. Closed; the specific fixing commit was never isolated.
|
||
|
||
## D289 — Dam: a crash in the computer room, not reproducible on a second attempt (M-141, Steam Deck user playtest).
|
||
|
||
**Dam: a crash in the computer room, not reproducible on a second attempt (M-141, Steam Deck user playtest).** **User's own read, most likely: the same computer-terminal SIGSEGV as D255** (Facility door-opening terminals — root-caused and FIXED this session, M-141, `vtxstore_fix_refs` reading `Model*` through the wrong union field, a 32-bit pointer truncation). D255's mechanism is generic to computer-terminal props, not Facility-specific, so a Dam terminal hitting the same bug is plausible and would already be fixed by the same patch. **Not yet confirmed** which build the user was on when this happened (pre- or post-D255-fix) — if it was pre-fix, this is very likely just D255 and needs no separate investigation; if it recurs on a build that has the D255 fix, that's a new, real, distinct bug worth its own crash log.
|
||
|
||
**Status (from §F table):** OPEN — needs a human re-test specifically on a build with the D255 fix (this session's `release/v0.3.0`, not any earlier build) to see if it still happens. If it does, get `ge007.crash.log` (or a gdb `bt full` under `debug.ps1`) and the exact terminal/action — that's the actionable next step, not further static analysis. **A build with both the D255 fix and this session's D293 quit-QoL row has been deployed to the Deck's own `~/Downloads/goldeneye-pc-port-0.3.0-local.20260915c-linux-x86_64/ge007.x86_64`** (rebuilt via WSL `Ubuntu-24.04`, `scp`'d over — SSH itself can't drive the Deck's own X/gamescope display to launch-and-play remotely, so the actual Dam-computer-room retest still needs the user at the hardware).
|
||
|
||
## D290 — Watch's in-game "controller" section shows no control-pad graphic (M-141, Steam Deck user playtest).
|
||
|
||
**Watch's in-game "controller" section shows no control-pad graphic (M-141, Steam Deck user playtest).** Likely a missing/failed texture or model bind on one of the watch's equipment-display pages — candidate area `src/game/bondview2.c` (watch display) alongside the front-end options/controller-config screens, not yet located precisely.
|
||
|
||
**M-152 (2026-09-16, static-only pass, no game execution this session — user was mid-playtest):** traced the real render path: `draw_watch_control_options_page()` (`src/game/options.c:3511`) gates `draw_watch_controller()` (`options.c:3336`) on `check_watch_page_transistion_running()` — this gate covers the model draw AND both text labels, so a mis-gate would drop the whole page, not just the graphic; ruled out. `draw_watch_controller()` picks `watchRenderController`/`watchRenderControllerOpaque` (`src/game/gunfire.c:1983`/`:2343`) by `g_WatchBackgroundGreen` fade state — both build a 13-node joystick+buttons model from `g_CurrentPlayer->copy_of_body_obj_header[GUNRIGHT]`, animated from live `joyGetStickX/Y`/`joyGetButtons`, then `subdraw()`. Verified the equip path is correct: `sub_GAME_7F05DA8C(GUNRIGHT, 0x55)` equips `ITEM_JOYPAD` (0x55 confirmed against `bondconstants.h`'s `ITEM_IDS` enum, not an off-by-one), the `GjoypadZ` ROM resource is present in `file_resource_table.inc.c:165`/`obseg.h:162`, and `port/shim/bondconstants.h`'s `GUNFILERECORD`/`GUNSTATS` macros are only token-paste syntax variants of the same decomp macros — no port-shim divergence. **The entire `src/game/` decomp side (item ID, model header wiring, generic equip/on-demand-load pipeline shared by every weapon, page-transition gating) is byte-matched/generic-and-proven-elsewhere — ruled out with HIGH confidence.** Root cause not found. Best-narrowed hypothesis (LOW confidence, pattern-match only, no direct evidence yet): downstream in `port/fast3d/gfx_pc.cpp`'s rasterization, not game logic — `watchRenderController`'s fading branch draws `PROP_TYPE_PLAYER` with a low `envcolour` alpha (green-6) and `fogcolour` alpha=0, a translucent one-off combiner/blend path that doesn't share code with the common in-level render path; this project has a recurring bug class of fast3d mishandling exactly this kind of one-off blend/CC-mode/texture-format combo while the common path works fine (D172 particle color, D217/D228 CI/IA16 palette, D229 water blend, D233 invisible-through-doors) — matches the "graphic vanishes, surrounding text stays" symptom shape. **Falsifier for next session:** get a live repro (open the watch → controller-config page) with `GE_PCDUMP`/F12 at both `g_WatchBackgroundGreen < 0xe0` (fading, `PROP_TYPE_PLAYER`) and `>= 0xe0` (settled, `PROP_TYPE_OBJ`) — missing in BOTH states kills the blend-mode hypothesis and points back to something structural (e.g. `objheader->Switches[]` never populated for this object); missing ONLY in the fading state confirms it and narrows the fix to `gfx_pc.cpp`'s `PROP_TYPE_PLAYER` + low-alpha handling.
|
||
|
||
**Status (from §F table):** OPEN — M-152 ruled out the entire `src/game/` decomp side (item ID, resource table, equip pipeline, gating) with high confidence via static trace; root cause not found, best-narrowed hypothesis points at a fast3d translucent-blend edge case, needs a live repro with `GE_PCDUMP` at both watch fade states to confirm/refute (see falsifier above).
|
||
|
||
**FIXED (2026-09-23, v0.4.0): ABI/layout class, the D264 pattern on a second call site.** Headless repro: `-level_09`, `GE_INPUTSCRIPT="1000:START;1400:RIGHT;1600:RIGHT"` (watch → Inventory → Control Options), `GE_PCDUMP` through the page. Labels drew and the model didn't, in **both** fade states (frames 1650–1700 fading, 1725+ settled), which falsifies M-152's blend-mode hypothesis. Cause: `watchRenderController` (`gunfire.c`) builds its `ModelRenderData` from `*(ModelRenderData *)((u8 *)D_80035D04 + 0x3c)`. On N64 that 64 bytes is D_80035D04's last word (0) followed by `watchControllerButtonBases` `{1, 3, 0…}` (gun.c): the same template D264 documents (basemtx NULL, zbufferenabled TRUE, flags 3). On PC `basemtx` is 8 bytes and the globals aren't at those offsets, so `flags` read 0 and `subdraw()` gated off every node. D264 fixed only `set_enviro_fog_for_items_in_solo_watch_menu`. **Fix (`#ifdef PORT`, same explicit template as D264).** Three more pointer-width/Gfx sites on the same path were fixed because the model now draws: both `matrix_4x4_copy((u32)modelstack.render_pos + i*sizeof(Mtxf), …)` loops (u32 pointer truncation; harmless on this Windows build because the pool sits below 4 GB, fatal under Linux PIE), `watchRenderControllerOpaque(…, (s32) watchTable, …)` in `options.c` (truncated stack pointer), and the dual-controller branch's hand-encoded N64 `G_MTX` word `0x01030040` (B class; now `gSPMatrix`, as the decomp comment says it should be). **Verified:** native Windows capture shows the N64 pad model and the floating button icons (`scratch/d290_after.png`); bunker1 golden 3/3; Windows and Linux builds clean. The dual-controller branch (2.x control styles with a second pad) was not exercised at runtime.
|
||
|
||
## D291 — Dam: texture glitches when the truck drives past (M-141, Steam Deck user playtest).
|
||
|
||
**Dam: texture glitches when the truck drives past (M-141, Steam Deck user playtest).** No further detail captured (which surface/texture, timing, one-shot vs. every pass). Could be a moving-vehicle-specific instance of a known texture-cache/TMEM-residency class (cf. D124's stale-DL-marker mechanism, a different object family) or something new.
|
||
|
||
**Status (from §F table):** OPEN — observed, not investigated. Needs a live repro with the truck's exact position/timing and, ideally, a screenshot.
|
||
|
||
**v0.4.0 check (2026-09-23): not reachable headlessly.** Bare `-level_33` capture (300–3100 every 70 frames): the truck is parked in the intro flyby (frames 580–720, textures correct) and never drives through the spawn view; it is set off by the player moving toward the checkpoint. Needs the playtest brief item (Dam, watch the truck pass, F12 if glitched).
|
||
|
||
**2026-09-25 (M-200 session): headless walk attempted, truck not reached; AI mapped.** The truck is the Dam `Vehicle` record (setup index 321, `UsetupdamZ.c:1152`). Its AI list id `0x040a` = `ai_9` (`:2028`, debug-logs "brrm brrm"): `vehicle_start_path(0x07)` + `vehicle_speed(0x0002, 0x7800)`, then sleeps. So it drives patrol path 7 as soon as its AI ticks, and nothing else gates it in script. The "set off by walking" behaviour from the 2026-09-23 note is therefore prop activation (its room coming into play), not an AI trigger. Headless: `GE_INPUTSCRIPT` stick-up is *look* in the default control style; forward is `CUP` pulses (6 polls each, max 64 entries ⇒ ~6 s of walking). A straight walk from spawn drifts onto the ramp by the wall and draws guard fire (`GE_D320R="850:cheat=inv"` keeps Bond alive); no truck in view by frame 3300. Reaching it headless needs a steered route (turn + walk) or a teleport action in the harness; not worth it before a user sighting says which surface glitches. The intro wheel depth-fight (D306/D308, real per `GE_ZF`) remains the only captured truck-area rendering defect. D291 may be that same effect seen in motion.
|
||
|
||
**Status:** OPEN; still needs the playtest item (Dam: watch the truck pass, F12 screenshot if glitched, note whether it's the wheels, i.e. D306).
|
||
|
||
|
||
**2026-09-26 (M-201): merged into D306.** Captured by the user: intro-only (Dam level intro/attract, truck over the walkway camera), wheels drawing through the body; reproduces at FovScale 100. Tracked under D306 from here.
|
||
|
||
**Status: CLOSED with D306 (faithful).**
|
||
|
||
## D292 — Death animations are glitched — Bond is often sunk halfway into the floor (M-141, Steam Deck user playtest).
|
||
|
||
**Death animations are glitched — Bond is often sunk halfway into the floor (M-141, Steam Deck user playtest).** **User confirms this is the same broader "Bond's world model is positioned wrong" problem as D173** — not a new/separate mechanism, another surface of the same known-but-under-investigated bug. The umbrella is already tracked: `GRAPHICS-BACKLOG.md` D148 is explicitly "the tracking entry for the whole cutscene-reliability problem," already lists D173 (floating at level start / Cuba credits) and a Cradle instance ("Bond spins in circles" during the Trevelyan-falls cutscene) under it — findings.md's D173 entry has the fullest investigation detail (M-106's two ruled-out D209-style struct-aliasing candidates, LOW-MEDIUM confidence that class is even the right one). Nothing has actually been lost — the write-up just isn't concentrated in one place (split across `GRAPHICS-BACKLOG.md` D148/D173 and `findings.md` D173), which reads as "known but not detailed" unless you go looking. Sunk-into-floor is a third concrete instance of the family (level-start floating, cutscene spin, now death-pose sinking) — worth keeping as one thread when it's next investigated live, since a single root cause (most likely the render-side puppet/joint transform, not collision) could explain all three.
|
||
|
||
**Status (from §F table):** OPEN — same status as D173 (observed, not root-caused). Cross-ref D173/D148 as one investigation thread, not three separate bugs, next time someone has a live session to chase it. **2026-09-17 addendum (see D173's M-160/M-164/M-165 note, and D243's own entry):** the D243 abseil-cutscene investigation this session found a well-evidenced general mechanism for this whole family — `chrUpdateAnim` reads a puppet's model-root position back into its logical position after advancing whatever animation is attached, so a transition (teleport/spawn/death) that doesn't also update the animation state produces exactly this "position lags/diverges from the animation" symptom. Not confirmed as D292's actual cause (needs its own live capture), but worth trying the same `GE_D193A`-style `aidx=`/`animframe=` technique (D243 M-166) on a death-sequence capture before assuming a new mechanism.
|
||
|
||
## D293 — QoL ask: no discoverable way to quit the game from the UI (M-141, Steam Deck user playtest — "couldn't see a way to exit the game. Maybe build this into the…
|
||
|
||
**QoL ask: no discoverable way to quit the game from the UI (M-141, Steam Deck user playtest — "couldn't see a way to exit the game. Maybe build this into the settings/options menu.").** Confirmed in code: the only quit path is `SDL_QUIT` (`port/src/video.c:515`, the OS window-close event / Alt+F4) — there is no Escape-key handler and no "Quit" entry in the front-end menus or the F10 options overlay. Fine on a desktop with visible window chrome; a real gap on Steam Deck / controller-only / fullscreen setups with no easy way to reach the OS window controls.
|
||
|
||
**Status (from §F table):** FIXED (M-141, `release/v0.3.0` local). Added a "Quit to desktop" row to the F10 overlay (`port/src/optionsoverlay.c`) — a new `ROW_ACTION` kind (not config-backed, same non-config pattern as the existing `__Resolution` row) that calls the identical `sysLogPrintf` + `configSave()` + `exit(0)` path `SDL_QUIT`/Alt+F4 already use. Reachable by keyboard (arrow/Enter when the row is selected), mouse click, or gamepad A/X — no new Escape binding was added (D145 already dedicates ESC to menu-back / cursor-release). Build-verified on Windows (`ninja`, clean link) and Linux (WSL `Ubuntu-24.04` cross-build); `tools_pc/verify.sh dam` PASS, no regression. **Not yet live-confirmed** — needs a human to actually select the row on a real session (headless/SSH-launched smoke tests can't reach the Deck's own X/gamescope display to drive input).
|
||
|
||
## D294 — Statue: ground geometry near the large CCCP/hammer-and-sickle monument briefly renders solid black over a wide area, then self-recovers 10-30 seconds later…
|
||
|
||
**Statue: ground geometry near the large CCCP/hammer-and-sickle monument briefly renders solid black over a wide area, then self-recovers 10-30 seconds later (M-141, Steam Deck user playtest).** **Correction (user, same session): happened at normal ground level during normal forward progression through the level, near the statue/monument — NOT from the elevated ramp vantage point** (the ramp is a nearby landmark used to describe the location, not the trigger condition; a first-pass write-up of this report wrongly framed "wide visibility from elevation" as the likely cause — that framing is retracted). User's settings: max `Video.DrawDistance`/`Video.LodDistance`, high `Video.FovScale` (~130%). **Not an exact match for any existing tracked culling bug, but closely neighbors three:** D222 (high-FovScale culling breakage — but D222 is specifically *NPC/object* culling near screen edges, root-caused and FIXED, not ground/BG geometry); D249 (LOD/culling breakdown on large open levels — but D249 is at *default* FOV on Streets/Egyptian, and doesn't self-recover); D218 (wide-FOV fog/far-clip boundary artifacting — but D218 is a color/tint issue at the frustum edge, not geometry going missing). **Distinguishing features that don't match any of the three:** (a) the drop-out is transient and self-corrects without the player doing anything special, (b) it's ground/BG geometry specifically, not NPCs or props, (c) it happens *within* the draw-distance/FOV settings, not past a boundary. Leading unconfirmed guess, weakened by the correction above but not ruled out: a portal/room-visibility computation (the BFS the D106/D271/D233/D234 family already touches) temporarily excluding a room/cell that contains ground geometry until the walk recomputes on subsequent player movement — the widened-FOV-frustum framing no longer applies (the drop-out wasn't tied to an unusually wide sightline), but a room-transition/streaming-timing trigger at ground level is still plausible and fits the self-recovering, "normal progression" description just as well. This is a hypothesis from the symptom shape, not a code-level finding, and now has less to go on than the first write-up assumed.
|
||
|
||
**Status (from §F table):** OPEN — not investigated. Next: reproduce on Statue (`-level_22`) at the reported settings (`Video.FovScale≈130`, max draw distance/LOD) near the ramp by the CCCP monument; a `GE_D271`/`GE_D104`-style room-visibility trace (`roomsDrawn` count, portal-cull hits) correlated with the black-ground window would confirm or rule out the room-visibility-BFS hypothesis quickly.
|
||
|
||
**ROOT-CAUSED AND FIXED (2026-09-22 session) — not the room-visibility BFS; it is room-model POOL exhaustion under widened PC visibility settings.** The room-visibility hypothesis above is ruled out: `roomsDrawn`/portal-cull state stays healthy throughout the black window. The actual chain: (1) each level's authored `memallocstringtable` row budgets the room-model pool — Statue's is `-ma220` = **220 KB**; (2) the PC visibility knobs (`Video.FovScale`, `WidescreenAuto`, `DrawDistance`, `LodDistance`) keep more rooms on screen at once than the N64's fixed ~60°/4:3/authored-far-clip view, so more room models must stay resident simultaneously — the monument-plaza 12-room set alone totals ~250 KB "used", already over the authored pool; (3) when demand exceeds supply, `memaAlloc()` returns NULL (after its 8 defrag passes) and `bgLoadRoomModelData()` bails **silently** — the undrawn room's scissor region then shows the framebuffer clear colour. On Statue that is the night-sky fill RGB(0,0,8): a wide solid-black patch of "ground" that self-recovers 10–30 s later when the player moves and the rooms reload into freed space — exactly the reported symptom shape (transient, ground-specific, self-correcting). **A/B confirmed both knobs independently trigger it:** FovScale=130 alone → 372 alloc failures; DrawDistance=400 alone → 33 (and `DrawDistanceAutoFov`, default ON, couples DD to 4×FovScale when DD is at its default, so "FOV-only" configs carry a hidden 5.2× effective DD). **Stochastic overlay:** `randomSetSeed(nowCount)` uses the wall clock, so trajectory → room load order varies per run; one run logged 174 failures while six subsequent identical runs logged zero — even at the *authored* pool size in short walks. That is why the bug was intermittent for the user and evaded early headless repro.
|
||
|
||
**Fix (port-only, `#ifdef PORT`):** `portRoomPoolScale()` in `port/src/video.c` returns a pool multiplier composed from the same knobs that widen the view — FOV term = manual FovScale × the WidescreenAuto aspect factor (both widen the frustum horizontally); DD/Lod terms = **square root** of the distance multipliers (room *count* grows far slower than distance² — measured 9→12 rooms at 4× DD on Statue — so sqrt is a deliberate under-estimate). Capped at 2.0× (worst case `-ma350` row → 700 KB, well inside the PC's ~6 MB STAGE bank, D95); **identity (1.0) when every knob is N64-faithful**, so all-default settings keep the exact authored budget and golden dumps stay byte-identical. Applied in `bossMainloop` (`src/boss.c`) immediately after the `-ma` token parse — the single chokepoint both play flows (menu-driven and `-level_XX`) pass through — clamped to STAGE-bank-minus-128 KB headroom. `GE_ROOMPOOL=<float>` overrides the computed scale outright (test hook; floor 0.25 for stress-testing the exhaustion path; also a user opt-out).
|
||
|
||
**Verified headless (`-level_22`, probes `GE_D294`/`GE_D104`; the `GE_D294` stage-load print is left in `boss.c`, env-gated):** all-N64-faithful INI → `ma=225280` = exact authored size (identity ✓); user settings (FovScale 130, DD/Lod 400) → `ma=450560` (2×) with **zero** alloc failures across repeated runs; `GE_ROOMPOOL=0.25` → `ma=56320` and exhaustion reproduces on demand (thousands of `FAIL alloc` lines with movement) — proving both the override and the mechanism.
|
||
|
||
**Residual, documented only (NOT a port bug): the far-horizon hard edge at high DrawDistance on night levels.** Where fog saturates to the sky colour before the geometry's far clip, the horizon reads as a sharp lit→black boundary. That is inherent to the D218 draw-distance setting on levels whose sky ≈ black (Statue, and similar); the N64 never shows it because its far clip is authored short enough that fog always saturates first. No fix warranted; if it bothers a user, lower DrawDistance.
|
||
|
||
**Side finding (CLI footgun, no fix — game code is ground truth):** `tokenFind(1, "-m")` **prefix-matches** `-ma`, `-ml`, `-me`, …, so passing a bare `-ma N` (or `-m`) on the PC command line corrupts `g_CurentMMallocValue` (→ 0 → divide-by-zero crash). The in-ROM rows are the only intended source of these tokens; don't pass `-m*` flags on the CLI. (The D121 guard `tokenFind(1, "-m") == 0` correctly means "no -m* flag supplied".)
|
||
|
||
**Playtest update (evening of 2026-09-22).** First live Statue session at the user's settings: the wide monument-plaza patch is **gone**; residual report — "minor geometry going black/void at the corner of the screen at max FOV when running forward/backwards fast". An instrumented re-test (`scratch/d294_capture.bat`) did not visibly reproduce, but the log caught **75 `FAIL alloc` events anyway** (room 11 ×43 @33 KB, plus 23/7/17) — brief per-frame-retried starvation episodes the eye doesn't register. Root of the residual: GE keeps loaded rooms resident, and Statue's **full 27-room set is 487 KB = 2.2× its authored pool** — running around at max visibility eventually demands the whole set, which starves even the 2× (450 KB) pool. The sqrt rationale measured only *simultaneously visible* rooms; it missed accumulated residency.
|
||
|
||
**3×/linear attempt REGRESSED — reverted (same evening).** Distance terms linear + cap 3.0 (`ma=675840`) fixed the starvation headroom but broke the renderer on the **menu-driven** Statue path: "world renders as untextured polygons, Bond's hands normal". Log evidence: `[D156] bad anim frame guarded: frame=nan ...` + `subcalcmatrices: frameb out of range!` spam starting ~5 frames after stage load; the corrupted Model has valid fields (speed=0.54, anim ptr into PERMANENT) mixed with NaN/garbage floats (newspeed≈1e24, elapsespeed≈-FLT_MAX). **Not a bank overflow** — `GE_D294BANK` probe shows STAGE [0x70050000..0x706a8000] with pos=0x702ebb30 (2.6 MB used, 3.7 MB free) and `needmemallocation=0`. **Path-dependent**: direct `-level_22` boots were clean in 4/4 headless runs (Statue at vi post #1); the user's menu-driven load (Statue at vi post ~#3541, after ~60 s of front-end) corrupted. Suspects: menu-path-specific init order, or wall-clock-seeded stochasticity that the short headless runs didn't hit. Reverted to sqrt + 2.0 cap (user-verified working state; `ma=450560` re-verified). Probes left in place for the next repro: D156 hexdump of the corrupted struct (`src/game/model.c`, TEMP-marked) + `GE_D294BANK` bank-state dump (`src/game/bg.c`, wired into `scratch/d294_capture.bat`).
|
||
|
||
**Status:** **FIXED (main symptom, user-confirmed); residual corner flicker OPEN** (2026-09-22). The 2× build is the shipped state. To close the residual: capture-bat run at max FOV with fast movement → if `FAIL alloc` recurs, the fix must cover whole-level residency (487 KB) **without** the 3× regression — options: scale only when the level's room-set total is known to exceed pool (bg data has per-room sizes at load), or a smaller targeted bump (e.g. 2.5×); any change needs BOTH a direct-boot AND a menu-driven verification.
|
||
|
||
**2026-09-25 playtest (user, Windows, one ~46-min session Silo → Caverns, `GE_D322=1 GE_D204=1`, build `7baf4900`):** walking past the CCCP monument did not reproduce the black ground. Confirmed fixed.
|
||
|
||
## D211 — PC FOV slider — `Video.FovScale` (M-83 QoL wave, port-only, `WIDESCREEN-FOV-PLAN.md` Phase 4).
|
||
|
||
**PC FOV slider — `Video.FovScale` (M-83 QoL wave, port-only, `WIDESCREEN-FOV-PLAN.md` Phase 4).** fast3d applies a multiplicative half-angle scale to every *perspective* projection matrix as it is loaded (`gfx_apply_fov_scale()` in `gfx_pc.cpp gfx_sp_matrix`, on the `G_MTX_PROJECTION | G_MTX_LOAD` path only — GE always reloads its view projection via `guPerspectiveF` at `fr.c:725`, so a relative `G_MTX_MUL` onto an already-scaled matrix cannot compound). Columns 0 and 1 are scaled by the same factor `tan(half_orig)/tan(half_adj)` → H and V FOV widen uniformly, aspect ratio exactly preserved (no stretch), and because the game rewrites fovy every frame for aim-zoom the scale composes with zoom for free. Ortho matrices (`P[3][3]==1`) are left untouched so HUD/menu 2D is unaffected. `Video.FovScale` = percent of the original vertical FOV, 50–150, **default 100 ⇒ `g_fov_scale == 1.0f` ⇒ guarded early-return, zero FP ops on the matrix ⇒ golden dumps byte-identical**. F10 overlay row "FOV scale %". Math checked offline (`s=1.25 → fovy 60°→75°`, aspect held at 1.3333).
|
||
|
||
**Status (from §F table):** **PARTIAL — M-84 refix: game-side FOV scale (`src/fr.c`, `#ifdef PORT`), front-end gated, user-verified.** Original M-83 note follows. LANDED, port-only; build clean; `bunker1` framediff worst_cell 12.219 vs 12.221 baseline (= render noise, no-op confirmed). **M-84 refix — the fast3d hook only scaled *pure* perspective loads; GE draws the world through `g_CurrentPlayer->field_10E0`, a projection × view matrix pre-combined on the CPU (`bondview2.c:8224`) that failed the `gfx_apply_fov_scale` guard, so only the pause/watch model + sky FOV changed (user report).** Moved the scale game-side to the `guPerspectiveF` chokepoint (`fr.c:718`, `#ifdef PORT`, `frFovY *= portFovScale`, clamp 160°), which lands *before* the CPU combine; fast3d `gfx_apply_fov_scale`/`gfx_set_fov_scale` removed; `port/src/video.c` sets `f32 portFovScale`. Also gated to `lvlGetCurrentStageToLoad() != LEVELID_TITLE` — the front end (main menu / file+mission select / briefing) renders through the same path (`lv.c lvlRender`) and its 3D is authored at a fixed FOV. **User-verified: front end unaffected + in-level view widens (Dam tunnel at high FovScale).** M-85 headless nit check (`-level_09`, FovScale 100 vs 120): **HUD-static confirmed** — the ammo counter ("`7 | 93`") sits at the identical bottom-right screen position in both, and the door-arrow stays square (no 4:3 stretch); viewmodel scales down + stays bottom-right anchored. **aim-zoom-compose — M-86 static analysis: correct by construction.** `bondview2.c:3123` lerps `zoominfovy` then `viSetFovY()` stores it in `g_ViBackData->fovy`; `fr.c:737` applies `frFovY = fovy * portFovScale` **once**, at the per-frame chokepoint, *after* the zoom lerp. Hipfire and zoomed states scale by the same factor so the zoom magnification ratio is preserved exactly; no double-application; the smooth zoom interpolation is unaffected (scale is post-lerp). Residual human eyeball (low risk, cosmetic) at FovScale ~120–150 + full aim-zoom: the viewmodel (`c_perspfovy`, `bondview.c:644`) and frustum-cull (`currentPlayerSetCameraScale`) use the *unscaled* fovy, so a subtle viewmodel-scale or edge-cull-early mismatch is possible; the scope reticle is 2D screen-space, unaffected. Caveat (same root): edge geometry culls a hair early at large scale (cosmetic). **User-verified good at big FovScale.** New adjacent report → **D220**: 1P muzzle flash position shifts upward at higher FovScale (Jungle, M16) — likely the same unscaled-viewmodel-fovy root as the caveat above. Wide FOV also exposes the per-level draw-distance boundary → **D218** (logged, parked). **M-87: the frustum-cull residual is a real bug, not cosmetic** → split to **D222**.
|
||
|
||
## D219 — PPK explosions render purple on Dam (M-87, user QA, "not 3D but noting").
|
||
|
||
**PPK explosions render purple on Dam (M-87, user QA, "not 3D but noting").** Effect-color defect, not a texture-decode one — likely the D172 particle-color family (`gfx_lod_tile_offset` / `G_CC_INTERFERENCE` channel-order class) recurring on a different particle type/weapon than the D172 fix covered, or a fresh instance of the same class.
|
||
|
||
**Status (from §F table):** **RESOLVED (M-115, 2026-09-12)** — byte-order fix in `src/game/image.c` scoped by pixel format (16-bit family bswap16-at-store, 32-bit identity); user playtest-confirmed. Superseded history below, which began: **OPEN — observed, not investigated.** Next: `GE_PCDUMP`/`GE_TEXDUMP` a Dam PPK explosion; check whether it's the same `gfx_lod_tile_offset` tile-sampling defect D172 fixed for blood, on an explosion DL D172's fix didn't touch. **Status: re-checked at runtime M-90 — mechanism covered by the D172 fix on current `main`; close pending a human by-eye pass (folds into PR #47 playtest).** **M-89 triage (local-Qwen, static analysis only, not yet runtime-confirmed):** the explosion DLs (`assets/oddtextures.c` `globalDL_0x078/0x120/0x1c8/0x270…`) use the same `G_CYC_2CYCLE`+`G_CC_INTERFERENCE,G_CC_MODULATEIA2`+`G_TL_TILE`, tile0=IA smoke/tile1=RGBA fire mechanism as the already-fixed D172 blood/spark records — no separate/unfixed code path found in `explosion.c` or the asset DLs. If still purple after D172's fix (which the user's Dam report postdates — needs re-confirming on current `main`), the next suspect is the GLSL combiner implementation of `G_CC_INTERFERENCE`+`G_CC_MODULATEIA2` for cycle 2 specifically (`gfx_generate_cc`, `port/fast3d/gfx_pc.cpp:312-494`), not the tile-offset fix. **Next, concrete:** run Dam (`-level_33`) with `GE_D172=1` while triggering a PPK/grenade explosion and check for "BUG" lines in the log (silence = D172's fix already covers this DL and the row can likely close on a re-test; needs a scripted-fire repro, not yet built). **M-90 runtime re-check DONE (scripted-fire repro built with `GE_INPUTSCRIPT`): the D172 fix demonstrably covers Dam's particle DLs — row can close on a human by-eye pass.** Ran `-level_33` headless with `GE_D172=1`, 64 scripted Z-pulses (`GE_INPUTSCRIPT="200:Z;215:Z;..."`, ~15 controller-reads apart) and a `GE_PCDUMP` window over the same span. Result: **40/40 particle multitex tris log `SAMPLED1=tile1 (tmem=392)`** — the correct RGBA fire tile, i.e. exactly the post-D172-fix behaviour (pre-fix these read `SAMPLED1 = tile0 (tmem=0 fmt=IA)`, the smoke texture); **zero** "BUG"/unknown-GBI/D146-abort lines; **0** purple/magenta pixels (`r>120, b>120, g<90, r-g>50, b-g>50`) across all 28 captured frames. Caveat: the user's M-87 "purple" report may predate the D172 fix on their binary (playtests run on whatever build was current); this re-check proves the sampling path is correct on current `main`, which is as far as a headless pass can take it — a final by-eye look at a live Dam PPK firefight is the honest closer (cheap for the user, folds into the D217 PR #47 playtest). **M-108 — REOPENED, the by-eye closer FAILED.** User played a live Dam (`-level_33`) PPK firefight on the current `main` build (post-D233/D234 fixes, same session as D243) and confirmed explosions are **still blue/purple**, directly contradicting M-90's clean scripted-repro result (40/40 correct-tile samples, 0 purple pixels, 28 frames). This means the M-90 `GE_INPUTSCRIPT` scripted-fire repro was very likely **not exercising the same explosion instance/DL the live firefight hits** — plausible gaps: a different explosion variant (grenade vs. bullet-impact spark vs. enemy-death vs. PPK-specific muzzle/hit effect) using a DL the scripted test's Z-pulses didn't trigger, or a state/lighting/prim-color condition (M-89's alternate suspect: the `G_CC_INTERFERENCE`+`G_CC_MODULATEIA2` cycle-2 GLSL combiner implementation, `gfx_generate_cc`, `port/fast3d/gfx_pc.cpp:312-494`) that only manifests under conditions the synthetic repro didn't reproduce. **Do not trust the M-90 all-clear for this finding going forward** — it proved one particle path clean, not that Dam's live-fire purple is fixed. Next: a live `GE_PCDUMP`/`GE_D172=1` capture during an actual purple explosion in real play (not a scripted approximation) is now the only way forward — need to pin down exactly what triggers it (PPK gunshots hitting a surface/enemy? a thrown/tossed explosive? which). **User has now confirmed (2026-09-12) this is NOT Dam-exclusive — game-wide, not just suspected.** Treat as confirmed scope, not pending a second-level check. **User wants this actively fixed, not just triaged** — next session should prioritize a live `GE_PCDUMP`/`GE_D172=1` capture during a real explosion (any level) over further static analysis, since the M-89/M-90 static passes are exhausted on this finding without producing a fix. **New HIGH-interest lead (M-110): `explosion.c` never clears `G_LIGHTING` before drawing any of the 15 particle DLs** (`globalDL_0x078` through `0x9a8` in `assets/oddtextures.c`, all structurally identical, all `G_CC_INTERFERENCE`+`G_CC_MODULATEIA2`) — grepped every particle-draw call site in `explosion.c` (`gSPClearGeometryMode` calls at lines 883/1495/1718/2227 only ever clear `G_CULL_BOTH`/`G_FOG`/`G_CULL_FRONT`, never `G_LIGHTING`) and confirmed `G_CC_INTERFERENCE,G_CC_MODULATEIA2` is used nowhere else in the codebase, so it's safe to key a probe on it. The port's vertex path (`port/fast3d/gfx_pc.cpp:1301-1346`) branches hard on `rsp.geometry_mode & G_LIGHTING`: if lighting is on when these particles draw, the authored RGBA tint in `v->v.cn[]` gets reinterpreted as a normal vector, and the resulting SHADE color (fed into `G_CC_MODULATEIA2`'s `COMBINED*SHADE`) becomes a scene-light color instead of the DL's actual red/orange fire tint — a mechanism that would plausibly read as "wrong color, state-dependent, game-wide" exactly as reported, and would explain why a synthetic scripted-fire repro (M-90, no nearby lit models) came back clean while live firefights (guards/lit models on screen) don't. **Not yet confirmed** — this depends on whatever last touched `geometry_mode` before the particle draw in a given frame, which the game's own N64-accurate command order controls; it is not yet established whether this is a genuine port bug (fast3d misordering/dropping a `G_GEOMETRYMODE` command relative to real hardware) or something that would reproduce identically on N64 (in which case it isn't actually the D219 mechanism, since N64 doesn't show purple explosions). **Instrumented, not fixed:** added a `#ifdef PORT`/`GE_D172=1`-gated log line to the existing D172 multitex-tri probe (`port/fast3d/gfx_pc.cpp` ~line 1740, `gfx_sp_tri1`) that now also prints `geometry_mode` and flags `LIGHTING=ON <-- D219 SUSPECT` whenever one of these particle triangles draws with lighting on. Sanity-checked against the known-clean M-90 repro (headless `-level_33`, 40 scripted Z-pulses): probe fires exactly 40/40 as before, all show `LIGHTING=off` — consistent with the already-known-clean case, confirming the probe works and doesn't false-positive. **Next, concrete:** run `build-pc/d172_silo.bat` (or equivalent, `GE_D172=1`) and trigger a real explosion (any level, any explosive source) near visible character models; grep the log for `D219 SUSPECT`. A hit confirms the mechanism and turns this into a real fix (clear `G_LIGHTING` in the port's particle-draw path or in fast3d's geometry-mode tracking, whichever turns out to be the actual divergence from N64); a miss across a real purple-explosion capture rules this whole lead out. **M-112 (this session): decode-chain code review + two new inert diagnostic probes; runtime capture NOT obtained (environment limitation, see below). Confidence: LOW-MEDIUM on a narrowed next step, no fix applied.** Read the full N64 texture decompression ground truth for these assets end to end: `texLoadFromDisplayList`→`texLoad`→`texInflateNonZlib`/`texInflateZlib`→(`texReadUncompressed | texInflateHuffman/texInflateRle+texChannelsToPixels | texBuildLookup+texInflateLookup/texInflateLookupFromBuffer`) (`src/game/image.c`), plus `gimgFixupGlobalimagetable`/`gimgSyncCompiledGlobalDLs` (`port/src/gimgfixup.c`) and `import_texture_rgba16` (`port/fast3d/gfx_pc.cpp:653-681`). Findings: (1) `IMAGE_FIRE_0..14` are **not** `sImageTableEntry`-table images (unlike `IMAGE_IMPACTMULTI`/`IMAGE_IMPACTREDBRICK*`/flare/ammo images, which live in the 32 `sImageTableEntry` arrays at segment offset 0xAC8+ and get their `G_SETTIMG` marker synthesized at runtime by `texSelect`, `src/game/othermodemicrocode.c:318`) — FIRE_N's `gsDPSetTextureImage(...IMAGESEG(IMAGE_FIRE_N))` is baked directly into the compiled `globalDL_0x078..0x9a8` display lists (segment offset 0x000-0xA50) alongside the interleaved, correctly-rendering `IMAGE_SMOKE_0..14` (IA16). Both FIRE's and SMOKE's markers go through the *same* `gimgFixupGlobalimagetable` part-1 DL-bswap step and the *same* `texLoadFromDisplayList`→`texLoad` resolution — no separate/divergent table-offset math applies to FIRE_N specifically (this narrows away part of the open brief's option (a): there is no `sImageTableEntry`/`s_tbl_offs[]` bounds bug possible here, because FIRE_N never goes through that array class at all). (2) `texChannelsToPixels`'s `TEXFORMAT_RGBA16` case and `texReadUncompressed`'s `TEXFORMAT_RGBA16` case are decomp ground truth (byte-exact, `#ifdef PORT` free) and are format-generic, not texture-specific — a bug there would require a same-format sibling to also be wrong, and IMPACT/FLARE RGBA16 sprites (same `G_IM_FMT_RGBA,G_IM_SIZ_16b`, same underlying `texLoad`) are the only same-format comparison point on hand, **but this session did not runtime-confirm whether those actually render correct colors in game** — flagging as an unverified assumption inherited from earlier sessions' "shared decoder is proven correct" framing, not re-proven here. (3) `texSwapAltRowBytes` is already an unconditional no-op on `PORT` (D159, `src/game/image.c:2219`, `return;` before any format switch) so it cannot be format- or texture-selective on the port — ruled out as a contributor for FIRE specifically (it's equally a no-op for every other texture). **New, added, NOT yet exercised:** two matching `GE_D219RAW`-gated diagnostic probes (inert unless the env var is set, `#ifdef PORT`, no behavior change): (a) in `texInflateNonZlib` (`src/game/image.c`, right after the per-image header read, and again right after decode) — logs `texnum`, `format`, `width`, `height`, `compmethod` from the bitstream header, then the first row of decoded RGBA5551 pixel words, for every image shaped 16x14 (the FIRE tile shape); (b) in `import_texture_rgba16` (`port/fast3d/gfx_pc.cpp`) — logs the same pixel words as read back at upload time, so a session can diff "as decoded" vs "as consumed" to rule pool corruption/mis-addressing in or out. **Environment blocker hit this session:** could not get the built `ge007.x86_64.exe` to produce *any* stderr/log output when launched from this delegated agent's shell, across four different invocation strategies (direct foreground exec, backgrounded `&`/`nohup`, `cmd /c start foo.bat` detached, `cmd /c foo.bat` synchronous with a hard timeout) — the process either exits or is killed before a single `osSyncPrintf`/`sysLogPrintf` line reaches the redirected log file, even though every print call in this codebase does an explicit `fflush(stderr)`. A plain GUI sanity check (launching `notepad.exe` the same way) worked fine, ruling out a blanket "no GUI" sandbox restriction, so the failure is specific to this binary/environment combination and its cause was not identified. Per `env-no-runtime-verify` (session memory): runtime verification is session-dependent; treat this session's empty captures as inconclusive, not as evidence of anything. **Next, concrete (for a session that CAN capture runtime output, interactive or otherwise):** (i) run any level with `GE_D219RAW=1` during an explosion/PPK firefight (or simply on level load, since these are likely eagerly cached) and read the logged `compmethod` for `IMAGE_FIRE_N` — cross-reference against a `GE_D219RAW`-instrumented capture of a *confirmed-correct-on-screen* RGBA16 sprite from the sibling `sImageTableEntry` families (e.g. `IMAGE_IMPACTMULTI`) to see whether FIRE uses a different `TEXCOMPMETHOD_*` (the compressed-bitstream compression method, not the pixel format) than a texture confirmed to render correctly — `TEXCOMPMETHOD_LOOKUP`/`_HUFFMANLOOKUP`/`_RLELOOKUP` (`texBuildLookup`+`texInflateLookup*`) build/consume a packed-color lookup table with a completely different bit-layout code path than `_HUFFMAN`/`_HUFFMANPERHCHANNEL`/`_RLE` (`texChannelsToPixels`, per-plane R/G/B/A order); if FIRE's method differs from the working comparison texture's, that pinpoints exactly which decode function to scrutinize next, rather than continuing to treat "the RGBA16 decoder" as one monolithic suspect. (ii) if the compmethod matches, the bug is genuinely upstream of decode (ROM/asset-extraction data itself, not the decompression code) — at that point compare the decompressed byte stream against a reference N64 emulator's texture-dump of the same asset (this repo has no such reference on hand) to establish definitively whether the ROM bytes for FIRE really do decode to blue/purple on real hardware too (in which case the bug is actually downstream in the color-combiner path after all, reopening the D172/G_CC_INTERFERENCE lead this session did not have budget to re-examine) or whether the extraction into this repo's `assets/oddtextures.c`-adjacent ROM data diverges from the real cart image for just these 15 offsets. Probes left in tree, both inert by default: `GE_D219RAW` (`src/game/image.c` `texInflateNonZlib`, `port/fast3d/gfx_pc.cpp` `import_texture_rgba16`). **M-141: Steam Deck playtest confirmation, widened symptom scope** — user reports "blood is purple, shots on body are blue/green" (general, not tied to a specific level in the report). Blood/hit-decal color is a new specific instance beyond this row's tracked "PPK explosions"/"particle effects" framing — same RGBA16/particle-color neighborhood as D172/D252, worth keeping in mind together rather than as three unrelated color bugs; whichever gets a real runtime capture first (this row's own `GE_D219RAW` probe, or D252's `GE_D252`) should also check blood decals specifically.
|
||
|
||
**M-157 (2026-09-16/17, this session): first real visual capture of this bug family — but the G_LIGHTING theory below turned out to be a plausible-looking dead end, corrected at the end of this entry; read the whole thing, not just the first paragraph.** While chasing D252 (see that entry's M-156), reused the `GE_INPUTSCRIPT` Z-pulse technique on a bare `-level_09` (Bunker1) boot with `GE_PCDUMP` running, aimed at a plain metal door. **Got the first-ever visual capture of this bug family**, and it is dramatic: the same bullet-impact spark, hitting the same spot on the same door across consecutive shots, renders as bright **magenta** (frame 108), fading pale-purple (114), **green** (129), **teal/cyan** (144), purple again (198) — a literal hue-wheel cycle, screenshots in `scratch/d252_impact_zoom_*.png` (session-local, not committed — small enough to re-capture on demand with the command below). This is a plain rifle/pistol bullet hitting a static door, nowhere near an explosion — a different, previously uninvestigated code path from both D219's `explosion.c` particles and D252's `IMAGE_FIRE_N` aging system.
|
||
|
||
**Traced the actual call chain and ruled out three plausible mechanisms with code evidence before finding a fourth that fits:**
|
||
1. **Not the D252 fire-tile system.** `GE_D252=1` (hooked in `import_texture_rgba16`) got **zero hits** on this exact repro — this bug fires through a different texture-loading path entirely.
|
||
2. **Not a struct-layout/ABI bug.** The spark's texture-frame lookup (`glass2.c:678` etc.) does raw pointer arithmetic — `((struct sImageTableEntry *)((u8*)thing->unk0C + frame*12))->width` — which looks exactly like the D3x/A1 "hardcoded stride into a struct that grew" pattern this session's rule-2 work catalogued. **Checked and refuted**: `sImageTableEntry` (`src/bondtypes.h:4085`) is `{u32 index; u8 width,height,level,format,depth,flagsS,flagsT,pad;}` — no pointer members, `sizeof == 12` on both 32- and 64-bit. The hardcoded `12` is correct on this platform; bucket (a) does not apply here.
|
||
3. **Not the fixed primitive-color path.** `bullet_spark_render` sets vertex color (`vtx.v.cn[]`) from `g_BulletSparkColors[arg2]` (`glass2.c:20-27`); for a world-prop hit (`propobj.c:9633`, `objHit`) `arg2` is a hardcoded literal `1`, giving a fixed pale-yellow `{0xFF,0xFF,0xC8,0xFF}` every time — cannot itself produce magenta/green/cyan.
|
||
4. **The actual texture (`explosion_smokeimages`, `assets/oddtextures.c:484`) is genuinely IA8** (`G_IM_FMT_IA, G_IM_SIZ_8b`, 6 frames, no color channels at all) — confirmed fast3d's format dispatch (`gfx_pc.cpp:~1200`) correctly routes `fmt=IA,siz=16b` (after `texSelect`'s legitimate 8b→16b RDP-upload promotion, `othermodemicrocode.c:~400`) to `import_texture_ia16`, which decodes it as plain intensity+alpha with no byte-order bug (unlike D219's RGBA16 case) — so the texture itself cannot be the color source either.
|
||
|
||
**With texture and primitive color both ruled out as direct color sources, the M-110 `G_LIGHTING` mechanism is the one that fits everything: `bullet_spark_render` (`glass2.c:606-702`) never clears `G_LIGHTING`** — its only geometry-mode call is `gSPSetGeometryMode(gdl++, G_CULL_BACK)` (line 696), which only sets a bit, never clears one. If `G_LIGHTING` is on (inherited from whatever level geometry drew immediately before this overlay), fast3d's vertex path reinterprets the authored `vtx.v.cn[]` color bytes as a lighting normal vector and computes a scene-light-derived SHADE color instead — exactly the mechanism M-110 proposed for `explosion.c`'s particles, now found completely independently in a second file, for a completely different visual symptom (bullet sparks, not explosions), both showing the same "wrong, state-dependent, cycling" color family. This is strong convergent evidence for the G_LIGHTING theory generally, even though neither call site has a *confirmed* fix yet.
|
||
|
||
**Bucket classification, per this session's own dev-process.md §taxonomy — genuinely unresolved, flagging honestly rather than guessing:** `bullet_spark_render` carries a real decomp address comment (`// Address: 7F0A3F04`), i.e. its lack of a lighting-clear call is decomp-verified N64 ground truth, not a decompilation error — so if this were simply "add `gSPClearGeometryMode(G_LIGHTING)` to glass2.c," that would be changing verified-original game code, squarely bucket (c) territory needing a rule-2 sign-off with proof N64 hardware doesn't show this (not yet gathered). But it's equally plausible this is bucket (b): a port-layer bug in *which* lights are considered, or in geometry-mode tracking/restoration between draws, that N64's actual per-DL state machine would never have produced — fast3d's vertex-lighting implementation (`gfx_pc.cpp:~1301-1346`) hasn't been audited for a normal-vs-reference-implementation divergence in this session. **Do not add a lighting-clear call to `glass2.c` or `explosion.c` without first ruling out (b)** — that's the two-question gate `docs/dev-process.md`'s §7 requires before any bucket-(c) fix.
|
||
|
||
**Next, concrete:** (1) audit `port/fast3d/gfx_pc.cpp`'s vertex-lighting path against the real RSP lighting equation for a divergence that would explain why this manifests as literal rainbow hues rather than a plausible (if wrong) lit color — a genuine implementation bug there would be bucket (b), fixable entirely in `port/`, no sign-off needed, and should be checked FIRST. (2) If that's clean, trace what actually leaves `G_LIGHTING` on at the moment these overlays draw and whether N64's real command stream would have the same state (needs either real hardware or a from-scratch RSP command trace, not available this session) before treating this as a genuine bucket-(c) case. (3) A cheap falsifier for a future session with build access: force `G_LIGHTING` off immediately before `bullet_spark_render`'s draw call under a temporary `#ifdef PORT`/env-gated probe (not a permanent fix) and see if the spark holds a stable pale-yellow — a clean result narrows the mechanism definitively without committing to a fix path yet.
|
||
|
||
Repro for future sessions (headless, no game code touched): `GE_PCDUMP="0-1200:3" GE_INPUTSCRIPT="60:Z;70:Z;80:Z;...;350:Z" ./build-pc/ge007.x86_64.exe -level_09`, then inspect `ppm/frame_0001*.ppm` visually (or via any PPM viewer) near the door directly ahead of Bunker1's spawn — the spark reproduces on nearly every shot. This headless-visual-capture technique (`GE_INPUTSCRIPT` + `GE_PCDUMP` together, then inspecting the resulting PPMs directly) is itself a reusable finding for future "does this actually look wrong" investigations in this project — no live human needed to get eyes on a rendering bug, which several other OPEN findings (D290, D291, D294) have been stalled on for exactly that reason.
|
||
|
||
**Correction, same session, right after writing the paragraphs above: went back and actually measured G_LIGHTING instead of trusting the pattern-match to M-110.** Missed on first pass that the code comment at `gfx_pc.cpp` (right next to the D219 `GE_D172` probe, ~line 2015) already documents a live result never written up in this findings.md file: **"D219 round 2: G_LIGHTING is ruled out (5000/5000 samples off, live-confirmed still purple/blue)"** for `explosion.c`'s particles specifically — the sibling case the M-157 write-up above leaned on had *already* been empirically disproven, not merely unconfirmed. That should have been caught before asserting a "cross-validation" for `glass2.c` on code-reading alone.
|
||
|
||
Added a small, targeted diagnostic (`GE_D157`, `port/fast3d/gfx_pc.cpp`, right after the vertex color/lighting branch in `gfx_sp_vertex`, `#ifdef PORT`, cached getenv) that logs `geometry_mode`/`LIGHTING` state and the resulting shaded RGB whenever a vertex's raw `v.cn[]` bytes exactly match one of `glass2.c`'s `g_BulletSparkColors[]` entries (`{0xFF,0xFF,0xFF,0xFF}`, `{0xFF,0xFF,0xC8,0xFF}`, `{0xFF,0x00,0x00,0xFF}`). Re-ran the identical Bunker1 repro that produced the magenta/green/cyan screenshots: **only 2 hits, both `LIGHTING=off`, both pure white `(255,255,255)` unaffected** — no hit at all for the pale-yellow `(255,255,200)` value `objHit`'s door-spark call path should produce via `g_BulletSparkColors[1]`. Two possible readings, both worth recording: **(a)** most likely, **G_LIGHTING genuinely is off here too, and this mechanism does NOT explain the bullet-spark rainbow** — consistent with the sibling explosion.c case, and the two white hits this probe did catch (probably the correctly-rendering muzzle flash, `arg2==4`/`6`'s `flareimage2` branch) support that lighting is off for this whole particle family in this scene; **(b)** the probe's exact-byte-match approach may simply not be catching the actual spark vertices at all — vertex color could be getting modified before reaching this function by something not yet traced (Gouraud interpolation across the quad, a different `gfx_sp_vertex` call site, or `arg2`'s value at runtime differing from the `1` literal read in the static source). **Confidence: LOW-MEDIUM that G_LIGHTING is not the mechanism; the real root cause of the visually-confirmed rainbow cycling is OPEN AGAIN, not narrowed.** Do not carry the "cross-validates M-110" framing forward — that phrase in the first paragraph of this entry is superseded by this correction, kept only so the reasoning trail (and the mistake) stays visible.
|
||
|
||
**Next, concrete, revised:** (1) confirm whether `GE_D157` actually fires at all during the visible magenta/green frames by cross-referencing frame numbers (`num_dls`) against the known-bad frame range (108-220 in the repro above) — if it never fires in that window, the probe is looking at the wrong vertices entirely and needs a texture-identity-based trigger instead (key on `explosion_smokeimages`/`flareimage2`/`scattered_explosions`'s actual runtime texture address, the way D252's probe keyed on `IMAGE_FIRE_N`'s addresses) rather than an exact vertex-color match. (2) Once the probe reliably fires on the actual bad frames, log the full RGBA path in the frame the screenshot shows magenta/green, not just LIGHTING state — the D217-style texture-cache-collision lead already proposed for `explosion.c` (see the code comment cited above) applies here too and hasn't been checked for this call path at all. (3) `GE_D157` is left in tree, env-gated, zero cost unset — extend it rather than replacing it.
|
||
|
||
**M-158 (2026-09-16, late evening, continuation of the M-157 session): both revised next-steps implemented as probes — and the bug did NOT reproduce. The rainbow is currently non-reproducible in the dev environment; 0/6 runs.** Implemented per the "next, concrete, revised" list above: **(a) `D157T`** (in `gfx_sp_vertex`, same file/probe family as M-157's exact-color-match probe, which is kept): a vertex-level full-path log keyed on **texture identity, not color** — fires when tile 0 holds a loaded texture ≤16 KiB within the frame window [90,240]; logs raw `cn[]` bytes, post-shade RGBA, LIGHTING bit, geometry_mode, combine_mode, tile-0 tmem/fmt/siz/addr/size/line, and uv. Cap 400 lines + a summary every 200 so the log stays bounded. **(b) `D157I`** (in `import_texture`): an import census — every small (≤16 KiB) texture imported in the window, deduped per source address (first 3 hits), logging addr/fmt/siz/size/tile/tmem so a `D157T` line's `addr=` resolves to a named image. Both `#ifdef PORT`, env-gated on `GE_D157`, zero cost unset, verified firing (~1800 D157T lines + 5 D157I lines per run).
|
||
|
||
**Repro matrix (all `-level_09` with M-157's exact 30-pulse Z script, `GE_D157=1`, `GE_PCDUMP="0-1200:3"`):** five runs at the ini default (4K-class fullscreen) + one run forced to 640×480 windowed → **zero rainbow pixels in any run**, frames 90–240, using a per-run baseline-diff scan (high-saturation blobs, delta >50 vs that run's own static scene). Input delivery verified independently via `GE_INPUTLOG`: exactly 180 reads = 30 pulses × 6 reads — the script works and `Z`=`CONT_G`=fire is confirmed in both `port/src/input.c` and the game's `bondview2.c`. The spark IS drawing: periodic pale-yellow spikes (~350–450 px at 4K) every ~5 render frames from frame ~88 (control acquired ≈ HUD appears), one per Z pulse, color stable at (255,255,~200) — never cycling hue.
|
||
|
||
**Conclusions:** (1) The D219/D252 rainbow is **flaky/environment-dependent**: seen on the user's Steam Deck (M-124) and in M-157's dev-box session, but 0/6 identical-parameter runs here. Run-to-run/environment candidate variables: heap layout/ASLR (the D250 note about an "uninitialized read of `key" on the texture-cache collision path was ruled out under a determinism assumption — worth re-examining under a flakiness hypothesis), GL driver/display state at launch, timing. (2) **Probe caveat for the next occurrence:** in these clean runs `D157T` captured level geometry plus one periodic IA texture (addr=0x70123a20, 2752 B, imported before frame 90 — a blinking level element, not the spark), but **no vertex matching the on-screen pale-yellow spike** — so either the actual spark quad doesn't satisfy the ≤16 KiB tile-0 key (texture larger at import, or sampled from another tile) or the on-screen pale yellow is a different object (muzzle flash). If the bug recurs and `D157T` logs nothing new, widen the key (drop the size cap; also key on tile 1).
|
||
|
||
**Next, concrete:** (1) ask the user to reproduce on the Steam Deck with `GE_D157=1` (stdout to a file; optionally `GE_PCDUMP`) and send the log back — the probes now capture the full RGBA path + texture identity on whatever frames are bad, which is exactly the data M-157's next-steps asked for. (2) Re-examine uninitialized-read candidates in the texture-cache key/collision path under the flakiness hypothesis before assuming a driver-state cause. (3) Probes stay in tree; do not remove them on the strength of 0/6 — two independent environments have seen the symptom.
|
||
|
||
**ROOT-CAUSED AND FIXED (2026-09-18 session).** Two blockers in the probe infrastructure were fixed first: `D157T`/`D157I` (`gfx_pc.cpp`) were both hard-gated to `num_dls` in `[90,240]` — sized for the old short `GE_INPUTSCRIPT` synthetic repro, silently discarding ~100% of the data in a real, minutes-long interactive session (which is what finally reproduced this live, on Windows, per the user's confirmation this is not Deck-exclusive). Removed that gate; kept the existing per-run/per-address hit caps to bound log size. Re-ran live with the user actively triggering bullet sparks: `D157T` finally caught the actual bad vertices, all sharing one exact draw signature (`geom=00002205`, `combine=f39e4f1f39e4f1`, `fmt=3 siz=1` = IA8, matching `glass2.c`'s `explosion_smokeimages`/`scattered_explosions`/`flareimage2` family) but with wildly varying, clearly-non-palette `cn[]` vertex colours (e.g. `(5,57,245)`, `(141,175,37)`, `(198,187,42)` — nothing close to `g_BulletSparkColors[]`'s white/pale-yellow/red/black), while the alpha byte stayed confined to just two values — good alpha, garbage RGB, a strong tell for a byte-offset/struct-layout bug rather than a lighting or combiner issue (both already ruled out above).
|
||
|
||
**Mechanism, confirmed against source (`src/game/glass.h:51-67`, `src/game/glass2.c:644-647`):** `s_bullet_spark` has `void* unk0C` at byte offset 0x0C, followed by six `f32` fields (`unk10..unk24`) and then the spark's stored RGBA colour bytes `unk28..unk2B` (set correctly in `bullet_sparks_init` from `g_BulletSparkColors[arg2]`). On 32-bit N64, `void*` is 4 bytes, so `unk28` sits at raw byte offset 0x28 — and `bullet_spark_render` reads it that way: `vtx.v.cn[0] = ((u8*)thing)[0x28]` etc., a **hardcoded raw-offset read, not a named-field access**. On 64-bit PC, the pointer widens to 8 bytes and the struct needs 4 bytes of alignment padding before it, pushing the *compiler-computed* offset of `unk28` to 0x30 — but the hardcoded `0x28` literal doesn't move with it, so the read instead lands inside `unk1c`/`unk20` (the spark's `cosf`/`sinf`-derived random velocity components), reinterpreting arbitrary float bytes as the vertex's RGBA colour. This is exactly the D3x/A1 hardcoded-offset-into-a-struct-that-grew pattern (`docs/porting-notes.md` §A1) — M-157's own D3x/A1 check for this function (see above) tested the *wrong* raw-offset call site (`glass2.c:678`'s `sImageTableEntry` stride, confirmed safe since that struct has no pointer members) and didn't look at this one, a plain hardcoded literal with no struct cast to raise suspicion in a static read.
|
||
|
||
**Fix (ABI-class, semantics-preserving, `#ifdef PORT`; `src/game/glass2.c`):** read `thing->unk28`/`unk29`/`unk2A`/`unk2B` (the correctly-typed named fields) instead of the hardcoded byte-offset literals — the compiler places these at the right offset on either pointer width, bit-identical to the old code on 32-bit N64. **Also fixed in passing, same function, same session:** `vtx = *((Vtx*)(&D_80040980))` (`glass2.c:638`) reads a full 16-byte `Vtx` from a 4-byte `u32` global — real UB the PC compiler flags directly (`-Warray-bounds`). Every field this sets (`ob`/`tc`/`cn`) is unconditionally overwritten later in the function; only `v.flag` survived from here, and `D_80040980`'s own `=0` initializer shows the N64-side intent was a zeroed vertex — replaced with a direct `memset(&vtx, 0, sizeof(vtx))` under the same `#ifdef PORT` guard (N64 line kept verbatim under `#else`).
|
||
|
||
**Verified:** rebuilt clean (only pre-existing unrelated warnings; the `-Warray-bounds` warning is gone post-fix). Re-ran the exact `GE_INPUTSCRIPT` door-spark repro with `GE_D157=1`: every `fmt=3 siz=1` (IA8 spark family) `shaded=` value is now pure grayscale (`r==g==b`) across the whole capture — zero hue variation, matching the intended white/pale-yellow/red palette shaded through an intensity-only texture. **User live-confirmed** on the same session, after triggering the bug interactively with the probes active, that the fix "looks better." D219 CLOSED.
|
||
|
||
**Scope note for D252 (`## D252`, the sibling fire/explosion-particle rainbow report):** this session's fix is specifically `glass2.c`'s bullet-spark path — `explosion.c`'s particle rendering (the original D252 report) was checked for the same hardcoded-raw-offset pattern and does **not** have it (`grep` for the `0x28]`-style literals in `explosion.c` found nothing), so D252 is very likely a **separate bug** sharing only the visual symptom family, not automatically fixed by this. D252 should be picked up next as its own investigation, not assumed closed.
|
||
|
||
|
||
## D250 — FPS is inconsistent/low on many levels after 60 Hz shipped (M-119, user QA; D248 follow-on).
|
||
|
||
**FPS is inconsistent/low on many levels since the port started actually running at 60fps — drops to 20–40fps intermittently, described as worse than before (M-119, user QA report, follow-on from D248).** Very likely a direct consequence of **D248** shipping: the port's render+tick loop had been silently running at half rate (30Hz) for the entire life of the project (D248, M-118) — the *real* per-level CPU/GPU cost of rendering and simulating this port was always this high, it was simply invisible while D248 halved the work being asked of the hardware every second. D248's own write-up flagged this as an open risk ("any level-specific perf regression now that render work happens twice as often per second") and explicitly left a live playtest / perf pass as un-done. Not yet investigated which side (CPU sim, CPU fast3d command generation, or GPU draw) is the bottleneck, or whether it's uniform or level-specific (the user's report implies specific/intermittent, which fits a per-scene cost — draw-call count, overdraw, or a specific expensive subsystem — rather than a flat doubled baseline cost). **M-120: partial progress, real bottleneck not yet caught.** User report sharpened: ~45fps at 720p on a self-described high-end PC, present even outside of dedicated stress levels — worse than the coarse "many levels, intermittent" framing suggested. Two things ruled out this session: (1) **build flags are not the cause** — `build-pc/CMakeCache.txt` confirms `CMAKE_BUILD_TYPE=RelWithDebInfo` (`-O2 -DNDEBUG`) is the actual default (`CMakeLists.txt:75-76`, unconditional unless a cache already exists), matching what `./build-pc.sh` produces with no extra flags — a debug/`-O0` build is not the explanation. (2) **idle-load is not the bottleneck, at least not in this dev box:** `GE_D193=1` on `-level_09` (BUNKER1, small level) idles at a rock-steady `render=60 sim=60`, frame-render time ~2.5 µs·10^3 (2.5 ms); `-level_29` (STREETS, one of D249's flagged large/open levels) idles at `render=55-60 sim=60-62`, frame-render time 6-7 ms — both comfortably under the 16.7 ms/60fps budget with no catch-up clamp hits. **This does not reproduce the user's 45fps** — either the real cost only shows up under actual gameplay load (AI/combat/particles active, not an empty idle scene — matches D193's own "player fine, AI-driven cost is different" pattern) or it's resolution/GPU-fill-rate-bound in a way this box's small idle test window doesn't exercise (this session could not drive real keyboard/mouse input into the game to generate a loaded scene — no interactive input tooling available headlessly). Also spotted and ruled out in passing: the `D51 vi post` debug log firing every render frame in `port/src/libultra.c:781` is throttled to ~once/sec (`% 60 == 1`), not a per-frame cost — not the culprit, though it's a `TEMP D51` instrument that's been live since early porting and could be removed as cleanup. **M-120 continued, live session with the user driving the game directly — the sim/render split is now confirmed live, and several strong candidates are ruled out, but the actual cost is still unlocated.** Reproduced directly on Dam (`-level_33`): `sim` holds a healthy 59-67/s throughout, `render` sags to 45-50/s, and the CPU-side `frame rendered in Nus` cost (`gfx_run`'s software-RSP interpretation, `port/src/libultra.c:1252`) stays only 6-10ms the whole time — comfortably under the 16.7ms/60fps budget. That leaves a genuine, unexplained ~10-15ms/frame, and a live A/B pass ruled out every obvious GPU-side suspect one at a time, each via the F10 in-game overlay while watching `GE_D193`: **MSAA 2→1 and Anisotropy 16→1 — no change.** **FovScale 125→100 — no change.** **VSync 1→0 (true uncapped rendering) — render still pinned at ~60 max, never higher, and the bad-scene numbers were unchanged** (also tells us the ~60fps ceiling itself is not a vsync artifact — something else already caps the loop at 60, vsync or not). **Resolution 1280×960 fullscreen → 640×480 windowed — no change** (rules out GPU fill-rate/pixel-count entirely; the cost does not scale with pixels at all). **Found and fixed a real, separate bug in passing (did not turn out to be the active cause here):** `sync_framerate_with_timer()` (`port/fast3d/gfx_sdl2.cpp`) computes the remaining frame budget in 100ns units (`qpc_to_100ns`) and passes it straight to `sysSleep()`, which takes **microseconds** — a genuine 10x oversleep whenever that branch fires. Fixed (`left / 10`). But this path only runs when `target_fps` is nonzero (tied to `Video.FpsCap`), and the user's test config had `FpsCap=0`/`VSync=0` — confirmed live that the bug fix made no difference to the reported drops, because the buggy code path was never executing in that config. Still worth landing as a correctness fix for whenever `FpsCap`/`VSync` *is* active. **Added `GE_D250`** (`src/boss.c`, wraps `lvlRender()`, the DL-building/culling/scene-traversal step that runs *before* `gfx_run()` and is separate from it) to check whether DL-building was hiding the missing time — live-tested on Dam: **`lvlRender` averages only 0.5-1.5ms, also not the bottleneck.** So: known costs (`gfx_run` ~6-10ms + `lvlRender` ~1ms) sum to ~7-11ms, real frame period is ~20-22ms (45-50fps) — the remaining ~10-15ms/frame is resolution-independent, GPU-setting-independent, and not in either of the two obvious CPU call sites. Traced the dataflow one level further: the game's main loop (`src/boss.c` `bossMainloop`) blocks on `osRecvMesg(&gfxFrameMsgQ, ..., OS_MESG_BLOCK)` waiting for `OS_SC_RETRACE_MSG`, posted by a dedicated pacemaker thread (`portTickThread`, `port/src/libultra.c`) at a fixed cadence independent of how fast the consumer drains it — meaning the observed render rate is whatever the consumer (game+render loop, one OS thread hop away from the pacemaker) can actually sustain, not a directly-configured cap. **Leading remaining suspect: the emulated N64 inter-"thread" message-passing/scheduler round-trip itself** (`port/src/gesched.c`/`sched.c`, real OS threads + mutex/condvar standing in for the N64's cooperative SP/DP/main threads) — each hop (game thread → scheduler → SP "thread" → done-message back) is a real OS context-switch-and-wake, and Windows condvar/thread wake latency can easily run several ms per hop, unlike the free N64 hardware handoff this code was ported from. **Not yet confirmed** — this needs either a targeted timer bracketing the full scheduler round-trip (same technique as `GE_D250`, just at a different call site) or a real sampling profiler (Visual Studio's own, or Windows Performance Recorder/WPR) attached to `ge007.x86_64.exe` during a live bad-fps moment, which would show this directly without more printf-probe guessing. Ad-hoc per-call timers have now ruled out the two most obvious candidates without finding the cost; a real profiler is the better next tool. **M-120 — ROOT-CAUSED AND FIXED: `getenv()`.** Set up a real profiler as recommended (`tools_pc/perf.ps1`, new sister script to `debug.ps1`, wraps `xperf` CPU-sampling + context-switch tracing). A live WPR/xperf capture on Dam, with Microsoft's public symbols resolved (`-symbols`, downloads system-DLL PDBs), showed the dominant render thread spending only **5.2% of its CPU time in the game's own code** — **43% was in `msvcrt.dll`**, and within that, `getenv()`/`_getenv_helper_nolock` alone accounted for **~50% of the thread's entire CPU time**. Root cause: `port/fast3d/gfx_pc.cpp`'s `import_texture()` (runs on every texture bind) and several per-triangle/per-`G_SETCOMBINE` combine-mode probe checks (`GE_DTEX`, `GE_TEXDUMP`, `GE_TEXRAW`, `GE_D229` ×2, `GE_D172` ×2, plus `GE_TEXDUMP`/`GE_PCDUMP` in `gfx_opengl.cpp`) called `getenv()` **uncached, every single call** — unlike the safe `static int x = -1; if (x < 0) x = getenv(...) != NULL;` pattern already used everywhere else in this codebase (`ft_d193`, `ge_d116`, `dvm`, etc.). On a texture-heavy scene this is tens of thousands of Windows CRT environment-lookup calls per second (each a locked, locale-aware linear scan per Microsoft's msvcrt implementation), completely invisible to both of M-120's earlier timers (`gfx_run`'s "frame rendered in Nus" and the new `GE_D250` `lvlRender` probe) because neither one specifically isolated CRT/library time from the game's own code. **Fix:** cached every uncached `getenv()` site in `gfx_pc.cpp`/`gfx_opengl.cpp` with the established pattern — narrow, mechanical, zero logic change. **Verified:** the exact idle-Dam headless test that was pinned at 45-50fps before the fix now holds a rock-steady 60-61fps, zero dips, with no other change. This also retroactively explains why M-120's earlier resolution/MSAA/vsync A/B pass (all "no change") was so consistent — the `getenv()` cost was a large, *per-call* (not per-pixel) constant that swamped any smaller resolution-dependent GPU cost at every setting tried, so the A/B genuinely couldn't have detected a fill-rate difference underneath it. **User re-tested 1440p/4K after the fix and confirmed it's fine too** — the initial "still runs badly at 1440p+" report was from before this fix landed (a stale-build mixup, same class as the DLL-load-error false alarm earlier in this session); once actually re-tested on the fixed build, high resolutions hold up fine. **D250 fully resolved, all resolutions tested clean.**
|
||
|
||
> The §F row had no status cell; the table row's status is derived from this entry's verdict text (ctx-opt Bucket B, 2026-09-15).
|
||
|
||
## D252 — Particle effects cycle through a rainbow palette (M-124, user playtest of the v0.2.0-pre candidate).
|
||
|
||
**Particle effects cycle through a rainbow palette (M-124, user playtest of the v0.2.0-pre candidate).** Shooting repeatedly makes the bullet-impact "sparks" slowly progress through the full hue wheel (~10 shots to sweep it); other lingering particle effects — smoke / explosion residue — show the same rainbow colouration instead of their intended grey/orange palette. **Mechanism context (verified this session):** particle tris carry no per-primitive material; their colour comes entirely from the ROM-resident state records `globalDL_0x078 … globalDL_0xa50` (`assets/oddtextures.c`, 15 of them) replayed per particle batch (`explosion.c:894`). Each record binds a **distinct** texture pair — `IMAGE_SMOKE_N` (IA, tile 0 @ TMEM 0) × `IMAGE_FIRE_N` (RGBA16, tile 1 @ TMEM 0x188) under `G_CC_INTERFERENCE` (TEXEL0·TEXEL1), and `explosionRenderPart` uses a fixed all-white default vertex (`explosion.c:215`) — so the intended per-stage colour is whatever those 15 fire/smoke pairs encode, and particles **age through the records** (`var_s2` 14→0 at `frame/flareanimspeed`), so a per-texture decode error surfaces exactly as a hue sweep over time / across shots. **Ruled out this session:** (a) the M-82/D172 tile-offset fix is correctly configured for this path — `gsDPSetTextureLOD(G_TL_TILE)` does NOT set the `G_TL_LOD` bit, so `rdp.tex_lod` stays false (`gfx_pc.cpp:2860`) and `gfx_lod_tile_offset` returns `i`, i.e. TEXEL1 genuinely samples tile 1; (b) the RGBA16/5551 texel decode in `import_texture_rgba16` is correct (big-endian, R=bits15-11 G=10-6 B=5-1 A=bit0). **Prime suspect: the width/height derivation in `import_texture_rgba16`** — `width = rdp.texture_tile[tile].line_size_bytes / 2`, `height = size_bytes / line_size_bytes`. The fire tiles are 16 px wide with a MASK_16 TMEM line (64 bytes), so `width` computes 32 (true: 16) and, with `size_bytes` = the LOADBLOCK's 16×14×2 = 448, `height` computes 7 (true: 14) — the GL texture would be built from the right pixels at the wrong dimensions, scrambling each of the 15 fire sprites into a different wrong-hued smear; that fits "slow rainbow progression" precisely (each aging stage = a different scrambled sprite). **Not yet confirmed** — needs one imported fire tile dumped and compared against the raw `IMAGE_FIRE_N` bytes (`GE_TEXDUMP`/`GE_PCDUMP`, or a targeted probe at the `upload_texture` call). **Per user instruction: documented only, NOT fixed for v0.2.0-pre** — ships as a known issue; pick up afterwards (cheap if the suspect holds: derive width/height from the LOADBLOCK's dtx/dlh rather than the tile line size, or special-case non-32-texel-wide RGBA16 tiles). D172/M-82 lineage. **M-141 update: the width/height suspect is REFUTED by the authored asset data, not just re-tested — the prior "prime suspect" reasoning assumed `line_size_bytes` derives from the tile's `MASK_16` wrap period (64 bytes ⇒ 32 texels), but `assets/oddtextures.c`'s actual `gsDPSetTile` for the RGBA16 **render** tile (tile 1, e.g. `globalDL_0x078:50`) sets `line=4` explicitly (independent of the mask), giving `line_size_bytes = 4*8 = 32` bytes ⇒ `width = 32/2 = 16` texels — and `size_bytes` from the paired `gsDPLoadBlock(...CALC_LRS(16,14,G_IM_SIZ_16b)...)` is 16×14×2 = 448 bytes ⇒ `height = 448/32 = 14`. `import_texture_rgba16`'s existing `width = line_size_bytes/2` / `height = size_bytes/line_size_bytes` formula therefore computes the CORRECT 16×14 for every fire tile, cross-confirmed by the pre-existing `is_fire_shape = (width==16 && height==14)` filter already hardcoded into `gfx_opengl.cpp`'s `GE_TEXDUMP` dumper (D219-era) — that filter only makes sense if 16×14 is what the importer actually produces at runtime. **Do not chase width/height again.** Also checked and rejected: the render tile's `gsDPSetTileSize(1,...,CALC_TILESIZE(56),CALC_TILESIZE(56))` sets a 56×56 texel-coordinate WINDOW for UV-wrapping the 16×14 content against the co-rendered 56×56 smoke texture (`G_CC_INTERFERENCE`, TEXEL0=smoke/TEXEL1=fire) — this is not a usable content-dimension source and switching to it (the naive "use SETTILESIZE instead of line_size_bytes" fix) would have made the bug worse, not better. **GEVR-TRIAGE item E (byte-group-order hypothesis) also does not apply new information** — the original session's decode-order check (still correct: `addr[2i]<<8|addr[2i+1]`, matches the already-fixed D228 IA16 sibling) stands; no runtime capture this session found a byte-order anomaly either (see below). **Root cause is now genuinely open, not "unconfirmed suspect"** — both named hypotheses are dead. Next investigator: the TMEM slot the fire render tile always loads into (`tmem=0x188`/392, `rdp.loaded_texture[]` indexed by tmem, hard-coded identically across all 15 `globalDL_0x078..0x9a8` records) is a plausible next avenue — 15 *different* ROM source images share one fixed TMEM address and one GL-cache key derived from `{texture_addr, fmt, siz, size_bytes, ...}` (`gfx_pc.cpp:~1962`); worth checking whether rapid record-to-record aging ever reuses a stale GL texture object keyed wrong, though this is an unverified new lead, not a confirmed mechanism. **Not runtime-confirmed this session** — added an env-gated diagnostic (`GE_D252=1` in `import_texture_rgba16`, `port/fast3d/gfx_pc.cpp`) that logs computed vs. SETTILESIZE dimensions and dumps raw texel bytes per distinct upload to `scratch/d252_raw_*.bin`, but two 35s/90s headless `-level_37` (Jungle) runs never triggered an explosion/fire draw (no player input available in this environment) — needs an interactive session or a longer/different-level run to actually fire the probe. **User-confirmed live, same session: still reproduces on plain bullet-impact sparks**, not just explosions — this is a much cheaper repro than an explosion (any gunshot against a wall/surface) and should be the target for the next `GE_D252=1` capture, not a scripted explosion.
|
||
|
||
**M-156 (2026-09-16, this session): the `GE_D252` probe finally fired, and the decode is now proven correct by direct byte inspection — rule out asset/decode entirely, the bug is confirmed downstream.** Reused the D219/M-90 scripted-fire technique (`GE_INPUTSCRIPT` with `Z` pulses, `Z`=`GE_CONT_G`=fire) on a bare `-level_33` (Dam) boot facing spawn geometry, 40 pulses over 30s — got **14 distinct fire-stage textures dumped** to `scratch/d252_raw_*.bin` (the probe dedupes by `(addr, size_bytes)`, so 14 unique ROM source addresses fired, each `tile=1 size_bytes=448 computed(w=16 h=14) settilesize(w=56 h=56) tmem=392`, matching M-141's already-established correct computed dimensions).
|
||
|
||
**Decoded all 14 raw dumps by hand** (Python, RGBA5551 big-endian: `r=(val>>11)&0x1F, g=(val>>6)&0x1F, b=(val>>1)&0x1F, a=val&1`, matching `import_texture_rgba16`'s own documented decode) and averaged the opaque (`a=1`) texels per stage:
|
||
|
||
```
|
||
stage n_opaque avg_rgb (0-255)
|
||
0 39 (248, 245, 155) pale yellow-white (hot)
|
||
1 54 (248, 234, 140)
|
||
2 65 (247, 227, 124)
|
||
3 69 (246, 220, 105)
|
||
4 81 (244, 192, 73)
|
||
5 83 (235, 148, 54)
|
||
6 84 (232, 134, 49)
|
||
7 85 (234, 142, 53)
|
||
8 86 (225, 125, 46)
|
||
9 78 (191, 81, 28)
|
||
10 61 (135, 48, 15)
|
||
11 20 (89, 22, 4) dark red, embers
|
||
12 0 (fully transparent)
|
||
13 0 (fully transparent)
|
||
```
|
||
|
||
This is a **perfect, smooth white-hot → yellow → orange → deep-red → fade-out fire progression** — exactly the intended palette, zero rainbow/off-hue content anywhere in the source data. This is the first time this session's (or any prior session's) D252 investigation has actually decoded and eyeballed the numeric pixel values rather than reasoning about dimensions/formulas — and it settles the question definitively: **the ROM asset data is correct, the byte-order/width/height decode is correct (independently reconfirms M-141), and the ordering of stages is a correct hot→cold sequence.** Whatever produces the on-screen rainbow effect is **entirely downstream of this point** — GL upload (`upload_texture`), the texture cache (`gfx_texture_cache_lookup`, `port/fast3d/gfx_pc.h`'s `TextureCacheKey`), or the `G_CC_INTERFERENCE` combiner/shader path — not the source data, not `import_texture_rgba16`'s math.
|
||
|
||
**Checked the texture-cache-key collision lead from M-141 and it does NOT hold as stated:** `TextureCacheKey` is `{texture_addr, palette_addrs, fmt, siz, palette_index, size_bytes, palette_hash}` (`gfx_pc.h:23-42`) — all 14 fire stages captured this session have **distinct `texture_addr` values** (14 different ROM addresses, confirmed in the probe log), so a same-address stale-cache-hit cannot explain cross-stage color bleed; each stage's texture is a genuinely separate cache entry. The shared `tmem=392` is an RDP TMEM slot number (where the RSP's interpretation of the texture lives during the *current* draw call), not a cache key component — irrelevant to GL-side caching. This specific theory is now ruled out; the M-141 paragraph proposing it should be read as superseded by this correction.
|
||
|
||
**Next, concrete:** since source-side decode is now proven clean, trace forward from `import_texture_rgba16`'s `gfx_rapi->upload_texture(tex_upload_buffer, width, height, gen_mipmaps)` call (`gfx_pc.cpp:718`) through to the actual GL texture object and the `G_CC_INTERFERENCE` (TEXEL0×TEXEL1) shader combiner — the smoke (tile 0, IA) × fire (tile 1, RGBA16) multiply is the last place color assembly happens before the pixel hits the framebuffer. A live capture with `GE_PCDUMP` alongside `GE_D252` during the same kind of repro (bullet impact against a wall) would let a future session correlate a specific on-screen rainbow frame against which of the 14 (now known-correct) source textures was bound at that moment — if the bound texture is stage N but the on-screen color doesn't match stage N's known-correct palette above, the bug is confirmed to be in upload/sampling/combiner, not stage-selection; if the *wrong* stage is bound (e.g. game logic's `var_s2` aging index reads garbage), that's a `src/game` question requiring the bucket-(a)/(b)/(c) check (`docs/dev-process.md`'s new taxonomy) before any fix. Raw dumps kept at `scratch/d252_raw_*.bin` (gitignored) for a follow-up session to reuse without re-capturing. **Tried, no data:** re-ran the identical Z-pulse script with `GE_D172=1` instead (the existing multitex-tile probe that checks whether TEXEL1 actually samples the tile the DL configured) hoping to catch a stale-tile-binding bug directly — **zero hits**, meaning the probe's own gate condition (`use_2cyc && comb->used_textures[1] && !rdp.tex_lod`, tmem mismatch between configured/sampled tiles) never matched during this repro. Not investigated why; could mean this particular repro's particles don't take the 2-cycle dual-texunit path the probe expects, or that path genuinely isn't where the bug lives. Don't re-run `GE_D172` on this exact repro expecting different results without first checking why it stayed silent. **M-157 update (same session, see `## D219`):** found and visually captured the rainbow-cycling symptom in a completely different code path — `glass2.c`'s `bullet_spark_render` (plain bullet-impact sparks on walls/doors, not fire/explosion particles at all) — and traced it to the same `G_LIGHTING`-not-cleared mechanism M-110 already proposed for `explosion.c`. That entry now has the strongest lead and the only real screenshots for this whole particle-color bug family; read it before continuing D252 specifically, since the two may turn out to share one root cause once the `G_LIGHTING` mechanism is confirmed or refuted. **M-158 (2026-09-16, see `## D219`): the bug did not reproduce in 6 identical-parameter dev-box runs — the whole family is currently non-reproducible here and flaky/environment-dependent; the extended `GE_D157` probes (D157T/D157I) are in tree to capture the full RGBA path on the next occurrence (user Steam Deck run with `GE_D157=1`).**
|
||
|
||
> The §F row had no status cell; the table row's status is derived from this entry's verdict text (ctx-opt Bucket B, 2026-09-15).
|
||
|
||
**M-188 (2026-09-18, same session as the D219 fix): the user's actual live-observed symptom for this label — "little lingering particle sprites that show up after an explosion ran" (explicitly NOT the main fireball, which the user separately confirmed was already fixed a while back, presumably the D228/M-115 byte-order fix) — is CONFIRMED RESOLVED by the D219 fix.** Traced the mechanism before asking for a retest, not just guessed: `bullet_spark_create(pos, 1, ..., room)` (`explosion_smokeimages`-typed spark, `glass2.c`) is called from `objHit` (`propobj.c:9633`) on any bullet/explosion-damage impact against a world object, plus `chr.c:3408/3414`, `chraction.c:6865`, `chrprop.c:1200` — i.e. the "lingering embers/residue" particles left behind after something is shot or blown up **are** `s_bullet_spark` instances, rendered through the exact same `bullet_spark_render` function D219's fix corrected. User retested live on Facility (destroying an object/explosion) on the post-D219-fix build: "seems fine now." **This row (the lingering-particle-residue manifestation of D252) closes as a duplicate of D219, not a separate bug.** The *original* M-124 "particle effects cycle through a rainbow palette" report bundled at least two distinct code paths under one label (see the M-157/M-158 history above, which already started separating them) — the surviving open question, if any, is only the **main explosion fireball's own palette** (the `explosion.c`/`import_texture_rgba16`/`G_CC_INTERFERENCE` combiner investigation above), which the user says was already fixed separately and has not been re-reported since. Re-open only if a *fireball* (not residue/embers) rainbow report comes in again.
|
||
|
||
**M-200 (2026-09-25, v0.4.0): the M-191 "rainbow confetti" is identified as the explosion *debris* particles; four headless explosion repros are all clean; that code path is statically clean for colour; still OPEN, with a working repro harness.** (M-191's own text was filed under `## D303` by mistake. Read it there.)
|
||
|
||
- **What the "confetti" is.** The only confetti-like explosion element is the shrapnel/debris quads: `explosionInitFlyingParticles` spawns them (`explosion.c:1541`, called from `explosionCreate` with `shrapnel_*` sizes), they tumble with random rotation, fall under gravity, and are drawn by `explosionRenderFlyingParticles` (`explosion.c:1722`) via `globalDL_0xa50`. Captured frames show exactly that: dozens of small tumbling flecks over the fireball.
|
||
- **Statically clean for colour.** Vertex colour is always grey (`cn[0]=cn[1]=cn[2]`, alpha 0xDC). The texture is `IMAGE_STATIC_NOISE` (IA8 32x32, loaded as IA16 LoadBlock; the texture-cache key includes the render tile's fmt/siz, so it always decodes as greyscale). The combiner is `G_CC_MODULATEIA` 1-cycle. The only way to get hue on this path is a coloured SHADE, i.e. `G_LIGHTING` on at `gSPVertex`, which would make `cn[]` read as a normal and give a colour that changes as the quad rotates. That would fit "rainbow confetti", but the preceding `glassRenderShards` always clears `G_LIGHTING|G_TEXTURE_GEN` and nothing else in the `lv.c` sequence sets it. `struct FlyingParticles` has no pointer fields; the buffer uses `sizeof`; the `(struct FlyingParticles *)(u32)g_FlyingParticlesBuffer` cast (`:1737`) is safe because DRAM is mapped below 4 GiB (`dram.c`). No A1 raw-offset reads exist in `explosion.c`, `glass.c`, `cleanexplosions.c` or `initexplosioncasing.c` (the D219 site in `glass2.c` is fixed in full). Smoke (`g_SmokeTypes[]` rgb), scorch (grey `cn`), and explosion parts (white default vertex, 15 fire/smoke records) are the same: no per-vertex colour source.
|
||
- **Measured.** `GE_D157=1` on the Bunker 1 forced-explosion repro: every debris vertex (`cn` alpha 220) has `shaded == cn` and grey, with `LIGHTING=off`. The only `LIGHTING=ON` draws are `geom=0x60205` (texgen: viewmodel/glass), not particles.
|
||
- **Repro matrix, all clean** (hue scan: saturated cyan/blue/magenta pixels outside the HUD strip; plus contact sheets checked by eye): (1) Bunker 1, forced `EXPLOSION_DEF_STANDARD` at 500 u; (2) Jungle, 4 forced explosions; (3) Jungle, **the user's original trigger**: 2x grenade launcher cheat + invincibility, 12 grenades fired (`scratch/d252gl_sheet.png`); (4) the same at the user's video settings (1280x720 windowed, MSAA 8, aniso 16, filter 2, FOV 150, native widescreen). Debris is grey/white in every frame, and the fireball and smoke palettes are correct. This is the sixth-plus clean dev-box attempt for this family (cf. M-158's 0/6), so the trigger is environmental or state-dependent and not a fixed mechanism in this path.
|
||
- **Ruled-out side lead (worth keeping):** the GL texture cache (`gfx_texture_cache_lookup`) has no content hash for non-CI keys and is cleared only at stage load (D235 M-193). Particle textures load through the main texpool, which is bump-only inside a level (`texLoad`, `image.c:2510`: no eviction; on pool-full it returns `pool->start`). So they can't hit a stale address. Per-weapon pools that reset on weapon switch remain a theoretical stale-hit site (unrelated to explosions).
|
||
- **Harness (TEMP, in the existing `GE_D320R`, `port/src/d320repro.c`):** new actions `explode=<type>,<dist>` (calls `explosionCreate` `<dist>` units ahead of Bond) and `cheat=gl|rl|inv|<id>` (the game's own `cheatButtonHandleCheatsTurnedOn`). Real-trigger repro: `GE_D320R="850:cheat=gl;850:cheat=inv" GE_INPUTSCRIPT="950:A;1060:Z;1100:Z;…" GE_PCDUMP="1000-2500:2" ./build-pc/ge007.x86_64.exe -level_37` (A once equips the dual GLs; a second A goes to mines). Scan script: `scratch/d252hue.py`. Do **not** combine with `GE_D157` on Jungle: the per-vertex log drops rendering to ~1 frame/1.5 s.
|
||
- **Next (needs the user):** when it is seen live, capture it. Take a screenshot, note the level and what exploded, and ideally run with `GE_D157=1` on an indoor level (logging is too heavy outdoors). The probe logs `LIGHTING`/`shaded` for the debris vertices; a `LIGHTING=ON` line or non-grey `shaded` for an alpha-220 vertex confirms the lighting-leak branch and names the preceding state. If shaded is grey while the screen shows colour, the fault is GL-side (texture binding/sampling), not the vertex stage.
|
||
|
||
**Status: OPEN (PARTIAL) — unchanged; not reproducible headless across 4 configurations including the user's own trigger; debris-particle path statically clean for colour.**
|
||
|
||
**M-201 (2026-09-26, v0.4.0): ROOT CAUSE FOUND + FIXED — port-side, `port/src/gimgfixup.c` (the D68 shim). Explosion display lists kept the *first stage's* (title screen's) texture pointers for the rest of the session.**
|
||
|
||
- **User trigger that cracked it:** Facility explosions are fine at first and go rainbow for *every* weapon once the player reaches the middle (by the lockers), and stay broken. So it is state-dependent, not per-explosion-type (the tank = `EXPLOSION_MEDIUM` vs grenade `EXPLOSION_DEF_STANDARD` split was a red herring).
|
||
- **Refuted on the way:** stage texpool exhaustion. `GE_D252POOL` (TEMP, `image.c` texLoad) logged 0 pool-full events; Facility's 650 KB pool is 85% full at spawn and still had ≥61 KB free at the lockers.
|
||
- **Evidence:** the F12 burst capture (`GE_D157`) showed the debris/smoke/fire textures being read from `0x700ae2e8…0x700bb5d8`, while Facility's texpool was `[0x70102ae0..)`. `0x700ae2e8` is exactly the *title stage's* texpool start + the 8-byte texnum header. The debris quad state itself was clean (grey `cn`, LIGHTING off, MODULATEIA, IA8), so the colour came from the texture bytes.
|
||
- **Mechanism:** `texReset()` (`image_bank.c`, called from `lv.c` on **every** stage load, after `set_mt_tex_alloc()`) re-copies the global image table from ROM and `texLoadFromDisplayList()` resolves the `globalDL_0x078…0xa50` IMAGESEG words into the new stage's pool. On N64 those ROM-copy DLs are what executes. On PC `explosion.c` executes the *compiled* shadows (`g_ExplosionDisplayLists[]`), and `gimgSyncCompiledGlobalDLs()` copied resolved words into them **only for slots still holding the `0xABCDxxxx` marker**, i.e. only on the first call (the title stage). Every later stage kept pointers into the title's freed pool region. That memory is reused by the new stage's own allocations, so explosions look right until the stage overwrites it (Facility: rooms streamed in around the lockers), then every smoke/fire/debris texture samples garbage.
|
||
- **Why ~10 headless repros were clean:** `-level_XX` boots straight into the level (its own first stage, so its pointers are correct), and even a harnessed `level → title → level` reload lands both pools at the same address with the same load order, so stale pointers hit identical bytes. Only a real boot → title → level sequence moves the pool.
|
||
- **Fix:** learn which compiled `G_SETTIMG` slots are IMAGESEG references on the first sync (marker still present), store them in `s_imgslot[][]`, and re-copy those slots on every call. Semantics now match N64 (fresh pointers per stage). Port-only; no game code touched.
|
||
- **Verified:** user playtest, boot → title → Facility → past the lockers, grenades/mines/tanks all correct ("yeah that fixed it"). `D252SYNC` (TEMP, `GE_D252POOL`) logged `32 texture slots re-synced, 32 changed` at the Facility load, i.e. all 32 were stale before the fix. Golden gate 3/3.
|
||
- **Scope:** covers every D252-family report that goes through these 17 global DLs (explosion parts, smoke, debris; `globalDL_0x000` too). The M-124/M-157 bullet-impact spark sightings are likely the same stale-pointer effect but were not separately re-tested.
|
||
- **TEMP probes to remove when the release settles:** `GE_D252POOL` (`image.c`, `initmttex.c`, `D252SYNC` in `gimgfixup.c`); `GE_D157` F12 burst (`gfx_pc.cpp`, `video.c`); `GE_D320R explode=/cheat=`.
|
||
|
||
**Status: FIXED (M-201, user-verified 2026-09-26).**
|
||
|
||
## D255 — Facility firefight SIGSEGV on Steam Deck (v0.2.0-pre CI bundle; D253 fix confirmed working).
|
||
|
||
**Facility firefight SIGSEGV on Steam Deck (v0.2.0-pre CI bundle, D253 fix confirmed working — user got past the opening crouch tile).** Crash during gunplay; abort-unwind backtrace points at `sub_GAME_7F09BAC4` (`vtxstore.c:158`) ← `sub_GAME_7F09BBBC` (`:220`) ← `lvlManageMpGame` (`lv.c:1302`) ← `bossMainloop` (`boss.c:567`). Disassembling the exact CI binary at the fault PC (`0x200db135`) gives `mov 0x14(%rax),%edi; mov 0x10(%rdi),%rax`: the function walks the active PropRecord list keeping `type == 1` — which is **PROP_TYPE_OBJ, not CHR** (the decompiler cast to the union's first member and misnamed the field `ChrRecord.chrflags`; it is actually `ObjectRecord.model` at `0x14`) — then dereferences `model->obj`. Two port-relevant wrinkles: (a) the load is **32-bit zero-extended** because the decomp types the field through the `CHRFLAG` enum, so a model pointer ≥ 4 GB would truncate to garbage; (b) the loop skips records whose `model->obj` is NULL but *dereferences* `model` unconditionally, so an active object with a NULL/freed `model` faults — plausible in a firefight where destructibles (crates etc.) die. Not reproduced on WSL after equivalent play. **Register capture (D254 format, 2026-09-13):** two more Deck crashes — Bunker mid-firefight, then Frigate with ~3 NPCs alive; log: `SIGNAL: 11 / FAULT ADDR: 0x10 / Rax=0x701e5f54 Rdi=(nil) / PC 0x200db135`. `Rdi` = the record's model value = **exactly NULL** → case (b) confirmed: an active PROP_TYPE_OBJ record with a NULL `model`, dereferenced by the unconditional `mov 0x10(%rdi),%rax`; truncation (a) and stale/garbage pointers are ruled out. Faulting PC matches the recorded disassembly (`sub_GAME_7F09BAC4` = `vtxstore_fix_refs`). Grep-verified: nothing in game code ever writes NULL into `ObjectRecord.model` — `clear_model_obj()` nulls `model->obj` only, and `objInit`/spawn paths only activate a record once model ≠ NULL — so the NULL comes from either a fresh/uninitialized record slot being activated or a destroy-path ordering race (model freed/nulled while the record is still active this tick). **Fix constraint:** any guard lands in game code (`vtxstore.c`) → needs rule-#2 sign-off; `port/` cannot intercept this dereference. Next: (1) decision on whether a NULL-model skip in `vtxstore_fix_refs` qualifies as an ABI-class exception (semantics-preserving defensive guard, PD analogue check); (2) if yes, add it plus a `#ifdef PORT` trace at the deactivation/model-free sites to name the path that leaves the record model-less. **Corrected, deterministic repro (v0.2.1 user testing, 2026-09-15, superseding the initial report): the trigger is Facility's computer terminals — the ones you ACTIVATE (interact with, not shoot/destroy) to open a door for level progression.** User confirms this is consistent/reliably reproducible on those specific terminals, on Steam Deck **and** Linux generally (not Deck-hardware-specific). This is a much stronger lead than the original "sometimes during firefights" — a terminal activation deactivating/freeing its associated object model while the record briefly stays active this tick is fully consistent with the destroy-path-ordering theory above, just via a scripted trigger instead of combat destruction, and gives a deterministic repro instead of a probabilistic one. **Next session should reproduce via terminal activation specifically**, not generic combat — much cheaper to instrument. **M-140: added a diagnostic probe (`GE_D255`, off by default, `#ifdef PORT`) — `vtxstore.c`'s `sub_GAME_7F09BAC4` now skips-and-logs instead of dereferencing when it finds a NULL model on an active OBJ record (identity + state fields), and `propobj.c`'s `objFree` logs every free call for correlation. Attempted a live Windows repro this session (5 terminal activations across 3 Facility launches, one direct `-level_34` boot each time): zero probe hits, zero crashes — Windows does not reproduce this, consistent with the existing WSL-negative result. Two negatives (WSL, native Windows) against one positive (Linux/Steam Deck) points at something genuinely platform-specific (allocator/timing), not a Windows-reachable path — don't spend further Windows session time chasing a live repro.** The probes are harmless (env-gated, zero behavior change when unset) and committed so a Linux/Deck session can set `GE_D255=1` and get the trace directly. **User report, 2026-09-15 (same day, later, real Steam Deck hardware on the published v0.2.1 release — unverified by this session, logging as reported, not re-asserting the activation theory as wrong):** Facility crashes **consistently on terminals being destroyed** (shot), not (only) on activation. Frigate crashed once during this same testing pass, specifics not recorded. Shooting terminals elsewhere/other attempts did not reliably reproduce. This is closer to the *original* pre-M-139 framing (combat/destructibles triggering the NULL-model race) than the M-139 "corrected, activation-only" theory — both are plausible triggers of the same underlying destroy-path-ordering race (any code path that frees/nulls an active OBJ record's model mid-tick), not necessarily contradictory. **Not yet reconciled or verified by a session with hands on the repo** — next Linux/Deck session should test both triggers (activation AND destruction-by-gunfire) with `GE_D255=1` set before revising the "reliable repro" framing again. **Reconciled, same day, further real-Deck testing (v0.2.1 published release): destruction is the trigger, not activation — confirmed across four levels.** Facility: **every** terminal crashes consistently when destroyed (not merely activated). Caverns: terminals near the radio objective crash when they explode — same mechanism, different level, rules out a Facility-specific asset/state bug. Archives: a **similar** crash when a guard's grenade detonates — same destroy-via-explosion mechanism, no terminal involved at all, which narrows the real trigger to "an object dying via explosion damage" in general, not anything terminal-specific. **Negative control:** shooting an entire room of terminals on Frigate did not reproduce it — so it isn't simply "any destructible OBJ record exploding" either; something about the specific object/scene state still gates it. Release notes updated to match (`.github/release-notes.md`, D255 bullet: "consistently crash the game when destroyed"). **Static lead for next session (untested, from this session's code read):** `objFree`'s destroy path (`propobj.c:1017-1039`, entered via `RUNTIMEBITFLAG_REMOVE` from explosion damage, `sub_GAME_7F04424C`/`sub_GAME_7F0442DC`) never explicitly nulls `ObjectRecord.model` — `clear_model_obj()` only nulls `model->obj`. The NULL therefore can't come from that assignment; it must come from either (a) `chrpropFree`'s freed slot being reused by `chrpropAllocate` for a *different*, not-yet-`objInit`'d record before `vtxstore_fix_refs`'s later same-frame pass reaches it (a stale-pointer/reuse race, not a field write), or (b) the active-list unlink (`chrpropDelist`) happening while some other code is mid-iteration over the same list (a modify-during-iterate bug) — explosion damage plausibly spawns new effect props (sparks/debris/smoke) in the same tick that immediately reuse the just-freed slot, which would explain why it's tied to *explosions specifically* and not simple destruction. **Next, concrete:** with `GE_D255=1` set (probes already in tree from this session, ship in any build off `release/v0.3.0` including the local Linux package built this session), reproduce on Facility from the Deck itself (Desktop Mode, launch from a terminal, redirect stdout to a file) and send the log back — the probe already logs the dying record's identity plus every `objFree` call, which should show whether the crashing record's `prop`/`obj` pointer address was recently freed-and-reused this same session.
|
||
|
||
**ROOT-CAUSED AND FIXED, same day, live Deck session over SSH (M-140).** The `objFree`/destroy-path-race theory above was a red herring — set up a real Deck test (SSH access, key-based auth, WSL-cross-compiled local Linux build deployed and run live against the user's own prepared ROM assets), reproduced the crash directly, and symbolized it on-device against the build's own debug info. **First capture with the `GE_D255` probe active:** the probe's `ObjectRecord.model == NULL` guard did NOT fire, yet the game crashed anyway at `vtxstore.c:199` (`var_v1->RootNode`), `FAULT ADDR: 0x10`, `Rdi=(nil)` — a *different* pointer than the one the probe was checking. **Actual bug:** `sub_GAME_7F09BAC4` (`vtxstore_fix_refs`) reads the record's `Model*` through `((Model*)((ChrRecord*)prop->chr)->chrflags)` — a union pun onto `ChrRecord.chrflags`, which is typed `CHRFLAG`, a **4-byte enum**. On 32-bit N64 this is bit-exact; on 64-bit PC the cast reads just the pointer's low 32 bits and zero-extends it, discarding the real high bits — the exact D3x pointer-width ABI class. `(nil)` is exactly what that truncation produces whenever the real pointer's low 32 bits happen to be zero, which is what pointed two prior sessions at the wrong (NULL-model/destroy-race) theory. **Proof it was the field, not the object:** reading the same record through the correctly-typed `ObjectRecord.model` field showed it was **always a valid, non-NULL pointer**. **Fix (ABI-class, semantics-preserving, `#ifdef PORT`; `src/game/vtxstore.c`):** read the pointer through `ObjectRecord.model` instead of the mistyped `ChrRecord.chrflags` alias, at both use sites. The earlier `GE_D255` diagnostic guard (which checked the wrong field) is removed as unneeded. **Verified live:** held through the entire Facility level (multiple terminal destructions) and Caverns' radio-objective terminals with zero crashes, on the user's own Steam Deck hardware; Windows rebuild + full 21-level `verify.sh sweep` also clean. The separate audio-thread crash (D285) is untouched by this fix.
|
||
|
||
**Status (from §F table):** FIXED (M-140) — root-caused as a D3x-class 32-bit pointer truncation (not a destroy-path race) and verified live on real Steam Deck hardware across both confirmed-repro levels (Facility, Caverns).
|
||
|
||
## D152+ — D152 addendum — static audit + narrow fix (M-31).
|
||
|
||
**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.
|
||
|
||
**Status (from §F table):** FIXED (`src/snd.c`, `port/src/libultra.c`, `#ifdef PORT`). porting-notes.md §D4.
|
||
|
||
> Consolidated 2026-09-15 (ctx-opt Bucket B): two §F rows carried this label — an earlier condensed pass and this full writeup. The condensed pass's content (audit verdict, the three fixes, steal-lock backstop) is fully covered above; its status line "fade-out repro playtest-gated, not headless-verified" survives in the body. Nothing deleted — earlier text is in git history and `notes/snapshot-pre-ctx-opt/`.
|
||
|
||
## D233 — All levels (intermittent): indoor level geometry invisible near doors — you can see through walls/floors into rooms you can't see into, while world objects…
|
||
|
||
**All levels (intermittent): indoor level geometry invisible near doors — you can see through walls/floors into rooms you can't see into, while world objects (terminals, furniture) in that space still draw (M-106, user QA report; screenshots `docs/img/bugs/20260912-cullingbug-bugged.png` vs `-notbugged.png`).** Root-caused M-107 (session-016TaKy6, port/fast3d only): **fast3d's CPU-side "trivial reject" AND-test in `gfx_sp_tri1` (`port/fast3d/gfx_pc.cpp:1455-1460`) has a latent w-sign bug independent of D222/D106.** The per-vertex outcode bits computed in `gfx_sp_vertex` (`x<-w`/`x>w`/`y<-w`/`y>w`/`z>w`, `gfx_pc.cpp:1372-1386`) are only valid half-space tests when `w>0`; for a vertex that has crossed behind the camera plane (`w<0` — common when the camera is close to large room-structure polygons, e.g. walls/ceiling trusses near a doorway) the comparison sense is flipped, so its outcode can come out wrong. If that spurious bit happens to match the other two (genuinely correct) vertices' bits, `v1->clip_rej & v2->clip_rej & v3->clip_rej` fires and the whole triangle is dropped before it ever reaches OpenGL — even though GL's own homogeneous clipper (which *does* handle `w<0` correctly) would have rendered the visible part of it. Confirmed against the two screenshots: same room/camera/ammo-HUD, one frame is missing the room's ceiling trusses/walls (replaced by flat sky) while the console/terminal props in the same room still draw fully — exactly the "big background polys vanish, small prop models don't" split you'd expect, since large near-camera polygons are far more likely to have a vertex cross behind the eye than small self-contained prop meshes. The backface-cull code 6 lines below (`gfx_pc.cpp:1462-1495`) already special-cases this exact `w<0` hazard for its own cross-product sign (`(v1->w<0)^(v2->w<0)^(v3->w<0)`) — the trivial-reject block above it never got the same treatment. **Independent of D222** (D222 is a game-logic frustum-cull bug in `src/game/fr.c`/`bondview2.c` that only manifests at non-default `Video.FovScale`; this is a GPU-pipeline clip-rejection bug in `port/fast3d/` that fires at any FovScale, including the default 100, whenever the camera gets close to a large polygon — matching the report's "all levels, near doors" framing with no FovScale mentioned). Also independent of D106 (D106 fixed the *game-side* portal-visibility bbox computation for a different z==0 edge case in `bg.c`; this is the *renderer-side* per-triangle clip decision, a different stage of the pipeline entirely) — same neighbourhood/symptom vocabulary ("things vanish near a portal-adjacent boundary"), different bug.
|
||
|
||
**Status (from §F table):** **FIXED (port/fast3d/gfx_pc.cpp, `gfx_sp_tri1`) — high confidence on mechanism, UNVERIFIED visually.** Fix: skip the trivial-reject AND-test entirely whenever any of the triangle's 3 vertices has `w<0`, deferring to GL's clipper instead (mirrors the cull code's existing w-sign handling one block below). Change only ever *adds* triangles that were previously dropped — cannot make anything newly invisible, so it's a safe, narrowly-targeted fix with no game-logic touch (fast3d is PC-only code, no N64 build parity to preserve, no `#ifdef PORT` needed). **Build verified green** (`./build-pc.sh` equivalent — MSYS2 mingw64 cmake+ninja, full `ge007.x86_64.exe` link succeeds, only one pre-existing unrelated `-Wformat` warning in the same file). **VISUALLY VERIFIED (M-108):** user played `-level_26` (Frigate — corrected level id, the old `-level_02` note above predates the `bondconstants.h` numbering check) and confirmed the doorway/ship-corridor geometry no longer flickers or vanishes near room boundaries. Closed.
|
||
|
||
> Earlier triage pass, merged 2026-09-15 (ctx-opt Bucket B): "The geometry-vs-object split localises it to room/portal background visibility or frustum culling in fast3d, not textures/models. Logged in `GRAPHICS-BACKLOG.md`." — status then: FIXED + VISUALLY VERIFIED (M-107 fix, M-108 verify).
|
||
|
||
## D234 — Dam: guard towers along the dam structure invisible except the first one (M-106, user QA report).
|
||
|
||
**Dam: guard towers along the dam structure invisible except the first one (M-106, user QA report).** Investigated M-107 (session-016TaKy6, port/fast3d scope): **NOT root-caused — no fast3d-side mechanism found that plausibly explains "only the first of N repeated instances renders."** Ruled out: fast3d has no matrix/model instance cache that could explain a first-instance-only bug (the D217-family "keyed by address, not content" cache-bug class doesn't recur here — the *only* content-addressed cache in fast3d is the texture cache, already excluded from this investigation per the M-106 triage note); the modelview matrix stack (`gfx_pc.cpp:105`, depth 11, push properly gated `< 11` at `gfx_pc.cpp:1220`) is correctly bounded and would affect every level's nested-DL geometry, not just Dam's towers specifically; the D233 w<0 clip-rejection bug (above) doesn't fit either — towers are viewed from a distance along the dam, not close enough for a camera-behind-vertex condition, and D233's mechanism is per-triangle/per-frame flicker, not a stable "always the same instances missing" pattern. **Leading hypothesis, NOT verified:** GE's road/structure-instancing for repeated architecture typically goes through the room-graph's bytecode "vis program" (`VISOP_*` opcodes interpreted in `bg.c` ~L4450-4620, e.g. `VISOP_PUSH_IF_ROOM_IN_RANGE`, `VISOP_ADD_VISIBLE_ROOM`) or through per-instance room/portal definitions in the level's compiled BG data — both are level *data*, not code reachable via static analysis without the ROM (this worktree has none extracted). Could equally be a per-instance object/prop transform or cull-volume bug in `src/game/propobj.c`/`chrprop.c` (out of this task's `port/fast3d/` scope, and out of the `src/game/*` model/anim exclusion in the brief regardless). **No fix attempted — do not have enough evidence to touch code.**
|
||
|
||
**Status (from §F table):** **VISUALLY VERIFIED FIXED (M-108) — mechanism still unexplained.** User confirmed on a live Dam playthrough (post-D233 fix, correct `-level_26`/Frigate session's build) that all guard towers along the dam now render, not just the first. No code change was made specifically for D234, and the M-107 investigation's own reasoning argued D233's mechanism doesn't fit this symptom (towers viewed from a distance, not a close-camera w<0 case) — so either that reasoning was wrong, D233's fix had a broader effect than analyzed, or the original symptom was intermittent/state-dependent rather than deterministic. Not closed with high confidence in *why* it's fixed, just closed on live confirmation that it *is*. If it resurfaces, re-open with a fresh `GE_PCDUMP` capture rather than assuming this write-up's ruled-out list still holds.
|
||
|
||
> Earlier triage pass, merged 2026-09-15 (ctx-opt Bucket B): "Repeated instances → per-instance transform decode, shared model/texture resource bound only by the first instance, or cull-volume issue. Same neighbourhood as D233. Logged in `GRAPHICS-BACKLOG.md`." — status then: VISUALLY VERIFIED FIXED (M-108), mechanism unexplained.
|
||
|
||
## D120 — Blood-decal `PointUsage[]` chain — converter now emits it (M-30).
|
||
|
||
**Blood-decal `PointUsage[]` chain — converter now emits it (M-30).** `d43_emit.py` reserved the opcode-0x18 `PointUsage` region in the layout pass (`add_region(puo, 2*nv)`) but the **emit pass never wrote it** — every entry stayed zero. `chr.c`'s decal walk `index = PointUsage[index]` then cycled 0→PointUsage[0]→0 forever (capped by the `#ifdef PORT` guard at `chr.c:3328`, so decals were missing/wrong rather than a true hang). Fix: `op24_pointusage[puo] = nv` in layout + a byteswap loop (`put_s16(remap(puo)+2k, be16(src, puo+2k))` for `nv` = `numVertices` entries — a negative-terminated `s16` index chain, no remap) in the emit pass. Full regen chain re-run, round-trip validation passes. The `chr.c` guard stays as a safety net (SMALL-FIXES B3).
|
||
|
||
**Status (from §F table):** FIXED (`tools_pc/d43_emit.py`). Interactive verify pending — blood decals on a BUNKER1 firefight should now appear + no hang.
|
||
|
||
## D228 — FIXED: `palette_to_rgba32` G_TT_IA16 byte order (fix write-up from the §F row, appended 2026-09-15 ctx-opt)
|
||
|
||
**FIXED (`port/fast3d/gfx_pc.cpp`, `palette_to_rgba32`) — a real byte-order bug, not a texpool/decode-never-ran issue as originally guessed.** `GE_TEXDUMP` on Facility (`-level_34`) found ~14% of LOD-textured uploads (fmt=CI, `palette_fmt=G_TT_IA16`) rendering solid near-white; a `GE_D228` correlation probe ruled out the texpool/RC2-clip hypothesis (the decoded size exactly matched the tile's base image every time — the data reaches `import_texture` fine, sized correctly). The actual bug: `palette_to_rgba32`'s `G_TT_IA16` branch read `intensity = palentry & 0xff` (low byte) and `alpha = palentry >> 8` (high byte) — backwards relative to the already-correct direct-IA16 decode two functions above it in the same file (`import_texture_ia16`: `intensity = addr[2*i]` (first/high byte after `gfx_dp_load_tlut`'s `PD_BE16` swap), `alpha = addr[2*i+1]`). A dim, fully-opaque palette entry (e.g. intensity=0x58, alpha=0xff) decoded backwards as bright and mostly-transparent (intensity=0xff, alpha=0x58) — exactly the white-patch symptom, on any CI-format material whose TLUT happens to be IA16 rather than RGBA16 (weapon metal/grip materials commonly are, since IA is a natural fit for grayscale metal with a separate opacity channel). Fix: swap the two lines to match the working convention. **User-verified live:** AK47 and other metallic guns render correctly (no more white patches). Verified crash-free with a `-level_34` headless run before the live check; a full 21-level sweep is still owed (not run this session — the user was using their own game instance concurrently, `verify.sh`/`taskkill` would have killed it). D217 · D161 lineage — same general "CI/LUT format-assumption" bug class, distinct specific bug.
|
||
|
||
## D295 — Campaign-halting crash at the end of Control Center on 00 Agent difficulty, then a crash on every subsequent relaunch/resume (GitHub #87, user campaign playtest).
|
||
|
||
**Campaign-halting crash at the end of Control Center on 00 Agent difficulty, then a crash on every subsequent relaunch/resume (GitHub #87, user campaign playtest).** Reported directly as [GitHub issue #87](https://github.com/jkdansereau/goldeneye-pc-port/issues/87) (not yet actioned there — this repo is under a push freeze to `main`, per M-139b; issue itself was filed by the user independently and needs no push). User played the campaign on 00 Agent difficulty through the end of Control Center; at that point the game crashed, and it now **crashes on every subsequent launch/resume attempt** — i.e. this is not a one-off, it reproduces deterministically from the user's current save/session state, unlike D289 (Dam, non-reproducible) or D286 (Caverns freeze, self-recovering). That "crashes every time now" detail is the most actionable lead: whatever corrupted is either the EEPROM save file (`data/ge007.eep` — see the D255/D3x pointer-truncation class, and the D284 PRNG-state class) or some other persisted/level-transition state that Control Center's completion writes, and it's now poisoning every subsequent boot into that save slot, not just the original crash frame.
|
||
|
||
**Also reported, same playtest, possibly related:** progressively worsening audio corruption starting around Streets and continuing through to the Control Center crash — user's own framing is "Idk if that contributed to it somehow." Streets → Depot → Control Center is roughly the mission order in this stretch; if audio state is shared/leaked across level transitions (cf. D204 tempo drift, D202 stuck door loop, D285 audio-thread SIGSEGV — all real, distinct bugs already found in the audio-thread class this session/prior sessions), a slow audio-side corruption building up over several levels and finally taking down Control Center is plausible, but **nothing here confirms causation** — could equally be an unrelated coincidence of timing.
|
||
|
||
**Status (from §F table):** OPEN — not investigated. No crash log, `bt full`, or repro steps captured yet. **Next steps, in order:** (1) get the user's `ge007.crash.log` (or a `gdb bt full` under `debug.ps1`) from the actual crash, plus their `data/ge007.eep` if they're willing to share it — a corrupted-save reproduction would be the fastest path to a fix and would explain the "crashes on every relaunch" persistence; (2) if the save file also crashes cleanly on a fresh debug build, that confirms save-state corruption over a fresh code bug; (3) chase the audio-degradation report only if it turns out to correlate with the crash (e.g. an audio-thread crash address) — don't spend time on it as a separate lead until then.
|
||
|
||
**M-146 (2026-09-16, same day): artifacts obtained + platform + version pinpointed; likely already fixed by v0.2.2, unconfirmed.** User supplied `ge007.crash.log`, `audiotrace.log`, and `data/ge007.eep` (via the GitHub issue + a G-Drive link for the save).
|
||
|
||
- **Platform confirmed Windows, from the crash log format itself, not inference.** `port/src/crash.c` emits totally different output per platform (`#if PLATFORM_WINDOWS` vs `#elif PLATFORM_LINUX`). The user's log opens `EXCEPTION: 0xc0000005` (an NT `STATUS_ACCESS_VIOLATION` code) and includes `MxCsr=... x87CW=... x87SW=...` (the x86 `CONTEXT` FPU state) — both Windows-only; the Linux path prints `SIGNAL: 11` and never emits FPU control-word fields. `FAULT ADDR: 0000000000000000` — a genuine null-pointer dereference, PC `0x1400797c1` (module base `0x140000000`, RVA `0x797c1`, unsymbolicated — no matching build/PDB available to resolve it further).
|
||
- **Version pinpointed by ancestry, not the crash log (no version string is captured in `ge007.crash.log` today — worth adding one).** Issue #87 was filed 2026-09-16 01:13 UTC; `v0.2.2` was tagged 2026-09-16 13:13 UTC, ~12h *later* — v0.2.2 did not exist yet, so the user was on `v0.2.1` (the latest public release at report time). `git merge-base --is-ancestor` confirms both `1ba83ba4` (D255 fix) and `1970ab37` (D285 fix) are **absent from `v0.2.1`, present in `v0.2.2`**.
|
||
- **D255 is the leading candidate**, on shape + level content: D255 was a 32-bit-pointer-truncation bug (`Model*` read through a mistyped 4-byte `chrflags` alias) that on PC either dereferences garbage or, when the truncated low bits land on zero, crashes exactly like a real NULL — matching this log's `FAULT ADDR: 0`. Control Center's own briefing (`assets/obseg/brief/UbriefcontrolZ.c`) lists **"Destroy armored mainframes"** as an objective — an explosion-triggered object-destruction event, the same `vtxstore_fix_refs` code path D255 patched. Not proven (no symbols to confirm the crash PC actually lands in `vtxstore_fix_refs`), but a strong circumstantial fit.
|
||
- **D285 fits the reported audio-corruption symptom** (an unlocked shared-list read racing a locked writer in `sndHandleEvent`) but less specifically fits the null-fault shape.
|
||
- **New, separate, still-OPEN issue found while reading `audiotrace.log`:** it is nothing but a wall of `[EVTQ-DROP]` lines (1089 total) — `src/libultra/audio/event.c:92`'s D202-era diagnostic, which logs unconditionally (no env gate) whenever the audio event free-list (`evtq->freeList`) is exhausted. This fires continuously in the user's log, i.e. **real evidence the event pool was genuinely, sustainedly exhausted during their session** — distinct from D285 (a race, not a leak) and not fixed by either v0.2.2 patch. The probe itself is also a minor release-hygiene bug (D202 cleanup was supposed to remove it — see the D202 M-2x closeout note — and never did; it's still live on `release/v0.3.0`/HEAD today, silently writing an unbounded log file in shipped builds).
|
||
- **Repro attempt on current `release/v0.3.0` HEAD (already carries the v0.2.2 fixes) did NOT reproduce the crash**, but this is a partial result, not a clean disproof:
|
||
- Front-end/mission-select/file-select screens with the user's real `ge007.eep` loaded (`GE_STARTMENU=5/7`, `tools_pc/debug.ps1`): no crash, save data reads fine.
|
||
- **User live-played Control Center start-to-finish at the correct difficulty** (`-level_23 -hard2` — `src/boss.c:396-401`'s native N64 debug switch for `DIFFICULTY_00`, not a bare `-level_23` which silently defaults to Agent and would NOT have been a faithful repro) with the real save loaded, under `tools_pc/debug.ps1` (gdb-attached). Completed the level (with cheats enabled partway through) — **no crash**, consistent with "v0.2.2 already fixed it" but not a controlled A/B, since the branch tested already has both fixes applied. User separately reported drone-gun stutter during this playthrough — logged as **D296**, with the debug-harness caveat noted there.
|
||
- **Next, concrete: user is pulling the actual `v0.2.1` release artifact and replaying the same save file on it.** If it crashes there and not on current HEAD, that's the controlled confirmation this finding needs to close as FIXED-by-v0.2.2; if it does NOT crash on `v0.2.1` either, D255/D285 are not the cause and this stays open for a fresh investigation (get a build with matching symbols/PDB from whoever can reproduce it, to actually resolve RVA `0x797c1`).
|
||
|
||
**M-147 (same day, 2026-09-16): RETRACTING the D255/D285 hypothesis above — real root cause found via symbols, and it's a different bug family entirely.**
|
||
|
||
- User pulled the actual `v0.2.1` release artifact (`goldeneye-pc-port-0.2.1-win64`, which ships full DWARF debug info) and reproduced with the real save: **crashes deterministically right after the intro, same `EXCEPTION: 0xc0000005` / `FAULT ADDR: 0000000000000000` / same crash PC `0x1400797c1` as the original report** (confirmed by direct comparison — this is the exact same crash, not a new one).
|
||
- `addr2line -e ge007.x86_64.exe -f -C -p 0x1400797c1` resolves cleanly: **`langGetJpnCharPixels` at `src/game/language.c:320`.** A real, symbol-confirmed NULL dereference, not a guess.
|
||
- **Mechanism:** `g_JpnCacheCacheItems` (`language.c:29`) is a global pointer, allocated only inside `langInit()` when `j_text_trigger` is true (`language.c:247-249`, gated on Japanese-language mode). `langGetJpnCharPixels()` (`language.c:303`) has **no NULL check** before indexing it at line 320. It is called unconditionally — **not gated by `j_text_trigger` or the actual selected language** — from `textRenderGlyph`'s string-draw loop (`src/game/textrelated.c:475-499`) for *any* character byte with the high bit set (`*text >= 0x80`). So any rendered string containing a stray high-bit byte null-derefs immediately, on any region build, regardless of language setting. This is the same general shape as the already-documented `D50.5` "Legal-screen UB exposure" nearby in this file (a read that's harmless-by-luck on N64 hardware, fatal on PC) — a good candidate for the same style of minimal `#ifdef PORT` defensive guard, not a game-logic change.
|
||
- **Confirmed save-specific, not a stock v0.2.1 regression:** user re-ran the same v0.2.1 binary with a fresh (no-save) `data/` and it ran fine — the crash needs this specific save's corrupted content.
|
||
- **The wrinkle that keeps this open rather than closing it as a clean v0.2.1-only bug:** `src/game/language.c`, `textrelated.c`, `file2.c`, `file.c`, and `token.c` are **byte-identical** between the `v0.2.1` tag and current `release/v0.3.0` HEAD (`git diff v0.2.1 HEAD` on those five files shows nothing but two unrelated diagnostic-probe additions in `front.c`/`libultra.c`). Yet replaying the exact same save on a HEAD build through a full, real boot (no `GE_STARTMENU` shortcuts, 90 s window, `tools_pc/debug.ps1` gdb-attached) **did not crash.** Since the code path itself is unchanged, the most likely explanation is that the *actual* defect is upstream of `language.c` — almost certainly an out-of-bounds/garbage read while formatting save-derived display text (leading guess: the packed completion-time/percentage fields, `geSaveGetTime`-style bit-unpacking in `port/src/libultra.c`/`src/game/file2.c`, or a folder/mission-select stat string built from `unlocked_cheats_*`/`times[76]`) — and *which* garbage byte lands there is sensitive to the binary's memory layout, which differs between the two builds purely from the ~100+ unrelated intervening commits. **This means the underlying bug is plausibly still live on HEAD, just not triggered by this specific save on this specific build — not something v0.2.2 fixed on purpose.** D255/D285 are unrelated; both are real, correctly-identified, already-fixed bugs, they just aren't this one.
|
||
- **Next steps, in order:** (1) add a temporary `#ifdef PORT` probe at `language.c:319` logging `codepoint`/`i` plus the 8–16 bytes around the source string pointer whenever `g_JpnCacheCacheItems == NULL`, then replay the v0.2.1 repro (or try harder to trigger it on HEAD — a longer soak, or navigating to the specific folder/mission-select row that displays this save's Control Center/Caverns stats, since our 90 s window only proved the *default* landing screen doesn't trigger it) to catch the actual string content live; (2) once the upstream OOB read is identified, the real fix is there, not just a defensive NULL guard in `language.c` (though that guard is also cheap, safe, and worth adding regardless, matching the `D50.5` precedent, since a malformed/third-party-corrupted save should degap gracefully rather than crash even if the deeper cause isn't found yet); (3) do NOT close this finding as "fixed by v0.2.2" — that was this session's own wrong first guess, corrected here.
|
||
|
||
**M-148 (2026-09-16, independent re-verification session): FULLY ROOT-CAUSED end-to-end; verdict OPEN — the bug is live on `release/v0.3.0` HEAD, reproduced there from scratch. The M-147 "upstream OOB/garbage read" guess is retracted: no OOB exists.** Every claim below was re-derived from primary evidence (crash logs, both binaries under gdb, the user's actual `ge007.eep`, compiled-C CRC reproduction) rather than trusted from prior write-ups.
|
||
|
||
- **The user's save is NOT corrupted — it is a valid *pre-D284* save.** `fileValidateSaves` (`file2.c:544`) CRCs each slot over `&save->completion_bitflags .. save+1` (slot bytes [8..96), 88 bytes) via `fileGenerateCRC` (`src/game/crc.c`), which runs `randomGetNextFrom` off a **local constant seed `0x8F809F473108B3C1`** — *not* `g_randomSeed`. Reproduced that function in compiled C from both the pre-fix (`git show 0de99b4d^:port/src/random.c`) and current-HEAD `random.c`: **the pre-D284 (v0.2.x-shipped) PRNG reproduces all five stored slot CRCs bit-exact; the fixed PRNG matches zero.** (Pure-Python model `tools_pc/d295_crc.py` — gdb scripts/outputs in `scratch/d295/` — now matches the compiled C exactly — two transcription bugs found and fixed along the way: the seed is updated *in place* by `randomGetNextFrom(&polynormal)` each step, and `a2 = a2 >> 0x1f` / `a3 = a2 >> 0x14` are plain `dsrl`, not the `*32` helpers.) Consequence: on any post-fix build, first launch with this save wipes all five slots (confirmed live, `GE_SAVELOG=1`: every slot `BAD -> RESET (progress wiped)`), so **the reported crash does not reproduce on HEAD with the unmodified artifact — but that is a side effect of the wipe, not a fix.**
|
||
- **This disproves the D284 commit/entry claim "savegames are unaffected (seed isn't save state)"** — see the correction appended to `## D284`. `fileGenerateCRC`'s stream *is* persisted state: every stored slot CRC is a fingerprint of the PRNG implementation that wrote it. **Release-blocker-class regression for v0.3.0: every existing player's entire campaign progress is silently erased on first launch.** Needs its own D entry + a decision (dual-CRC acceptance / one-time migration / at minimum a loud release note) — flagged here, not unilaterally filed.
|
||
- **The crash mechanism (M-147) is confirmed end-to-end and is the whole story; there is no upstream OOB.** `front.c:2616` declares `char difficultytext[4]`; the file-select folder panel does `strcpy(difficultytext, "00 Agent")` (9 bytes incl. NUL) + `strcat("\n")`. gdb on **both** the v0.2.1 artifact and a HEAD debug build confirms the stack layout: `&textpos.p[0] == &difficultytext + 8`, so the `\n` lands on the y-coordinate's low byte and the NUL one past it; the subsequent `textpos.p[0] = floor(folderpos2d.f[1]) + 0x15` store then clobbers `\n` with y. Observed y values across window geometries: **128 and 143 — both ≥ 0x80**, i.e. the fatal case is the *normal* one here. `textRender` (`textrelated.c`, `-funsigned-char`) then scans a byte ≥ 0x80 → JPN glyph path → `langGetJpnCharPixels` (`language.c:320`) dereferences `g_JpnCacheCacheItems`, which is NULL outside Japanese mode on PC. Note: on N64 **non-JP builds this pointer is likewise unallocated** (`langInit` allocates it only when `j_text_trigger`, i.e. JP) — so the crash potential is decomp-native, not a port artifact; the reason it (apparently) never manifested on N64 is that at native geometry `y = floor(folderpos2d.f[1]) + 0x15` presumably stayed < 0x80 (unverified from here), whereas PC window geometry puts y at 128/143 in the observed cases. The overflow itself is decomp-faithful original behavior. `"007"` (4 bytes) and `"Agent"` (6) fit or stop before offset 8 → safe; only `"00 Agent"`/`"Secret Agent"` as a folder's highest completed difficulty reach the fatal byte. (`missiontext[18]` one line above has the same class: `"Mission Cuba.Control Center\n"` is 29 bytes — flagged for review, not separately instrumented.)
|
||
- **The M-147 "OOB read of save-derived stat text" guess is retracted — verified against source:** `fileGetSaveStageDifficultyTime` (`file2.c:230`) special-cases `DIFFICULTY_007` *before* any bit math — it returns constant `0x3FF` if `fileIs007ModeUnlocked(folder)` (the slot's `flag_007` bit, or all 20 levels completed on 00 Agent) else 0; the high bit region (offsets 600–790 of a 608-bit `times[]`) is **never read**, and the setter (`fileSetDifficultyStageTime`) excludes 007 (`difficulty < DIFFICULTY_007`), so it's never written either. No OOB, no build-layout sensitivity in the game code. The v0.2.1-vs-HEAD difference was entirely the CRC wipe above: v0.2.1 accepted the save (old PRNG) → folder 1's highest = 00 Agent (`flag_007`=0, 00-Agent 16/20 levels → not 007-unlocked) → `"00 Agent"` rendered → crash; HEAD wiped the save → nothing rendered → no crash.
|
||
- **DECISIVE HEAD REPRO (bug is live on v0.3.0):** recomputed the user's five slot CRCs with the *fixed* PRNG (`d295_crc.py`, data-only patch of the artifact — no code probes), placed it in `data/`, ran HEAD unattended 80 s: `GE_SAVELOG` shows all five slots **OK** (progress retained), then `EXCEPTION: 0xc0000005`, `FAULT ADDR: 0x0`, register state byte-identical to the user's v0.2.1 log (`Rax=2 Rcx=0 Rdx=1 Rsi=0xffffffff Rdi=0`); `addr2line` on the HEAD binary → **`langGetJpnCharPixels` at `src/game/language.c:320`**. Repro log saved as `ge007.crash.log.head-repro-M148` in the issue-artifacts folder. `data/ge007.eep` restored to the user's original artifact afterwards (md5 `76e18aa4…`).
|
||
- **M-149 (fix landed + verified):** both signed-off items applied and verified on `release/v0.3.0`: (a) `port/src/main.c` seeds `g_JpnCacheCacheItems`/`g_JpnCharCachePixels` from static port buffers before `osStartThread` (exact `langInit` construction, `#include "game/language.h"`, `_Static_assert` on the pixel-stub size; reviewer-caught bugs fixed pre-apply: the `0xC000` bitfield sentinel was wrong — eliminated in favour of field assignment — and a stale `sizeof(jpnItems)==0x100` assert would have hard-failed the build, dropped); (b) `src/game/front.c` widens `difficultytext[4]`→`[32]`, `missiontext[18]`→`[32]` under `#ifdef PORT` (D8 class; N64 build keeps decomp sizes — the decomp author's own comment at front.c:2549-2550 already flagged this buffer as "likely incorrect"). Verification: clean rebuild; **the exact previously-crashing input** (user's save re-stamped with post-fix CRCs via `tools_pc/d295_crc.py`) now runs 80 s unattended with all five slots OK, no crash log — vs the byte-identical register-state NULL deref before the fix; user's original artifact still boots cleanly (takes the D297 wipe path, no crash); 21/21-level `tools_pc/level_sweep.sh` PASS. `data/ge007.eep` restored to the original artifact (md5 `76e18aa4…`) — **note: every game launch rewrites `data/ge007.eep`, so the sweep clobbered the restoration that preceded it; the restore+hash check was re-run after the sweep and again at review time.**
|
||
- **Verdict: FIXED (M-149)** for the crash itself; the companion save-wipe regression is tracked separately as **D297** (release-blocker, migration shim pending). History — pre-fix, any v0.3.0 player who completed an 00-Agent (or Secret-Agent) level — their save then validates fine on the post-fix PRNG — would crash at file-select on next launch in any window geometry where the folder y ≥ 0x80 (the common case observed here). Fix plan, **signed off by user 2026-09-16** (both items go; review order: crash-fix bundle first, migration shim separately): (a) *port-side defense-in-depth, rule-2-clean:* seed `g_JpnCacheCacheItems`/`g_JpnCharCachePixels` from static port buffers before `mainproc` starts (non-JP mode only) so a stray high-bit byte takes the normal cache-miss path (romCopy from the JP font segment → garbage glyph) instead of NULL-deref — design validated: `langTick` is `j_text_trigger`-guarded, `langInit` non-JP never touches these, no race; (b) *game-side, D8-class layout correction:* enlarge `difficultytext[4]` → `[16]`, `missiontext[18]` → `[32]` under `#ifdef PORT`. Both are small. Scope note for the migration shim: `fileValidateSaves`'s block-0 `smallSave` CRC (file2.c:512-535) needs **no** migration — `SAVEFLAGS_SET(FOLDER3, SAVESLOT1, BOND_CONNERY, FALSE)` = 0x42 and any in-use save has a different header byte (user's: 0x57), so it takes the "customised file" branch and is factory-reset on every load by design (N64-faithful); only the 5 slots carry user data. The separate v0.3.0 save-wipe regression (D284 side effect, above) is a second, independent release-blocker candidate and should be filed/decided on its own.
|
||
|
||
## D296 - Control Center, 00 Agent: noticeable stutter, user's own read is "maybe from the drone guns" (M-146, 2026-09-16, same D295 investigation session).
|
||
|
||
**Reported while live-playing Control Center on 00 Agent (`-level_23 -hard2`) to investigate D295** (that crash did not reproduce; this is a separate, secondary observation from the same playthrough). User: "I am getting pretty good stuttering maybe from the drone guns on 00 agent on that level."
|
||
|
||
**Caveat that must be resolved before trusting this as a real perf bug — flagged proactively by the user, not yet checked by us:** this playthrough ran under `tools_pc/debug.ps1`, i.e. **gdb-attached**, not the packaged release artifact. The user has previously seen debug-harness overhead produce stutter that doesn't exist in a genuine release build. **Not yet investigated** whether:
|
||
- (a) the stutter reproduces on a plain `build-pc/ge007.x86_64.exe` launch (no gdb attached, no `-NoBuild`/dev-tree quirks) — this is the first thing to rule out, cheap (~2 min repro attempt), before any perf tracing;
|
||
- (b) it reproduces on the actual packaged/zipped release artifact a real user would download (further rules out any local-tree-only difference, e.g. debug symbols, unstripped binary, dev `data/` sidecar state);
|
||
- (c) if it survives both of those, whether it is specifically tied to drone-gun turret enemies (their fire pattern, tracking, or projectile spawn — worth an env-gated per-frame timing probe around whatever `ai_*`/`gunfire.c` path drone turrets use) or just general Control Center scene load on 00 Agent (higher enemy density than Agent/Secret Agent difficulty).
|
||
|
||
**Status:** OPEN — single live report, un-triaged, un-reproduced outside the debug harness. **Do not start perf-tracing `port/fast3d/` or AI code over this until step (a)/(b) above rule out harness overhead** — that's the cheap, disqualifying check and it hasn't been run yet.
|
||
|
||
## D297 - v0.3.0 silently wipes ALL pre-fix player saves on first launch (D284 side effect; release-blocker, found during D295/M-148).
|
||
|
||
**What:** every save written by a v0.2.x build fails `fileValidateSaves` CRC on any post-D284 build and is reset to factory (`BAD -> RESET (progress wiped)`, confirmed live via `GE_SAVELOG=1` on the user's #87 artifact — all five slots). Players upgrading to v0.3.0 lose their entire campaign with no warning.
|
||
|
||
**Root cause (full evidence chain in `## D295` M-148):** slot CRCs are computed by `fileGenerateCRC` (`src/game/crc.c`) off a *local constant seed* through `randomGetNextFrom` — so each stored CRC is a fingerprint of the PRNG implementation that wrote it. D284 (`0de99b4d`, port-side `dsll32/dsrl32` semantics fix) changed the PRNG output without changing any persisted state, so every pre-fix CRC now mismatches. The D284 commit's "savegames are unaffected" claim is corrected in `## D284`.
|
||
|
||
**Scope of any migration (verified, not assumed):**
|
||
- Only the **5 slots** (file bytes 32–607) carry user data; each needs its two CRC words recomputed with the *new* PRNG over slot bytes [8..96).
|
||
- The block-0 `smallSave` header check (`file2.c:512-535`) needs **no** migration: `SAVEFLAGS_SET(FOLDER3, SAVESLOT1, BOND_CONNERY, FALSE)` = 0x42 and any in-use save has a different header byte (user's artifact: 0x57), so it takes the "customised file" branch and is factory-reset on every load by design (N64-faithful). It is a pristine-cart seal, not progress.
|
||
|
||
**Fix plan (signed off in principle; reviewed 2026-09-16 and landed as M-150):** `port/src/legacycrc.c` — an isolated, clearly-dated port file carrying the *old* (deliberately-wrong) PRNG + CRC math, called from the eeprom shim's read path: if a slot fails the new-CRC check but validates under the legacy PRNG, re-stamp `chksum1/chksum2` with the new-PRNG values before handing the region to the game. Zero game-code changes; rule-2-clean (port layer satisfies a hardware-format dependency). The old PRNG must stay in-tree until no pre-D284 saves are expected in the wild — the file header must say so loudly. Reference implementation: `tools_pc/d295_crc.py` (bit-exact against compiled C for both PRNGs).
|
||
|
||
**Alternatives rejected:** dual-CRC acceptance inside `fileValidateSaves` (game-code change, rule 2); "loud release note only" (ships known total data loss — not acceptable for a release).
|
||
|
||
- **M-150 (fix landed + verified):** independent review of the draft passed with no corrections — reviewer diffed `geLegacyRandomGetNextFrom` line-by-line against `git show 0de99b4d^:port/src/random.c` (bit-exact, operation for operation), and separately confirmed the `save_data` layout, the hook site in `geEepromRW`'s read branch, that `geEepromPatchAllCheats` is genuinely read-time-only (never persists — so the persist-before-patch ordering claim holds), the one-past-the-end boundary pattern (same as `fileValidateSaves` itself, file2.c:544), and that fresh zero slots fail *both* CRC checks and fall through untouched. Applied: new `port/src/legacycrc.c` (isolated, loud dated header explaining why a deliberately-wrong PRNG lives in the tree and when it may be deleted) + a hook in `port/src/libultra.c` `geEepromRW` read branch — fires only on `block == 4 && nbytes == sizeof(ge_save_slot)*5`, runs **before** `geEepromPatchAllCheats` so only pristine+migrated bytes are ever persisted, persists once via `s_eeprom` + `geEepromStore()` with an INFO log. No CMakeLists change (`SRC_PORT` is `GLOB_RECURSE`); zero game-file edits. Verification on `release/v0.3.0` HEAD (ntsc-final): **(1)** user's original #87 artifact → `D297-migrated 5`, all five slots OK, no crash; stored CRCs now exactly the new-PRNG values (`tools_pc/d295_crc.py`: "new PRNG matches all 5 stored CRCs: True"); relaunch → **no** migration (idempotent), still all OK. **(2)** native post-fix save (CRCs pre-stamped via `d295_crc.py`) → zero migrations, all five OK. **(3a)** corrupted slot (one byte flipped inside slot 0's CRC region) → only 4 migrated; the corrupt slot fails both checks, is left alone, and takes the game's own `BAD -> RESET` wipe path as designed. **(3b)** fresh zeroed eep → no migration, clean boot. Then 21/21-level `tools_pc/level_sweep.sh` PASS. `data/ge007.eep` restored to the original artifact and hash-verified **at report time** (md5 `76e18aa4…`; per the porting-notes §E gotcha, after the sweep, not before). Operational note: `fileValidateSaves` runs from `init_menu00_legalscreen` (`front.c:1450`) — the legal screen appears ~36 s / frame ~1800 into an unattended boot, so verification runs must be ≥ 60 s to observe it.
|
||
|
||
**Status:** **FIXED (M-150)** — migration shim landed and verified; release-blocker cleared. Crash-side companion D295 is FIXED (M-149).
|
||
|
||
## D298 — Full HUD missing in a v0.3.0 dev build (reported 2026-09-16; suspected debug-launch artifact)
|
||
|
||
**Symptom (user, 2026-09-16):** initially reported as "the aim-mode crosshair is missing as of this build" (release/v0.3.0 @ `ce8427cf`, the D243 M-154 probe build); the user then corrected it — **the full HUD is missing**, not just the crosshair. User's own suspicion: "this is the debug launch or something" — i.e. an artifact of how the dev build was launched/configured rather than a code regression. Explicit instruction: **do not raise/chase this until it reproduces in a regular release-targeted build.** Parked.
|
||
|
||
**Ruled out so far (trivially):** the M-154 probe commits (`1b3476a4`/`ce8427cf`) are `#ifdef PORT`, read-only, and only emit `osSyncPrintf` — no state writes on any path; extremely unlikely to be the cause. Also checked `4fc54293` (D295 M-149, the most recent commit touching a crosshair-referencing file, `front.c`): it only widened two menu-text stack buffers under `#ifdef PORT` and passed a 21/21 sweep — not an obvious suspect either.
|
||
|
||
**Where the crosshair lives (for the eventual investigation):** `crosshairimage` (`src/image_bank.h:7`, loaded in `image_bank.c`), draw-state fields `crosshair_angle` / `crosshair_x_pos` / `crosshair_y_pos` (`bondview.h:1114-1116`; show/hide flag documented at `bondview.h:558-559`: 0 = shown, 1 = hidden), referenced from `front.c`, `gunfire.c`, `player.c`. Most recent commit touching a referencing file besides D295: `f602b173` (v0.2.1 hotfix batch — D215 "1P weapon viewmodel never drew in-level — renderdata.flags read 0", `gunfire.c`) — same HUD/viewmodel render-data family, plausible regression source if the crosshair draw shares that path.
|
||
|
||
**Next (only if it reproduces in a release-targeted build):** first re-check the launch path (debug vs release config, `data/ge007.ini` state, any env vars left set — e.g. a stray debug render flag), then ask for the last build where the HUD was confirmed working → `git bisect` with a 30-second in-level check; static-trace the HUD draw conditions (crosshair show/hide flag at `bondview.h:558` is the cheapest single suspect).
|
||
|
||
**Status:** **OPEN — parked per user instruction: suspected debug-launch artifact; do not raise until seen in a regular release-targeted build. Not yet investigated.**
|
||
|
||
## D299 — Game.SkipIntro crashed selecting any save slot except one (found + fixed this session, 2026-09-17)
|
||
|
||
**Symptom (user, live repro):** with `Game.SkipIntro=1` (D216), booting straight to file-select and selecting any of the 4 file slots except one crashed the game (`EXCEPTION: 0xc0000005`, fault addr `0x10`). Reproduced on both a clean `CMAKE_BUILD_TYPE=Release` rebuild and the normal `RelWithDebInfo` dev build, and with a completely fresh (no `data/ge007.eep`) save — ruling out stale/incompatible save data. With `SkipIntro=0` (normal intro/legal-screen boot), all 4 slots worked with no crash.
|
||
|
||
**Root cause:** `fileValidateSaves()` (`file2.c:499`) — the only place `saves[]` is populated from EEPROM (`joyGamePakLongRead`, CRC-validated/repaired) — is called exclusively from `init_menu00_legalscreen()` (`front.c:1450`), which only runs when the front-end state machine passes through `MENU_LEGAL_SCREEN`. `Game.SkipIntro`'s shortcut (`src/game/lv.c:417-430`, `#ifdef PORT`) jumps straight to `MENU_FILE_SELECT`, skipping the legal screen entirely, so `fileValidateSaves()` never ran — `saves[SAVESLOTMAX]` (`file2.c:29`, a plain zero-initialized global) stayed all-zero instead of loading real save data. `fileGetSaveForFoldernum()` (`file2.c:434`) then legitimately returned `NULL` for every folder except whichever one a zeroed `save_data` happens to decode its folder field as. File-select's `fileGetIsCheatUnlocked()` (`file2.c:397`) dereferences its `save` argument with no `NULL` guard (unlike sibling call sites, e.g. `file2.c:1190`, which do check) — crashing for every other folder.
|
||
|
||
**Fix (`src/game/lv.c`, `#ifdef PORT` block, D216's own shortcut):** call `fileValidateSaves()` from the `SkipIntro` shortcut, so `saves[]` gets populated exactly as the skipped legal screen would have done. Zero game-logic change — reuses the existing decomp-verbatim function; rule-2-clean (port-layer completeness fix for a port-only feature, not a `src/game` behavior change). Left `fileGetIsCheatUnlocked`'s missing `NULL` guard alone since the actual bug is the port shortcut not fully replicating the skipped screen's side effects, not a missing defensive check in game code.
|
||
|
||
**Verified:** build-clean (`ninja`, both `RelWithDebInfo` and `Release`); live user re-test with `SkipIntro=1` restored — intro correctly skipped, all 4 file slots selectable with no crash.
|
||
|
||
**Generalisable lesson (added to `porting-notes.md`):** a port-only "skip to state X" shortcut must replicate every side effect the skipped screens perform, not just the terminal menu-state flags — `SkipIntro`'s original comment claimed to fully reuse "the game's own post-intro route," but missed the legal screen's one-time `fileValidateSaves()` call.
|
||
|
||
**Status:** **FIXED** — verified live this session. Not yet run through a full 21-level sweep (docs-only + one `#ifdef PORT` line touching front-end boot, not level code) or committed.
|
||
|
||
## D300 — Hipfire look non-uniform (GitHub #89): direct camera write replaces the N64 stick-curve routing (WI-1, `docs/dev/GEPD-INPUT-PLAN.md`)
|
||
|
||
**Symptom (issue #89):** hipfire mouse-look feels non-uniform — slow mouse motion is "almost unrecognised," fast motion saturates quickly, overall bang-bang rather than smooth.
|
||
|
||
**Root cause (diagnosed in the plan, confirmed by code reading):** hipfire converted mouse px into an *analog stick value* (`sx += (int)(hipEdx * hipSens * MOUSE_TURN_GAIN)`, `port/src/input.c`), which the game then runs through its own N64-era natural-turn response — a quadratic-ish curve with a soft floor that saturates at full 315°/s by ~13 px/poll (D238). Consequences: small stick values from slow motion square off to near-zero turn rate; fast motion saturates almost immediately, narrowing the usable band; `(int)` truncation adds a quantized dead zone. Aim mode (D194's `aimGepdCompute`) never had this problem because it already writes `vv_theta`/`vv_verta` directly, bypassing the stick curve entirely — hipfire was the one leftover N64-stick-shaped path.
|
||
|
||
**Fix (`port/src/input.c`, port-only, no `src/game` touch):** new `hipDirectCompute()`, structurally mirroring `aimGepdCompute()` and GEPD's own hipfire branch (`reference/mouse-injector/games/goldeneye.c`: `camx += XPOS/10 * (SENS/40) * (fov/basefov)`). Converts this poll's dt-scaled, already-invert/Y-scale-adjusted px directly to degrees (`px * 0.1 * (MouseTurnSpeed/100) * (MouseSensitivity/100) * (fovy/GEPD_BASE_FOV)`) and adds straight to `g_CurrentPlayer->vv_theta`/`vv_verta` (pitch clamped ±90°, matching the aim-mode convention). At default `MouseTurnSpeed=50`/`MouseSensitivity=100` this reduces to GEPD's implied px/10 baseline scaled by the existing 50% fine-trim — same "MouseSensitivity multiplies everything" convention D238 established.
|
||
|
||
**Safety gates (per the plan's GEPD-parity requirement):** GEPD gates injection on `camera==4||0 && menupage==11 && !dead && !watch && !pause`. Aim mode never needed explicit dead/watch/pause checks because it only runs while RMB is held, which a player can't do through those states in practice; hipfire look is always live, so it needs the check explicitly or it would fight the game's own camera control during a death cam or cutscene. `menupage==11` is already covered by the existing `menuMode`/`current_menu` gate (hipfire's branch is only reached when `!menuMode`). Added explicit reads of `g_CurrentPlayer->bonddead`, `->outside_watch_menu`, `->pause_state` (decomp-canonical fields, `src/game/bondview.h:465/672/705`) — read-only, no logic change, function returns 0 (falls back to the legacy stick path) if any gate is closed.
|
||
|
||
**Escape hatch:** `Input.MouseDirectLook` (default 1) — `0` reverts to the pre-existing `MOUSE_TURN_GAIN` stick path unchanged. Keyboard-driven turn (arrows) is unaffected either way (only mouse px is rerouted).
|
||
|
||
**Not done this pass (deferred per the plan's phasing):** WI-2 (raw-input-by-default), WI-3 (sensitivity-knob consolidation), tank-specific direct write (GEPD's separate `0x80036484` tank-rotation global — `g_TankOrientationAngleRad`, `bondview.h:2529`/`bondview.c:269`, per a background mapping pass this session; not wired up — tanks are rare in single-player, phase 2 per the plan).
|
||
|
||
**Verified:** compiles clean (`ninja`, `port/src/input.c` — no new warnings); full link succeeds; a 1200+-frame bare `-level_33` smoke boot (`GE_D243M=1`, unrelated to this change but running concurrently) completed with zero exceptions/crash signatures, confirming the build is otherwise healthy with this change in it. `GE_INPUTLOG` logs `hipdirect d=... cam=...` lines for the linearity check the plan's Verification section calls for.
|
||
|
||
**Live-playtested by the user this session: "input already feels better."** No known issues from that testing. Two unrelated side effects noted (mouse now active during the pre-control intro flying cutscene; no mission-complete screen after Dam) were explicitly deferred by the user for separate investigation — not a regression in this fix itself.
|
||
|
||
**M-190 (2026-09-18, D243 camera-tracking review session): closed the first deferred side effect above — the missing gate was in this feature's own safety-gate list, not a new bug.** The "mouse now active during the pre-control intro flying cutscene" report, surfaced but deferred at ship time, reproduced again during a live review of the Dam abseil cutscene ("camera angles do not follow bond... on the intro fly in camera... you're not supposed to be able [to move the mouse]... same as the intro flyover cam"). Root cause: this entry's own Safety Gates section above already named the requirement ("hipfire look is always live, so it needs the check explicitly or it would fight the game's own camera control during a death cam or cutscene," mapping to GEPD's `camera==4||0` condition) and `hipDirectCompute()`'s in-code comment stated it explicitly too — but the actual gate list (`bonddead`/`outside_watch_menu`/`pause_state`) never implemented the camera-mode check, only the death/watch/pause ones. Fixed by adding the missing check: `gameScriptedCameraActive()` (`bondview2.c`, renamed from D243's `d243mCutsceneActive()` since it was never D243-specific), gating on `CAMERAMODE_POSEND`/`INTRO`/`SWIRL`/`FADESWIRL`. User confirmed live, full intro-flyover + Dam abseil playthrough: "ok yeah looks good." The second deferred item (no mission-complete screen after Dam) is unrelated and remains open — not touched this pass.
|
||
|
||
**Status:** **FIXED, user-confirmed live** — code complete, compiles clean, links, live-playtested with positive feedback, and the one deferred safety-gate gap (mouse-lock during cutscenes) closed in M-190. WI-2/WI-3/tank-direct-write remain deferred per the plan's phasing (not blockers for this item). The "no mission-complete screen after Dam" side effect remains open, untouched.
|
||
|
||
## D301 — `fileGetIsCheatUnlocked()` NULL-deref, unguarded on every call site, not just the D299/SkipIntro one (GitHub #87)
|
||
|
||
**Symptom (GitHub #87, two independent reports):** (1) fresh save → unlock a cheat (paintball, Dam) → backing out to the profile/file-select screen, or relaunching, crashes (`0xc0000005`, `FAULT ADDR: 0000000000000000`); deleting the save "fixes" it until the next cheat unlock. (2) normal campaign progress through Jungle → Control on 00 Agent (with worsening audio-sync/skipping reported starting around Jungle/Streets, likely unrelated — not folded into this finding), exit level, exit game, relaunch → crash after the intro, before the main menu. **Both crash logs have the identical PC (`00000001400797c1`) and identical `FAULT ADDR: 0000000000000000`**, strong evidence of one shared crash site rather than two coincidental bugs.
|
||
|
||
**Root cause:** `fileGetIsCheatUnlocked(save_data *save, s32 cheat)` (`src/game/file2.c:391-402`) dereferences `save` with no NULL check:
|
||
|
||
```c
|
||
bits = save->unlocked_cheats_1 | save->unlocked_cheats_3 << 0x18 | ...
|
||
```
|
||
|
||
It's called with `fileGetSaveForFoldernum(selected_folder_num)` passed straight through, unguarded, from several sites: `front.c:1067-1124` (the mode-select screen's per-level cheat-availability checks — the code path that runs every time the mode-select/cheat menu renders) and `file.c:59` (briefing cheat check). `fileGetSaveForFoldernum()` returns NULL whenever no slot in `saves[]` currently matches the requested folder — which real N64 hardware invariants prevent for the *active* folder, but which the PC port can hit whenever that folder's slot fails `fileValidateSaves()`'s CRC check, or was never populated for the active folder in the first place.
|
||
|
||
**Relationship to D299:** D299 fixed *one* route into this same unguarded function — `Game.SkipIntro` skipping `fileValidateSaves()` entirely, leaving `saves[]` all-zero. That fix (`src/game/lv.c`, `#ifdef PORT`) only calls `fileValidateSaves()` on the SkipIntro boot path; it does **not** guard `fileGetIsCheatUnlocked()` itself. Neither #87 report mentions SkipIntro, so they're reaching the same crash through a different, not-yet-fully-root-caused route (most likely a real save slot failing CRC validation after a cheat-unlock write, or a folder/slot mismatch on a fresh profile) — the *mechanism* (unguarded NULL deref) is now understood and closed regardless of *why* the lookup fails.
|
||
|
||
**Fix (`src/game/file2.c`, `#ifdef PORT`, unreachable on real N64 hardware so no decomp/byte-match impact):**
|
||
|
||
```c
|
||
#ifdef PORT
|
||
if (save == NULL)
|
||
{
|
||
return FALSE;
|
||
}
|
||
#endif
|
||
```
|
||
|
||
Returns "cheat not unlocked" — the same safe default other cheat-state paths already produce — instead of crashing. Closes the NULL-deref class at its single choke point rather than patching each call site.
|
||
|
||
**Verified this session:** build green (`ninja`, clean incremental link); a `-level_09` smoke boot ran 1000+ frames crash-free with the fix in place; **confirmed the guard doesn't mask a working code path** by temporarily reverting it, rebuilding, and re-running the same smoke test (still crash-free either way — see caveat below). **Not yet reproduced live:** attempted to force the NULL-`save` condition headlessly via `GE_STARTMENU=6` (jumps straight to `MENU_MODE_SELECT`, skipping `fileValidateSaves()` the same way SkipIntro does) — this did **not** crash even on the pre-fix build, meaning `saves[]`'s all-zero decode coincidentally satisfies `fileGetSaveForFoldernum(0)` for that particular repro (the same "works for one folder" coincidence D299's own write-up describes), so this specific synthetic repro doesn't exercise the actual NULL path either reporter hit. **This fix has not been confirmed against the exact reported repro** — it closes a real, confirmed-unguarded crash vector of the identical shape (NULL-deref in this exact function, matching both crash logs' fault signature), but the precise trigger for *why* the lookup fails in the #87 reports remains unconfirmed. A live re-test from either reporter (or a `GE_SAVELOG=1` capture at crash time) would confirm whether this was the whole story or one of two contributing bugs.
|
||
|
||
**Status:** FIXED locally on `release/v0.3.0` (not yet pushed) — build-verified, crash-free smoke test; live confirmation against the actual #87 repro still owed.
|
||
|
||
**M-183/M-185 (2026-09-17, this session):** Added playspeed and endframe clamp workarounds for D243's Bond position/animation issue. M-183 clamps playspeed > 10.0 to 1.0 during scripted camera modes; M-185 clamps corrupted endframe values (< 0.0 or > 1000.0) to 100.0. Both were originally env-gated on GE_D243M — a gate M-187 found to be wrong (the clamps never fired for real players); they are now gated on `gameScriptedCameraActive()` alone and active by default. See M-192 for the v0.3.0 release-review sign-off on their retention. The clamp fixes prevent rapid animation advancement (~97 frames/tick) but Bond's animation still "repeats" — he can't complete his animation during the Dam abseil cutscene. ~~Root cause (struct layout corruption from 32→64-bit pointer widening, D100/D140 family) not yet addressed.~~
|
||
|
||
**M-186 (2026-09-17, later recovery session) — the M-183/M-185 diagnosis above is WRONG; correcting the record.** A follow-up agent acted on "struct layout corruption from pointer widening" and converted `Model`'s 7 pointer fields to u32 slots + accessor macros, rewriting ~200 call sites with blind `sed` across 12 files — this broke the build (macro name collisions with unrelated structs' same-named fields, malformed chained-access substitutions) and was **reverted this session** (changes stashed, not committed: `git stash` "D243 broken sed attempt (superseded)"). The diagnosis itself doesn't hold up: a `Model*` accessed normally is always self-consistent post-compilation; this codebase's actual pointer-widening bug class only bites when something *puns/overlays* a Model onto raw hardcoded-offset bytes (§A, `porting-notes.md`), and all three known instances of that for models (D100 Bond's own inline model, D102 weapon model, D140 watch model) are already fixed with real inline storage. No unfixed punning gap exists for Bond's world model. **The real, already-established lead is above this entry, in M-169 through M-178** — the 3rd `chrlvPerformAnimationForActor` scripted restart (`chraction.c:605`) triggers the rapid `animframe` advancement; M-178's own stated next step (diff that call's caller-side state writes, `chraction.c:615-627`, against a normal `modelTickAnim` loop-wrap) was never carried out and is the correct place to resume. See `docs/HANDOFF.md` for the fuller writeup, including a new unconfirmed candidate (`modelSetAnimation`/`modelSetAnimation2` never reset `playspeed`/`unkb0`/`unkb4`/`unkac`/`animrate`, only `modelSetAnimPlaySpeed` does) that needs a live `GE_D243M` capture to check, and a reminder not to lose D243 CONSOLIDATED NEXT STEPS item 5's model-pointer-reuse caveat as an alternative explanation for the "playspeed already corrupted before `modelSetAnimPlaySpeed` was even called" observation.
|
||
|
||
### M-187 (2026-09-18, same recovery session): 25-minute parallel triage sweep of `docs/dev/BUG-TRIAGE-REPORT.md`'s open P0/P1 items — 5 read-only investigations, no fixes applied. **v0.3.0 priority set by user this session: D252 and the D243/D173/D292 family are release-blocking; D286/D287/D296 are explicitly deprioritized (old reports).**
|
||
|
||
**D243/D173/D292 (this family, continuing directly from M-169–M-178 above):** Confirmed as code fact, high confidence on the "what": `modelSetAnimation2` (`model.c:2720-2846`, unmodified decomp) never resets `unkb0`/`unkb4`/`unkac`/`animrate` on a scripted animation restart — those four are written only by `modelSetAnimPlaySpeed` (`model.c:3011-3034`) and consumed by the blend branch in `modelTickAnim` (`model.c:3559-3574`). A scripted restart landing while `unkb0` is still small-but-nonzero (orphaned mid-blend from an earlier call) divides by that stale `unkb0` using an `unkac`/`animrate` pair describing the *previous* animation — a plausible mechanism for the observed `playspeed≈388-410` one-tick spikes. **Rule-2 complication:** `modelSetAnimation2` has zero `#ifdef PORT` — this gap is equally present on N64 given identical prior state, so a reset inside it would be a behavioral change needing sign-off, not a free port fix. The legitimate PORT-scoped angle is narrower: find what's *PC-specific* about `unkb0` being stale at that exact moment — leading candidate is D193's frame-catchup mechanism delivering multiple sim ticks in one burst, which single-tick-per-frame N64 execution would never produce. **Not verified — needs a live `GE_D243M` capture logging `unkb0/unkb4/unkac/animrate/playspeed` for ~10 ticks before the 3rd `chrlvPerformAnimationForActor` restart (frame ~4100-4107 in the existing repro), cross-referenced against D193's catchup-tick counter for that same window.** If `unkb0` is already 0 going in, this hypothesis is refuted and the search moves to `unka0` (annotated "likely a function pointer" at `bondtypes.h:1643`, stored as `s32` — worth a grep for raw pointer-arithmetic use). **Correction to the shipped M-183/M-185 clamp workarounds' in-code comment:** it attributes the corruption to "D100/D140 struct-layout pun family" — this does not hold up on inspection; `Model` is accessed everywhere by named field, not raw-offset/union-alias, and no pun pattern was found anywhere in this call path. Do not carry that citation forward without evidence. D173/D292 overlap with the same `modelSetAnimation2` entry point is plausible (M-177 already groups all three as one family) but not confirmed this pass — D173's `stage_intro_anim_table`/`setanim_caller` path and D292's death-anim trigger need their own read, not done.
|
||
|
||
**D252 — rainbow particles.** Static pass this session: the leading hypothesis (uninitialized `TextureCacheKey` field, `port/fast3d/gfx_pc.cpp:1039-1045`) is **REFUTED, high confidence** — every field of the 7-member struct is explicitly assigned on both the CI and non-CI branches before the first read in `gfx_texture_cache_lookup`; `operator==` is member-wise so struct padding is irrelevant; the one under-specified literal (`gfx_texture_cache_delete`, `:582`) is only ever hashed, never compared. **User correction (2026-09-18, overrides the triage report's framing): reproduces on Windows too, not just Steam Deck** — the report's "0/6 dev-box repro" and "environment-dependent" framing is wrong or at least incomplete; this is NOT Deck/driver-specific. This changes the next step: the two GL-driver-state leads from the refuted-hypothesis writeup (wrong-texture-object-bound vs. correct-object-wrongly-colored) are now testable on a normal Windows dev box with the existing `GE_D157`/`GE_D157T`/`GE_D157I` probes — no Deck required. **Elevated to v0.3.0-blocking per user; next session should run `GE_D157=1` (and the widened-cap `GE_D157T` variant, dropping the ≤16KiB tile-0 size cap per M-158's own unexecuted next step) on this Windows box where it's now confirmed to reproduce.**
|
||
|
||
**D245 — water tc-shift seam.** Root cause CONFIRMED: `skyPortPickShift()` (`sky.c:1768`) is a stateless per-frame function of the current S/T span; ordinary view rotation drifts the water quad's span (~290k-390k units) across a `k` breakpoint every rotation, causing a one-frame re-quantization pop at the shared vertex tc. Proposed fix (untested, not applied): replace the direct `skyPortPickShift` call in `skyPortBeginFan` (`sky.c:1830-1831`) with a hysteresis wrapper — keep the previous `k` unless the span clearly needs to move (a Schmitt-trigger margin, e.g. ~5-10%, on both the up and down thresholds), scoped `#ifdef PORT` with static per-call-site "last k" state (careful not to share state across unrelated fans/levels). Should look better than the existing untested `GE_D245_FIXEDSHIFT` opt-in at the boundary while avoiding its always-coarser quantization elsewhere. Needs the same live A/B (baseline / fixed-k / hysteresis) that `GE_D245_FIXEDSHIFT` itself has never gotten. Not v0.3.0-blocking per user priority (not in the D252/D243-family list) but cheap to pick up if a live A/B session happens anyway.
|
||
|
||
**D236 — Surface 1 billboard-tree wall (tenth pass).** **Correction to `docs/dev/BUG-TRIAGE-REPORT.md:24`'s claim that "I2:A2 decode + AC-aware edge fix re-applied in-tree (uncommitted)":** false — confirmed via `git log`/`git diff` that this fix (`gfx_pc.cpp:743-766`, `import_texture_ia4`, tagged D266) landed in `532092c7`, predates D272-D280, and was already accounted for as ruled-out in the existing D236 write-up (fixed a real but separate IA4 edge-dither bug, not the wall symptom). No new root-cause lead found this pass; the actual next step is the already-drafted eleventh-pass framing implied by M-155's own data — trace the `0x0c184b50` discrete-tree-card class's on-screen behavior (does it render at all, does the noise "wall" quad draw over/under it incorrectly) using the frame ranges M-155 already identified (`-level_36`, frames 1-536/537-747) and the existing `GE_D236RM` probe. Ten passes with no resolution — not v.0.3.0-blocking per user priority; recommend parking as a known cosmetic defect rather than dispatching an eleventh pass unless it's specifically requested.
|
||
|
||
**D240/D241 — gunshot SFX.** **D241 CONFIRMED (not "likely") duplicate of D207**: identical mechanism — GE's 8-voice SFX cap, with D206's fix correctly making the alarm klaxon a genuine infinite looped voice that legitimately occupies one slot; once 8/8 are held, new one-shot gunshot requests are silently dropped by the preemption scan's refusal to steal an owned LOOPED voice. **Correction to the triage report's "monitoring status" framing:** D207 has **no fix in the tree** — an M-77b last-resort LOOPED-preemption pass was implemented but reverted after its BUNKER1 test harness failed to reproduce starvation (`[D207-DROP]=0`). D241 supplies the repro D207 was missing (alarm active + PP7/AK47 specifically; Klobb unaffected, asymmetry still unexplained — both share `AutomaticFiringRate=0x03` so fire-rate alone doesn't explain it). Recommend: close D241 as a duplicate, fold its repro into D207, re-apply M-77b's preemption pass and retest against D241's specific repro rather than the harness that never triggered it. **D240** (SFX cadence mismatch): low-medium confidence this is a distinct live bug — both plausible tick chains (D209 game-tick side, D204 audio-DAC-rate side) are already fixed/merged; the report may simply predate those merges. Not touched by user's v0.3.0 priority list.
|
||
|
||
**D286/D287/D296 — freezes/crash. Deprioritized per user (2026-09-18): "old reports," not v0.3.0 scope.** Recorded for completeness, no further action planned this cycle: **D296 is mislabeled in the triage report** — the actual finding is a *stutter* report ("maybe from the drone guns"), not a crash; it got conflated with D295 (a separate, already-fixed crash that did not reproduce in that same playtest session). `docs/dev/BUG-TRIAGE-REPORT.md` and `findings-index.csv` both need correcting from "crash" to "stutter." **D287**: architecture confirmed (32-slot scheduler completion queue, drained only by the main frame loop), but the evidence file previously believed to show the queue full at freeze-time (`scratch/d287_freeze_gdb.txt`) is actually a normal clean-exit capture with zero hits for the freeze markers — no real freeze-time state has ever been captured; needs a live `GE_D287` heartbeat variant that doesn't cap at 3 reports. **D286**: no new narrowing, asset-streaming-stall remains an untested guess.
|
||
|
||
**M-187 (2026-09-18, this session): the "shipped" M-183/M-185 clamps were never actually active for a real player — found and fixed, independent of the still-open root cause.** Both clamps (`model.c` playspeed >10.0 → 1.0, endframe <0/>1000 → 100.0) were gated on `d243mProbeActive()`, which ANDs the scripted-camera-mode test with `getenv("GE_D243M") != NULL` — so despite HANDOFF.md/findings-index describing them as a committed partial fix, they only ever fired during a diagnostic capture (`GE_D243M=1` set), never in a normal playthrough. This is a plausible full or partial explanation for why the user kept seeing the unclamped symptom after M-183/M-185 "shipped." **Fixed:** added `d243mCutsceneActive()` (`bondview2.c`, new, camera-mode test only, no getenv) and re-gated both clamps on it; the `D243M: clamp ...` log lines stay separately gated on `d243mProbeActive()` so diagnostic output is unchanged. Also corrected both clamps' in-code comments, which still cited the M-186-refuted "D100/D140 struct-punning" explanation — replaced with the real, still-open mechanism (`modelSetAnimation2` never resets `unkb0/unkb4/unkac/animrate` on a scripted restart). `#ifdef PORT`, no game-logic change, ABI-neutral (control-flow/gating only). Build clean (`./build-pc.sh ntsc-final`, only pre-existing unrelated warnings); `tools_pc/verify.sh dam` PASS (3 frames, crash-free). **Not yet live-verified against the actual Dam abseil cutscene** — needs the user to play it and confirm whether Bond's animation/positioning now looks correct with a normal launch (no env vars needed, the fix is unconditional-in-cutscene now).
|
||
|
||
**Also this session: extended the D243M instrumentation for the still-open root-cause question** (findings.md's own "Not verified" item above), ready for the next live capture: `modelTickAnim`'s existing `D243M: tickstart` probe (`model.c`) now also logs `numticks` (the D193 multi-tick catch-up burst size for that call — the leading candidate for why `unkb0`/`unkb4` could be PC-specifically stale, since N64 never executes more than one tick per frame). New `D243M: animrestart` probe (`chraction.c`, `chrlvPerformAnimationForActor`, right before the `modelSetAnimation()` call) logs `chr=/animID=/startframe=` plus the model's `unkb0/unkb4/unkac/animrate/playspeed` at the exact instant of a scripted animation restart — same `d243mProbeActive()` gate as the rest of the family, zero cost/behavior change when `GE_D243M` is unset. **Next live step (needs the user, per the standing division of labor on this bug):** `GE_D243M=1`, play Dam to the abseil, send the log. Look for the 3rd `D243M: animrestart` line for Bond's `chr=` and check whether `unkb0` is already nonzero going in (confirms the stale-state hypothesis) and whether the preceding `D243M: tickstart` lines show `numticks>1` (confirms the D193-catchup-burst PC-specific trigger) in the ticks immediately before it. If `unkb0` is 0 at that point, the hypothesis is refuted and the search moves to `unka0` (D243 CONSOLIDATED NEXT STEPS' still-untried alternative, `bondtypes.h:1643`).
|
||
|
||
### M-189 (2026-09-18, same session): ROOT CAUSE FOUND AND FIXED — live-confirmed by the user; D243/D173/D292's animation/positioning symptom is resolved
|
||
|
||
**The user's own live capture (`GE_D243M=1`, full Dam→abseil playthrough) delivered exactly the data M-187/M-188 asked for, and it refuted the `unkb0`-blend-state hypothesis outright — every `D243M: animrestart` line for Bond's cutscene chr (`chr=5037`) showed `unkb0=0.000` at every one of the 4 scripted restarts.** But the same lines carried the real signature: `unkac=6.283`/`12.566` (exact multiples of 2π — a rotation-angle value, not a legitimate animation blend factor) and `animrate=-3111.625` — which is bit-identical (to print precision) to this same chr's own `pos.z` at that moment (`-3111.6`, matching the exact Z value M-160/M-162's captures cycled through repeatedly). Cross-referencing the immediately preceding `D243M: setanimplayspeed`/`CREATE` lines for the same `model=` pointer made the mechanism undeniable: at model-creation time, `playspeed=-14647.539`/`animrate=-2082.015` (read as stale/garbage before being overwritten) were bit-identical to that exact model's own `prop->pos.y`/`pos.z` (`-14647.5`/`-2082.0`).
|
||
|
||
**Root cause: a hardcoded 32-bit struct-size literal, stale after 64-bit pointer widening — the actual mechanical bug behind the whole family, distinct from (and more precise than) M-186's "no unfixed punning gap" finding, which only checked ordinary named-field `Model*` access, not this buffer-layout site.** `bondview2.c`'s player third-person body model (used by the `CHR_BOND_CINEMA` cutscene path, the exact model this whole investigation has been chasing) is carved out of a raw scratch buffer with manually-computed offsets: `model = (Model *)(weaponbuf0 + cursor); cursor = ALIGN64_V3(cursor + 0xfb);` (`bondview2.c:598-599`, right after the identical, correctly-written idiom for `ModelFileHeader` one line up: `cursor = ALIGN64_V3(cursor + sizeof(ModelFileHeader) + 0x3f)`, line 591). Decoded: `0xfb == 0xbc + 0x3f` — i.e. this literal IS `sizeof(Model) + 0x3f` for N64's 32-bit `Model` (188 bytes), just pre-computed as a constant instead of written with `sizeof()` like its neighbor. **Confirmed empirically, not just by inspection:** a standalone compile against the real build's exact flags/includes (`scratch/sizeof_model.c`, same defines as `compile_commands.json`'s `bondview2.c` entry) measured `sizeof(Model) == 232` (`0xe8`) on 64-bit PC — pointer-widened fields (`attachedto`, `attachedto_objinst`, `anim`, `anim2`, plus alignment) grew the struct by 44 bytes past the 32-bit original. The stale `0xfb` literal under-reserves by exactly that much, so the very next thing placed in the buffer — the animation-node data array, `animdata = (u32 *)(weaponbuf0 + cursor)` — starts **inside** the tail of the just-allocated `Model`, overlapping precisely `playspeed`/`animrate`/`unkac`/`unkb0`/`unkb4` (`bondtypes.h:1643-1650`, the struct's last 5 real fields before `anim_translation_scale`). Every subsequent write to that overlapping animation-data region corrupts those fields with arbitrary content, and the model's own uninitialized/garbage state before first use reads back as whatever happened to be sitting in adjacent memory (here, the model's own `pos`) — exactly matching every anomalous value captured across M-160 through M-188.
|
||
|
||
**This qualifies as the project's standard ABI/pointer-width narrow exception (AGENTS.md rule 2), not a game-logic change:** semantics-preserving, mechanical, and the fix simply completes the pattern the surrounding code already uses. **Fix applied (`#ifdef PORT`, `bondview2.c:598-609`):** replaced the stale `ALIGN64_V3(cursor + 0xfb)` with `ALIGN64_V3(cursor + sizeof(Model) + 0x3f)` — the real `sizeof(Model)` computed at compile time, matching the `ModelFileHeader` idiom one line above exactly. The `#else` branch (N64/original literal) is preserved unmodified for non-PORT builds.
|
||
|
||
**Live-verified by the user, full Dam→abseil playthrough, normal launch (no env vars): "Yep fixed and the animations seem to play right."** This closes the actual animation-corruption mechanism for D243/D173/D292's shared family (see D173/D292's own entries — same `Model`-carving-buffer class is worth checking there too, not yet done). Build clean, `tools_pc/verify.sh dam` PASS. M-183/M-185's clamps (now correctly gated per M-187) remain in the tree as a defense-in-depth stopgap — harmless now that the actual corruption source is fixed, since `playspeed`/`endframe` should no longer go out of range in the first place.
|
||
|
||
**Remaining open item for this family (not a regression, a distinct piece of work): the Dam end-cutscene camera should now correctly track Bond, but this needs a dedicated review, not just an assumption.** M-170's `field_488.pos` freeze-and-rebaseline-on-teleport-epoch (`bondview2.c:10599-10629`, unconditional, not env-gated) was built as a workaround for camera shake that we now know was itself downstream of this exact M-189 corruption (a corrupted/cycling render-skeleton position feeding the camera's look-at filter). With the real root cause fixed, `field_488.pos` should already be receiving correct, non-cycling data every tick — which may make M-170's freeze either redundant or, worse, actively wrong: freezing between teleports could now be suppressing *legitimate* per-shot camera tracking of Bond's real (correct) motion that the freeze was never designed to distinguish from the old corruption. **Next step:** live-review the Dam abseil cutscene with fresh eyes on whether the camera visibly tracks Bond through each of the 3 scripted shots (per the user's own N64 memory: "stands on the edge and then jumps... camera tracks him as he descends," M-169's note) — if it does, no further work needed; if the camera looks static/wrong-angle for any shot, M-170's freeze is the first thing to loosen or remove now that it's very possibly no longer needed.
|
||
|
||
### M-190 (2026-09-18, same session): live camera-tracking review confirmed M-189's prediction — M-170's freeze replaced with a filter re-seed; a second, unrelated mouse-lock bug found and fixed in the same pass
|
||
|
||
**User's live review confirmed the M-189 tail's prediction exactly: "the camera angles do not follow bond and dont fully match n64."** M-170's freeze (`bondview2.c:10599-10629`) was skipping every `field_488.pos` write during `CAMERAMODE_POSEND` except the one tick a teleport epoch changed — correct behavior when the input was corrupted garbage (pre-M-189), actively wrong now that it's legitimate per-tick motion, since freezing it means the look-at target never moves within a shot.
|
||
|
||
**Fix:** replaced the freeze with a write-every-tick-plus-instant-reseed scheme. `field_488.pos` is now written unconditionally every tick (restoring real per-tick tracking of Bond's motion within a shot). Separately, on the exact tick a teleport epoch changes (a real shot-change, same signal M-170 used), the look-at filter's leaky-integrator accumulator (`field_3B8`, consumed by the unmodified decomp function `bondviewUpdatePlayerCollisionPositionFields`, `bondview2.c:4910-4924`) is re-seeded directly to the new position's *pre-scale* value (`pos / FACTOR_2`, since `field_3C4/8/C = field_3B8 * FACTOR_2` is the filter's actual output) instead of being left to converge over ~20 ticks (M-143's original shake mechanism, confirmed PC-only vs N64 in M-144). `field_3C4/8/C` (what the camera's look-at code actually reads) are also set directly so the correct value is live even before the next filter tick runs. Entirely `#ifdef PORT`, port-layer only — the decomp filter function itself is untouched, this only seeds its accumulator field from the outside at a legitimate, decomp-recognized "reposition" event.
|
||
|
||
**Second, unrelated bug found and fixed in the same review pass: mouse-look was never gated during scripted/no-control camera states, letting the player freely move the view during the Dam intro flyover and the abseil-jump cutscene (both meant to be camera-locked).** Root cause: `port/src/input.c`'s `hipDirectCompute()` (the default-active WI-1 direct mouse-to-`vv_theta`/`vv_verta` write path, `mouseDirectLook=1`) had its own doc comment explicitly stating "a frozen or scripted-camera state (POSEND/INTRO cutscenes, death cam, pause) must be checked explicitly or this would fight the game's own camera control during those states" — but the actual gate list (`mouseDirectLook`/`mouseGrabbed`/`bonddead`/`outside_watch_menu`/`pause_state`) never implemented that documented check. `bondviewFrozenMoveBond` (`bondview2.c:7947`) already correctly zeroes stick input during these camera modes, but that gate lives entirely inside the stick-based `MoveBond`-vs-`bondviewFrozenMoveBond` dispatch (`bondview2.c:8268`) — `hipDirectCompute` writes `p->vv_theta`/`vv_verta` directly, bypassing that dispatch and the game's own camera-lock logic entirely. **Fix:** added the missing check, calling the same scripted-camera test D243's clamps already use. Renamed `d243mCutsceneActive()` → `gameScriptedCameraActive()` (`bondview2.c`) since the function was never D243-specific and is now used by two unrelated call sites (`model.c`'s clamps, `port/src/input.c`'s new gate) — behavior-identical rename, all three call sites updated.
|
||
|
||
Build clean (`./build-pc.sh ntsc-final`), `tools_pc/verify.sh dam` PASS. **User confirmed both fixes live, full Dam intro-flyover + abseil-jump + abseil-descent playthrough: "ok yeah looks good."**
|
||
|
||
### M-192 (2026-09-19): v0.3.0 release review — sign-offs, watchdog re-arm, repo hygiene, post-release cleanup list
|
||
|
||
Adversarial pre-ship review of the whole v0.3.0 window (147 commits since v0.2.2). Build verified clean (`./build-pc.sh ntsc-final`, 244/244 + link); `gen_findings_index.py --check` and `gen_env_probes.py --check` both pass. Outcomes, per user decisions in-thread:
|
||
|
||
1. **M-183/M-185 clamps (this section): RULE-2-SIGNOFF granted for RETENTION.** They are ungated behavior-modifying guards in `src/game/model.c` outside the ABI/layout exception; with M-189's root cause fixed and user-verified they no longer fire in practice, but removing them a day before ship would drop defense-in-depth across ALL cutscenes (verification so far covers Dam only). User approved retention as the stability-maximizing option. In-code comments updated (incl. fixing the stale "Threshold: 50.0" — code uses 10.0f). **Post-release: re-verify all scripted-camera cutscenes with the clamps compiled out; if clean, delete both blocks (reverts to pure decomp).**
|
||
2. **D311 heal clamps (`model.c`): RULE-2-SIGNOFF granted for RETENTION** — same decision, classified as defensive guards against non-finite state in the accepted D156 class (fire only on poison values; snap to valid frames; never touch legitimate animation). See the D311 entry.
|
||
3. **D318 watchdog re-arm (fixed this pass).** `port/src/d318watchdog.c` was one-shot per PROCESS: `s_fired` never reset, so a Facility replay later in the same session (AllUnlocked) hitting the same race would softlock with no recovery — the exact scenario the feature exists to prevent. Now one-shot per OCCURRENCE: after firing, the watchdog re-arms once the signature has been absent for `D318_REARM_TICKS` (600 ≈ 10 s) consecutive ticks; logs a `D318W: ... re-armed` line.
|
||
4. **GPL reference source removed from the public repo.** `reference/mouse-injector/` (GEPD-Edition Mouse Injector, GPLv2, incl. a prebuilt `discord-rpc.lib`) was committed inside the v0.3.0 window of an MIT project and would have shipped in the tag's auto-generated source tarball. Removed; a provenance stub README remains (`reference/mouse-injector/README.md`), the path is gitignored for local re-vendoring, and AGENTS.md / `docs/dev/GEPD-INPUT-PLAN.md` / `optionsoverlay.c` references were updated. The ported model lives in `port/src/input.c` (D194 lineage).
|
||
5. **`scratch/` untracked + gitignored.** 19 session logs / investigation scratch files (incl. a 1396-line regression log and probe `.c` sources) had been committed; removed from the index, `/scratch/` added to `.gitignore`. Local copies untouched.
|
||
6. **D310 perf gauge (no change needed).** v2's up-front `chraiGetAIListID()` in `chraiGoToLabel` adds ≤ (level-local + ~20 global) pointer comparisons per label jump, on a path that previously ran only in the rare EndList-not-found branch. Worst case for a heavy level (Train/Archives, ~50 active guards × 1–2 goto-family records/tick): ~1000 calls/tick × ~40 comparisons ≈ 40 µs/tick < 0.3% of a 16.7 ms frame — two orders of magnitude below D250's per-triangle `getenv` anti-pattern. Re-measure only if an AI-heavy level regresses.
|
||
7. **Post-release cleanup list (deferred by user):** (a) remove the TEMP diagnostic probes marked "remove once Dxxx concludes" whose investigations have ended (D236 passes 10/12/14/16 in `gfx_pc.cpp` + `GE_D236RAW` in `bg.c`, D288, D157, D252, D245V); (b) cache the few remaining per-call `getenv()`s on tick paths (bondview2 D243CAM ×3, chrai `AI_PLAYANIMATION` GE_D243M, sky.c water GE_D245_FIXEDSHIFT) per the D250 discipline; (c) delete M-183/M-185 if cutscene re-verification passes (item 1); (d) D317's latent heap-corruption class (39 dev-run crashes, pre-final-fix) stays on the radar — see its entry.
|
||
|
||
## D302 — Render FPS regression to ~40-45fps on release/v0.3.0 (user QA on the local v0.3.0 pre-release build, 2026-09-18).
|
||
|
||
**User reported drops to 40-45fps on a self-described high-end PC on a local v0.3.0 pre-release build**, after the port had previously held a rock-steady 60-61fps end-to-end (D250, fully resolved). Same symptom class and roughly the same fps range as D250's original regression.
|
||
|
||
**Root cause: a reintroduction of exactly D250's own bug class, this time in the still-open, uncommitted D236 (Surface1 tree-card) investigation.** Passes 10/12/14/16 of D236 added four `getenv()` calls directly inside `gfx_sp_tri1` (`port/fast3d/gfx_pc.cpp`) — `GE_D236RM`, `GE_D236ORDER`, `GE_D236ALPHA`, `GE_D236ZFIX` — every one of them called **uncached, unconditionally, on every single triangle draw**, exactly the anti-pattern D250 already root-caused and fixed everywhere else in this file (`getenv()`/`_getenv_helper_nolock` is a locked, locale-aware linear scan in the MSVC CRT; at triangle-call frequency it dominates the render thread). These four sites were added across several D236 sessions after D250 landed and were missed by D250's own sweep because they didn't exist yet at the time.
|
||
|
||
**Fix:** cached all four behind the same `static int x = -1; if (x < 0) x = getenv(...) != NULL;` pattern already used everywhere else in this file (e.g. `s_d252`, `ge_dtex`, `ge_d172_a`) — mechanical, zero behavior change when the env var is unset, matches D250's own established fix idiom exactly. `GE_D236RAW` (`src/game/bg.c`, room-load time, not per-frame) and `GE_WRAPFIX` (`gfx_pc.cpp`, a one-shot settings-setter, not per-frame) were checked and are not contributors — left as-is.
|
||
|
||
**Verified:** rebuilt clean; `GE_D193=1 -level_33` (Dam, the same level D250's own verification used) idles at a rock-steady `render=59-61 sim=60-67` with zero clamp hits, matching D250's post-fix baseline exactly (pre-fix D250 pinned this same level at 45-50fps).
|
||
|
||
**Process note for future D236 sessions:** any further temp diagnostic added to a per-triangle/per-vertex hot path in `gfx_pc.cpp` must use the cached-getenv pattern from the first line, not a raw `getenv()` call — this is now the second time an uncached hot-path `getenv()` has shipped in this file. Worth a lint/grep sweep (`grep -n 'if (getenv(' port/fast3d/*.cpp`) before any future release cut.
|
||
|
||
**Status:** FIXED, verified via `GE_D193` idle telemetry. Not yet re-confirmed by the user's own hands-on playtest of the updated build.
|
||
|
||
## D303 — Some muzzle flashes (M16 named specifically, "and some others") draw wrong — very long, straight up from the gun (user QA on the v0.3.0 pre-release build, 2026-09-18).
|
||
|
||
**Reported only, not yet investigated.** User playtest of the local v0.3.0 pre-release build: the muzzle flash on the M16 (and "some others," unspecified) draws visibly wrong — described as very long and extending straight up from the weapon, rather than the expected short flash at the muzzle. User found it hard to describe precisely; a screenshot/clip from a future session would sharpen this before diagnosis starts.
|
||
|
||
**Not yet looked at:** which code path renders muzzle flashes (likely a dynamically-built quad/billboard in `src/game`, similar family to the drop-shadow/flare-effect functions already surveyed during the unrelated D236 investigation — see that entry's pass 16 for the list of quad-building functions already read), whether this is a geometry/orientation bug (billboard not facing camera, or built in the wrong basis — "goes straight up" suggests a billboard axis or transform mixup) versus a render-mode/texture-stretch bug, and whether it's PC-port-specific or was already present pre-port (no N64 comparison done yet).
|
||
|
||
**Repro clarified, same day (follow-up playtest):** the normal, correct muzzle flash still draws — this is an **extra**, erroneous long flash overlaid on top of it, not a corruption/replacement of the one correct effect. This significantly narrows the search: two draw calls are happening where one is expected, one correct and one wrong. Points more strongly at an extra/duplicate quad-builder call site or a second render-mode pass firing unconditionally alongside the correct one (e.g. a "flare arms"-style secondary effect, per the pass-16 D236 survey's `dorottex` muzzle-flash-flare-arms function, distinct from the main flash builder) than at a single corrupted transform.
|
||
|
||
**Investigation (2026-07-19):**
|
||
|
||
- **Flash pipeline mapped.** `gunUpdateAndFire()` (`gunfire.c`): per shot it builds `flashmtx` = random yaw (bitflag 0x1) × scale `flashscale`∈[1.0,1.25] × `MuzzleFlashExtension` on the Z-row (`matrix_column_3_scalar_multiply(flashext, flashmtx.m[0])`), multiplies into `gunmtx`, copies to `rwmtx[1]`; a second matrix `rwmtx[2]` is built from `mdlhdr->Switches[2]` (the flash-anchor node) with the same scale/extension. The model-side flare arms are the `MODELNODE_OPCODE_DLPRIMARY` nodes rendered by `dorottex()` (`model.c`) — 4-vertex shuffling with per-frame random rotation/scale. **All byte-identical game code; the `matrixmath.c` helpers verified index-for-index.**
|
||
- **Data ruled out (two layers).** (a) `MuzzleFlashExtension`: M16 = 6.0, the max in the set (sniperrifle also 6.0; others 1.0–4.0) — a legitimate per-weapon stat from the decompiled `gunWeaponStat.inc.c`, not a misread. All ext≥3 weapons happen to be `CAN_DUAL_WIELD` but so are many short-flash ones, so that's design coincidence, not a lead. (b) Model conversion: Gm16Z's `DisplayListPrimaryRecord` words + vertex blocks are byte-faithful through `d43_emit.py` (numVertices bswap, 16B vertex fields verbatim, GDL pointers remapped; round-trip validated per M-197).
|
||
- **Live capture blocked headless.** Added a TEMP env-gated probe in `port/fast3d/gfx_pc.cpp` (`GE_D303=1`, cached-getenv pattern): logs every emitted triangle whose NDC bbox is very tall + thin (h>45% frame, aspect>3) reaching GL, with blend/oml/comb/prim/env/w. Ran `-level_09/-level_05/-level_17` unattended 75s each: **only static scene geometry** (identical across frames — pillars; Bunker1 is full of tall thin quads). Guards do not fire at an idle player, so the artifact cannot be reproduced without live input.
|
||
- **Next step (playtest-gated):** user runs with `GE_D303=1`, fires the M16 until the artifact appears, captures the log lines around that moment. The probe prints the RDP state of every candidate quad — if the spurious flash is a distinct draw call its comb/oml/prim/w will differ from the correct one and point straight at the source (model node vs rwmtx slot vs fast3d pass). Fallback suspects if the log shows only ONE tall quad: fast3d handling of the flash texture's S/T wrap, or `rwmtx[1]/[2]` being consumed by the wrong model node.
|
||
|
||
**Status:** OPEN — investigated (pipeline + data ruled out), GE_D303 capture probe in-tree, blocked on a live firing repro.
|
||
|
||
**M-191 (2026-09-18, user QA on the v0.3.0 pre-release build): REOPENING the "main fireball" thread — the fresh report matches exactly the condition M-188 said to watch for.** User describes smoke/explosion effects as now looking correct overall, EXCEPT a "weird rainbow confetti looking sprite" that still draws overlaid on top of otherwise-correct smoke/explosion particles, sometimes. M-188 explicitly closed only the "lingering particle residue" manifestation (which turned out to be `s_bullet_spark`/D219, unrelated code) and left this exact scenario — a rainbow artifact riding on the actual explosion/smoke effect itself — open, with the instruction "re-open only if a fireball (not residue/embers) rainbow report comes in again." This report fits that description, not the residue/embers one. **Not yet investigated further this session** — the last concrete lead (before M-188's tangent into D219) was: trace forward from `import_texture_rgba16`'s `upload_texture()` call (`gfx_pc.cpp:718`) through the GL texture object and the `G_CC_INTERFERENCE` (TEXEL0=smoke IA × TEXEL1=fire RGBA16) combiner, since the source ROM data itself was already proven correct by hand-decoding all 14 fire-stage textures (M-156, see above) — the bug is downstream of decode, in upload/cache/combiner. A live `GE_PCDUMP` + `GE_D252=1` capture during an actual reproduction (shoot something that smokes/explodes) is still the next concrete step, unchanged from M-156's own "next, concrete" recommendation.
|
||
|
||
**Status: REOPENED (was incorrectly treated as fully covered by the D219 closure) — OPEN, root cause still unconfirmed for this specific manifestation.**
|
||
|
||
**M-201 (2026-09-26, v0.4.0): ROOT CAUSE FOUND + FIXED — the offline model converter copied only a quarter of every star muzzle flash's vertices.**
|
||
|
||
- **Confirmed port bug:** user side-by-side with 1964: the N64 M16/AR33 shows a normal star/burst at the muzzle; PC draws a long flame shooting up. D220 (flash "rises at high FOV") merged here: FOV-independent.
|
||
- **Headless repro:** new TEMP `GE_STARTWEAPON=13` (M16, `bondview_r.c`, adds 400 ammo) + `GE_INPUTSCRIPT` `ZHOLD` on Dam: every flash frame shows the upward spike (`scratch/d303_sheet.png`).
|
||
- **Ruled out by dump:** gun model bindings (group 0 body mtx 0, group 1 muzzle mtx 1 = `Switches[3]`, group 2 billboard mtx 2 = `Switches[2]`) and both flash matrices (`rw1` Z-stretched ×6 = `MuzzleFlashExtension`, as PD's `muzzlez`) are as designed; every matrix helper matches PD (`mtx00015ea8` = column_3, `mtx00016710` = row_3, `mtx00016e98` = axis-angle).
|
||
- **Cause:** op-22 `DisplayListPrimaryRecord` (PD `modelrodata_stargunfire`) stores the number of flare **arms**; `dorottex()` (`model.c`) and PD's `modelRenderNodeStarGunfire` read `count*4` vertices (`src += 4` per arm). `tools_pc/d43_emit.py` (and its `d43_convert.py` twin) sized the vertex region as `16*nv`, copying only `nv` vertices. The M16 star (6 arms, 24 vertices) kept vertices 0–5; 6–23 came from whatever followed in the PC sidecar (dump: `src[6]=(4,0,0) st=(-28648,28709)`, `src[7].x=-28960`), rendering as long spikes on top of the real arms.
|
||
- **Fix:** size op-22 vertex regions as `16*4*nv` in both tools. `d43_emit.py ntsc-final` re-run: ALL CHECKS PASSED (round-trip field validation, region tiling, buffer bounds); 512 sidecars regenerated. After: `src[6..7]=(289,±20)` real arm vertices; the M16 renders a clean 6-arm star (`scratch/d303_after.png`). Affects every star-flash weapon. Golden gate 3/3.
|
||
- **Note:** `data/pcmodels-*` is generated (gitignored). Existing installs must re-run `python3 tools_pc/d43_emit.py <region>`; bundles run the shipped emitter.
|
||
|
||
**Status: FIXED (M-201, user-verified 2026-09-27).**
|
||
|
||
## D304 — Mouse sensitivity menu overhaul (user request, 2026-09-18): aim speed/turn speed sliders weren't "1:1" and had too little range.
|
||
|
||
**User request, immediately after cutting the v0.3.0 Deck pre-release build:** review the mouse sensitivity configs against "the new logic" (D194/D300's GEPD-direct-write input model) and overhaul the F10 menu — specifically, "aim speed/turn speed are not 1:1 in the menu" and players need "more range for mouse sensitivity/settings," referencing the fact that GE's whole aim/hipfire model was ported from the GEPD Mouse Injector (`reference/mouse-injector/`, our D194 mirror), which itself mirrors Perfect Dark's own input scheme.
|
||
|
||
**Investigation: read the reference implementation directly (`reference/mouse-injector/games/goldeneye.c`/`perfectdark.c`) rather than guessing.** Both games' injector code use exactly ONE user-facing `SETTINGS[SENSITIVITY]` value (raw range 0-100, displayed as `raw*5`%) fed into two different per-mode formulas with different, fixed, intentional divisor constants: hipfire `sensitivity = SENS/40`, aim-mode crosshair `sensitivity = SENS/292` (roughly; GE's own `games/goldeneye.c` uses 292, confirmed against our own `aimGepdCompute`'s doc comment "GepdSens... default 20 ~= 1:1... SENS/292"). This is the ground truth "PD/GEPD-reference" model the user meant: a *single* raw-SENS unit passed through per-mode, not two independently-scaled percentages.
|
||
|
||
**Root cause of the "not 1:1" complaint, found in `port/src/optionsoverlay.c`, not `input.c`:** the two F10 rows for `Input.AimModeSens` and `Input.MouseTurnSpeed` are mathematically already the same underlying "reference SENS passthrough" unit (confirmed: `hipDirectCompute`'s `sens = mouseTurnSpeed/100 * mouseSensitivity/100` and the `dxPx*0.1*sens*scale` line is bit-for-bit GEPD's `XPOS/10*(SENS/40)` when `mouseTurnSpeed` == GEPD's raw `SENS`; `aimGepdCompute`'s `sens = gepdSens/2920.0 * mouseSensitivity/100` is likewise GEPD's `XPOS/10*(SENS/292)` with `2920 = 292*10`) — but the **F10 overlay row definitions hardcode two different, mismatched, and in one case outright wrong display/edit ranges**: `Input.AimModeSens` row = `(uiMin=1, uiMax=80)` (matches its own config clamp), `Input.MouseTurnSpeed` row = `(uiMin=0, uiMax=100)` — which does **not** match that key's actual registered config clamp of `(1, 500)` (`input.c`: `configRegisterInt("Input.MouseTurnSpeed", &mouseTurnSpeed, 1, 500)`). The overlay's own `rowLo()`/`rowHi()` prefer the row's explicit `uiMin`/`uiMax` over the real config clamp whenever they differ (`(r->uiMin != r->uiMax) ? r->uiMin : r->cfgMin`), so the overlay was silently overriding the wider, correct config clamp down to an arbitrary, undocumented `0..100` window — a menu-layer bug, not a formula bug. This directly explains both complaints: the two sliders showed different, arbitrary ceilings (80 vs 100) despite being the same unit, and `MouseTurnSpeed`'s true available range (up to 500) was invisible/unreachable from the menu.
|
||
|
||
**Fix (`port/src/input.c`, `port/src/optionsoverlay.c`, mechanical, no formula/feel change):**
|
||
1. Widened `Input.AimModeSens`/`Input.GepdSens` (deprecated alias, same backing variable) config clamp from `(1, 80)` to `(1, 500)`, matching `Input.MouseTurnSpeed` exactly.
|
||
2. Both overlay rows switched from hardcoded, independently-drifting `uiMin`/`uiMax` numbers to the `(0, 0)` "inherit the registered config clamp" sentinel the struct already supports (`uiMin, uiMax; /* 0,0 -> use the registered clamp */`) — single source of truth in `input.c`'s `configRegisterInt` calls, so the two sliders can never silently diverge again the way `MouseTurnSpeed`'s did. Step raised `1 -> 5` on both (a full-range keyboard/gamepad traverse at step=1 over a 500-wide range would need ~500 presses; mouse-drag is already continuous/proportional via `sliderSetFromX`, unaffected by `step`).
|
||
3. Surfaced `Input.MouseSensitivity` (the existing D238 master multiplier — already clamped `1..500` in `input.c`, but had **zero** menu row, reachable only by hand-editing `ge007.ini`) as a new "Mouse sensitivity" overlay row, same `(0,0)`-inherited range/step convention as the other two.
|
||
|
||
**Explicitly NOT changed:** the underlying `hipDirectCompute`/`aimGepdCompute` formulas, the `Input.SensLink` ratio-lock logic (`38:50`, ratio-based so range-widening doesn't affect it), or any default value (`AimModeSens=38`, `MouseTurnSpeed=50`, `MouseSensitivity=100` all unchanged) — this is a menu/range-parity fix, not a re-tune of the D194/D238/M-123-calibrated default feel.
|
||
|
||
**Verified:** clean Windows rebuild (`./build-pc.sh` equivalent, only pre-existing unrelated warnings). Headless `GE_OPTIONSOVERLAY=1` smoke test opens the overlay with the new row with no crash/assert. Manual `ge007.ini` round-trip test: setting `AimModeSens`/`GepdSens`/`MouseTurnSpeed` to `300` in a fresh ini and relaunching confirms all three now survive (previously `AimModeSens` would have clamped to 80 on load). **Not yet human-playtested** — no one has actually opened the F10 menu on this build and dragged the new/widened sliders; the numeric/config-level verification above is real but the in-game feel and layout (label width, new row's position in the scroll list) are unconfirmed.
|
||
|
||
**Status:** FIXED, config/build-level verified. Owed: a live F10-menu playtest (does "Mouse sensitivity" fit/scroll correctly, do the widened sliders feel right in-game at both ends of the new range).
|
||
|
||
**Follow-up, same day (user tried the widened sliders live, two new pieces of feedback):**
|
||
|
||
1. **"Is it possible to make a single mouse sensitivity and not the two values that I'm afraid a user might decouple and then set bad settings."** Agreed this is a real risk the range/parity fix above didn't address — widening both per-mode sliders independently makes it *easier*, not harder, for a player to land on a confusing decoupled state (e.g. aim mode crawling while hipfire is very fast) with nothing in the UI indicating anything is wrong, since `Input.SensLink`'s ratio-lock is an opt-in toggle a player has to know to leave on. **Fix:** pulled `Input.AimModeSens`, `Input.MouseTurnSpeed`, and `Input.SensLink` from the F10 menu entirely (commented out in `rows[]`, matching the existing pulled-row pattern used for D181/`Game.ScreenShakeIntensity` and D216/`Game.SkipIntro`) — only the single `Input.MouseSensitivity` master row remains player-facing. This is not a workaround but the literal reference model: `reference/mouse-injector/games/goldeneye.c`'s own GEPD/PD injector design uses exactly ONE user-facing `SETTINGS[SENSITIVITY]` value, with the per-mode divisor constants (40 for hipfire, 292 for aim) fixed and never independently exposed to the player — which is what "we brought over input like that" (the user's own framing from the original ask) actually implies structurally, not just numerically. The two per-mode config vars, their config registration, and the `SensLink` linking code in `rowSet()` are untouched (`input.c` unchanged from the first pass) — existing `ge007.ini` files with either key keep working, ini power users can still hand-edit them; only the *menu surface* shrank.
|
||
|
||
2. **"F10 menu is a bit weird when you try to use the mouse to set a mouse sensitivity, it keeps jumping to the setting below when I left click."** Found the real, pre-existing mechanism (not new, not caused by this session's other changes, just newly exposed because the sensitivity rows happened to sit mid-list where it's most noticeable): `overlayUpdateScroll()` (`port/src/optionsoverlay.c`) unconditionally set `s_scroll = s_sel - (maxV - 1)` on every call — i.e. it always forced the just-selected row to the very LAST visible slot, regardless of whether that row was already fully visible somewhere else in the window. Since this runs immediately after every left-click's `s_sel = hoverVis`, clicking any row that wasn't already sitting in the bottom slot forced an immediate re-scroll of the whole list for that same frame — shifting every row's on-screen Y position, so whatever the user saw or clicked next at that same screen coordinate was a *different* (typically the next, i.e. "below") row. Reproducible on any click into the (22-row, scrolls past ~12 visible) list at any position other than the last visible slot. **Fix:** rewrote `overlayUpdateScroll()` to the standard minimal-scroll list behavior — only move the scroll window when the selection is actually above the top (`s_sel < s_scroll`) or below the bottom (`s_sel > s_scroll + maxV - 1`) of the currently visible range, and then only by the amount needed to bring it back into view. A click on an already-visible row is now a true no-op for scroll; the window only moves for genuinely off-screen selections (arrow-key/gamepad navigation past an edge, or scroll-wheel).
|
||
|
||
**Verified (both parts):** clean Windows rebuild after each change (only pre-existing unrelated warnings — the same `implicit declaration of lround`/`atoi` warnings this file has always had, `<math.h>` was never included despite using `lround` throughout; not introduced by this session). Headless `GE_OPTIONSOVERLAY=1` smoke test opens cleanly post-fix with no DL-overflow log line and no crash. **Not yet live-verified** — no one has clicked through the actual collapsed menu with a mouse yet to confirm the jump is gone and the single sensitivity row behaves as expected; that's the next concrete step, not more static review.
|
||
|
||
**Second follow-up, same day (user report right after the collapsed-menu build): "i clicked unlock all and I think it crashed."** Not actually a crash — the process's own log shows a clean `[INFO] optionsoverlay: quit to desktop requested` / exit code 0 immediately after the click, meaning **`__QuitToDesktop`'s `ROW_ACTION` fired**, not `Game.AllUnlocked`'s toggle. Root-caused by re-reading `overlayRowAtY` (the mouse hit-test) against the draw loop it's supposed to mirror: `overlayRowAtY` looped over **all** `s_visN` visible-list entries regardless of which ones are actually scrolled into view and drawn this frame, while the draw loop correctly clips to `[s_scroll, pLast]`. Since `OV_ROW_Y(p - s_scroll)` is a plain linear function of `p`, the row immediately after the last visibly-drawn one still produces a geometrically valid, in-range Y band just below the panel's real bottom edge — so a click near that boundary (real-window-pixel-to-virtual-2D-space scaling in `ox`/`oy`, or simply a slightly-low click on the last visible row) could resolve to the next, invisible, scrolled-past row instead of the one actually drawn there. `Game.AllUnlocked` sits second-to-last in the row list, immediately before `__QuitToDesktop` — exactly the row pair this class of bug hits, and exactly what the user reproduced.
|
||
|
||
**Fix:** `overlayRowAtY` now computes the same `pLast` the draw loop uses and bounds its scan to `[s_scroll, pLast]` instead of `[0, s_visN)`. **Found and fixed a second, related bug in passing while doing this:** the "everything fits, no scroll needed" branch of `pLast`'s formula (both in the draw path and now shared by the hit-test) was `s_visN - s_scroll` — a **count**, not a valid last-index — which since that branch only runs when `s_scroll==0` evaluates to `s_visN`, one past the end of `s_visIdx[]`/`rows[]`. This is a latent out-of-bounds read that didn't trigger in this session's in-level (320x240, always-scrolled with 19 rows) testing, but would on the larger front-end 440x330 screen once enough rows fit without scrolling — fixed to `s_visN - 1` in both places.
|
||
|
||
**Verified:** clean rebuild; headless `GE_OPTIONSOVERLAY=1` smoke test opens cleanly, no DL-overflow, no crash. **Not yet live-reproduced-then-fixed** — the mechanism is confirmed by code reading against the exact log evidence (quit fired, not a crash), not by a live click-repro-then-retest cycle; next playtest should specifically re-try clicking "All unlocked" (and any other row adjacent to a scroll boundary) to confirm the right row now responds.
|
||
|
||
**Third follow-up, same day: a genuinely new, concrete root-cause candidate found and fixed, user-authorized.** A fresh read of `explosion.c`'s actual particle-render code (looking for a headless repro path, not expecting to find the bug itself) turned up a real out-of-bounds write that six prior investigation sessions had not spotted: `explosionRenderPart()` (`src/game/explosion.c`, ~line 990) calls `vertices = dynAllocateVertices(4);` — reserving exactly 4 `Vtx` slots from `dyn.c`'s shared bump allocator (`g_GfxMemPos += count * sizeof(Vtx)`, no bounds checking, no per-call isolation) — then writes **five** default-init copies: `vertices[0]` through `vertices[4]`. Confirmed directly (not just agent-reported): the function's own GBI emission a few lines later is `gSPVertex(gdl++, osVirtualToPhysical(vertices), 4, 0);` / `gSP2Triangles(gdl++, 0, 1, 2, 0, 0, 2, 3, 0);` — only ever consumes indices 0-3. `vertices[4]` is never read again inside this function; the write is provably dead for this quad's own drawn output and exists only to corrupt one `Vtx`-worth of memory (16 bytes) at the START of whatever the *next* `dynAllocate*` call in the same frame claims from the shared pool — plausibly another particle's own vertex data in a busy explosion scene, which fits the "hue drifts/scrambles as more particles render" symptom shape far better than anything the source-texture or texture-cache investigations found. Checked every other `dynAllocateVertices(4)` call site for the same pattern (`glass2.c`'s `bullet_spark_render`, the already-D219-fixed sibling, and `explosion.c`'s own `smokeRenderPart`) — both correctly write only 4 vertices; this is an isolated, one-function bug, not a systemic pattern.
|
||
|
||
**Process note:** this is decomp-verified original code (`Vtx` has no pointer members, so `sizeof(Vtx)` is identical on 32-bit N64 and 64-bit PC — this is not a struct-widening/ABI-class bug like D219's), so it doesn't fit AGENTS.md's narrow pointer-width exception on paper, and per `docs/dev-process.md` §7 would normally need the full rule-2 sign-off procedure (proof of N64-vs-decomp divergence) before any `src/game` edit. Presented the exact mechanism above to the user (project owner) in-thread — a write provably dead for this function's own draw, whose only effect is corrupting unrelated adjacent pool memory — and the user explicitly chose "just fix it" over the alternatives (live-capture-first, or skip) when asked directly. Recording that as the authorization in lieu of the full N64-reference-divergence proof, per rule-2's own "the user reviews... and replies with an explicit go-ahead" grant path.
|
||
|
||
**Fix:** deleted the stray `vertices[4] = spA0;` line (`src/game/explosion.c`), no `#ifdef PORT` needed — removing a provably-dead-for-this-draw out-of-bounds write is safe under both build targets, and the surrounding code has no existing N64/PORT fork at this exact point.
|
||
|
||
**Verified:** clean rebuild (only pre-existing, unrelated warnings elsewhere in the same file — uninitialized-variable and pointer-cast warnings that predate this change). Headless smoke boot (`-level_09`, 400+ frames) crash-free. **Not visually verified against a live explosion** — this session's separate scripted-repro research found no reliable headless path to a real explosion (needs cycling to an explosive weapon + firing, level/loadout-dependent, never confirmed working; the cheaper "shoot a wall" repro that worked for D219 exercises `glass2.c`'s already-fixed bullet-spark path, not this `explosion.c` function). Do not claim the rainbow is gone until a human (or a future scripted-repro session) actually sees it fail to reproduce on a real explosion — this fix is build-verified and provably behavior-neutral for the drawn quad, not yet visually confirmed as the actual fix for what the user sees on screen.
|
||
|
||
**Also newly identified this session, NOT investigated or fixed — logged for a future live-capture session:** the GL texture upload/bind/combiner path was independently audited end-to-end (texture-unit binding, `gfx_texture_cache_lookup`, shader-cache keying) and came back clean except for one concrete, previously-undocumented lead: `rdp.loaded_texture[512]` (`port/fast3d/gfx_pc.cpp:194`) is indexed purely by **TMEM slot number**, not by texture/draw identity, and feeds a D229-added reroute (`gfx_pc.cpp:~1250-1264`) that redirects an `RGBA16`-declared tile through the CI8 palette-index decoder if `loaded_texture.src_fmt` was left over as `CI` from a *different*, unrelated draw that previously loaded into the same TMEM slot. This would produce literal per-pixel confetti (raw RGBA5551 bytes reinterpreted as palette indices) and would explain the bug's flakiness (only fires if some unrelated CI-format draw happens to land on the same TMEM slot in between fire-tile loads) — but was never caught actually firing on the fire tile in this session's static read. Next step for whoever picks this up: a targeted probe logging `loaded_texture.src_fmt` and whether the CI8-reroute branch is taken, gated on the fire tile's known signature (`size_bytes==448`, `tmem==0x188`), during a live repro.
|
||
|
||
## D305 — Steam Deck SIGSEGV on the v0.3.0 pre-release build (Cavern, early in level, `Game.AllUnlocked` previously enabled) — a D285 recurrence past its own fix.
|
||
|
||
**User report (2026-09-18):** crash on Cavern, early in the level, on the local v0.3.0 pre-release build this session deployed to the Deck (`~/Downloads/goldeneye-pc-port-0.3.0-deck-playtest/`). User's own hypothesis: likely related to `Game.AllUnlocked` (the F10 "unlock everything" toggle, D257), which had been previously enabled on that session/save.
|
||
|
||
**Fetched `ge007.crash.log` via SSH from the Deck (192.168.69.18) and symbolized it against the exact deployed binary** (confirmed identical build via matching ELF BuildID `d6ec0681d2e82a656c7a099f66264d6bfa4480b7` between the Deck's copy and this session's local `build-linux/ge007.x86_64` — the SHA256 differs only because `bundle`-style `patchelf --set-rpath` modifies the file after linking, not because the code differs):
|
||
|
||
```
|
||
SIGNAL: 11 FAULT ADDR: 0x62
|
||
#00 sndHandleEvent src/snd.c:379 <- fault site
|
||
#01 sndPlayerVoiceHandler src/snd.c:253
|
||
#02 _timeToSamplesNoRound src/libultra/audio/synthesizer.c:330
|
||
#03 amHandleFrameMessage src/audi.c:551
|
||
#04 amMain src/audi.c:452
|
||
#05 portThreadWrapper port/src/libultra.c:387
|
||
```
|
||
|
||
**This is the exact same FAULT ADDR (0x62) and the exact same fault site as D285** ("M-140: a real Steam Deck SIGSEGV... an unprotected shared-list read in `sndHandleEvent`'s preempt-scan"). D285 was root-caused, fixed (M-141: wrapped the scan in `osSetIntMask(OS_IM_NONE)`/restore, matching the paired writer `sndSetupSound`'s existing lock), and **live-verified crash-free on real Steam Deck hardware** under a sustained dual-grenade-launcher stress test (v0.2.2 hotfix, 2026-09-16). That fix is confirmed present in the current tree (`src/snd.c`, the `d285Mask` lock around the scan). **Getting the identical crash again, past that fix, disproves D285's "unprotected concurrent mutation" theory as the sole or complete cause.**
|
||
|
||
**Root cause, confirmed by direct disassembly (not just a source read):**
|
||
```c
|
||
ALSoundState *iterState = (ALSoundState *) D_800243E4.node.prev; // NULL if the list is empty
|
||
do {
|
||
s32 ownerlessLoop = (iterState->unk3e & ...) ... // src/snd.c:379 -- unconditional dereference
|
||
...
|
||
iterState = (ALSoundState *) iterState->link.prev;
|
||
} while (limitReached && iterState != NULL); // NULL check only AFTER the body already ran
|
||
```
|
||
`objdump` of the exact crashing binary at the fault PC confirms `mov r13, [D_800243E4+8]` (loading `iterState`) immediately followed by `movzx eax, [r13+0x62]` — and FAULT ADDR `0x62` with no base-register offset implied means `r13` (`iterState`) was **exactly NULL** at the crash (`0x0 + 0x62 = 0x62`). `D_800243E4 = { {NULL, NULL}, NULL }` is the sentinel head of the active-`ALSoundState` list, whose `node.prev`/`node.next` genuinely are `NULL` whenever the list is empty (not a self-referencing circular sentinel) — this do-while's `!= NULL` check runs only *after* the body's first (unconditional) dereference, per D285's own original write-up, which correctly identified this shape but attributed the *cause* of hitting it to a cross-thread race that the M-141 lock has since closed. **D285's lock prevents another thread from emptying the list mid-scan; it does nothing if the list is already empty (single-threaded, no race involved) the moment this scan starts** — which is exactly what still happened here.
|
||
|
||
**Not proven: why the list is empty while `limitReached` (pool-exhausted) is true.** The scan only runs when all 8 voices are believed allocated, which should imply at least one live tracked node in this same list — a genuine accounting desync between the voice pool and this list, mechanism unidentified. **`Game.AllUnlocked` correlation is reported, not confirmed** — plausible (more cheat-unlocked content/audio triggered, or an early-level audio-init-ordering interaction with the toggle) but equally plausible this is coincidental timing (early-level moments naturally have fewer/no ambient sounds queued, making an empty list more likely to be sampled at exactly the wrong instant regardless of AllUnlocked). Do not treat AllUnlocked as a confirmed trigger without a repro that isolates it.
|
||
|
||
**Fix (defensive guard, same class/precedent as D255's "skip-and-log instead of dereferencing a NULL model"):** added `if (iterState != NULL)` immediately before the `do { ... } while (...)`, so an empty list at scan-start is a silent no-op (the scan simply finds nothing to preempt) instead of a crash — zero behavior change for the normal (non-empty) case, since the loop's own body and exit condition are untouched. Added an opt-in diagnostic (`GE_D305=1`, `fprintf(stderr, ...)`) logging exactly this desync condition if it fires again, to gather more data on the AllUnlocked correlation and the desync mechanism without needing another crash to learn from.
|
||
|
||
**Process note:** like D252's `explosionRenderPart` fix earlier this session, this touches decomp-verified `src/game/snd.c` and isn't a pointer-width/ABI-class change. Unlike that fix, this one is squarely the same shape as the already-applied, unchallenged D255 precedent (a defensive skip-guard against a state that should never occur, added without a separate rule-2 procedure) — proceeding on that precedent rather than re-requesting sign-off for an identically-shaped fix.
|
||
|
||
**Verified:** clean rebuild (Windows); pre-existing unrelated warnings only (a `D_800243E4`/`ALEventQueue` aliasing quirk in a different function, `sndCountAllocList`, flagged by `-Warray-bounds` — not touched by this fix). Headless `-level_09` smoke boot, 400+ frames, crash-free.
|
||
|
||
**Live-verified on the Deck, same day.** Rebuilt via WSL Ubuntu-24.04 (same toolchain as the original deployment — confirmed `GLIBC_2.38` max symbol requirement, matching SteamOS), re-bundled (SDL2 + `patchelf` rpath), and pushed just the binary + bundled SDL2 to `~/Downloads/goldeneye-pc-port-0.3.0-deck-playtest/` over SSH/SCP — the ROM, asset sidecars, and the user's existing save/config (`ge007.ini`/`ge007.eep`) were left untouched (confirmed unchanged timestamps post-deploy). **User ran two Cavern test sessions, plus toggled `Game.AllUnlocked` off and back on and relaunched — no crash in any of them.** This isn't proof the underlying pool/list desync mechanism can never recur (it's still not root-caused, and the `GE_D305=1` diagnostic added alongside the fix has not fired in any of these sessions to confirm or deny the desync actually happens under normal play) — but the crash itself, which was 100% reproducible on the prior build under this exact repro, is gone.
|
||
|
||
**Visually confirmed, same day (Windows only — see the immediate follow-up below for the Deck residual).** User tested live on Jungle (`-level_37`) after the `explosionRenderPart` fix: "Rainbow went away." This closes a real, distinct, 6+-session-old bug via a fix found by direct code reading (a provably-dead out-of-bounds write into the shared vertex pool), not the live-capture-and-trace-forward approach every prior session's own "next step" had called for — genuine progress, even though (per below) it was not the whole story.
|
||
|
||
**Follow-up, same day: reopened as PARTIAL, not fully closed.** Immediately after the Windows "Rainbow went away" confirmation, the user separately tested Jungle on their Steam Deck (the same build, redeployed post-D305) and reported the rainbow can still occur there, describing it as "possibly intermittent." This does not contradict the `explosionRenderPart` fix being real and correct — that specific 4-vs-5-vertex overrun is confirmed gone by direct code proof, not just the one Windows observation — but it means **at least one more, still-unidentified contributing mechanism exists**, likely platform-dependent given this exact bug family's own prior history (M-158, `## D252` above: "0/6 runs on the dev box, but seen on Steam Deck and elsewhere... currently non-reproducible here and flaky/environment-dependent"). The session's own unconfirmed secondary lead — a stale `rdp.loaded_texture[].src_fmt` value surviving across unrelated draws that share a TMEM slot, feeding a D229-added CI8 palette-index reroute (`port/fast3d/gfx_pc.cpp` ~line 1250) — remains the best-named candidate: its trigger condition (another draw's format landing in the same TMEM slot in between fire-tile loads) is inherently timing/scheduling-dependent, which fits both "intermittent" and "platform-dependent" better than a deterministic memory-layout bug like the one just fixed would. **Not investigated further this session** — next step is the same one logged earlier: a targeted probe on the fire tile's exact signature (`size_bytes==448`, `tmem==0x188`) during a live Deck repro, this time with a concrete "does it actually recur" signal to chase instead of a cold start.
|
||
|
||
**User decision (2026-09-18): ship v0.3.0 with this as a known issue** ("ship as known issue, intermittent") rather than delay the release to chase the Deck-specific residual now. Release notes should describe this accurately: the dominant/deterministic rainbow-particle bug is fixed, but an intermittent, Linux/Deck-leaning residual is known to remain — not "fully fixed," not "still broken," a real partial improvement.
|
||
|
||
**Status:** PARTIAL — real fix landed and confirmed (Windows), residual known and deferred past v0.3.0 by explicit user decision, not by default/oversight.
|
||
|
||
**Correction, same day: the residual is NOT Deck/Linux-specific — it's intermittent on Windows too.** User follow-up testing: "The rainbow explosion particles is still intermittent on PC sometimes, I can reproduce on Jungle for example but it does not always occur." Repro method: `Game.AllUnlocked` + cheats to get the grenade launcher immediately, rather than earning it through normal play. **Also reports the same repro method does NOT reproduce the bug on every level** — i.e. it's both run-to-run intermittent on a single level AND level-dependent, not just platform-dependent as the previous paragraph assumed. This walks back the "Windows-fixed/Deck-residual" framing from the paragraph above — the correct framing is: **the `explosionRenderPart` 4-vs-5-vertex fix is real and eliminated one genuine, deterministic contributing bug, but the rainbow symptom itself was never fully platform-isolated to begin with, and a second, still-unidentified mechanism can fire intermittently on any platform.** The user notes `Game.AllUnlocked`+cheat-immediate-grenade-launcher as the repro method used, flagged as a possible (not confirmed) contributing factor — matches this session's own prior TMEM-slot-reuse lead's inherent timing-dependence (a scene reached quickly via cheats, before the game's own normal object/texture allocation order would occur, could plausibly produce a different TMEM allocation pattern than reaching the same scene through normal play). Not confirmed causal; logged as a correlation worth checking in a future live-capture session, same as the AllUnlocked/D305 correlation was.
|
||
|
||
**Status, corrected:** PARTIAL — `explosionRenderPart` fix is real, verified, and stays in. The rainbow symptom is intermittent and not cleanly platform-isolated (seen on both Windows and Steam Deck); the secondary mechanism remains unidentified. Public docs (release notes, README) should describe this as "still occasionally intermittent, on any platform," not "fixed on Windows, only recurs on Deck."
|
||
|
||
## D306 — Dam intro/truck wheels render with odd transparent-looking areas, possibly z-fighting (user QA, 2026-09-18).
|
||
|
||
**Reported only, not investigated, explicitly not for v0.3.0.** User playtest: the truck's wheels on Dam (seen during the level intro) render oddly, showing what look like transparent areas where they shouldn't be — user's own suspicion is z-fighting. No code read done. May be an instance of the broader z-fighting issue logged separately as D308, or a distinct texture/alpha bug specific to this prop — not distinguished yet.
|
||
|
||
**Investigation (2026-07-19, with D308):** see §D308's investigation block — the two were worked together. The `GE_ZF` flicker probe confirms **real, persistent depth-fighting in the Dam intro's lower-left screen region** (the truck area) — cells there accrue oscillation counts to ~1000 over a 40 s unattended run, i.e. near-equal depths genuinely alternating frame-to-frame, not tessellation noise. The "transparent-looking areas" are consistent with the wheel geometry losing the depth test against coincident surfaces in alternating frames.
|
||
|
||
**Status:** OPEN — confirmed as real z-fighting by probe; port-vs-faithful question open (see D308).
|
||
|
||
**2026-09-26 (v0.4.0, M-201): D291 is this same bug; FOV ruled out.** User captured the D291 "Dam truck" glitch with F12: it happens only on the Dam level intro (also the attract demo), in the shot where the truck drives over the low walkway camera (~frames 280–440 from `-level_33` boot). Wheels and tyre treads draw through the mudguards/body. It reproduces at **FovScale 100** (user re-test, `scratch/d291_shots/fov100_full.png`) as well as at 150 + native widescreen (`shot_000..003.png`), so the port's wider cutscene FOV is not what exposes it. Headless A/B at default vs user settings: `scratch/d291_AB.png` (runs not frame-locked; `GE_ZF` hit its 600-line cap in both). Occlusion-state path checked in passing: fast3d honours `G_ZBUFFER`/`G_ZS_PRIM`/`Z_CMP`/`Z_UPD` per triangle (`gfx_pc.cpp` depth_mode), so no obvious state gap. **Still gated on an N64/emulator reference of this exact shot** (porting-notes D12): if the N64 shows clean wheels there, the fight is PC-specific and the next suspect is the vehicle model's per-part transforms at extreme close range; if it shows the same, close as faithful.
|
||
|
||
**2026-09-26 (M-201, user: "investigate the PC-specific leads"): every PC-specific lead refuted; the artifact is the authored truck geometry seen from under the truck.**
|
||
|
||
- **Repro pinned:** Dam has 6 intro cameras and picks the first at random (`bondview_r.c`, `randomGetNext() % g_SetupIntroCameraCount`); the truck-over-walkway shot is **index 5**. New TEMP `GE_INTROCAM=5` forces it (PRNG draw still consumed). `GE_RSEED` does not reliably select it. With it pinned the truck passes at ~frames 280–320 (60 fps), and frame 280 at **default settings** (640x480/1280x720, FovScale 100) shows the same tread-slab look as the user's captures.
|
||
- **FOV:** refuted (user re-test at FovScale 100).
|
||
- **Depth precision / DrawDistance:** refuted. DrawDistance does move the far plane (`bgfog.c` → `viSetZRange`), but perspective depth resolution near the camera is set by the near plane; at the truck's range (w ≈ 280–790) 24-bit depth resolves far below the tyre↔mudguard separation, so these non-coplanar surfaces cannot precision-fight at any setting.
|
||
- **Depth state:** refuted. TEMP `GE_D306C="f0 f1 [wmax]"` census (per emitted triangle: GL depth mode, geometry_mode, other_mode_l, tile-0 texture, NDC, z/w, min w): every truck triangle is `dm=07` (test+compare+update, ZMODE_OPA, `G_ZBUFFER` on). The only no-compare near geometry (`dm=01`, `oml=c8102048`) is the walkway/ground under the camera, and 2D text is `dm=00`.
|
||
- **Near plane:** refuted. Truck triangles have w ≥ 278, no eye-plane crossings; only the ground under the camera crosses w=0, and GL clips it in homogeneous space.
|
||
- **Wheel transforms:** refuted. TEMP wheel-state log (removed): `wheelyrot` = 0 through the straight run, `wheelxrot` advances normally, radius/scale constant; the one ~20° steering blip (t≈530, `turnrot60`=0.0026) is the original formula's response and is outside the overhead pass. The wheel code uses named fields only (no A1 raw-offset reads).
|
||
- **Conclusion:** the rendered image is a depth-correct depiction of the geometry (correct depth state, sane z/w, sane transforms, ROM model data). The camera sits under the truck, where the model's wheel/mudguard construction (tyres passing through the arches, open-backed panels) is visible; it is not seen from normal gameplay angles. **Faithful with high confidence**; an N64/emulator frame of intro camera 5 would make it certain.
|
||
- **Probe-quality correction:** `GE_ZF` quantises NDC z to ±255 (`z24 = ndc_z*255`, not 24-bit as its comment says) with tolerance 2, so it flags unrelated nearby surfaces as "oscillating". The 2026-09-18 "confirmed real depth-fighting" was over-read and should not be cited as evidence of a fight.
|
||
|
||
**N64 reference (user, 2026-09-26):** the same shot on the 1964 emulator (GEPD bundle) shows the same wheel/body fighting as the port. Confirmed faithful.
|
||
|
||
**Status: CLOSED — faithful, confirmed against 1964 (authored geometry; no PC-specific cause). D291 closes with it.**
|
||
|
||
## D307 — In-level security camera entities can be flipped facing the wrong direction (backwards) on some levels, e.g. Bunker (user QA, 2026-09-18).
|
||
|
||
**Reported only, not investigated, explicitly not for v0.3.0.** User playtest: on some levels (Bunker named specifically), the in-level **security camera props/entities** — the gameplay objects that spot the player and alert guards, not the player's own view camera — can end up facing backwards (the wrong direction) "for some reason." No further repro detail given (trigger condition, whether it self-corrects, whether it's tied to a specific spawn/setup path). No code read done. **Correction (same day): initially misread as the player's view camera — clarified by the user to mean the in-level security camera entities specifically.**
|
||
|
||
**Investigation (v0.4.0 prep, 2026-09-21):** the full orientation path was traced and every input verified N64-faithful; **no port-side divergence found**.
|
||
|
||
- Orientation is computed once at load in `setupCctv()` (`src/game/prop.c`): watchpoint = `obj->mtx · Switches[0]->Data + prop->pos`; look basis = `set_basis_and_position_target(watch − padpos, up=(0,1,0))` with row 0 scaled by model scale (0.1); yaw `unkC4 = atan2f(tx, tz)`; initial turret angle `unkCC` from record config word (N64 w51). Per-frame render: `mtxs[1] = RotY(unkC8) × unk84` (`propobj.c` ~5964). All matrix/atan2 helpers are byte-identical `src/game` code (`matrixmath.c`, `math_atan2f.c`) — none are port forks.
|
||
- Data verified against the ROM for Bunker1 (`UsetupsevbunkerZ`, level_09, 4 CCTV records): model switch offset `(0, 70.3664, 31.9837)` matches `PcctvZ` (NOT `GcameraZ` — that's the gun; PitemZ idx 24 = `assets/obseg/prop/cctv`) node sw[0] bit-for-bit; boundpad positions match the pccg sidecar exactly once the legitimate load-time room-scale is applied (`pad->pos *= get_room_data_float2()`, 1/level-scale ≈ 1.8542 for Bunker1 — initially looked like a mismatch because raw file values are pre-scale); `unkCC` raw angles match record config words; prop placement derives from pad + bbox via the standard `moveToPad` path.
|
||
- An env-gated diagnostic (`GE_D307=1`, TEMP, load-time only) dumps every input and derived value per CCTV record for offline ROM comparison — kept in-tree like D60/D88.
|
||
|
||
**Conclusion so far:** given byte-identical code + verified-identical inputs, the PC orientation values are what the N64 computes. If the cameras genuinely differ from N64 hardware, the remaining suspect is fast3d's handling of the non-orthonormal lens matrix (`unk84` row 0 scaled 0.1×) in the per-frame render path — but that is unproven. **Blocked on user repro detail:** which camera/level, a screenshot, and ideally an N64 comparison (the symptom may be faithful N64 level-design behavior).
|
||
|
||
**Status:** OPEN (investigated — no port-side bug found; awaiting repro detail).
|
||
|
||
**M-201 (2026-09-26, v0.4.0): ROOT CAUSE FOUND + FIXED — a renamed duplicate struct member re-pointed `setupCctv`'s look-at pad to the mount pad. User-verified.**
|
||
|
||
- **Confirmed port bug first:** user side-by-side with 1964: every Bunker 1 camera faces backwards on PC, correct on N64.
|
||
- **Measured (TEMP render dump, since removed):** the CCTV model is fine (group `mtx 0` = base, group at the switch origin (0,70.4,32.0) = `mtx 1` = lens). The lens matrix's forward axis pointed back into the mount: camera 48's base maps model +Z to world +X, its lens matrix mapped +Z to about (-0.80,-0.60,0).
|
||
- **Cause:** `CCTVRecord` inherits `ObjectRecord` (s16 `pad` @0x08 = mount pad) and declares its own s32 `pad` @0x80 = the **look-at** pad. IDO accepts the duplicate and `arg1->pad` in `setupCctv` means the CCTV's own look-at pad. GCC rejects duplicate members, so the PC struct renamed it `lookpad`, and a comment asserted game code "never" means this member. So on PC `arg1->pad` silently became the mount pad: the look vector was just the lens's offset from its own mount (`target` ≈ (9.4, 7.0, 0.9)) and `matrix_4x4_set_basis_and_position` (Z = -look) aimed every lens back at its wall. The load dump showed it: `pad=10018 bound=1` (mount) while the record's look-at pad is 0x58 = 88.
|
||
- **Cross-check:** Perfect Dark's same function (`pd_port/src/game/setup.c:795`, `setupCreateCctv`) uses `cctv->lookatpadnum`, the CCTV's own field.
|
||
- **Fix (`src/game/prop.c`, `#ifdef PORT`, A1 class: read the correctly named field):** `CCTV_LOOKPAD` = `arg1->lookpad` on PC, `arg1->pad` on N64, for the 4 look-pad reads in `setupCctv`. `bondtypes.h`'s misleading comment corrected. After the fix the look vectors run from the floor look-at pads (y ≈ 185) to the lenses (~700 units, ~240 up), and the lens forward axis points at the watched spot.
|
||
- **Verified:** user playtest ("that fixed it"); golden gate 3/3. Only `CCTVRecord` had this rename (grep for the duplicate-member note).
|
||
|
||
**Follow-up (2026-09-27): reaction speed.** User: cameras now point correctly, but may be slow to react. By code this is faithful and tick-based: the alarm fires after `CCTV_ALARM_FRAMES` (NTSC 300) x `F_80030B14` ticks of continuous sighting (Agent 2.0 -> ~10 s, Secret/00 1.0 -> ~5 s), accumulated with `g_ClockTimer`; the sweep integrates `g_GlobalTimerDelta`. No frame-rate dependence. Optional check: time spot-to-alarm on the same camera and difficulty on 1964.
|
||
|
||
**Status: FIXED (M-201, user-verified 2026-09-26).**
|
||
|
||
## D308 — Occasional z-fighting confirmed on some levels' geometry, general/broader than the D306 truck-wheel instance (user QA, 2026-09-18).
|
||
|
||
**Reported/confirmed by the user, not investigated, explicitly not for v0.3.0.** User: "Confirmed occasional z-fighting in some levels due to the level geometry and current state of code" — framed as a general, known-class issue (overlapping/coincident geometry at the current renderer's precision) rather than a single specific bug site. D306 (Dam truck wheels) may be one visible instance of this broader class, not confirmed as the same mechanism.
|
||
|
||
**Investigation (2026-07-19, with D306):**
|
||
|
||
- **Depth pipeline precision compared against N64 — equivalent.** GE's RSP (`rsp/graphics/gmain.s` vertex transform) writes post-transform x/y/z/w back to the vertex buffer as **full 32-bit 16.16 fixed-point** (`sdv` stores at +0/+8/+18), NOT s16 8.8 — so the RDP receives full-precision NDC on N64. The port's `gfx_sp_vertex` keeps float (≥ as precise). Both sides write into a **24-bit integer depth buffer** (port: `GL_DEPTH24_STENCIL8`; N64 RDP: 24-bit z-image) with the same near/far from the game's projection matrix. Net: the PC has *no less* depth precision than the N64 — coincident-geometry fighting that exists in the level data fights on both platforms.
|
||
- **Probe.** TEMP env-gated `GE_ZF=1` flicker detector in `port/fast3d/gfx_pc.cpp`: a 96×54 screen-cell grid remembers recent per-cell 24-bit depth values; a hit requires non-monotonic oscillation (a-b-a within `GE_ZFTOL`, default 2 units) that **recurs ≥5 times at the same cell** — filters out tessellation gradients and one-off camera-motion coincidences. Optional region filter `GE_ZFBOX="x0 y0 x1 y1"`.
|
||
- **Result (Dam, `-level_33`, 40 s unattended, intro plays automatically):** sustained hits in the lower-left screen region (ndc ≈ x∈[-0.1,+0.1], y≈-0.57 — the truck) with per-cell oscillation counts reaching ~970: genuine near-equal depths alternating across hundreds of frames.
|
||
- **Open question — port bug or faithful?** Given equivalent precision on both sides, the most likely explanation is **level-authored coincident geometry** (the user's own framing: "due to the level geometry") that fights on N64 too. Proving/disproving requires an N64 reference of the same spot (footage or side-by-side), which is not available in this environment. Secondary suspects if it turns out PC-only: exact `glDepthFunc`/z-range edge cases, or MSAA depth behavior — none evidenced so far.
|
||
- **Fix posture if confirmed PC-only:** no game-code lever exists (depth values are the game's); a port-side mitigation would be a precision change (e.g. 32-bit float depth) which would *reduce* fighting but diverge from N64 behavior — needs a rule-2-style decision, not a silent fix.
|
||
|
||
**Status:** OPEN — real z-fighting confirmed by probe; port-vs-faithful blocked on an N64 reference of the same spot.
|
||
|
||
## D309 — Complete non-self-recovering freeze (Windows) returning from Caverns to the intro screen — boss thread spins forever in `ai()` on `m_RunToBondPersistent` via a phantom `GotoNext(lblZero)` (user report + live GDB diagnosis, 2026-09-18).
|
||
|
||
**Repro (user):** played through Caverns, then returned to the intro screen (Bond's intro circles); game locked up completely, no self-recovery. Process left alive for live diagnosis (PID 35936 at capture time).
|
||
|
||
**Live evidence (GDB attach, frozen process):**
|
||
- Boss/game thread (T9) stack: `bossMainloop` → `lvlRender` → `chrlvActionTick` → `ai()` (`src/game/chrai.c:928`) → `chraiGoToLabel(AIList=m_RunToBondPersistent, Offset=133 [mid-scan], LabelNum=0)` → `chraiitemsize`. Main thread idle in the SDL event pump. Stacks: `scratch/freeze_caverns_stacks.txt`.
|
||
- The AI list's live memory was dumped (`x/160bzx m_RunToBondPersistent`, `scratch/freeze_ailist_dump.txt`) and compared byte-for-byte against the compiled object (`objcopy` of `chraidata.c.obj` `.data`): **IDENTICAL** — no runtime corruption, and since the decomp is byte-matched to ROM, no data-side explanation. ABI/pointer-width drift was also ruled out structurally: every AI record type is `#pragma pack(1)` small-int (no pointer fields), so `chraiitemsize`'s `sizeof`s match N64 widths exactly.
|
||
|
||
**Root-cause chain (all verified against source + live bytes):**
|
||
1. `m_RunToBondPersistent` (`src/game/chraidata.c:551`) begins `Label(lblRun)` / `TRYRunToBond(lblRunning)` / `PRINT("no go!\n")` / `DO(lblRunning)` … The decompiled `PRINT` macro (`src/aicommands2.h:4913`) expands to **only the one command byte** — no string is stored in the stream.
|
||
2. `chraiitemsize()`'s `AI_PRINT` case (`src/game/chrai.c:682`) sizes the record by scanning forward until a NUL byte. With no embedded string, it runs 18 bytes past the PRINT at offset +4 (through eight real records) to the first NUL at +21 (inside an `IFMychrflagsHas` record).
|
||
3. Execution resumes at +22 = bytes `00 00`, which decode as `AI_GotoNext` with `GOTOLABEL=0` (`lblZero`). A full named decode of the list (decoder: `scratch/decode_ailist.c`, command table: `scratch/ai_cmd_vals.txt`) confirms the stream is otherwise perfectly coherent — `Label(40=lblRun)`, `TRYRunToBond(27=lblRunning)`, `PRINT`, `Label(27)`+`Yield`, … all matching the source exactly — and that **no `Label(0)` exists anywhere in the list**.
|
||
4. `chraiGoToLabel` scans forward from +22 for label 0, reaches `AI_EndList`, and returns 0 — the decomp's own comment: "restart ai list PC if next label not found - causes infinite loop outside of debug" (`src/game/chrai.c:753`).
|
||
5. `ai()` sets `Offset=0` and its `for(;;)` restarts: `TRYRunToBond` fails **immediately without yielding** (`chrGoToBond` false → plain fall-through, `src/game/chrai.c:1534`) → PRINT → 18-byte mis-skip → phantom `GotoNext(0)` → scan → 0 → repeat. Tight infinite loop inside a single `ai()` call — matches the observed stack exactly.
|
||
|
||
**Why PC and not N64:** code and data are ground truth, so given this state the N64 would spin identically — this is a **latent original bug** requiring a guard on this list to tick AI while Bond is unreachable ("guard can't reach bond"). The repro (level teardown back to the intro screen) is exactly such a window: on PC the boss thread still ticks a guard whose level/Bond state is already invalid, and the state never recovers so the spin is permanent. Same freeze class as D287 (Windows, non-self-recovering) / D286 (Deck). Not yet established *why* the N64 scheduler never presents this state (teardown ordering / single-threaded timing) — that's the open question for any fix.
|
||
|
||
**Fix bucket:** NOT an ABI/layout exception (no pointer-width involvement at all). The decomp's code+data are faithful; the divergence is in *when* the state occurs, i.e. PC teardown/scheduling. A port-side fix (ensure guards stop ticking AI before/while Bond+level state goes invalid on level exit) is the natural home and needs its own investigation of the teardown ordering (`gesched.c` / boss-thread vs level-unload race). Any `src/game` edit would require rule-2 sign-off.
|
||
|
||
**Status:** **FIXED via D310 — user-playtest-confirmed 2026-09-18** (Caverns→intro transition clean; the spin record `PRINT("no go!")`@+4 itself was observed in the post-fix probe log stepping `size=1 global=1` and resuming at `Label(27)` as intended). The teardown-ordering open question (why PC presents the unreachable-Bond state) remains documented but is now harmless — reaching that state no longer spins.
|
||
|
||
## D173/D292 — user confirmation, 2026-09-18: mostly fixed as a side effect of D243's root cause.
|
||
|
||
**User playtest, same day as the D243/D173/D292 draft-wording correction above (which had said the shared mechanism was "identified but not confirmed" for these two):** "Yeah the Bond floating/animations - 9/10 times he's in the right place now, if he's off it's only by a small amount, no more floating in midair or glitching out spinning like before."
|
||
|
||
This confirms the M-160/M-164/M-165 hypothesis (`## D173`'s own consolidated-next-steps section) — that D173/D292/D243 share one "third-person world model transform/lifecycle" mechanism, specifically `chrUpdateAnim` reading a stale/mismatched animation's root motion back into the puppet's logical position after a transition (teleport/spawn/death) that didn't also update the animation state — was correct, even though it was never separately code-traced and confirmed for D173/D292 the way it was for D243 (D243's own fix was the hardcoded 32-bit `sizeof(Model)` struct-size literal corrupting animation-timing fields; the M-160 write-up's own caveat was exactly that this general mechanism might explain D173/D292 too, without a live capture to prove it — this user confirmation is that proof, by outcome).
|
||
|
||
**Not "fully fixed"** — user is explicit that a small positional-offset residual remains ~1/10 of the time, and that no separate investigation/fix was made for D173/D292's own code path specifically (D243's fix in `bondview2.c`'s cutscene body-model buffer layout was the actual change; D173/D292 improving alongside it is the side-effect being confirmed here, not a targeted fix). Worth noting for a future session: since D173/D292 were never traced to their own exact call site, if the residual offset ever becomes a live complaint again, whoever picks it up should not assume it's the same code path as D243's fix — that fix touched one specific buffer-layout call site (Bond's cutscene body model), and D173's own M-172/M-173 measurements were taken on the *level-start intro puppet*, a different code path (`solo_char_load`/`bondviewSetCameraMode`'s `CAMERAMODE_SWIRL` branch, per D173's own M-175 finding) that was never itself touched by the D243 fix — the improvement is a side effect of shared underlying mechanics, not the same call site being patched twice.
|
||
|
||
**Status:** MOSTLY FIXED (D173 and D292 both), user-confirmed live, small residual offset remains and is not investigated further. Public docs (release notes, README) should describe this as "the Bond positioning/floating issue is now mostly resolved, small residual placement drift only, no more full floating or spin-glitching" rather than "separate, unconfirmed, still open."
|
||
|
||
## D310 — RULE-2-SIGNOFF: `chraiitemsize()` mis-sizes 1-byte `AI_PRINT` records (NUL-scans forward), corrupting AI execution for any list containing an active PRINT — fixes D309's freeze mechanism and the Caverns standing-still guards (signed off + implemented, 2026-09-18).
|
||
|
||
**Status:** **RULE-2-SIGNOFF / FIXED — user-playtest-confirmed (2026-09-18).** All three checks passed live in Caverns: standing-still guards resume, Trevelyan follows his path, Caverns→intro no longer freezes. `src/game` behavior change under `#ifdef PORT`; N64 build untouched.
|
||
|
||
**Post-fix probe log (clean session, 989 lines):** global-list PRINTs (`m_RunToBondPersistent`, chr=4/24/72) all step `size=1 global=1` — including the D309 spin record itself (`off=4 "no go!" landcmd=0x02`=Label, resumed correctly) and the standing-still record (`off=167 "wait" landcmd=0x95`=UnsetMyFlags2), each hit exactly once (no per-tick re-execution). Local-list PRINTs (Trevelyan chr=88, fake-GUARD driver chr=254) all keep NUL-scan sizing (`size=4/7 global=0`) with valid landing commands. Zero `LABEL-NOT-FOUND`, zero D156 fires, zero watchdog freezes.
|
||
|
||
**The defect.** The `PRINT()` macro (`src/aicommands2.h:4913`) emits exactly **one command byte** into the built AI list — its string argument is discarded at list-build time. But `chraiitemsize()`'s `case AI_PRINT:` (`src/game/chrai.c:682`, original) sized the record by scanning forward to the next NUL byte, so stepping on a PRINT always skips ~20 bytes into whatever records follow and resumes at an arbitrary byte pattern. Only 4 PRINTs exist in all of `src/game/chraidata.c`; two are commented out (`m_DeadAI:74`, `m_StandardGuard:136`) and **both active ones are in `m_RunToBondPersistent`** — so exactly one AI list in the game is affected.
|
||
|
||
**Two observed symptoms, one landmine (both live-verified 2026-09-18 with `GE_D309` probes, gdb symbol confirmation):**
|
||
1. **D309's complete freeze** (Caverns→intro): `PRINT("no go!\n")` @+4 mis-lands at +22 = `00 00` = phantom `AI_GotoNext(0)`; no `Label(0)` exists → `chraiGoToLabel` returns 0 ("causes infinite loop outside of debug", `chrai.c:753`) → PC restarts at 0 → same path → tight spin inside one `ai()` call. (Full mechanism in `## D309`.)
|
||
2. **Standing-still guards** (user report, Caverns 00 Agent: one after the first elevator, one after the next door — "fidgeting but staying still", still react to shots): guard completes its 1-second dash window → `Stop()` + `PRINT("wait\n")` @+167 mis-lands at +189, where arg bytes of a later record decode as `Label(88)` then `0x04` = **`AI_EndList`**. `ai()` at EndList `return`s **without saving the offset** (`chrai.c:988`, "same as yield except without pushing offset past it") → the identical path re-executes every tick → `Stop` re-issued forever; the source's intended follow-up (UnsetMyFlags2 / random check / MyTimerStart / sit-rep DO-LOOP / `GotoFirst(lblRun)` resume) is swallowed by the mis-skip. Damage still interrupts because shot/death changes `actiontype` outside the AI list. Live probe lines: `D309: PRINT chr=3/4 list=<m_RunToBondPersistent> off=167 size=22 landcmd=0x02` repeating every tick; `gdb info symbol` confirmed the list pointer is `m_RunToBondPersistent`.
|
||
|
||
**Sign-off request as stated (per `docs/dev-process.md` §7):**
|
||
1. *Change:* `src/game/chrai.c`, `chraiitemsize()`, `case AI_PRINT:` — replace the NUL-scan with `return 1;` (the record's true on-disk size), under `#ifdef PORT`; the `#else` arm is byte-identical to the original decomp.
|
||
2. *Proof of divergence:* mechanism verified live (gdb symbol + per-tick probe lines above, exact offsets/sizes/landing bytes). Intended-behavior references: (a) the list source itself — PRINT sits between two real commands and is a no-op by construction; (b) user's direct N64 experience — guards do not stand frozen in Caverns on real hardware (same standard as M-144's user comparison that settled D243). Honest caveat recorded at request time: the ROM *code* would mis-skip on N64 too if reached — this is a latent original defect, and the fix restores the list author's intent rather than provable N64 behavior.
|
||
3. *Why not `port/`:* no N64 hardware dependency to shim — the sizer function's own logic operating on game data is the defect; nothing upstream (ABI/layout §A1 suspects, port-layer) is involved. *Why not the ABI exception:* nothing pointer-width-driven; the 32-bit original has identical code and data.
|
||
4. *Same-engine precedent:* Perfect Dark removed `PRINT` from the AI language entirely and **reassigned slot 0xAD** to a real command (`aiChrCopyProperties`, `pd_port/src/game/chraicommands.c:8159`) — Rare themselves retired this broken record class in the next title on the same engine.
|
||
|
||
**Grant (2026-09-18, in-thread):** "I want to proceed with your implementation plan here to fix this bug." (User's reply to the full §7 write-up; asked only whether a fresh session was needed — answered no, granted in this session.)
|
||
|
||
**Resulting diff v1 (REGRESSED, superseded):** `chraiitemsize()` `case AI_PRINT:` → `return 1;` under `#ifdef PORT`. Fixed the guards, but live testing then broke **Caverns' Trevelyan (chr=88)**: ran in circles with glitched animations. Memory dump of his list (`0x701cca30`, a level-LOCAL list from `g_CurrentSetup.ailists`) shows local lists **embed a NUL-terminated string after the PRINT byte** — live bytes `AD 44 0A 00` (PRINT + "D\n"), `AD 57 0A 00`, `AD 34 0A 00` at offsets 50/288/302 — so the NUL-scan is CORRECT for local lists; the uniform 1-byte size landed mid-string (garbage commands + a `LABEL-NOT-FOUND` spin, off=403 label=0, probe-capped). Only the macro-built GLOBAL lists (`g_GlobalAILists`, chraidata.c) discard their PRINT strings.
|
||
|
||
**Resulting diff v2 (current):** `chraiitemsize()` left byte-identical to the decomp. New PORT-only `d310ItemSize(AIList, offset, isGlobalAIList)` returns 1 for a PRINT only when `chraiGetAIListID()` confirms the list is global; otherwise falls through to the original NUL-scan. Applied at both record-walk sites: `ai()`'s AI_PRINT step and `chraiGoToLabel`'s label scan (a backward `GotoFirst` crossing a global PRINT would mis-scan even with v1's sizer, since the scan also steps records). Lists found in neither table (teardown) default to the original NUL-scan. Verification: full-list decode of `m_RunToBondPersistent` with 1-byte global PRINTs (`scratch/decode_printfix.c`) walks +0→EndList@223 with every record valid, and the post-`"wait"` sequence matches `chraidata.c:666+` line-for-line (`UnsetMyFlags2(4)`, `IFNewRandomGreaterThan(160)`, `SetMyFlags2(4)`, `MyTimerStart`, sit-rep block, `GotoFirst(lblRun)`); post-`"no go!"` resumes at `Label(27)+Yield` as in source.
|
||
|
||
**Scope note (§7 drift check):** v1's sign-off scope ("exactly one AI list affected") was wrong in one respect — PRINT records also exist in level-local lists, but with a different shape (embedded string), where the original NUL-scan is already correct. v2's *behavioral* change is still confined to global-list PRINTs: `m_RunToBondPersistent`'s two records. Local-list behavior is byte-identical to the original decomp — Trevelyan reverts to his pre-fix (working) pathing. Drift disclosed to user in-thread when the regression was reported; same sign-off intent (fix global PRINT sizing, no other behavior change).
|
||
|
||
**Cross-refs:** D309 (this fix neutralizes its spin mechanism — the teardown-ordering open question in D309's "why PC and not N64" remains, but reaching that state is now harmless); GE_D309 probes (`chrai.c` `ai()`/`chraiGoToLabel`, `chraction.c` `chrGoToBond`) remain as diagnostic tools.
|
||
|
||
**Perf gauge (2026-09-19, release review — M-192 item 6):** v2's up-front `chraiGetAIListID()` call in `chraiGoToLabel` (added so the sizer knows global-vs-local) runs on EVERY label jump; originally that lookup ran only in the rare EndList-not-found branch. Cost per call: ≤ (level-local lists + ~20-entry `g_GlobalAILists`) pointer comparisons ≈ tens of ns. Worst case for a heavy level (Train/Archives, ~50 active guards each executing 1–2 goto-family records per tick): ~1000 calls/tick × ~40 comparisons ≈ 40 µs/tick — under 0.3% of a 16.7 ms frame, and two orders of magnitude below the D250 `getenv` anti-pattern (which was per-TRIANGLE, tens of thousands of calls per frame). **No change warranted; re-measure only if an AI-heavy level shows a regression.**
|
||
|
||
## D311 — Complete freeze in `modelSetAnimFrame2WithChrStuff` root-motion loop: D156 guard hole — poisoned `framea` (`model->animframe1`) passes through the frameb-only clamp and saturates to a ~3.1e9-iteration spin (Caverns, live GDB diagnosis, 2026-09-18).
|
||
|
||
**Reported:** user playtest of the D310 v2 build in Caverns — "it's frozen" shortly after launch (watchdog: no frame rendered for 8014 ms at frames=8424; ~2.3 min into the session).
|
||
|
||
**Live evidence (GDB attach, PID 46948, mainThread):**
|
||
- Stack: `sincosf` ← `modelSetAnimFrame2WithChrStuff` (`model.c:3349`, anim-merge branch) ← `modelTickAnim` ← `bheadUpdate` ← `bondviewMoveAnimationTick` ← `MoveBond` — the **player's own** model anim tick. Zero `D309:` probe lines in the log → no AI-list involvement; this is a different bug from D309/D310.
|
||
- Locals at the spin site: `curframe = -966612173`, `endframe = 2147483647` (INT_MAX) → ~3.1e9 loop iterations; `pos34 = (-6.7e30, ?, 2.9e32)` — root-motion accumulator already blown up; `model->animframe1 = -1.6708e+31`; `model->endframe = 17` (the anim has 17 frames); `speed = 0.22` (normal), but `newspeed = -3.2e7`, `oldspeed = -1.75e18`, `timespeed = 0`, `unkb0 = 0`; `anim2 != NULL` with healthy frame2 (18.8→20.9).
|
||
- The existing D156 guard **had fired** earlier in the same session (`[D156] bad anim frame guarded: frame=-1.6708e+31 animframe1=-1.6708e+31 ... oldspeed=-1.75136e+18 timespeed=0 unkb0=0`) — i.e. `animframe1` was already poisoned *before* the guarded tick, and the guard's fallback (`frame = model->animframe1`) plus the callee's frameb-only clamp (`if bad frameb: frameb = framea`) passed the poison straight through.
|
||
|
||
**Mechanism (the guard hole):** D156's guard in `modelSetAnimFrame2WithChrStuff` validates only `frameb`. With `framea = frameb = -1.67e31`, `floorFloatToInt` saturates the two near-equal huge floats to far-apart ints (`curframe = -966612173`, `endframe = INT_MAX`), so the `while(1)` root-motion loop walks ~3.1e9 frames — each doing a joint transform + bitstream decode + `sincosf` — i.e. a permanent hang. The N64 never presents this state (fixed timestep, bounded speeds); the poison is PC-side upstream corruption of the model speed/frame fields (see open question).
|
||
|
||
**Fix (landed, `#ifdef PORT`, same file/pattern as the accepted D156 guards — port-side defensive clamp against non-finite state, no game-logic change):**
|
||
1. `modelSetAnimFrame2WithChrStuff`: validate **both** `framea` and `frameb` against ±1e6; a bad one snaps to the other if that is valid, else 0. Both-bad → both 0 → loop does ~0 iterations; the in-loop `animframe1` rewrite (`model.c:3447`) then heals stored state on subsequent ticks.
|
||
2. `modelTickAnim`: heal `model->animframe1`/`animframe2` (snap to 0) **before** the bad-frame fallback reads them, so the poison source itself is cleared instead of being re-derived every tick.
|
||
|
||
**Open question (deeper root cause, NOT fixed):** what wrote `-1.75e18` into `oldspeed` / poisoned `animframe1` in the first place? `oldspeed = model->speed` at transition start (`model.c:2975`) means `speed` was already garbage upstream; `timespeed = 0` and `unkb0 = 0` at the guarded tick are suspicious division denominators (D156's original note flagged `/ unkb0` and `/ timespeed` as guarded `> 0` but not against tiny values). Also, the model's persistent root-motion header (`header->unk34/unk24`) is poisoned to ~1e30–1e32 in this instance, so **Bond's rendered model may be displaced even after the freeze is prevented** — a level reload clears it. Needs its own investigation (dump speed-field write sites during the triggering transition) before any further fix.
|
||
|
||
**M-194 (v0.4.0 Phase 1, 2026-09-21): upstream poison source — static pass + choke-point instrumentation.** Every write of `model->speed` funnels through exactly two functions: `modelSetAnimSpeed` (startframe≤0 path) and `modelSetAnimation2`. Both now log a `D311: … ANOMALY` line when the incoming value OR the already-resident `model->speed` is non-finite or |v|>1e4 (`#ifdef PORT`, unconditional-on-fire like the D156 guards — silent in healthy play). The line captures in/resident/startframe/oldspeed/newspeed/timespeed + model pointer, so the next occurrence pins the exact injecting call. Static pass over the computed-speed callers: `sub_GAME_7F06FE90` (the `t = speed + 2·φ/arg2` divider) is only ever called with arg2=16.0f — its denominator is safe, though a garbage `animframe1` would poison its numerator; `bheadAdjustAnimation` divides by the *static* `g_BondMoveAnimationSetup[].speedMultiplier` table (bondhead.c:19) — safe constants, but its `speed` parameter is the player's live movement speed, so an upstream velocity-field corruption would flow straight into `model->speed`; the old→new lerp at model.c:3654/3663 only circulates already-poised values (no new garbage). Live repro (Caverns, ~2 min in) remains the trigger; the probe turns that occurrence into a one-line diagnosis.
|
||
|
||
**Status:** GUARDED (freeze class neutralized; upstream poison source OPEN — M-194 probe armed). **RULE-2-SIGNOFF (2026-09-19, release review — M-192 item 2):** user approved retention as the stability-maximizing option; classified as a defensive guard against non-finite state in the same class as the accepted D156 guards — it fires only on poison values that indicate an already-broken state and snaps to a valid frame rather than altering any legitimate animation. Previously disclosed in-thread as a D156-guard extension following the already-accepted D156 precedent in this same file; revert is still two small blocks. Cross-refs: D156 (original guard + Facility outro hang), D309/D310 (ruled out for this freeze — no AI involvement).
|
||
|
||
**2026-09-24 — likely root cause found in D336.** D311's own static pass named `bheadAdjustAnimation` as the one path where a corrupted value reaches `model->speed`. D336 showed that path computing Bond's head-bob start frame from a never-written alias field (`field_5C0` = N64 `model.animframe1`), i.e. uninitialised player-struct bytes, whose garbage depends on memory layout (room-pool size, previous level). That matches D311's intermittent `oldspeed=-1.75e18`-at-transition signature. After the D336 fix: headless Caverns (`-level_39`, user settings incl. the 2× room pool `ma=512000`, scripted movement/turning, ~80 s) gave **0 D156 guards, 0 D311 anomaly lines, no crash**. **Close after one live Caverns combat session (~2 min) stays D156/D311-silent.**
|
||
|
||
**2026-09-25 playtest (user, Windows, one ~46-min session Silo → Caverns, `GE_D322=1 GE_D204=1`, build `7baf4900`):** Caverns combat ~2 min, plus Silo onwards: zero `D311: … ANOMALY` lines (the probe logs via `osSyncPrintf` into the same log, and D318W lines from the same path prove it is live). Combined with D336 (upstream poison source) this closes D311. Guard stays (Rule-2 sign-off 2026-09-19).
|
||
|
||
## D312 — Throwable items (mines, grenades, etc.) can clip through walls in certain spots (user report, v0.3.0 pre-release playtest, 2026-09-18) — FIXED: `bgBuildRoomVtxBounds` mis-reads the G_VTX vertex count on PC (Gfx_le `dma.par` bit-layout shift), collapsing whole batches of wall geometry out of the collision query (diagnosed + fixed, 2026-09-18).
|
||
|
||
**Reported:** user's last-minute v0.3.0 playtest: thrown/placed throwable items — mines, grenades, "etc." — can pass through wall geometry in certain spots rather than colliding with it. Spot-dependent ("certain spots"), not universal.
|
||
|
||
**Diagnosis (2026-09-18, env-gated probes `GE_D312`/`GE_D312V`, Runaway spawn-area wall):**
|
||
- Fidelity confirmed first: the N64 original does NOT clip — this is a port-side divergence (user confirmation + the pipeline below being structurally identical to the working door/window path).
|
||
- Both D312 (projectiles) and D313 (bullets) funnel into `bgTestBulletHitBackground` (`propobj.c handles_projectile_motion` bg loop; `chrprop.c` fire path). Doors/windows use the separate, working `bgTestHitOnObj` path — matching the user's "works on doors/windows" symptom exactly.
|
||
- Probe run 1: queries during a mine flight DO reach the room (room 14) and even register HITs — but only on floor triangles far ahead of the mine; the wall's triangles are never tested. The per-batch AABB pre-test in `bgTestBulletHitBackground` rejects the batches that own the wall.
|
||
- Probe run 2 (`D312B` batch-table dump): room 14 has 37 vertex batches; most have degenerate 1-unit bounds (single vertex), while a few (e.g. the floor batches) have correct extents. The pre-test therefore only ever admits the correctly-bounded batches.
|
||
- Probe run 3 (`D312V` per-triangle world-box dump, mirroring `bgTestRayIntersectionInRoom`'s index recovery): the wall geometry EXISTS and is at the right place — batch 35 owns a vertical wall at z=-647 spanning x 510..560, y -59..-23; the mine's fixed-point trajectory crosses it dead-center. So the geometry is fine; only the bounds table is wrong.
|
||
- Root cause: `bgBuildRoomVtxBounds` (bg.c) computes the G_VTX vertex count as `((gdl[cmdindex].dma.par >> 4) & 0xf) + 1`. On N64, `Gfx.dma.par` IS the params byte `((n-1)<<4 | v0)`, so that is correct there. The PC shim's `Gfx_le` packs word0 as `(cmd<<24)|(par_byte<<16)|len`, and its `Gdma_le.par` bitfield spans bits 0–23 — i.e. `(par_byte<<16 | len)` — so `(dma.par>>4)&0xf` actually reads the low nibble of `len>>4` (= `n & 0xf`). For n ≤ 15 the builder still reads the whole block (+1 vertex, harmless); for **n = 16** (the most common authored size) it collapses to `numvertices = 1`, and the batch's bounds shrink to a single vertex. The per-batch AABB pre-test then rejects that batch for nearly every ray, so its triangles are never intersected: projectiles pass through those walls (D312) and bullets register no impact on them (D313). Batches with n ≤ 15 keep correct bounds — hence "certain spots", not universal. This is the §A1 struct-layout-driven misread class (raw field alias into a union arm whose bit layout changed in the port shim), same pattern as D154/M-30.
|
||
|
||
**Fix:** `src/game/bg.c` `bgBuildRoomVtxBounds`, under `#ifdef PORT`: recover the params byte from bits 16–23 of `words.w0` — `numvertices = (((u32)gdl[cmdindex].words.w0 >> 20) & 0xf) + 1;` — restoring the N64 semantics (the `(n-1)` field). N64 path untouched. Verified by repro: with the fix, the mine-flight queries pre-pass and win batch 35, HITs land exactly on the wall plane (hit z = -647.0 in fixed-point), and `handles_projectile_motion` reports `result=0` (collision) instead of pass-through; pistol shots at the same wall now produce impact decal positions on the wall plane.
|
||
|
||
**Status:** FIXED (v0.3.1). Cross-ref: D313 (same root cause, merged diagnosis), D154/M-30 (same §A1 pattern in this file).
|
||
|
||
## D313 — Bullet impacts do not appear on some walls/surfaces — the projectile appears to pass through them; impacts DO work on some surfaces such as doors/windows (user report, v0.3.0 pre-release playtest, 2026-09-18) — FIXED: same root cause as D312 (`bgBuildRoomVtxBounds` G_VTX count misread), merged (diagnosed + fixed, 2026-09-18).
|
||
|
||
**Reported:** user's last-minute v0.3.0 playtest: firing at some walls/surfaces produces no bullet-impact effect — visually it looks like the projectile goes through them — while other surfaces (doors, windows named specifically) show impacts correctly. So the impact system works; its trigger is surface-dependent.
|
||
|
||
**Diagnosis:** merged into D312 — see that entry for the full trace. The bullet path (`chrprop.c` fire → `bgTestBulletHitBackground`) shares the same broken per-batch bounds table: batches whose G_VTX block holds exactly 16 vertices get single-point bounds, fail the AABB pre-test, and their wall triangles are never ray-tested — so no hit is registered and no impact effect spawns. Doors/windows use the separate `bgTestHitOnObj` path (unaffected), which is why impacts worked there. The "surface-dependent" appearance was actually batch-size-dependent.
|
||
|
||
**Fix:** same one-line change as D312 (`src/game/bg.c` `bgBuildRoomVtxBounds`, `#ifdef PORT`). Verified: pistol shots at the previously-failing Runaway wall now register hits on the wall plane and spawn impacts.
|
||
|
||
**Status:** FIXED (v0.3.1). Cross-refs: D312 (root cause), D172 (impact-effect path, fixed earlier).
|
||
|
||
## D313 — Bullet impacts do not appear on some walls/surfaces — the projectile appears to pass through them; impacts DO work on some surfaces such as doors/windows (user report, v0.3.0 pre-release playtest, 2026-09-18).
|
||
|
||
**Reported:** user's last-minute v0.3.0 playtest: firing at some walls/surfaces produces no bullet-impact effect — visually it looks like the projectile goes through them — while other surfaces (doors, windows named specifically) show impacts correctly. So the impact system works; its trigger is surface-dependent.
|
||
|
||
**Not investigated.** Starting pointers only, nothing below is evidence:
|
||
- The surface-dependence is the key clue: impacts likely gate on a hit-surface property (surface type / imagetable entry / material class). The first step is finding where `src/game` decides "this hit spawns an impact" and what surface field it reads — then checking whether that field is being misread on PC (§A1 ABI catalogue: a 32-bit-era offset or width in the surface/imagetable record would produce exactly this "works on some surfaces, not others" shape).
|
||
- D172 (impact-particle color, FIXED) is the nearest prior art for the impact-effect path itself; D266/D271 show this codebase has a history of texture-format-dependent misreads — worth checking whether the no-impact surfaces correlate with a particular authored surface/texture class.
|
||
- Cross-check D312: if throwables clip through the SAME walls that don't show impacts, one shared wall/collider class is the likely root and the two findings should be merged during investigation.
|
||
|
||
**Status:** OPEN — reported only. Explicitly not for v0.3.0; queued for v0.3.1. Cross-refs: D312 (possible common wall class), D172 (impact-effect path, fixed).
|
||
|
||
## D314 — F10 menu items flicker randomly/intermittently (reported on the main-menu context, user report, v0.3.0 playtest build, 2026-09-18).
|
||
|
||
**Reported:** "F10 menu glitches on main menu, the menu items flicker randomly/intermittently." **Repro detail (user, 2026-09-18):** it happens when F10 opens the settings menu while on the **file-select / level-select screen**, and also when the settings menu is open in-game and the user is then **returned to file-select/level-select** (i.e. the overlay outlives a context switch). The playtest build is the current-tip exe (includes D310/D311 fixes). So this is specifically an overlay-vs-mission-select-screen interaction, not the plain title screen.
|
||
|
||
**Not investigated.** Pointers only:
|
||
- **Prime suspect by recency: D304 (fixed TODAY) changed exactly this overlay's code** — collapsed the mouse-sensitivity rows to a single menu row (changing the total item count) and fixed `overlayUpdateScroll()`'s unconditional bottom-anchor. The in-game F10 playtest confirmed the *collapsed sensitivity section* worked, but flicker is a row-layout symptom and the main-menu context was not tested. If row positions are re-derived per frame from a list-height/anchor that assumes the old item count, intermittent flicker + mis-landing clicks (D316) would both fall out of the same bug.
|
||
- D251 (bottom-row duplication, fixed) and D304's click-jump fix are the sibling bugs in this exact code path — check `overlayUpdateScroll()` and the row hit-test/height math for any count assumption that D304's row collapse invalidated in a context with fewer/more items.
|
||
- Likely one root cause shared with D316; investigate together.
|
||
|
||
**Triage update (2026-09-18):** ruled out `viGetX()`/`viGetY()` instability (stable per-screen, set once by `front.c`) and non-determinism in the overlay display list construction; no shared fixed-buffer overflow found. The D316 mouse-mapping/hit-band bugs are fixed (see D316) but do not explain a *flicker* (those were click-accuracy bugs, not a rendering instability), so D314 likely has an independent mechanism — probably front-end-context specific (the text/font layer, or file-select's own mouse handling racing the overlay's input-suspend). Added a cheap, cached, env-gated diagnostic probe (`GE_D314=1`) in `optionsOverlayHandleInput()` (`port/src/optionsoverlay.c`) logging `viGetY()`/`s_visN`/`s_scroll`/`s_sel` once per frame while the overlay is open — diagnosis only, no behavior change when unset. A ~30s file-select repro run with it enabled should show which value oscillates (or prove none do, pointing at the text layer).
|
||
|
||
**Confirmed still present post-D316 fix (user, 2026-09-18):** the D316 mouse-mapping/hit-band fix did not resolve this — as expected, since D314 is a rendering-instability symptom (flicker), not a click-accuracy one. **User call: not worth chasing down currently** — deprioritized, left OPEN with the probe in place for whenever it's revisited.
|
||
|
||
**Status:** OPEN — deprioritized by user request (2026-09-18); probe landed, repro run not currently planned.
|
||
|
||
**2026-09-25 (v0.4.0, M-200 session): threading audit, one race fixed, headless repro clean; plausibly fixed via D287; owed a live check.**
|
||
- **Thread map (verified in source):** `optionsOverlayEmit` runs inside `gfx_run`, called from `osSpTaskStartGo` in `__scMain` (scheduler thread). `optionsOverlayHandleInput` runs from `joyPoll` → `osContGetReadData` → `inputComputePad`, *also* synchronously inside `__scMain` (`sched.c:308/321`; see the `osRecvMesg` note in `libultra.c`). So Emit and HandleInput never race. The host thread (SDL event pump in `videoPumpEvents`) calls `optionsOverlayToggle` (F10) and `optionsOverlayScroll` (wheel).
|
||
- **Race fixed:** `optionsOverlayScroll` ran `overlayUpdateVisible()` (`s_visN = 0`, then refill in place) and wrote `s_sel` on the host thread. That can race Emit's draw loop on the scheduler thread, which could draw a truncated row list for a frame or clamp the selection against a half-built `s_visN`. It now only does `SDL_AtomicAdd` of the notches; `overlayApplyWheel()` in HandleInput applies them on the scheduler thread (the D287 pattern). Behaviour is unchanged (one row per notch, clamped, headers skipped). This only mattered while the wheel was moving.
|
||
- **Pre-D287 cross-thread SDL calls on this exact path:** HandleInput calls `inputSuspendForOverlay()` on every poll while open. Before `d07ed0f2` (D287, 2026-09-25) that called `SDL_SetRelativeMouseMode`/`SDL_ShowCursor` directly from the scheduler thread while the host thread made its own cursor/focus calls. That's the D287 class, and it fits a "flicker" that was seen only with the overlay open. The D314 report (2026-09-18) predates D287, so D287 may already have fixed it.
|
||
- **Headless repro, clean:** `GE_STARTMENU=7` (mission select) and `GE_STARTMENU=5` (file select) with `GE_OPTIONSOVERLAY=1`, `GE_PCDUMP="200-800:1"` at 640x480: 600 consecutive frames each, **0 frames** with a text-level (>120 summed RGB delta, >30 px) change against the previous frame (`scratch/d314diff.py`). The post-fix file-select frame 500 is pixel-identical to pre-fix. So without input the overlay draws deterministically; any real flicker needs input (mouse/cursor) or the in-game→file-select transition.
|
||
- **User check (next playtest):** open F10 on file-select and mission-select and move the mouse, click rows, wheel-scroll; also open it in-game, then abort to file-select with it still open. If there's still flicker, note whether it's the overlay rows or the game's menu behind it, and whether it happens with the mouse still.
|
||
|
||
**Status:** OPEN, likely fixed (D287 + wheel race), pending the user check above.
|
||
|
||
|
||
**2026-09-26 (v0.4.0, M-201): user-confirmed.** F10 on file-select/mission-select with mouse move/click/wheel, and in-level → abort with F10 open: no flicker. Closed by the M-200 wheel-queue fix plus D287 (cursor/relative-mode calls moved to the window thread).
|
||
|
||
**Status: FIXED (user playtest, 2026-09-26).**
|
||
|
||
## D315 — Menu scaling differs between main menu and in-game — main menu scales OK, in-game menu "seems kinda big sometimes" (user report, v0.3.0 playtest build, 2026-09-18).
|
||
|
||
**Reported:** "Menu scaling can change between main menu / game — main menu scales OK, game seems kinda big sometimes." The "sometimes" is the key word: it tracks some state (video mode? window size? level?) rather than being constant.
|
||
|
||
**Not investigated.** Pointers only:
|
||
- Check how the in-game overlay derives its scale factor vs the main-menu UI — if one uses a fixed design resolution and the other tracks the current render/window size, any video-mode change between contexts (resolution switch at level load) would produce exactly "fine on main menu, big in game".
|
||
- Widescreen FOV phase 4 landed recently and touches the in-game letterbox/FOV path — check whether it changed any shared scale constant the overlay reads. D246/D247 (overscan crop) is the adjacent video-mode work.
|
||
- Repro ask: which resolution/video mode, and does resizing the window change the in-game menu size live?
|
||
|
||
**Triage update (2026-09-18):** confirmed distinct from D316 (D316 was a click-accuracy bug in the overlay's *input* mapping; D315 is about the overlay's *rendered* size differing between contexts) — not a shared root cause. This is a bigger design question (whether/how the safe-area crop and the front-end/in-game logical-canvas size difference — 320x240 in-game vs 440x330 on front-end screens, `viSetXY` — should both feed into overlay scale) than a mechanical fix. Disclosing as a known issue and deferring to v0.3.1 rather than fixing in this pass.
|
||
|
||
**Status:** OPEN — disclosed as a known issue, deferred to v0.3.1. Lower urgency than D314/D316/D317.
|
||
|
||
## D316 — Mouse-clicking some menu items glitches and lands on the item below instead (user report, v0.3.0 playtest build, 2026-09-18).
|
||
|
||
**Reported:** "On some menu items when you try to click with mouse, it glitches and forces you onto the item below." **Repro detail (user, 2026-09-18):** possible on any item, but specifically noticed on the **crop overscan bars** setting; not currently reproducible on demand — intermittent.
|
||
|
||
**Root-caused and FIXED (2026-09-18), two compounding port-side bugs, both in `port/src/optionsoverlay.c`:**
|
||
|
||
1. **Mouse→logical mapping ignored the safe-area crop.** `optionsOverlayHandleInput()` mapped a click with a naive `ox = mx * viGetX() / ww` / `oy = my * viGetY() / wh` — a linear scale against the raw SDL window size. But the overlay's own 2D content is drawn into whatever on-window rect the safe-area crop (`Video.SafeAreaCrop`, default ON) currently maps the logical `viGetX() x viGetY()` canvas to, via `gfx_adjust_viewport_or_scissor` (`port/fast3d/gfx_pc.cpp`) — and that rect is inset below the full window whenever the last-set gameplay viewport was inset (NTSC "Full" removes ~8% top+bottom; this is why "the crop overscan bars setting" specifically was the noticed repro — it sits low in the list, where the growing offset between the naive scale and the real inset rect is largest). Fix: added `gfx_get_ui_screen_rect()` (`gfx_pc.cpp`, declared in `gfx_api.h`) which *reuses* `gfx_adjust_viewport_or_scissor` itself (not a hand-derived inverse) against the full VI canvas rect to get the real on-window rect, then `optionsOverlayHandleInput()` inverts the click against that rect instead of the raw window size.
|
||
2. **Asymmetric row hit-test band.** `overlayRowAtY()`'s band was `[OV_ROW_Y(p)-3, OV_ROW_Y(p)+12)` on 15px rows (`OV_LINE`) — 3 units above the row anchor but 12 below, a systematic downward bias independent of bug 1. Fixed to `[OV_ROW_Y(p) - OV_LINE/2, OV_ROW_Y(p) + OV_LINE/2)`, centered on the row anchor.
|
||
|
||
Verified: build clean (`build-pc`, MSYS2 MINGW64/ninja); `tools_pc/verify.sh bunker1` — PASS, `worst_cell=0` (pixel-identical to golden, confirming the new `gfx_get_ui_screen_rect()` accessor is a true no-op for normal 3D rendering, as intended — it only reads existing state). Interactive click-accuracy verification (varying window size/aspect, `SafeAreaCrop` on and off) still owed — headless/non-interactive here, mouse-driven UI needs a live session.
|
||
|
||
**Status:** FIXED (pending interactive click verification). Cross-ref: D304 (fixed the same file's out-of-scan-range scroll-boundary bug, a separate root cause from both bugs above — see the comment trail in `overlayRowAtY()`/`overlayUpdateScroll()`).
|
||
|
||
## D317 — Crash when toggling Game.AllUnlocked back and forth in-game (user report, v0.3.0 playtest build, 2026-09-18) — no dump left behind.
|
||
|
||
**Reported:** "Toggling unlock all back and forth in game causes a crash." Reproduced by the user on the current-tip playtest exe (the one at `C:\Users\james\Desktop\ge007-v0.3.0-playtest`, includes D310/D311 fixes). **Repro detail (user, 2026-09-18):** level = **Runway**. **No crash dump or log was left in that folder** — hard exit, so Windows Event Viewer is the only system-side artifact to check.
|
||
|
||
**Closed as a symptom of D316, not a real crash.** Evidence:
|
||
- No WER report exists for the playtest exe; every `ge007` AppCrash in Windows Error Reporting traces to this repo's own `build-pc` dev runs, none after 12:00 that day, while the playtest session was ~13:15 — a real crash would have logged one.
|
||
- `Game.AllUnlocked` and `__QuitToDesktop` are the last two rows in the overlay's `rows[]` table (adjacent, `Game.AllUnlocked` directly above `__QuitToDesktop`).
|
||
- User confirmed (2026-09-18, asked directly) the exit was an **instant window close, not a hang/freeze** — consistent with `__QuitToDesktop`'s `ROW_ACTION` firing via the game's normal exit path, not a fault.
|
||
- The D316 fix's downward hit-test bias explains it directly: a toggle-off click on "All unlocked" (last visible row in most layouts) landing low enough would resolve to the row below it, `__QuitToDesktop`, producing a clean exit that reads as "it crashed."
|
||
|
||
Same fix as D316 resolves this (no separate code change). Verification: toggle `Game.AllUnlocked` on Runway ~5x post-fix, confirm it never falls through to Quit — owed as part of D316's interactive verification, not yet run (headless here).
|
||
|
||
(Separately noted, not part of this fix: Windows Error Reporting history does show 39 genuine heap-corruption crashes from this repo's own `build-pc` dev runs, latest dated 2026-09-13 — unrelated to the user's playtest exe/session, flagged here as a latent finding worth a future look, not a v0.3.0 blocker.)
|
||
|
||
**Status:** CLOSED — symptom of D316; the D316 fix resolves it. Re-open if a post-fix Runway toggle test still exits unexpectedly.
|
||
|
||
## D318 — Facility (regular play path): after Objective C (destroying the bottling tanks) completes, Ourumov freezes and does not shoot Trevelyan as he should (user report, 2026-09-18).
|
||
|
||
**2026-09-23 correction (D329):** the pin is a tick-granularity divergence, not a platform-invariant lock. It persists only at 1 sim tick per rendered frame (the port at 60 fps); a single frame of 2 or more ticks (GE's normal N64 regime) breaks it. Lockstep A/B (GE_D318B_CLK) on ai_19 and ai_9: clk=1 pins every time, clk=2 never confirms. Earlier WSL wall-clock pin rates are uncalibrated (mixed clk 1-6). See D329.
|
||
|
||
**Reported (user, 2026-09-18):** on Facility (`-level_34`), following the **regular play path**, Ourumov **freezes up and will not shoot Trevelyan when Objective C — destroying the bottling tanks — is completed**. The user states this is **definitely a bug** despite prior agent conclusions to the contrary.
|
||
|
||
**Contradicts D263's resolution.** D263 (v0.2.1) closed the same surface symptom as "not a port bug — proximity/LOS-triggered exactly as on N64", verified via a *teleport probe* that dropped Bond 65 units from Trevelyan and confirmed the full chain incl. the execution shot firing. But that verification was **synthetic** (teleport, not the authored play path), and D263's own refinement already noted the trigger is "significantly sensitive to Bond's precise location" and "not confirmed byte-identical to N64 for every approach". This report reaches the beat via the **authored objective sequence** (Objective C = bottling tanks destroyed) and sees Ourumov frozen with no shot — a materially different entry point than D263 tested. **Do not re-assert the "not a bug / finicky trigger" conclusion from D263 without fresh evidence from this specific path.**
|
||
|
||
**Investigated (2026-09-18/19): RESOLVED — as-authored latent race, faithfully reproduced by the port (NOT a port bug); port-side deadlock watchdog added (`port/src/d318watchdog.c`).** D310/D311 ruled out (no interaction). D263's mechanism map stands; this is a *different* failure mode than D263 tested — a derailment race, not the proximity trigger.
|
||
|
||
### Scene graph (byte disasm of ai_22, 384 bytes; `assets/obseg/setup/UsetuparkZ.c`)
|
||
|
||
Ourumov = chr 78 on list 0x0417 (ai_22). Key offsets: off=38 pre-aim at Trevelyan (`actor_aim_at_actor`, TARGET_AIM_ONLY|TARGET_DONTTURN → attacktype 0x64); off=44 gate-B wait loop (gas bit 0x04 → combat, 0x40 → flee, Bond-miss → combat, surrender 0x20 → dialogue cascade); off=72 dialogue cascade (~1320-tick timers) → off=272 label 0x13 = the execution shot `chr_hit_body_part_with_item_damage(0x43, 0x08, 0x06)` (headshot on chr 67); **off=302 label 0x2a** (combat section, reached when a gas/combat bit lands mid-monologue): off=304 Trevelyan-exists? → **off=307 `actor_fire_or_aim_at_target_update`** (succeeds only while ACT_ATTACK + AIM_ONLY|DONTTURN: sets attacktype/entityid + endframe, returns TRUE → off=321) → else falls to off=315 aim/fire at BOND → **off=321 label 0x01**: sleep → off=324 `if_guard_has_stopped_moving` (→ off=328 headshot on pass) → else loops back to 321.
|
||
|
||
### Root cause chain (all links byte-matched ROM logic)
|
||
|
||
1. **Pre-aim hold.** During the monologue c78 pre-aims at kneeling Trevelyan (atk=0x64). `chrlvInitActAttack` picks a RANDOM variant from the aim-animation group (`randomGetNext() % len`) and `modelSetAnimation`s it with `animlooping=0`.
|
||
2. **The hold pose is a permanent anim pin.** The aim anim plays to its endframe H (the hold frame). With `animlooping==0`, `modelTickAnim`'s wrap logic is inactive; when the frame reaches endframe, `modelConstrainOrWrapAnimFrame` clamps **both** `framea` and `frameb` to `ceil(H)` (`if (0 <= endframe < frame) frame = ceilFloatToInt(endframe)`), so `framea == frameb`, and the tail of `modelSetAnimFrame2WithChrStuff` (`if (vb == va) { animframe1 = va; }`) then discards all sub-frame progress: the model is pinned at H forever. That is how a hold pose works by design — normally something else changes the attack state next.
|
||
3. **The derail.** Objective C (bottling tanks) leaks gas → ai_42 sets obj bit 0x04 (~300 ticks after the leak). If that lands while c78 is in the pre-aim hold, ai_22 derails to section 0x2a and off=307's update command SUCCEEDS: attacktype becomes 0x04, entityid becomes Trevelyan (67), and `chrlvAttackActionRelated` sets a NEW endframe from the variant's recoil/shoot start — **but it never calls `modelSetAnimation`**. The model stays pinned on the aim-hold pose.
|
||
4. **The branch (set by the random variant from step 1):**
|
||
- new endframe ≤ H: next tick, `chrlvTickAttackCommon`'s `endframe <= frame` block fires and its unk31 path (`chraction.c:7209`) calls `modelSetAnimation` again → pin broken → anim advances into the shoot window → c78 live-fires Trevelyan → he dies (~230 ticks) → attack sequence completes → ACT_STAND → the off=324 stop check passes → scene continues (combat state).
|
||
- new endframe > H: **nothing ever calls `modelSetAnimation` again** (probe-verified: zero calls in frozen runs). Frame stays pinned at H < shoot_start, c78 never fires, Trevelyan never dies, and the off=324 stop check (`chrHasStoppedOrPatroling`, TRUE only for ACT_STAND/ACT_ANIM/ACT_PATROL) can never pass while c78 is in ACT_ATTACK → **PERMANENT SOFTLOCK**.
|
||
|
||
### Evidence (probe runs, `scratch/d318_*.log`)
|
||
|
||
- `modelTickAnim` per-call trace: frozen state ticks every frame (`numticks=1, speed=0.68`) but `animframe1` is byte-identical tick to tick (e.g. 19.00 → 19.00) — the pin is in the model layer, not the AI/attack state machine.
|
||
- `modelSetAnimation` funnel trace: frozen runs have **zero** calls post-derail; escape runs' unpin call resolves via addr2line to `chrlvTickAttackCommon` (chraction.c:7209, the unk31 path).
|
||
- Pre/post-derail contrast: frozen run pinned at 19.00/end=19.00 → post-derail end=24.00 (24 > 19 → never crosses); escape run pinned at 22.00/end=22.00 → post-derail the unk31 path re-inits at startframe=22 (end ≤ frame) → advances 24→27→44, c67 takes damage, dies t≈2930-2960.
|
||
- Non-determinism is by design: the variant pick in step 1 is `randomGetNext()`; forced-bit repros froze ~2/3 of runs. The organic path (user's report; playtest2) hit the frozen branch with no artificial forcing.
|
||
|
||
### Classification
|
||
|
||
**As-authored latent race, not a port bug.** Every link — ai_22 flow, the update command's missing re-init, the `framea==frameb` pin, the ACT_ATTACK-gated stop loop — is byte-matched ROM logic.
|
||
|
||
**N64 manifestation — CORRECTION (2026-09-22, user challenge).** The earlier claim "the N64 has the identical race and the identical unrecoverable softlock" was *inference* from byte-matched logic, never observation — we cannot instrument an N64. The user's empirical prior (and ~30 years of community play with no such known bug) is that guards do **not** freeze on real hardware. For that to hold, the derail must effectively never occur on N64 — i.e., the gas bit never lands inside c78's pre-aim hold window there. But that window is player-timing-gated (tanks destroyed during the hold) and tick counts are platform-invariant, so if the port were fully tick-faithful upstream of this beat, an organic PC freeze should reproduce on N64 with the same play. Two live hypotheses: **(i) an upstream port divergence** (timing shim / scheduler phase / earlier state misread) opens a derail window on PC that never opens on N64 — in which case the freeze is a *port-exposed* artifact and the real fix is finding that divergence; **(ii)** it can occur on N64 but has simply never been observed (narrow window). **Discriminating test (revised 2026-09-22.)** Primary — **deliberate timing on an unthrottled emulator**; the derail window is *player-controllable*, so no instrumentation is needed. Per D321 the combat bit lands 212 ticks (~3.5 s) after the last tank destruction, and c78's vulnerable span runs from pre-aim (off=38) through the monologue loop (off=75, check re-evaluated continuously; ~22 s). So: on an **unthrottled emulator** (stable 60 Hz tick regime — the same regime as the port; Mupen64Plus-Next/Project64 at full speed) with save states, trigger the Ourumov scene, then destroy the last tank ~1–15 s into the monologue; repeat with fresh loads to sample the aim-variant lottery. **Freezes ≈ the port's forced-repro rate (~2/3)** → the race is reachable under stable ticks; "N64 never freezes" is an organic-timing/frame-drop story (hypothesis ii; watchdog = insurance, no port bug). **Never freezes across ~10 deliberate placements** → the mechanism differs environmentally (hypothesis i) and there is a genuine upstream port divergence to hunt. Control first: confirm the *port* freezes at the expected rate under the same deliberate timing (forced-repro probe can calibrate this headlessly). Secondary — **PRNG-stream A/B** (only if the emulator never freezes and hypothesis (i) needs hunting): the port already ships `GE_RSEED_LV` (`port/src/d318watchdog.c`) + DET seed logging — read `g_randomSeed` from N64 RAM `0x80024460` at a canonical moment (emulator memory viewer; that read is the only console-side step), inject it with `GE_RSEED_LV=<hex>@<tick>`, replay the same play, and compare DET lines: any divergence pinpoints the tick where the port's stream leaves the original's. The watchdog remains justified either way as a port-side liveness measure: it acts only in a state where the game otherwise hard-locks.
|
||
|
||
### Fix (port-side, `port/src/d318watchdog.c` — same pattern as D202 M-66b)
|
||
|
||
Per-tick check (hooked at the `g_GlobalTimer += g_ClockTimer` site in lv.c). Signature, all held for 600 consecutive ticks (10 s; the escape path resolves in < 2 s): chr 78 on list 0x0417 with offset in [321, 333), `actiontype == ACT_ATTACK`, `act_attack.entityid == 67` (targeting Trevelyan — legitimate combat at this offset targets Bond, ent=1), and chr 67 alive. On confirm: **re-initialize c78's attack via `sub_GAME_7F025560(c78, attacktype, entityid)`** — the same entry point the game itself uses for a fresh attack (chrlvTickAttack type_of_motion==2, chraction.c:7401). That runs `chrlvInitActAttack` → random variant + `modelSetAnimation(start_frame)`, breaking the pin; c78 then live-fires Trevelyan exactly like the natural escape path. One-shot per OCCURRENCE: after firing, the watchdog re-arms once the signature has been absent for 600 consecutive ticks (~10 s), so a later replay of Facility in the same session (AllUnlocked) is still covered — added 2026-09-19 during the v0.3.0 release review (M-192 item 3; previously one-shot per process, which would have left a replayed Facility unprotected). Logs `D318W:` lines for playtest audit; opt-out `GE_D318W=0`.
|
||
|
||
**Why not just kill Trevelyan:** first attempt applied the scripted headshot's own damage call (`handles_shot_actors(c67, 0x08, &zero, 0x06, FALSE)` — ai_22 off=333). c67 died, but a pinned attack has **no completion event tied to target death** — c78 stayed in ACT_ATTACK and the stop check still never passed (probe-verified: act=8 in 100% of samples post-kill vs 32/264 ACT_STAND samples in natural-escape runs). The attack state itself must be re-seeded.
|
||
|
||
**Why not an in-src fix:** making `actor_fire_or_aim_at_target_update` call `modelSetAnimation` would change behavior relative to N64 ground truth in *normal* play (Rule 2, no sign-off basis — there is no port divergence to correct). The watchdog is inert in every non-deadlocked state.
|
||
|
||
### Verification
|
||
|
||
- Forced-bit repros with the final watchdog: escape runs stay silent (c67 dies t≈2930-2960, 60× under threshold); a frozen run armed at t=2702, fired at t=3300, c67 died t=3570 from c78's own gunfire, and c78's post-recovery action profile (ACT_STAND/ATTACK/ATTACKWALK mix, moving) matches natural-escape runs.
|
||
- Plain headless Facility run (no env): zero D318 lines, no errors, clean shutdown.
|
||
- Interactive playtest (`scratch/d318_playtest3.log`, 2026-09-19): organic derail occurred; the run drew the escape variant, so c78 broke the pin on his own and killed Trevelyan within seconds. The watchdog detected the signature twice (t=7496, t=9270) but never confirmed — both windows cleared before 600 consecutive ticks → zero false positives in live play.
|
||
- Diagnostic probes removed (`port/src/d318probe.c`, `port/include/d318probe.h`, and the `#ifdef PORT` hooks in chrai.c/chraction.c/lv.c/model.c); only the permanent watchdog hook remains (lv.c).
|
||
|
||
**Status:** RESOLVED (2026-09-19). As-authored latent race documented; port-side watchdog ships by default. Trigger chain upstream of the derail (tanks → combat bit) fully mapped in D321 — PC behavior confirmed faithful to N64. Cross-ref: D263 (mechanism map — stands), D321 (trigger chain + live validation), D202 M-66b (port-side expiration precedent).
|
||
|
||
## D319 — Frame cap slider was mostly a dead zone; replaced with a plain 30/60 toggle (user ask, 2026-09-18).
|
||
|
||
**Reported:** user noticed `Video.FpsCap` "does not work below 30fps, it also does not go above 60fps" and asked whether that's a toggle-vs-refactor question.
|
||
|
||
**Not a bug — the slider just exposed a huge dead zone.** Per D186, the port's own frame-pacing wait runs inline on the game's scheduler thread, so:
|
||
- Below 30, `video.c` already force-uncaps the value (D186 guard) — the sim would otherwise be throttled, not just presentation.
|
||
- Above 60 (50 PAL), the sim's own tick pacemaker (`port/src/libultra.c`, hardcoded to the console's native VI rate) never produces a new frame faster than that anyway, so any slider value there was a silent no-op.
|
||
|
||
Only 30, 60, and 0 (uncapped: skip the port's frame-pacing wait entirely) ever did anything. Uncapped was considered as a third UI state but dropped after discussion: with `Video.VSync` on (the default) it's indistinguishable from 60, and with VSync off it just burns GPU re-presenting the same simulated frame — a confusing option with no real player benefit.
|
||
|
||
**Fix:** `port/src/optionsoverlay.c` — new `ROW_FPSCAP` row kind (mirrors the existing `ROW_MSAA` fixed-cycle pattern) cycling `Video.FpsCap` between exactly 30 and 60; the free 0-360 `ROW_SLIDER` and its fill-bar are gone for this row. `port/src/video.c` — default changed from `0` (uncapped) to `60` so a fresh install shows a real, reachable menu value instead of a state the toggle can no longer select. The `0`/uncapped value still works at the config level (old inis, hand-edits) — `valueText` still labels it "Uncapped" if seen — it's just not reachable from the menu any more.
|
||
|
||
Verified: build clean (MSYS2 MINGW64/ninja); `tools_pc/verify.sh bunker1` — PASS, `worst_cell=0`. Interactive check (toggle Frame cap in the F10 menu, confirm it reads 30/60 and only those two) still owed.
|
||
|
||
**Status:** FIXED (pending interactive confirmation). Cross-ref: D186 (root cause of both dead zones, this just fixes the UI's exposure of it).
|
||
|
||
## D320 — D318-class sweep: aim-hold → `_update` AI lists in 8 levels carry the same as-authored softlock race (static analysis, 2026-09-20).
|
||
|
||
**2026-09-23 correction (D329):** the pin is a tick-granularity divergence, not a platform-invariant lock. It persists only at 1 sim tick per rendered frame (the port at 60 fps); a single frame of 2 or more ticks (GE's normal N64 regime) breaks it. Lockstep A/B (GE_D318B_CLK) on ai_19 and ai_9: clk=1 pins every time, clk=2 never confirms. Earlier WSL wall-clock pin rates are uncalibrated (mixed clk 1-6). See D329.
|
||
|
||
**Context.** D318 (Facility ai_22) root-caused a permanent softlock to a *generic* game-code mechanism, not to anything Facility-specific: `actor_fire_or_aim_at_target_update` (`chraction.c:4882`) succeeds whenever the chr is in ACT_ATTACK with `attacktype & (TARGET_AIM_ONLY | TARGET_DONTTURN)`, retargets, and calls `chrlvAttackActionRelated` — which only changes the model's **endframe** and **never calls `modelSetAnimation`**. If the chr is pinned on a non-looping aim-hold pose whose endframe H sits *before* the variant's shoot window (`shoot_start_frame`), and the new endframe E lands above H, nothing ever re-inits the animation (the `chrlvTickAttackCommon` "endframe <= frame" block's re-init sub-branches are all gated out: `unk31==0`, `unk33 != unk34`) → the model stays pinned below the shoot window → the timer/frame-gated fire events (`shoot_start <= frame < shoot_end`) never open → the chr never fires. If the surrounding AI script's only escape is an `if_guard_has_stopped_moving` gate, it can never pass: `chrHasStoppedOrPatroling` (`chraction.c:4291`) returns FALSE for ACT_ATTACK. **Permanent softlock.** Every link is byte-matched ROM logic. (N64 manifestation unverified — see D318's 2026-09-22 correction: the user's empirical prior is that no such N64 freeze exists; the same upstream-divergence-vs-unobserved question applies to every list in this sweep.)
|
||
|
||
**Sweep.** All 21 levels' AI script sources (`assets/obseg/setup/*.c`) scanned for lists containing both an aim command (`guard_try_fire_or_aim_at_target[_kneel]`, BITFIELD byte with `TARGET_AIM_ONLY` 0x20 set — the hold pose) and a `guard_try_fire_or_aim_at_target_update` reachable while the hold is active. 17 lists in 8 levels match; control-flow checked per list:
|
||
|
||
| List | Level | Hold target | Update | Escape gate after update | Assessment |
|
||
|---|---|---|---|---|---|
|
||
| ai_22 | Facility | chr 0x43 (Trevelyan) | 0x04→0x43 ×2 | stop-check loop (off=321–333) | **D318 — confirmed live, watchdog shipped** |
|
||
| ai_19 | Facility | chrs 0x44–0x48 / pad 0x6d (kneel-aim cascade, 0x24/0x28 AIM_ONLY) | 0x04→chr / 0x08→pad | label 0x34 loop: own-flag bit 0x01 → `jump_to_ai_list(0xfd, 0x1504)` (the only progression); stop-check → re-aim at 0x08 (loops) | **HIGH — same non-self-healing shape.** Long monologue (text prints + local timers) precedes the hold; if pinned, ACT_ATTACK fails the stop check and nothing in this list sets its own flag bit 0x01 (the only `chr_bitfield_set_on(…, 0x01)` in the level targets chr 0x43). Whether progression actually gates on list 0x1504 is probe-verify territory. Second softlock point in the same level as D318. |
|
||
| ai_9 | Control | chr 0xFC (aim or kneel-aim, random pick) | 0x04→0xFC | label 0x0b: chr 0xFC dying/dead (i.e. *killed by this guard's shot*) **or** stop-check → re-aim at 0x04 | **HIGH — near-exact ai_22 mirror**: pre-aim hold → local-timer wait (`if_local_timer_greater_than(0x780000)`) → update → fire loop gated on the target dying. Pinned guard never fires → target never dies → stop check (ACT_ATTACK) never passes → softlock before `jump_to_ai_list(0xfd, 0x0d00)`. |
|
||
| ai_12 | Depot | chrs 0x0F then 0x0E (aim / kneel-aim, 0x24 AIM_ONLY) | 0x04→chr ×2 | after update: scripted `chr_hit_body_part_with_item_damage` kills the target, but the **objective bit** (`objective_bitfield_set_on(0x00080000)`) is only set after an `if_guard_has_stopped_moving` loop *and* a own-flag-0x01 wait | **MED-HIGH.** The kill itself is scripted (unlike ai_22/ai_9), so the scene visibly "progresses" — but if pinned, the guard wedges in ACT_ATTACK at the stop-check loop and the objective bit never lands → level-complete softlock. Second pass: the update lands mid-hold *by construction* — `AI_TRYFireOrAimAtTarget` jumps straight to the update on `actor_aim_at_actor` success, so the `if_guard_is_firing_and_using_180_range_flag` wait between them is unreachable on this path (and 0x24 carries no DONTTURN bit anyway). |
|
||
| ai_13/14/15 | Depot | pad 0x1601 (AIM_ONLY) | 0x08→pad | after update: infinite sleep loop (`goto_first(0x06)`), no progression dependency in-list | **LOW-MED.** No in-list gate, but if the level elsewhere requires these guards' shots to hit pad 0x1601 (destructible/trigger), a pinned guard blocks it. Verify against the Depot objective graph before dismissing. |
|
||
| ai_34 | Facility | Bond (0x21) ×4 | 0x01→Bond | re-aim every cycle (label 0x05 → fresh AIM command) | **Likely safe — see below.** |
|
||
| ai_0 | Aztec | Bond (0x21)/(0x01) | 0x01→Bond | combat loop, re-aims each cycle | Likely safe. |
|
||
| ai_11 | Control | Bond (0x21) ×2 | 0x01→Bond ×2 | combat loop | Likely safe. |
|
||
| ai_3 / ai_14 | Egyptian | Bond (0x21)/(0x01) | 0x01→Bond | combat loop | Likely safe. |
|
||
| ai_10 | Depot | Bond (0x21) | 0x01→Bond | combat loop | Likely safe. |
|
||
| ai_17 | Streets | Bond (0x21) ×2 | 0x01→Bond ×2 | combat loop | Likely safe. |
|
||
| ai_20 | Bunker | Bond (0x21) | 0x01→Bond | combat loop | Likely safe. |
|
||
| ai_24 / ai_32 | Surface | Bond (0x21) | 0x01→Bond | combat loop | Likely safe. |
|
||
|
||
**Why the Bond-combat loops are probably not affected.** In normal sustained fire the aim-hold endframe sits *at/inside* the shoot window (the hold pose is the recoil pose; shots fire from the `attack_time`/`unk44` timer machinery in `chrlvTickAttackCommon`, not from frame progression past endframe), so a `_update` 0x21→0x01 keeps the model pinned *inside* the firing window and the guard keeps shooting — that is how continuous fire works by design. The D318 danger requires the hold pose to be a distinct pre-shoot pose (H < shoot_start) — which is what "aim at X but don't fire yet" scripts produce. These combat lists are also exercised on every playtest of every level with no observed freezes, which is strong empirical evidence. This distinction is inferred from code reading + playtest history, **not** probe-verified per list.
|
||
|
||
**Classification.** As-authored latent race(s) in the byte-matched state machine; PC manifestation now confirmed by forced repro (below). N64 manifestation unverified — inherits D318's 2026-09-22 correction (upstream-divergence-vs-unobserved; A/B against an N64 capture is the discriminating test). No `src/game` fix is warranted or permitted absent Rule-2 sign-off.
|
||
|
||
**Forced-repro results (2026-09-22, headless).** Method: probe `port/src/d320repro.c` (env-gated `GE_D320R`, TEMP) hijacks a named guard's AI list + offset at chosen ticks in a bare `-level_XX` boot. **Detector fix first:** the D318T timeline scanned only `g_ActiveChrs`, which holds *background script entities only* (chrnum 0xFE, allocated from AI lists with ID ≥ 0x1000 in `deb_loadallmodels.c`) — it was blind to every real guard. Real guards live in `g_ChrSlots[0..g_NumChrSlots)`; the detector now builds a unified pointer array over both (both are ticked by `chrlvAllChrTick`, chraction.c:9539).
|
||
- **ai_19 (Facility): PIN CONFIRMED.** c5 pinned at off=483 (label 0x34 loop), act=8, atk=0x8, ent=109 (pad 0x6d), frame stable 30.00/23.00 for thousands of ticks.
|
||
- **ai_9 (Control): PIN CONFIRMED, variant-dependent** (~2/3 of seed variants pin; the rest escape — the same random-variant structure as D318). Pinned at off=69 (label 0x0b loop), act=8, atk=0x04, ent=252, frame stable 23.00.
|
||
- **ai_12 (Depot): CLEAN 3/3.** The scripted `chr_hit_body_part_with_item_damage` kill progresses the beat to off=267+ regardless of pin state; no objective-block observed under forced repro → deliberately NOT covered by the watchdog.
|
||
|
||
**Watchdog generalized (`port/src/d318watchdog.c`).** Table-driven entries: `{0x0417, [321,333), ent=67, c67-alive}`, `{0x0414, [480,548), any}`, `{0x040a, [60,86), any}`; detection = ACT_ATTACK + live-fire attacktype (no TARGET_AIM_ONLY bit) + offset in range [+ per-entry target-alive condition], held 600 ticks; recovery = `sub_GAME_7F025560(chr, attacktype, entityid)` (the game's own fresh-attack entry). **Repeat rescue:** a successful reseed lets the attack play out → stop-check passes → the list re-aims → a new variant can re-pin (~240 ticks later), so if the signature persists the watchdog re-fires every 360 ticks, uncapped. Verified on both Facility and Control forced runs: continuous rescues at ~600-tick cadence, guard never frozen >~6 s. D318's entry behavior is unchanged by the refactor (code path re-verified in a live playtest is still owed). Fidelity note: detection + one-shot rescue is faithful (the N64 escape path does exactly this re-init); the *uncapped repeat* is an intentional port-side accommodation for the forced/context-dependent re-pin — same class as D318/D202 M-66b; logged (`D318W:`) for playtest audit.
|
||
|
||
**Observation (single, not reproduced).** Depot bare headless boot: c0 on ai_10 (a TREED guard list) pinned at off=49, act=8, atk=0x1 (live-fire), ent=0, frame stable 33.00 for 1200+ ticks — a combat-loop list the sweep rated "likely safe." Not reproduced on re-run. If seen in playtests, the "combat loops are safe" inference needs revisiting; severity is low either way (a frozen combat guard doesn't block objectives — the player can kill him).
|
||
|
||
**Status:** RESOLVED (2026-09-22) — pins confirmed on ai_19 + ai_9, clean on ai_12; generalized table-driven watchdog shipped. Open threads: (a) N64-fidelity classification per D318's 2026-09-22 correction (unthrottled-emulator deliberate-timing A/B); (b) the Depot ai_10 observation; (c) ai_13/14/15 pad lists untested (LOW-MED). Cross-ref: D318 (mechanism + watchdog), D263 (ai_22 mechanism map).
|
||
|
||
**Second-pass verification (2026-09-20, review of this entry).** All mechanism claims re-checked against source and confirmed: `chrHasStoppedOrPatroling` (chraction.c:4291) returns TRUE only for ACT_STAND/ACT_ANIM/ACT_PATROL; `actor_fire_or_aim_at_target_update` (chraction.c:4882) succeeds on ACT_ATTACK + (AIM_ONLY\|DONTTURN) and calls `chrlvAttackActionRelated`, which only ever calls `modelSetAnimEndFrame` (recoil/shoot start or end frame) — never `modelSetAnimation`. The three high/med-high lists were re-read in full: ai_19 (UsetuparkZ.c 2630–2826), ai_9 (UsetupcontrolZ.c 2279–2313), ai_12 (UsetupdepoZ.c 2174+) — structures match the table above, with the two precision corrections applied to the ai_19/ai_12 rows. **AI command-ID mapping settled:** `bondaicommands.h`'s explicit IDs are the cmdbuilder enum values from `aicommands.def` order (cross-checked on four known commands; a naive `#define CMDNAME` count is off by 2). In particular `if_guard_bitfield_is_set_on` = ID 0x96 = `AI_IFMyFlags2Has` — it tests the **executing guard's own** flags2 (`AiIFMyFlags2HasRecord {cmd, BITS, GOTOLABEL}`, handler chrai.c:3146), and `guard_bitfield_set_on` = 0x94 = `AI_SetMyFlags2`. The per-chr-targeting family is separate (0x97–0x99, `chr_bitfield_*`). `AI_TRYFireOrAimAtTarget[Kneel]` jumps to GOTOLABEL on success, falls through on failure (chrai.c:1372+). TARGET_ flag decode for the hold commands: 0x24 = CHR\|AIM_ONLY, 0x28 = PAD\|AIM_ONLY (hold poses); updates set 0x04/0x08 (live fire, no AIM_ONLY).
|
||
|
||
(Superseded 2026-09-22 — see the RESOLVED status line at the end of this entry.)
|
||
|
||
## D321 — D318 trigger chain fully mapped: tanks → combat bit is an authored ~3.5 s gas-cascade delay (chr 254 script); derail lands same-tick; PC behavior confirmed faithful to N64 (probe captures, 2026-09-20).
|
||
|
||
**2026-09-23 correction (D329):** the pin is a tick-granularity divergence, not a platform-invariant lock. It persists only at 1 sim tick per rendered frame (the port at 60 fps); a single frame of 2 or more ticks (GE's normal N64 regime) breaks it. Lockstep A/B (GE_D318B_CLK) on ai_19 and ai_9: clk=1 pins every time, clk=2 never confirms. Earlier WSL wall-clock pin rates are uncalibrated (mixed clk 1-6). See D329.
|
||
|
||
**Question.** User's N64 memory: Ourumov shoots Trevelyan the moment the tanks are blown up. PC playtests looked like he waited for the countdown to finish, then paused ~10 s, then shot. D318 covered the pin + watchdog; this entry closes the upstream question — *when and why does the combat bit `0x04` land, and is the PC trigger chain faithful?*
|
||
|
||
**Probes (all env-gated, port-only, capped, read-only; post-release removal candidates per M-192 item 7):**
|
||
- `GE_OBJT` in `chrSetStageFlags`/`chrUnsetStageFlags` (`chraction.c`): logs every low-16-bit write to `objectiveregisters1` with tick, writing chr, its actiontype/aioffset, and its AI-list ID (`chraiGetAIListID`).
|
||
- `GE_OBJT` in the `PROPDEF_GAS_RELEASING` destruction handler (`propobj.c`): timestamps each tank destruction.
|
||
- Existing `GE_D318T` timeline (`port/src/d318watchdog.c`) for correlation.
|
||
|
||
**Findings (three playtest captures, Facility `-level_34`):**
|
||
1. The level setup script (`UsetuparkZ.c`) never writes the low bits of `objectiveregisters1` — only character-action scripts can, via `chrSetStageFlags`. Writers observed: chr 67 (Trevelyan) sets `0x100`, `0x02`, and `0x20` (monologue); **chr 254** (`ACT_BONDMULTI`, aiid=0x1001, off=15 — a scripted gas-cascade entity) sets **`0x40` (flee) + `0x04` (combat) together, same tick**.
|
||
2. With tank-destruction timestamps: all 10 tanks destroyed t=7544–7634; the bits land t=7846 — **Δ = 212 ticks (~3.5 s) after the last tank**. This is a fixed ROM script delay in chr 254's cascade, identical data on the N64 — not a port-side lag.
|
||
3. The derail at ai_22 off=75 fires **on the same tick the bit lands** (run A: t=8807→8808; run B: t=7846→7847). While c78 waits in the monologue loop at off=75 the check is re-evaluated continuously (heartbeat-verified), so there is no additional polling latency.
|
||
4. Run B took the **natural escape**: Trevelyan died the same tick as the derail, zero `D318W` lines — i.e. "blow up the tanks → shot ~3.5 s later" is exactly what this build does, matching the user's N64 memory. Runs A and the original 09-18 report rolled the **pin branch** (random aim variant, D318 step 4), where the ~10 s felt by the user was the watchdog's 600-tick confirmation window breaking an anim pin the N64 would soft-lock on permanently.
|
||
|
||
**Conclusion.** No port bug; no `src/game` change warranted or needed. The perceived delay decomposes into (a) the authored ~3.5 s gas cascade + (b), only on the pin branch, the watchdog confirmation window. The PC trigger chain is faithful to the N64 end-to-end.
|
||
|
||
**Optional tuning (deferred, user's call).** `D318_DEADLOCK_TICKS` 600→300 would halve the worst-case pin-branch pause (10 s → 5 s); the margin over the natural escape (<2 s; run B: 0 ticks) stays ≥2.5×. One-line `#define` in `port/src/d318watchdog.c`; not done pre-ship to avoid re-validating the watchdog on the release branch.
|
||
|
||
**Status:** CLOSED — trigger chain mapped and faithful *at the script-data level* (the ~3.5 s cascade delay and same-tick derail are identical ROM data on both platforms); D318 watchdog validated in live play (fired + recovered in run A, correctly inert in run B). Caveat per D318's 2026-09-22 correction: "faithful end-to-end" does not establish that the derail window ever opens organically on N64 — that remains the open upstream-divergence-vs-unobserved question. Cross-ref: D318 (pin mechanism + watchdog + N64-manifestation correction), D320 (sweep of the same race in 8 levels), D263 (ai_22 mechanism map), M-192 (release review).
|
||
|
||
## D322 — Long-session audio degradation: full campaign on v0.3.0, audio progressively worsens (Silo → Caverns/Cradle: OST inaudible, SFX "come and go"); restart restores it (issue #87, user report, 2026-09-20)
|
||
|
||
### Report (verbatim, issue #87 comment, v0.3.0 build)
|
||
|
||
> I just did a full run on 00 agent including Aztec and Egyptian in one shot :) It definitely felt better overall but the audio really started to struggle in the final levels again, but i made it through to the end. Interestingly, exiting the game and starting it back up again let the audio go back to normal. Feels like something about long gaming sessions throws off the audio after a while. [...] i think Silo was the first instance i noticed some audio sync issues popping up (possibly from me spamming the KF7). But it wasn't until the end around caverns / cradle that it became very noticeable (like to the point you couldn't hear the background OST and the audio felt like it came and went)
|
||
|
||
### Symptom profile (what the report tells us before any code)
|
||
|
||
- **Onset is gradual and session-scoped:** fine early, first wobble ~Silo (mission 2 of the campaign arc, heavy firefights), severe by Caverns/Cradle (final levels). Not level-specific — a fresh boot of any single level has never shown this (every `-level_XX` sweep + single-level audio run since D198–D207 is clean).
|
||
- **Two distinct perceptual halves:** (a) *music* — "couldn't hear the background OST"; (b) *SFX* — "came and went". On this port both music and SFX draw from the same 24-voice physical synth pool (`alSynAllocVoice`, `src/libultra/audio/synallocvoice.c`); SFX additionally sit under an 8-voice soft cap (`g_sndAllocatedVoicesCount` vs `maxSounds`, `src/snd.c:328`). One accumulator can therefore produce both halves at once: cap pinned → new SFX take the preempt/drop path ("come and go"); physical pool depleted → music note-ons steal from or fail against each other (OST inaudible).
|
||
- **Restart cures it** → process-lifetime accumulation, not a per-level fault and not a slow drift that a level reload would clear. (Level reloads during the run did NOT cure it — the user kept playing through.)
|
||
- Precedent: the v0.2.2-era report in the same issue showed the same shape ("audio got wonky right around streets [...] kept getting worse up until [...] control center") — so this is a long-standing class, improved by v0.3.0's audio fixes but not eliminated.
|
||
|
||
### Static triage (this session — what was checked and what it rules in/out)
|
||
|
||
1. **Level teardown IS thorough (and is N64-identical code).** The level-exit cleanup (`src/game/lv.c` ~1650–1675) deactivates: MP sound state, `cleanupObjectSounds()` (all `sfx_related[i]`), `cleanupAlarms()`, `cleanupSFXRelated()` (tank SFX), `cleanupplayersoundrelated()` (both hands per player). So the *owned* loop classes with known owners are released at level end — on PC and N64 alike. This does NOT rule out leaks: it rules out "teardown simply forgets a class".
|
||
2. **`port/src/mixer.c` is stateless per frame** (DMEM scratch + per-voice saved envelope state in DRAM; `mixerInit` zeroes everything at boot). No session accumulator lives in the mixer itself. Reverb delay lines are the only cross-frame DRAM state and were ruled out as a drone source in M-65 (`GE_NOWET`), though that was a short single-level run.
|
||
3. **D202's known leak class is covered — but only for one shape, and only validated on single-level runs.** M-65's guard reclaims *ownerless, non-retrigger, LOOPED* voices under pool pressure; M-66b's `AL_SNDP_PORT_EXPIRE_EVT` fades out ownerless infinite loops (ADPCM `count == -1`) ~2.5 s after start. Both were measured on `-level_09` only (≤134 s). Shapes NOT covered by either: (a) a looped voice whose owner pointer is non-NULL but whose owner record was freed without deactivating it; (b) an ownerless loop whose wave carries no `count == -1` ADPCM loop (finite-loop or non-looping sustained envelope that never reaches its end state); (c) any sound whose envelope/voice state machine wedges mid-decay so no END_EVT is ever posted. M-65 also explicitly banked: **"it is NOT known why the N64 does not leak here"** — the ownerless-loop accumulation is already a documented port deviation, and a full campaign is simply 10–15× more exposure of it.
|
||
4. **A pool/list desync is already on record.** The D305 guard comment in the preemption scan (`src/snd.c` ~352) records that `limitReached` was observed with the tracked-voice list EMPTY — "the pool-exhaustion accounting and this list have desynced by some still-unidentified mechanism." If `g_sndAllocatedVoicesCount` drifts high over hours (any missed decrement on a dispose path), `limitReached` becomes permanently true: every new SFX goes through the preempt scan, finds nothing stealable (all survivors looped/RETRIGGER/PREEMPT), and is dropped — exactly "came and went" — while the physical pool separately depletes for the music half.
|
||
5. **Queue starvation is the alternate class.** `port/src/audio.c`: if production falls below 22050 Hz real-time (the 30 Hz audio tick starved by a slowly-degrading main loop), the SDL queue underruns and the device pads silence → choppy "come and go" with music most affected (it is the only continuous signal). The D204 monitor (`rt=`, `q=`, `drop=`) distinguishes this in one capture. Note D248/D250 (frame-pacing) are fixed, but a *gradual* main-thread slowdown over hours (growing lists, leaked entities) would show up here rather than in any single-level test.
|
||
6. **evtq saturation (third candidate).** The ALEventQueue has 64 slots and `alEvtqPostEvent` silently drops posts when full (M-71). Anything that re-posts every 33 ms (WAIT_VOICE retries for looped sounds, D207's alarm) while the queue is busy can starve music/SFX event delivery. The new probe reports occupancy.
|
||
|
||
**Fidelity framing:** the N64 also runs a full campaign in one process, so hypothesis (1)-class leaks are either (a) port-side accumulation amplified by the documented M-65 deviation, or (b) an original quirk the N64's exact 30 Hz/VI-paced audio cadence never triggers. Diagnosis is unrestricted per AGENTS.md; the fix bucket (port vs rule-2) follows whichever mechanism the capture identifies. (5)/(6) are port-specific by construction.
|
||
|
||
### Probe shipped (this session)
|
||
|
||
`GE_D322=1` — one line per 5 s of wall clock from `port/src/audio.c` (`audioSetNextBuffer`, ~30 Hz cadence, campaign-safe): `sfx=count/max` (8-voice soft cap), `evtq=n/64`, `pv free/lame/alloc` (24 physical voices via new `sndD322PoolSummary()` in `src/snd.c`, lists walked under the D285 lock), plus `q=queued/limit drop=n` (pipeline). Read with `GE_D204=1` for `rt=` (production rate; <1.0 sustained = starvation).
|
||
|
||
**Probe smoke-tested 2026-09-20:** 40 s headless `-level_09` run, no crash, pools coherent (`pv free+alloc = 24`, `sfx=0/8` idle, `evtq=0`). (First build faulted: the probe took the address of `ALSndPlayer.drvr`, which is a *pointer* field, not an embedded synth — fixed with a NULL guard for pre-sndInit.)
|
||
|
||
### Evidence ask (user, next campaign run)
|
||
|
||
Full campaign (or at minimum Silo → Caverns/Cradle) with **`GE_D322=1 GE_D204=1`** set, sharing the resulting `ge007.log`. Decision rules:
|
||
- `sfx` ratchets up level-over-level and/or stays elevated through quiet sections → hypothesis 4 (counter drift / leaked SFX states). Follow-up: short `GE_AUDIOTRACE=1` run in a late level to pair `[VOICE+]`/`[VOICE-]` and name the holding soundIndex(es).
|
||
- `pv alloc` climbs and never returns to baseline → physical-voice leak (music half); same follow-up names the owners.
|
||
- `rt` < 1.0 sustained with `q` → 0 in the bad sections → hypothesis 5 (starvation); then profile what slows the main loop over a long session.
|
||
- `evtq` pinning at 64 → hypothesis 6; correlate with WAIT_VOICE re-posters.
|
||
- All pools healthy while audio is bad → back to the mixer/reverb state classes (re-open M-65's ruled-out list with a long-session `GE_AUDIODUMP`).
|
||
|
||
**Status:** OPEN — static triage complete, probe shipped, awaiting campaign capture. Cross-ref: D202/M-65+M-66b (ownerless-loop leak class + expiration), D207 (8-cap starvation design notes), D305 (pool/list desync observation), D204 (pipeline pacing + `GE_D204` monitor), D248/D250 (frame pacing, fixed), issue #87.
|
||
|
||
### M-197 (v0.4.0 Phase 1, 2026-09-21): D75 root cause — the D50 model-sidecar converter dropped embedded texture blobs; fix + live verification
|
||
|
||
**Symptom (inherited from M-33/M-195/M-196):** on the intro's Nintendo-logo screen the 3D `logoinst` model rendered as a tiny orbiting fragment (0–33 content px/frame) — M-195 proved emission + vertex transform correct, M-196 refuted G_VTX slot-overwrite and narrowed to "texture-modulate material rendering black" with an open anomaly (`utex`/`comb->used_textures[0]` reading 0 at emit despite `gfx_generate_cc` setting it).
|
||
|
||
**Step 1 — the anomaly was a probe artifact.** The D75D frame-level fields are first-write-wins per frame: `utex`/`cutex` captured on the *first* emitted triangle (a title-screen quad with no texture) while `comb` held the *last* triangle's combine mode. Added logo-mode-specific sampling (`lg_*` fields, updated on every emitted tri whose `rdp.combine_mode == 0x009ffe4f19ffe4f1`): `lg_utex=1 lg_tex=1` on all 14k+ logo triangles, tile 0 = tmem 0 / fmt IA8 / 32×32, `lg_texaddr=0x7015dec0`. The combiner/GL-shader texture claim was never the bug.
|
||
|
||
**Step 2 — the texture buffer is zero because the sidecar never contained the pixels.** New `GE_TEXDUMP_ADDR=<hex>` probe (raw source-byte dump + min/max/nonzero stats for every import from one address, bypassing the 400-dump cap) showed the buffer at `0x7015dec0` all-zero on *every* import across a 90 s run. GE's ucode has no RSP `loadtxl` (checked `rsp/graphics/gmain.s`) — all texture loading is DP DMA from DRAM, so the zeroed DRAM is upstream of the RSP. Tracing the load path: `front.c` loads `PnintendologoZ` into `ptr_logo_and_walletbond_DL` via `load_object_fill_header` → `load_resource` → RZ-inflate of the **D50 PC sidecar** (`port/src/pcmodels.c`, produced by `tools_pc/d43_emit.py`). Offline decode of the shipped sidecar: all 23 GDL `G_SETTIMG` slots correctly remapped to `w1=0x05006428`, but the decompressed image at `0x6428` is **zero for ≥1024 bytes** and contains no trace of the source pixels (the N64 original's blob at file offset `0x408` is a 32×32 IA8 white wordmark, `ffffffff…`, 713/1024 nonzero). Address chain closes exactly: runtime file base `0x70157a98` (M-195) + `0x6428` = `0x7015dec0`.
|
||
|
||
**Root cause:** in `d43_emit.py`, the layout pass detects embedded image blobs (G_SETTIMG seg-5 targets — present only in P* logo/legal files), reserves the span via `add_region` (so pointers remap and tiling passes) — but **no emit-pass code ever copies the span's bytes into `buf`** (`bytearray(D_PC)` stays zero there). Round-trip validation checked every structured field and GDL slot (including `w1 == 0x05|remap(target)`, which *passes* because remap found the reserved region) but never the raw blob pixels, so the gap was invisible to `ALL CHECKS PASSED`.
|
||
|
||
**Blast radius:** a scan of all 512 model files finds exactly three with seg-5 SETTIMG targets: `PnintendologoZ` (1 image), `PgoldeneyelogoZ` (2), `PlegalpageZ` (5) — the intro's GoldenEye logo and legal page were black-textured too (less visible: dark screens). No placed object offset falls inside any blob span in any of the three files, so old/new-space remapping is unambiguous (the nintendologo node-region overlap at the blob start is a PC-footprint bookkeeping artifact — 12 bytes past the N64 node's true end, no pointer targets it).
|
||
|
||
**Fix (tooling-only):** `tools_pc/d43_emit.py` — (1) layout pass records `blob_regions = [(old, osz, new)]`; (2) emit pass raw-copies each span (`buf[n:n+osz] = src[o:o+osz]`, identity — the RDP reads blob bytes as-is on both platforms); (3) `validate()` gains a raw-identity round-trip check per blob span. Regenerated `data/pcmodels-ntsc-final/` (`ALL CHECKS PASSED`); offline re-decode confirms all 8 blob targets now carry their N64 pixels. No C code changed — the port's load path (`pcmodels.c` → `load_resource` → inflate) is correct and untouched; no rule-2 sign-off involved (no `src/game` edit).
|
||
|
||
**Live verification:** bare front-end boot (existing binary, new data only). `GE_PCDUMP="240-330:10"`: logo window goes 45 → 851 → 8,599 → **23,302 non-black px (7.59%)** over frames 260–320 with a wide wordmark bbox `(94,190)-(542,290)`; `GE_PCDUMP="340-560:20"` shows the logo continuing its rotation animation at 3–10% coverage (ASCII-art inspection: elliptical ring + shaded 3D lettering, grey/white palette consistent with the wordmark texture). Pre-fix baseline (M-196): 0–33 px/frame.
|
||
|
||
**Status:** **FIXED, user-confirmed live (2026-09-21)** — "the images and legal page and Nintendo logo are good now." Tooling fix + ntsc-final sidecars regenerated; headless-verified before the live check. Remaining: `pal-final`/`jpn-final` sidecar regeneration on machines holding those ROMs (`python tools_pc/d43_emit.py <region>` — the fix is in the tool, so any future regen is correct), and D75's last unverified scope: cast-roll models (status unknown since M-33; recheck with `GE_PCDUMP="1400-2200:25"`).
|
||
|
||
**2026-09-25 capture (user, Windows, ~46 min, Silo → Caverns, `GE_D322=1 GE_D204=1`, build `7baf4900`; log `scratch/d322_session.log`).** User: "sound gets glitchy… gunshots from your gun and other sounds start to drop out as things stack up"; "audio did get garbled" (times not noted). No crash.
|
||
|
||
- **Pools: no leak.** SFX voices (`sfx`) fall back to 0–2 in quiet stretches; physical voices never exhausted (min free 4/24, `lame` always 0); event queue max 28/64. The voice-pool-exhaustion/counter-drift hypothesis (ranked #1 above) is **refuted** for this session length.
|
||
- **(a) SFX soft cap saturation:** `sfx=8/8` in 10 of 204 samples in the first 17 min, all during combat. While 8 are playing, `sndPlaySfx` refuses new requests, so the player's own gunshots drop out. This is D207/D241. `maxSounds=8` is the game's own configuration, so the N64 has the same cap; open question = whether N64 voices free sooner (per-sound duration/release) or the port holds slots longer.
|
||
- **(b) NEW: periodic output-queue overflow ("garbled").** 1,244 blocks (~33 ms each) dropped at `audioSetNextBuffer` (`port/src/audio.c`, `q >= queueLimit` 2880) in **10 bursts starting at t = 30, 356, 672, 984, 1295, 1612, 1923, 2229, 2546 s**: period **~314 s** (306–326), each ~20–50 s, drops per 5 s ramping up (4 → 7 → … → 20) then stopping abruptly with `q` collapsing (e.g. 2576 → 624). Across bursts: VI steady at 60/s; produced-audio rate (`rt`, which counts dropped blocks too) ~0.99 throughout. So the queue sits near the limit while production is *not* above nominal, which implies the SDL device drains more slowly during a burst (or the regulator's floor exceeds the drain rate). It happens on every level and **already at t=30 s (title/menus)**, so it is **not gameplay load** and should reproduce headless.
|
||
- **Next (cheap):** headless 6–7 min run at the title with `GE_D204=1` (expect bursts at ~30 s and ~345 s); add per-block timestamps / SDL queued-bytes deltas to measure drain rate inside vs outside a burst; check the regulator floor (`g_MinFrameSize` × audio ticks/s vs 22050) and the `AUDIO_TARGET_FRAMES` setpoint (D204/F5) for a beat against the 60 Hz VI timer. A ~314 s period at a 2000-frame swing corresponds to a ~6 frames/s (~290 ppm) mismatch between the clocks.
|
||
|
||
**2026-09-25 (later): the periodic overflow bursts are the host audio device, not the port.**
|
||
|
||
- **Ruled out, cheaply:** the SDL pull pattern (512 frames every 20–30 ms, steady); level loads and combat (bursts hit with 0 SFX playing and no load stall nearby, and are spaced 306–326 s over 50 min of varied play); a pacemaker-vs-vsync beat (the VI pacemaker's integer `1000000/60` = 16,666 µs = 60.0024 Hz predicts ~417 s against the 60/1 Hz monitor; but a 3-min run with the pacemaker nudged to a ~69 s beat, via a TEMP `GE_VI_US` override, showed no periodic bursts). The in-game capture's arithmetic already showed the drain side falling behind: during bursts production fell to 0.97× (the regulator at its 720-frame floor) while 12–15 % of blocks were dropped and the queue stayed near the limit, so SDL consumed only ~85 %.
|
||
- **Decisive:** `tools_pc/sdl_drain_monitor.c` (SDL opened exactly like `port/src/audio.c`, silence, no game, no window, 11 min) on the maintainer's **Focusrite USB Audio** (WASAPI) showed drain dips of **88–95 % for t=76–89 s and 93 % at t=388–390 s: 312 s apart**, the same period as the in-game bursts, with a long-run average that is exact (22,005.7 frames/s; SDL 2.32's 22050→48k path drains at ~22,002). The device/driver/audio engine periodically falls behind, and during those windows our 2880-frame queue overflows and drops 33 ms blocks. A TEMP cadence probe (`GE_D322T`) also caught a queue jump with perfectly regular submissions.
|
||
- **Port impact:** none to fix for this mechanism. A bigger `Audio.QueueLimit` would only trade drops for up to ~2 s of latency during a dip. Suggested user-side checks: Focusrite Control sample rate = the Windows device format; driver update; A/B on onboard audio.
|
||
- **Still open under D322:** (a) the 8-voice SFX cap starving gunshots in firefights (D207/D241, N64-configured cap: is it faithful to the N64's audible behaviour?); (b) #87's own report of *progressive* campaign-long decay, which this machine does not show. The reporter's hardware may have its own periodic device dips; the monitor tool is the first thing to run there.
|
||
|
||
**2026-09-25 (fix): it is SDL's WASAPI backend, not the device. Correction to the entry above.** Four copies of the monitor ran simultaneously on the same Focusrite for 700 s (WASAPI and DirectSound, 512 and 2048 samples each):
|
||
|
||
| backend | buffer | dips (5 s mean) | long-run drain |
|
||
|---|---|---|---|
|
||
| WASAPI | 512 | 236–269 s (88 %), 553–605 s (82 %) | 21,730 f/s |
|
||
| WASAPI | 2048 | same windows (81–88 %) | 21,733 f/s |
|
||
| DirectSound | 512 | none | 22,048.5 f/s |
|
||
| DirectSound | 2048 | none | 22,048.5 f/s |
|
||
|
||
The same device at the same moment drains at exactly real time through DirectSound, so the periodic shortfall is SDL 2.32's WASAPI path with this USB interface. Buffer size makes no difference. The earlier "host device, nothing to fix" conclusion is withdrawn.
|
||
|
||
**Fix:** `audioInit()` sets `SDL_HINT_AUDIODRIVER = "directsound,wasapi"` on Windows before `SDL_InitSubSystem(SDL_INIT_AUDIO)` (the only audio init in the port); `SDL_AUDIODRIVER` in the environment still overrides it (normal-priority hint). The init log line now names the driver. **Verified:** golden gate 3/3; Bunker 1 60 s with `GE_D204=1`: driver `directsound`, rt 0.997–0.999, q 240–624, one drop during the level load (pre-existing) and none after. **Owed:** a long play session on the maintainer's machine (the bursts came every ~5 min), expect `drop=` flat after the load. Then re-assess the firefight SFX drop-outs; D207's measurements found no sounds actually dropped by the 8-voice cap, so they were most likely this same block loss.
|
||
|
||
## D323 — G_TL_LOD bindings of explicit-LOD textures: PC samples driver mips from LOD0; N64 samples the authored mip chain (static census, 2026-09-21)
|
||
|
||
**Context.** D107 established that GE's room DLs emit `G_TL_LOD` and that the port folds every LOD draw to tile 0 (driver-generated GL mips from the finest level). D236 then found + fixed the one case where that folding was *visibly* wrong for a type-1 (DETAIL) binding of an RGBA5551 cut-out. This entry closes the loop on the rest of the surface: how often do LOD-on draw classes bind textures that actually carry **authored** mip levels, and does the N64 hardware behavior differ from the port's?
|
||
|
||
**N64 mechanism (confirmed in code).** For a texture whose imagelist header declares `hasExplicitLods` with N images (`tex->maxlod = N`, image.c:343), every binding type emits `gDPSetTextureLOD(G_TL_LOD)` + `G_TD_DETAIL` and sets up **N tiles**: tile 0 at the image-table TMEM slot, tiles 1..N-1 at successive TMEM addresses with halved dims/masks (`texWriteTileLods`, tex.c:355; one LOADBLOCK covering the whole chain via `texGetDepthAndSize`). With TEXTLOD on, the RDP ORs the minification level into the tile index, so distant geometry samples the **authored** coarser images.
|
||
|
||
**Port behavior.** fast3d folds all of tiles 0..N-1 to GL texture unit 0 (D107; D236's type-1 carve-out excepted), with `glGenerateMipmap` mips from LOD0. So on PC, minified geometry samples **driver-filtered** versions of the finest image instead of Rare's hand-authored mips.
|
||
|
||
**Census (`tools_pc/bg_lod_audit.py`, ntsc-final, bg room DLs only).** GE's modified-fast3d `G_SETOTHERMODE_H` is a partial field write (op 0xBA; w0 bits 8-15 = shift, bits 0-7 = width; the value sits at its *absolute* bit position in w1 — fast3d calls `gfx_sp_set_other_mode(shift+32, nbits, w1<<32)`, an identity map). TEXTLOD = bit 16. The tool walks every room DL tracking OML/OMH state and cross-references each bind's texnum against the imagelist explicit-LOD flag:
|
||
|
||
- **423** textures carry explicit LODs; **15 levels** bind at least one of them under a TEXTLOD-on class (azt 806, stat 378, arec 88, sevx 84, crad 17, pete 15, jun 25, depo 28, dest 8, run 8, arch 4, ark 4, sev 4, sevb 4, silo 2 LOD-on binds).
|
||
- Formats: overwhelmingly **RGBA16_CI8 / IA8** (7-8 authored levels each) — driver mips of these are usually close to the authored ones; this is a subtle fidelity gap, not an artifact.
|
||
- The only **RGBA5551** (1-bit alpha cut-out) explicit-LOD textures bound under G_TL_LOD anywhere are tex1198-1201 in sevx/sev/sevb — the D236 set, already handled by the type-1 carve-out; user reports Surface 2's treeline reads fine.
|
||
|
||
**Assessment.** Real N64-vs-PC divergence, pervasive but low-visibility: no reported artifact, and the one high-visibility subset (cut-outs) is already covered by D236. A full fix means uploading GL mip chains from the explicit LOD images for TEXTLOD-on bindings (tex.c + fast3d texture-cache plumbing), which would invalidate golden baselines across all 15 levels for a change nobody has seen to be wrong — not worth doing speculatively.
|
||
|
||
**Status:** OPEN / parked (GRAPHICS-BACKLOG). If a playtest ever flags mushy distant detail in one of the 15 levels above (Statue and Aztec are the heaviest), the fix path is: in fast3d's texture upload, when the binding class has TEXTLOD on and `tex->maxlod > 1`, feed `glTexImage2D` levels 0..N-1 from the decoded explicit images instead of `glGenerateMipmap`. Audit tool: `tools_pc/bg_lod_audit.py` (also documents the GE OMH field-write encoding, which no other tool captures).
|
||
|
||
## D324 — GE's <stdlib.h> stub declares nothing, so TUs that include it get NO CRT prototypes: implicit `int f()` declarations silently corrupt non-int returns (atof garbage in every float ini parse; D38 class)
|
||
|
||
**Context.** Found while verifying the D294 fix: the new `GE_ROOMPOOL` env hook in `port/src/video.c` called `atof()`, and the parsed value was deterministic-per-call-site garbage (observed 15279232 / 7087232 / 6817888 / 14288992 across runs) instead of the env string's value. The scale override was silently ignored on every run until prototypes were added.
|
||
|
||
**Mechanism.** `include/stdlib.h` (the N64 decomp's libc stub) wraps its entire body in `#if 0` — only the `lldiv_t`/`ldiv_t` typedefs are live. `port/shim/stdlib.h` (what `<stdlib.h>` resolves to for C TUs; see docs/internals.md §11 D12) includes that stub and then declared a couple of stragglers — but `getenv`/`realloc` **only on non-Windows**, on the empirically-wrong assumption that MinGW's other CRT headers "leak" prototypes (config.c gets no leaked `getenv`; the implicit-declaration warning proves it). So any port/src or game TU that calls a CRT function nothing else declared compiles with an **implicit `int f()` declaration**. On x86-64 SysV that means: a `double`/`float` return in **xmm0 is read from eax** (garbage), pointer returns are truncated to 32 bits, and `long` returns truncate on LP64 (Linux) builds.
|
||
|
||
**Why only some functions actually misbehave (the triage rule for this class):**
|
||
- **GCC builtins** (`floorf`, `sqrtf`, `memset`, …): GCC recognises them even when implicitly declared — it warns (`-Wbuiltin-declaration-mismatch`) but generates the *correct* xmm0/inline code. Harmless in practice. (This is why `sky.c`'s implicit `floorf` never visibly broke: correct values, and its fold result is additionally masked by `SKY_TC_WRAP` periodicity — any multiple of the wrap period is visually identical on a wrapping sky texture.)
|
||
- **int-returning** (`atoi`, `atexit`): the 32-bit int fits in eax exactly. Harmless (still UB per the standard; warning only).
|
||
- **Non-builtin float/double/pointer/64-bit-long returns** (`atof`, `strtod`, `getenv`, `strtol` on LP64, …): genuine silent corruption. This is the only bucket that needs fixing.
|
||
|
||
**Proven impact.** (1) `port/src/config.c` `parseFloat` → **every float ini option parsed to garbage whenever a user set one** — currently exactly one exists, `Game.ScreenShakeIntensity`, and the affected user had it set to 10 (silently became an eax-derived float for months). (2) The D294 `GE_ROOMPOOL` hook (video.c) — caught during verification. (3) `getenv` in 8 port TUs + `src/libultra/audio/csplayer.c`: pointer truncated to 32 bits — harmless *so far* on Windows (CRT env block sits below 4 GB), a landmine otherwise.
|
||
|
||
**Ruled out during diagnosis** (all tested, all clean): msvcrt corruption, locale, stack alignment, C++ runtime interference — plain MinGW test programs parse identically; only this build is affected, purely via the header shadowing. Garbage values are deterministic per call-site (same eax state each time) but vary between runs.
|
||
|
||
**Fix (port-layer only):**
|
||
- `port/shim/stdlib.h`: now declares `atof` / `strtod` / `strtol` / `atexit` / `getenv` **unconditionally** (K&R form — compatible with any real or leaked prototype), after including the N64 stub. `realloc` stays non-Windows-only (strict-GCC leak gap).
|
||
- `port/include/pc_protos.h`: gains `double atof(); double strtod();` for game TUs that include `<ultra64.h>` but never `<stdlib.h>` (no game TU currently calls them; future-proofing + canonical D38 list).
|
||
- `src/game/glass2.c` (the D219 `#ifdef PORT` block): `memset` → `__builtin_memset` so that block needs no prototype at all.
|
||
|
||
**Residual implicit declarations (audited, all in the harmless buckets above — warning-only, no action):** `atoi` in `src/game/chr.c`, `src/game/sky.c`, `src/game/bondview2.c`, `port/src/optionsoverlay.c`; `lround` in `optionsoverlay.c` (fine on Windows LLP64 where `long`=32 bits; would truncate on an LP64 Linux build — revisit only if a Linux port is actively maintained); the builtin family (`floorf`/`sqrtf`/`memset`) wherever it appears.
|
||
|
||
**Status:** FIXED (2026-09-22). Build clean; D294 verification battery re-run post-fix (faithful→authored size, user settings→0 failures, `GE_ROOMPOOL=0.25`→honoured + exhaustion on demand). Cross-ref: D38 (original implicit-declaration finding), D294 (where it was caught).
|
||
## D325 — ROM-mod (xdelta) compatibility: relocating/total-conversion mods cannot work while the file table is compiled in; in-place data-only mods are one sidecar-staleness fix away (Goldfinger 64 probe, 2026-09-22)
|
||
|
||
### Question
|
||
|
||
Can the port run classic GoldenEye ROM mods distributed as xdelta patches, and
|
||
is a `mods/` folder mechanism worth building? Probe target: **Goldfinger 64
|
||
v1.0** (12.8 MB xdelta, a total-conversion campaign), with the Random Eye
|
||
randomizer (286 KB xdelta) as the contrasting light-touch case.
|
||
|
||
### What the port actually does with the ROM
|
||
|
||
- `port/src/romdata.c` reads the `.z64` and memcpys it to the N64 cart address
|
||
`0x10000000`; **all game data is read out of it at runtime**. `romSize` comes
|
||
from `fsSize`, so ROM size is not fixed — an *expanded* ROM maps fine. The
|
||
only header validation is the country byte at `0x3E` (`romHeaderValid`).
|
||
- ROM **code is never executed** — we run the compiled decomp C. Every ASM hack
|
||
in a mod is inert by construction.
|
||
- **The binding constraint:** the file table is *compiled into our binary*, not
|
||
read from the ROM. `src/game/ob.c:29` includes
|
||
`assets/obseg/file_resource_table.inc.c`, whose `hw_address` fields are
|
||
linker symbols resolved by `port/src/romassets_<region>.s` to **hardcoded
|
||
vanilla ROM offsets**. The offline converters do the same at convert time:
|
||
`tools_pc/d88_emit.py:96` reads that table, and `scripts/filelist.u.csv`
|
||
carries 812 vanilla offset/size rows.
|
||
- Sidecars (`pccg-*/`, `pcmodels-*/`) are **presence-checked only**
|
||
(`rcSidecarsPresent`, `port/src/romconvert.c`); `manifest.csv` is
|
||
`name,offset,size` with **no ROM identity stamp**. Swapping the ROM therefore
|
||
leaves stale converted data in place silently.
|
||
|
||
### Probe (isolated worktree off `main`; primary checkout untouched)
|
||
|
||
1. `xdelta3 -d` of `Goldfinger64_1_0.xdelta` against our vanilla NTSC-U ROM
|
||
applied **cleanly** (rc=0). Output **25,165,824 bytes** — a 12 MB → 24 MB
|
||
expanded ROM.
|
||
2. Header: magic `80371240` unchanged, internal name `GOLDENEYE` → `GOLDFINGER`,
|
||
country byte still `E` (so `rcRegionForCountry` maps it to `ntsc-final` and
|
||
`romHeaderValid` accepts it), CRC pair changed, boot segment
|
||
`0x40..0x1000` byte-identical.
|
||
3. **Relocation measured:** slicing all 803 usable `filelist.u.csv`
|
||
(offset, size) rows out of both ROMs and comparing — **797 of 803 differ**,
|
||
6 identical. Total content replacement with a rebuilt file layout.
|
||
4. **Converter failure signature** (first hard stop, exactly as predicted):
|
||
|
||
```
|
||
File "tools_pc/d43_emit.py", line 273, in process
|
||
src = zlib.decompress(rom[addr:addr + size][2:], -15)
|
||
zlib.error: Error -3 while decompressing data: invalid block type
|
||
```
|
||
|
||
i.e. a hardcoded vanilla offset now lands on unrelated bytes. `d69`/`d88`
|
||
and the runtime path would fail the same way; not run, and no build was
|
||
attempted.
|
||
|
||
### Taxonomy + verdict
|
||
|
||
| Mod class | Verdict |
|
||
|---|---|
|
||
| **ASM / code hacks** | **Never.** ROM code is not executed here. |
|
||
| **Relocating / expanding / total conversions** (Goldfinger 64) | **Out of reach.** Every compiled `hw_address` and every `filelist.u.csv` row is wrong. Would require the file table (and, for added levels, the stage tables) to be **parsed from the ROM at runtime** instead of compiled from `file_resource_table.inc.c` + `romassets_*.s`. That is an architectural project, and the added-level half collides with AGENTS.md rule 2 (new stage-table entries are `src/game` logic, not an ABI/layout edit). Not a `mods/` folder feature. |
|
||
| **In-place data-only** (offsets and sizes preserved — e.g. a setup randomizer like Random Eye) | **Hypothesis — DISPROVEN by D326**, which found the Random Eye randomizer repacks the ROM as well (identical size, 803/803 slices shifted); no known mod actually inhabits this tier. The fix below is still worth doing on its own merits, but unlocks no mod by itself. Needs one fix: stamp ROM identity (size + hash) into `manifest.csv` and reconvert on mismatch, in `rcSidecarsPresent` / `port/src/romconvert.c`. Without it a swapped ROM keeps stale sidecars and mis-renders in a way that looks like corruption rather than like a stale cache. |
|
||
|
||
A bounded scan of both ROMs for an in-ROM 12-byte `fileentry` array
|
||
(`{s32 index, char *filename, u8 *hw_address}`, `src/game/ob.h:16`) found no
|
||
convincing ~800-entry candidate (only a 40-entry run at `0x29e24c`, present in
|
||
both ROMs, almost certainly a false positive of the heuristic). So **whether
|
||
the table is even recoverable from an arbitrary modded ROM is unresolved** and
|
||
is its own investigation — it is the gating question for the relocating-mod
|
||
refactor above.
|
||
|
||
### If a `mods/` loader is ever built
|
||
|
||
- xdelta3 is **GPLv2** — invoke it as a separate bundled executable (the
|
||
existing `prepare-assets/ge007-convert` spawn pattern in
|
||
`port/src/romconvert.c`) or implement VCDIFF (RFC 3284) decode; do not link
|
||
it into this MIT tree.
|
||
- Never redistribute a patched ROM or a patch; user supplies both, same posture
|
||
as the base ROM.
|
||
- Sidecar trees must be keyed per ROM hash, which is the same stamp fix as
|
||
above.
|
||
|
||
**Status:** CLOSED as a question — mechanism established and measured; no code
|
||
changed. Follow-up (b) was run and **disproved the cheap tier — see D326**, which
|
||
also identifies the real keystone (recovering the file table from the ROM at
|
||
runtime) and carries the backlog pointer. Follow-up (a), the `manifest.csv`
|
||
ROM-stamp fix in `port/src/romconvert.c`, still stands on its own merits (a
|
||
genuine latent bug today for anyone swapping ROMs or regions). Cross-ref: D43/D69/D88 (the converters whose vanilla offsets
|
||
this exposes), D179 (missing-sidecar abort path).
|
||
|
||
## D326 — Random Eye randomizer repacks the ROM too: the hypothesised "in-place data-only" mod tier is empty in practice; the in-ROM file table is the single keystone for any mod support (2026-09-22)
|
||
|
||
### Why this was run
|
||
|
||
D325 closed with a hypothesis: mods that preserve every file's offset and size
|
||
would work almost for free, needing only a ROM-identity stamp on the sidecars.
|
||
The Random Eye randomizer (`Murks-Random-eye-zer-v1.1.xdelta`, 286 KB) was the
|
||
obvious candidate — a *setup* randomizer, no new content, and a patch 45× smaller
|
||
than Goldfinger 64's. This tests that hypothesis. **It fails.**
|
||
|
||
### Measurements (same isolated worktree method as D325)
|
||
|
||
- Patch applies cleanly (rc=0) to our vanilla NTSC-U ROM. Output size is
|
||
**identical** — 12,582,912 bytes. Internal name still `GOLDENEYE`, country
|
||
byte still `E`, boot segment `0x40..0x1000` byte-identical, CRC pair changed.
|
||
Every surface signal says "small in-place edit".
|
||
- It is not. **96.5% of all ROM bytes differ** (12,144,703 of 12,582,912), and
|
||
**803 of 803** usable `filelist.u.csv` slices differ — every single file,
|
||
including fonts and the Rareware logo, which a randomizer has no reason to
|
||
touch.
|
||
- The content is preserved and **shifted**: searching the modded ROM for
|
||
vanilla file content finds it intact at a new offset —
|
||
`jfont_dl` / `jfont_chardata` / `efont_chardata` / `animationtable_entries`
|
||
all at **+7216**, `GwppksilZ` / `PICBMZ` at **+7392**, and
|
||
`GwristdartZ` at **−229888** (genuinely rearranged, not merely displaced).
|
||
- Converter failure is the D325 signature again:
|
||
`tools_pc/d43_emit.py:273` → `zlib.error: Error -3 while decompressing data:
|
||
invalid stored block lengths`.
|
||
|
||
**Conclusion:** the community toolchain (GE Setup Editor lineage) *repacks* the
|
||
ROM. Even a content-neutral randomizer comes out with a rebuilt file layout. So
|
||
the D325 taxonomy's cheap tier — "in-place data-only" — has no known real
|
||
inhabitants, and the `manifest.csv` ROM-stamp fix, while still worth doing on
|
||
its own merits (swapping ROMs/regions today silently serves stale sidecars),
|
||
**does not by itself unlock any actual mod**.
|
||
|
||
### Revised verdict: one keystone, not a tiering problem
|
||
|
||
All ROM-mod support now reduces to a single research question: **can the file
|
||
table be recovered from an arbitrary modded ROM at runtime?** Our table is
|
||
compiled in (`src/game/ob.c:29` → `file_resource_table.inc.c` → linker symbols
|
||
in `port/src/romassets_<region>.s`), and the converters re-use the same vanilla
|
||
offsets (`tools_pc/d88_emit.py:96`, `scripts/filelist.u.csv`).
|
||
|
||
Two bounded scans failed to locate the table in either ROM:
|
||
|
||
1. 12-byte-stride runs of `{s32 index, char *filename, u8 *hw_address}`
|
||
(`src/game/ob.h:16`) with ascending index and in-range ascending address —
|
||
best candidate was a 40-entry run at `0x29e24c` present *identically in both
|
||
vanilla and Goldfinger 64*, i.e. a heuristic false positive, not an ~800-entry
|
||
table.
|
||
2. Known file offsets encoded as big-endian u32 in raw / `|0x10000000` /
|
||
`|0xB0000000` form, over the first 200 `filelist.u.csv` entries: 20 raw hits
|
||
across the whole ROM, 1 each for the other two encodings, **no dense cluster
|
||
anywhere**.
|
||
|
||
**Answered in D327:** the table is provably not in the ROM in any plaintext
|
||
encoding (including the segment-relative `0x7F000000` form, 0 hits), so approach
|
||
(a) is ruled out — `file_resource_table.inc.c` is a decomp reconstruction, and
|
||
the retail table lives in the loaded/compressed code image this port never
|
||
unpacks. D327 replaces this open question with a viable route: content-addressed
|
||
offset remapping with gap inference over contiguously-packed files.
|
||
|
||
**Not fixable regardless:** ASM/code mods (ROM code is never executed here), and
|
||
mods that add levels (new stage-table entries are `src/game` logic — AGENTS.md
|
||
rule 2, not the ABI/layout exception).
|
||
|
||
**Status:** CLOSED as a question — D325's cheap tier disproven, keystone
|
||
identified and then **resolved in D327** (table-in-ROM ruled out; content-addressed
|
||
remapping specified). No code changed. Backlogged as §5 of the local
|
||
`docs/dev/notes/MODERN-PORT-FEATURES-BACKLOG.md` (extends T3.6), sized
|
||
large/multi-session and gated on the table-recovery question; correct sequencing
|
||
is after first public release. Cross-ref: D325 (Goldfinger 64 probe + the
|
||
taxonomy this revises), D43/D69/D88 (the converters carrying vanilla offsets),
|
||
D179 (missing-sidecar abort path).
|
||
|
||
## D327 — Mod support, resolved plan: reading the file table out of the ROM is ruled out by measurement; content-addressed offset remapping is the viable route (1964GEPD fork review, 2026-09-22)
|
||
|
||
### Why this was run
|
||
|
||
D326 left one gating unknown — "can the file table be recovered from a modded
|
||
ROM at runtime?" — with a guess that it lives inside the compressed code image.
|
||
A community fork, `1964GEPD-automatic-mod-compatibility`, advertises automatic
|
||
mod compatibility and was reviewed for a technique. This entry settles the
|
||
gating question with measurements and replaces the guess with a plan.
|
||
|
||
### What the 1964 fork actually does (not what we need)
|
||
|
||
Its change is in the emulator's TLB setup (`InitTLBOther`). 1964 had a
|
||
GoldenEye **speed hack** that direct-maps the `0x7F000000` game segment with the
|
||
ROM offset hardcoded (`0x90034b30`); mods relocate that segment, so the hack
|
||
broke. The fork replaces the hardcode with a scan for a unique 5-instruction
|
||
signature at the segment's head. Its own code comments note the game *"still
|
||
work[s] without hack"* — this is an optimization guard, not mod-loading
|
||
machinery.
|
||
|
||
**Why it transfers so poorly:** 1964 *executes the ROM's MIPS code*, so it never
|
||
needs a file table at all. Mod compatibility is nearly free for an emulator and
|
||
structural for us. There is exactly one transferable fact (next paragraph).
|
||
|
||
### Transferable fact, independently verified
|
||
|
||
GE maps its `0x7F000000` game segment **directly from ROM, uncompressed**, and
|
||
the segment head is uniquely identifiable. Re-derived here against our own
|
||
vanilla NTSC ROM by scanning for the instruction sequence
|
||
`lui $at,0x3f80` / `mtc1 $at,$f0` / `addiu $v0,$zero,-1` / `lui $at,hi` /
|
||
`sw $v0,lo($at)`: **exactly one hit, ROM offset `0x34b30`** — matching the
|
||
emulator's old hardcoded `0x90034b30`. This locates a *relocated* segment in a
|
||
modded ROM in one pass. Useful, but on its own it does not yield the file table.
|
||
|
||
### (a) Reading the table out of the ROM — **CORRECTED BY D328: NOT ruled out**
|
||
|
||
> **This section's conclusion is wrong.** Every scan below was run over
|
||
> *uncompressed* ROM bytes. GE's file table lives at offset `0x252c4` inside
|
||
> the 1172-compressed data segment that starts at ROM `0x21990`, with
|
||
> pointers in a `0x80020d90`-based space — one raw-inflate call away, using
|
||
> the decompressor `port/src/rzdecomp.c` already ships. D328 verifies it
|
||
> against our own ROM (681 exact offset+size matches vs `filelist.u.csv`, 0
|
||
> mismatches) and reads two real mods' own tables with no hardcoded offsets.
|
||
> The measurements below remain accurate as *negative results about raw ROM
|
||
> bytes*; the inference drawn from them does not hold.
|
||
|
||
`file_resource_table`'s `hw_address` values are not present in the vanilla ROM
|
||
in any plaintext encoding. Measured over the first 100 `filelist.u.csv` offsets:
|
||
|
||
| Encoding | Hits |
|
||
|---|---|
|
||
| raw u32 offset | 4 (incidental) |
|
||
| `\|0x80000000` | 1 |
|
||
| `\|0xB0000000` | 1 |
|
||
| segment-relative `0x7F000000 + off − 0x34b30` | **0** |
|
||
|
||
Plus two structural scans for a 12-byte `{s32 index, char *filename,
|
||
u8 *hw_address}` run (`src/game/ob.h:16`) — one requiring ascending index and
|
||
in-range ascending address, one additionally requiring `filename` to point at a
|
||
plausible ASCII string. No candidate in either ROM. (The 40-entry run at
|
||
`0x29e24c` reported in D325 appears identically in vanilla *and* Goldfinger 64,
|
||
confirming it as heuristic noise.)
|
||
|
||
The retail table does live in the compressed image — that much was right — but
|
||
"which this port never unpacks" was the error: the data segment is a single
|
||
raw-inflate away and `port/src/rzdecomp.c` already does exactly that inflate.
|
||
See **D328**, which reads the table out of it and validates it end to end.
|
||
(`file_resource_table.inc.c` is still a decomp reconstruction; its *ordering*
|
||
matches the retail table, which is what makes index-keyed extraction work.)
|
||
|
||
### (b) Content-addressed offset remapping — the viable route
|
||
|
||
`obInit` derives each file's size by differencing adjacent `hw_address` values
|
||
(`src/game/ob.c:126`), which means **files are packed contiguously in the ROM**.
|
||
That is the lever:
|
||
|
||
1. We know every vanilla file's exact bytes. For a repacked ROM, locate each
|
||
*unchanged* file by content search → its new offset.
|
||
2. A file the mod actually **changed** cannot be found that way, but because the
|
||
layout is contiguous it is **bounded by the gap between its located
|
||
neighbours** — offset and size both fall out.
|
||
3. Output is a per-mod offset/size table that replaces the compiled
|
||
`hw_address` values at runtime and feeds `d43`/`d69`/`d88` in place of
|
||
`scripts/filelist.u.csv`.
|
||
|
||
One-time cost at the convert step, not a runtime cost. Verified plausible
|
||
against Random Eye, whose content is preserved and merely shifted
|
||
(+7216 / +7392, one file −229888; D326).
|
||
|
||
**Known limits:** a run of *adjacent* changed files cannot be subdivided by gap
|
||
inference; files a mod *adds* get no help; and mods that add levels still need
|
||
new stage-table entries in `src/game` (AGENTS.md rule 2, not the ABI/layout
|
||
exception). ASM/code mods remain impossible — ROM code is never executed here.
|
||
|
||
### License posture (important)
|
||
|
||
`1964GEPD-automatic-mod-compatibility` is **GPL-2.0**; this is an MIT tree. Do
|
||
not vendor it, copy code from it, or paste its functions — the same hygiene call
|
||
as the 2026-09-19 removal of the vendored Mouse Injector (`AGENTS.md`, `reference/mouse-injector/README.md`).
|
||
The segment-start offset and instruction signature recorded above are facts
|
||
about *Rare's* code and were re-derived directly from our own ROM and decomp;
|
||
any implementation must be written from our sources, with provenance noted as
|
||
"technique observed in a GPL-2.0 project, reimplemented independently". Better
|
||
still: the segment start is obtainable from our own link map, so the scan can be
|
||
skipped entirely.
|
||
|
||
**Status:** SUPERSEDED BY D328 — approach (a) is **not** ruled out; this entry's
|
||
scans missed the compressed data segment. Approach (b) (content-addressed
|
||
remapping) remains a valid fallback for a mod that relocates the data segment
|
||
beyond what the scan finds. Mod support is viable; only implementation effort
|
||
remains (large, correct sequencing
|
||
is after first public release). No code changed. Cross-ref: D325 (Goldfinger 64
|
||
probe + mod taxonomy), D326 (Random Eye repack — cheap tier disproven),
|
||
D43/D69/D88 (converters carrying vanilla offsets), D179 (missing-sidecar abort).
|
||
|
||
## D328 — Mod support is viable and D327's "ruled out" was wrong: GE's file table is in the ROM inside the 1172-compressed data segment, and locating it needs no hardcoded offsets (verified on vanilla + 2 mods, 2026-09-22)
|
||
|
||
### Source
|
||
|
||
The Perfect Dark PC port fork at `perfect-dark-dabs-mod-dabs-mod` (**MIT**,
|
||
Copyright 2022 Ryan Dwyer — same license as this tree; `AGENTS.md` already names
|
||
the PD port a standing reference). Two files matter:
|
||
|
||
- `port/src/modimport.c` — in-game console-mod import. Its header states our own
|
||
constraint verbatim: *"The port cannot run the patched ROM — it runs decomp
|
||
code and reads the ROM at fixed offsets — but it can use the mod's content."*
|
||
- `port/src/geconvert.c` — converts a **GoldenEye 007 US ROM** into PD arenas, so
|
||
it parses *our* ROM and carries GE's constants.
|
||
|
||
### The architectural flip (why D325–D327 asked the wrong question)
|
||
|
||
D325–D327 scoped mod support as "make the port load a modded ROM", concluded
|
||
that needed a runtime-parsed file table, and stalled on whether that table was
|
||
recoverable. PD does not do that at all: it **diffs the patched ROM against
|
||
stock, extracts only what differs, and writes it out as a mod directory**
|
||
(`files/`, `segs/`) in the layout its loader already reads. The game keeps
|
||
running its own stock ROM at fixed compiled offsets; mod content arrives as loose
|
||
files. Mod support is an **offline import step plus a file-override layer**, not
|
||
a loader rewrite.
|
||
|
||
Supporting pieces in that fork, all MIT:
|
||
|
||
- `port/src/rompatch.c` — its own VCDIFF (xdelta3: RFC 3284 + app header +
|
||
per-window adler32), BPS and IPS decoder, verified byte-identical against
|
||
xdelta3 on 40 patches. **This removes the GPLv2 xdelta3 dependency D325 flagged
|
||
as a licensing constraint.**
|
||
- `findFileTable()` (`modimport.c:321`) scans the decompressed data segment for a
|
||
run of ≥`MIN_FILES` (500) records; the data segment is found by scanning the
|
||
first 4 MB. No hardcoded offsets, so it survives relocation.
|
||
- `tblStartBySamples()` locates audio tables by searching for stock waveform
|
||
prefixes and voting — the content-addressed idea D327 proposed, in production.
|
||
|
||
### GE's file table: where it actually is
|
||
|
||
`geconvert.c` gives `DATA_ROM 0x21990`, `DATA_VRAM 0x80020d90`, `FILES_AT
|
||
0x252c4`, and reads the table as 12-byte records with size by differencing
|
||
adjacent addresses — **exactly `obInit` (`src/game/ob.c:126`)**. Verified here
|
||
against our own `data/ge007.ntsc-final.z64`:
|
||
|
||
- `1172` magic present at `0x21990`; `zlib` raw-inflate (`-15`) after the 2-byte
|
||
header yields a **247,120-byte** data segment. This is the same inflate
|
||
`port/src/rzdecomp.c` already wraps.
|
||
- Table at `0x252c4`, **727 rows**, first word counting `0..726`. Names resolve
|
||
via `vaddr − 0x80020d90` to `bg/bg_sev_all_p.seg`, `bg/bg_silo_all_p.seg`, …
|
||
matching `assets/obseg/file_resource_table.inc.c` order.
|
||
- **Cross-checked against ground truth:** derived `addr`/`size` pairs vs
|
||
`scripts/filelist.u.csv` — **681 exact offset+size matches, 0 mismatches**
|
||
(44 unmatched are artifacts of the basename matcher used, not data errors).
|
||
|
||
**D327's approach (a) is therefore NOT ruled out.** Its scans were run over
|
||
*uncompressed* ROM bytes, so it was right about the mechanism ("lives inside the
|
||
compressed code image") and wrong about the consequence: the segment inflates in
|
||
one call with a decompressor we already ship.
|
||
|
||
### Hardcode-free discovery, verified on two real mods
|
||
|
||
Implemented the PD strategy independently (scan for `1172` in the first 4 MB,
|
||
inflate, scan for a ≥500-row run whose first word counts from 0) and ran it on
|
||
all three ROMs. **No constant from `geconvert.c` was used.** Then validated by
|
||
comparing vanilla file *content* against each mod ROM at the offsets its **own**
|
||
table gives:
|
||
|
||
| ROM | data seg | table | rows | content identical to vanilla at extracted offsets |
|
||
|---|---|---|---|---|
|
||
| vanilla NTSC-U | `0x21990` | `0x252c4` | 727 | — (reference) |
|
||
| Random Eye v1.1 | **`0x219f0`** | `0x25304` | 727 | **704 / 726 (97.0%)** |
|
||
| Goldfinger 64 v1.0 (24 MB) | `0x21990` | `0x252c4` | 727 | 19 / 726 (2.6%, as expected for a total conversion) |
|
||
|
||
Random Eye's non-identical entries, fully accounted for:
|
||
|
||
- **2 real changes** — `UsetupcaveZ` and `UsetuptraZ` (setup files; the
|
||
randomizer's actual edits).
|
||
- **1 artifact** — index 0, the `NULLFILE` slot, whose "size" is the differencing
|
||
of `addr 0` against the first real file.
|
||
- **19 zero-size slots** — unused `bg/` entries (`bg_sho_all_p.seg`,
|
||
`bg_eld_all_p.seg`, `bg_imp_all_p.seg`, …), i.e. cut-level table padding, not
|
||
failures.
|
||
|
||
704 + 2 + 1 + 19 = 726. **Complete.**
|
||
|
||
Contrast with D326, which found **0 of 803** slices matching when read at
|
||
*vanilla* offsets: the same ROM, read through its own table, is 97% recognisable.
|
||
That is the whole mod story in one number, and it also confirms D326's shift
|
||
measurement (`+7392`) was the data segment and file area moving together.
|
||
|
||
### Revised viability
|
||
|
||
From "architectural project gated on unresolved research" (D327) to **known-good
|
||
architecture with an MIT reference implementation**. Remaining work is real but
|
||
bounded, and none of it is research:
|
||
|
||
1. **File-table extraction** — the scan above, in C, reusing
|
||
`port/src/rzdecomp.c`. Proven.
|
||
2. **Patch decoding** — VCDIFF/BPS/IPS. `rompatch.c` is MIT and adaptable with
|
||
attribution; no GPL dependency needed.
|
||
3. **Mod-directory override layer** — the genuinely new piece. PD has
|
||
`romfile.moddir` file slots; **we have no analogue** — `port/src/romdata.c`
|
||
reads the ROM only. This is where the effort sits.
|
||
4. **Converter integration** — `d43`/`d69`/`d88` taking an extracted table
|
||
instead of `scripts/filelist.u.csv`.
|
||
|
||
**Unchanged limits:** mods that *add levels* still need new stage-table entries in
|
||
`src/game` (AGENTS.md rule 2, not the ABI/layout exception); ASM/code mods remain
|
||
impossible since ROM code is never executed here.
|
||
|
||
**Status:** CLOSED as a question — viability established, mechanism verified end
|
||
to end on two real mods, no research blocker remains. Amends **D327** (approach
|
||
(a) un-ruled-out) and supersedes its keystone framing. No code changed; backlog
|
||
updated in the local notes backlog §5. Cross-ref: D325 (GF64 probe + taxonomy),
|
||
D326 (Random Eye repack measurement this re-reads correctly), D327 (the scans
|
||
whose conclusion this corrects), D43/D69/D88 (converters), D179.
|
||
|
||
---
|
||
|
||
### M-199 (2026-09-22): adversarial review of the proposed D318/D320 fix plan (frame-data audit → runtime trace → conformance sweep → watchdog excision). **Verdict: SOUND-WITH-CORRECTIONS** — direction and constraint posture are right, but step 1 mischaracterizes the data path and is ~80% already done, the plan's "unhandled branch" is actually the *expected* outcome of step 1 (with no decision rule for it), and step 2 as written hunts for a mechanism that cannot exist in byte-matched code. No code changed; review only.
|
||
|
||
**Pre-flight (mechanism re-verified against source this session, all links hold):**
|
||
- `chrlvInitActAttack` (chraction.c ~1258–1376): `next_anim = randomGetNext() % len`, `modelSetAnimation(panim_float->anim, flip, panim_float->start_frame, …)`; `unk36`/`unk32`/`unk34` set from weapon props + PRNG. ✓
|
||
- `chrlvAttackActionRelated` (chraction.c:1126–1163): **only ever calls `modelSetAnimEndFrame`** — never `modelSetAnimation`. E-selection branches verified verbatim: AIM_ONLY → `H = (0 ≤ recoil < shoot_start) ? recoil : shoot_start`; live-fire → `E = recoil (if ≥ 0, both unk36 arms) else shoot_start / end_frame / −1`. ✓
|
||
- Re-init block (chraction.c ~7126+): `modelGetAnimEndFrame <= frame` gates everything; the unk31 path (~7209) is the only `modelSetAnimation` re-init and is unreachable while E > H. ✓
|
||
- `modelSetAnimEndFrame` (model.c:2936): accepts `endframe` only if `endframe < ModelAnimation->unk04 − 1`, else stores −1 (unbounded). Named fields throughout. ✓
|
||
|
||
**New finding 1 — there is no conversion step for this data; step 1's premise is wrong.** The firing-animation groups are byte-matched decompiled C in `src/game/chr.c` (`pistol_firing_animation_groupN[]`, named-field access → A1-immune; `len = −1` resolved at runtime by `initResolveAnimGroups`/`initResolveAnimGroupTable`, initactorpropstuff.c:82–158, counting to the sentinel row). The `ModelAnimation` records come from **raw ROM via `romCopy`** (D34, port/src/romdata.c; `g_pc_animdata_base`) — the PC reads the *same bytes* the N64 does, and `ModelAnimation` has no pointer members (bondtypes.h:629; D32 already u32-ized `bitDescriptors`/`bitStream`) so the layout is stable. "Decode from raw ROM and compare against the port's converted assets" compares two things that are identical by construction; no ROM-decode tooling is needed or in `tools_pc/`.
|
||
|
||
**New finding 2 — the audit was effectively pre-run, and its outcome is knowable now.** Applying `chrlvAttackActionRelated`'s exact selection logic to `pistol_firing_animation_group1` (the group matching both probe signatures):
|
||
|
||
| variant | start | shoot_start | recoil_start | H (pre-aim) | E (post-derail) | outcome |
|
||
|---|---|---|---|---|---|---|
|
||
| fire_hip_…_slow | 12 | 19 | 24 | **19** | **24** | **PIN** — exact match to frozen-run probe (H=19.00/E=24.00) |
|
||
| fire_hip_…_fast | 17 | 25 | 30 | 25 | 30 | PIN |
|
||
| fire_standing_… | 12 | 58 | 60 | 58 | 60 | PIN |
|
||
| fire_hip_forward_… | 4 | 22 | 22 | **22** | 22 | ESCAPE (E==H → `endframe <= frame` re-init) — exact match to escape-run probe (H=22.00) |
|
||
|
||
Group1 is **3/4 pin-capable** — consistent with the observed ~2/3–3/4 forced-repro rate. Other pistol groups: g2 1/2, g3 3/4, g4 1/1, g5 1/1, g6 3/4 — **no all-escape group exists**. Consequence: byte-matched code + ROM-identical data ⇒ under identical ticks the N64 should pin on ≥½ (typically ¾) of derails. The user's spec (0 pins in ~30 y / all 1964 trials escape) is therefore **in direct conflict with the byte-matched data** — this is the plan's "unhandled branch," and it is the *expected* outcome of step 1, not a corner case. The plan has no decision rule for it.
|
||
|
||
**New finding 3 — the pin sits exactly ON the fire-window boundary; D318/D320's stated condition is imprecise.** In every E>H variant above, **H == shoot_start exactly** (19/25/58) — not "H < shoot_start" as D318/D320 state. The pinned frame therefore sits at the edge of the fire gate `shoot_start <= frame < shoot_end` (chrlvTickAttackCommon), and whether c78 fires from that state hinges on **sub-frame settling**: `modelTickAnim`'s s32 `framea/frameb` clamp (`modelConstrainOrWrapAnimFrame`) + the `if (vb == va) animframe1 = va` sub-progress discard. The D318 probe printed `%.2f` ("19.00") — 18.99x would display identically. Working hypothesis: on N64 the frame settles at exactly shoot_start → gate opens → every "pin" variant fires (N64 pin rate = 0%, matches spec); on PC it settles just below (or a downstream gate in the fire loop — `unk38[i]` weapon slots, `unk44`/`attack_time` priming — stays closed) → ¾ pin (matches observation). **A single deterministic per-derail divergence explains the full 0%-vs-~66% rate gap with no PRNG argument** — which also confirms the brief's claim that a stream difference cannot explain it. Note this makes "find what re-inits the animation on N64 that the port misses" (plan step 2) misdirected: there is **no** re-init path for E>H in byte-matched code, so if data+code are identical, nothing can re-init on N64 either — the N64 escape must come from the fire gate opening at the boundary (or from the derail never landing), not from a missed re-init.
|
||
|
||
**Verdict per attack surface:**
|
||
1. **Candidate completeness.** {data, runtime} is exhaustive only if "runtime" is expanded to include (a) fire-gate boundary settling in `modelTickAnim`/`modelConstrainOrWrapAnimFrame` (new finding 3 — the prime suspect) and (b) the upstream derail window (hypothesis i). The §A1 concern (E/H misread at runtime from correct data) is *ruled out by construction* for this chain — compiled named-field access + raw-ROM `ModelAnimation` with no pointer members — but the plan should record that ruling explicitly instead of running a ROM decode.
|
||
2. **The unhandled branch.** Expected outcome, not corner case (finding 2). "The user's spec is wrong" is **not** an acceptable first conclusion: byte-matched code + identical data vs a 30-year community record means one link in the pin chain carries an unverified platform assumption; hunt runtime/reachability first (boundary settling is cheap and port-probeable). If the boundary hypothesis fails, one *bounded* emulator artifact — a single `g_randomSeed` memory read feeding `GE_RSEED_LV`, or one trial with frame-precision observation — is a different cost class from the 10-trial series the user ruled out; needs user sign-off before use.
|
||
3. **Ordering/feasibility.** Data-audit-first is right but ~80% complete after this review (finding 2). Remainder: extend the H/E table to the ai_19/ai_9 groups and confirm which group index c78 actually draws (`anim_index` from `chrlvDistanceToChrRelated`, distance-bucketed over the 32-entry `ptr_pistol_firing_animation_groups[]`). Effort: ~1–2 h, no new tooling.
|
||
4. **ai_19 / ai_9.** Different anim groups (kneel/crouched tables; probes pinned at frames 30.00/23.00 — neither matches a group1 row). Fixing ai_22's root cause implies theirs only if the cause is shared (boundary settling would be; per-group data is not). Step 4's "re-verify first" must be an explicit **per-entry H/E table + boundary check + forced-repro sweep**, and each watchdog entry excised on its own evidence — not on ai_22's result. Depot ai_12 (clean) and the untested ai_13/14/15 (LOW-MED) stay out of scope for excision.
|
||
5. **Emulator A/B.** Cutting the trial series is correct (given identical data it can only re-test "does the derail happen on N64," which the spec's +3.5 s fire signature already answers — the monologue-completion path takes ~22 s, so a +3.5 s shot is itself evidence the derail landed). Preserve `GE_RSEED_LV` + DET logging armed as the fallback oracle for an upstream stream hunt (cost: one memory-viewer read + an env var); do not burn it until the boundary hypothesis fails.
|
||
6. **Constraint compliance.** All steps port-side; no Rule-2 exposure *as written*. Two cautions: (a) watchdog excision changes shipped v0.4.0 behavior mid-release — keep the watchdog on `release/v0.4.0` until step 3 passes and land the excision post-release (M-192 item 7 precedent); (b) pre-decide the fix bucket for the boundary root cause: if it turns out to be FMA contraction / float-rounding in compiled game code, a **build-flag** change (CMakeLists, e.g. `-ffp-contract=off`) is port-side, but any arithmetic edit in `src/game/model.c` requires Rule-2 sign-off.
|
||
|
||
**Corrected plan sketch:** (1) finish the static H/E table for all three confirmed entries' groups + c78's group index — ~1–2 h; (2) env-gated probe: full-precision (`%f`/bit-exact) `animframe1` + fire-gate operands (`shoot_start`, `phi_f20`, `unk38[i]`, `unk3a[i]`, `unk44`) in a frozen forced-repro run, plus an A1/float pass over `modelTickAnim`/`modelConstrainOrWrapAnimFrame` — ~half a day; (3) decision gate: boundary divergence found → port-side fix + per-entry conformance (static table + existing ai_19/ai_9 forced sweeps; c78 headless materialization is NOT available today — d320repro cannot spawn the proximity-spawned c78, so a "headless forced-derail seed sweep" of the Trevelyan beat as written in plan step 3 is infeasible without a ~1-day harness extension; cost it or drop it) → excision post-release; boundary hypothesis dead → upstream hunt with `GE_RSEED_LV` fallback (user sign-off for the single bounded emulator artifact); (4) D318/D320/D321 rewrite only on pass, per-entry.
|
||
|
||
**Status:** review recorded; plan amended per above. No code changed. Cross-ref: D318 (mechanism + 2026-09-22 correction + probe evidence), D320 (sweep + forced repros), D321 (trigger chain), D34 (animationtable ROM-copy data path), D32 (ModelAnimation u32 fields), D94 (firing-table pointer arithmetic), porting-notes §A1 (ruled out for this chain by construction).
|
||
|
||
## D329 — The D318/D320 guard "anim pin" is a tick-granularity divergence, not a platform-invariant lock: it exists only at 1 sim tick per rendered frame (the port at 60 fps); at ≥2 ticks/frame (GE's normal N64 regime) it cannot persist (lockstep A/B, 2026-09-23)
|
||
|
||
**Question (M-199 step 2 decision gate).** Is the pinned ACT_ATTACK state behind D318 (Facility ai_22), D320 ai_19 (Facility) and ai_9 (Control) a lock that would also happen on N64, or a platform divergence? M-199 expected the former. It is the latter.
|
||
|
||
**Mechanism (source, model.c).** `modelTickAnim` accumulates `frame += playspeed * speed` once per tick inside its `numticks` loop, then calls `modelSetAnimFrame2WithChrStuff(model, animframe1, frame, …)` **once per rendered frame**. That function only writes `framea`/`frameb` inside its per-frame loop, which runs only when `floor(frame)` crosses an integer boundary; its tail re-emits the stored pair and, when `vb == va`, sets `animframe1 = va` and discards all sub-frame progress. During the aim hold the endframe clamp (`modelConstrainOrWrapAnimFrame`: `frame = ceil(endframe)`) leaves `framea == frameb == H`. When `actor_fire_or_aim_at_target_update` then raises the endframe (`chrlvAttackActionRelated`, endframe only, no `modelSetAnimation`), the model can move again **only if one call advances by ≥ 1.0 frame**. Guard attack speed is 0.5–0.8 (`chrlvGetGuard007SpeedRating(0.5, 0.8)`; the fire gate also re-sets 0.5 via `modelSetAnimSpeed`). So:
|
||
- `numticks` = `g_ClockTimer` = 1: advance 0.5–0.8 < 1, no crossing, stale pair re-emitted, pinned for ever.
|
||
- `numticks` ≥ 2: advance ≥ 1.0, crossing, loop runs, `frameb` becomes `framea+1`, the anim plays on. **A single ≥2-tick frame breaks any such pin.**
|
||
|
||
`g_ClockTimer = speedgraphframes` = 60 Hz ticks elapsed since the last rendered frame (`lvlManageMpGame`, `waitForNextFrame`). The port renders at 60 fps → 1 (D193 telemetry's "healthy" 60 render / 60 sim). GE on N64 runs gameplay at roughly 20–30 fps → 2–3. For the pin to survive on N64 the console would have to hold a steady 60 fps on every frame from the update onward, with guards on screen. This matches the user's spec (no such freeze on N64) without any PRNG, variant-lottery or reachability argument.
|
||
|
||
**Evidence (lockstep A/B, WSL Linux build).** Harness: `GE_D318B_CLK=N` in the TEMP probe `port/src/d318bprobe.c` rewrites `g_ClockTimer`/`g_GlobalTimer`/`g_GlobalTimerDelta` in the existing lv.c hook (before any chr tick), so every rendered frame is exactly N ticks regardless of host speed. The probe log's `clk` field confirms it: 100% `clk=N` in every run. Same forced repros as D320 (`GE_D320R`), same PRNG path (the first variant drawn matches across N).
|
||
|
||
| Run | ticks/frame | Post-update state | Watchdog |
|
||
|---|---|---|---|
|
||
| Facility ai_19 (c5) | 1 | `fr=19` exact, `fa=fb=19`, `ef=-1`, `spd=0.5`, frozen for ever | confirmed t=1561, then re-pin rescue every ~600 ticks (6×) |
|
||
| Facility ai_19 (c5) | 2 | first post-update frame 19→20 (`fa=20 fb=21`), advances each frame | 6 detections, **0 confirms**; clean aim→fire→re-aim cycle |
|
||
| Control ai_9 (c3) | 1 | `fr=23` exact, `fa=fb=23`, `ef=-1`, `spd=0.5`, frozen | confirmed t=1021, rescue (2) t=1679 |
|
||
| Control ai_9 (c3) | 2 | 24→25→26… (`fa≠fb`) | 6 detections, **0 confirms** |
|
||
|
||
Logs: `scratch/d318b_{fac,ctrl}_clk{1,2}.log`. Summariser: `scratch/d318b_summ.py`.
|
||
|
||
**Corrections to earlier framing:**
|
||
1. **Not "clamp at endframe".** After the update the endframe is often *unbounded* (`ef=-1`: rifle variants with `recoil_start = end_frame = -1`). The pin is purely the stale `va == vb` pair from the pre-update clamp, which only a ≥1.0-frame step (or a `modelSetAnimation` re-init) clears.
|
||
2. **Weapon class does not decide it.** Both pinned guards carry an **automatic** weapon (`wr=3`). While pinned at frame == `shoot_start` the per-hand fire gate (`shoot_start <= frame < shoot_end`) is open, so the gun **fires every tick** (`unk44` = current tick). The guard "fires for ever and never finishes the animation"; it is not "never fires". The escape conditions all read false (`efle=0`: endframe unbounded; `trm=0`: `recoil_end < 0`; unk31 re-arm path unreachable). "Semi-auto with E>H = deterministic lock on any platform" (M-199 plan) is refuted as the explanation. At ≥2 ticks/frame no escape path is needed, because the model just plays on.
|
||
3. **The M-199 "sub-frame settling at the fire-gate boundary" suspect is also refuted** (as the probe header predicted). Frozen values are exact integers and the gate is open, not closed.
|
||
4. **Earlier WSL wall-clock repros are not calibrated evidence.** WSLg renders this game at ~5 fps, so wall-clock runs mix clk 1–6. Whether they pinned depended on host speed. D320's "PIN CONFIRMED" and ai_9's "~2/3 of seed variants" rates came from such runs. The first run of this session (42 detections, zero pins) is the same artifact in the other direction. `GE_DETERM=1` does **not** hold 1 tick/frame either (clock advances per VI-retrace request; measured clk 1–6), so use `GE_D318B_CLK` for any granularity-sensitive repro.
|
||
5. **D318's variant split still holds, but only at clk=1.** E == H variants escape via the endframe re-init; E > H variants pin. That is where the ~2/3 PC rate comes from. At clk ≥ 2 every variant escapes (e.g. ai_22 hip-slow H=19/E=24 at speed 0.68: 19 + 1.36 crosses, plays to 24, normal re-init fires).
|
||
|
||
**Float / A1 pass (M-199 task 3).** Nothing in the pin path is precision-sensitive: `floorFloatToInt`/`ceilFloatToInt` are truncation-based on both targets; the stored pair and endframe comparisons are integer-valued floats; the build uses no `-march`/`-ffast-math` (x86-64 baseline SSE2 single precision, no FMA contraction), and the R4300 has no fused multiply-add either. `Model`, `act_attack` and the firing tables are accessed by named fields (A1 ruled out, M-199). The divergence is purely the number of ticks folded into one `modelTickAnim` call.
|
||
|
||
**Scope (implication, not yet swept).** The trigger is generic: any chr whose non-looping anim is clamped (`framea == frameb`) at an endframe that is then raised without a `modelSetAnimation`, at speed < 1. D320's "combat loops are likely safe" inference and the Depot ai_10 one-off freeze (c0 pinned at 33.00, atk=0x1) should be re-read in this light. The three-entry watchdog covers only the known scripted cases.
|
||
|
||
**Classification.** A genuine port behavioural divergence caused by frame pacing (the port sim runs finer tick granularity per render than the N64 ever did), acting on byte-matched, non-associative game code. Not a data or ABI issue, not in the game logic's inputs. The N64-side claim "GE runs 2–3 ticks/frame in these scenes" rests on GE's well-known frame rate, not an in-session measurement. It is not needed for the PC-side A/B, but it is what connects the result to the user's spec.
|
||
|
||
**Fix options (decision owed — user; none applied):**
|
||
- (a) **Keep the table-driven watchdog** (ships in v0.4.0). Correct for the three known entries; blind to other instances.
|
||
- (b) **Generic port-side pin breaker**: detect a chr model in the stale state (`framea == frameb`, non-looping, raised/unbounded endframe above the frame, speed > 0) and nudge it the way a 2-tick frame would. Port-side, but it writes game state from a hook (watchdog class).
|
||
- (c) **Rule-2 edit in `modelSetAnimFrame2WithChrStuff`**: keep sub-frame progress when the per-frame loop does not run. This is the standard "high-frame-rate fix" pattern. It changes behaviour only where N64 would have taken ≥2 ticks. Needs sign-off.
|
||
- Workaround available now (expected, not verified): `Video.FpsCap=30` gives 2 ticks/frame, which by the above makes the pin impossible.
|
||
|
||
**Fix applied (2026-09-23, RULE-2-SIGNOFF: user approved option (c)).** `src/game/model.c` `modelSetAnimFrame2WithChrStuff`, `#ifdef PORT`: when the per-frame loop did not run, progress was requested, and `vb == va`, re-derive `frameb = constrain(va±1)` against the CURRENT endframe (as `modelSetAnimFrame` does); applied only if that next frame is unclamped. Genuine holds, anim ends and looping wraps are untouched. Env: `GE_D329=0` disables (A/B), `GE_D329LOG=1` logs triggers (cached getenv). **Verified (WSL, lockstep):** clk=1 with watchdog off (`GE_D318W=0`): Facility ai_19 and Control ai_9 no longer pin (frame 19→19.5→20…, 0.5/tick = the N64 clk=2 trajectory; aim→fire→re-aim cycle ≈192 ticks vs ≈195 at clk=2/3); authored holds intact. Trigger census: clk=1 Facility, 9 triggers, all on the repro guard, one per attack update; **clk=2: 0 triggers** over ~5300 ticks (Facility) and ~3600 (Control), so the fix is inert in the N64 regime. **Windows-native validation (2026-09-23):** Facility forced repro, native exe, `GE_D318B_CLK=1`, watchdog off: 48 post-update fire episodes, **0 pinned**, 23 D329 triggers. Bunker 1 golden (640x480 pinned, direct launch): REGRESSION with the fix ON (worst dmean 113.6) and equally with it OFF (`GE_D329=0`, 126.4), so it predates this fix (stale golden; re-baseline already owed). **Interactive playtest (2026-09-23, user, native Windows, shipping config + GE_D329LOG):** Ourumov reacts with N64-matching timing (user-confirmed on screen). Log: 4 D329 triggers (four guards, same tick, stale-pin state, released at 0.5/tick), 3 watchdog detections that cleared on their own, 0 watchdog rescues, no crash (`scratch/d329_playtest.log`).
|
||
|
||
**Status:** FIXED + playtest-verified (2026-09-23). Next: demote the D318 watchdog to detect-and-log post-release (M-192 item 7), then remove the TEMP probes (d318bprobe, d320repro). Watchdog stays shipped on `release/v0.4.0`; plan to demote it to detect-and-log after playtests. Cross-ref: D318, D320, D321, M-199, D155, D186/D193.
|
||
|
||
## D330 — #103 (v0.3.0, Win 11): Auto-Aim / Look Ahead "won't stay on" in the in-game watch options (2026-09-23)
|
||
|
||
**Report:** "Look ahead and Auto-aim both won't remain 'on' in game or in menu options. They simply seem to not work?"
|
||
|
||
**Root cause (port input layer, not game logic).** The in-level watch runs with `current_menu == RUN_STAGE`, so `inputComputePad`'s `menuMode` is 0 and the mouse took the in-stage look path. `hipDirectCompute` declines while the watch is open (`!outside_watch_menu || pause_state`, the GEPD `!watch && !pause` gate), and control fell through to the **legacy stick fallback**: `sx += dx * hipSens * MOUSE_TURN_GAIN` (defaults 0.5 × 1.0 × 6 = 3 counts/px/poll; SOLITAIRE also routes mouse Y to `sy`). The watch reads that stick exactly like an N64 stick:
|
||
- `options.c game_option_toggle_input`: while an option is selected, stick X < −0x2D selects value 0 (OFF) and > 0x2D selects 1 (ON). `controlstick_lr_enabled` re-arms each time the stick recentres, so every mouse flick of about 15 px per poll re-selects a value.
|
||
- With no item selected, the same stick flips watch pages, and mouse Y scrolls rows.
|
||
|
||
So a player sets Auto-Aim ON with a key, touches the mouse, and it snaps back OFF. An RMB hold in the watch also ran the GEPD aim writes (`aimGepdCompute` has no watch gate; GEPD itself gates on `!watch`).
|
||
|
||
**Ruled out.** Save/load round trip: `fileSaveSettingsForFolder`/`fileLoadSettingsForFolder` pack/unpack symmetrically (`OPTION_AUTOAIM 0x2`, `OPTION_LOOKAHEAD 0x10`). The save runs through the misnamed `deleteCurrentSelectedFolder` → `fileClearSavefileForFolder`, which really means "write the current watch options into the selected folder" and is called on watch close and on abort. The load runs at each stage start (`options.c:415`). This machine's `ge007.eep` has the bits set in every populated folder (`opts=003a`). No port code writes `game_options_entries`, `autoxaimenabled`, `autoyaimenabled` or `automovecentreenabled`.
|
||
|
||
**Fix (`port/src/input.c`, port-only, no Rule-2).** A new branch in the mouse block, ahead of the aim/hipfire paths: while in-stage with the watch open or paused, the mouse contributes nothing. Keyboard (A/D = C-left/right, turn keys = stick) and gamepad still drive the watch exactly as on N64.
|
||
|
||
**Verification.** Build clean. Bunker 1 golden 3/3 (no-input path unchanged). A runtime check of the mouse path needs real mouse motion (scripted input replaces the whole controller-0 source), so **a human must run it**: in a level, START → Control Options → select Auto-Aim → ON → move the mouse around → still ON; close and reopen the watch → still ON; next level → still ON.
|
||
|
||
**Related but not changed (faithful behaviour worth knowing if the "doesn't seem to work" part persists):** look-ahead (`bondview2.c` ~6060) only arms on `analogWalk > 60` (analog stick walking) and cancels on any manual pitch. Under the port's default SOLITAIRE scheme, W/S are digital C-up/C-down and pitch is continuous mouse/right stick, so look-ahead rarely engages. That is N64 1.2/SOLITAIRE behaviour, not a port bug. Auto-aim's crosshair pull is overwritten by the GEPD aim-mode writes while RMB is held, which is the D194 design, but it still applies in hipfire.
|
||
|
||
**Status:** FIXED, feel-check owed. Cross-ref: D194, D261, WI-1 (`hipDirectCompute`).
|
||
|
||
**2026-09-25 playtest (user, Windows, one ~46-min session Silo → Caverns, `GE_D322=1 GE_D204=1`, build `7baf4900`):** watch → Control Options → Auto-Aim ON → mouse moved → stays ON. Feel-check done.
|
||
|
||
## D331 — Ejected shell casings never rendered on PC (D264 pattern, third site) (2026-09-23)
|
||
|
||
Found by the sibling-site sweep that D290 prompted (porting-notes §B "one layout bug often has sibling call sites"); never user-reported, likely because a missing casing is easy to overlook. `sub_GAME_7F068EC4` (`gunfire.c`, casing draw) initialises its render data with `*(ModelRenderData *)g_DefaultCasingModelRenderData`, where the global is `u32[15] = {0, 1, 3, 0…}` (`gun.c`): the N64 template basemtx NULL, zbufferenabled TRUE, flags 3. On PC `basemtx` is 8 bytes, so the words shift: zbufferenabled = 3, **flags = 0**, and `subdraw()` gates off every node. The copy also reads past the 60-byte array into whatever the linker put next. **Fix (`#ifdef PORT`, ABI/layout class, no Rule-2):** the explicit template, as in D264/D290. **Verified (native Windows, headless):** `-level_09`, `GE_INPUTSCRIPT="1000:Z;1020:Z"`, `GE_PCDUMP=850-1000:3`, shots at frames ~970/991. Before: impact sparks only, no casing. After: a brass casing ejects right of the PP7 and tumbles across frames 973–979. Bunker 1 golden 3/3; Windows + Linux builds clean. Sweep status: every `*(ModelRenderData *)` read in `src/game` is now covered (D215 :1592, D264, D290, D331). Cross-ref: D264, D290, porting-notes §A1/§B.
|
||
|
||
## D332 — Reticule jitter in aim mode (RMB): the drawn crosshair steps because the game damps once per tick and we write once per poll
|
||
**Symptom.** Holding RMB (precision aim), the reticule jumps around. Reported in issue #89 as "mouse movement is not uniform", and independently as a jitter that is worst under uneven frame pacing.
|
||
**Root cause, measured from live captures.** `caclulate_gun_crosshair_position_rotation` (`src/game/gunfire.c`) runs once per **sim tick** and does `crosshair_x_pos = crosshair_x_pos * guncrossdamp + turn_x`. The GEPD aim path (`port/src/input.c`, `aimGepdCompute`) *overwrites* `crosshair_x_pos` once per **input poll**. So the value the game displays is `write * damp^k`, where `k` is the number of ticks between two of our writes — and `k` varies, because the poll cadence (per VI retrace) and the tick cadence drift in phase.
|
||
|
||
Measured over 1266 aim frames from two captures, with the mouse still and the accumulator constant, `game/cross` took exactly three values (`damp^0`, `damp^1`, `damp^2`); `game = cross * damp^k` fits within 0.02 on 814/834 frames:
|
||
```
|
||
k=0: 72 k=1: 664 k=2: 89 k=3: 5 k=5: 3 k=6: 1
|
||
ct=1 -> k=1 (226), k=0 (7) ct=2 -> k=2 (77), k=0 (68)
|
||
```
|
||
The drawn offset is that times `(1 - damp)`, so at `damp = 0.8` the reticule steps **20%** whenever `k` changes — on 16.5% of frames. (Diagnosed with `GE_INPUTLOG=1`, which logs the port's accumulator, the game's residue, `ct`, and the damp.)
|
||
**Ruled out by the same capture:** auto-aim (`aa=0` throughout), stick turn (`st/sv=0`), camera edge-scroll (`cam` constant), and damp changes (only 5 KISSY/HONEY flips in 839 frames — real, each a ~43% rescale jump, but far too rare to be the main effect).
|
||
**Two fixes tried and reverted.**
|
||
- *Removing the D194(b) dt-normalisation* from the GEPD accumulator. Theoretically right — a position integral wants raw displacement, not a rate — but it did not stop the jitter.
|
||
- *Pre-compensating `damp^k` with a predicted `k`.* Made it **worse**. `k` is not predictable at write time, and using the observed previous `k` is worse than doing nothing: simulating properly against the captures gives no compensation `mean|step| 0.0066`, lagged compensation `0.0170`, ideal `0.0005`. The first validation of this attempt was **circular** — it used `ct` as the number of damps, the very assumption the data contradicts.
|
||
**Fix — adopt the Perfect Dark port's model.** `fgsfdsfgs/perfect_dark`'s aim code is structurally identical to GE's (`crosspossum` == `crosshair_x_pos`, `screenx` == `turn_x`, `crossdamp` == `guncrossdamp`, the same rescale block and display formula), but PD chooses the damp **per input device**: its mouse path calls `bgunSwivelWithDamp(x, y, 0.01f)` while its stick path uses `~0.945`. GE has one damp for both, and the weapon's `CrosshairSpeed` (~0.8) is a stick value — the integrator is what smooths a stick. At 0.01 the input dominates and `k` stops mattering.
|
||
|
||
Implemented as `Input.PdMouseAim` (**default off**, because it changes aim feel): the port only accumulates (`aimGepdAccumulate`, shared with GEPD's model) and a `#ifdef PORT` block in `sub_GAME_7F067FBC` takes the port-supplied turn and sets `guncrossdamp = 0.01f`. It is an F10 overlay row ("PD mouse aim"), which is live — toggling it in game takes effect on the next poll, no restart — plus a `GE_PDMOUSEAIM` launch-time override for A/B runs without editing the ini. PD does the same thing from its own game files (`bondmove.c`/`bondgun.c`/`player.c`, behind `#ifndef PLATFORM_N64`) calling port functions, so a platform-guarded game hook is the reference's own approach.
|
||
**Also fixed here:** the first cut of PD mode skipped `aimGepdCompute` entirely, which is where the camera **edge-scroll** lived, so dragging the crosshair to the screen edge stopped moving the camera. The edge-scroll belongs to the aim position, not to whichever model owns the crosshair — it is now `aimGepdEdgeScroll()`, called from both paths.
|
||
**Owed:** sensitivity tuning (PD's 0.01 against GEPD's accumulator scale may not match PD's own calibration; `Input.AimModeSens` is the knob).
|
||
**Integrated locally on `release/v0.4.0` (2026-09-23), from PR #96 (danturn; authorship kept).** Changes on integration: renumbered D337 → D332 (next free label); `GE_PDMOUSEAIM` lookup cached via `GE_ENVSTR` (it ran every input poll and every aim tick; see the getenv sweep `d9d7cd35`), and the "live env A/B without restart" comments corrected (a running process's env is fixed; the live toggle is the F10 row); the `gunfire.c` hook is a behavioural `#ifdef PORT` hook and got the maintainer's **Rule-2 sign-off 2026-09-23**. Scope checked: the hooked `sub_GAME_7F067FBC` runs only on the aim-mode (KISSY) branch of `bondview2.c`, so hipfire and auto-aim are unaffected. Windows build clean; golden 3/3 (default off). **Owed:** hands-on feel A/B (F10 → "PD mouse aim" on/off while holding RMB).
|
||
|
||
## D333 — #104: FPS-style centred aim on RMB → `Input.AimStyle` selector (2026-09-23)
|
||
|
||
**Request (#104):** with mouse and keyboard, RMB gives N64-style aim (crosshair travels, camera edge-scrolls). The reporter found that holding Q (bound to the game's L button) gives "PC FPS-style" aim, with the crosshair centred and the mouse turning the camera, and wanted that on RMB. **Why Q behaves that way:** Q sends `GE_CONT_L`, so the game enters its sight mode, but the port's `aimHeld` is RMB/`IA_AIM` only, so `inputComputePad` stays on the hipfire direct-look path (`hipDirectCompute`, WI-1). No stick is emitted, and the aim integrator (`pos = pos*damp + turn`, gunfire.c via the KISSY branch) settles the crosshair at 0 (centre).
|
||
|
||
**Implementation (port-only, no game-code change):** one setting, `Input.AimStyle` 0–2, replaces D332's boolean in the F10 overlay ("RMB aim style"). 0 = **N64** (GEPD mirror, D194; default, unchanged). 1 = **PD DAMP** (D332). 2 = **CENTRED**: inside the RMB `aimHeld` branch, the mouse goes to `hipDirectCompute` (FOV-scaled, so zoomed weapons turn slower; the same watch/pause/cutscene gates) and no aim stick is emitted, which is exactly the Q behaviour on RMB. `Input.PdMouseAim=1` from PR-#96 builds migrates to style 1 on first use. `GE_PDMOUSEAIM` still forces PD on or off at launch.
|
||
|
||
**Verified:** Windows + Linux builds clean; bunker1 golden 3/3 (default style 0). Not verifiable headlessly: the scripted input source has no mouse, and the overlay row sits below the fold at every window size (it uses the same `ROW_ENUM` machinery as Texture filter). **Owed (user):** F10 → RMB aim style → CENTRED, hold RMB and move the mouse: the camera turns, the crosshair stays centred, and zoomed weapons turn slower. Also compare PD DAMP against N64 for D332's jitter fix.
|
||
|
||
## D334 — Native widescreen, world side (WIDESCREEN-FOV-PLAN Option B, Phase 2) (2026-09-23)
|
||
|
||
**Approach:** GE already ships an N64 16:9 mode (`bondviewMovePlayerUpdateViewport`, bondview2.c): `aspect = (vp_w/vp_h) × 0.75 × 16/9`, an anamorphic pre-squash that a 16:9 TV undid. The port's canvas-to-window mapping (fast3d `RATIO_X = win_w/320`) is exactly that TV, so native widescreen is the same formula with 16/9 replaced by the real window aspect, via `portNativeAspect()` (`port/src/video.c`, clamp [0.5, 4.0]). Vertical FOV stays the game's; horizontal widens (Hor+, the PD standard). Because the game's canvas, portal scissors, culling (`currentPlayerSetPerspective` gets the same aspect through `viSetAspect`) and AI off-screen tests all stay in the 320×240 logical space with one consistent aspect, the GEVR "two notions of view width" trap (plan §6) doesn't arise. That's the same reason the N64 16:9 mode worked.
|
||
|
||
**Game edits (`#ifdef PORT`, Rule-2 approved by the maintainer 2026-09-23), the complete set of GE's own 16:9 special cases, generalised:** (1) the camera aspect, NTSC and EU branches; the in-game "Ratio 16:9" option is ignored while native is on, since the window is the ratio; (2) the crosshair sprite half-width, where ×0.75 = (4/3)/(16/9) becomes (4/3)/aspect; (3) the rocket launcher's lowered viewmodel (`gunofs.y -= 3`) for aspect ≥ 1.55. **Port:** `Video.NativeWidescreen` (default 1, F10 "Native widescreen"); `portScaleFovY` skips the Phase-4 `sqrt(aspect/4:3)` vertical boost while native is on (it would double-count); `portRoomPoolScale` keeps its aspect term (the horizontal frustum still widens, D294).
|
||
|
||
**Verified (native Windows, headless):** 640×480 golden 3/3, so 4:3 is identity by construction (aspect 4/3 → ×1). Quantitative check: the centre 960 columns of a native 1280×720 frame vs a 960×720 (4:3) frame of the same moment differ by mean 4.3 (run-to-run noise), against 12.4 for the old stretched+boosted mode squeezed back. 21:9 (1680×720): Bunker intro + gameplay, Facility intro room and Dam gorge all render edge to edge with no scissor slabs, missing rooms or sky gaps. Windows + Linux builds clean. **Not yet native:** HUD, text and 2D overlays (level title card, ammo counter, watch) are still stretched with the canvas. That's Phase 3 (EXT-aspect wraps). The front-end menus' fixed 3D (`viSetAspect(ASPECT_RATIO_SD)` in front.c) is still stretched too. **Owed (user):** feel check at 16:9/21:9 (aim zoom, rocket launcher, cutscenes).
|
||
|
||
## D335 — Native widescreen, HUD + front end (WIDESCREEN-FOV-PLAN Option B, Phase 3) (2026-09-23)
|
||
|
||
**Mechanism:** fast3d already decodes PD's `G_EXTRAGEOMETRYMODE_EXT` aspect modes (`gfx_update_aspect_mode`). Under a mode, clip-space vertex x (`gfx_pc.cpp` vertex path, so 3D-projected HUD too) and rectangle x are scaled by `(4/3)/window_aspect` and anchored LEFT, RIGHT or CENTER; scissors are adjusted to match. `port/include/hudaspect.h` (game-includable) adds `gSPHudAspectEXT` (via `gDma0p`; the decoder clears `~(w0 & 0xFFFFFF)`, so passing `~MASK` swaps only the aspect bits) and `PORT_HUD_ASPECT(gdl, mode)`. That emits only while native widescreen is on **and** the window is wider than 4:3. At ≤ 4:3 nothing is emitted (byte-identical); narrower windows keep the squeezed canvas mapping rather than cropping.
|
||
|
||
**Wraps (`#ifdef PORT`, Rule-2 approved):** gameplay HUD in `maybe_mp_interface` (bondview2.c): watch (`bondviewRenderWatch`), gauge bars (both sites), bottom messages / level title card (`hudmsgBottomRender`, both camera modes), upper text window (`sub_GAME_7F08AAE8`) and countdown timer, all CENTER. Ammo (`generate_ammo_total_microcode`, gunfire.c): right-hand block RIGHT, left-hand (dual-wield) block LEFT. Front end: all of `menu_jump_constructor_handler` (front.c) CENTER, i.e. pillarboxed 4:3, and `input.c` maps the absolute menu pointer across the centred region instead of the window. **Deliberately unwrapped:** the crosshair (its position lives in the world-projection space; only its sprite width is corrected, D334), full-screen fades, blood and damage overlays (should cover the window), and the world.
|
||
|
||
**Verified (native Windows, headless captures):** 21:9 gameplay: ammo counter right-anchored and undistorted; title card undistorted; watch round, inventory and control-options pages (incl. the D290 pad) undistorted. 16:9 front end: legal screen and file select pillarboxed and undistorted; gun-barrel spiral clips at the 4:3 edge. 640×480 golden 3/3; Windows + Linux builds clean. **Known cosmetic:** gun-barrel circle leaks into the bars while sliding in (GRAPHICS-BACKLOG D335a). **Not yet wrapped (next pass, needs live checks):** end credits (`bondviewRenderCredits`), sniper/zoom overlays inside `gunDrawSight`, MP radar (MP out of scope). **Owed (user):** full HUD pass at 16:9 and 21:9 in play (pickup messages, objectives, health bars, dual-wield ammo, mission complete/failed screens, menu pointer accuracy).
|
||
**D335 follow-up (2026-09-23):** end credits (`bondviewRenderCredits`, camera-mode-1 path) wrapped CENTER. Verified at 16:9 via `-level_54`: ending cutscene subtitles undistorted, cast roll inside the 4:3 region. `gunDrawSight` checked: it draws only the crosshair sprite (GE has no scope mask), which D334 already corrects, so no wrap is needed. **D335a resolved:** a full-canvas scissor after the front-end CENTER wrap clips the gun-barrel backdrop at the pillarbox edge (capture-verified).
|
||
**D334/D335 16:9 smoke sweep (2026-09-23, native Windows, 1280×720, defaults):** 16 further levels (Runway, Surface 1, Silo, Frigate, Bunker 2, Statue, Archives, Streets, Depot, Train, Jungle, Control, Caverns, Cradle, Aztec, Egypt; plus Bunker 1, Facility and Dam earlier), `GE_PCDUMP` at an intro frame (400) and a gameplay frame (1000). 16/16 render both frames, 0 crash lines. Contact sheets eyeballed: world edge to edge with no scissor slabs or missing rooms, skies full width, intro captions and Archives' dialogue box undistorted inside the 4:3 region, ammo counter right-anchored, Streets' countdown timer undistorted.
|
||
|
||
## D336 — "All polygons break except the gun" on Frigate after another level: uninitialised head-anim alias fields (A1 / D100 follow-up) (2026-09-24)
|
||
|
||
**Report (user playtest):** load Runway (or Dam/Surface/Silo…), exit, load Frigate, look around, and everything but Bond's gun breaks for the rest of the session. The `D156` bad-anim guard fired in those sessions.
|
||
|
||
**Repro (headless, native Windows):** `GE_STARTMENU=10 GE_STARTMENU_PAGE=3` (Runway briefing) → scripted watch Abort → TEMP `GE_D235_NEXT=26` re-entry into Frigate → sustained `SLEFT`/`SRIGHT` turn. The user's ini (DrawDistance 400, LodDistance 400, FOV 120) gives D294's room pool its 2.0× cap. After the turn the world renders black except the gun, and D156 fires. `GE_ROOMPOOL=1.0` (authored pool) is clean. The pool size was the trigger, not the cause.
|
||
|
||
**Elimination:** stage bank not exhausted (3.8 MB free, `GE_D294BANK`); texture pool full-size (768 KB) with 0 pool-full events (new `D85TEX` pool-size line); no mema alloc/free outside the heap and no room-GDL/room-total overflow (TEMP probes, removed). The D156 victim was always **heap end + 0x838**, i.e. `&player->model` (the player struct is allocated right after the room heap). Hardware watchpoints (gdb Python, `scratch/watch2.py`) on the model's anim fields caught the poisoned value arriving through legitimate code: `MoveBond → bondviewMoveAnimationTick → bheadAdjustAnimation (bondhead.c) → modelSetAnimation → modelSetAnimFrame(frame=2.07e32)`. The startframe comes from `g_CurrentPlayer->field_5C0`, and a watchpoint on that field saw **no writes at all**.
|
||
|
||
**Root cause (ABI/layout, A1 raw-offset alias, D100 follow-up):** on N64 the player's `model` at 0x598 is an inline `struct Model`. The decomp split it into `Model *model;` plus placeholder fields, so `animFlipFlag` (0x5BC) is `model+0x24` = **`model.gunhand`** and `field_5C0` is `model+0x28` = **`model.animframe1`** (4-byte pointers, no holes; the neighbouring `unk25`/`unk27`/`unk2c` names confirm). D100 made `model` a real inline struct on PC, so those placeholders became separate fields that nothing ever initialises. `bheadAdjustAnimation` computed Bond's head-bob start frame from uninitialised player-struct bytes. Where the player struct lands depends on the room-pool size (D294 2× moves it) and on what previously occupied that memory (hence the level-order dependence). Garbage like 2e32 poisoned Bond's model anim state and wrecked the scene. A sweep of all `field_59C..field_650` placeholders found these two as the only reads.
|
||
|
||
**Fix (`src/game/bondhead.c`, `#ifdef PORT`):** `BHEAD_CURFRAME` = `model.animframe1`, `BHEAD_FLIPFLAG` = `model.gunhand`, the exact bytes the N64 code touches. No logic change.
|
||
|
||
**Verified:** the same Runway → Frigate → turn repro at the 2× pool renders correctly throughout, D156 0 fires; bunker1 golden 3/3; Windows + Linux builds clean. **D311 link:** D311 (upstream anim-poison source, open) named `bheadAdjustAnimation` as the one path where a corrupted value reaches `model->speed`. This is very likely that source; re-check D311's Caverns signature in play before closing it. **D294 note:** its "linear 3.0× variant corrupted model anim state on the menu-driven path" is this same bug (a bigger pool moved the player struct onto dirtier memory), not a pool-size problem. The 2× cap can be revisited. **User-confirmed (2026-09-24, native Windows, shipping settings incl. DD/LOD 400):** "Looks good when doing Facility > Frigate and in same session Runway > Frigate." Session log: 14 level loads (Bunker 1, Facility, Silo, Dam, Runway, Frigate…), **0 D156 fires** (every prior session had ≥1), 0 texture pool-full.
|
||
|
||
## D337 — Aim modes folded; Q/L + Toggle routing; per-device mouse integration; headless aim measurements (2026-09-24)
|
||
|
||
**User decision:** one N64 aim mode (the PD-integration of D332, made default) plus the opt-in "Centred (PC)" mode from #104. The old GEPD overwrite survives only as the hidden `Input.AimLegacyGepd=1` for one release. **Setting:** `Input.AimMode` 0 = N64, 1 = Centred (PC). Legacy `Input.AimStyle` 2 migrates to 1; `AimStyle` 0/1 and `Input.PdMouseAim` map to N64. F10 row "Aim style". `GE_PDMOUSEAIM=0/1` still forces the path at launch.
|
||
|
||
**#104 origin, corrected:** the reporter was describing the port's existing Q behaviour, not requesting a new design. In GE's 1.1/1.2 control schemes the aim buttons are `L_TRIG | R_TRIG` (bondview2.c `aimButtons`), so Q ("LeanLeft", which sends L; the bind name is a misnomer) is a second aim button. The port's mouse routing only recognised RMB/Shift, so Q put the game in aim mode while the mouse kept turning the camera: an accidental "FPS aim". **Fix:** mouse aim routing is now "RMB/Aim bind **or** Q/L **or** the game's `insightaimmode`". That also covers the Aim Control "Toggle" option and gamepad aim, which the held-button check never did. The R emit stays tied to the physical button, so Toggle still sees edges. Verified: `LHOLD` + scripted mouse moves the drawn crosshair N64-style (160 → 280 px) with the camera turning only via edge-scroll.
|
||
|
||
**Device-aware integration (would have been a regression when made default):** the D332 game hook replaced the stick's turn whenever the mode was on and the mouse was grabbed, freezing a gamepad or keyboard user's crosshair. PD chooses its damp per input device; the port now tracks the last-used aim device (mouse motion sets it; pad stick deflection or keyboard turn keys clear it), and the hook feeds the mouse integration only while the mouse is active.
|
||
|
||
**Headless measurement harness (new):** `GE_INPUTSCRIPT` tokens `MDX<n>`/`MDY<n>` (sustained scripted mouse px per poll, treated as a grabbed mouse), `RHOLD`/`LHOLD`/`RREL` (sustained aim hold), and `GE_AIMLOG=1` (per-poll: aim state, active device, the game's drawn crosshair x `crosshair_angle.f[0]`, `crosshair_x/y_pos`, camera angles). **Results (Bunker 1, 640×480, 3 px/poll sweep):**
|
||
- *Screen range and edge-scroll, old GEPD vs new:* the drawn offset is `crosshair_x_pos × (1 − damp) × W/2` (gunfire.c), so GEPD's raw ±5.16 at damp 0.8 and PD's ±1.0 at damp 0.01 are the same screen edge. Sweep speed on screen 1.00 vs 1.21 px/poll; edge-scroll starts at 0.745 vs 0.719 of half-width; edge-scroll rate identical (1.478°/poll, sd 0). Equivalent.
|
||
- *Jitter:* drawn-crosshair cv 0.18 (GEPD) vs 0.16 (new), one stall and one jump each in 75 polls. The GEPD step jitter needs irregular ticks per poll (real vsync pacing), which this headless run rarely produces, so the jitter benefit rests on D332's analysis plus hands-on feel, not this test.
|
||
- **Accuracy finding (open, user decision):** N64 stick aim turns the crosshair by `stick × 0.65/80` per tick, so at full deflection the drawn crosshair settles at **65% of the half-width regardless of weapon damp**, and the camera starts turning past stick 60/80 (≈49% of half-width). Both mouse paths let the crosshair travel to about 100% of the half-width, with edge-scroll from about 72%. A faithful calibration would map the mouse range to ±65% and start the camera turn at ≈49%.
|
||
|
||
**Verified:** golden 3/3; Windows + Linux builds clean. **Owed:** user feel pass (N64 default vs Centred; Q now aims); decision on N64-range calibration.
|
||
|
||
## D338 — Aim range option: PC (default) vs N64 original limits (2026-09-24)
|
||
|
||
**User decision (D337 follow-up):** keep the GEPD / mouse-injector feel (the crosshair reaches the screen edge, edge-scroll from 72%) as the PC default even though it isn't N64-exact, and offer the original behaviour as an option. **`Input.AimRange`** 0 = PC, 1 = N64 (F10 "Aim range", hidden while the aim style is Centred (PC)). N64 derivation: aim turn is `stick × 0.65/80` per tick, so full deflection settles at a drawn offset of 0.65 × half-width regardless of weapon damp (`screen = pos × (1−damp) × W/2`, gunfire.c), and the camera turns past stick 60/80 = 75% of that range. Implementation: the mouse accumulator is unchanged; its screen mapping is scaled by 0.65 (PD turn and legacy GEPD write alike) and the edge-scroll threshold moves to 0.75. The non-aim "adopt" of the game crosshair into the accumulator now converts through the game's screen mapping and the range scale, so aim entry never jumps (it copied raw units before, a small pre-existing mismatch on the PD path). **Measured (GE_AIMLOG harness, 3 px/poll sweep):** PC max drawn x 316/320 (98%, the game's 4 px edge clamp), camera turn from 72%; N64 max 264 (65%), camera turn from 237.9 px (49%); aim entry at 160 (centre) in both. Golden 3/3; Windows + Linux builds clean.
|
||
**D337/D338 user feel check (2026-09-24):** "Seems good." The session exercised the legacy-key migration live: the user's ini had `AimStyle = 2` (Centred) from D333 testing and loaded as `AimMode = 1` as designed. User's ini then set back to `AimMode = 0` (N64, their stated preference), `AimRange = 0` (PC).
|
||
|
||
## D339 — #92 low-end performance: CPU budget measured, GPU the likely bottleneck; GE_PERFSTAT probe (2026-09-24)
|
||
|
||
**Report (#92, filed by the maintainer for a user):** Celeron N3060 (2C, 1.6 GHz) + Intel HD 400 (Bay Trail), Windows 10: the port runs ~30 fps, "a bit higher in enclosed areas", while the PD PC port holds 60 on the same machine.
|
||
|
||
**Probe (`GE_PERFSTAT=1`, env-gated, zero cost when unset):** `port/fast3d/gfx_pc.cpp` averages over 300 frames: `pre` (frame setup), `dl` (display-list pass = software RSP + GL draw submission), `post` (MSAA resolve / end_frame), `swap/pace` (swap_buffers_begin, which contains the D186 frame-pacing wait), triangles and draw batches per frame. `src/boss.c` reports game-thread busy time per scheduler message (~2 messages/frame). `port/fast3d/gfx_opengl.cpp` adds a double-buffered `GL_TIME_ELAPSED` GPU timer (logged with `GL_RENDERER`/`GL_VERSION`).
|
||
|
||
**Measured (this machine: RTX 5090, fast desktop CPU; 1280×720, defaults):**
|
||
|
||
| Level | dl (ms) | tris/frame | batches/frame | game thread (ms/frame) |
|
||
|---|---|---|---|---|
|
||
| Bunker 1 | 0.32–0.39 | ~560 | ~99 | ~0.2 |
|
||
| Dam | 1.24 | ~2,700 | ~233 | — |
|
||
| Streets | 1.69–1.71 | ~3,080 | ~411 | ~0.35 |
|
||
| Egypt | 1.77–1.79 | ~3,560 | ~322 | ~0.3 |
|
||
|
||
`pre`/`post` ≈ 0; `swap/pace` ≈ 2 ms is the pacing wait, not work. **CPU cost scales with scene complexity** (matching the reporter's "higher in enclosed areas"), but totals only ~2.1 ms/frame at the heaviest. Scaled for an N3060 (~5× slower per core, logic and render on separate threads), that's roughly 2 + 9 ms on two cores, which fits in 16.7 ms. CPU is therefore unlikely to be what pins the reporter at 30. **GPU is the prime suspect:** an HD 400 at native resolution with the port's defaults (MSAA 4×, 16× anisotropic, the 3-point filter shader) against the PD port's lighter defaults. **GPU timer caveat:** on this RTX 5090 the query read 1.7 ms at defaults and 4.1 ms at MSAA 1 / aniso 1 / bilinear, an inversion that shows the timer also captures swap-chain back-buffer waits on the direct (non-FBO) path. It is only meaningful on a GPU-bound machine (it would then read close to the frame time).
|
||
|
||
**Next (needs HD 400-class hardware, not reporter logs):** run `GE_PERFSTAT=1` on a comparable machine at defaults vs MSAA 1 / aniso 1 / bilinear; if GPU-bound, consider a first-run low-end preset (the Steam Deck preset, `da31edaa`, is the precedent) keyed on the GL renderer. CPU-side headroom if ever needed: batches per frame (100–411) is the obvious lever (state-change batching in `gfx_flush`).
|
||
|
||
## D341 — Intro gun-barrel blood drip draws as static blocks/lines instead of dripping (M-201, 2026-09-26)
|
||
|
||
**Report:** the 2D red blood "drip" at the end of the gun-barrel intro glitches and doesn't draw right; the death blood looks normal.
|
||
|
||
**Measured:** `die_blood_image_routine` builds a correct 96x80 I4 drip image each step (dumped and rendered: a growing top band with drips); `gunbarrelBloodOverlayDL` draws it as a full-screen 1-cycle texture rectangle at the title's 440x330 VI (dsdx 223, dtdy 248, tile 0 = I4 96x80, `GE_D116` confirmed). The screen showed a constant pattern of red blocks and horizontal lines that never changed.
|
||
|
||
**Cause 1 (the wrong image):** texture-unit 0 imported from **tile 1** (`unit=0 tile=1 cyc=1CYCLE lod=1`): the title leaves `G_TL_LOD` set from earlier draws, and `gfx_lod_tile_offset`'s D236 detail-base rule (tile 1 in different TMEM with a different format → sample tile 1) redirected the rectangle to a stale CI8 tile-1 declaration sharing TMEM 0 — 128 bytes of the blood buffer decoded as CI8 and stretched. On the RDP, LOD tile selection only exists in 2-cycle mode; a 1-cycle texture rectangle samples exactly its command's tile. Fix: `gfx_lod_tile_offset` returns `i` while a texture rectangle is drawing outside 2-cycle mode (`s_in_texrect`, set around `gfx_draw_rectangle` in the texrect + wide-texrect paths). 3D triangle paths (D107/D236) are unchanged.
|
||
|
||
**Cause 2 (frozen animation):** the image is regenerated in place in the per-frame dynamic pool, at the same address every step, and reached via `OS_K0_TO_PHYSICAL` → the KSEG0 mirror (`0x800a8f20`). The GL texture cache keys non-CI textures on address only, so after the first import every later step was a stale hit. Fix: textures in the dyn pool (`[g_VtxBuffers[0], g_VtxBuffers[2])`, with the 0x80000000 mirror normalised to V1) get an FNV-1a content hash in the key. A/B with `GE_DYNTEXHASH_OFF=1`: blood coverage stays at 1% (frozen); with it, the drip grows.
|
||
|
||
**Verified:** headless boot capture — the blood now runs down from the top over the gun barrel as on N64 (`scratch/blood_after.png`). Golden gate 3/3. User visual check owed.
|
||
|
||
**Status: FIXED (M-201, user-verified 2026-09-27).**
|
||
|
||
## D342 — File select: folder bodies and Bond photos vanish after selecting a file and backing out (M-201, 2026-09-26)
|
||
|
||
**Report:** select a file, back out to file select: the folders draw only their outlines; bodies and Bond photos are gone. Reproduced headless (`SkipIntro=1`, `GE_INPUTSCRIPT="400:A;700:B"`).
|
||
|
||
**Cause (layout-only, A1-adjacent):** the file-select folders are `PROP_WALLETBOND` model instances loaded at the start of `ptr_logo_and_walletbond_DL`. On PC that model expands to 0x1664C bytes and was given a 0x17000 reservation (D45) instead of N64's 0xA000, but two users of the space AFTER it kept the N64 offset `4096*10` (+0xA000): `init_menu05_fileselect`'s scratch DL (budget 0x6e000) and `load_briefing_text_for_stage` (briefing data). On PC both write into the middle of the wallet model. `load_walletbond` only reloads the model when the instances were freed (coming from the legal screen), so after main menu → back, the overwritten model was drawn: outlines intact, body/photo geometry corrupt.
|
||
|
||
**Ruled out first:** GL texture-cache staleness on pool reuse (a queued texLoad-address invalidation made no difference; reverted).
|
||
|
||
**Fix (`src/game/front.c`, `#ifdef PORT`):** both sites use `ptr_logo_and_walletbond_DL + 0x17000`. The budget is unchanged: 0x85000 (PC buffer) - 0x17000 == 0x78000 - 0xA000 == 0x6e000.
|
||
|
||
**Verified:** user playtest (select/back out repeatedly, folders and photos intact); golden gate 3/3.
|
||
|
||
**Status: FIXED (M-201, user-verified 2026-09-27).**
|
||
|
||
## D343 — File select: PC "Options" label + F10 idle-timer fix (M1, 2026-09-27)
|
||
|
||
**Feature (v0.4.0 intake):** PD PC reaches its extended settings through its own menus (Options > Extended). GE has no generic menu system (each `front.c` screen is hand-written) and no front-end options screen, so this is "route B" from `docs/dev/OPTIONS-MENU-PLAN.md` §4: a native-looking entry point on the file select screen. M1 adds the entry point and reuses the existing F10 overlay; M2 (a GE-styled screen) is next.
|
||
|
||
**Game-code edit (Rule 2, user sign-off 2026-09-27, a feature not an ABI fix):** one additive `#ifdef PORT` block in `constructor_menu05_fileselect` (`src/game/front.c`), right after the Erase label, calling `optionsFileSelectLabel(DL)`. Nothing else in `src/game` changed. Drawing inside GE's own frame means the label fades with screen transitions and gets the D335 pillarbox for free.
|
||
|
||
**Port side (`port/src/optionsoverlay.c`, `port/src/input.c`):**
|
||
- The label is ASCII "Options" (issue #87 / D295) in Zurich Bold, right-aligned with the Erase text (x 392) at y 24, in the dark strip above the right-hand folder. The bottom row is full; the cursor can reach this spot (clamp 20..420 x 20..310). White normally, gold (`0xEBD879FF`, the folder text colour) when hot.
|
||
- Hot = cursor over the text (+4 px), overlay closed, no pending menu change (`menu_update == -1`), and no erase confirmation open (`folder_selected_for_deletion < 0`). The label is clear of every folder and Copy/Erase hit box, so a press on it only triggers front.c's "clear the Copy/Erase mode" path.
|
||
- The click is A/Z/Start via `joyGetButtonsPressedThisFrame` (the game's interface tick runs before the constructor each frame, so the latched press is valid). A left click is A in menus. It plays `DOOR_LOCK_SFX` (222, Copy/Erase's sound).
|
||
- **Threads:** the hook runs on the game's main thread, but the overlay's open flag is read by the scheduler thread (input poll + `gfx_run`). So the hook only posts an `SDL_atomic_t` request, which `optionsOverlayPollRequests()` applies from `inputComputePad(0)` (the same shape as the D314 wheel queue).
|
||
- `textMeasure` returns a height of 0 for text without a trailing newline, which collapsed the first hit box to one row. The height is now measured from `"Options\n"`.
|
||
|
||
**Bug fixed along the way (existing since F10 landed):** while the overlay is open it gives front.c a neutral pad, so `interface_menu05_fileselect`'s idle timer (`g_MenuTimer`, `front.c:~2304`) never resets. After 30 s the screen went to the legal screen and the intro attract loop. The hook now sets `g_MenuTimer = 0` while the overlay is open, which is the same write front.c makes when a button is pressed. This is scoped by construction: the hook only runs on MENU_FILE_SELECT, the one screen where `g_MenuTimer` is an idle timer. Elsewhere it is the intro/cast-roll animation clock and must keep running, so a blanket "front-end menu" gate would have frozen the intro logos under F10.
|
||
|
||
**Verified (Windows native, 640x480 defaults-only ini + `MouseEnabled=0`):**
|
||
- Click: `GE_STARTMENU=5 GE_INPUTSCRIPT="300:SUP,SRIGHT;360:SNONE;370:SLEFT;376:SNONE;420:A"` puts the cursor at (386,20), hot=1, press, and logs `optionsoverlay: opened`. The frame shows the overlay over file select.
|
||
- Idle A/B (`GE_OPTIONSOVERLAY=1`, 45 s, TEMP switch since removed): with the fix, `current_menu` stays 5 for the whole run. Without it, 5 → 23 → 0 (legal) → 1 → 2 (logos).
|
||
- With the overlay closed, the file-select frame differs from the pre-change frame only in the label box (788 px, x 500-571 / y 35-55 at 640x480). There is no file-select golden; the golden gate (Bunker 1) is 3/3.
|
||
- Captures at 854x480 (16:9) and 1120x480 (21:9) show the label and highlight correctly pillarboxed.
|
||
|
||
**Harness note:** in headless menu runs the menu pointer follows the real OS mouse (WI-2 absolute tracking), so the maintainer's desktop mouse moves the in-game cursor mid-script. Set `[Input] MouseEnabled = 0` in the test ini (porting-notes §E).
|
||
|
||
**Owed:** a user playtest: mouse hover/click and pad on the label, F10 and label opening the same overlay, leaving it open for more than 30 s, and 16:9 / 21:9. Then M2 (a GE-styled screen) per the local plan.
|
||
|
||
**User playtest (2026-09-27):** mouse hover and click, F10 and label opening the same overlay, open for more than 30 s, and widescreen all confirmed. Controller not yet confirmed. (Headless scripted stick + A drives the same game-side path; only the SDL pad read, which is existing code, is uncovered.)
|
||
|
||
**Status: LANDED (M1), user-verified except a physical controller.**
|
||
|
||
**M2 (2026-09-27): GE-styled options panel.** The label now opens a panel drawn in the same hook (`port/src/frontoptions.c`, new; the label code moved there from `optionsoverlay.c`):
|
||
- A dark sheet over the folders with gold rules, "PC OPTIONS" in Bank Gothic, and one Zurich tab per section of the F10 overlay's row table (Display / View / Mouse/Aim / Controller / Game), spread to fit the panel. Rows show label + value, slider bars, and "(restart)" for MSAA; a footer has "L / R: section" and a clickable Back. The bottom bar (SELECT FILE / Copy / Erase) and the game's own crosshair stay visible.
|
||
- Rows, values and live-apply come from a small exported row API in `optionsoverlay.c` (`optionsRow*`), so F10 and the panel edit the same settings. The panel keeps its own page and selection state.
|
||
- **Input:** while the panel is open on file select, `input.c` passes the stick through (so the mouse or stick moves front.c's crosshair, the panel's pointer, exactly as in GE's menus) and hands the buttons to `frontOptionsFeedPad()`; front.c gets 0. Edges are published with atomics; all UI state lives on the game thread, in the hook. Controls: the crosshair highlights rows and tabs; A/Z/Start (click, Space) acts on the highlighted row (toggle/cycle/step, or set a slider from the crosshair's x and drag); D-pad or C up/down moves the highlight; D-pad or C left/right (keyboard A/D) adjusts with hold-repeat; L/R switch section; B (right-click, Esc) or Back closes and saves the config.
|
||
- Two bugs found and fixed by headless scripts: (1) A acted on the row under an idle crosshair instead of the D-pad's highlighted row; (2) the press that opened the panel leaked in as a first press and toggled Fullscreen. The panel now ignores buttons until they are released once.
|
||
- The hook calls `microcode_constructor()` after the panel, so the Copy/Erase icons and the cursor drawn after it keep their render state.
|
||
- **Verified headless (self-quitting runs, D344):** panel render at 640x480; tab/section switching, D-pad select + A toggle (Mouse invert Y OFF to ON, nothing else changed), B close; label click opens it; open for 2450 frames with no legal-screen drop; clean exit every run. User playtest 2026-09-27: works on PC (mouse/keyboard, window close, Alt+F4). Owed: physical controller; widescreen captures of the panel.
|
||
|
||
**Route B, final (2026-09-27, user sign-off for both game-code edits): the panel above is RETIRED.** Two reviews flagged the panel's weak points (a sub-state of file select, a button hand-off in input.c, a per-frame g_MenuTimer write). PC options are now a real GE front-end screen, `MENU_PC_OPTIONS`, built like the Cheat Options screen:
|
||
- **Game code (all `#ifdef PORT`):** one enum value before `MENU_MAX` (`src/bondconstants.h`; nothing is sized by `MENU_MAX`); five one-line dispatch cases in `front.c` (debug name, update, init, interface, constructor); `front.c` includes `frontoptions.h`.
|
||
- **Screen (`port/src/frontoptions.c`):** `frontOptionsMenuInit` copies `init_menu15_cheat` (tab state reset, `load_walletbond()`) and sets `selected_folder_num` to folder 1, as a return from mode select does, because `frontSetupMenuBackground` frames `folderpositions[selected_folder_num]`. The interface sets the view like the cheat screen, highlights rows from the crosshair's y (not while A is held), and uses A/Z/Start to open a section, toggle/cycle/step a row, or set and drag a slider from the crosshair's x. D-pad/C left-right (keyboard A/D) adjusts with hold-repeat; B or the PREVIOUS tab goes back (section to list, list to file select, which saves the config). The dossier is shown with tabs + blank CLASSIFIED page, the cheat screen's switches. The constructor draws the black fill, `frontSetupMenuBackground`, black Zurich ink via `frontPrintText`, GE's 0x32 highlight boxes, ON in the cheat red (0xA00000FF), slider bars as two menu boxes, then `frontAddPreviousTabText` and `frontDrawCursor`.
|
||
- Page 0 is a numbered section list ("1. Display" ... "5. Game", title case from the row headers); page 1 lists one section's rows under a title.
|
||
- `input.c`'s M2 button hand-off is removed. F10 and Select don't open the overlay while `MENU_PC_OPTIONS` is up. The file-select hook keeps only the label and the F10 idle-timer fix.
|
||
- **Bottom bar (`constructor_menu05_fileselect`, `#ifdef PORT`):** Copy text/icon 247/225 moved to 222/200 and Erase 357/335 to 304/282, making room for "PC Options" at the right end of the same bar (y centred on 285). The click boxes follow the draw positions. The label is drawn by the port hook at `max(352, Erase bound.right + 14)`, so a wider localised Erase (JP) pushes it right instead of overlapping. The old top-right label is gone.
|
||
- **Verified headless (self-quitting runs):** `GE_STARTMENU=26` boots the screen; a script opened section 2 (View), went back to the list, then back to file select, confirmed by log lines. A scripted crosshair click on the bottom label opened the screen once, and the click did not carry into it. Captures at 640x480, 854x480 and 1120x480 are pillarboxed correctly. The Erase click box (to ~338) clears the label's (from 348). Golden gate 3/3.
|
||
- **Not verifiable here:** PAL/JP captures, because only the NTSC ROM is present. PAL shares the canvas, strings and constants; JP relies on the dynamic label x.
|
||
- **Owed:** user playtest (mouse, keyboard, pad; music/ambience on entering and leaving; "PC Options" sits close to the right edge, ending about x 428 of 440); a physical controller.
|
||
|
||
**Closed 2026-09-27.** User visual pass done: label placement accepted, mini slider glyph added beside it (D348), screen navigation fixed and verified (D345), wording aligned to Turok/Nightdive (D346/D346b); music on enter/leave unremarked. NTSC reference frames captured + pixel-verified headless (PCDUMP, incl. the D348 glyph: 135 knob px vs 9 paper). PAL/JP visual verification not capturable — only an NTSC ROM is present locally; layout is font-width-driven and region-independent except label width, so risk is low. Subsequent user physical-controller pass on the bottom-bar label and options-screen navigation: all good.
|
||
|
||
|
||
|
||
## D344 — Host PC bugchecks (0x119) during game runs: unclean multi-threaded quit (2026-09-27)
|
||
|
||
**Report:** the maintainer's PC blue-screened twice while headless test runs were going (10:17 and 10:22), and once before (2026-09-14 14:38, during M-123 captures). All three are bugcheck **0x119 VIDEO_SCHEDULER_INTERNAL_ERROR, parameter 1 = 0x3000**, raised by the Windows GPU scheduler (dxgkrnl) on an invalid driver state. No minidump was written (volmgr 161, "Dump file creation failed"). Host: RTX 5090 (driver 2026-09-03) + AMD iGPU + Parsec virtual display + Microsoft Remote Display adapter; hardware-accelerated GPU scheduling on (`HwSchMode = 2`). The first crash hit while `data/ge007.ini` was being restored, leaving it as 1465 zero bytes (restored from backup).
|
||
|
||
**Evidence (event logs only; runs were stopped):**
|
||
- Both of today's crashes fell within seconds of the harness ending a run with `timeout`: the ini restore that runs right after the kill never reached disk, and the second reboot came about 30 s after its kill was due.
|
||
- A killed run is not an abrupt kill: SDL turns the signal into `SDL_QUIT`, the logs end with `config: wrote data\ge007.ini`, and the process then ends in `abort()` (WER holds thousands of ge007 `0x40000015` reports at shifting in-binary offsets, plus older `0xC0000374` heap-corruption reports; Windows has applied the Fault Tolerant Heap to ge007). The teardown is not clean even when nothing crashes.
|
||
|
||
**Probable cause (medium confidence, no dump):** the GL context is created on the host thread but bound to the scheduler thread, which does all rendering and `SDL_GL_SwapWindow` (`gfx_sdl2.cpp`). Quit was `exit(0)` straight from whichever SDL pump dequeued the event: the host thread (`video.c`, three sites) or fast3d's render-thread pump (`gfx_sdl2.cpp`, three sites), plus the overlay's "Quit to desktop". From the host thread, process teardown could run while the render thread was mid-frame inside the NVIDIA driver. A user-mode program shouldn't be able to bugcheck the machine, so the driver/HAGS stack is at fault too, but the port was feeding it a hazard on every exit.
|
||
|
||
**Fix (port-only, `port/src/video.c`, `port/fast3d/gfx_sdl2.cpp`, `port/src/optionsoverlay.c`):**
|
||
- Every quit site calls `videoRequestQuit(why)` instead of `exit(0)`.
|
||
- The render thread brackets each frame with an `s_inFrame` atomic. At a frame boundary after a quit request (`videoStartFrame` before binding, or `videoEndFrame` after the swap) it runs `gfx_sdl_park_for_exit()` (`glFinish` + unbind the context) and parks for good.
|
||
- The host thread (`videoHostExitIfRequested`, each pump) waits until the render thread is parked or outside a frame (at most 2 s), logs `video: exiting (render parked|idle|STILL IN FRAME (timeout) after N ms)`, then `exit(0)`, so atexit still saves the config.
|
||
- Ordering: the render thread sets `s_inFrame` before reading `s_quitReq`; the host sets `s_quitReq` before reading `s_inFrame` (SDL atomics are sequentially consistent), so no frame can start after the host decides to exit.
|
||
- **Harness:** `GE_QUITFRAME=<n>` requests the same orderly quit after frame n. `tools_pc/golden_gate.sh` now quits 30 frames after its last capture; `timeout` stays as a backstop only. `verify.sh`'s sweep still uses `timeout` (follow-up).
|
||
|
||
**Not changed (noted):** fast3d's render-thread SDL pump still drains the shared event queue alongside the host pump. On Windows only the window's creating thread should pump; worth revisiting. The audio device is still open at exit.
|
||
|
||
**Verification owed (runs paused at the user's request):** with the user's OK (ideally HAGS off first), run `tools_pc/golden_gate.sh` and a few `GE_QUITFRAME` runs. Check each log ends with `video: exiting (render parked ...)`, check no new ge007 `0x40000015` WER reports appear, and check a window-close / Alt+F4 by hand. A crash dump (fix the host's dump creation) would confirm or rule out the cause.
|
||
|
||
**Verified (2026-09-27):** golden gate 3/3 with self-quit; five self-quitting runs all logged `render parked after 0-17 ms`; the user closed the game by window close and Alt+F4 with no crash. Not proven as the bugcheck's root cause (no dump): watch for recurrence.
|
||
|
||
**Status: FIXED (probable cause), user-verified.**
|
||
|
||
|
||
## D345 — File select → mode select: crosshair snaps to the Agent row and stays there until the mouse moves (2026-09-27)
|
||
|
||
**Report:** with mouse input, entering a folder page (file select → mode select) resets the crosshair away from the pointer; it stays at the snapped position until the next mouse movement.
|
||
|
||
**Evidence:** `front.c:2516` — the file-select→mode-select entry path calls `setCursorPOSforMode(0)` unconditionally, teleporting `cursor_h/v_pos` to (126, 226), the first row. Its two sibling re-entry paths (`front.c:4502`, `:4646`) already use `setCursorPOSforMode(gamemode)`. The port's 1:1 menu pointer (`port/src/input.c`, D165/D169) writes `cursor_h/v_pos` from the absolute mouse position **only on frames where the OS mouse moved** (idle-mouse cede so WASD/arrow navigation can drive the cursor through the game's integrator). A click-to-enter leaves the mouse idle, so the game's snap persists until the next movement. `menuPrevActive` (`input.c:1359`) tracks menu-vs-not only, so the "just entered a menu" baseline-adopt branch does not fire on menu-to-menu transitions. Poll order is input → interface (D343), so the same-frame snap wins over any same-frame re-assert.
|
||
|
||
**Fix (port-only, `port/src/input.c`):** carry pointer ownership across front-end screen changes.
|
||
- New statics: `menuPointerLastMenuId`, `menuPointerTransitionFrames`, `menuPointerLastAbsWrite` (perf-counter stamp of the last absolute write).
|
||
- Every pad-0 poll: if `current_menu` changed, open a 3-poll re-assert window (the transition poll's write loses to the game's same-frame snap; poll+1 sticks).
|
||
- In the 1:1 pointer block, force the absolute write when `(mouse moved) OR (window open AND mouse used within MENU_POINTER_REASSERT_MS = 1500 ms)`. Click-to-enter → cursor lands back under the pointer on the new page. Stale idle mouse (WASD navigation) → no yank; the cede-to-keyboard behaviour is preserved.
|
||
- The F10 overlay does not change `current_menu`, so opening/closing it opens no window (correct: same screen, pointer already under the mouse). Entering `MENU_PC_OPTIONS` does change it and re-asserts — desirable, flagged for playtest.
|
||
|
||
**D345(b) — the new PC options screen (user report: "doesn't work on our new PC settings menu").** `MENU_PC_OPTIONS` has its own cursor teleporter: `cursorToItem()` (`port/src/frontoptions.c:178`) snaps the crosshair to a page's first item, called from screen-entry init, section entry, and back. The section/back hops happen *inside* frontoptions.c (no `current_menu` change), so the D345(a) window never sees them; every page hop re-snapped the crosshair away from the pointer. Fix (port-only): input.c now exports `inputMenuPointerLive()` (in a menu + abs pointer available + mouse used within `MENU_POINTER_REASSERT_MS`; refreshed every pad-0 poll, forced 0 while the F10 overlay owns the mouse), and `cursorToItem()` skips its snap when it is set — the cursor is already under the OS pointer. Keyboard/D-pad users (stale mouse) keep the snap. Also added `#include <SDL.h>` to `port/include/input.h` (its API uses SDL types; previously only includers that had SDL already compiled — cf. video.h).
|
||
|
||
**D345(c) — options screen: W/S did not move through the lists (user playtest).** Cheat-screen-style menus integrate stick input into `cursor_h/v_pos` by calling `frontUpdateControlStickPosition()` from their interface (`interface_menu15_cheat`, front.c:7700); `frontOptionsMenuInterface()` never did, so keyboard/D-pad vertical input had nothing to drive the cursor (A/D worked because frontoptions.c reads those as buttons directly). Fix (port-only): `frontOptionsMenuInterface()` now calls `frontUpdateControlStickPosition()` after `buildPages()`, before the highlight reads the cursor — mirroring the cheat screen. No-op while the 1:1 pointer owns the cursor (it emits a zero stick).
|
||
|
||
**D345(d) — W/S still dead after (c): NaturalPitch mode starves the stick (user playtest via playtest_v040.bat, confirmed running the fixed binary by exe/log timestamps).** With `NaturalPitch = 1` (default; in the user's ini), the keyboard forward/back mapping emits **C-up/C-down buttons** instead of stick (`input.c:1037-1042`, the D194/D238 in-stage aim scheme) -- and no front-end screen navigates on C-buttons, so W/S (and Up/Down arrows) produced zero stick in every menu. Fix (port-only): the natural-pitch branch now requires `!menuMode`, so menus always get the stick mapping (N64 front end is stick-driven). Watch/pause is unaffected: it runs at `current_menu == RUN_STAGE` (menuMode 0) and keeps the C-button mapping, N64-style. This also restores W/S vertical navigation on file select / cheat screen for NaturalPitch users (mouse users never noticed).
|
||
|
||
**D345(e) — user preference: F10-style discrete keyboard navigation on the options screen ("behave like the F10 menu but keep the mouse option").** Replaced the D345(c) raw stick drift with one-row-at-a-time stepping in `frontOptionsMenuInterface()`: W/S / Up-Down arrows (stick, via D345(d)) or pad stick/D-pad (`U_JPAD`/`D_JPAD`) move the highlight ±1 row, clamped at the ends, hold-to-repeat on the screen's existing 18/4-frame cadence (separate `s_vrepeatDir/Timer` state so it can run alongside left/right value adjust). Each step snaps the crosshair via new ungated `cursorToItemRaw()` (explicit keyboard nav owns the cursor even right after mouse use); `cursorToItem()` keeps the D345(b) pointer-live gate for page transitions. Mouse is untouched: any movement re-owns the cursor and the highlight hit-test follows it next frame. Stepper is skipped while a slider drag holds A. The D345(c) `frontUpdateControlStickPosition()` call is removed (it fought the pointer over `cursor_v_pos`).
|
||
|
||
**D345(f) — D345(e) shipped broken (user: "W/S still just move the crosshair").** The D345(e) edit added the stepper but its two *removals* never landed: the D345(c) `frontUpdateControlStickPosition()` call (after `buildPages()`) and its extern stayed in `frontoptions.c`, so W/S ran raw cursor drift on top of the row stepper. The build didn't catch it: with the extern gone from my intended edit but present in the file, compilation only produced an implicit-declaration warning, which this project compiles through. Fix: removed the call + extern (verified by grep, not just the edit tool's success message), and added stick-X to the left/right value block so Left/Right arrows adjust values like F10 (A/D letters already worked via C-buttons). Lesson recorded: after any edit that both adds and removes, grep-verify the removals against the file.
|
||
|
||
**Not changed:** the game-side snap itself (`front.c:2516`) is untouched — no Rule-2 sign-off needed for this fix. The optional one-liner `setCursorPOSforMode(gamemode >= 0 ? gamemode : 0)` (matching its siblings) was considered for pad users, who have no re-assert at all. Following a successful physical-controller pass, the maintainer chose to defer it; do not change game code without fresh Rule-2 sign-off.
|
||
|
||
**User-verified (2026-09-27):** folder-page snap gone (mouse); options screen entry/section hops keep the pointer under the mouse; sliders/toggles fine; F10-style W/S + arrow navigation with hold-to-repeat works; left/right arrows adjust values; crosshair parks in the gutter off the row text. Resolved for time being.
|
||
|
||
**Verification:** builds clean (`build-pc.sh ntsc-final`; only pre-existing warnings). Headless smoke: `GE_STARTMENU=5 GE_INPUTLOG=1 GE_QUITFRAME=400` — 412 `menuptr` lines, cursor stable, orderly self-quit (`render parked after 3 ms`). The playtest items listed here were subsequently covered by the user verification above.
|
||
|
||
## D346 — PC-settings wording pass: Nightdive/Turok + PD-port conventions (2026-09-27)
|
||
|
||
User request: make all PC setting labels/values consistent with the PD port and
|
||
Nightdive remasters (Turok) where possible. Both settings UIs (the F10 overlay
|
||
and `MENU_PC_OPTIONS`) share one table — `rows[]` in `port/src/optionsoverlay.c`
|
||
— so a single pass covers both. Display-only: config keys, stored values, and
|
||
adjustment behavior are untouched (existing inis read/write identically).
|
||
|
||
**Label changes** (`rows[]`, optionsoverlay.c):
|
||
|
||
| Before | After | Rationale |
|
||
|---|---|---|
|
||
| `DISPLAY` (section) | `VIDEO` | Nightdive section naming ("Video"); pairs with the existing `VIEW` section |
|
||
| `Frame cap` | `Frame rate cap` | Turok 3's exact wording; values already Nightdive-style (`Uncapped` / `120 FPS`) |
|
||
| `MSAA` | `Anti-aliasing` | Nightdive standard; jargon-free |
|
||
| `Anisotropic` | `Anisotropic filtering` | Nightdive standard |
|
||
| `FOV scale %` / `Draw distance %` / `LOD distance %` / `HUD scale %` / `Trigger threshold %` | `%` dropped from labels, moved to the value (`100%`, `150%`, …) | Nightdive shows units in the value column, not the label |
|
||
| `Crop overscan bars` | `Crop overscan` | trimmed; "bars" was internal phrasing |
|
||
| `Draw dist. auto` / `LOD dist. auto` | `Draw dist. follows FOV` / `LOD dist. follows FOV` | the old labels didn't say what follows what (they couple the distance to `Video.FovScale`, video.c:203-231) |
|
||
| `Mouse invert Y` / `Pad invert look Y` | `Invert look (mouse)` / `Invert look (controller)` | Nightdive per-device convention; now a consistent pair |
|
||
|
||
**Value changes:** `OFF/ON` → `Off/On` (kOnOff); `NEAREST/BILINEAR/3-POINT` →
|
||
`Nearest/Bilinear/3-Point`; `CENTRED (PC)` → `Centred (PC)`; MSAA `OFF` →
|
||
`None` (Nightdive AA vocabulary). The options screen's ON-colour match in
|
||
`frontoptions.c:454` updated `"ON"` → `"On"` to follow.
|
||
|
||
**Mechanism:** two display-only fields appended to `struct Row` (`unit`,
|
||
`dispDiv`; C zero-fill keeps every existing positional initializer valid) and a
|
||
small branch at the integer-slider tail of `valueText()`: optional raw→display
|
||
divide, then unit suffix. Used by: FovScale/DrawDistance/LodDistance/HudScale/
|
||
PadTriggerPct (`%`), Anisotropy (`x`), PadDeadzone (`%`, dispDiv 300 — raw
|
||
0..30000 stick units → % of full travel; default 7000 now reads "23%" instead
|
||
of "7000").
|
||
|
||
**Kept deliberately:** `VSync`, `Fullscreen`, `Resolution`, `Texture filter`
|
||
(universal); `Aim style`/`Aim range` + their `N64`/`PC` values (GE-specific,
|
||
already clear); `No hit flash` (community-mod name; inverting the polarity to
|
||
"Hit flash: Off" would change what On means — not a wording fix); `Skip intro`,
|
||
`Show FPS`, `All unlocked`, `Quit to desktop` (plain English, no reference
|
||
counterpart worth matching).
|
||
|
||
**Known cosmetic follow-up (not done):** the deadzone slider's raw step is 500
|
||
stick units, so its % display advances ~1.7% per press (23→24→25→27…). A clean
|
||
1%-per-press grid would mean changing `step` to 300 — a behavior change to the
|
||
adjustment feel, out of scope for a wording pass; flagged for a future input-QoL
|
||
item.
|
||
|
||
**D346b — VIEW section merged into VIDEO (user request, same day).** The old
|
||
`VIEW` header row was deleted from `rows[]`; its rows (FOV scale, Native
|
||
widescreen, Widescreen auto FOV, Crop overscan, draw/LOD distance pairs) now
|
||
follow the display/quality rows in one Nightdive-style "Video" section. The
|
||
options screen's page model is one-header-one-page with a row cap, so the cap
|
||
moved 12→15 (`MAX_PROWS`) and the row pitch 20→18 px (`ROW_DY`, frontoptions.c)
|
||
to keep the worst case (15 rows: "Draw dist. follows FOV" off reveals both its
|
||
toggle and the slider) on one page — last row text at y=317, inside the 330
|
||
paper; its 22px highlight box overflows by 1px in that state only (clipped,
|
||
invisible). Hit bands stay contiguous at the tighter pitch (band = [y-3, y+15),
|
||
next starts y+15). F10 overlay unaffected (own pitch, scrolls). Section list is
|
||
now 4 entries: Video / Mouse / Aim / Controller / Game.
|
||
|
||
**Verification:** `build-pc.sh ntsc-final` clean (no new warnings); headless
|
||
boot into the options screen (`GE_STARTMENU=26 GE_QUITFRAME=300`) orderly
|
||
self-quit, zero crash lines. All strings plain ASCII (JP-region safe, #87).
|
||
Port-only: no `src/game` edits, N64 build untouched.
|
||
|
||
## D347 — F10 overlay controller value-adjust + hold-to-repeat (2026-09-27)
|
||
|
||
User report (controller, Steam Deck class): in the F10 options overlay,
|
||
left/right did nothing — only A (increment) / Y (decrement) adjusted values,
|
||
"feels bad". Root cause: `optionsOverlayHandleInput()` wired only DPAD_UP/DOWN
|
||
+ stick-Y to navigation and A/X/B/Y to adjust; **DPAD_LEFT/RIGHT and stick-X
|
||
were never read**, and every action was edge-triggered — no hold-to-repeat
|
||
anywhere (holding a key or the stick stepped exactly once).
|
||
|
||
Fix (port-only, `optionsoverlay.c`):
|
||
- D-pad left/right + left-stick X (±12000, matching the existing Y-axis style)
|
||
OR-ed into the lf/rt adjust inputs. A/X/B/Y keep working as before.
|
||
- Hold-to-repeat on value adjust: first press fires immediately, then an
|
||
18-frame delay and a 4-frame repeat — the same cadence as the options
|
||
screen's D345(e)/(f) blocks, so both settings UIs now feel identical.
|
||
- Same repeat added to up/down navigation (previously one row per press even
|
||
on keyboard — traversing the list held-down was impossible). `overlayStepSel`
|
||
clamps at the ends, so holding at an end is a harmless no-op.
|
||
- Repeat state (`navDir/navTimer/adjDir/adjTimer`) resets when the overlay
|
||
closes, alongside the existing prev*/dragRow reset.
|
||
|
||
Toggles flip on either adjust direction (existing `rowAdjust` behaviour);
|
||
sliders step by their registered step; MSAA/enum wrap as before.
|
||
|
||
**Verification:** `build-pc.sh ntsc-final` clean; headless boot smoke clean.
|
||
Subsequent user physical-controller pass validated the F10 overlay, options
|
||
screen and bottom-bar label; all good. No new automated controller test was run.
|
||
|
||
## D348 — File-select "PC Options" label gets a mini slider glyph (2026-09-27)
|
||
|
||
User request: an icon next to the "PC Options" bottom-bar label, like Copy/Erase
|
||
have theirs. There is no settings-style sprite anywhere in the game art — the
|
||
`mainfolderimages` bank (`assets/oddtextures.h:57-64`) holds exactly six:
|
||
COPY, DEL, SEL (select arrow), X, CHECK, DOT. Per user choice, a glyph is drawn
|
||
instead of borrowing an off-meaning sprite.
|
||
|
||
Implementation (port-only, `optionsFileSelectLabel()` in frontoptions.c):
|
||
three 2px menu-box tracks with offset 3×4 knobs (a miniature slider control),
|
||
emitted with the same `microcode_constructor_related_to_menus` primitive the
|
||
options screen uses for its slider bars. Tracks are `INK_DIM`; knobs match the
|
||
label ink (white, gold while hot). The glyph is 10px wide (`GLYPH_W`) and sits
|
||
INSIDE the existing 14px `LABEL_GAP` after Erase (2px margins each side), so it
|
||
tracks the dynamic label x — JP's wider Erase pushes glyph + label together —
|
||
and the text position / right-edge clearance are unchanged. The hot region
|
||
extends left to include the glyph. No new strings (issue #87).
|
||
|
||
**Verification:** `build-pc.sh ntsc-final` clean; headless file-select capture
|
||
(`GE_STARTMENU=5 GE_PCDUMP=200-201`, 1088×816 = 440×330 canvas × 2.473): the
|
||
glyph box (logical x 339–351, y 278–292) contains 135 pure-white pixels — the
|
||
three knobs — vs 9 on an equivalent empty-paper strip. Visual feel-check owed
|
||
to the user (proportions/knob offsets are one-constant tweaks if it looks off).
|
||
|
||
## D349 — Watch options are session-only in normal GE play (static audit)
|
||
|
||
**Ground truth:** `fileSaveSettingsForFolder` (`src/game/file2.c:1246`) serializes live Music/FX and the eight watch options into a supplied `save_data`; its only call site is `fileClearSavefileForFolder` (`:1379`), reached by `deleteCurrentSelectedFolder` (`src/game/file.c:84`, the Erase path). The normal watch controls (`options.c`) update live state, not the save. At stage start `init_watch_at_start_of_stage` (`options.c:415`) calls `fileLoadSaveSettingsForSelectedFolder`, which reloads the file values (`file2.c:1308-1350`) and discards unsaved watch adjustments. A repository search found no other assignments to `save->music_vol`, `save->sfx_vol` or `save->options` from live watch state. This is a decomp/N64 quirk, not a PC regression; recheck any indirect write path before implementing persistence.
|
||
|
||
**Consequences for PC options:** editable watch-backed rows on file select would revert on stage entry without an extra save path. The maintainer approved a deliberate PC-only QoL divergence: **edits in the PC settings/F10 UIs persist to the explicitly chosen/active Bond file; edits made directly in GE's watch stay session-only (faithful to N64)**. Preserve the N64 game/save format and verify per-file isolation. The agreed design is `docs/dev/WATCH-SETTINGS-PLAN.md`.
|
||
|
||
**Save hazards to cover in implementation:** `fileSaveSettingsForFolder` serializes *all* live options, including `OPTION_CONTROLTYPE`. Since the port forces Honey/Solitaire in `input.c`, keep the original stored control-type bits. Serialize into a temporary copy, merge **only the PC-UI-edited field/bit(s)** into the actual slot pointer returned by `fileGetSaveForFoldernum` (folder number is not a `saves[]` slot index), then call `fileWriteSave`. This also keeps unrelated watch-only edits session-only when a PC-UI value is later changed. Toggle/enum edits commit on change; slider drags commit on release. GE's folder loader would change front-end Music/FX volume live; D350's chooser reads the saved fields directly instead, avoiding this side effect. Erase (`fileClearSavefileForFolder`) serializes *current live* settings into the wiped save: editing B, then erasing A may put B's live watch settings into wiped A. The same edge exists on N64 after changing watch settings; acknowledge it in tests, do not change game logic. D350's front-end chooser avoids loading B into live state, so editing B there does not itself trigger this edge.
|
||
|
||
**Defaults:** `BLANKSAVEDATA` (`src/bondconstants.h:2151`) starts `music_vol`/`sfx_vol` at 0xFF and `options` at `DEFAULT_OPTIONS` (`src/game/file2.h:23`: auto-aim, sight, look-ahead, ammo on). No code change or runtime verification in this finding.
|
||
|
||
## D350 — Watch-backed PC settings: shared rows, explicit chooser and per-file save (2026-09-27)
|
||
|
||
**Port-only, no `src/game` changes.** `port/src/optionsoverlay.c` adds an Audio (Bond File) section (Music/FX sliders) and a Bond File page (explicit file chooser + watch look direction, auto-aim, aim control, sight, look-ahead, ammo). These rows are shared by F10 and `MENU_PC_OPTIONS`; control style, GE Screen/Ratio and unrelated M3 items remain deferred. `watchSettingsFieldForKey` routes them to `port/src/watchsettings.c` rather than `config.c`/ge007.ini. Empty/unvalidated files cannot be edited; the chooser is independent of `selected_folder_num`, skips absent saves, and reads save fields directly rather than loading a folder and changing front-end audio. In a running level, F10 targets the active file and reads a game-thread-published snapshot so direct watch changes appear next frame. The watch's own edits remain session-only.
|
||
|
||
**Thread handoff:** F10's scheduler-thread input queues bounded `(folder, field, value, commit)` requests under an SDL spinlock (not a GE state mutation). `port/src/libultra.c` drains these on successful `gfxFrameMsgQ` receives, *after* unlocking the OS queue mutex, on `boss.c`'s game thread; the only consumer of that queue is the boss main loop. The game thread checks stage and target file identity before using GE's setters or EEPROM, updates the snapshot, and discards stale cross-file requests. It also supports direct `-level_XX` boots, where `current_menu` stays `MENU_INVALID` despite an active level. F10's file chooser is hidden; the front screen has a separate explicit chooser.
|
||
|
||
**Save safety:** `fileGetSaveForFoldernum` alone may return a zero-filled phantom slot if `GE_STARTMENU` skipped `fileValidateSaves` (D299). `validSave` verifies GE's CRC before showing/editing a file. For F10, `fileSaveSettingsForFolder` serializes into a temporary copy and only the PC-edited Music/FX byte or watch bit is merged into the real physical save slot, then `fileWriteSave` updates CRC/EEPROM. In the front screen (no active player), the same single field is edited directly on the explicitly chosen save without invoking the folder loader; front-end audio levels do not preview the selected file's new Music/FX value until the stage loads it. The stored controller-type bits and unrelated watch-only changes stay untouched. Toggles and discrete value steps persist on change; mouse-drag sliders update their displayed value (and apply GE audio live in-stage through F10) but persist once on release (or F10 close/back). F10 preview and commit use the game-thread queue; no save per drag detent.
|
||
|
||
**Verification (native Windows, backed-up ini and EEPROM restored after each probe; temporary test hooks removed):**
|
||
- Build/link clean; `bash tools_pc/golden_gate.sh` 3/3, orderly self-quit. `GE_STARTMENU=26 GE_QUITFRAME=320` shows the new screen and quits cleanly; `-level_09 GE_OPTIONSOVERLAY=1 GE_QUITFRAME=500` opens F10 cleanly.
|
||
- A `GE_STARTMENU=26` scripted chooser attempted to edit file 1 without prior save validation: CRC guard refused, **EEPROM byte-identical**. This prevents D299-class writes to phantom slot 0.
|
||
- With test-only save validation injected at options init, `GE_INPUTSCRIPT` navigated to Bond File, selected file 1, changed Watch look: EEPROM slot 0 options `0x013a→0x013b`, only option byte + CRC changed. Separately selected file 1, entered Audio, decremented Music once: `music_vol 255→254`, FX unchanged, only music byte + CRC changed. Neither temporary hook ships.
|
||
- A second front-end script chose Bond file 1 then switched to Bond file 2 and changed Look. Only physical EEPROM **slot 4** changed (the file-2 save lives there; file number is not slot index), while slot 0/file 1's options stayed `0x013a` untouched. This confirms basic two-file targeting; copy/erase and rapid transitions remain manual checks.
|
||
- Game-thread `-level_09` temporary probe toggled Auto-aim and persisted: options `0x013a→0x0138`, original `0x0100` control-style bits unchanged, only option byte + eight CRC bytes changed. A second process loaded Auto-aim=0 from the same test EEPROM. A temporary scheduler-thread/F10 queue probe produced the same targeted EEPROM change using the game-thread handoff. All test EEPROM copies were restored; neither probe ships.
|
||
|
||
**User feedback:** maintainer reports "it tests good" and requested a commit before a new-session review. No per-case playtest notes were supplied, so do not infer that every edge case below was exercised.
|
||
|
||
**For the review:** confirm mouse/pad chooser and both settings surfaces, watch↔F10 live changes, slider drag/release and rapid close/stage transitions; front-end audio slider preview feel (edits persist but do not apply to current menu audio); two-file switch/copy/erase. PAL/JP build/visual checks remain uncapturable locally (only NTSC ROM).
|
||
|
||
## D351 — D346 unit-row initializers shifted into `hidePtr` (2026-09-27)
|
||
|
||
The existing positional `struct Row` initializers for FOV, anisotropy, draw/LOD distance, HUD scale, deadzone and trigger threshold omitted the `hidePtr` placeholder. GCC warned about assigning a `char *` unit ("%"/"x") to `int *hidePtr` and assigning deadzone's numeric display divisor to `unit`. The visible-list builder dereferences non-null hidePtr, so these rows were hidden or misformatted despite D346's intended unit labels. Corrected *only* these rows to designated initializers, preserving their key, label, clamp, step and intended `hiddenIfOn`/unit/divisor. New watch rows also use designated initializers to prevent repeats. Build has no new optionsoverlay warnings; headless menu smoke/golden gate clean. Maintainer reports the implementation tests good, but did not separately report a unit-label/auto-hide visual check; carry that into the new-session review.
|
||
|
||
## D352 — D350 watch-settings bridge review: transition drops, chooser sync, queue coalescing, silent failures (2026-09-27)
|
||
|
||
Design review of `1303c0eb` (D350) of the game-thread bridge in `port/src/watchsettings.c`. Four robustness fixes, all port-side, behavior-neutral:
|
||
|
||
1. **M1 — hold-not-drop at stage transitions.** `watchSettingsGameTick` drained the queue *before* checking `stageActive()`, so any setting change committed while a stage transition was in progress was silently discarded. Now the stage-active check runs first: while unavailable the queue is held (with coalescing, so only the latest value per field is ever pending) and applies as soon as the stage reactivates. The drain still happens before the `gfxFrameMsgQ` spin, so the bridge can never deadlock on its own lock ordering.
|
||
2. **M2 — chooser state synchronized.** `chosen`/`stagedVal`/`stagedValid` are mutated by `watchSettingsChooseFile` (UI thread, both surfaces) and read by the bridge (game thread). A single `SDL_SpinLock uiLock` now guards the whole read/modify/write on both sides, and the chooser is a no-op unless the UI is at the front end (`frontScreen()`), where the chooser row is the only caller. The lock is short-critical-section-only (no I/O held), taken in one order (never with `qLock` held).
|
||
3. **L2 — per-field coalescing.** A fast slider drag queued one `WsCmd` (and one `wsPersist`, i.e. one `fileWriteSave`) per detent per frame. The queue now keeps at most one pending command per field: a new commit for an already-pending field replaces its value, ORs its `commits` flag, and reuses the slot. `fileWriteSave` still happens at most once per tick (the drain loop is unchanged), but the per-detent spam is gone and a stage transition can never build up an unbounded queue.
|
||
4. **L3/L6 — visible failures, smaller API.** Every silent no-op path now logs a one-line `LOG_WARNING` (`frontoptions`/`watchSettings`-tagged): stage transition holding the queue (rate-limited by the coalescing), front-path commit with nothing staged, F10 commit with no valid save, no loaded player record, save absent from the EEPROM range. `watchSettingsPersistField` was only ever called inside `watchsettings.c` — made `static`, removed from the public header.
|
||
|
||
Verification: NTSC build clean (no new warnings in `watchsettings.c`), headless menu smoke boots to the front end and renders (frames advancing, EEPROM load). Edge cases (setting F10 during a level transition; rapid slider drag; committing with an invalid file) are log-visible but need a manual pass to confirm by eye.
|
||
|
||
## D353 — Turok-style settings regrouping: DISPLAY/GRAPHICS split, AUDIO(BOND FILE) header dropped, designated rows, overflow guard (2026-09-27)
|
||
|
||
The D346 "single Video section" compromise (14–15 rows, exactly at `MAX_PROWS=15`) is re-grouped along the Turok/Nightdive menu axes (per `docs/dev/notes/OPTIONS-MENU-PLAN.md` §6), all port-side, no config-key renames:
|
||
|
||
- **VIDEO → DISPLAY + GRAPHICS.** DISPLAY: fullscreen, resolution, vsync, frame-rate cap (the window-management block Turok's "Video" panel covers). GRAPHICS: MSAA, texture filter, anisotropy, FOV scale, native widescreen, auto-FOV pair, overscan crop, the two draw/LOD-distance sliders and their auto-FOV hide-ifs. 4 + 11 rows, both well under the page limit.
|
||
- **"AUDIO (BOND FILE)" hack header dropped.** The music/FX sliders now sit under the BOND FILE section, next to the other persisted-per-file watch rows — the section is exactly the per-file save, so the parenthetical header is redundant. No standalone master-volume row yet (that is the M3 addition, which will land in a future AUDIO section alongside the config-side volume keys).
|
||
- **Scope annotation.** The Bond-file page title carries the chooser's value ("Game\n(File 2)" / "(no file)" in dim ink), so a player navigating from the front end sees which save the section writes without scrolling to the "Edit file" row. `frontoptions.c` level-1 draw only; F10 shows the chooser row inline (its value already there) so no annotation is needed.
|
||
- **Full designated-initializer conversion.** The remaining positional `struct Row` entries (all of them) are now designated, per the D351 policy: new fields (like `found`) can never silently shift a value into the wrong member again. Keys, labels, clamps, steps, names, hide-ifs, units are all carried over unchanged — this is a display-only regroup, no value or config-key change.
|
||
- **Overflow guard + accessor.** `buildPages` logs a `LOG_WARNING` naming the section and the last visible row if a section ever outgrows `MAX_PROWS` (the D346 VIDEO section truncated silently at exactly 15 rows before this); `optionsRowIsBondChooser()` is exported so the front-end title annotation and future F10 code can find the chooser row by role instead of index.
|
||
|
||
Section order: DISPLAY, GRAPHICS, BOND FILE, MOUSE / AIM, CONTROLLER, GAME (6 sections, under `MAX_PAGES=8`). Per-section "reset to defaults" (Gate E) and the §6-missing master-volume row remain outstanding. Verification: NTSC build clean, headless menu smoke boots to the front end; the regrouped page flow (arrows through 6 pages, annotation visible, no overflow log) needs a manual visual pass.
|
||
|
||
## D354 — Front-end F10 Bond rows disabled; default Bond-file selection (2026-09-27)
|
||
|
||
Maintainer report: the F10 volume settings "don't apply", while the watch and the main-menu options work. Diagnosis (port-only):
|
||
|
||
- **The in-stage F10 path is sound.** A new env-gated probe (`GE_WSPROBE=<tick>`, same pattern as `GE_D314`) queues an F10-style commit of Music+FX on the Nth active stage tick and logs live + saved before/after. Headless `-level_33` run: live music 15163→4096, save.music 118→32, `watchsettings: persisted field 0/1 in Bond file 1` — the queue→apply→persist bridge works end-to-end in a stage.
|
||
- **The real gap was the front end.** `watchSettingsAvailable()` special-cased only `MENU_PC_OPTIONS` (front path) and stages (snapshot path); every other context — notably **F10 over the file-select screen**, the primary F10 surface — fell through to the stage path and returned "unavailable", disabling every Bond row. With the chooser row hidden in F10 and `chosen` starting at -1 ("none"), the rows read "Select file" and could never be enabled: the user's "volume settings in F10 don't apply".
|
||
- **Fix (maintainer-suggested UX):**
|
||
- All **non-stage** contexts now route through the front path (chooser + `validSave`), so F10-on-file-select behaves like the options screen.
|
||
- New `frontFolder()`: lazy default target, resolving `chosen` on first use to **explicit pick > `selected_folder_num` (the folder the player is viewing) > first valid folder**, under the existing `uiLock`. A fresh PC eeprom is booted with a BLANKSAVEDATA slot per folder (libultra.c D259/D281 patch), so the default almost always lands on file 1 — the Bond section is usable transparently, no "Edit file" ceremony. The D353 title annotation now shows "(File 1)" by default instead of "(no file)".
|
||
- **Auto-init fallback:** one-shot, game-thread, in `watchSettingsGameTick`: if *no* folder holds a valid save (wiped/corrupt eeprom), call the game's own `fileBuildWriteNewSave(FOLDER1)` (the routine boot validation uses, `file2.c:509`) and default to it; logs the outcome. Never modifies game code.
|
||
- `watchSettingsChooseFile` guard flipped to "refuse in stage context" (front-end control only); "none" remains reachable by explicit cycling.
|
||
- `GE_WSPROBE_FRONT=<tick>` probe verifies the same front commit headlessly: default boot (menu) resolves folder 0 (file 1) with no interaction, commit 4096 → save.music 255→32.
|
||
|
||
Verification: NTSC build clean; both probes pass headless; in-stage behavior unchanged (probe-identical). The D350 threading gate is unchanged in effect: the front path's `fileWriteSave` (EEPROM) now also runs from the F10 input thread in front contexts, but only one settings surface is ever active at a time and the game thread performs no save writes on menus, so writes stay serialized; reads (`saves[]`/CRC) are stable menu state. Manual by-ear check owed: F10 music/FX on file select, and default-file behavior after erase. (By-ear confirmed by the maintainer in the D355 session: FX applies live; the music follow-on gap became D355.)
|
||
|
||
## D355 — F10/options Music slider inaudible on in-stage BGM (2026-09-27)
|
||
|
||
Maintainer report (after D354): F10 sound-FX changes apply in real time, but the Music slider does not; opening the in-game watch menu seemed to be what "applied" it. Diagnosis (port-only):
|
||
|
||
- **Root cause: the slider and the audible music are on different tracks.** The Music row drives `mTrack2Vol` → `set_mTrack2Vol` → the **X-track (track 2)** CSP player (cutscene/stinger music). In-stage BGM, however, is **track 1**: solo level start plays `lvlPlayMusicTrack1(getmusictrack_or_randomtrack(...))` (lv.c:304) and the level-start reset pins **all three** players to `VOLUME_MAX` (lv.c:364–366) — it never rescales track 1 from `mTrack2Vol` in solo. The MP path is the exception: `mpmenu.c:354` explicitly feeds `get_mTrack2Vol()` into `musicTrack1ApplySeqpVol` at level entry, and `mp_music.c` mission-state transitions do the same before each `musicTrack1Play`.
|
||
- **Why the watch "fixed" it:** the in-game watch re-applies `set_mTrack2Vol` every frame while its volume screen is drawn (`draw_music_volume_slider`), so the watch-screen/stinger music (X track) audibly tracked the slider — the in-stage BGM never did. A one-shot F10 commit has the same effect as the N64 watch: correct value stored, inaudible on BGM.
|
||
- **Fix (`port/src/watchsettings.c`, port-side only):**
|
||
- `applyValue(WATCH_SETTING_MUSIC)` now calls `musicTrack1ApplySeqpVol(value)` after `set_mTrack2Vol(value)` — the *same* resync the game's own MP path performs (mpmenu.c:354), so F10/options Music edits are audible on the BGM player in real time (mirroring how FX always was via the global SFX scaler).
|
||
- **Once-per-activation resync** in `watchSettingsGameTick`: level start pins track 1 to `VOLUME_MAX` before `init_watch_at_start_of_stage` (lv.c:474, level load) re-applies the saved music value to the X track; the hook resyncs track 1 to `get_mTrack2Vol()` on the first active tick of each activation (`wsStageWasActive` reset whenever the stage is inactive), so the player's setting survives level starts, not just live edits. The `!=` guard keeps it a no-op when the game already set an equal value.
|
||
- Track 3 (MP BGM) is intentionally untouched — the game drives it from its own volume, and the PC port is solo.
|
||
- The N64 watch-menu path is **unchanged** (ground truth: the watch scales the X track only); this is a PC-settings-surface extension, in the same category as D350's existing `set_mTrack2Vol` call from the port.
|
||
- GE_WSPROBE logs extended with the track-1 volume (`t1=`) so the resync is visible headless.
|
||
|
||
Verification: NTSC build clean; **by-ear confirmed by the maintainer** — F10 Music now changes in-stage music in real time. The front-end commit path (input thread) now also calls `musicTrack1ApplySeqpVol`, which wraps `alCSPSetVol` on the track-1 player — same thread pattern as the pre-existing track-2 call, so the D350 threading gate is unchanged in effect.
|
||
|
||
## D356 — Settings regroup: functional sections, per-section reset-to-defaults, N64-watch rows off the menu (2026-09-27)
|
||
|
||
**Problem.** The D353 regroup left the F10 overlay with a Turok-style layout
|
||
and Turok-style per-page "Reset to defaults" rows (options.c:121/140/159/173/190,
|
||
all N64-watch defaults, none of which the PC build honors -- a D330-era known
|
||
gap). The N64-watch `Bond.Look` (stacks double-negation with the PC invert
|
||
rows) and `Bond.AimControl` (collides with `Input.AimMode`) rows were
|
||
menu-surface hazards. The plan
|
||
(docs/dev/D356-SETTINGS-REGROUP-PLAN.md, signed off 2026-09-27): rename the
|
||
page model to functional sections, scope the per-file rows under a file
|
||
annotation, drop the N64-watch rows from the menu (keep the code paths), and
|
||
replace the Turok reset row with a real per-section reset-to-defaults.
|
||
|
||
**Fix (port-only; `optionsoverlay.{h,c}`, `watchsettings.{h,c}`,
|
||
`frontoptions.c`, + plan doc; zero game-code changes).**
|
||
1. **Sections (plan §4).** `INPUT` (7 rows: the old MOUSE + PAD headers
|
||
merged, `__HdrMouse`/`__HdrPad` dropped) / `GAMEPLAY` (File N; 8 rows:
|
||
surviving watch toggles `Bond.AutoAim`/`LookAhead`/`Sight`/`Ammo` + the
|
||
`Game.*` ini rows + Quit) / `GRAPHICS` (11 rows, D353's DISPLAY+GRAPHICS
|
||
merged) / `AUDIO` (File N; `Bond.Music` + `Bond.FX` watch sliders, out of
|
||
the Bond-file page) / `VIDEO` (5 rows: the D353 DISPLAY rename,
|
||
`Video.DisplayFPS` moved from GAME). All pages below the MAX_PROWS cap.
|
||
`Bond.Look`/`Bond.AimControl` are commented out of `rows[]` with a D356
|
||
note (D181/D216 style: keys + watch paths stay live; ini power users can
|
||
still set them). The `__HdrBond` header + `__BondFile` chooser row are
|
||
retired, replaced by the front-only top `Save file: File N [change]` row
|
||
(frontoptions.c; hidden in F10 via the generalized
|
||
`optionsRowIsBondChooser` mechanism).
|
||
2. **Save-file scoping.** Sections that carry per-file rows (GAMEPLAY,
|
||
AUDIO) get a PD-style header annotation `"(File N)"` / `"(no file)"`
|
||
driven by `Row.saveScoped` + `watchSettingsActiveFolder()` (in-stage: the
|
||
active file; front: the resolved front target); per-file rows inside the
|
||
mixed-scope GAMEPLAY section carry a dim `"(save)"` tag (the section
|
||
title already says which file). The D353 `optionsRowIsBondChooser`
|
||
special-case is generalized to the `saveScoped` flag (both UIs: F10
|
||
overlay + front options screen).
|
||
3. **Per-section reset-to-defaults (Gate E).** The Turok rows are deleted and
|
||
replaced by a trailing `Reset to defaults` ROW_ACTION on every section
|
||
(`__ResetInput`/`__ResetGameplay`/`__ResetGraphics`/`__ResetAudio`/
|
||
`__ResetVideo`). Activation is edge-triggered, two-step arm -> confirm
|
||
within 3 s (`RESET_ARM_US`), one commit per confirmed activation, disarm
|
||
on timeout or navigating away (`optionsResetMaintain`), disarm on
|
||
close (`optionsResetClear`); the arm state is shared by the F10 overlay
|
||
and the front screen (never open at once; both clear it). The front
|
||
screen suppresses held-key repeat for reset rows; F10's held-repeat
|
||
branches skip them -- callers guarantee edges, so hold never repeats
|
||
into confirm. Confirm clears the pending arm first, then
|
||
`rowResetSection(i)` walks the section's DECLARED row range (header to
|
||
next header, so conditionally hidden rows are reset too):
|
||
watch rows -> `watchSettingsSet(field, watchSettingsBlankValue(field),
|
||
1)` (BLANKSAVEDATA, through the D352 commit path: front direct write,
|
||
stage queue drained + persisted by the game thread); ini rows -> the
|
||
`kResetDefaults[]` table (the port's own C initializers, each entry
|
||
citing its source variable) applied via `rowSetCommit` and persisted like
|
||
any ini edit. `__Resolution` is a documented exclusion (action-backed,
|
||
not a registered config row -- a numeric default cannot express it; the
|
||
player's resolution choice survives a VIDEO reset). No new write path.
|
||
|
||
**Verification (headless, this session).**
|
||
- `GE_WSPROBE_RESET=1` front boot: all 5 sections dirty -> arm -> confirm ->
|
||
verify, `failures=0` each; `__Resolution` untouched (exclusion holds);
|
||
`file 2 unchanged (scope isolation)` -- the second-file negative test.
|
||
- `GE_WSPROBE_RESET=1 -level_33`: `stage dispatch: 6 command(s) queued`
|
||
(4 GAMEPLAY + 2 AUDIO watch rows), all 5 sections `failures=0` after the
|
||
game-thread drain, `saved bytes OK (music/sfx = 0xFF)` -- the persisted
|
||
save holds BLANKSAVEDATA raw.
|
||
- Probe phase machine (`watchSettingsGameTick`, same env gate): front tick
|
||
120 = prepare (front-only, direct commits), stage tick 120 = dispatch
|
||
(queue), stage tick 121 = verify (post-drain). A direct `-level_XX` boot
|
||
enters the stage long before the front counter would reach 120, so a
|
||
stage run starts its timer from phase 0 (the stage dispatch builds its
|
||
section list lazily).
|
||
- Regressions: `GE_WSPROBE=120 -level_33` live music/fx/t1 32767->4096 +
|
||
save 255->32 (D355 resync still fires); `GE_WSPROBE_FRONT=120` default
|
||
folder 0 commit 4096 -> save.music=32. All runs end with the orderly
|
||
`video: exiting (render parked ...)` quit.
|
||
- Build: full reconfigure + 248/248 targets + link clean
|
||
(`build-pc-cmd.bat`; the pi agent shell drops MSYS env at the
|
||
msys-2.0.dll boundary, so the build goes through a `cmd.exe` wrapper with
|
||
the MSYS2 toolchain on PATH).
|
||
|
||
**Residuals.** By-ear confirmation of the in-stage Music slider is owed
|
||
(D355 territory, unchanged by this entry). The hidden N64-watch rows
|
||
(`Bond.Look`, `Bond.AimControl`) are still reachable by hand-editing the
|
||
save file, exactly as on N64 -- only the menu surface is gone
|
||
(D181/D216/D304 pattern).
|
||
|
||
## D358 — D356 level-1 overlap: the front options save-file row painted under the first content row; s_hl -> s_rowIdx off-by-one (2026-09-27)
|
||
|
||
**Problem.** D356's top save-file row (item 0 of the level-1 page) shared
|
||
the page's item numbering (0 = save row, k = content row k-1) but the
|
||
level-1 draw loop painted content row k at `rowY(k)` -- the save row's own
|
||
line -- so "Save file: File N" and the section's first row (e.g.
|
||
"Auto-aim") printed on top of each other (maintainer-reported overlap,
|
||
2026-09-27). The same item/content confusion ran through the input paths:
|
||
`s_hl` (item-numbered) was indexed straight into `s_rowIdx`
|
||
(content-numbered), so A-press / L-R adjusts hit the row *below* the
|
||
highlight, the last content row (`s_hl == s_rowN`) fell through both
|
||
handlers entirely, `s_rowIdx[s_hl]` could read one past the built rows,
|
||
and the highlight test (`k == s_hl`) lit the wrong row.
|
||
|
||
**Fix (port-only, `port/src/frontoptions.c`).** Content rows now draw at
|
||
`rowY(k + 1)` (one line below the save row, as `rowY`'s level-1 shift
|
||
intended) and every `s_hl` -> `s_rowIdx` use takes `s_hl - 1` (A-press,
|
||
L/R repeat, `optionsResetMaintain`); the A-press branch now guards
|
||
`s_hl >= 1 && s_hl <= s_rowN` so the last row is live. The hit-test and
|
||
cursor paths were already item-numbered (they use `rowY(k)` for item k)
|
||
and are untouched. Verified by build + headless boot smoke; visual check
|
||
of the screen owed (D357 plan gate 4).
|
||
|
||
**Companion change (same commit):** the per-file row tag now reads
|
||
"(profile)" (was "(save)", maintainer request), both UIs. (Full term
|
||
history: "(save)" -> "(profile)" -> "(game file)" -> the final
|
||
"(per profile)" set anchored on the end-user "Profile" term -- D357
|
||
plan section 4.)
|
||
|
||
## D359 — Settings UI file terminology settled on the end-user term "Profile" (2026-09-27)
|
||
|
||
**Reported:** maintainer English/terminology review of the settings UI:
|
||
the screen mixed five terms for the same thing ("Save file" label,
|
||
"File N" values, "(profile)" tag, "Select file" fallback, "(no file)"
|
||
state), and the instruction manual's "game file" was questioned for
|
||
clarity.
|
||
|
||
**Decision:** the end-user term is **"Profile"** — a per-player bundle
|
||
of progress + per-file settings. "Game file" rejected: in modern PC
|
||
usage it reads as the program's own files (Steam's "Verify integrity of
|
||
game files"), not the user's save. "Save" was the fallback but reads as
|
||
a verb in the row tag and implies progress-only. Rationale + the full
|
||
string table: `docs/dev/D357-SETTINGS-VALUES-PLAN.md` §4.
|
||
|
||
**Landed (port-only, `frontoptions.c` + `optionsoverlay.c`, both draw
|
||
paths + comments):** top row `Profile` / value `N` (was `Save file` /
|
||
`File N`); section-title annotation `(Profile N)` / `(none)` (was
|
||
`(File N)` / `(no file)`); per-file row tag `(per profile)` (was
|
||
`(save)` -> `(profile)` -> `(game file)`); F10 unavailable value
|
||
`Select a profile` (was `Select file`). Code identifiers and ini keys
|
||
(`Bond.*` rows, `watchSettings*`, ini) are unchanged — terminology is a
|
||
display-layer concern only (principle 3).
|
||
|
||
**TODO:** update the instruction manual's "game file" to "profile"
|
||
(port-owned doc, not in this repo).
|
||
|
||
## D360 — F10 category navigation and modern-display distance defaults (2026-09-27)
|
||
|
||
**Reported:** F10's INPUT header was offscreen above Mouse sensitivity when
|
||
scrolling; the long flat list was hard to navigate. Draw/LOD distance auto
|
||
switches obscured the controls and N64-scale pop-in looked like a port bug.
|
||
4x default MSAA was too expensive for low-end hardware.
|
||
|
||
**Fix (port-only):** F10 opens on a five-category list, and entering a
|
||
category shows its header (with Back) and settings together. Keyboard,
|
||
controller, mouse and wheel all use the same per-page list. ESC backs out of
|
||
a section, then closes from the category list; F10/Start/X still close.
|
||
Front-end PC Options already had category pages and shares the row table.
|
||
Removed AutoFov controls from both menus (ini keys remain for legacy files),
|
||
and exposed both distance sliders at all times. Stored values remain percent
|
||
of authored distance; the UI displays 0–100, where 50/100 maps to 250%.
|
||
Fresh and SteamOS defaults are 250% draw and LOD distance, 2x MSAA. Reset
|
||
uses the same new defaults. Existing ini settings are preserved; old LOD
|
||
values below the menu minimum (100%) can still be edited via ini. Note:
|
||
these higher distances increase visible geometry/pool demand; test on
|
||
open levels and low-end GPUs before claiming performance parity.
|
||
|
||
**Verification:** `git diff --check` clean. `build-pc.sh ntsc-final`
|
||
initially failed because GCC could not create temporary files in
|
||
`C:\Windows\`; configuring C/C++ with `-pipe` built all 248 objects.
|
||
The existing executable was locked by a running `ge007.x86_64` process,
|
||
so the same linker response file was linked to
|
||
`scratch/cc-tmp/ge007-check.exe` successfully. No live game was interrupted.
|
||
Manual mouse/pad/ESC, reset, and 50/100 rendering/perf checks are owed.
|
||
|
||
## D361 — F10 Audio on the main menu locks up: self-deadlock in EEPROM poll gate (2026-09-27)
|
||
|
||
**User repro:** fresh v0.4.0 playtest, change an AUDIO slider from F10
|
||
on the main menu → game locks (not merely an inaudible volume change).
|
||
|
||
**Exact chain:** F10 input is processed inside `inputComputePad()` called
|
||
by `osContStartReadData()` in `joyPoll()` (`src/joy.c:500`). D354 routed
|
||
front-context watch edits through `watchSettingsSet()` → `saveFrontField()`
|
||
on that same poll thread. Both `fileGamePakProbe()` and `fileWriteSave()` call
|
||
`joyGamePakProbe/LongWrite()` → `joyDisablePoll()` → blocking receive on
|
||
`g_ContDisablePollReceiveMessageQueue`. Only `joyPoll()` can send the
|
||
acknowledgement (`src/joy.c:434`), but it is itself blocked inside the
|
||
F10 edit: deterministic self-deadlock. D354's claim that one settings
|
||
surface meant this thread's writes were safe was incorrect; serialization
|
||
between editors does not make the controller-poll handshake re-entrant.
|
||
|
||
**Fix (port-only):** front F10 writes now coalesce in a small locked
|
||
folder/field queue. `watchSettingsGameTick()` drains commits on the game
|
||
thread before its non-stage early return and runs `saveFrontField()` there,
|
||
with no lock held across EEPROM I/O. The on-screen value is staged before
|
||
enqueue for immediate feedback, then cleared only after the matching commit.
|
||
The separate stage queue and the direct front options screen path retain
|
||
their prior behavior. `GE_WSPROBE_F10FRONT=1` is an opt-in regression probe
|
||
called from F10's actual controller-poll input path at file select (once,
|
||
after a valid save is available), committing Music+FX to 4096.
|
||
|
||
**Verified:** NTSC incremental build/link succeeded. Native Windows test
|
||
with `GE_OPTIONSOVERLAY=1`, `Game.SkipIntro=1` and the poll-thread probe
|
||
logged the queue plus both Music/FX field persist events and exited via
|
||
`GE_QUITFRAME=700` (with existing EEPROM **and** absent EEPROM on boot).
|
||
The pre-existing game-thread `GE_WSPROBE_FRONT=120` front-path regression
|
||
also persisted Music 255→32 and quit normally. The user's ini and EEPROM
|
||
were copied aside and restored byte-identically after each run. Manual
|
||
mouse slider/section-reset and stage transition checks remain owed.
|
||
|
||
## D362 — F10 GoldenEye dossier-style visual refresh (2026-09-27)
|
||
|
||
**Request:** restyle F10 to match GoldenEye, following
|
||
`docs/dev/F10-GE-STYLE-PLAN.md`. Existing front Options already uses the
|
||
in-game dossier; in-stage F10 needs a lighter port-owned 2D equivalent.
|
||
|
||
**Implementation (port-only):** replace the blue/black panel with a dark
|
||
scene scrim and warm paper card/shadow/rules, Zurich Bold menu ink (Bank
|
||
Gothic fallback during early font loading), MI6 gold masthead, dark-red
|
||
active values and slider fills/knobs, numbered GE-style category labels,
|
||
faint black selected-row highlights. The active section's Back/title/Profile
|
||
band stays pinned while content scrolls, so INPUT never disappears above
|
||
Mouse sensitivity. A short `P` gutter marker and footer legend retain
|
||
per-profile scoping without the old `(per profile)` label/value collision
|
||
at 320px. One `OvLayout` drives render, max rows, slider/drag coordinates,
|
||
mouse row-hit bands and close box. No config, input binding, save format,
|
||
N64 game-code, or F10 audio queue changes. `GE_OPTIONSOVERLAY=2..6`
|
||
optionally auto-opens a particular section for deterministic screenshots
|
||
(`=1` remains the category root).
|
||
|
||
**Verification:** NTSC compile/link clean; native-Windows `GE_PCDUMP`
|
||
front-end root and GRAPHICS capture plus 320x240 stage GRAPHICS capture
|
||
(no crash/overflow; orderly `GE_QUITFRAME` exit). Captures:
|
||
`scratch/d362-test/` (local, not distributed). User ini and EEPROM restored
|
||
byte-identically after each run. A final tiny title/footer polish rebuild
|
||
compiled, but the running playtest held `build-pc/ge007.x86_64.exe` open;
|
||
linked identical objects to `scratch/d362-test/ge007-d362.exe`, and the
|
||
local `scratch/playtest_v040.bat` installed that binary on the next
|
||
closed-game launch (guards against a concurrent game process; installed exe
|
||
size/timestamp match the staged build). Live human
|
||
review of palette, label fit, mouse clicks/drag/wheel, controller focus,
|
||
back/close, longest GRAPHICS labels and the F10 Audio regression is owed.
|
||
**User follow-up:** dossier/paper styling rejected; see D363 for the dark,
|
||
watch-green successor. D362 is retained as the design/iteration record.
|
||
|
||
## D363 — F10 returns to a dark overlay, restyled after GE's green watch (2026-09-27)
|
||
|
||
**User feedback on D362:** "make it more like how it was but use the watch
|
||
style as a reference — green, that other font etc." The watch's actual
|
||
options pages (`src/game/options.c:3524+`, `:3825+`) use Bank Gothic and
|
||
mint/bright green ink (`0xA0FFA0F0` selected, muted green unselected).
|
||
The paper card, Zurich Bold, gold, red, numbered categories and dossier
|
||
branding from D362 were not the desired direction.
|
||
|
||
**Fix (port-only):** restore the original F10's simple dark translucent
|
||
panel and full-screen dim; use the existing Bank Gothic font for all rows,
|
||
subdued green for ordinary text and bright watch green for selection,
|
||
active values and slider knobs/fill. The `PC OPTIONS` title and close `X`
|
||
replace MI6/CLOSE; category names return to their uppercase row labels.
|
||
Keep D360's two-level navigation and D362's shared layout/hit geometry,
|
||
scrolling and pinned section heading; no setting behavior or save changes.
|
||
The mixed-scope profile marker stays short to avoid 320px overlap.
|
||
|
||
**Verification:** NTSC build/link clean. `GE_OPTIONSOVERLAY=4`,
|
||
`GE_PCDUMP=160-160` with native Windows `GE_QUITFRAME=170` passed in both
|
||
front/menu and `-level_09` stage contexts at 640x480: dark panel and green
|
||
pixels present (front/stage captures in `scratch/d363-test/`), no errors,
|
||
orderly exit. User ini and EEPROM restored byte-identically after both.
|
||
Live review of colour, text, click/drag/wheel, controller, ESC/close and
|
||
F10 Audio remains owed.
|
||
**User follow-up:** palette/font approved, but P shorthand, low-contrast
|
||
legend, slider mouse hit position and off-centre input defaults need work;
|
||
see D364.
|
||
|
||
## D364 — Profile scope, F10 slider hitbox and calibrated input displays (2026-09-27)
|
||
|
||
**Reported:** user likes D363's palette but not `P` / `P = PROFILE`;
|
||
controls legend reads like setting text; clicking the visible F10 slider
|
||
requires aiming above the bar; Input defaults such as deadzone and trigger
|
||
show ~23% rather than a visually centred 50/100 or 5/10.
|
||
|
||
**Diagnosis:** the F10 bar was at `rowY + 10..12` (knob `+8..14`), but
|
||
`overlayRowAtY` assigns the row only within `rowY - 8..+8`: the bar/knob
|
||
landed in the NEXT row's hit band. The input values were physically correct
|
||
(raw `MouseSensitivity=100` of 1..500; `PadDeadzone=7000` of 0..30000,
|
||
~21% full-stick travel; `PadTriggerPct=23` of 1..99), but the UI treated
|
||
them as linear full-range sliders rather than calibrated defaults. AUDIO's
|
||
`Bond.Music`/`Bond.FX` rows were watch-backed but lacked `.saveScoped`, so
|
||
the promised `(Profile N)` title was missing despite every Audio row being
|
||
per-profile.
|
||
|
||
**Fix (port-only):** move F10 slider bar to `rowY+3..5` and knob to
|
||
`rowY+1..7`, both entirely inside the row's `-8..+8` click band. The one
|
||
shared slider-bar geometry still drives dragging. Give the control legend
|
||
its own dark inset strip with muted-grey Zurich text (Bank Gothic remains
|
||
for SETTINGS); likewise use a separate font/colour for scope notes. Remove
|
||
`P`/legend. Tag each saved Gameplay toggle `(profile)` beside its label
|
||
when measured space permits; Audio is entirely per-profile, so its title
|
||
now carries `(Profile N)` (both UIs) and no per-row tags. The front dossier
|
||
also suppresses redundant inline Audio tags. Input's three calibrated
|
||
sliders now display 0–100, mapping each *unchanged* raw default to 50/100
|
||
via a two-segment monotonic map preserving raw minimum and maximum; bars,
|
||
mouse drags and the shared front options screen use that same map. A
|
||
`(50 = original)` note explains the scale. Other sliders (audio %, texture
|
||
filter x, FOV/HUD %) keep their physical units and authored defaults.
|
||
Reset still writes the original raw defaults, config/ini values are unchanged,
|
||
and input sensitivity/deadzone/trigger behavior is untouched.
|
||
|
||
**Verification:** NTSC build/link clean. Native-Windows `GE_OPTIONSOVERLAY=2`
|
||
+ `-level_09` stage screenshot at 640x480 and orderly `GE_QUITFRAME=180`
|
||
pass, with the user ini/EEPROM restored byte-identically after the run.
|
||
Mouse click/drag on real 4:3/16:9 front/stage, reset and visible tooltip
|
||
fit still need an interactive user pass. User accepted most of D364 but
|
||
requested smaller profile annotation, a `Controls:` heading with smaller
|
||
instructions, and a visible mouse-change target; see D365.
|
||
|
||
## D365 — F10 annotations and bounded mouse-change controls (2026-09-27)
|
||
|
||
**Reported:** `(profile)` is visibly larger than its setting label; the
|
||
Controls legend should read `Controls:` with smaller following instructions;
|
||
it isn't visually clear that a label click *selects* but the right column
|
||
*changes* a setting.
|
||
|
||
**Fix:** Bank Gothic (the smaller watch font already used for row labels)
|
||
now renders dim profile tags and section scope notes instead of the larger
|
||
Zurich Bold. The footer's `CONTROLS:` heading keeps Zurich Bold, but its
|
||
instructions use smaller, dim Bank Gothic, including `BOX: CHANGE` within
|
||
sections. Draw subtle green-bordered dark control boxes around every section
|
||
row's right-hand interactive area, brightening the hovered box without
|
||
moving keyboard selection. Restrict left/right mouse changes to the same
|
||
bounded x region; label clicks still select only. Coordinates are mapped
|
||
through `gfx_get_ui_screen_rect` for hover as for input, preserving the
|
||
safe-area crop behavior (D316). No game logic or saved data touched.
|
||
|
||
**Verification:** NTSC compile/link and 640x480 `GE_OPTIONSOVERLAY=2`
|
||
`-level_09` frame-160 capture with clean frame-180 quit; ini and EEPROM
|
||
restored byte-identically after testing. During compilation the user's
|
||
existing game process held the exe, so it was first linked to a staged
|
||
scratch path rather than overwritten; final build linked directly after
|
||
that process exited. Mouse hover/click in a live window is still owed.
|
||
|
||
## D366 — Input slider calibration: “default,” not “original” (2026-09-27)
|
||
|
||
**Request:** user prefers the GEPD bundle as the standard for input and
|
||
wants the `(50 = original)` note to read `(50 = default)`.
|
||
|
||
**Fix:** changed both settings UIs' Input-header text to `(50 = default)`.
|
||
`calibratedDefault()` still maps the port's current *real* defaults
|
||
(`MouseSensitivity=100`, `PadDeadzone=7000`, `PadTriggerPct=23`) to 50/100,
|
||
and `kResetDefaults` agrees with `input.c`. No live config or save edited.
|
||
**Caveat:** GEPD's emulator bundle is an external reference; this checkout
|
||
has a stub in `reference/mouse-injector/` and its `GEPD-INPUT-PLAN.md` records
|
||
its camera aim formulas, but neither has an authoritative bundle controller
|
||
config for all three numbers. The port's 38/50 per-mode mouse speeds were
|
||
user-calibrated for its own FOV. Don't claim numeric parity with the GEPD
|
||
bundle or re-tune default input feel without the user's bundle configuration.
|
||
|
||
**Verification:** NTSC compile and staged link succeed. The active user's
|
||
playtest held `build-pc/ge007.x86_64.exe`, so the new binary was linked to
|
||
`scratch/d366-test/` and the local v040 launcher installs it only after the
|
||
running game exits. No stage smoke test while the user's save is active. The user later exited;
|
||
D367 includes the subsequent native stage smoke test of both changes.
|
||
|
||
## D367 — F10 control frames aligned with their actual clickable contents (2026-09-27)
|
||
|
||
**Reported:** D365's full-width rectangles do not align with their text
|
||
contents. Root cause: every row used a fixed `[barX0, right-7)` box despite
|
||
right-aligned toggles/enum text near `right-12`, slider bars ending much
|
||
earlier at `barX1`, and reset actions having their button in the *label*
|
||
column (empty value until armed). This advertised empty space as clickable
|
||
and left the reset's real target unframed.
|
||
|
||
**Fix:** `overlayControlSpan()` now calculates the actual button region per
|
||
row: tight around slider tracks, around measured right-aligned values, or
|
||
around the reset label; non-interactive/unavailable slider rows get no box.
|
||
Both mouse buttons and hover use that same geometry, so moving the mouse
|
||
only highlights a genuinely clickable area. The narrower border fits the
|
||
Bank Gothic text's vertical footprint. Label clicks on ordinary settings
|
||
still only select; clicking the framed reset label invokes its existing
|
||
two-step confirmation. No game logic or config/default value changes.
|
||
|
||
**Verification:** NTSC compile/link passed, native 640x480
|
||
`GE_OPTIONSOVERLAY=2 -level_09` stage capture and orderly frame-180 exit;
|
||
ini/EEPROM restored byte-for-byte. Mouse click/hover and confirmation remain
|
||
to be feel-checked live. User's immediate playtest found remaining boxes
|
||
wrong; replaced by the simpler D368 rule.
|
||
|
||
## D368 — F10: name selects, right-of-name changes (2026-09-27)
|
||
|
||
**User decision:** stop trying to frame each differently sized value; all
|
||
of the row except the setting name should change it. D367's boxed controls
|
||
were still misleading in a live test.
|
||
|
||
**Fix (port-only):** `overlayControlSpan` now starts after the measured
|
||
Bank Gothic setting name (capped at its label-column clipping edge) and
|
||
extends to the panel's right edge. It is used by mouse hover, left/right
|
||
click and a single subtly tinted strip with a thin left divider; the
|
||
per-value borders are gone. Clicking a name still focuses without changing.
|
||
A slider click on its track positions/drags; clicking elsewhere to the right
|
||
of its name changes it by one step instead of snapping to the minimum or
|
||
maximum. Reset is an action, so its whole row activates the existing
|
||
confirmation. The footer now says `RIGHT: CHANGE`. No config/save or game
|
||
logic changes. NTSC build/link passes; live mouse check still owed.
|
||
|
||
## D369 — Remove F10 per-row paint (2026-09-27)
|
||
|
||
User still saw boxes in D368 because the tinted per-row change strips and
|
||
selected-row rectangles were still painted. Removed **all** of those from
|
||
`optionsOverlayEmit()` (including hover paint). Selected text remains
|
||
brighter, sliders retain only their track and knob, and the existing dark
|
||
panel/Controls footer remain. Mouse hit behavior from D368 is unchanged.
|
||
NTSC compile and staged link pass; a running playtest still holds the old
|
||
exe, so the local launcher will install `scratch/d369-test/ge007-d369.exe`
|
||
only after the game exits. No save/ini touched.
|
||
|
||
## D370 — Avoid redundant work during F10 slider drags (2026-09-27)
|
||
|
||
**Reported:** dragging Draw/LOD distance, Music or FX volume drops FPS.
|
||
**Diagnosis:** `sliderSetFromX` runs on every controller poll while LMB is
|
||
held, including repeated polls over the *same snapped value*. `rowSetCommit`
|
||
then re-enqueues watch audio updates and, for *any* `Video.*` key, requests a
|
||
`videoStartFrame` live-config pass: `set_swap_interval`, FPS cap, texture
|
||
filter, image state and a log line. Draw/LOD multipliers are already read
|
||
straight from their config pointers by the render path, so no such pass is
|
||
needed. The active playtest log has hundreds of `video: live config applied`
|
||
lines and multiple watch-setting persists; log alone does not isolate all
|
||
of those calls to one knob. Physically increasing Draw/LOD can independently
|
||
increase rendering load, which this fix will not hide.
|
||
|
||
**Fix (port-only `optionsoverlay.c`):** early-return before live-apply/queue
|
||
when a snapped value has not changed (including queued watch settings); held
|
||
mouse polls no longer repeat the same audio setter or GL state work. Retain
|
||
slider-release `watchSettingsCommit` so a drag persists exactly once. Exempt
|
||
only `Video.DrawDistance` and `Video.LodDistance` from
|
||
`videoRequestLiveConfig`; all other Video keys retain their old live-apply
|
||
semantics. Audio updates still preview live for genuinely changed detents.
|
||
No user config/save or game code edited. NTSC compile + staged link passed;
|
||
active Windows game initially held the executable; the local launcher
|
||
installed the staged binary on the user's next safe launch (now running).
|
||
Live FPS retest (ideally compare dragging
|
||
Music/FX while paused vs simply raising Draw/LOD values) owed.
|
||
**User playtest:** "it can still drop it down if you spam it but mostly good."
|
||
Residual not isolated: the latest playtest log shows repeated full video
|
||
config passes while changing FOV (not Draw/LOD) and several watch EEPROM
|
||
persists; sustained high Draw/LOD also increases actual GPU work. Parked as
|
||
polish until a focused repro identifies which slider/spam pattern dominates.
|
||
|
||
## D371 — GEPD key-layout preset + reload/crouch bindings (v0.4.0 modern options wave, M3) (2026-09-27)
|
||
|
||
**Spec:** `docs/dev/notes/MODERN-OPTIONS-PLAN.md` M3. **Deviation from plan
|
||
(user decision, 2026-09-27):** the planned "MODERN" F10 section is DROPPED —
|
||
it is a provenance bucket, not a functional category, which conflicts with
|
||
the D356 Turok-standard "every section is functional, baseline names" rule
|
||
(the same rule that retired the D353 BOND FILE section). The rows land in
|
||
the functional section (INPUT); no new category, no `__ResetModern` — the
|
||
existing per-section reset rows cover the new keys via `kResetDefaults`.
|
||
|
||
**Implementation (port-only; `port/src/input.c`, `port/include/input.h`,
|
||
`port/src/optionsoverlay.c`):**
|
||
- New actions `IA_RELOAD` / `IA_CROUCH` (emits `GE_CONT_B` both; on N64 the
|
||
single B button does reload-tap vs crouch-hold, and the game's own
|
||
`bond_interact_object` context logic makes that choice — so the port
|
||
emits B and leaves the decision to the game, as on the N64). Defaults are
|
||
empty strings = unbound (byte-identical N64 layout out of the box).
|
||
- `Input.Layout` (0 = FPS defaults, 1 = GEPD) + `Input.CrouchMode`
|
||
(0 = hold, 1 = toggle/latched). The GEPD preset
|
||
(GEPORT-REFERENCE-DEEPDIVE.md section 7.3: Action=Q, Cancel=E, Reload=R,
|
||
Crouch=Left Ctrl, Fire="" — LMB fires/RMB aims, LeanLeft="" — lean is
|
||
GEPD-removed) acts as the EFFECTIVE DEFAULT only for actions the user has
|
||
not individually overridden in `[Input.Bind]`; per-key ini values layer
|
||
on top, so switching layouts never clobbers explicit binds. Revert =
|
||
switching back.
|
||
- Toggle crouch latches B; the latch is dropped on entering a menu (a
|
||
latched B would read as back/cancel) and on `inputLayoutApply()`.
|
||
- F10 rows: "Key layout" (kKeyLayout) + "Crouch mode" (reuses kHold) under
|
||
INPUT, before its reset row.
|
||
- `inputRebuildBinds`: an intentionally-empty string no longer logs the
|
||
"action unbound" warning; only a NON-empty string that yields no valid
|
||
keys warns (D214's warning was written when no default was ever empty).
|
||
- `optionsoverlay.c` `rowSetCommit` hook: `Input.Layout` /
|
||
`Input.CrouchMode` -> `inputLayoutApply()` (scheduler thread; a mid-poll
|
||
rebuild can at worst drop/add one scancode for one frame — the D214
|
||
thread-pairing, just at F10 commit time instead of boot).
|
||
|
||
**Verification:** NTSC build clean. GEPD boot (`Layout=1`, `CrouchMode=1`,
|
||
`Bind.Reload=R`, `Bind.Crouch=Left Ctrl`): no crash, no spurious warnings.
|
||
ini round-trip via orderly `GE_QUITFRAME` exit: `Layout`/`CrouchMode`
|
||
persist under `[Input]`; `Reload`/`Crouch` under `[Input.Bind]` (config.c
|
||
`splitKey` splits on the LAST dot — pre-existing D214 behaviour, binds were
|
||
always saved there with bare leaf names). F10 INPUT page
|
||
(`GE_OPTIONSOVERLAY=2`) boots clean, both rows resolve. **Owed:** live F10
|
||
layout-switch feel-check (GEPD vs FPS), in-game crouch toggle/reload B
|
||
semantics + menu-latch-reset, PAL/JP.
|
||
|
||
## D372 — Low-end performance preset row (v0.4.0 modern options wave, M5) (2026-09-27)
|
||
|
||
**Spec:** `docs/dev/notes/MODERN-OPTIONS-PLAN.md` M5. One row in the
|
||
functional VIDEO section (no MODERN bucket — same D356 Turok-standard
|
||
decision as D371).
|
||
|
||
**Implementation (port-only; `port/src/video.c`, `port/src/optionsoverlay.c`):**
|
||
- `Video.LowEndMode` (0/1, default 0) registered in `videoConfigInit`. The
|
||
key only records which preset the user last selected; the effect flows
|
||
through the two real knobs.
|
||
- F10 row "Low-end mode (restart)" (VIDEO section, after Frame rate cap):
|
||
ON → `rowSet(Video.FpsCap, 30)` + `rowSet(Video.MSAA, 1)`; OFF → 60 / 2
|
||
(the compiled defaults). We do NOT remember per-user pre-preset values —
|
||
the defaults are the N64-original setting, and a single well-known restore
|
||
point is simpler and predictable (deviation from plan §M5's
|
||
"pre-toggle values" wording; plan's parenthetical already notes the
|
||
defaults are the expected pre-toggle state).
|
||
- FpsCap applies live (`videoRequestLiveConfig` via the row's own commit);
|
||
MSAA is a restart row (applied at next GL context rebuild), hence the
|
||
"(restart)" label.
|
||
- `kResetDefaults`: `Video.LowEndMode = 0`.
|
||
|
||
**Verification:** NTSC build clean. F10 VIDEO page (`GE_OPTIONSOVERLAY=6`)
|
||
boots clean — row resolves, no "not registered" warning. `LowEndMode=1`
|
||
survives an orderly `GE_QUITFRAME` exit. **Owed:** live F10 preset
|
||
toggle (ON→FpsCap/MSAA written, FPS readout drops to 30), restart-for-MSAA
|
||
check, PAL/JP.
|
||
|
||
## D373 — In-game crosshair on/off + colour (v0.4.0 modern options wave, M2) (2026-09-27)
|
||
|
||
**Spec:** `docs/dev/notes/MODERN-OPTIONS-PLAN.md` M2 — the wave's ONLY
|
||
rule-2 item (the port intentionally differs from N64 rendering when the
|
||
option is on; sign-off recorded 2026-09-27 via user direction to
|
||
implement + live test).
|
||
|
||
**Implementation:**
|
||
- `port/src/gunfire.c` `gunDrawSight`: `#ifdef PORT` guard only.
|
||
`Video.CrosshairHide` → early return (no texSelect, no draw);
|
||
otherwise `portCrosshairTint()` supplies the RGB args that the N64
|
||
hardcodes to 0xFF,0xFF,0xFF (alpha 0x6E unchanged). Non-PORT builds
|
||
keep the original literals byte-identical (local `extern`s inside the
|
||
function, the bondview2 `portNoHitFlash` pattern; single consumer, no
|
||
header declaration).
|
||
- `port/src/video.c`: `s32 portCrosshairHide` (non-static, D232
|
||
pattern) + `portCrosshairTint()` — 7 named tints (index 0 = white =
|
||
the N64 identity), `Game`-section registration `Video.CrosshairHide`
|
||
(0/1) + `Video.CrosshairColor` (0..6).
|
||
- `port/src/optionsoverlay.c`: GRAPHICS rows "Crosshair" (kOnOffRev,
|
||
0 = On) + "Crosshair colour" (kCrosshairColor) + kResetDefaults.
|
||
|
||
**Verification:** NTSC build clean. `GE_OPTIONSOVERLAY=4` (GRAPHICS
|
||
page) boots clean — both rows resolve. Headless level captures (dam,
|
||
`-level_33`) with hide/red-tint configs: no crash; the crosshair is
|
||
suppressed in cutscene frames (gunDrawSight only draws in live
|
||
gameplay), so the visual A/B was done by the user: **crosshair shows in
|
||
normal gameplay (user-confirmed 2026-09-27, "crosshair showed up")**.
|
||
Defaults (hide=0, colour=0) leave the render byte-identical to the
|
||
pre-wave tree. `betacrosshairimage` confirmed dead (plan §M2) — one
|
||
hook covers in-level rendering.
|
||
|
||
## D374 — GEPD becomes the default key layout; preset layering fix (v0.4.0 M3 playtest feedback, 2026-09-27)
|
||
|
||
**Trigger:** first GEPD playtest (user, 2026-09-27): "Crouch is broken,
|
||
in GEPD mode ctrl shoots the gun" + "why is FPS different than GEPD? GE
|
||
is a first person shooter ... make [GEPD] the default and call it
|
||
default."
|
||
|
||
**Diagnosis (two distinct bugs, one user-visible symptom):**
|
||
1. The M3 "preset as effective default" only applied to EMPTY
|
||
`g_bindStr`. But the config migration writes EVERY bind key into the
|
||
ini on clean exit, so any pre-existing ini carried the full set of
|
||
persisted N64-layout defaults (`Fire = Left Ctrl`, `Action =
|
||
Space,Z,E`, ...) — all non-empty — and the preset was silently dead
|
||
for real users. Switching to GEPD left `Fire = Left Ctrl` in force
|
||
while the preset added `Crouch = Left Ctrl` → **Ctrl fired and
|
||
crouched at once**.
|
||
2. Naming/expectation: in the GEPD scheme (DEEPDIVE §7.1) Q = A button
|
||
(accept/next weapon), E = B button (use/cancel/crouch/reload). The
|
||
stale ini's `Action = Space,Z,E` kept E on the A button (weapon
|
||
cycle), not B (use) — the user correctly expected E = USE.
|
||
|
||
**Fix (user decision 2026-09-27; port-layer only, zero game-logic
|
||
change):**
|
||
- **GEPD is now the DEFAULT layout** (`Input.Layout`: 0 = GEPD — the
|
||
natural mouse/FPS default; the N64 never had a keyboard, so the old
|
||
"FPS" default was always a port artifact — 1 = the N64-era keyboard
|
||
binds, byte-identical to the pre-wave set, one click away). F10 row
|
||
now reads "Default" / "N64".
|
||
- **True effective-default semantics** (`inputRebuildBinds`, D374
|
||
precedence): an EXPLICIT per-key `[Input.Bind]` value (non-empty and
|
||
≠ the N64 default, or an explicit unbind of a default-bound action)
|
||
wins; a persisted value that merely equals the N64 default counts as
|
||
UNSET, so the layout preset fills it. With this, the user's existing
|
||
ini (full of persisted defaults) gets the clean GEPD preset:
|
||
Fire="" (LMB/RMB fire/aim), Crouch=Left Ctrl, Reload=R, Action=Q
|
||
(A), Cancel=E (B=use) — no Ctrl conflict.
|
||
- Boot log line: `input: layout GEPD (default)/N64, crouch mode ...`
|
||
(verifiable in `ge007.log` without an F10 screenshot).
|
||
- **One-off semantics flip:** an ini with the pre-D374 `Layout = 1`
|
||
(which meant GEPD) now means N64. No in-code migration marker exists
|
||
(the value is ambiguous); the dev ini was flipped to 0 by hand —
|
||
note it in the v0.4.0 release notes for users who had selected GEPD.
|
||
|
||
**Verification:** user ini (stale binds, `Layout = 0`): boot log
|
||
"layout GEPD (default), crouch mode hold", no "no valid keys" warnings,
|
||
clean exit persists `Layout = 0`. `Layout = 1`: "layout N64". Ctrl no
|
||
longers fires (Fire unset under the preset); crouch/reload/E-use per
|
||
preset. **Owed:** user re-playtest (Ctrl crouch, E use, Q cycle) before
|
||
the wave closes.
|
||
|
||
## D375 -- Crouch wired to the wrong pad bit (v0.4.0 M3 playtest, 2026-09-27)
|
||
|
||
**User report:** "crouch still does not work" after D374. The ini was checked
|
||
first: clean (`Crouch =` empty, preset applies) -- **not the cause**.
|
||
|
||
**Diagnosis:** the engine never reads B for crouch. bondview2.c:5318/5579:
|
||
`moveData.crouchDown = insightaimmode && (buttons & (D_JPAD | D_CBUTTONS))`
|
||
(keyboard/stick variant: `insightaimmode && stickY < -30`). N64 crouch was
|
||
**Z (aim) + C-down**. M3's `IA_CROUCH -> GE_CONT_B` (and the toggle latch)
|
||
was inert -- the bit the game reads was never set.
|
||
|
||
**Fix (port-layer only, `port/src/input.c`):** IA_CROUCH hold + the
|
||
`CrouchMode=1` latch now emit `GE_CONT_D` (0x0004, C-down). Side effect,
|
||
N64-faithful: S (IA_BACK, already C-down for "back") also crouches while
|
||
aiming, exactly as C-down did on N64. Crouch therefore only works **while
|
||
in aim (insight) mode** -- holding RMB/Left Shift + Left Ctrl. On release
|
||
(or leaving aim) the player stands (bondview2's `crouchUp` branch).
|
||
|
||
**Verification:** build clean; clean boot + exit. **Owed:** user live
|
||
re-test (aim + Ctrl, both hold and F10 Toggle mode). Cross-tag: the D373
|
||
entry's "emits B" wording was superseded by D375 (reload still emits B;
|
||
crouch no longer does).
|
||
|
||
## D376 — Crouch follow-up: control-style axis and pad mapping (2026-09-27)
|
||
|
||
**Diagnosis:** The 1-controller branch in `bondview2.c:5579` reads C-down
|
||
for BOTH 1.1/HONEY and 1.2/SOLITARE. The stick-Y crouch at 5318 is the
|
||
**two-controller** branch; it does not apply to the port's single-controller
|
||
layout. The existing gamepad B/Y mapping in `port/src/input.c` still sent B
|
||
in stages (never crouch). Also D375's keyboard C-down was emitted in menus
|
||
and outside aim, where it can navigate or move backwards instead.
|
||
|
||
**Fix (port only):** keyboard Ctrl and pad B/Y feed one hold/toggle latch;
|
||
in-stage, while aiming, send C-down for both single-controller schemes.
|
||
Do not emit crouch/navigation input in menus or outside aim; B/Y retain
|
||
B/cancel in menus. Drop the latch at menu/overlay transitions. No game-code
|
||
edits. The engine still *requires aim* for crouching; releasing aim before
|
||
releasing crouch may leave the posture down until aim resumes (N64
|
||
constraint, not a promise that Ctrl alone crouches). Some weapons disable
|
||
crouch and use down for zoom instead.
|
||
|
||
**Spot check of M2/M3/M5:** M2 hide/tint hook defaults to the original white
|
||
render and keeps the non-PORT branch unchanged (`gunfire.c:gunDrawSight`);
|
||
M3 E and R both send context-sensitive B, **not** a guaranteed
|
||
interaction-free reload (deferred v2 reload gate in the plan); D374
|
||
preset layering treats N64-default strings as unset, which also means an
|
||
explicit custom binding *equal to* the N64 default cannot override GEPD;
|
||
for the default-empty Reload/Crouch keys, an empty string likewise cannot
|
||
express an intentional unbind of the GEPD preset (the ini writer stores
|
||
empty for both "unset" and "explicitly unbound"). This needs a separate
|
||
config migration/presence design before promising full rebind semantics.
|
||
M5 low-end row writes 30fps/MSAA 1 on selection and restores 60/2 on
|
||
deselection (does not remember custom values); the individual settings
|
||
are stored separately, so `LowEndMode` describes the last preset selection,
|
||
not an enforced invariant after subsequent ini edits. No other code fix
|
||
made in this spot check.
|
||
|
||
**Verification:** touched object compiles (two pre-existing warnings);
|
||
linked alternate `build-pc/ge007.review.exe` because the normal exe was
|
||
locked by a running process. Scripted `-level_33` boot via
|
||
`GE_INPUTSCRIPT='60:RHOLD;90:CDOWN'` with `GE_INPUTLOG=1` observed
|
||
`btn=0014` (R+C-down) during pulses, normal self-quit at frame 140.
|
||
This tests native pad delivery, **not** live SDL keyboard or physical
|
||
pad input. **Superseded by D377:** user confirmed GEPD freely crouches
|
||
without aim; the aim gate here was wrong for the GEPD preset and prevented
|
||
the requested feature from working as advertised.
|
||
|
||
|
||
## D377 — GEPD independent crouch via native player stance (2026-09-27)
|
||
|
||
**User correction:** GEPD Ctrl crouches freely without aim. D375/D376
|
||
mistook N64's controller chord (aim + C-down) for the GEPD *input injector's*
|
||
dedicated crouch behavior. `docs/dev/GEPD-INPUT-PLAN.md` WI-6 had already
|
||
identified the GEPD mechanism: port-side stance flag write, not a pad bit.
|
||
The source is no longer vendored here (GPL stub in `reference/mouse-injector/`),
|
||
so no injector code is copied into the port.
|
||
|
||
**Diagnosis:** `bondview2.c:5579` gates native C-down crouch on
|
||
`insightaimmode`; `5969–5975` updates `g_CurrentPlayer->crouchpos` via
|
||
`currentPlayerAdjustCrouchPos`; `bondview.c:1543–1560` clamps it to
|
||
`CROUCH_SQUAT..CROUCH_STAND`; `bondview2.c:7038–7074` interpolates
|
||
`ducking_height_offset` from `currentPlayerGetCrouchPos()` (minimum of
|
||
`crouchpos` and `autocrouchpos`). No pad bit can request independent crouch
|
||
without the game's aim gate. This is a PC control feature, not an N64
|
||
game-code divergence: implement in the port, no rule-2 game edit.
|
||
|
||
**Fix (`port/src/input.c`):** the Ctrl and gamepad B/Y hold/toggle latch
|
||
now writes `g_CurrentPlayer->crouchpos = CROUCH_SQUAT` while active and
|
||
restores `CROUCH_STAND` on release; the game continues to animate the
|
||
height offset itself and automatic crouch still works via its separate
|
||
`autocrouchpos`. While already aiming, the port also feeds native C-down
|
||
so the game's per-tick `crouchUp` branch cannot undo the held stance; without
|
||
aim, no C-down is needed. The adapter does not force stand after a native
|
||
C-down crouch, and the native game path remains unchanged. No effect in
|
||
menus, watch, pause, death, tank, scripted camera,
|
||
or on weapons whose flags disable crouching; no B action is emitted in a
|
||
stage from pad B/Y. `GE_INPUTSCRIPT` gained CHOLD/CREL for headless stance
|
||
checks. Holding a bind through a menu/overlay drops the latch.
|
||
|
||
**Verification:** compiled and linked `build-pc/ge007.review.exe` (normal
|
||
exe still locked). On `-level_09`, `GE_INPUTSCRIPT='30:START;300:CHOLD;600:CREL'`
|
||
with `GE_INPUTLOG=1 GE_QUITFRAME=780` logged **free crouch applied
|
||
(aim=0)** and **free crouch released**, with normal self-quit. The same
|
||
run with simultaneous RHOLD/CHOLD also applied and released the stance.
|
||
A Dam `-level_33` control run kept the scripted-camera gate closed during
|
||
its intro, as intended. User later said crouch was good in live testing; which of hold/toggle and
|
||
pad B/Y were tried was not specified, so those specific checks remain open.
|
||
|
||
|
||
## D378 — GEPD E/R were both B (use/reload separation, 2026-09-27)
|
||
|
||
**User report:** R opens doors; question whether E is use-only. **Confirmed:**
|
||
D371 mapped `IA_RELOAD` *and* `IA_CANCEL` (GEPD E) to `GE_CONT_B` in
|
||
`port/src/input.c`. `bondview2.c:5642/5712` sets `field_D0` on a B tap;
|
||
`lv.c:796` calls `bond_interact_object()` and reloads both hands via
|
||
`attempt_reload_item_in_hand()` only if the interaction found no target.
|
||
Therefore **R opens doors and E reloads with no nearby target**. The M3
|
||
plan's "we get it for free" premise was incorrect: the B context alone
|
||
cannot implement two separate actions. Native single-button B stays intact
|
||
for N64 controls; this is a PC input feature, not an N64 game-code bug.
|
||
|
||
**Fix (`port/src/input.c` only):** with GEPD layout in a playable stage,
|
||
E's rising edge calls the existing `bond_interact_object()` (its return
|
||
value is ignored: no fallback reload), while R's rising edge calls the
|
||
existing `attempt_reload_item_in_hand(GUNRIGHT/GUNLEFT)` without B/door
|
||
interaction. During front menus, watch, tank, pause/cutscenes, E retains
|
||
native B/cancel; R does not fire while controls are unavailable. Gamepad
|
||
B/Y still drives crouch in stages and B/cancel in menus (D377).
|
||
Use/reload edges are tracked across polls and the F10 overlay to avoid
|
||
held-key repeat or action leaks. **Caveat:** input sampling runs before
|
||
`lv.c`'s original after-`propsTick` interaction site, so the port call can
|
||
use the previous frame's on-screen prop list. A real door/terminal playtest
|
||
is needed to check for a one-frame targeting discrepancy; no `src/game`
|
||
behavior edits were made. Special N64 B combos (e.g. remote-mine A+B)
|
||
are not promised on GEPD's dedicated E key.
|
||
|
||
**Verification:** touched object compiled (only pre-existing warnings),
|
||
`build-pc/ge007.review.exe` linked (normal exe can be locked by a running
|
||
playtest). Headless Bunker1 `-level_09` with
|
||
`GE_INPUTSCRIPT='30:START;300:UHOLD;315:UREL;450:RELOADHOLD;465:RELOADREL'`
|
||
logged exactly one `dedicated use (no target=1)` and one `dedicated reload
|
||
(no use)`; exit clean at GE_QUITFRAME=800. The scripted scene had no door
|
||
target and does not prove interaction targeting or a visible magazine refill;
|
||
live R-near-door, E-near-door, E-no-target, R-with-partial-mag verification
|
||
remains owed. Cross-ref D371/D376/D377.
|
||
|
||
|
||
## D379 — Crosshair colour is multiplied against a red texture (2026-09-27)
|
||
|
||
**User report:** Yellow/Cyan/Blue are visibly the wrong colours.
|
||
`gunDrawSight` (`src/game/gunfire.c:6550`) passes the RGB from
|
||
`portCrosshairTint` to `display_image_at_position`, whose
|
||
`G_CC_FADEA` combine mode multiplies TEXEL0.rgb by ENVIRONMENT.rgb.
|
||
The authored `IMAGE_CROSSHAIR1` is not a neutral white mask: captured
|
||
`-level_09` with GE_TEXDUMP, fmt=RGBA32, size=4096, tile=32x32;
|
||
`texdump/r331_f0_s3_32x32.bin` had 787 fully transparent black pixels,
|
||
232 nonzero RED pixels and essentially zero green/blue (examples
|
||
`(235,0,0,203)`, `(245,0,0,255)`). A blue tint multiplied by this image
|
||
remains dark/black/red; the user observation follows exactly. The texture
|
||
alpha retains the antialiased reticle silhouette (237 nonzero pixels).
|
||
|
||
**Fix:** preserve byte-identical default (White/0) and the N64 builds.
|
||
Only for selected nonwhite tints, `src/game/gunfire.c`'s existing
|
||
`#ifdef PORT` opt-in crosshair hook calls
|
||
`portCrosshairApplyTintCombine()` in `port/src/video.c`, replacing only
|
||
that sprite's generated combine command. Colour now uses
|
||
`ENVIRONMENT.rgb * TEXEL0_ALPHA`, transparency stays
|
||
`TEXEL0_ALPHA * ENVIRONMENT_ALPHA` (the original alpha expression).
|
||
The hook points at the command immediately after
|
||
`display_image_at_position`'s `gDPSetEnvColor`; the latter emits the
|
||
combine command next (`bondwalk2.c:136–155`). This is the same opt-in
|
||
crosshair-colour behavior previously authorized in D373, corrected to
|
||
actually show the named colours; not a new default-game behavior change.
|
||
No generic texture/combiner path is affected.
|
||
|
||
**Verification:** touched NTSC objects compile and alternate
|
||
`build-pc/ge007.review.exe` links. With user's Cyan (5) ini and a scripted
|
||
Bunker1 aim, GE_PCDUMP at frames 1050–1090 shows ~2130 cyan-dominant
|
||
pixels in the reticle region (x=664–774,y=385–505), vs zero in the same
|
||
region outside aim; screenshot-independent pixel count, no unsupported
|
||
combiner errors. The temporary GE_TEXDUMP 800-cap probes in gfx_pc/
|
||
gfx_opengl were reverted, and the user's ini was not edited for the
|
||
capture. Owed: human Blue/Yellow/Cyan and White default visuals in the
|
||
actual playtest window. Cross-ref D373.
|
||
|
||
|
||
## D380 — Remove the N64 keyboard preset; migrate to one honest PC layout (2026-09-27)
|
||
|
||
**User decision:** no N64 equivalent on PC; remove the N64-vs-GEPD key
|
||
layout selector and consolidate. Rebindable keys like the PD port should
|
||
be investigated next. The menu row used to hide the effective GEPD strings
|
||
behind persisted N64-era defaults (`Input.Bind.Fire = Left Ctrl` even though
|
||
Ctrl was actually crouch); it also made explicit unbinding of empty-default
|
||
Reload/Crouch impossible (D374/D376).
|
||
|
||
**Implemented (`port/src/input.c`, `port/src/optionsoverlay.c`):** remove
|
||
`Input.Layout` registration and F10 INPUT row. There is one PC layout.
|
||
`Input.Bind.*` now represents effective bindings directly: a fresh ini
|
||
contains Fire empty, Action Q, Cancel E, Reload R, Crouch Left Ctrl,
|
||
LeanLeft empty; `NONE` explicitly unbinds any action (PD convention).
|
||
`Input.BindingsVersion=0→1` performs a one-time migration of legacy ini
|
||
values equal to the old N64 defaults to the real PC values, preserving
|
||
non-default custom bind strings; then writes the updated ini and drops
|
||
the obsolete Layout key. There is no perfect way to distinguish a user's
|
||
explicit N64-default-valued custom bind from a stale auto-written default
|
||
in a pre-versioned ini: the one-time migration treats both as defaults,
|
||
matching D374's previous behavior. Once migrated, bindings with those
|
||
same strings can be intentionally set and retained. The crouch mode row
|
||
still invokes `inputLayoutApply` to drop the latch/rebuild parsed keys;
|
||
the exported function can also serve the later capture UI.
|
||
|
||
**Verification:** touched input/options objects compile and alternate exe
|
||
links. Backed up the user's ignored `data/ge007.ini` before a 90-frame
|
||
headless boot, observed the one-time migration: Layout removed;
|
||
BindingsVersion=1; Fire empty, Action=Q, Cancel=E, LeanLeft empty,
|
||
Reload=R, Crouch=Left Ctrl; normal quit. Then restored the original
|
||
user ini byte-for-byte (live game may still use it until next boot).
|
||
|
||
**PD-port analogue / next work:** `../pd_port/port/src/optionsmenu.c:1694–1885`
|
||
has a binding capture dialog (next key/button; ESC cancels, DEL clears)
|
||
and multiple slots per action; `port/src/input.c` there collects
|
||
SDL_KEYDOWN/MOUSEBUTTONDOWN/controller events into a last-key queue and
|
||
serializes `NONE`. GE already has multi-key ini strings but no UI; add
|
||
`ROW_BIND` to the shared F10/front-options row data with primary +
|
||
secondary slots, action labels and values, and a modal capture state.
|
||
`port/src/video.c:videoPumpEvents` owns SDL events on the host thread,
|
||
whereas `optionsOverlayHandleInput` runs on the scheduler thread, so
|
||
handoff must be synchronized (one-shot scancode/button event, ignore the
|
||
opening click/press). Escape cancels, Delete clears to `NONE`, and F10/
|
||
mouse-wheel/overlay navigation must be swallowed during capture. Use
|
||
`inputLayoutApply` to rebuild + persist on commit, expose only actions
|
||
that can actually be remapped (mouse LMB/RMB are currently hardwired).
|
||
No binding-capture UI is implemented yet; first verify the D379 colours
|
||
and D378 doors/reload with the user.
|
||
|
||
|
||
## D381 — RULE-2-SIGNOFF: opt-in RGB, size and Beta crosshair (2026-09-27)
|
||
|
||
**Status: IMPLEMENTED; live visual acceptance and golden sweep owed.** The
|
||
user asked for an RGB slider in the menus, then crosshair size and alternate
|
||
in-game art. After the concrete design was explained (Original/default,
|
||
Custom RGB with a swatch, 50–200% size, Original/Beta using the existing
|
||
`IMAGE_CROSSHAIR2`), the user explicitly replied **“OK proceed with all of
|
||
these.”** This is an opt-in customization, not a claim that the N64 drawing is
|
||
wrong: with the default Original/100%/Original, the same game code draws the
|
||
same source image at the same dimensions with the same N64 combiner. There
|
||
is therefore no alleged default N64 divergence to prove. D379 already
|
||
established the actual red source image/combiner contract (not a port bug in
|
||
the default path). The user-approved opt-in behaviour deliberately differs
|
||
from the fixed N64 appearance; it is not an ABI/layout fix.
|
||
|
||
**Narrow src/game scope:** `src/game/gunfire.c:gunDrawSight`, inside the
|
||
existing `#ifdef PORT` crosshair hook (D373/D379) only. The exact
|
||
`texSelect`/`display_image_at_position` calls determine which of the two
|
||
32×32 asset-table entries is uploaded and the half-size of the sprite. These
|
||
cannot be changed safely from `port/` alone without intercepting and
|
||
rewriting another game's texture/draw display list after the fact. Only the
|
||
opt-in image pointer and half-size are selected; the aim coordinates, alpha,
|
||
other sprites, and unguarded N64 `#else` code are unchanged. PD's analogous
|
||
port uses configurable sight colour/size in `../pd_port/src/game/sight.c`
|
||
and sliders in `../pd_port/port/src/optionsmenu.c`; its image formats are
|
||
not assumed interchangeable with GE's. The previously unused
|
||
`betacrosshairimage` is loaded in `src/game/image_bank.c` and backed by
|
||
`IMAGE_CROSSHAIR2` in `assets/oddtextures.c`; it is **not** automatically
|
||
selected by 007 mode.
|
||
|
||
**Implementation:** `port/src/video.c` registers `Video.CrosshairRed/Green/
|
||
Blue` (0–255, defaults 255), `Video.CrosshairSize` (50–200, default 100),
|
||
and `Video.CrosshairStyle` (0 Original, 1 Beta). Existing colour values
|
||
0–7 preserve their meanings; 8 = Custom. Colour 0 keeps the sprite RGB
|
||
and original combine mode; all other colours (including Custom) use the
|
||
D379 alpha-silhouette combiner. The three RGB sliders appear only while
|
||
Custom is selected; changing presets does not discard their saved values.
|
||
`port/src/optionsoverlay.c` adds a functional CROSSHAIR page because the
|
||
GRAPHICS front page has a fixed 14-row cap; F10 and front options share its
|
||
rows, live values, and section reset (including hidden RGB rows). Both UIs
|
||
show a representative colour swatch; Original shows red, not the white
|
||
identity multiplier. The game menu cursor and every other image remain
|
||
unaffected. Ini saved under `$S/ge007.ini` as before; user ini was untouched
|
||
for testing.
|
||
|
||
**Verification:** MSYS2 MINGW64 `./build-pc.sh ntsc-final` compiled/linked.
|
||
In an isolated temporary data directory, `-level_09` with scripted aim
|
||
(`GE_INPUTSCRIPT='30:START;60:RHOLD'`, `GE_PCDUMP=580-580`) rendered and
|
||
quit cleanly twice. Beta/Custom (10,200,240)/150% had 159 cyan-dominant and
|
||
0 red-dominant pixels in the 100×80 reticle region; Original/100% had 384
|
||
red-dominant and 0 cyan-dominant pixels there. With `GE_TEXDUMP`/`GE_TEXRAW`,
|
||
the Beta texture was actually loaded as 32×32 RGBA32 (4096 bytes): 112
|
||
nonzero-alpha pixels in a thin plus-shaped sight, confirming that the
|
||
D379 colour-mask approach applies. Section row census: CROSSHAIR has at
|
||
most 8 content rows, GRAPHICS 10, both under the front menu's 14-row cap.
|
||
`git diff --check` passes. These scripted checks do **not** replace a human
|
||
size/style/colour swatch inspection or the post-landing 21-level golden gate.
|
||
|
||
|
||
## D382 — Functional crosshair page and full GPU-state reset on every slider detent (2026-09-27)
|
||
|
||
**Status: PARTIAL (built; live FPS/menus and 21-level golden gate owed).** User
|
||
asked to keep Turok-style functional categories/setting names and reported
|
||
hitches while dragging sliders, possibly when launched with the v0.4.0 batch
|
||
file. Evidence from the actual `scratch/playtest_v040.log`: **349**
|
||
`video: live config applied` passes. `optionsoverlay.c:rowSetCommit` called
|
||
`videoRequestLiveConfig()` after *every changed `Video.*` value* except
|
||
Draw/LOD and restart rows, including all RGB sliders, size, style, colour and
|
||
FOV. Each pass in `videoStartFrame` called `videoApplyTexFilter()` and
|
||
`videoApplyImageOptions()` regardless of the key. In
|
||
`port/fast3d/gfx_pc.cpp`, both texture-filter setters and the anisotropy
|
||
setter call `reset_texture_state()` (clears texture and shader caches). These
|
||
three resets and the log line per detent plausibly explain the crosshair/FOV
|
||
drag hitches; a high Draw/LOD setting independently increases real render
|
||
cost, and changing anisotropy/texture-filter themselves necessarily still
|
||
rebuilds texture state. The log does not tie each pass to a specific row, so
|
||
this is a proven redundant-work path, not a measured per-slider frame-time
|
||
attribution.
|
||
|
||
**Fix (port-only):** `videoRequestLiveConfigForKey()` uses atomic bitmask
|
||
coalescing across the options and render threads. VSync, FPS cap, texture
|
||
filter, FOV, anisotropy and safe-area crop each update *only themselves* on
|
||
render-thread frame start. Crosshair knobs and the other direct-read
|
||
world/HUD settings request no GL pass at all. No default, ini key or
|
||
rendered-image behavior changes; fullscreen still uses its host-thread
|
||
request. With the existing early-return on unchanged snapped values (D370),
|
||
mouse-held duplicate polls do no GL work. User's local
|
||
`scratch/playtest_v040.bat` still runs `build-pc/ge007.latest.exe` with the
|
||
same ini and logs output, but no longer forces `GE_D322`/`GE_D204` audio
|
||
telemetry on each run (these were cheap 5-s probes, not proven to cause
|
||
hitches); setting them in the calling environment restores diagnostics.
|
||
The outdated pre-D377 binary warning and launcher label were corrected.
|
||
No running game was interrupted, and no live ini was changed.
|
||
|
||
**UI decision:** Keep the Turok five functional headings and add only a
|
||
sixth *functional* CROSSHAIR page beside GAMEPLAY (not a "MODERN" provenance
|
||
bucket, and not a graphics-quality control). The front menu has a fixed
|
||
14-content-row cap; merging RGB/style/size into GRAPHICS or GAMEPLAY would
|
||
silently lose settings. It has its own reset and no change to the existing
|
||
per-profile `Bond.Sight` option in GAMEPLAY. Rename the ambiguous duplicate
|
||
"Crosshair" row to **Show crosshair** (`On`/`Off`, original On); colour 0 to
|
||
**Original (red)** (keeps the authored sprite), Beta style to the visible
|
||
shape **Thin cross**, custom channels to **Red / Green / Blue** with `N/255`
|
||
values only while Custom is selected. **Crosshair size** displays 50–200%,
|
||
100% default. Keys and existing numeric ini values are unchanged; selecting
|
||
Beta does not affect the front-end cursor or automatically select 007 mode.
|
||
The show-crosshair machine-level gate is separate from the original save-
|
||
scoped Sight-on-screen option; both may hide the aim sight.
|
||
|
||
**Verification:** MSYS2 MINGW64 NTSC rebuild linked after the selective
|
||
update; a first compile caught a MinGW SDL without `SDL_AtomicOr`, replaced
|
||
with `SDL_AtomicCAS` loop and then rebuilt successfully. Isolated data-dir
|
||
headless run `GE_OPTIONSOVERLAY=4 GE_PCDUMP=90-90 GE_QUITFRAME=125` booted
|
||
the sixth-section layout into CROSSHAIR and captured a frame with no
|
||
missing option or crash. GRAPHICS has 10 rows and CROSSHAIR has 8, both
|
||
within the 14-row front menu cap. `GE_OPTIONSOVERLAY=2..7` now supports
|
||
all six sections. **Not yet measured:** live FPS during drag with the new
|
||
binary; the 349-pass log is from the previous build. Human verification of
|
||
both menus' labels and sizing is still owed.
|
||
|
||
## D383 — PD-style keyboard rebind capture in both options UIs (2026-09-27)
|
||
|
||
**Status: PARTIAL (built and isolated automated checks; live keyboard-menu
|
||
acceptance and the release golden gate owed).** User explicitly requested
|
||
PD-style keyboard rebinding after the D380 single-PC-layout migration.
|
||
PD analogue: `../pd_port/port/src/optionsmenu.c:1694–1885` offers a per-action
|
||
slot dropdown, next-key capture, ESC cancel, DEL clear, and reset. GE's
|
||
`Input.Bind.*` string parser already supported four keys per action (D214)
|
||
but neither the F10 overlay nor the front options screen exposed them.
|
||
No `src/game` code changes or N64 build changes were needed.
|
||
|
||
**UI:** shared `ROW_BIND` entries in `port/src/optionsoverlay.c`, with
|
||
**MOVEMENT KEYS** (six actions) and **ACTION KEYS** (eight actions) as two
|
||
functional pages adjacent to INPUT; each fits the front menu's 14-row cap
|
||
and has a two-step **Reset to defaults** row. `port/src/frontoptions.c`
|
||
uses the same row/slot/capture API; F10 has a modal footer, the front
|
||
options screen has a modal prompt. Left/Right chooses slot **1–4** on an
|
||
action; Enter/A/click begins capture; Escape cancels, Delete clears the
|
||
selected slot; moving/clicking other controls is suppressed until the key
|
||
is released. `NONE` is displayed for empty slots. After user feedback the
|
||
ambiguous `1/4` display became **Primary / Secondary / Extra 1 / Extra 2**:
|
||
all four keys remain accessible, including older hand-edited extras. The
|
||
user's Turok hierarchy reference puts Bindings beneath INPUT; the two
|
||
standalone binding categories here are an interim layout, not final menu
|
||
acceptance. Do not add fake Weapons/Automap/Menu rows without actual separate
|
||
GE actions/input sources. The front-end `Profile`
|
||
chooser remains on every page by its pre-existing design; key bindings
|
||
are machine-wide, not attached to a save file. Mouse LMB/RMB fire/aim,
|
||
mouse wheel and gamepad controls remain hardwired and are NOT advertised
|
||
as rebindable; the on-screen instruction explicitly says mouse/pad fixed.
|
||
|
||
**Implementation:** `port/src/video.c:videoPumpEvents` consumes the first
|
||
SDL_KEYDOWN while capture is active *before* F10/ESC shortcuts; F10/F12 and
|
||
Alt+F4 are reserved, and repeats ignored. An SDL atomic flag and spinlock
|
||
pass one scancode to the scheduler/front UI thread, which alone serializes
|
||
the bind, calls `inputBindingsApply()`, and commits with `configSave()`.
|
||
Capture ignores navigation, wheel and click-to-lock; the actual captured
|
||
key is suppressed through key release. `inputBindingSetSlot()` preserves
|
||
all four existing slots (rather than discarding older hand-edited third/
|
||
fourth keys), prevents duplicate keys within an action, and never truncates
|
||
the 64-byte ini value. An explicit `NONE` token is a positional empty slot
|
||
for sparse lists (e.g. `NONE,NONE,F1`); the sole `NONE` string still unbinds
|
||
the entire action. SDL key names containing a comma or lacking a name use
|
||
`SC:<number>` in the ini, so the delimiter cannot corrupt a key. Section
|
||
reset restores the GEPD one-layout defaults.
|
||
An opt-in, restoring `GE_BINDPROBE` tests the runtime string round-trip;
|
||
`GE_BINDCAPTUREPROBE=F2|ESC|DEL` exercises the actual host-event/modal
|
||
handoff only against an isolated test ini.
|
||
|
||
**Verification:** MSYS2 MINGW64 NTSC build linked. With an isolated
|
||
`/Temp/ge-d381/data/ge007.ini`, `GE_BINDPROBE=1` logged PASS for sparse
|
||
slots 3/4 (including keypad comma), deleting slot 1 while preserving 3,
|
||
clearing all, then restoring the original strings in memory. `GE_OPTIONSOVERLAY=4` action-keys page
|
||
booted with no missing options. With `GE_BINDCAPTUREPROBE=F2` the host SDL
|
||
event was consumed and the menu committed `Input.Bind.Fire` slot 1 = F2;
|
||
`Fire = F2` appeared under `[Input.Bind]`. Repeating with ESC left F2
|
||
unchanged; DEL committed `Fire = NONE`. The real user's ini and running
|
||
process were not modified. User physical-key capture feel-check, front
|
||
options page traversal/reset, mouse/pad input after capture, other regions,
|
||
and 21-level default-render golden comparison are still owed.
|
||
|
||
|
||
## D384 — Front PC Options used editable gameplay keys as its own controls (2026-09-27)
|
||
|
||
**Status: PARTIAL (NTSC built, isolated probes pass, user retest owed).** User
|
||
reported A/D could not be rebound and C caused menu glitches. Live log
|
||
`scratch/playtest_v040.log` shows captures succeeding but selection cycling
|
||
uncontrollably across slots 1–4 (e.g. `Input.Bind.StrafeRight` A → D → C → X,
|
||
then C across slots). The user's live ini now has `Forward=NONE,D` and
|
||
`StrafeRight=NONE,V,C,X` from that session: DO NOT modify or overwrite it
|
||
while testing. Root cause is precise and port-side: `inputComputePad()`
|
||
turns *newly assigned* A/D/C into live GE `GE_CONT_C/GE_CONT_F` strafe bits
|
||
or stick X/Y; `frontOptionsMenuInterface()` reads those same C buttons and
|
||
stick values as Left/Right slot selection and Up/Down row selection. The
|
||
editor therefore feeds its own rebind results back into its navigation on
|
||
the very next poll. F10 already swallows the virtual pad and did not have
|
||
this particular feedback path. In parallel, four visible slots and `1/4`
|
||
were confusing and produced unnecessary ghost mappings; user wants two.
|
||
|
||
**Fix, `port/` only:** while `current_menu==MENU_PC_OPTIONS`, the port
|
||
builds keyboard menu input from PHYSICAL arrows and Enter/Escape, not from
|
||
editable gameplay keys. Mouse LMB/RMB and gamepad still reach the menu; the
|
||
scripted-input test harness retains its exclusive source. While capture
|
||
is active or the captured key remains down, keyboard/mouse menu buttons
|
||
are neutral (`optionsBindingInputBlocked()` checks an atomic held-scancode
|
||
across host, scheduler and front threads); the front menu's capture modal
|
||
continues to suppress interface navigation. The same `Input.Bind.*` keys
|
||
still drive gameplay outside PC Options, unchanged. `GE_BINDPROBE` exercises
|
||
the menu conversion with A+D+C all pressed (zero menu input), arrows+
|
||
Enter (only their fixed signals), and a blocked modal (zero input).
|
||
|
||
`INPUT_BIND_SLOTS` is **2**: only Primary and Secondary are displayed and
|
||
active. Old third/fourth ini tokens are **preserved verbatim and ignored**
|
||
on boot (one warning per affected action); editing that action writes its
|
||
two displayed keys, removing only that action's extras. No automatic ini
|
||
rewrite/migration, no hidden active mappings. Reset to defaults still
|
||
restores the one-PC-layout binds. This also eliminates the 3rd/4th slot
|
||
selection loop; physical arrows and mouse/pad are the only editor controls.
|
||
|
||
**Verification:** MSYS2 MINGW64 NTSC link succeeded without new diagnostics.
|
||
Isolated `GE_BINDPROBE=1` reports PASS for two slots, sparse and comma-name
|
||
serialization, A/D/C no-nav, arrow/Enter and modal block, restoring
|
||
original bind state. In an isolated ini, `StrafeRight=NONE,V,C,X` produced
|
||
one `legacy extra keys (ignored)` warning and survived a no-op quit
|
||
unchanged; capturing F2 into Primary using the host-event/modal test
|
||
changed *only that action* to `StrafeRight=F2,V` (C/X discarded).
|
||
`GE_BINDCAPTUREPROBE` synthetic SDL_KEYDOWN must set `ev.key.repeat=0` and
|
||
`ev.key.keysym.mod=KMOD_NONE` explicitly; without initialization its
|
||
random repeat value caused the **test event** to be ignored. Real user
|
||
SDL key events were not implicated. Live physical-key feel check and
|
||
Turok Input→Bindings nesting remain open. No 21-level visual gate yet.
|
||
|
||
|
||
## D385 — Mouse 1–5 need to be real action bindings (2026-09-27)
|
||
|
||
**Status: PARTIAL (NTSC build and isolated probe pass; live acceptance owed).**
|
||
User correctly noted that Mouse 1/2/other buttons should be rebindable, not
|
||
merely described as fixed UI decorations. Previously `inputComputePad()`
|
||
unconditionally emitted Z on LMB and R on RMB even after Fire/Aim keyboard
|
||
slots were edited or cleared. The binding editor captured only keyboard
|
||
`SDL_KEYDOWN`; a click while capturing could instead lock the gameplay cursor.
|
||
|
||
**Port-only fix:** `Input.Bind.*` Primary/Secondary accept `Mouse 1` through
|
||
`Mouse 5` alongside SDL scancodes; held gameplay actions now read those
|
||
actual tokens rather than special-casing LMB/RMB for Fire/Aim. Fire defaults
|
||
to `Mouse 1`; Aim defaults to `Mouse 2,Left Shift`. Captured mouse-down events
|
||
are consumed before click-to-lock. The modal blocks through physical button
|
||
release so a binding click cannot also activate the editor or stage. Mouse
|
||
clicks stay fixed as *menu* accept/back, irrespective of their gameplay
|
||
assignment; wheel up/down still cycle weapons and are not remappable here.
|
||
Gamepad mappings remain untouched.
|
||
|
||
`Input.BindingsVersion=2` upgrades pre-mouse ini files on the next launch:
|
||
formerly-hardwired Mouse 1/2 are placed in Fire/Aim slots without losing an
|
||
existing single keyboard bind. For an action already using two keyboard
|
||
slots, the two user keys take priority: no hidden mouse bind is kept, a
|
||
warning says how to reassign a slot if the old mouse default is wanted.
|
||
Old third/fourth tokens follow D384's existing ignore-until-edit rule.
|
||
The change does **not** erase game saves or migrate other actions.
|
||
|
||
**Verification:** MINGW64 NTSC build/link passed. `GE_BINDPROBE=1` checks
|
||
Mouse 5 serialization, duplicate-slot clearing and keyboard/modal isolation,
|
||
restoring original strings in memory. In a private copy of the test data
|
||
(`AppData/Local/Temp/ge-d385`, not the user's ini), a version-1 config with
|
||
`Fire=F2` and `Aim=Left Shift` migrated to `Mouse 1,F2` and
|
||
`Mouse 2,Left Shift`; `GE_BINDCAPTUREPROBE=MOUSE5` reached the host event
|
||
pump, committed Fire Primary and persisted `Mouse 5,F2`. Quit after 120
|
||
frames was clean. **Owed:** physical Mouse 1/2/side-button capture and
|
||
stage-level behavior, and the 21-level visual gate.
|
||
|
||
|
||
## D386 — Aim default must use SDL Mouse 3, not Mouse 2 (2026-09-27)
|
||
|
||
**Status: PARTIAL (NTSC build and isolated migration verified; physical retest
|
||
owed).** User caught that SDL numbers mouse buttons as left=1, middle=2,
|
||
right=3. D385 used `Mouse 2` for Aim, so RMB stopped aiming and MMB took its
|
||
place. This was a port binding-default error, not a gameplay change.
|
||
|
||
**Fix:** default Aim is `Mouse 3,Left Shift`; pre-D385 migrations now put
|
||
SDL_BUTTON_RIGHT (3) into Aim. Increment `Input.BindingsVersion` to 3 so a
|
||
version-2 ini with the **exact** erroneous default `Mouse 2,Left Shift` is
|
||
corrected on launch. Any other version-2 Aim string is left alone, preserving
|
||
explicit middle-button assignments and other user changes. Mouse-button
|
||
capture/serialization and fixed front-menu clicks still use SDL's 1–5 values.
|
||
|
||
**Verification:** NTSC MINGW64 build; isolated version-2 ini (Fire custom
|
||
`Mouse 5,F2`, Aim old default `Mouse 2,Left Shift`) migrates just Aim to
|
||
`Mouse 3,Left Shift` and version 3; version-3 restart is stable. User ini was
|
||
not edited for the probe. Live RMB/MMB test and release gate remain open.
|
||
|
||
|
||
## D387 — M7 AllUnlocked read-time patches leak into persistent saves (2026-09-27)
|
||
|
||
**Status: OPEN; M7 cannot be signed off as a preservation-only playtest.**
|
||
User offered `data/` for investigation. Original `data/ge007.ini` and
|
||
`data/ge007.eep` were **read only**, checksums unchanged after all runs.
|
||
Private isolated test folders live under
|
||
`AppData/Local/Temp/ge-m7-20260927/`: current ini + ROM/sidecars,
|
||
with the older `data/ge007 copy.eep` as the reproducible EEPROM seed.
|
||
The original seed has two genuinely completed times in folder 0, no
|
||
0x3FF fake completions, and only its earned cheat bits (`04 00 00`).
|
||
No edits to game logic, port code, or user saves in this investigation.
|
||
|
||
**Mechanism:** `port/src/libultra.c:geEepromRW` patches a *copy* of all five
|
||
save slots on the block-4 read when `portAllUnlocked` is on. The raw EEPROM
|
||
on disk stays untouched until a later save. `fileValidateSaves` accepts the
|
||
patched copies (CRC recomputed by `geEepromPatchAllCheats`) into the live
|
||
`src/game/file2.c:saves[]` array. An unrelated call to `fileWriteSave`
|
||
serializes **all 96 bytes of the live slot** back to EEPROM, including its
|
||
synthetic unlocked-cheat bits and 0x3FF time fields. D257's original finding
|
||
explicitly warned these unlocks can persist after a save; the M7 plan's
|
||
"read-time patch should guarantee untoggle preservation" premise is false.
|
||
The two genuine times in the test slot were retained, but fake completions
|
||
and cheats now survive an OFF restart. `main.c` also seeds the RAM debug
|
||
unlock flags only when the process starts: switching the setting OFF during
|
||
the same process does not clear those flags. A valid OFF test needs restart.
|
||
|
||
**Isolated evidence:** with `GE_SAVELOG=1 GE_QUITFRAME=105`, both ON and OFF
|
||
booted from the same older seed; no-gameplay-write EEPROM hashes were equal
|
||
(legacy CRC migration occurred in both). Starting from the migrated raw
|
||
EEPROM, `Game.AllUnlocked=1 GE_WSPROBE_FRONT=600 GE_QUITFRAME=780` called the
|
||
real front-end `watchSettingsSet(Music, 4096, commit=1)` on folder 0.
|
||
`SAVELOG` recorded `fileWriteSave slot=4 block=52`; only that EEPROM slot
|
||
changed. Cheat bits went `04 00 00` → `ff ff 0f`; 58 empty completion
|
||
times became 0x3FF, and the 2 earned times remained. Starting from that
|
||
result, `AllUnlocked=0` followed by a clean restart/quit validated the
|
||
slot's stored CRC and left the 58 fakes intact. Logs and private snapshots
|
||
in `on-save/` and `after-off/`. This reproduces on a **settings save alone**,
|
||
without a mission completion. Current user `data/ge007.eep` already has two
|
||
nonfree slots with 60/60 0x3FF times and all cheat bits set, consistent with
|
||
past persistence; this test neither created nor changed those bytes. Do not
|
||
silently overwrite it with an older backup (that would lose later progress).
|
||
|
||
**Scope before fixing:** decide whether OFF must restore *earned* progress
|
||
while stripping only artificial unlocks (recommended). A port-only write
|
||
adapter needs to retain unpatched per-slot state and merge legitimate
|
||
changes while ON, including new real completion times and cheat awards;
|
||
simply masking every 0x3FF on writes cannot distinguish a genuinely earned
|
||
capped time or a previously persisted fake. There is no safe automatic
|
||
repair for already-persisted fake bits without prior raw-state provenance.
|
||
Test fresh/real/mixed saves in isolated directories, ON and OFF restarts,
|
||
settings writes and actual mission completion, CRC validity and all folders;
|
||
only then mark M7 done. The 21-level visual gate is independent and open.
|
||
|
||
## D388 — Nest functional binding/HUD menus and remove redundant M5 toggle (2026-09-27).
|
||
|
||
Reconstructed stub — the original full entry was lost when `findings.md` was truncated to its section table (recovery, 2026-09-28); this entry is rebuilt from the section-table row and the `findings-index.csv` one-liner. Original root-cause detail is not recoverable.
|
||
|
||
**Status: PARTIAL (NTSC build, isolated nested navigation/reset/capture probes; physical UI acceptance and visual release gate owed)**: five root sections, Input → Bindings → Movement/Actions; Gameplay → HUD; independent FPS/MSAA remain exposed and unchanged by removing Low-end mode.
|
||
|
||
## D389 — F10 footer clipping and ambiguous submenu links (2026-09-27).
|
||
|
||
Reconstructed stub — the original full entry was lost when `findings.md` was truncated to its section table (recovery, 2026-09-28); this entry is rebuilt from the section-table row and the `findings-index.csv` one-liner. Original root-cause detail is not recoverable.
|
||
|
||
**Status: PARTIAL (NTSC build + measured 320-wide footer variants; visual/user review owed)**: two-line help strip with reserved height, no default-scale title note, PD-style ellipses in place of "Open >".
|
||
|
||
## D390 — F10 pinned Back selection did not visibly change (2026-09-27).
|
||
|
||
Reconstructed stub — the original full entry was lost when `findings.md` was truncated to its section table (recovery, 2026-09-28); this entry is rebuilt from the section-table row and the `findings-index.csv` one-liner. Original root-cause detail is not recoverable.
|
||
|
||
**Status: PARTIAL (NTSC build + isolated page/tree probes; human visual check owed)**: Back now uses row-selected colour only when selected, title uses neutral ink; keep mixed-scope HUD under Gameplay, not Video.
|
||
|
||
## D391 — F10 click at glyph bottom could select next row (2026-09-27).
|
||
|
||
Reconstructed stub — the original full entry was lost when `findings.md` was truncated to its section table (recovery, 2026-09-28); this entry is rebuilt from the section-table row and the `findings-index.csv` one-liner. Original root-cause detail is not recoverable.
|
||
|
||
**Status: PARTIAL (pixel-bound measurement, NTSC build, isolated hit probes; real pointer UX owed)**: root header hint removed; hit bands follow rendered glyph baselines, leave a dead zone and reject clicks outside card.
|
||
|
||
## D392 — Train intro soldier hunched; hand intersects torso vs 1964 (2026-09-27).
|
||
|
||
Reconstructed stub — the original full entry was lost when `findings.md` was truncated to its section table (recovery, 2026-09-28); this entry is rebuilt from the section-table row and the `findings-index.csv` one-liner. Original root-cause detail is not recoverable.
|
||
|
||
**Status: FIXED (`#ifdef PORT` ABI/layout in `chraction.c`)**: firing-table float-offset alias across an 8-byte pointer union; Train aim-angle probe confirms correction; user visually verified the soldier looks fixed against 1964.
|
||
|
||
## D393 — Controller B/Y both crouch; no in-game use button (2026-09-27).
|
||
|
||
Reconstructed stub — the original full entry was lost when `findings.md` was truncated to its section table (recovery, 2026-09-28); this entry is rebuilt from the section-table row and the `findings-index.csv` one-liner. Original root-cause detail is not recoverable.
|
||
|
||
**Status: FIXED + user live-tested A use / X reload / Y weapon cycle**: gameplay pad face map conflated modern buttons and native N64 A/B. D394 supersedes B crouch and shoulder cycling to match the user-supplied Xbox 1.1 chart.
|
||
|
||
## D394 — Xbox 1.1 Jinx controller parity (2026-09-27).
|
||
|
||
Reconstructed stub — the original full entry was lost when `findings.md` was truncated to its section table (recovery, 2026-09-28); this entry is rebuilt from the section-table row and the `findings-index.csv` one-liner. Original root-cause detail is not recoverable.
|
||
|
||
**Status: PARTIAL (port 1.1 gameplay button roles user-tested; exact gadget-category parity and other selectable presets outstanding; RB HD toggle unavailable)**: user supplied the Xbox 1.1–1.4 control table; B is gadgets, stick clicks crouch, LB aim, RB HD toggle (not weapon cycling).
|
||
|
||
## D395 — Controller trapped in keyboard/mouse-only binding capture (2026-09-27).
|
||
|
||
Reconstructed stub — the original full entry was lost when `findings.md` was truncated to its section table (recovery, 2026-09-28); this entry is rebuilt from the section-table row and the `findings-index.csv` one-liner. Original root-cause detail is not recoverable.
|
||
|
||
**Status: PARTIAL (both options UIs block pad-initiated capture; pad B can cancel an active modal; NTSC build linked, controller-only retest owed)**: pad could enter the binding modal but could not finish or cancel without keyboard.
|
||
|
||
**Steam Deck retest (2026-09-27/28, Steam Controller):** a pad can go into the binding UI and is not left stuck — the original "trapped in capture" symptom is not reproduced. (This is distinct from the broader controller-rebinding *feature*, which still doesn't work — PR #109 / D383.)
|
||
|
||
|
||
## D396 (F10 pad controls) — done
|
||
Already completed and you confirmed it ("looks good"). It's linked in NTSC, documented, and awaiting the physical‑pad retest only. Nothing to do there.
|
||
|
||
## The interlaced / glitchy background — recorded as D397, but I deliberately did **not** ship a blind fix
|
||
|
||
I traced both the file‑select background and the intro gun‑barrel through the render path, and ruled out the obvious suspects rather than guess at a change:
|
||
|
||
- **D159 odd‑row pre‑swap** (`texSwapAltRowBytes`, the original "venetian‑blind" cause) — it's a full `#ifdef PORT` no‑op (D159, FIXED). Not the cause.
|
||
- **D183 pitch/stride shear (Family A)** — already disproven for `-level_36` (0/166 loads strided), and it's a *no‑op for this screen anyway*: `titleRenderFolderMenuBackgroundLines` issues 299 separate 440×1 `gDPLoadTextureBlock`s, each with `line == full == 440`, so the de‑stride never engages.
|
||
- **RLE header** — the gun‑barrel image is `rle_expand_8bit` of the `unknown2` blob; D55 already bswaps the BE w/h header. The 440×299 byte count is correct.
|
||
- **Row‑strip texrect math** — verified a 1‑texel‑tall quad samples v 0→1 across one screen row. Correct.
|
||
|
||
**The key new data point:** the intro gun‑barrel is a *first‑time* render, so the comb is present on first display, not only after a back‑out/re‑entry. That **refutes D182's "stale descriptor on re‑entry" hypothesis** — it's a *persistent* defect on these large 1:1 grayscale front‑end images.
|
||
|
||
I stopped at diagnosis (no code touched this session — the tree is still in the known‑good D396‑linked state) because the two remaining candidate causes can't be told apart without a live capture, and a blind fix here would be the kind of speculation the project rules are meant to prevent:
|
||
|
||
1. **Ground‑truth dithered content** — the gun‑barrel is a dithered 8‑bit halftone; at the port's 440×330→window nearest‑neighbour upscale the dots alias into a comb. *If the N64 CRT showed the same, this isn't a bug* and "fixing" it would diverge from ground truth.
|
||
2. **GL sampling / texture‑cache artifact** on the 299 distinct 440×1 I8 uploads (cache eviction or 1‑row mip handling).
|
||
|
||
## What a real fix needs (one focused session)
|
||
- One `GE_PCDUMP` capture of the **fresh** file‑select screen + the **intro** gun‑barrel (not a round‑trip), plus an N64/emulator screenshot of the same screens to settle cause 1 vs 2.
|
||
- A ROM ground‑truth decode of the `unknown2` gun‑barrel RLE image (the `ge007.u.2A4D50.usedby7F008DE4.bin` isn't in the repo — it's generated from the ROM).
|
||
- If cause 2, a `GE_DTEX` sweep of the 299 row imports (it already logs `STRIDED`/`OVER‑TALL`).
|
||
|
||
**One question that narrows it fast:** does the file‑select background look interlaced on the *very first* time you reach it (fresh boot / skip‑intro), or only after you've left and come back? The intro gun‑barrel already says "first display," but confirming it for file‑select too will tell us whether to keep it merged with D182 or split it as its own defect.
|
||
|
||
I logged all of this as **D397** (`docs/dev/findings.md`, index CSV, and the D182 cross‑ref in `GRAPHICS‑BACKLOG.md`) so the investigation doesn't get lost.
|
||
|
||
|
||
## D397 — file-select background + intro gun-barrel "interlaced / comb" look (2026-09-27)
|
||
|
||
**Status:** OPEN, re-scoped. New user observation; no code change this session (diagnosis below; a blind fix is not safe without the ground-truth capture that is blocked here).
|
||
|
||
**User report:** the SELECT FILE background image (the 007 gun-barrel / Bond-silhouette backdrop behind the file boxes) and the intro gun-barrel both have a "weird interlaced glitchy look" — a horizontal comb / line-scramble. This extends the long-standing D182(2) "comb / interlacing" symptom and adds a **second, independent instance** (the intro gun-barrel) that D182 never covered. Critically, the intro gun-barrel is a **first-time render** (not a back-out / re-entry), so the interlace is present on first display, not only after a round-trip. That refutes D182's "only after re-entry / stale tile descriptor" hypothesis for this symptom: it is a *persistent* defect on these large 1:1 grayscale front-end images.
|
||
|
||
**What was ruled out this session (static read, no live run):**
|
||
- **D159 odd-row pre-swap** — `texSwapAltRowBytes` (`src/game/image.c:2271`) is a full `#ifdef PORT` no-op (D159, FIXED). It is not the cause.
|
||
- **M-36 Family A line/pitch shear** — D183 DISPROVED it for the `-level_36` wall repro (0/166 texture loads strided). The D183 de-stride (`gfx_tex_pitch_fix`, on by default) is a **no-op for the row-strip** anyway: `titleRenderFolderMenuBackgroundLines` (`src/game/title2.c:24`) issues 299 *separate* 440×1 `gDPLoadTextureBlock`s (each advances `image += 440`), so every individual load has `line == full == 440` and the de-stride never engages.
|
||
- **RLE header byte-swap** — the gun-barrel background is `rle_expand_8bit` of the `unknown2` blob (`src/game/title.c:465`); D55 already bswaps the BE w/h header in place (`port/src/romdata.c:244`). `rle_expand_8bit` only consumes `w*h` (the product is swap-invariant), so the 440×299 byte count is correct.
|
||
- **Row-strip texture-rectangle math** — `gfx_dp_texture_rectangle` for a 1-texel-tall quad (ult=0, dtdy=1024) samples v 0→1 texel across one screen row; verified correct for `titleRenderFolderMenuBackgroundLines`.
|
||
|
||
**Remaining candidate causes (need a live capture to pick between):**
|
||
1. **Ground-truth content** — the gun-barrel image is a dithered 8-bit halftone; at the port's 440×330→window nearest-neighbour upscale the dither dots alias into a visible comb. If the N64 (CRT low-pass) showed the same, this is *not a bug* and "fixing" it would diverge from ground truth. **Must be confirmed against an N64/emulator screenshot of the same screen before any code change.**
|
||
2. **GL sampling / texture-cache artifact** on the 299 distinct 440×1 I8 uploads (cache key/eviction or 1-row mip handling). The `GE_DTEX` probe already logs `STRIDED` / `OVER-TALL` for exactly this path; a `GE_PCDUMP` screenshot across the file-select + intro gun-barrel frames is the next step.
|
||
3. **RC3 / shift-LOD density** (D167 family) or a wrong-texture bind, per the M-36 re-scope note.
|
||
|
||
**Owed (concrete next steps, single session):** (a) one `GE_PCDUMP` capture of the fresh file-select screen + the intro gun-barrel (not a round-trip); (b) ROM ground-truth decode of the `unknown2` gun-barrel RLE image (`tools_pc` extract of `assets/ge007.u.2A4D50.usedby7F008DE4.bin`) to settle cause 1 vs 2; (c) if cause 2, an `GE_DTEX` sweep of the 299 row imports to confirm cache/STRIDED behaviour. Cross-refs: D182(2) (same symptom, round-trip trigger), D176(b) (Family A, disproven), D159 (no-op'd), D183 (pitch de-stride, latent no-op here).
|
||
|
||
**Status (2026-09-28, per `scratch/d397_fs/HANDOFF-D397.md`) — OPEN, re-scoped; no fix landed.** An in-tree CI→I import-routing block in `gfx_pc.cpp` `import_texture` was tried and is **ineffective** (the D116 stale-tile-metadata hypothesis is REFUTED: D161 already routes `fmt==CI && palette==G_TT_NONE` to `import_texture_i8`; the GE_TEXI logs show `palfmt=0` on all 299 rows) — the block was removed 2026-09-28. Established: the data path is 100% clean (299 row strips upload byte-exact vs `scratch/d397_fs/gunbarrel.pgm`); the artifact is static; MSAA, mips, RAND_NOISE, 5-bit quant and texture-cache aliasing are ruled out. Leading hypotheses: **H1** — the file-select custom-CC LERP (`gDPSetCombineLERP(TEXEL0, PRIMITIVE, ENV_ALPHA, ...)`) uses ENV_ALPHA 0x14 as the mix factor; the port feeds it as `20/255 ≈ 0.078` (`G_CCMUX_ENV_ALPHA` input, `gfx_pc.cpp`), so a black texel renders ≈0.92×prim ≈ the measured 27/28/17 black-region census — the N64 RDP 5-bit coefficient scaling (check `rsp/graphics/gmain.s` and the PD port's CC engine for the same combine id) is the root-cause question; **H2** — the 6-of-9-row comb is a second, separate row-strip-geometry defect (non-integer quad y + NEAREST, or the 256-tri vertex-ring wrap between the two tris of a row); **H3** — part of the darkened look is authentic N64 (G_TC_FILT 4-bit intensity dither, unimplemented here). A `GE_D397SH` env-gated shader-formula probe (in `gfx_opengl.cpp`, ~30 lines) stays in-tree until the root cause lands, then is removed. Next steps per the handoff: Q1 correlate which of the 20 compiled shaders is the gun-barrel row-strip one, Q4 settle the LERP-factor scaling against gmain.s/PD, Q5 native 640×480 FBO capture to re-measure the comb period.
|
||
|
||
**Status (2026-09-27b, static read against the local `pd_port` checkout) — H1 REFUTED, H2 promoted to leading cause.** Cross-checked `G_CCMUX_ENV_ALPHA` handling against `C:\Users\james\Source\Repos\pd_port\port\fast3d\gfx_pc.cpp`: byte-identical formula (`tmp.r=tmp.g=tmp.b=rdp.env_color.a`, then `/255.0f` at the vertex-color pack site) — no special-cased coarser scaling in either port. There is no "5-bit RDP coefficient" quirk in the real color combiner; PRIM/ENV alpha are genuine 8-bit `gDPSetEnvColor` fields and the combiner's A/B/C/D math is full 8-bit on real hardware (the 5-bit-precision idea conflated the CC with an unrelated hardware unit). **H1 is very likely not the (or not the only) cause; the uniform darkening it predicts may simply be faithful/authored** — do not correct it without an N64/1964-GEPD reference frame.
|
||
|
||
Found a concrete geometric mechanism for **H2**, promoted to leading cause: `titleRenderFolderMenuBackgroundLines` (`src/game/title2.c:24`) emits `lry - uly == 3` (U10.2 units) per row — a **0.75-native-pixel-tall** rect, not 1.0 — via the universal N64 `((y+1)<<2)-1` idiom. That idiom is only exact on real hardware because the RDP's scan converter *floors* the fixed-point Y to an integer scanline before testing coverage. `gfx_draw_rectangle` (`gfx_pc.cpp:3443`) does not replicate that: it maps the U10.2 corners straight to continuous NDC floats and rasterizes with the GPU's ordinary continuous coverage rules. A sub-1-native-pixel-tall rect stacked 299 times (each offset exactly 1.0 native px from the last) does not reliably rasterize as "the one scanline the idiom intended" under continuous rasterization — which of the two neighbouring device rows a given source row's fractional edges land in drifts as the stack accumulates, worsened by any non-integer native→window scale (`gfx_adjust_viewport_or_scissor`'s `ratioY`). This is a rasterization property of the geometry, not stale state, so it naturally explains the first-render (non-re-entry) trigger that refuted D182's hypothesis for this instance. Full mechanism + tell for future occurrences: `docs/porting-notes.md` §D15. **Not yet done: a live capture to confirm the comb period tracks `ratioY`** (predicted: comb period should change if the window/render resolution changes, at fixed native resolution) — this is the concrete falsifiable test before attempting a fix. No code changed this pass (diagnosis only).
|
||
|
||
|
||
**2026-09-28 progress (this session — D397SH/D397SHM probes, env-gated in `gfx_opengl.cpp`/`gfx_pc.cpp`):**
|
||
- **Q1 answered:** the gun-barrel row-strip shader is `id0=0000000010001218`, formula `texel = mix(vInput1, texVal0.rgb, vInput2)`, with (D397SHM) **vInput1 = G_CCMUX_PRIMITIVE, vInput2 = G_CCMUX_ENV_ALPHA**, so the LERP (front.c:2596 `gDPSetCombineLERP(TEXEL0, PRIMITIVE, ENV_ALPHA, PRIMITIVE, …)`) renders `out = PRIM + (TEXEL − PRIM)·(envA/255)`; black texels → `prim·(1 − envA/255)`. The 5-bit LERP coef-C in the packed combine word is 0x14 (= 20, i.e. the env-alpha value travels in the coef slot).
|
||
- **H1's "1/255 vs N64 1/32 coefficient" variant is effectively ruled out:** the PD ground-truth port feeds the *same* `env_color.a / 255.0` factor for the identical ENV_ALPHA LERP input (`pd_port/port/fast3d/gfx_pc.cpp` ~1548→1580; same `append_formula`/`mix` generation, same 5-bit-coef packing at :1949). The GE port matches the reference implementation, so the LERP factor is **not** a port-side scaling bug. (If the *N64 hardware* itself scaled the 5-bit coefficient differently, that would be a shared PD/GE port interpretation, not a GE regression — and it cannot explain a *periodic* pattern regardless.)
|
||
- **The visible defect is periodic (the 6-of-9-row comb), which a uniform LERP factor cannot produce** → the comb is **H2 (row-strip geometry / vertex-ring)**, not H1. The gray census (27/28/17) is the LERP factor applied on top; the *lines* are H2.
|
||
- **Next steps (next session):** (1) extend the probe / add an env-gated counter in `gfx_flush()` to test Q3 (does a 256-tri ring flush land between the two tris of a row strip, splitting them across draw calls with attribute-state drops?); (2) Q5 native 640×480 FBO capture to re-measure the comb period in native rows (expect ≈3 if it's the 40px-window period scaled); (3) H3 control — an N64/PD screenshot of the equivalent screen to bound how much of the darkened look is authentic.
|
||
- **Cleanup owed before merge:** remove the `GE_D397SH` probes in `gfx_opengl.cpp` (formula dump) and `gfx_pc.cpp` (D397SHM mapping dump) — **done 2026-09-28** (both blocks removed; see below).
|
||
|
||
**RESOLVED (2026-09-28) — H2 confirmed; fix landed in `gfx_draw_rectangle`.**
|
||
- **The falsifiable 3-scale test CONFIRMS H2.** Captured the file-select background at 640×480 (1.0×), 1024×768 (1.6×) and 1280×960 (2.0×) via `GE_PCDUMP` and measured the comb period in *native* rows (window-period ÷ scale, on a clean background band, detrended diff-autocorrelation): **s2 (1.6×) P_win=5 → P_nat≈3.1; s3 (2.0×) P_win=6 → P_nat=3.0 (two independent bands agree); s1 (1.0×) too fine to resolve (near-Nyquist, consistent).** A *fixed native period* (~3 rows) is exactly the beat of 0.75-px strips stacked at a 1.0-px pitch (0.75 = 3/4 → four strips span three native rows → a 3:4 beat). A period that scales with the window while staying fixed in native rows is the signature of a **fixed-point scan-converter flooring** artifact — not a LERP/CC factor (which would be scale-invariant and non-periodic). H2 confirmed.
|
||
- **Fix (port-layer, surgical, `gfx_pc.cpp` `gfx_draw_rectangle`):** after the sub-pixel offset is applied, floor the U10.2 top edge to its scanline and the bottom edge to the *last covered* scanline — the RDP scanline range `[top_scan, bot_scan+1]` — before rasterizing: `top_scan = uly>>2`, `bot_scan = (lry-1)>>2`, then `uly = top_scan<<2`, `lry = (bot_scan+1)<<2`. **No-op for whole-pixel rects** (their edges are already `4k`/`4k−1`, and the N64 `(<<2)−1` bottom idiom already yields `bot_scan` = the intended last row), so only sub-pixel-tall strips change — exactly the gun-barrel / file-select row strips. Mechanism + future-occurrence tell: `docs/porting-notes.md` §D15.
|
||
- **Verified by playtest:** the gun-barrel intro + file-select gradient render clean — the interlaced/comb look is gone (user-confirmed on the v0.4.0 playtest). `GE_D397SH`/`D397SHM` probes removed.
|
||
- **H3 note:** the *overall* darkened look (gray census 27/28/17) is the faithful LERP (H1, byte-identical to the PD port) and/or authored — **not** a defect. Only the periodic comb was the bug, and it is now fixed; do not "correct" the darkening.
|
||
|
||
## D398 — PC Options label: IMG_DOT texture replaces the D348 procedural slider glyph (2026-09-27)
|
||
|
||
User request: drop the D348 mini-slider glyph in favour of a real sprite from
|
||
the existing `mainfolderimages` table — the option-1 path of the D348
|
||
discussion, zero new art. Chose `IMG_DOT` (the table's sixth entry;
|
||
`assets/oddtextures.h:57-64`, entry `{IMAGE_DOT, 0x10, 0x10, 0, G_IM_FMT_I,
|
||
G_IM_SIZ_8b, ...}` at `assets/oddtextures.c:657`).
|
||
|
||
Implementation (port-only, `optionsFileSelectLabel()` in `frontoptions.c`):
|
||
the D348 `microcode_constructor_related_to_menus` block is replaced with the
|
||
exact Copy/Erase pattern (`src/game/front.c:2816-2848`):
|
||
`texSelect(&gdl, mainfolderimages + IMG_DOT, 4, 0, 0)` +
|
||
`display_image_at_position(&gdl, &dotpos, &dothalf, 16, 16, 0, 0, 1, r, g, b,
|
||
0xFF, 0, 0)`. Two signature gotchas: `texSelect` (`game/othermodemicrocode.h`)
|
||
*and* `display_image_at_position` (`game/bondwalk2.h:6`) both return `void`
|
||
and advance the DL in place through `Gfx **` — front.c never assigns their
|
||
return values either, so neither is assigned here. IMG_DOT is already in
|
||
ROM's Globalimagetable (D39 table rebase covers the whole `s_mainfolderimages`
|
||
array), so no asset-pipeline work. Layout: dot centred on `LABEL_CY`, right
|
||
edge keeps the old glyph's 2px margin before the label (`DOT_MARGIN`), so it
|
||
tracks the dynamic `x` (JP's wider Erase pushes it along) and text position is
|
||
unchanged; `GLYPH_W` is superseded by `DOT_W` (16) / `DOT_MARGIN` (2) and the
|
||
hot region's left edge extends to the dot's left edge. I8 texels are
|
||
multiplied by vertex RGB, so the hot state passes the label's gold per channel
|
||
(0xEB/0xD8/0x79, the same tinting trick front.c:3829 uses for its red I8
|
||
tint), white otherwise. No texture-state leak: front.c re-binds its own
|
||
Copy/Erase/Select icons immediately after this hook returns. No new strings
|
||
(issue #87).
|
||
|
||
**Verification:** `./build-pc.sh ntsc-final` clean (first pass failed on the
|
||
`gdl = display_image_at_position(...)` assignment — void return; fixed).
|
||
Visual check owed: the dot's actual appearance is the ROM's `IMAGE_DOT`
|
||
texture (D348's audit noted nothing in the game art reads as "settings"; the
|
||
dot is the user-chosen compromise, not a settings icon).
|
||
|
||
|
||
## D399 — File-select bottom row shifted 15px left as a block (user sign-off 2026-09-27)
|
||
|
||
User: the D398 layout "is okay but a bit cramped" — asked whether the whole
|
||
row, **including the "SELECT FILE" text**, could move left. Answer: yes —
|
||
every x on that bar is a plain float constant, so it's the same mechanical
|
||
D343 class of port-guarded position tweak (no logic, no new assets).
|
||
|
||
Shift applied (all −15 logical px, NTSC; JP/PAL share the same PORT
|
||
constants):
|
||
- `selectpos.f[0]` 110 → 95 — the `IMAGE_SELECTFILE` 122×18 title, which is
|
||
NOT a 3D/asset element: it's `texSelect` + `display_image_at_position`
|
||
at `selectpos` in `constructor_menu05_fileselect` (src/game/front.c, ~2850).
|
||
Wrapped in `#ifdef PORT` (new; N64 keeps 110).
|
||
- Copy icon 200 → 185, Copy text 222 → 207 (D343 PORT block, values updated).
|
||
- Erase icon 282 → 267, Erase text 304 → 289 (same block).
|
||
- `LABEL_X` 358 → 343 in `port/src/frontoptions.c`; the dot's dynamic
|
||
`x = folder_option_ERASE_bound.right + LABEL_GAP` tracks the new Erase
|
||
position automatically (323 + 20 = 343 = LABEL_X, so the clamp is a no-op
|
||
on NTSC).
|
||
|
||
Hit bounds (`folder_option_COPY/ERASE_bound`) recompute from the shifted
|
||
positions, so the clickable regions move with the art. Nothing else on the
|
||
screen references these coordinates.
|
||
|
||
**Verification:** clean build; PCDUMP frame 195 column profile: SELECT FILE
|
||
text now 34..156, Copy icon 169..201, Erase text ends ~323, dot ~341..357
|
||
(no overlap, 18px clearance), label ends before the right edge. **Owed:
|
||
by-eye check via `scratch\playtest_v040.bat`.**
|
||
|
||
## D400 — File-select "PC Options" label: icon removed, text-only; `textMeasure` arg-swap and 7px vertical misalignment fixed (user sign-off 2026-09-27).
|
||
|
||
D398's ROM-native dot texture next to "PC Options" was removed on user sign-off
|
||
("most straightforward, no nonsense" design). The label is now text-only, same
|
||
size/look and highlight colour as the Copy/Erase labels: `optionsFileSelectLabel()`
|
||
(`port/src/frontoptions.c`) is one `textMeasure` + one `textRender` with the
|
||
game's own packed gold `0xEBD879FF` constant — no per-channel unpack, so the
|
||
D398 purple-hover bug class (`gDPSetEnvColor` AARRGGBB vs RRGGBBAA channel
|
||
unpack) is gone by construction. Removed includes: `image_bank.h`,
|
||
`othermodemicrocode.h`, `bondwalk2.h`, `assets/oddtextures.h`; `DOT_W` /
|
||
`DOT_MARGIN` gone.
|
||
|
||
Two measurement bugs found and fixed while landing D400:
|
||
|
||
1. **`textMeasure` argument swap.** The signature is
|
||
`textMeasure(s32 *textheight, s32 *textwidth, ...)` — height FIRST
|
||
(`textrelated.h:21`, impl `textrelated.c:766-843`). The first D400 rewrite
|
||
passed `(&w, &h)`, drawing the label ~35px above the bar and transposing the
|
||
hot/click band. Fixed to `(&h, &w)`.
|
||
2. **7px vertical misalignment vs Copy/Erase.** `textMeasure` only adds to
|
||
`*textheight` on a `\n` character; Copy/Erase's localized strings end in a
|
||
newline → h=14 → y = 285 - 7 = 278 (front.c:2778 does exactly this). The
|
||
bare "PC Options" measures h=0 → y=285 → tops 7px low. Fixed by measuring
|
||
height with `kLabelNL` ("PC Options\n") while rendering plain `kLabel`.
|
||
|
||
Constants: `LABEL_GAP` 20→28, `LABEL_X` 343→351 (plain-word gap after Erase; no
|
||
glyph to clear). Hitbox: `cursor_h [x-4, x+w+4]`, `cursor_v [y-4, y+h+4]` =
|
||
[274, 296] at h=14. PCDUMP frame 195 (NTSC): "PC Options" logical x 353..426,
|
||
top 279.8 vs Copy/Erase 279.3 (aligned); Erase ends 323 → 31px clearance; 14px
|
||
right margin (canvas 440). **Owed: by-eye check via `scratch\playtest_v040.bat`
|
||
(idle legibility, gold hover not purple, click opens the F10 overlay, vertical
|
||
alignment with Copy/Erase).** Cross-ref D348 (glyph lineage), D398
|
||
(superseded icon approach), D399 (bottom-row 15px shift).
|
||
|
||
## D401 — Rumble Pak → real gamepad haptics: `CONT_CARD_ON` gate + `osPfs*`/`osMotor*` shims rewired to `SDL_GameControllerRumble`, global `Input.RumbleScale` slider (2026-09-27/28).
|
||
|
||
Implements **D224 (M-87)** per `docs/dev/RUMBLE-PLAN.md` — the N64 Rumble
|
||
Pak state machine (`src/joy.c` `joyRumblePakInit`/`joyRumblePakTick`/Start/Stop,
|
||
call sites `gunfire.c:3284/3288`, `bondview2.c:9722/9726`, `lv.c:1688`,
|
||
`sched.c:272`) now drives real SDL gamepad haptics. **Port layer only: zero
|
||
`src/` edits**, so rule 2 / the ABI exception are not implicated.
|
||
|
||
**The two M-87 addendum blockers** (both `port/src/libultra.c`):
|
||
|
||
1. `contSnapshotFromKeyboard` left `g_contStatus[i].status = 0`, so
|
||
`joyRumblePakInit` (`src/joy.c:178` — requires `status & CONT_CARD_ON`) never
|
||
ran and `joyRumblePak` was never populated. Now
|
||
`status = (connected && inputRumbleSupported(i)) ? CONT_CARD_ON : 0` —
|
||
gated on real capability so non-rumble pads never enter the rumble path
|
||
(cleaner than PD's unconditional bit; `joyRumblePakInit` is the real gate).
|
||
`type` stays `CONT_TYPE_NORMAL` (includes `CONT_JOYPORT`; PD's
|
||
`CONT_ABSOLUTE` would have broken the init gate).
|
||
2. `osPfsInit` stubbed `PFS_ERR_NOPACK`, so `osMotorInit` was never
|
||
attempted (`src/joy.c:186-190` proceeds only on `PFS_ERR_ID_FATAL` or
|
||
`PFS_ERR_DEVICE`). Now returns `PFS_ERR_DEVICE` ("Rumble Pak present, not a
|
||
Memory Pak") for rumble-capable channels, `PFS_ERR_NOPACK` otherwise.
|
||
|
||
**`port/src/input.c`** (flat statics, GE style — not PD's `padsCfg[]`):
|
||
`padRumbleOn[MAX_PADS]` + a single global `gRumbleScale` (default 0.5) —
|
||
the per-pad `padRumbleScale[]` array was superseded 2026-09-28 by the
|
||
global slider (see "Menu row" below). Detection in
|
||
`inputOpenPads`: `SDL_GameControllerHasRumble` on SDL ≥ 2.0.18, else
|
||
`SDL_JoystickIsHaptic` with the known-type Windows no-haptic fallback; zeroed
|
||
on close in `inputDestroy`/`inputRescanPads`. PS4/PS5 HIDAPI rumble hints in
|
||
`inputInit`. New API: `inputRumbleSupported(idx)` (bounds-checked against
|
||
`MAX_PADS` — JPN `_HW_VERSION_1` has `MAXCONTROLLERS == 6`, so out-of-range
|
||
channels report unsupported and stay silent), `inputRumble(idx, strength,
|
||
time)` (no-op on `gRumbleScale <= 0`; scales strength, `*65535` / `*1000`
|
||
units, `SDL_GameControllerRumble`; `(0,0)` stops), global
|
||
`inputRumbleGetScale(void)`/`inputRumbleSetScale(f32)`. Config: a single
|
||
`Input.RumbleScale` (range 0..1, default 0.5) registered in
|
||
`inputConfigInit`; the four per-player `Input.Player%d.RumbleScale` keys
|
||
were dropped in the same-day menu-shape decision (stale keys in an
|
||
existing ini log four `unknown key` `LOG_NOTE` lines and self-clean on the
|
||
next `configSave`). `keyboard`/mouse-only play stays silent: no pad open
|
||
⇒ `pads[idx]` NULL ⇒ `inputRumble` no-ops.
|
||
|
||
**`port/src/libultra.c`** shims: `osPfsInit` as above; `osPfsIsPlug` sets its
|
||
pattern bits for the capable channels over `MAXCONTROLLERS` (completeness
|
||
only — **no GE game call sites**); `osMotorInit` stores `pfs->queue`,
|
||
`pfs->channel`, `pfs->activebank = 0xff` (PD `osMotorProbe` shape) and returns
|
||
0 for capable channels, `PFS_ERR_NOPACK` otherwise (no `PFS_MOTOR_INITIALIZED`
|
||
store — not defined in GE headers and `joy.c` never reads `pfs->status`);
|
||
`osMotorStart` → `inputRumble(channel, 1.0f, 5.0f)` (one generous window;
|
||
the game's `joyRumblePakTimer60` normally stops it, PD-style), `osMotorStop` →
|
||
`inputRumble(channel, 0.0f, 0.0f)`; both return 0 for non-NULL `pfs`.
|
||
|
||
**Verification.** `./build-pc.sh ntsc-final` clean (no new undefined/duplicate
|
||
symbols; `inputRumble*` defined once in input.c, declared in input.h).
|
||
**Real-pad haptics confirmed working by the user (2026-09-28)** — the gun-
|
||
impact rumble path is live on a real controller. The `RumbleScale=0`
|
||
silence check remains unverified (it is the trivial `gRumbleScale <= 0`
|
||
early-out in `inputRumble`). Same-session environment fix: the
|
||
recurring `Cannot create temporary file in C:\Windows\: Permission denied`
|
||
link failure is now self-healed by `build-pc.sh` (native-TMP probe →
|
||
cmake+ninja re-run under PowerShell with `TMP`/`TEMP` set natively; see the
|
||
AGENTS.md "Build" note), and `cannot open output file ... Permission denied`
|
||
is documented as the running-`.exe`-lock case (close the game before
|
||
rebuilding).
|
||
|
||
**Menu row (plan item 3, 2026-09-28).** The deferred options-menu slider
|
||
landed the day after the user confirmed the haptics, as a **single global
|
||
row** (user decision — "making the rumble menu just 1 player"):
|
||
`port/src/optionsoverlay.c` gains one `ROW_SLIDER` row — `Rumble strength`
|
||
on `Input.RumbleScale` (0.05 detents; the generic `CONFIG_OPT_FLOAT`
|
||
value/display path, shown `%.2f`) — placed in the INPUT section after the
|
||
other controller rows, with a `kResetDefaults` entry (0.5, the
|
||
`gRumbleScale` C initializer) so INPUT's "Reset to defaults" covers it.
|
||
The originally-coded four per-player rows (`Rumble (Player 1..4)` on
|
||
`Input.Player%d.RumbleScale`) were replaced before first compile; the
|
||
four ini keys are dropped rather than kept as hidden overrides (stale ones
|
||
self-clean on the next save, and a per-pad override would be a no-op since
|
||
`configSave` rewrites all registered keys — see `RUMBLE-PLAN.md` item 3).
|
||
Both the F10 overlay and the front options screen walk the shared `rows[]`
|
||
(`frontoptions.c` via the `optionsRow*` accessors), so the row appears in
|
||
both. A "row disabled when no pad is rumble-capable" state is NOT
|
||
implemented — the overlay has no generic row-disabled mechanism; the row is
|
||
inert (no-op) on unsupported/absent pads, and the disabled state is deferred
|
||
with the per-pad tuning row (`QOL-INVENTORY.md`), which will need the same
|
||
mechanism for deadzone/swap rows.
|
||
|
||
**Steam Deck confirmation (2026-09-27/28):** rumble works on the Deck's Steam Controller — `SDL_GameControllerRumble` fires on SteamOS/Steam Input with `Input.RumbleScale > 0` (user: "rumble works").
|
||
|
||
|
||
## D402 — Linux/Steam-Deck build broken: `bondconstants.h`-first TUs re-enter `bondtypes.h` from a half-parsed `bondconstants.h`, and `video.c` loses the `_SHIFTL` gbi macro (found 2026-09-28, building `release/v0.4.0` on Ubuntu 24.04 for the v0.4.0 Deck pre-release).
|
||
|
||
**Context.** Every v0.4.0 wave item since the modern-options work was validated on Windows/MinGW only. The first clean Linux (glibc GCC) compile of `release/v0.4.0` surfaced two port-layer build breaks, both include-resolution issues that MinGW happened to tolerate.
|
||
|
||
**Break 1 — half-parsed `bondconstants.h` (header include cycle).**
|
||
`src/bondconstants.h` includes `<ultra64.h>` at its **top** (line 16), before it defines `ITEM_IDS`/`ACT_TYPE`/`DIFFICULTY`/`TICKOP`/… (those land from ~line 4100 on). On PC, `ultra64.h` → `port/shim/PR/ucode.h` → `pc_protos.h` → `bondtypes.h` (whose own line-28 `#include <bondconstants.h>` normally pulls the *complete* constants header). A TU that includes `<bondconstants.h>` **first** — `port/src/optionsoverlay.c` (and any sibling port TU with the same shape) — sets `src/bondconstants.h`'s include-guard, then that header's top `#include <ultra64.h>` re-enters the `pc_protos.h`→`bondtypes.h`→`bondconstants.h` chain while `src/bondconstants.h` is still mid-parse (guard already set → body skipped). `src/bondtypes.h` then needs `ITEM_IDS`/`ACT_TYPE`/`CHRFLAG`/`DROPTYPE`/`AMMOTYPE`/`DIFFICULTY`/`MPSCENARIOS`/… which are not yet defined:
|
||
```
|
||
src/bondtypes.h:1722:9: error: unknown type name ‘ITEM_IDS’
|
||
… :2445 ACT_TYPE, :2457 CHRFLAG, :2621 DROPTYPE, :3230 AMMOTYPE, :3485 DIFFICULTY
|
||
port/include/pc_protos.h:219 ITEM_IDS, :260 MPSCENARIOS, :359 TICKOP
|
||
```
|
||
`-H` on `optionsoverlay.c` shows the exact cycle: `. shim/bondconstants.h → .. src/bondconstants.h → … ultra64.h → pc_protos.h → shim/bondtypes.h → chrobjdata.h → shim/bondconstants.h (guard set, body skipped) → src/bondtypes.h:1722` error. The natural `ultra64.h`-first path (the common N64 TU shape) is unaffected: `pc_protos.h`→`bondtypes.h`→`bondconstants.h` reaches `src/bondconstants.h` *fresh* and fully parses it before any type is needed.
|
||
|
||
**Break 2 — `video.c` `_SHIFTL` undefined (gbi macro without mbi.h).**
|
||
`port/src/video.c` includes `<PR/gbi.h>` (whose `gDP*` DL macros expand to `_SHIFTL(...)`/`_SHIFTR(...)`) but never reaches `PR/mbi.h` (where `_SHIFTL` is `#define`d) on Linux — no header in its include chain pulls it in, so the `gDPSetCombineLERP` in `portCrosshairApplyTintCombine` (crosshair tint, M2 wave D373/D379) compiles as an *implicit `_SHIFTL()` function call* and fails at **link**: `undefined reference to '_SHIFTL'` from `video.c:131`. `port/src/optionsoverlay.c` already worked around this locally (it defines `_SHIFTL`/`_SHIFTR` before its `gbi.h` include); `video.c` did not. MinGW reaches `mbi.h` through a different include path for these TUs, so it linked; glibc does not.
|
||
|
||
**Fix (port-layer only, zero `src/` edits; D402):**
|
||
1. `port/shim/bondconstants.h` (PC branch): `#define _PORT_DEFER_PC_PROTOS`, `#include <ultra64.h>`, `#undef` — i.e. pre-parse the PR base headers *first* while telling `PR/ucode.h` to **defer** `pc_protos.h` for that pass; then `#include "src/bondconstants.h"` (completes all the constants; its own top `ultra64.h` is now a no-op); then `#include "pc_protos.h"` so the D38 prototypes are still pulled in exactly once, *after* every type is defined.
|
||
2. `port/shim/PR/ucode.h`: wrap `#include "pc_protos.h"` in `#if !defined(_PORT_DEFER_PC_PROTOS)`, so it is skipped only during the bondconstants-defer pass and runs normally for every other `ultra64.h` consumer.
|
||
3. `port/shim/PR/gbi.h` (PC branch): after `#define Gfx Gfx_le`, `#ifndef _SHIFTL / #define _SHIFTL(v,s,w)…`, `#ifndef _SHIFTR / #define _SHIFTR(v,s,w)…` (verbatim from `mbi.h`), guarded so a TU that also reaches `mbi.h` keeps `mbi.h`'s definitions. Fixes `video.c` and protects any future `<PR/gbi.h>`-only TU.
|
||
|
||
**Verified (Ubuntu 24.04 / GCC, WSL):** full `cmake -S . -B build-pc -DROMID=ntsc-final && cmake --build build-pc -j16` of the freshly cloned branch compiles and **links** `build-pc/ge007.x86_64` (ELF x86-64, links `libSDL2-2.0`) with zero errors after the fix; the pre-fix build fails exactly on the two errors above. Windows/MinGW build unaffected (the `#ifndef`/`#if !defined` guards make both fixes no-ops where the macros are already provided).
|
||
|
||
**Landed** (see commit). **Lever:** this is the first clean Linux compile of the v0.4.0 branch; CI's `linux-build` job should now pass. The Deck pre-release test bundle (exe + bundled `libSDL2`, `rpath=$ORIGIN`) is produced from this build — note `tools_pc/bundle-linux.sh` still requires the CI-frozen `prepare-assets/ge007-convert` for the *drop-in-ROM* first-run path; a Deck running from an existing `data/` (ROM + pre-generated sidecars) never invokes it.
|
||
## D403 — Deck v0.4.0-pre test: Nintendo logo + copyright/legal page render broken, broader graphics "not reflecting new updates" — **test artifact (stale sidecars seeded into the Deck bundle), not a code regression** (Linux/SteamOS; log only per user, 2026-09-27)
|
||
|
||
**Reported (user, Steam Deck):** on the freshly built `release/v0.4.0` Linux/Deck pre-release, the **Nintendo logo is broken** and the **copyright/legal page (front_end `PnintendologoZ`/`PgoldeneyelogoZ`/`PlegalpageZ` front-end models) doesn't look right**; more broadly "this doesn't seem to reflect a lot of the new updates for some graphics." Meanwhile the **D397 gun-barrel / file-select fix clearly works**, and "other than that game seems alright on Steam Deck." Log-only requested (no fix this pass).
|
||
|
||
**Root cause (confirmed by commit dates, not code):** the Deck test bundle was **seeded with `pcmodels-ntsc-final` / `pccg-ntsc-final` sidecars copied from the `0.3.0-local.20260915c` install, whose `data/` is dated 2026-09-15** (the workflow's "reuse prepared assets" step). Those sidecars predate two converter-baked fixes:
|
||
- **D75 / M-197 — the embedded-image-blob raw-pixel copy in `tools_pc/d43_emit.py`** (the fix that made the Nintendo/GoldenEye logo + legal/copyright page stop drawing black), which landed in **commit `425237f9` on 2026-09-21** (21-line d43_emit change). Sidecars generated before that carry the **old zeroed-blob layout** → the front-end logo/legal textures come out broken.
|
||
- **D303 — model-converter muzzle-flash vertex copy** (`d061ca3d`, 2026-09-26) and any other converter-side fixes after 09-15, likewise baked into the sidecar image.
|
||
|
||
So the broken logo + copyright page + "a lot of graphics not updated" are **stale-sidecar artifacts**, exactly the front-end/`P*` items the D75/M-197 + D303 converter fixes address. The in-exe **fast3d** fixes (D397 gun-barrel/file-select comb, D341 blood drip, D226 HUD scale, …) are compiled into the current binary and are *not* sidecar-dependent, which is precisely why the user saw D397 working on the Deck while the front-end graphics looked wrong.
|
||
|
||
**Not a code regression.** The same `release/v0.4.0` code renders the logo correctly on Windows with freshly-regenerated sidecars (D75 user-confirmed 2026-09-21). The Deck discrepancy is a **test-harness artifact of seeding the bundle with 0.3.0-era sidecars**.
|
||
|
||
**Disambiguation (performed 2026-09-27) → RESOLVED:** `data/pcmodels-ntsc-final` + `data/pccg-ntsc-final` were regenerated for the Deck from the **current** Python-source toolchain — `tools_pc/dist/prepare-assets/prepare-assets.py` driving `d43_emit.py` (models, carries the M-197 blob copy) + `d69_emit.py` (bg/stan) + `d88_emit.py --regen` (stage setup), all `ALL CHECKS PASSED` on the NTSC ROM — and the two sidecar dirs were pushed to the Deck's bundle (sha256-verified), replacing the stale 0.3.0-era ones while keeping the user's `ge007.eep` save + `ge007.ini`. **User re-checked the front end on the Deck and confirmed the Nintendo/GoldenEye logo + copyright/legal page now render correctly** — D403 is closed. It was the stale-sidecar test artifact all along; no code was ever at fault.
|
||
|
||
**Cross-refs:** D75 (M-197 logo/legal blob fix), D303 (converter muzzle-vertex), D397 (fast3d in-exe, works on Deck), D402 (Linux build that produced this bundle), `docs/dev/notes/REMOTE-DECK-DEBUG-WORKFLOW.md` (the asset-reuse step that seeded the stale sidecars).
|
||
## D404 — "Aim style: Centred (PC)" has no effect when aiming with a gamepad; the row's own doc comment over-promises (Steam Deck, 2026-09-27; log-only per user)
|
||
|
||
**Reported (user, Steam Deck):** setting **Aim style → "Centred (PC)"** (`Input.AimMode = 1`, D333/D337) does nothing while aiming with a controller — the pad keeps the N64-style stick look. Log-only requested (no fix this pass).
|
||
|
||
**Root cause (code, no `src/` change involved):** the `AIMMODE_CENTRED` handling exists **only** in the mouse RMB/Aim-button-held branch of the aim poll — `input.c:1783`, `hipDirectCompute(edx * lookDtScale, dyLook * lookDtScale)`. That path is fed **only while the mouse is the active aim device** (D337 device-aware integration: "the hook feeds the mouse integration only while the mouse is active"). A pad's aim is the **native right-stick look** path, which never consults `aimMode` (grep: `aimModeGet()` is used only at `input.c:1783` and the `pdMouseAim`/`aimLegacyGepd` helper at `input.c:480`). There is no stick-input "centred" variant — Centred (PC) is defined by feeding *mouse deltas* straight to the camera with a fixed centre crosshair, so it is structurally a **mouse-only** model. On a pad, `AimMode = 1` is therefore inert.
|
||
|
||
**Contradiction with the row's own documentation:** the `Input.AimMode` row comment in `port/src/optionsoverlay.c` (line ~205) states *"CENTRED (PC) = opt-in FPS-style aim (#104), not N64. Applies to **every aim input (RMB, Shift, Q/L, pad trigger, Toggle mode)**."* The "pad trigger" applicability is not honored by the implementation. Related: D338's `Input.AimRange` is `hiddenIfOn=Input.AimMode` (Centred hides it, "the crosshair doesn't travel"), which is correct for the mouse path but misleading on a pad, where the inert Centred mode still leaves the N64 crosshair travelling.
|
||
|
||
**Not a code regression on the port's stated model** — Centred (PC) is a mouse-aim style and a pad always uses its native look stick. The defect is the **gap between the F10 row's documented scope ("every aim input … pad trigger") and its actual mouse-only behavior**, which reads as a dead/no-op option to a pad user.
|
||
|
||
**Resolution (2026-09-28, same day -- superseded from clarification to implementation):** morning pass closed this as a communication bug (relabelling the row "(mouse)", on the reasoning "a gamepad's right stick has no crosshair to travel, so the pad's continuous natural-pitch look already *is* the centred model"). User challenge that afternoon: *why not just let the toggle work on a controller?* On re-reading the code that reasoning was wrong: the pad's natural-pitch path sets `sx/sy` to stick **deflection** -- the same unit the N64-mouse path and the keyboard turn keys feed the game's crosshair integrator. A flick *does* travel the crosshair on a pad; edge-scroll + spring-back merely make a hold feel like direct rotation. The pad was therefore always in the **N64** aim model, and a real CENTRED pad mode was both well-defined and missing. **Implemented (port-layer only, no `src/` edits):** `padDirectCompute()` in `input.c` -- the pad twin of `hipDirectCompute`'s CENTRED branch, same safety gates (dead/watch/pause/`gameScriptedCameraActive`), minus the `mouseDirectLook`/`mouseGrabbed` gate (meaningless for a pad); full deflection (`STICK_MAX=80`, the established per-poll look unit) maps to `PAD_DIRECT_FULL_DEG` (2.0 deg/poll, fov-scaled like the mouse path) -- a first-pass feel estimate. **Revision (2026-09-28, user Deck A/B): "sensitivity goes down dramatically" in Centred mode** -- the first pass was a *linear* 2.0 deg/poll on a `fov/GEPD_BASE_FOV` (90) base, while the N64 path is the game's *quadratic* natural-turn curve `v=sign(v)\u00b7clamp(defl/70,\u00b11)\u00b2 \u00b7 (fov/60) \u00b7 3.5 deg/poll` (bondview2.c canNaturalTurn/canNaturalPitch) -- ~2.6\u00d7 slower at full stick. `padDirectCompute` now reproduces that curve verbatim (yaw and pitch, incl. pitch's `-v\u00b7(fov/60)` sign), so full-stick Centred turns at exactly the N64 path's top rate; the existing per-axis look-sensitivity rows still shape the deflection beforehand. The natural-pitch pad branch now routes `AIMMODE_CENTRED` through it (crosshair pinned at centre, no look stick emitted; declines fall back to the legacy stick emission, as the mouse branch does), and `AIMMODE_N64` keeps today's deflection behaviour -- which is also the default, so **no pad default changes**. Labels reverted to device-neutral ("Aim style" / "Aim range"), keeping the "(controller)" suffixes on the pad look-sens/smoothing rows from the same-day sweep. **Open item: Steam-Deck A/B of the new pad CENTRED feel vs N64 edge-scroll rate -- done 2026-09-28: centred confirmed good, sensitivity dramatically low; fixed by adopting the N64 path's verbatim quadratic curve + `fov/60` base (above); retest owed on the refreshed build.**
|
||
|
||
**Cross-refs:** D333 (#104 centred aim origin), D337 (device-aware aim integration; the "mouse-active only" gate), D338 (`Input.AimRange` hidden-in-Centred), D403 (same Deck session's other observations), `input.c:1783` / `optionsoverlay.c` Aim-style row.
|
||
|
||
|
||
## D405 — fast3d/OpenGL MSAA clamp + default-framebuffer bind (PR #106, italoarruda, security-reviewed and merged 2026-09-28; label rebased from the PR's D323)
|
||
|
||
**PR #106 (merged into v0.4.0).** Two `port/fast3d/gfx_opengl.cpp` fixes, both latent in the shipped renderer (Windows *and* Linux use the OpenGL backend):
|
||
|
||
1. `resolve_msaa_color_buffer`: `glRenderbufferStorageMultisample` with a `msaa_level` above the driver's `GL_MAX_SAMPLES` silently leaves the renderbuffers unallocated -> black screen when the user sets MSAA beyond what the GPU/driver offers (common on integrated GPUs and old IGP drivers). Fix: clamp `msaa_level` to `GL_MAX_SAMPLES` (queried once) before allocating.
|
||
2. Two `glBindFramebuffer(GL_FRAMEBUFFER, ...)` calls passed the *index* `current_framebuffer` (or `framebuffers[current_framebuffer]` while `current_framebuffer == 0`) where a GL *name* is required: when `current_framebuffer == 0` the call must bind `0` (the default framebuffer), not `framebuffers[0].fbo`. Binding index 1-3 as a raw name hits whatever unrelated GL object (or an invalid name -> GL error) that number holds.
|
||
|
||
**Security review (2026-09-28, per the unvetted-contributor policy):** port-layer only, three hunks, no external input, no allocations, no control-flow changes beyond the clamp; N64 build untouched; no game-code edits. Clean. Integrated by cherry-pick of the PR head's code file (`fc092d99`); the PR's docs hunks were superseded by our canonical findings log, and its D323 label rebased to D405 (our D323 is the G_TL_LOD finding).
|
||
|
||
**Verification (2026-09-28):** post-merge 21-level sweep PASS (build-pc Windows); MSAA=16 boot check: `-level_09` renders 900+ frames in a 20s window with no black screen and no crash (the clamp is silent by design; on drivers whose `GL_MAX_SAMPLES` < 16 the pre-fix build black-screened here). Link ritual: no undefined port symbols; the only duplicate `nm` hits are pre-existing per-TU internal statics (e.g. `_ZL12framebuffers` in each gfx backend TU) — legal under C++ unique linkage, unchanged by this PR.
|
||
|
||
**Status:** CLOSED (merged to v0.4.0 as 50e3426a, 2026-09-28; PR #106 closed as landed).
|
||
## v0.4.0 campaign playtest sign-off (2026-09-28, user -- full campaign, Steam Deck + physical pad)
|
||
|
||
The user walked the v0.4.0 release checklist end-to-end and reported (item = user verdict):
|
||
D322 not seen again ("probably good" -- closed on user trend, `GE_D322` recipe kept for recurrence);
|
||
D320 beats clean; D404 pad Centred good (after the parity-curve revision); D397 clean;
|
||
D395/D396 pad capture-cancel + F10 B/A/slider confirmed; D399/D400 by-eye confirmed;
|
||
D289, D294, D291, D249, D236, D311, D235, D303, D307, D245, D240, D241, D287, D286, D175 all
|
||
"fixed"/not seen; D306/D308 "same as N64" (faithful -- not a defect). **One residual: the Rareware
|
||
front-end logo shows a minor texture-filtering artifact (D75) -- kept open as a cosmetic known
|
||
issue, README-disclosed; Nintendo logo + legal page are clean.** Release decisions recorded:
|
||
M7/D387 ships as the loudly-documented EXPERIMENTAL option (user: the warning is sufficient);
|
||
D258 ships NTSC-only (PAL/JP repair deferred post-v0.4); D224 rumble validated on the Deck and
|
||
included; D281 keeps the shipped mitigation + disclosure, zero-saves root cause moves to v0.5.0.
|
||
|
||
## D406 — front-end PC Options: long sections (INPUT) run off the bottom of the paper; rows past the silent page cap are unreachable (user report 2026-09-28, during the v0.4.0 pre-push review)
|
||
|
||
**Report:** on the main-menu PC Options screen (MENU_PC_OPTIONS, `port/src/frontoptions.c`), the INPUT section "has too many settings and they go off the bottom of the screen."
|
||
|
||
**Root cause (geometry, two stacked miscalculations):** the 440x330 paper ends at y=330. `rowY()` puts content row k at `ROW_Y0 + (k+2)*ROW_DY` (the D356 top profile row takes slot 1). D346b sized the 18px pitch for 15 rows *without* a profile row; D356 then added the profile row (cap became `MAX_PROWS-1 = 14`), which pushed the 14th content row to y = 65+15*18 = **335 — 5px below the paper's bottom edge** (text + highlight box mostly off-screen). Meanwhile the v0.4.0 Wave-A additions grew INPUT to **17 visible rows** (mouse H/V, invert-mouse, aim style, aim range, invert-pad, deadzone L/R, pad look-sens X/Y, look smoothing, southpaw, trigger threshold, vibration, crouch mode, reset, Bindings…), so rows 15–17 (Crouch mode, Reset to defaults, **Bindings…**) were dropped by `buildPages()`' silent cap — logged as a warning, but **unreachable from the front screen** (key rebinding was only reachable via the F10 overlay).
|
||
|
||
**Fix (port-layer only, `frontoptions.c`; no `src/` edits, F10 overlay untouched — it scrolls its own window, D345–D347):** in-section pagination. `buildPages()` now gathers all visible rows of the active section and pages them at `ROWS_PER_PAGE = 13` (the largest page that fits: last row at y = 65+14*18 = 317, +~10px text stays inside 330). The discrete vertical stepper crosses page boundaries — stepping past the last visible row turns the page (cursor lands on the next page's first row; the reverse at the top), single-page sections still clamp as before — and the section title carries a `(p/N)` marker when it spans pages (e.g. "Input (1/2)"). `s_pageno` resets on every section/nested-page entry and back-navigation, and is re-clamped on each `buildPages()` so rows that auto-hide (aim range, auto-FOV) cannot strand the cursor past the end.
|
||
|
||
**Verification:** rebuild + user re-check of the front screen owed on the refreshed build (INPUT shows 13 rows + "Input (1/2)", stepping down turns to page 2 with Crouch mode / Reset / Bindings…; `GE_FRONTNAVLOG` logs the page number per step; `GE_FRONTOPTIONS_SECTION=INPUT` logs the gathered row count).
|
||
|
||
|
||
**D406b (2026-09-28, same pre-push cycle, user reports from the rebuilt screen):** (1) "X axis look sensitivity (controller)" ran into the fixed 232px slider track (label/track overlap) -- slider bars now start 10px past each row's measured label (rows too long for any track show the value only); drag hit-test and A-hold fraction mapping use the same per-row origin. (2) the subtle "(1/2)" title suffix was not a clear enough page-2 indicator -- replaced with a dim "Page p/N" marker right of the title plus a bottom-row hint ("Down: more rows >" / "Up: previous page <") on pages with room below (full 13-row pages carry the top-right marker only). `GE_FRONTOPTS_WIDTHS` one-shot label-metrics log added. Committed a973922d; redeployed to the Deck over the same directory (fresh-save state preserved).
|
||
**D406c (2026-09-28, Deck reports):** (1) page 1 of a multi-page section showed no hint (a full 13-row page left no bottom line) -- now 11 rows per page so the paper's last line always carries the hint: page 1 shows 'Down: next page', a short last page 'Up: previous page'. (2) the chevron glyphs ('more rows >' / 'previous page <') read as confusing direction arrows -- dropped; the Up/Down words carry the direction.
|
||
|
||
**Status:** RESOLVED (implemented 2026-09-28 pre-push; validated on the Steam Deck through the D406b/c/d refinements -- bars clear the long controller labels, 'Page p/N' + bottom hint on every page, hint pitch-aligned under the last entry; headless log runs confirmed both page-turn directions with all 17 INPUT rows reachable).
|
||
|
||
## D407 — Tanks cannot be boarded/exited on PC: the v0.4.0 use/reload button split removed the B-button tap the engine's tank handlers consume (user report 2026-09-28, Runway + Streets, during the v0.4.0 pre-push review)
|
||
|
||
**Report:** on the tank levels (Runway `-level_35`, Streets), the tank blocks Bond's movement but can never be entered — on the N64 original the same spot boards and drives.
|
||
|
||
**Root cause (port-layer input regression from the v0.4.0 wave, not game logic):** on the N64 a B tap drives both object use and the tank handlers in `bondview2.c` (`bondviewProcessInput`): B tap while `g_PlayerIsInTank == 1` exits the tank; B tap while `g_BondCanEnterTank != 0` (set by the `PROPDEF_TANK` proximity test in `bondviewCalcUpdatePlayerCollision`) boards it. The v0.4.0 input rework (D378: E = `bond_interact_object()` use with no reload fallback; D393: pad A = use / X = reload / Y = weapon cycle) routed the PC use action to that dedicated interact call and stopped presenting `GE_CONT_B`, so both tank branches lost their trigger: the tank stayed solid (collision) but its state machine was unreachable.
|
||
|
||
**Fix (port-layer only, `port/src/input.c` `inputComputePad`; `src/game` zero-diff — the ten `TANKDBG` probes used to diagnose this (7 in `bondview2.c`, 3 in `input.c`, all `#ifdef PORT`) were stripped for the release):** present `GE_CONT_B` on the use (E / pad A) rising edge only while `g_PlayerIsInTank == 1 || g_BondCanEnterTank != 0`. E keeps its D378 no-reload-fallback semantics everywhere else; the N64 layout presents B natively and is unaffected.
|
||
|
||
**D407(c) (same cycle, tank follow-ups from live playtest):** (1) **Board-animation lockout** — the game's B handler is a toggle: while `g_PlayerIsInTank` is set, a B tap is the EXIT path. Boarding commits a ~45-frame sit blend during which `g_EnterTankAudioState` stays `TANK_RUN_STATE_NOT_RUNNING`; a key bounce or impatient re-press of E in that window hit the exit a few frames later and silently cancelled the whole animation (playtest 2026-09-28: every attempt was a board→exit microcycle — "tries to do something, cancels out"). The port now suppresses B from E while `g_PlayerIsInTank == 1 && g_EnterTankAudioState == TANK_RUN_STATE_NOT_RUNNING` (`s_tankBoardLock`); once seated, E = exit again. (2) **Menu-accept B gate** — the level-triggered "pad A / E presents B outside playable stages" mapping is disabled in tank states, so a held E while seated cannot re-edge the game's own exit toggle. (3) **Tank aim routing + `Input.TankAimScale`** — in-tank the game aims the turret from raw stick deflection only (`bondview2.c`: |stick| > 60 → `aimTurn`/`speedVerta`), so the direct-write aim models (centred / GEPD) are bypassed while `g_PlayerIsInTank == 1` and the legacy velocity stick is emitted instead; new `Input.TankAimScale` (default 100, range 10–300) scales only the mouse→stick gain on that in-tank path — on-foot aim, hipfire and keyboard/pad input are untouched.
|
||
|
||
**D407(b) (same cycle, front-end follow-ups from the D406 Deck validation; `frontoptions.c` + `input.c`):** (1) on the front PC Options screen, past the top/bottom edge of the visible rows the mouse highlight was dropped entirely — it now clamps to the page-edge row, so a mouse-only user keeps a row selected at the page edge (wheel / S then turns the page). (2) the mouse wheel pages the row list on that screen (wheel down = step down); the wheel queue is consumed on the menu so a scroll cannot leak into the next stage's weapon cycle (D223). (3) W/S also drive pad up/down on that screen. **(2026-09-28 fix, user report):** the (2) mapping shipped inverted — `wheelBack` (SDL wheel-up) drove `-STICK_MAX` and `wheelFwd` (wheel-down) `+STICK_MAX`, but this screen's convention is `+STICK_MAX` = up (the W/Up keys in `pcOptionsKeyboardPad`). Swapped so wheel up = step up, wheel down = step down; scoped to `MENU_PC_OPTIONS` only — in-stage weapon cycling (D223: wheel down = next weapon) and the F10 overlay's own scroll are untouched.
|
||
|
||
**Verification:** NTSC build + link clean; zero `TANKDBG` strings in the release binary (re-verified after probe strip, 2026-09-28); 40 s front-end boot steady ~60 fps with zero errors. **Live sign-off (user, 2026-09-28):** first tank drive — board + exit work, no double-trigger against the tank prop, and the in-tank aim feel is good at `TankAimScale = 100` ("felt fine, glad to have the setting").
|
||
|
||
**Status:** RESOLVED (2026-09-28) — board-ordering fix + board-animation lockout + menu-accept B gate + tank aim routing + `Input.TankAimScale`, all port-only (`src/game` zero-diff), user-signed off on a live tank drive; probes stripped, release binary verified clean.
|
||
|
||
## D408 — `Game.SkipIntro` skips the post-mission failure dossier: failed/aborted missions return straight to the menus with no REPORT screen (user report 2026-09-28, during v0.4.0 wrap-up playtest)
|
||
|
||
**Symptom:** with `Game.SkipIntro = 1`, entering a solo mission and then either aborting (watch menu Z+A) or dying (KIA) returns the player straight to the front-end menus — the post-mission failure dossier is never shown. On a normal boot that screen is `MENU_MISSION_FAILED` (`front.c` `constructor_menu0C_missionfailed`: "REPORT / Mission status: KILLED IN ACTION" or "ABORTED", with the objective list and next/prev tabs to the mission-complete report). Initial suspicion was the user's save or `Game.AllUnlocked` (D257/D387); **user confirmed 2026-09-28 that it is a SkipIntro quirk** — save state and AllUnlocked are not involved.
|
||
|
||
**Traced so far (handoff site, mechanism not fully pinned):** the stage-end handoff lives in `menu_init()`'s `MENU_RUN_STAGE` case (`front.c:~8909`, called from `lv.c:1322` only once `g_CurrentStageToLoad == LEVELID_TITLE`): non-replay solo stages fall through to `frontChangeMenu(MENU_MISSION_FAILED, 1)` (Cuba → cast display, multi → MP options, ramrom replay → legal screen). Under SkipIntro that handoff evidently never runs on the fail/abort return path — the exact point where the boot shortcut's state diverges (which flag/menu value the skipped intro stages would normally have left behind) is not yet identified. The abort path itself (`options.c:798`: `set_missionstate(MISSION_STATE_0); bossRunTitleStage(); mission_failed_or_aborted = TRUE; deleteCurrentSelectedFolder()`) and the KIA path (`bondview2.c bondviewKillCurrentPlayer`, `g_isBondKIA = 1`) are game logic and behave per the decomp; nothing in `port/` intercepts `MENU_MISSION_FAILED`.
|
||
|
||
**v0.4.0 decision (user, 09-28):** `Game.SkipIntro` stays **EXPERIMENTAL — not recommended for regular users yet**, alongside its older unrooted audio-break report (D216 status note). The F10 row is relabelled "Skip intro (EXPERIMENTAL)" (`port/src/optionsoverlay.c`, mirroring the D387 AllUnlocked labelling precedent); the config var, default (off), and `lv.c` hook are unchanged.
|
||
|
||
**Status:** OPEN — cosmetic (no crash, no data loss; the mission still records as failed/aborted and the menus are fully functional). Root-causing needs a side-by-side of front-end state (`current_menu`, `g_CurrentStageToLoad`, `mission_failed_or_aborted`, `g_isBondKIA`) on the fail return with SkipIntro off vs on — candidate for v0.5.0 alongside the D216 audio half.
|