Files
goldeneye-pc-port/docs/dev/findings.md
T
James Dansereau fec396f53b D300: hipfire direct camera look (WI-1); D243 M-159/160/161 investigation
D300: new hipDirectCompute() in port/src/input.c writes vv_theta/vv_verta
directly for hipfire mouse-look, mirroring GEPD's linear px->degree model
and the existing D194 aim-mode pattern -- replaces the N64 stick-curve
routing that saturated at ~13 px/poll (issue #89). Ported GEPD's
dead/watch/pause safety gates. Input.MouseDirectLook=1 default, escape
hatch to the legacy path. User-confirmed better feel in a live playtest.

D243: added a permanent onscreen-visibility probe (D243M: onscreen,
chr.c) to test whether chrTick's posIsOnScreen gate explains the
abseil cutscene's frozen render position (M-159). A live capture
refuted that and found the real mechanism instead: chrTick overwrites
prop->pos with a stale value via its own chrUpdateAnim/modelTickAnim
animation-position logic, racing the AI script's scripted write
(M-160). A decisive throwaway experiment neutralizing that overwrite
produced no visible change in a live playtest (M-161) -- the mechanism
is real but isn't the dominant visible cause. Experiment reverted;
D243 parked pending a new lead after ~15 session-passes without a fix.

Docs: findings.md/findings-index.csv/porting-notes.md/GE-ENV-PROBES.md
updated throughout.
2026-09-17 10:56:41 -04:00

1.2 MiB
Raw Blame History

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.

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 #ifdefs 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 .incbins 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.

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) OPEN (parked). 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 fix proposed (not applied — front.c owned by another agent)
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 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. 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 **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 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 DEPRIORITIZED (M-87, user call). 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 OPEN — root-caused, not implemented. Plan…
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 OPEN — root cause / call sites identified, not designed or implemented. Two viable directions…
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).
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 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 (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 OPEN — observed, not investigated
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 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_CHECKs assert against), plus GE_DTEX STRIDED marker + GE_TEXRAW raw-source dump. Hypothesis DISPROVEN + diagnostics shipped; D176(b)/D182(2) still OPEN
D173 Third-person Bond model spawns too high — full ## D173 entry at file tail OPEN — observed, not investigated.…
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 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 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 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 **PARTIAL — M-84 refix…
D212 Anisotropic-filtering config wiring — Video.Anisotropy (M-83 QoL wave, port-only). — full ## D212 entry at file tail 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 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 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 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 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; note M-157/M-158 logged an adjacent open investigation under this label (bullet-spark rainbow, glass2.c path — tracked OPEN in D252, non-reproducible as of M-158) —…

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 | 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) | 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 | 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 | 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 (uncommitted; 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 | OPEN — probes live in tree (GE_D266/GE_D266W/GE_D267/GE_D268_FORCEA), fixes uncommitted | | 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 | 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, uncommitted) — probes live (GE_D271, extended GE_D104); 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 | 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 | 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 | 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). | | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | FIX PROPOSED, NOT APPLIED (front.c is owned by another agent this session).… |

| 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 | 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.… | | 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 | OPEN — tenth pass refuted the "instantiation is nondeterministic" theory (ordinary room-based occlusion, not a bug); original wall-vs-sprites symptom still unresolved; see D276/D280/M-155 | | 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 | OPEN — not designed; port-only | | D238 | QoL ask (M-106, user QA): mouse sensitivity rework toward normal PC-shooter feel. — full ## D238 entry at file tail | 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 | 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 | 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 | 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 | PARKED (M-161, diminishing returns after ~15 session-passes; resume only with a new lead — see below) — M-144: CONFIRMED PC-only (user, direct N64 comparison) — real N64 does not show this shake. Mechanism (M-143): the gBondViewCutscene look-at branch reads field_3C4/8/C, a leaky-integrator low-pass filter of Bond's real position that isn't reset when the scripted cutscene warps him, producing a ~1s look-at snap. Filter math is decomp-exact and g_ClockTimer-scaled, so the bug must be upstream: either the position warp itself is wrong/bigger on PC, or a call-ordering desync around the CAMERAMODE_INTRO transition. M-153: live GE_D243M capture (full Dam → abseil; scratch/d243/m153_dam_abseil_capture.log) REFUTES the double-instance hypothesis (single model, one draw/frame, no remove/recreate window) and shows the real divergence: the model's rendered position (field_488.pos = render readback) freezes on dam-surface points then locks into a strict period-4 cycle through four fixed world points, while prop->pos executes the smooth scripted void sweep — the lagged filter (field_3C4) chasing that cycle is exactly the observed shake. Next: code dive into chrTick→render_pos for what produces the four fixed points; D173/D292 are the same family. D146 still fully decoupled (zero hits, 4 runs). M-154: user sharpened the symptom (one new frozen Bond per shot; old copies persist); log re-read corrects M-153 — in phase 3 prop->pos itself cycles A,A,B,C through three z-lanes while y falls smoothly, and the rendered model tracks it (per-lane pose offsets), so it teleports every frame rather than freezing; H1 (multiple Model instances) vs H2 (framebuffer accumulation / subcam ghosts) now discriminated by a new D243M: chrdraw per-frame model-draw census in drawjointlist + extended tick fields (propptr/chrptr/rp); probe crashed twice inside its own render_pos read (stale slot contents, not a uniform tombstone) — final fix: tick probe never dereferences render_pos (raw= moved to chrdraw-only where the game guarantees validity); awaiting re-capture. M-159 (static code-dive, no build): proposed chrTick's frustum-visibility gate as the mechanism — REFUTED by M-160's live capture (vis=1 throughout the observed freeze). M-160 (live D243M: onscreen capture, user playtest): found chrTick overwriting prop->pos with a stale/stepped value every tick via its own chrUpdateAnim/modelTickAnim animation-position logic (chr.c:2578-2616, no POSEND/cutscene check), downstream of and competing with the AI script's smooth write to the same field. M-161 (decisive experiment, live playtest): NEGATIVE. Neutralizing M-160's overwrite (save/restore prop->pos around chrTick during POSEND, GE_D243X env-gated hack, reverted after) produced no visible change — user: "still happened, looked the same as it always does." M-160's mechanism is real but is not the (or not the whole) visible cause. Parked per user's call (diminishing returns after ~15 session-passes) — see D148 umbrella; resume only with a specific new lead. | | 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 | 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 | OPEN — one candidate ruled out (M-116 follow-up), root cause not found. Step (1) from the original plan is done… | | D247 | Black bars top and bottom of screen — possible aspect-ratio defect (M-116, user QA report). — full ## D247 entry at file tail | NOT A BUG — confirmed faithful, unmodified N64 decomp behaviour, no code change made. GE_PCDUMP captures (both against the pre-existing save and a fresh-EEPROM run, to rule out a stale Screen option from earlier test sessions) show the exact same inset on Frigate… | | 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); 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… | | 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 | OPEN — root cause genuinely open; M-157 got the first visual capture (bullet-spark path, glass2.c) and M-158 found it NOT reproducible in 6 identical dev-box runs — flaky/environment-dependent. GE_D157 probes (D157T/D157I) in tree to capture the full RGBA path on next occurrence (user Deck run with GE_D157=1). Cheap repro: bullet-impact sparks. Documented only for v0.2.0-pre per user instruction. | | 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), but refined (v0.2.1 further user testing, 2026-09-15) —… | | 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 | 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 | 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 (M-141, src/snd.c) — osSetIntMask lock added around the preempt-scan, matching sndSetupSound's existing unconditional (no #ifdef PORT) pattern in the same file; build green, -level_09/-level_20 unregressed (1400+ frames, no crash, no D152 lock-stuck warnings). Not yet gdb/live-confirmed against a real reproduced crash — see M-141 body. 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 | 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 | OPEN — one confirmed occurrence, real (not self-recovering), not root-caused; gfxFrameMsgQ full is a lead, not a confirmed cause. | | 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 | OPEN — visually confirmed and precisely bounded (frames ~562-741 of a bare -level_20 boot), root cause not yet identified; promoted from the long-parked GRAP… | | D289 | **Dam: a crash in the computer room, not reproducible on a second attempt (M-141, Steam Deck user playtest).** — full ## D289entry at file tail | OPEN — needs a human re-test specifically on a build with the D255 fix (this session'srelease/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 ## D290entry at file tail | OPEN — observed, not investigated. Needs a live repro (open the watch, navigate to the controller-config page) plus a screenshot for comparison against N64 reference. | | D291 | **Dam: texture glitches when the truck drives past (M-141, Steam Deck user playtest).** — full## D291entry at file tail | 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## D292entry at file tail | 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. | | 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## D293entry 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## D294entry at file tail | OPEN — not investigated.… | | 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 PORTbuffer widen infront.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 ontotextpos.p[0](y, ≥0x80 in normal geometries) → high-bit byte →langGetJpnCharPixels NULL deref (language.c:320, g_JpnCacheCacheItemsNULL 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;difficultytextenlarge = 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 | 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 ## D297entry at file tail | FIXED (M-150) — migration shim landed:port/src/legacycrc.c(isolated, deliberately-wrong pre-D284 PRNG, reviewer-verified bit-exact against0de99b4d^) + 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 ## D298entry at file tail |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## D298entry 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## D299entry at file tail | FIXED —fileValidateSaves()(only called from the skipped legal screen) never ran underSkipIntro, leaving saves[]all-zero; added the missing call to theSkipIntro 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 ## D300entry at file tail | FIXED (pending build-verify) — WI-1 perdocs/dev/GEPD-INPUT-PLAN.md: new hipDirectCompute() (port/src/input.c) writes vv_theta/vv_vertadirectly, 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 onbonddead/outside_watch_menu/pause_state(GEPD's!dead && !watch && !pause, ported from decomp fields since hipfire, unlike aim mode, has no RMB-held natural gate). Input.MouseDirectLook (default 1) is the escape hatch back to the legacy stick path. Compile-verified clean (ninja`, no new warnings); link/smoke-test pending (exe was locked by a running instance at compile time). |

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) #includes 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_.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>/.
  1. 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.
  2. 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.
  3. 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.
  4. 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 ≤
  1. 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 ALParam 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_.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 dynAllocated 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 Models 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.

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 sImageTableEntrys — 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 animInits 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 u8s 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() bswap16s the 6 leading u16s 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.
  5. 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 mempAllocBytesInBanks 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 Origins, 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 onosGetCount()`.

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 .words 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 memcpys 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) strcpys 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:

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 PORTs 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).

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 PVoices (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 PVoices 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 memcpys 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:

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:

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 s16s (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):

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:

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 ObjectRecords → 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.

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 sImageTableEntrys; 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): FIX PROPOSED, NOT APPLIED (front.c is owned by another agent this session). One-line #ifdef PORT in constructor_menu00_legalscreen: legal_text_end = legalpage_text_array + ARRAY_COUNT(legalpage_text_array); (N64 &legalscreen_MRD cast under #else). Same linker-layout class as the D75 loop-bound / D142 enum family. Confidence: high — mechanism confirmed by symbol addresses.

D159 — Front-end wallet-Bond photo (and other large front-end I4/I8/IA8 textures) render "interlaced" / combed (M-31, user bug RC1 / D149).

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

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.

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 bitstream 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 #includes 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.

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): OPEN — root-caused, not implemented. Plan: #ifdef PORT, env-gated (e.g. GE_DEBUG_UNLOCKALL=1, catalogue in GE-ENV-PROBES.md once added) short-circuit at the top of fileGetIsCheatUnlocked — if (getenv-cached-flag) return TRUE; before the bitfield read. Default off ⇒ zero behavior change for normal play; when set, purely a query-time override (save file itself is never touched, so it can't corrupt a save or leak into the shipped default). No src/ logic change beyond the guarded early-return (same class of edit as other #ifdef PORT dev-convenience hooks, e.g. GE_STARTMENU).

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.

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.

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 — tenth pass refuted the "instantiation is nondeterministic" theory (ordinary room-based occlusion, not a bug); original wall-vs-sprites symptom still unresolved; see D276/D280/M-155

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.

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

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): 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): 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).

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).

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.

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.

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.

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): OPEN — one candidate ruled out (M-116 follow-up), root cause not found. 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): NOT A BUG — confirmed faithful, unmodified N64 decomp behaviour, no code change made. GE_PCDUMP captures (both against the pre-existing save and a fresh-EEPROM run, to rule out a stale Screen option from earlier test sessions) show the exact same inset on Frigate: real scene content occupies rows ~20–459 of a 640×483 capture (the 483 vs 480 is a separate, harmless 3-row capture-tool rounding quirk, not investigated further), i.e. roughly 220/240 of the vertical field — and src/game/fr.h:34 defines VIEWPORT_HEIGHT_DEFAULT_NTSC 220 (240 for the full field), consumed unmodified by bondviewGetCurrentPlayerViewportHeight() (bondview2.c:7858) as the default in-game viewport height whenever PLAYER_OPTION_SCREEN is SCREEN_SIZE_FULLSCREEN (the decomp's own default value, options.c:128) — VIEWPORT_HEIGHT_FULLSCREEN (304, no inset) is a red herring name; it's only reachable via the cameraBufferToggle branch, not normal solo play. This is GoldenEye 007's well-documented original TV-safe-area letterboxing, baked into ground-truth decomp constants with no #ifdef PORT anywhere near it — the N64 game genuinely doesn't fill the full field during normal gameplay. Ruled out a stale/leftover EEPROM Screen=Wide/Cinema setting from earlier QoL-testing sessions as an alternative explanation: a fresh EEPROM (default options) shows the identical inset. No fix needed; this row closes. If the user's window shows noticeably thicker bars than this ~8–9%-per-edge margin, or bars appear on the sides instead of top/bottom, that would be a genuinely different (and real) symptom worth reopening under a new label — but "thin bars top and bottom, present at any window size" is correct, original behaviour. Cross-ref D226 (separate, already-tracked HUD-scale QoL ask — unrelated mechanism, don't conflate).

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".

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): 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).

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.

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 ws 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.

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).

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.

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.

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.

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 D157Tcaptured 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 andD157T` 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.

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).

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 (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. Manual playtest still owed — mouse feel (hipfire slow/fast linearity, aim mode unaffected, scope zoom, menu, death/cutscene safety-gate behavior) needs a human with real mouse input; a headless boot can't exercise it. GE_INPUTLOG already logs hipdirect d=... cam=... lines for the linearity check the plan's Verification section calls for — run with GE_INPUTLOG=1 during that playtest.

Status: FIXED (pending manual playtest) — code complete, compiles clean, links, and a concurrent smoke run showed no regression; mouse-feel verification needs a human session.