Files
goldeneye-pc-port/docs/porting-notes.md
T
James Dansereau a5804d10b8 D290: fix missing controller model on the watch Control Options page (D264 pattern, second site)
watchRenderController built its ModelRenderData from a raw 64-byte read at
D_80035D04+0x3c, which on N64 is the {0,1,3,...} template spanning into
watchControllerButtonBases. On PC the struct is wider and the globals aren't
at those offsets, so flags read 0 and subdraw() drew nothing. Use the
explicit template, as D264 did for the item preview. Also fix the three
pointer-width/raw-Gfx sites on the same path that now execute: two u32
render_pos casts (fatal under Linux PIE), a (s32) pointer arg, and the
dual-controller branch's hand-encoded G_MTX word (now gSPMatrix).

Verified on native Windows (headless watch walk + capture); bunker1 golden
3/3; Windows and Linux builds clean. D291: headless check noted (needs play).
2026-09-23 13:07:31 -04:00

113 KiB
Raw Blame History

title, description
title description
Porting Notes A field guide to the recurring N64-to-PC bug classes hit while running unmodified big-endian game code on a 64-bit host, each with its symptom and fix.

Porting notes: recurring N64→PC bug classes

A field guide to the bug classes that keep recurring when running big-endian 32-bit N64 game code, unmodified, on a little-endian 64-bit host. Terse by design; each entry compresses a full investigation to a symptom, a fix, and a grep heuristic for finding siblings. Each entry cites a Dxx label; the full evidence and fix for that instance live in dev/findings.md under the same label. Skim the section headers; read the classes relevant to the task at hand.

If you are debugging a crash in this port, read this first; the odds are good that you are looking at one of these.

Contents

A. Pointer-width struct growth (32→64): the dominant class

A decomp struct with pointer fields, or one pun-allocated into a fixed N64-sized hole / hardcoded byte count, is larger on x86-64. Reading it from ROM bytes misaligns; allocating it N64-sized overruns adjacent state.

  • Symptom: garbage pointer deref, corrupted neighbor field, delayed fault far from the cause.
  • Fix: store embedded ROM addresses as u32, cast at use site (PD ground truth); or give real inline storage / sizeof()-based alloc under #ifdef PORT.
  • Instances: D53.2 (ModelSlot pun), D56 (watch Model raw offsets), D57 (rwdata record count), D67 (image_entry), D79 (bg_room_data), D98 (struct player alloc), D100 (player.model inline Model), D101/D102 (ModelNode*/weapon Model puns), D115 (gunfire THROW* raw offsets), D140 (watch Model punned into a struct player field-run: PC sizeof(struct Model) grows so the "fields" that aliased .render_pos / .scale / .animframe1 on N64 no longer overlap → NULL render_pos → pause-menu crash; fix = real inline struct Model + pool, redirect the named reads to the member; same as D100/D102), D119 (weapons_held[]->chr punned as ChrRecord* to read .act_*.attack_item; aliased WeaponObjRecord.weaponnum at 0x80 on N64 via act-union@0x2C+84; act union moves to ~0x38 on PC).

A1. Raw-byte or mistyped aliases into a union/struct whose true field shifted because a sibling member widened; the silent variant. The nastiest members of this class do not crash: a literal byte index into a union that overlays pointer-bearing structs still reads, it just reads the wrong byte, so the game runs and merely behaves wrong. Grep for hardcoded indices into any padding[]/u8[] union arm.

  • D209 (the D193 root cause, and the highest-impact instance found so far): self->act_ubytes.padding[45] aliased act_gopos.unk59, the SPEED tier. act_gopos = {coord3d@0, StandTile *@12, waypoint *@16, waypoint *[6]@20, u8 curindex@44, u8 unk59@45} on N64; at 64-bit the three pointer members widen and unk59 moves 45 → 81, so the literal 45 lands on byte 5 of waypoints[1]. With a low-4GB arena (0x00000000_70xxxxxx) that byte is always 0x00; a stable, plausible value, not garbage. Effect: the locomotion-animation selector received tier 0 forever and every AI character in the game was bound to a walk animation; since GE travel is anim-root-motion driven, all AI moved at walk pace on every level. No crash, no log, no NaN; it took a runtime probe comparing the commanded tier (named field, correct) against the bound animation (alias, wrong) to see it.
  • D210 (sibling, found by the post-D209 sweep): chrToPatrol inits act_patrol.lastvisible60 via act_init.padding[0x13] (union byte 0x4c). act_patrol starts with a patrol_path *path; widens 4→8, so padding[0x13] no longer hits lastvisible60 (now 0x50); it lands in waydata and lastvisible60 is left uninitialised → the patrol "haven't seen the player recently" timer reads garbage for the first ticks. Fix: named field under #ifdef PORT. NB the alias need not land on a pointer byte (as D209 did); here it lands on a plain float that just isn't the field intended. The tell is the same: a hardcoded index into padding[] right next to named writes of the same arm. The rest of the padding[N] sweep (chrlvTickAnim/chrlvTickDead → act_anim/act_dead, both pointer-free) is layout-stable.
  • D255 (a mistyped-field variant, not a raw-offset variant — same mechanism, different syntax): vtxstore_fix_refs read a record's Model* through ((Model*)((ChrRecord*)prop->chr)->chrflags) — ChrRecord.chrflags is typed CHRFLAG, a 4-byte enum. On 32-bit N64 the cast is bit-exact; on 64-bit PC it reads only the pointer's low 32 bits and zero-extends, discarding the real high bits. The truncated result is (nil) whenever the real pointer's low 32 bits happen to be zero — which read exactly like a legitimate NULL-model case, and cost two prior investigation sessions chasing a "destroy-path race" theory before the field itself was the suspect. Tell that generalizes past D209/D210's padding[N] shape: it doesn't have to be a raw numeric offset — a field that is correctly named but undersized/mistyped relative to a pointer it's secretly meant to hold (an enum, a u32, a s32 doing double duty as a pointer slot) is the same bug through a different syntax. Fix: read through the correctly-typed sibling field (ObjectRecord.model) instead, #ifdef PORT, N64 line/cast kept verbatim under #else. §F D255 (M-140).

Lesson. When a value reads as a clean constant (0, 1, NULL) rather than garbage, a pointer byte is a prime suspect; the high bytes of a low-4GB heap pointer are all zero, so a misaligned or truncated read looks like a legitimate "feature off" / "not set" value. Tell-tale signs of this pattern, any one of which should trigger an A1-first check before assuming a bug is genuine game-logic behavior:

  • The observed value is byte-exact-looking but wrong from the very first call/frame — not intermittent corruption, not "usually right but occasionally wrong."
  • A raw/literal offset or byte-width read into a union or struct that has any pointer member, anywhere in that union/struct's layout (not just the field being read) — the read doesn't need to touch the pointer itself; any member after one that widened is at risk.
  • A field that is correctly named but too narrow to hold what it's actually storing (an enum/u32/s32 reused as a truncated pointer slot — the D255 shape, distinct from D209/D210's literal-offset shape but the same underlying cause).
  • The resulting behavior is deterministic and internally self-consistent, but demonstrably not what the N64 original would produce — the code isn't crashing or producing garbage, it's confidently doing the wrong (but stable) thing.
  • Prefer the named field (or a correctly-typed sibling field) under #ifdef PORT; the alias/pun is only correct at 32-bit pointer width. Verify every caller reaches the site with the union/struct arm you're naming actually active.

Distinguish from D284 (port/src/random.c's MIPS64 shift-emulation bug): same epistemic shape — deterministic, self-consistent, silently not-N64 — but a different mechanism (instruction semantics, not struct layout/pointer width) and, critically, entirely inside port/, no src/game touch at all. D284 was never a rule-2 question in the first place; don't fold it into this catalogue's "looks like game logic, is really ABI" framing — it never looked like game logic to begin with.

  • Open landmine: raw hardcoded-offset accessors into struct player / struct hand; see docs/dev/AUDIT-M6-player-offsets.md.

  • D126 corollary; a trailing runtime list pointer in a ROM-serialized record. If a fixed-size ROM record ends with a T *next (or *child, *parent) that game code writes while walking the setup stream (linked-list build), that field is 4B/end-of-record on N64 but widens to 8B and 8-aligns on PC; the store spills past the N64 record size into the next record and silently corrupts a polymorphic walk downstream (type byte clobbered → wrong strides → wrong command indices → wrong pointer resolution, crashing far away). Offline converter (d*_emit.py) MUST emit these at the real PC sizeof with the pointer slot widened + the matching #ifdef PORT sizepropdef/stride. Instances: d88_propdefs.py types 30/32/33/35 (criteria_* / setup_objective_text ->next), 22 (TagObjectRecord.NextTag, already handled). Check every record type the walk can see for a set_parent_* / *_entry_parent writer.

  • D132 corollary; a union { T *ptr; s32 IndexN; } field is 8B/8-aligned on PC even when the ROM image only ever stores the s32. LinkRecord (first/Index1), LockDoorRecord, SafeObjectRecord etc. pack their index words tight (4B) in the N64 setup stream, but the compiler places each IndexN in the LOW 4 bytes of an 8-byte-aligned pointer slot (Index1@8 not @4, Index2@16 not @8, then a trailing *next). A converter that lays them at N64 4-byte offsets makes pdef->Index1 read Index2's value and Index2 read 0 → the record's validity guard fails and switch-doors / dual weapons / locked doors / safes silently never initialise (usually non-crash, because the failed guard also skips the pointer stores). Fix: emit each IndexN at PC offset 8 + 8*(N-1) and size the record with the widened unions + trailing *next (d88_propdefs.py types 14/19/38/44; proposed in §F D132, not yet applied). Grep every ROM-serialized record for union { with a pointer arm. NB [u16 ID][s16 x] NOT in a union (e.g. TagObjectRecord) stays at offset 4; only the pointer union forces the 8-align.

  • D130 corollary; in-place N64→PC struct-array re-layout aliases when the stride grew less than the read span. A ROM struct array widened on PC (fontchar 24→32B, romdataFixupFont) is often re-laid-out in place: for glyph i, dst = base + PCstride*i, src = base + N64stride*i, so dst − src = (PCstride−N64stride)*i. For small i that delta is less than the per-element field-copy read span, so dst overlaps src, and a forward field-by-field *dst_k = f(*src_k) loop overwrites a not-yet-read src_j (j > k). Result: later fields of the first few elements come out as f(f(earlier field)); e.g. glyph 1's width became bswap(bswap(index)). Iterating elements backward does NOT help (the overlap is within one element). Fix: read every source field of the element into locals first, then write. Grep every romdataFixup* / in-place relayout for a read-write loop whose dst/src can alias for low indices.

  • A negative/OOB index into a widened struct array is fatal on PC, benign on N64. chars[*text - 0x21] with a control byte (*text < 0x21) reads before chars[0]. On N64 that hits the adjacent 4-byte-field kerning table (small values → a garbage-but-TMEM-valid glyph); on PC the struct's 8-byte pixeldata reads a wild pointer → fast3d AV. Latent in textrelated.c (textRender*/textMeasure ASCII paths); a GLYPH_IDX clamp fixes it if a level actually feeds a control byte to the HUD text (none do yet; D130 was a font-relayout bug, not this).

  • D131 corollary; osVirtualToPhysical() truncates a compiled-symbol pointer to 32 bits. The port shim is (u32)(uintptr_t)va (libultra.c). For a runtime DRAM pointer (0x70xxxxxx) the cast is lossless; for a compiled module symbol (.bss/.rodata matrix, e.g. &dword_CODE_bss_8007A100 in explosionRenderPropSmoke) it drops the 0x1_00000000 module high word, so the GBI w1 becomes 0x40xxxxxx and seg_addr() hands fast3d a wild pointer → AV in gfx_sp_matrix / gfx_sp_vertex. Same class as D94 (chraction.c:1243), but in a DL word. ~30 latent sites (grep 'osVirtualToPhysical(' src/game/{explosion,glass,glass2,blood_animation,bondview2}.c), each armed only when that effect first draws. Fixed once in seg_addr(): restore the high word for a fallthrough w1 ∈ [0x40000000, 0x70000000) (module is fixed-based at 0x140000000; DRAM/KSEG0/segmented/phys are all handled in earlier branches). gSPDisplayList(&globalDL_0xNNN) is NOT affected; the port's Gwords.w1 is 64-bit and gDma1p stores the full pointer; only osVirtualToPhysical truncates.

  • D177; a struct passed as s32 * and integer-indexed past a leading pointer member. stanCheckLinkedSpecialTile takes the caller's struct StandTileLocusCallbackRecord { s32 *rooms; s32 count; … } typed as s32 *outFlags and does outFlags[1] = 1. On N64 [1] is count; on PC rooms is 8 bytes so [1] is its high half and count moved to [2]; the write is silently lost, the reader (->count) sees 0. Two tells for this class: (a) a function parameter typed s32 * / u32 * / void * that is really a named struct (check what the caller passes and what other consumers cast it to); (b) the matching stack local declared as a small "placeholder while matching" struct whose N64 size equals the real record; it silently under-allocates once any member widens. Fix both: cast to the real type + write fields by name, and declare the local as the real struct, under #ifdef PORT. ([0]/rooms often still works by luck on LE, low half at +0, which masks the bug for one of the two fields.)

  • D248; a hardcoded byte offset used to index "one struct past the end of the array" desyncs from a real field write once the struct grows. OSScClient (src/sched.h) is {next; msgQ;}, 8B on N64 / 16B on PC. osScAddClient() deliberately writes a per-client flag into the next array element's first field (c[1].next = next) instead of adding a real member; cheap on N64 since the caller over-allocates the array by one slot. The reader, __scHandleRetrace, doesn't index the struct back; it recomputes the same address by hand as *((s32*)client + 2) (a hardcoded 8-byte offset). That offset only equals sizeof(OSScClient) (and thus &client[1]) when pointers are 32-bit. On PC it lands 8 bytes into client[0] itself, inside msgQ, a real non-NULL pointer, so the flag reads as permanently "set" for every client, silently collapsing a 60Hz/30Hz per-client split onto whichever branch a nonzero flag takes. General tell: any place a struct is deliberately under-declared and a sibling array slot is used as ad-hoc extra storage (c[1].field, arr[i+1].x) is fine as a write (indexing scales with the real sizeof), but a matching read done via a raw (T*)p + N/byte-offset cast instead of p[1].field will silently desync once the struct's size changes across the port. Fix: always index the struct on both ends (client[1].next), never hand-roll the byte math; it's the direct decompilation of what the write side already does, #ifdef PORT with the N64 hand-rolled offset kept verbatim under #else. §F D248.

  • One layout bug often has sibling call sites. D264 fixed a cross-global ModelRenderData template read in the watch item preview; the identical read in watchRenderController (D_80035D04 + 0x3c) stayed broken until D290. When fixing a raw-offset global read, grep for every other read of the same globals (D_80035D00/D_80035D04 here) before closing.

B. 16-byte PC Gfx / Vtx vs 8-byte N64

Any buffer reservation, memcpy size, slot stride, or pool budget expressed in N64 Gfx/Vtx units is half-size on PC.

  • Instances: D50.6 (texCopyGdls copied only w0 of each 16-byte slot), D58 (DL reserve 0x100→0x200), D85 (bgWidenRoomGdl 8→16 + bswap), D95 (2× master-DL buffer + raised mempool ceiling).
  • D135 corollary; an unported GBI parser (not just a buffer size). Code that walks a DL command stream with raw byte/word indices into an 8-byte N64 Gfx (*(s8*)gdl for the cmd, ((u32*)gdl)[1] for w1, ((u8*)gdl)[5..7] for G_TRI1 vtx indices, ((u32*)gdl)[0/1] nibble reads for G_TRI4) reads the wrong bytes of the PC 16-byte {u64 w0; u64 w1} slot and desyncs on the first command → walks off the DL → wild deref. The N64 32-bit words survive in the LOW dword of each 64-bit field, so the mechanical port is: u32 w0 = (u32)gdl->words.w0; u32 w1 = (u32)gdl->words.w1; then replace every raw access with the equivalent shift/mask on w0/w1 (BE byte i of a word → (word >> (8*(3-i))) & 0xff). gdl++ (advances by sizeof(Gfx)) is already correct. Watch (s32)ptr truncation in vtx-base math and x | 0x80000000 KSEG0 folds (identity on PC; just drop the OR, and guard segmented w1). Instances: bgTestHitOnObj (propobj.c, FIXED); bgTestRayIntersectionInRoom
    • bgTestBulletHitBackground tail (bg.c, D154; ported M-28, re-audited + bug-fixed M-30, playtest-gated). PD ground truth: pd_port uses gdl->dma.cmd + GFX_W0_BYTE(i)/GFX_W1_BYTE(i) macros (3-i / 11-i on 64-bit LE).
  • D154 corollary; the PC Gdma_le shim's .par is NOT the N64 params byte. When porting a room/model DL GBI parser, only .dma.cmd is safe to read through the port/shim/PR/gbi.h Gfx union: that shim lays Gdma_le out as par:24 (bits 0-23 of word0 = the packed length from gDma1p), cmd:8 (bits 24-31). The N64 Gdma has cmd:8 (byte 0) then par:8 (byte 1, bits 16-23 = the ((n-1)<<4)|v0 G_VTX params). So an N64 ((u8*)gdl)[1] / N64-semantics .dma.par becomes ((u32)gdl->words.w0 >> 16) & 0xff on PC; gdl->dma.par gives bits 0-23 (length), and & 0xf on it is always 0 because len == 16*n. The original D154 port had exactly this bug (vtxoff forced to 0). Same trap in bgBuildRoomVtxBounds (gdl.dma.par>>4&0xf reads PC bits 4-7 = n&0xf, not N64 bits 20-23 = n-1): for n ≤ 15 it happened to read the whole block (+1 vertex), but for n = 16 (the common authored size) it collapsed to numvertices=1 → single-point batch bounds → the per-batch AABB pre-test in bgTestBulletHitBackground rejected the batch → its wall triangles were never ray-tested: D312 projectile pass-through + D313 missing bullet impacts (FIXED 2026-09-18, recover the params byte via ((u32)gdl->words.w0 >> 20) & 0xf). Rule: for anything but the opcode, extract the bit-field explicitly from (u32)gdl->words.w0 / .w1, don't trust the named .dma.* sub-fields. Also: a words.w0 << k >> m bit-extract idiom that relied on 32-bit truncation on N64 must be (u32)-cast first on PC (words.w0 is 64-bit).
  • Also bites RAW hardcoded struct-stride writes, not just Gfx/Vtx. D128: sub_GAME_7F0B37EC did ((u8*)g_BgPortals)[(portal<<3)+6] |= 2; the N64 bg_portal_data_entry is 8B (ptr@0, cr1@4..cb2@7), but on PC the widened offset_portal ptr makes it 16B (cr1@8..cb2@11), so the write landed in the middle of another portal's pointer. Fix = use the struct accessor under #ifdef PORT (every other site already does: g_BgPortals[portal].controlbytes1 |= PORTALFLAG_SPECIAL). Grep for << 3 / * 8 / + 6 style raw offsets into any struct that gained a pointer field. D141: set_enviro_fog_for_items_in_solo_watch_menu (gunfire.c:1720) indexed a ModelNode* array (ModelFileHeader.Switches) as *(ModelNode**)((u8*)Switches + j + 0x48) with j += 4; the 0x48/ 0x5c and the step are 4-byte-pointer constants. PC 8-byte stride → wrong
    • misaligned slot → bogus non-NULL node → crash in modelGetNodeRwData. Fix: Switches[18 + (j>>2)] / Switches[23 + (j>>2)] under #ifdef PORT (0x48/4=18, 0x5c/4=23). Grep every (TYPE**)((u8*)arr + <const>) and arr[i << k] where the array element is a pointer. D191: bondviewSelectCuff (bondview2.c); the same Switches array, missed by D141. offset = switchindex << 2 then base = (ModelNode**)((u8*)switches + offset), deref'd base[0..5]. On PC the 8-byte slot stride makes base[N] a half-word-swapped pointer (0x70267a90 → 0x70267a9000000000) → crash in modelGetNodeRwData when the player fires with a fresh-save Bond model (Bunker ii first guard, Statue post-cutscene). Fix: offset = switchindex * sizeof(ModelNode *) under #ifdef PORT. When you fix one arr[i<<2] pointer-array site, grep the whole file; these travel in packs.

C. Big-endian rodata / ROM data read on little-endian PC

ROM assets and compiled-in .rodata are big-endian. Anything not run through a converter or a runtime bswap fixup reads scrambled.

  • f32 values are BE word pairs; a naive byte reversal off-by-one corrupts every float: D73 (sinf/cosf du pairs → DVAL() macro), D112 (d43_emit.py put_f32 src[doff:doff+4][::-1]).

    • D73 scope is now settled (M-32 D75 triage): the whole src/libultra/gu/ tree is endian-clean; only sinf.c/cosf.c used the du union and both are DVAL()-wrapped; rotate/perspective/ortho/ lookat/scale/translate/mtxutil/normalize/align use plain float literals or pure integer bit-packing (FTOFIX32+shift/mask). The game's own matrixmath.c (matrix_4x4_set_lookat*, _set_projection, _f32_to_s32) is likewise native-LE (D114). Do not re-audit gu or matrixmath for a "float endianness" bug; if a matrix comes out wrong on PC the cause is upstream data, a struct-field pun (D100/D140/D156), never these files. (The "D114/D116 viewport mirror" example is withdrawn; M-33/D168 showed D114/D116 were an upside-down GE_PCDUMP capture, not a real flip.)
  • Header offset tables / pointers: D54 (cseq ALMidiHdr), D68 (Globalimagetable), D87 (ramromfilestructure), D88 (Usetup* tables).

  • Negative-terminated index chains (PointUsage[]) in converted model rodata cycle forever if element endianness/stride is wrong: D120 (opcode-0x18 collision record, d43_emit.py; guarded, not fixed).

  • Packed bitfields cross byte boundaries differently: D78 / D83 (StandTile id/room, header mid/tail).

  • Rule: prefer an offline sidecar converter (D43, D69, D88 pattern, tools_pc/d*_emit.py) over a runtime fixup for a whole format.

  • D139; (u8)word / (s8)word to grab "the first field" of a struct reads the WRONG byte on LE. N64 code that does (u8)obj[0] (obj a u32*) to read a byte-3 field of a big-endian header word; e.g. the propDef type in [u16 extrascale][u8 state][u8 type]; gets the low byte, which is the last BE field. On LE it's extrascale. cleanupObjects did this for its walk-termination + type dispatch → never saw PROPDEF_END → ran off the blob → freed garbage → crash on stage unload. Fix: use the struct member (pdef->type) like every other consumer. Grep for (u8)*/(s8)*/& 0xff on a u32/s32 that's really a serialized multi-field word; especially GBI-word and header-word reads that were never routed through a converter or #ifdef PORT.

  • D150; langGet() NULL flows into a str* primitive. The watch BRIEF/OBJECTIVES pages (and front.c mission text) build their strings with strcpy/strcat(buf, langGet(id)). langGet returns NULL on PC for any bank the current menu flow never loaded (D129/D143); N64 always resolves these ids so the decomp never guards. strcat(buf, NULL) then derefs 0. Fix = NULL-tolerant strcpy/strncpy/strcat under #ifdef PORT in str.c (NULL src → empty string, NULL dst → return). Gotcha: these are __nonnull__ builtins to GCC; a plain if (src == NULL) on the parameter is deleted as provably-dead (confirmed in -Og disassembly: no test emitted). Launder the pointer through an empty __asm__("":"+r"(p)) (GE_IS_NULL()) so the guard survives. Same class as the D143 textRender NULL guards. Audit any hand-rolled libc primitive in src/ that a port NULL can reach.

  • D219 (M-112; the finding itself resolved in M-115, kept as a triage lesson), "the shared texture decoder is proven correct" is a claim about pixel format, not compression method, and the two are independent knobs on the same runtime texLoad. src/game/image.c's non-zlib texture decompressor reads a per-image 4-bit TEXCOMPMETHOD_* from the bitstream (uncompressed / huffman / huffman-per-channel / RLE / lookup / huffman-lookup / RLE-lookup / huffman-blur / RLE-blur) independent of the 4-bit pixel-format (RGBA32/16, IA16, I8/4, CI8/4 …). Two textures sharing a pixel format can still take completely different code paths: _HUFFMAN/_HUFFMANPERHCHANNEL/_RLE go through texChannelsToPixels (per-channel planes in R,G,B,A order), while _LOOKUP/_HUFFMANLOOKUP/ _RLELOOKUP go through texBuildLookup+texInflateLookup* (a packed color table, bits read directly in final pixel layout). "Every other RGBA16 texture in the game renders correctly, so the decoder can't be the bug" is not a valid inference unless you've confirmed the comparison texture used the same TEXCOMPMETHOD_* as the suspect one; they can silently diverge into different functions despite an identical G_IM_FMT_RGBA,G_IM_SIZ_16b DL declaration. Also: whether a texture's G_SETTIMG marker is baked directly into a compiled display list (gsDPSetTextureImage(...IMAGESEG(id)) in assets/*.c, fixed up by gimgFixupGlobalimagetable's DL-bswap pass) vs. synthesized at runtime from an sImageTableEntry's index field (texSelect, src/game/othermodemicrocode.c) is a real, checkable difference in resolution path worth ruling in/out early; but both funnel into the same texLoadFromDisplayList→texLoad call, so it is a weaker lead than the compression-method one. See docs/dev/findings.md D219 for the full writeup and an env-gated (GE_D219RAW) diagnostic left in the tree to check this on the next session that can capture runtime output.

  • D157; a small value in the LOW BYTE of a BE s32 word, read as s8 at the word's last offset, reads 0 on LE after the converter byte-swaps it. Same family as D151/D139. struct objective_entry.difficulty is s8 at offset 0xF; the underlying type-23 propDef word 3 (MissionObjectiveRecord.MinDificulty) is a BE s32 whose value (0..3) sits in byte 0xF on N64. d88_propdefs.py _bswap32's the word (correct; it IS a 32-bit field), moving the value to byte 0xC on LE, so the s8@0xF read yields 0 for every objective. Consequence was total: get_difficulty_for_objective → 0 for all → objectiveIsAllComplete() on Agent evaluated objectives that should be difficulty-gated out → never TRUE → end_of_mission_briefing() (the campaign-unlock EEPROM write) never fired → no solo level ever unlocked the next. Fix: reorder the struct's post-swap tail under #ifdef PORT so the named byte reads offset 0xC. Grep every struct that reads a sub-s32 field as s8/u8/s16/u16 at a NON-zero in-word offset; after a converter bswap that offset is wrong. Offline tools have the same trap (dump_objectives.py read byte 0xC = BE high byte = always 0).

  • D159; an N64 "pre-swap for the RDP" texture massage is poison on PC because fast3d doesn't emulate the RDP. texSwapAltRowBytes (image.c) pairwise-swaps the 8-byte (u32) groups of every odd texture row before upload, to cancel the N64 RDP's odd-line TMEM address XOR (address bit 2) that fires during 4-byte-word gDPLoadBlock loads of I/IA/RGBA16 formats. fast3d has zero odd-row handling, so the pre-swapped odd rows upload scrambled → an 8-texel "venetian blind" / interlace comb. Invisible on small or distant textures, glaring on large 1:1 front-end images (wallet-Bond photo, passport crest). Fix: #ifdef PORT no-op the function; fast3d wants a plain linear image. General rule: any src/ routine whose comment or shape says "for the RDP / TMEM / N64 hardware" and that reorders/massages bytes is a port-layer candidate (same family as the K0-fold and interrupt-mask shims), not game logic; audit image.c / tex.c for others (texAlignIndices row padding is load-bearing and must stay; the swap is not).

  • D160; a "propDef command-index walk desync" is only possible when sizepropdef() and the offline converter disagree on a record size. Since D122/D126/D132 closed every gap, d88_propdefs.PROPDEF_PC_BYTES[t] == sizepropdef()×4 for all 48 types, the propDef stream tiles byte-exactly, and setupGetPtrToCommandByIndex / tagGetCommandIndex (object = sizepropdef(object)+object, sizeof(PropDefHeaderRecord)==4) walk in lockstep with the converter. Before hypothesising a propDef walk desync for a new symptom (M-31 exhaustively re-verified: every record type, all 21 levels, byte-exact tiling), run the stride cross-check first; if it's clean, the bug is elsewhere. The Dam rappel cutscene (D148) reached this wall: data path proven intact; residual cause is runtime AI-script control-flow / CAMERAMODE_POSEND cinematic render (D75 family). GE_D160=1 diagnostic ships; needs a live Dam-to-exit playthrough. M-106 addendum: independently re-derived (not just re-cited); CutsceneRecord (propDef type 46) has zero pointer/union fields, so it cannot exhibit this class at all; confirms the M-31 stride audit rather than superseding it. D148/D160's residual cause is still unlocated (needs the live trace); see D173 for a related negative result on the puppet-position side.

  • D132/D126 corollary; a zeroed ROM-serialized pointer-width tail slot is not automatically a converter bug; check the N64 source literal first (M-106). Before assuming an offline converter wrongly zeroed a widened pointer field (the D122/D126/D132 "dead-on-load" pattern), grep the asset .c source tables (assets/obseg/**/*.c) for that field's literal initialiser. PadRecord.stan (src/bondtypes.h:1744-1751) looked exactly like a D126/D132 miss (a ROM- serialized tail pointer, zeroed by tools_pc/d88_emit.py's emit_pad()), but every PadRecord in every checked setup table (UsetupdamZ.c etc.) initialises stan to literal 0; N64 never carried a live pointer there either, so the PC zeroing is byte-identical to ground truth, not a regression. The tell that saves the trip: if the source, not just the ROM binary, always writes 0/NULL for a field, it was never "dead-on-load" in the D123-corollary sense (a field that reads a real small int/id before being overwritten); it's simply always runtime-computed, on both platforms, and a NULL seed into whatever consumes it is the original game's normal case, not a port defect.

  • D151; a ROM-serialized s32 slot decoded by the struct as [u16 hi][u16 lo] reads zero on LE. N64 code frequently splits a 32-bit setup-stream word into u16 reserved; u16 realvalue; where the useful value is always small and lands in the low 16 bits of the big-endian word. The offline converter correctly _bswap32's the word (it IS a 32-bit field), which puts the value in the low bytes; but the struct still reads realvalue from the high offset → 0. Instances: struct watchMenuObjectiveText.text / struct objective_entry.text (propDef types 35 / 23); every watch briefing + objective line rendered blank (langGet(0) → NULL). Fix: #ifdef PORT widens the field to a full u32 at the word offset; converter unchanged. Grep every ROM-serialized struct for a u16 reserved/u16 pad immediately before a u16 the runtime actually reads.

  • Python slice-assignment width is a silent corruptor (D125): out[a:a+8] = b"\x00\x00\x00\x00" on a bytearray does NOT raise; it deletes (8 - len(rhs)) bytes, shrinking out and shifting everything after a down. A widened pointer/stan slot filled with a 4-byte literal shrank d88_emit.py's output 4 B per pad; once len(out) fell below a later region's write offset, out[off:off+n] = data clamped to an empty range at the current end and inserted there; string blobs drifted, boundpad names truncated (p138d2→8d2), stanPackId rejected → door model=NULL → crash. When emitting into a pre-sized bytearray, every out[a:b] = rhs MUST have len(rhs) == b - a; grep the emitter for width-mismatched slice writes when a converted region lands wrong.

  • A one-shot "sync patched values from the ROM copy into a compiled shadow array" pass must key its slot-detection on a field that is still intact at sync time. D124: gimgSyncCompiledGlobalDLs looked for the 0xABCDxxxx IMAGESEG marker in the ROM copy; but texLoad() had already overwritten every one of those with a real pointer, so the sync silently copied nothing and the compiled globalDL_0xNNN explosion DLs kept their link-time markers (latent on all levels; only tripped when an explosion/smoke/particle DL is first drawn). Detect the slot from the destination (compiled) array, which still holds the marker.

  • A polymorphic-record converter with a per-type handler table + a "generic" fallback silently corrupts any type the table forgot: the fallback's word-granular bswap is wrong for sub-word fields. D122; d88_propdefs.py had no arm for 6 inherits ObjectRecord propDef types (TINTED_GLASS/VEHICHLE/AIRCRAFT/TANK/AUTOGUN/AMMO); the generic arm bswap32'd the [s16 obj][s16 pad] word as one u32, putting the model id in the wrong half → OOB table deref. When you add a converter, enumerate every type byte a level can emit and assert the handler set is total; [u16|u16] / [s16|s16] packed words need a half-swap (_hh_word), never a 32-bit swap.

  • D123 corollary: a pointer-width tail slot is not always runtime-populated. Some hold a small integer id in the ROM image that game code reads via that same field before overwriting it (VehichleRecord/AircraftRecord.ailist: prop.c:1764/1786 does x->ailist = ailistFindById(x->ailist)). A converter that lumps every pointer-width tail slot into one "widen 4→8B, emit zeroes" bucket destroys the id → ailistFindById(0) silently returns global list 0 (GAILIST_AIM_AT_BOND) → ai() runs a CHR aim list with a NULL chr → NULL deref in chrIsNotDeadOrShot. Before zeroing a widened slot, confirm the setup code writes it unconditionally; if it reads-then-writes, emit the value (_bswap32 into the low 4 bytes, LE). Decide per field, not per type.

  • Audit for ROM segments loaded raw with no fixup at all (D178). Every _fileNameLoadToAddr() / _fileNameLoadToBank() call site loads a raw big-endian ROM image; the ones with a struct overlay need a BE→LE pass and there is nothing in the load path that supplies one; the fixups are bolted on per call site (langFixupLoadedBank() in language.c, romdataFixup*() in port/src/romdata.c). The briefing segment (Ubrief*Z = u16 brief[4] + 10 × {u16 textid; u16 difficulty}) had none for a year, and its failure mode is silent and split: a swapped string id (0x2C04 → 0x042C) still passes != 0, so the loop body runs and langGet() quietly returns NULL → blank text, not a crash (that was the whole of the D143 "briefing/objective text is blank" residual); and a swapped small enum (0x0001 → 0x0100 = 256) turns a >= difficulty gate into an always-false filter, so lines silently vanish rather than render wrong. When a screen renders its own chrome/headers correctly but the file-driven rows are empty, suspect the raw file, not the renderer: dump the first words of the loaded blob and look for a byte-mirrored constant you can recognise from the asset .c source in assets/obseg/.

C2. Port-layer / SDL shims

  • #include <PR/os.h> in a port .c/.h that also sees <errno.h> breaks: OSContStatus/OSContPad have a u8 errno; field vs errno.h's macro. libultra.c wraps the include in #pragma push_macro("errno") / #undef errno; cleaner for a new module is to duplicate the handful of CONT_* bits it needs (D118 input.c).
  • GE's aim model is mode-dependent (bondview2.c bondviewProcessInput): in hipfire (!insightaimmode) yaw = analog stick-X ("natural turn") and pitch = digital C-up/C-down only (stick-Y is move fwd/back); in aim mode (R held) yaw and pitch are analog; stick pushed past ±60 → proportional (stick-60)/10; and C-up/C-down mean crouch/lean/zoom, not aim. So the mouse→pad map must also be mode-aware: aim mode pushes the stick into the 61..80 band and emits no C-buttons (emitting C-down for "look down" while aiming = crouch, D118c); hipfire keeps the digital C-button pitch. input.c reads its own RMB/LShift state as the mode proxy (exact for hold-to-aim; a toggle scheme needs g_CurrentPlayer-> insightaimmode). GE's native pitch is inverted (C-up → look down); hide it so mouse-down looks down; MouseInvertY flips (D118, M-24).
  • osContGetReadData(pad) must fill one OSContPad per channel (MAXCONTROLLERS-long array), not just controller 0; joy.c passes the whole samples[i].pads array (D118).
  • Controller state has one source: port/src/input.c. libultra.c's SI section marshals inputComputePad() into g_contPad[]; it is driven by osContStartReadData (per logic tick), no separate video.c frame hook.
  • The in-level watch/pause is not a "menu" to input.c (current_menu stays RUN_STAGE), but it reads the stick as discrete input: ±0x2E on X picks an option value or flips a page, and Y scrolls rows. Any path that turns a continuous device (the mouse) into stick deflection must be gated off there (!outside_watch_menu || pause_state), or small hand motion becomes phantom menu choices. D330/#103: mouse → legacy stick fallback silently reset Auto-Aim/Look-Ahead to OFF. The direct-write look paths already gate on this; the fallbacks must too.

D. N64 hardware idioms fast3d does not emulate

  • Appending a port-owned 2D overlay to the game frame: hook inside gfx_run(), not videoSubmitCommands. gfx_run() (gfx_pc.cpp) is monolithic; it does start_frame → gfx_run_dl → gfx_flush → end_frame → swap_buffers_begin in one call, and the game DL is executed from osSpTaskStartGo (libultra.c), not through videoSubmitCommands (which is dead). So a second gfx_run() per frame is not possible (it swaps buffers). The clean seam is a one-line C hook between gfx_run_dl(commands) and gfx_flush() that runs a second gfx_run_dl() on a port-built DL when non-NULL (D184, F10 options overlay). The overlay DL is self-sufficient: gDPPipeSync + gDPSetCycleType(G_CYC_1CYCLE) + gDPSetTexturePersp(G_TP_NONE)

    • a full-screen gDPSetScissor + microcode_constructor() (the game's own 2D combiner/rendermode prologue), then gDPFillRectangle / textRender (game symbols, extern'd like input.c externs current_menu; UI, not logic). 2D pixel space is viGetX() x viGetY(). Return NULL when the overlay is closed so the frame is byte-for-byte unchanged.
  • Verbatim RDP triangle commands chopped into gImmp1(G_RDPHALF_1/_CONT/_2) pairs (GE's skyRenderTri/skyRenderFull hand-build an edge-walked G_TRI_FILL/G_TRI_SHADE_TXTR for the modified RSP ucode to reassemble) are silently dropped by fast3d; it has no RDP triangle rasteriser. Substitute a normal gSPVertex + gSP*Triangle batch behind #ifdef PORT: if the game code already screen-space-projected the verts, re-emit them under a guOrtho(l,r,b,t,…) + identity modelview matching the projection's pixel extents, #else keeps the stream verbatim (D176(a), M-46). Caveat (D176(a), fidelity pass): a plain w=1 ortho is only correct when every vertex in the primitive really does share one depth/scale. The RDP's own coefficient block for this idiom carries S·w′/T·w′/w′ per vertex (a real perspective-correct texture unit divides it back out per pixel) whenever the upstream game math computed S/T from a world-space projection without dividing by w itself (GE's sky does; unk20/24 are texel coords on the world cloud-plane intersection, not pre-divided). Re-emitting those under w=1 makes fast3d's GPU pipeline interpolate texture coords linearly in screen space instead of perspective-correctly; invisible when the primitive's per-vertex w barely varies, but a primitive spanning near-camera to horizon-grazing geometry can have >30x w spread between its own vertices, and linear interpolation then smears the entire S/T delta evenly across every pixel instead of concentrating it near the far/horizon vertices; reads as "tiles too densely / scrolls too fast" everywhere instead of a gentle gradient. Fix: build a small custom projection matrix that gives each vertex its real w (any per-vertex camera-space w the game math already computed) while keeping the same screen-space x,y (clip.xy = ndc.xy·w, clip.w = w; the GPU's normal divide recovers the intended ndc.xy regardless of w, only the interpolation changes); Vtx.ob[] is s16 so pre-scale by a per-primitive wScale and have the matrix multiply it back in. Follow-on caveat (D227, M-95): compute that wScale once per shared-vertex group, not once per triangle. If a decompiled routine fans one shared vertex pool out across several separate calls to a per-primitive perspective- correction helper (one call per triangle instead of one call for the whole fan) and each call derives its own scale factor, two triangles sharing an edge/vertex can each independently pick a different scale; and because the scale feeds into a low-precision (s16) vertex field, the same logical vertex quantizes to two slightly different values depending on which call computed it. The position math stays exact for any scale (it's just a rescale that cancels out algebraically), so this isn't a logic bug and won't show up as wrong geometry; it shows up as a visible crack/seam at the shared edge, easy to misread as "two separate things happening" rather than one quantization mismatch. Fix: hoist the scale computation to scan every vertex in the whole shared group once, before the per-triangle calls, and thread that one value through instead of letting each call recompute its own. Follow-on caveat #2 (D227, M-98): fixing ONE per-primitive-derived quantity this way doesn't fix every one. The same routine can derive more than one such "shared state from the whole vertex group" value; GE's sky code also floors S/T into a per-primitive range fold to keep it in s16 (floorf(minS/64)*64, sky.c's skyPortRenderPoly). Sharing wScale across the fan and leaving the fold per-call left the exact same seam-shaped bug alive one field over: nominally-identical shared vertices still bake different absolute values (here, off by an exact multiple of the fold period) into each triangle's own vertex buffer. When you find one "derive this from only my local call's inputs, should derive from the whole shared group" bug in a routine, grep the same function for every other value computed the same way (any per-call min/max/floor/round over a subset of a larger shared vertex/primitive pool); don't assume fixing the first one you found closes the whole defect class.

  • Z buffer cleared by pointing the color image at it + fill-rect → does nothing in fast3d; must emit G_CLEAR_DEPTH_EXT (D105).

  • LOD / detail mip tiles: fast3d fabricates a crop when detail textures are off → force base tile (D107).

  • A CI-format tile drawn with the TLUT disabled (G_TT_NONE) is not a palette texture; the N64 RDP feeds the raw TMEM texel straight into the color pipe, i.e. a CI8+G_TT_NONE tile behaves as I8. fast3d's import_texture() dispatched purely on tile.fmt and did a palette lookup against a stale rdp.palette → garbage (D161, GE Depot ceiling = blue speckle). Fix: when rdp.palette_fmt == G_TT_NONE, route CI4/CI8 → I4/I8.

  • CI texture cache must key on palette CONTENT, not just the TLUT source address (D217). GE assembles weapon/character model DLs in scratch arena RAM and reissues gDPLoadTLUT from a repeated source address with different palette content between materials and frames. fast3d keyed CI textures on {texel addr, palette_addrs[0/1], palette_index, size_bytes} with no content check → a later material takes a stale cache HIT and samples the GL texture decoded for an earlier palette. Fix: FNV-1a the 512-byte rdp.palette in gfx_dp_load_tlut (rare vs draws) and fold that hash into the CI cache key.

  • GE model-DL TLUT payloads carry a 2-entry leading zero pad (D217, still open). Every larger weapon/chr model TLUT decodes with entry[0]==entry[1] ==0x0000 and real colour from index 2. count=1 model TLUTs that feed palette index 0 (solid-fill weapon parts; the PP7 grip) are the canary: if the port's model-TLUT source pointer is even 4 bytes off, index 0 reads adjacent texel/blob bytes and the part renders a wrong flat colour (green on Facility/Depot, black-by-luck on Bunker1) while larger tiles look plausible because they never index the low entries. Fix is upstream (segment resolution or tools_pc/d43_emit.py blob placement), not in the fast3d decode. M-88 correction: the "segment resolution / d43_emit.py blob placement" theory above is refuted. Traced the full reference chain: model files store textures as a numeric TextureID (ModelFileTextures, src/bondtypes.h) into the global Globalimagetable (fixed once at runtime by port/src/gimgfixup.c, D68; not per-model, not per-level, and not touched by any tools_pc/d43_*.py script); pixel+palette bytes are decoded fresh each level load by texInflateZlib/NonZlib (src/game/image.c) into a runtime pool, with the palette appended after the pixel data (tex->unk0a records the boundary); there is no ROM-serialized or offline-converted "model-TLUT source pointer" to be off by 4 bytes. General rule this adds to the catalogue: a "same asset, different bytes on different levels" symptom for a texture/model resource does NOT imply an offline-converter/sidecar bug; GE's model and image pipelines resolve almost everything through small numeric IDs into shared, runtime-decoded pools; the offline tools_pc/d43_*.py scripts only ever touch model geometry (nodes/GDL structure/vertex data), never texture or palette content. Before chasing a converter offset for a texture-content bug, first prove the content is even sidecar-owned (grep the relevant d43_*.py/d88_*.py/d69_*.py for the field name; if it isn't there, the bug is runtime: src/game/image.c/tex.c (decode) or port/fast3d/gfx_pc.cpp (RDP emulation)). Current leading hypothesis for the real mechanism (unconfirmed, needs a fast3d-side probe out of this session's scope): gfx_dp_load_tlut's palette-source addressing (port/fast3d/gfx_pc.cpp:2202) uses pitch = rdp.texture_to_load.width + 1 from the preceding gDPSetTextureImage, which texWriteLoadToTmemAddr (src/game/tex.c:503,519) always calls with a literal width=1; a software-RDP approximation that may not correctly reproduce hardware LOADTLUT byte-addressing for this width=1 special case. See docs/dev/findings.md §D217 "M-88" for the full trace + empirical GE_TEXDUMP repro (BUNKER1 pal[0]=0001 vs FACILITY pal[0]=1a00, exact match to the originally reported bytes).

  • RC3 / D167; GL GL_REPEAT wraps at the uploaded image size; the N64 RDP wraps a render tile at 1<<masks (= ceil(log2(dim)) for GE, texDimensionToMask). Equal for power-of-two textures, so this is invisible almost everywhere; but a non-PoT wrapping surface (Depot's 65×65 / 96×48 room textures) repeats at the next power of two on console and one image-width too soon in the port ("textures repeat oddly", squashed pattern, wrong seam). fast3d didn't even keep masks/ maskt. Fix lives behind Video.WrapFix (default OFF, GE_WRAPFIX env override): store the mask, fold the UV at 1<<mask in the hoisted per-texunit pre-wrap block, clamp the no-real-texels [dim,1<<mask) overflow band to the edge. The overflow band is a TMEM smear on real hardware; not exactly emulable in a fixed GL sampler, so this is an approximation. Any new fast3d UV/wrap work: the tile mask fields are the wrap period, NOT the tile-window or image size.

  • K0 segment-address folds (OS_K0_TO_PHYSICAL | 0x80000000) and hand-inlined BG_SEG_TO_PTR need the (u32) 32-bit wrap the macro has (D58, D84).

  • Portal near-plane: z==0 clip points project to ±1e20; x86-64 float garbage can come back min>max / non-finite and slip past a degenerate-box check that clamps to full-screen on N64 (D106).

  • fast3d CPU-side "trivial reject" (v1->clip_rej & v2->clip_rej & v3->clip_rej in gfx_sp_tri1) trusts per-vertex outcodes (x<-w, x>w, etc.) that are only valid half-space tests when w>0. A vertex behind the camera (w<0) flips the comparison sense, so its outcode can be wrong; if that spurious bit happens to match the other two (correct) vertices', the AND-reduction drops a triangle GL's own clipper would have rendered correctly. Bites large near-camera polygons (room walls/ceilings close to a doorway) far more than small prop models; reads as "background geometry flickers away, props keep drawing" (D233). The backface-cull code right below already special-cases this same w-sign hazard for its cross-product sign; the trivial-reject block didn't. Fix pattern: skip the AND-test (don't trivially reject) whenever any vertex has w<0; safe, since it only ever adds triangles, never drops more. General lesson: any CPU-side "skip this triangle" heuristic in fast3d needs the same w<0-behind-camera exemption the existing backface culling already has, or it'll silently eat near-camera geometry exactly where a port is most likely to get scrutinized (doorways, tight corridors).

  • gfx_sp_tri1's G_CULL_BOTH backface-cull cross-product (gfx_pc.cpp, right below the D233 trivial-reject guard) divides raw vertex w with no epsilon guard. A vertex at/near w≈0 during a near-plane crossing can NaN/Inf the cross product, which silently fails both the cross<=0 and cross>=0 cull branches and lets a triangle draw that should have been backface-culled — a candidate for D288 (Silo intro stray triangle), not yet confirmed against a live repro (M-152). Unlike D233's fix, this one hasn't been shown to actually fire yet; it's a code-review lead, not a confirmed mechanism.

  • When chasing a missing UI element, split "game logic decides to draw it" from "fast3d actually rasterizes it." This codebase's decomp side for menu/watch item rendering is consistently byte-matched and rarely at fault; the recurring bug class is a combiner/blend-mode or texture-format edge case in port/fast3d/gfx_pc.cpp for a one-off translucent/PROP_TYPE_PLAYER draw that doesn't share a code path with the common in-level rendering (D172 particle color, D217/D228 CI/IA16 palette, D229 water blend, D233 invisible-through-doors, and D290's leading hypothesis — the watch controller graphic's fade-in uses a low-alpha PROP_TYPE_PLAYER combiner state nothing else exercises). Confirm the src/game/ side is clean first (item ID, resource-table entry, model header, generic equip/gate pipeline) before spending time in fast3d — but once it is, look there, not back at the decomp logic.

  • portHeartbeatCheck (port/src/libultra.c:218) hard-caps at 3 reports total per process lifetime, by design ("stop after a few reports"). When chasing a long-idle hang (D287), "the heartbeat stopped logging" is that cap firing, not evidence the hang resolved, worsened, or changed mechanism — don't read silence from this diagnostic as a signal. Gate a raised/unlimited report count behind its own env var for these investigations instead of trusting the default cap's silence.

  • Before citing a scratch/*.log/.txt capture as "evidence" in a finding, grep it for the specific numbers/strings the write-up claims (D287): a file with the right name on disk is not proof it holds the claimed capture — this one turned out to be an unrelated normal-exit run, not the freeze it was cited for, and the mistake propagated through the entry unnoticed until a later static pass checked.

  • D74 wrap-block is DEAD CODE (gfx_pc.cpp:1546/1551): guard is cms & G_TX_WRAP but G_TX_WRAP == 0, so always false (line 1887 does it right with cms == G_TX_WRAP). And if it did run it indexes tex_width2[i] etc. (arrays [2], per-texunit) with the vertex loop index i 0..2 → OOB. Latent; harmless for CLAMP glyphs, would matter for wrapped textures on tris. Reworked M-30 (hoisted out of the vertex loop, indexed by texunit, gated on Video.WrapFix); RC3/D167 adds the non-PoT mask-period case to the same block. Still default OFF.

  • assert()-based SUPPORT_CHECK is a silent no-op in the release build. port/fast3d/gfx_pc.cpp:39 defines SUPPORT_CHECK(x) as assert(x), and the PC build compiles with NDEBUG. Every SUPPORT_CHECK in the file therefore documents an assumption that is never enforced; when it is violated the code silently reads wrong data instead of aborting. Seven of them assert full_image_line_size_bytes == line_size_bytes in the import_texture_* family (D183); treat any SUPPORT_CHECK as a TODO comment, not a guard. Corollary for triage: "there is an assert for that, so it can't be happening" is never valid reasoning in fast3d.

  • Diagnosing a "wrong texture" needs the raw importer input, not the uploaded RGBA. GE_TEXDUMP dumps the post-decode image, which cannot distinguish "the decoder is wrong" from "the source bytes are garbage". GE_TEXRAW=1 (D183) dumps the bytes as handed to import_texture_*. The cheap offline test on such a dump: compute the mean vertical neighbor-difference at every candidate row pitch; a correctly-pitched real image has a sharp minimum at its true pitch (≈0.2–0.7 on a 0–15 nibble scale), a pitch/shear bug has the minimum at a different pitch, and genuine noise data is flat (~3.7) at every pitch. That three-way split settles decode-vs-pitch-vs-source-data in one pass with no rebuild.

  • D219; a runtime bitstream decoder that stores a "wide" (>1 byte/pixel) reconstructed value via a native machine write needs an explicit byte-order fixup on PC; a compile-time C-array asset does not. And the fixup is per importer family, not per texture: scope it by pixel format, never by texnum range or compmethod (M-113/M-114/M-115). src/game/image.c's texture decompressor (texReadUncompressed, texChannelsToPixels, texBuildLookup, texInflateLookup, texInflateLookupFromBuffer) builds each wide pixel as a shifted-OR integer and does a native store; correct on N64 (native == big-endian), byte-reversed on x86-64. This is the opposite of the usual "BE ROM data read as LE" direction in this section: here the PORT host writes native-endian and a downstream shared, otherwise-correct consumer has opinions about byte order. The trap M-113→M-114 hit: the fast3d importer family is NOT uniform. import_texture_rgba16/_ia16 (G_IM_SIZ_16b: RGBA16, RGB15, IA16) read the pool as manually big-endian bytes ((addr[0]<<8)|addr[1]) → the decoder must store bswap16(value); import_texture_rgba32 (G_IM_SIZ_32b: RGBA32, RGB24) does PD_BE32() on a native u32 load → it wants host-order words and a store-time swap inverts them (that was the muzzle-flash-blue/ammo-pink regression). So the correct scope is the decoded TEXFORMAT_* (which every call site already switches on), not compmethod (the fire frames span LOOKUP/RLE/HUFFMANBLUR/RLELOOKUP) and not a hardcoded texnum range. Static assets/*.c C-array textures don't hit this because gfx_tex_normalize_source (D71) already normalizes them upstream of import_texture_*; only assets that are BOTH genuinely multi-byte-per- pixel AND decompressed at runtime through texLoad's bitstream path are exposed. Offline ROM census (M-115): 94 such images; the 16-bit family is exactly IMAGE_FIRE_0..14 + texnum 1198–1201/2430/2510–2523, and every ammo/flare/crosshair/muzzle-flash wide-pixel image is RGBA32. Only the fire frames were eyeball-verified (M-115); texnum 1198–1201/2430/2510–2523 were corrected by the importer contract, not seen on screen. If a sprite in any level ever renders with wrong or inverted colors, check first whether its texnum is in that list (and decode it with GE_D219RAW=1 against scratch/d219_census.txt). General rule for this bug class: before "fixing" a shared decoder's byte order, enumerate every consumer of the buffer and check which endianness each one assumes; the fix belongs at the store site, keyed on the format each consumer family expects. Final state (M-115, resolved): PORT_PIXEL16 = bswap16 under #ifdef PORT (16-bit family only), PORT_PIXEL32 = identity, wrapped around every wide-pixel store in the five functions above; CI/I4/I8/IA4/IA8 (single byte, no byte order) untouched. The intermediate history (M-113's blanket swap regressing muzzle flash/ammo HUD (M-114), reverted to identity, then M-115's format-scoped re-application) is in docs/dev/findings.md D219.

  • The tile's declared format is not the source data's format when GE's custom ucode transforms TMEM at load time (D229). texSelect (src/game/othermodemicrocode.c) loads CI8 water images with gDPSetTextureImage(CI,16b) + gDPLoadBlock (the 16b is only the 4KB-per-block load convention: lrs counts 16-bit units, so 700 × 2 = 1400 bytes of raw 8-bit indices), and GE's RSP ucode expands indices→RGBA16 in TMEM at load; after which sub_GAME_7F09343C (src/game/unk_092E50.c) re-declares the slot as RGBA/16b and samples blue. fast3d has no such transform: it trusted the tile declaration, read index byte-pairs as 16-bit texels (g = 2·idx mod 32 dominates → green mottle), and the water's pre-existing sinf() PRIM_LOD_FRAC cross-fade read as "pulsating". Fix (port/fast3d/gfx_pc.cpp, M-116): record the G_IM_FMT of each SetTexImage into LoadedTexture.src_fmt at load time; in import_texture, an RGBA/16b tile whose slot's last load came from a CI source routes through the CI8 palette import; the port equivalent of expand-at-load. Fires only on a genuine format disagreement, so real RGBA16 textures are untouched. General rule: when a GE draw declares a tile format that looks wrong for the data it loaded, suspect the ucode's load-time transform (ginit.s is missing from this repo; the expansion lives there), not UV, shade, or the combiner.

D12. "Collapse an LOD binding to tile 0" is wrong for a two-texture DETAIL binding (D236)

M-198 (finding D236 pass 26). GE's texLoadFromGdl resolves a room DL's G_NOOP texture reference through one of three handlers, chosen by in->words.w0 & 7 (src/game/tex.c):

type handler TMEM layout
0 TEXTURETYPE_LOD texHandleType0 ONE load block at TMEM 0; tiles 0..n are LOD levels of that one image
1 TEXTURETYPE_DETAIL texHandleType1 TWO load blocks: the detail texture at TMEM 0, the base image at TMEM texGetSizeInBytes(detail,0) on tiles 1..
2 TEXTURETYPE_MIPMAP texHandleType2 as type 0

fast3d's gfx_lod_tile_offset (port/fast3d/gfx_pc.cpp) folded every G_TL_LOD draw to tile 0, on the reasoning (D107) that "GE loads the whole mip chain at TMEM 0". That is true for types 0 and 2 and false for type 1, where TMEM 0 holds the detail texture and the image you actually want is on tile 1. The port therefore drew the detail texture in place of the base image — and never even imported the base image, so every probe looking for it came back empty.

On Surface 1 this was the whole of D236: all four treeline cards (tex1198–tex1201, RGBA5551 64×17 cut-outs) are type-1 bindings sharing one detail texture, tex2465 — an opaque 32×32 IA8 noise tile. Result: an opaque tiled noise wall where the treeline belongs, for 20+ investigation passes.

Tells. (a) A texture the game demonstrably decodes into the pool is never imported by the renderer — that is a binding bug, not a decode, alpha, mip or geometry bug, and no amount of work on the texture's contents will move it. (b) The wrong thing drawn is itself a real, correctly-decoded texture from the same draw — look for a second texture bound by the same command.

Discriminating the two layouts at draw time. Prefer an invariant that a mip chain can never satisfy. Tiles 1.. of a type-0/2 binding are LOD levels of tile 0's image, so they necessarily carry the same fmt/siz; a type-1 pair is two unrelated textures and generally does not. Deliberately choose the asymmetric test — a mip chain can never trip it, and a detail pair that happens to share a format merely keeps the old behaviour. Two weaker discriminators were tried live and both misfire: "tile 1 sits at a different TMEM address" matches ordinary mip chains (their levels are at different TMEM offsets too), and "tile 1's TMEM slot has a registered load" matches them as well, because rdp.loaded_texture[] is persistent state that is never cleared between binds.

D2. The HUD/model "X-mirror" (D114/D116): RESOLVED, it was an upside-down capture

M-33 (finding D168). There was no mirror. gfx_opengl_dump_bound_fbo (port/fast3d/gfx_opengl.cpp) wrote glReadPixels output; bottom-row-first, GL origin; straight into a top-row-first P6 PPM, so every GE_PCDUMP and F12 capture was vertically flipped. Sessions M-6/M-7/M-8/M-11 kept finding textRenderGlyph → GBI → fast3d → buf_vbo → GL each verified non-mirrored; because nothing was mirrored; they were staring at upside-down screenshots of asymmetric content (text, ammo digits, guard skins, the Nintendo logo) and reading "inverted" as "X-mirrored". The developer confirms the game renders correctly on real hardware.

The lesson worth keeping: when every stage of a pipeline probes clean but the output "looks wrong", suspect the observation tool before adding a correction. A cosmetic defect that no probe can localise after four sessions is a strong signal that the defect isn't in the code. Fix: PPM writer now emits rows top-to-bottom; tools_pc/golden/*.png were flipped to match.

D3. GCC/mingw makes an all-non-negative enum UNSIGNED

The N64 toolchain treats enum as signed int; GCC on the PC target gives an enum whose enumerators are all ≥ 0 an unsigned underlying type. Any descending loop that relies on the counter going negative to terminate then spins forever:

for (s = SP_LEVEL_EGYPT; s >= SP_LEVEL_DAM /* == 0 */; s--)   // never ends
  • Symptom: a silent hang (kernel-heartbeat stall, no crash log) inside a loop over an enum range; the counter holds a huge value in gdb.
  • Instances: D142; LEVEL_SOLO_SEQUENCE in fileGetHighestStageDifficultyCompletedForFolder froze the SELECT FILE screen. DIFFICULTY was already safe (DIFFICULTY_MULTI = -1).
  • Fix: add a never-used negative sentinel enumerator under #ifdef PORT (SP_LEVEL__PORT_SIGNED = -1); forces the type signed, first real enumerator stays 0, sizeof stays 4, no stored value changes. Audit any enum used as a descending / >= 0 loop counter or in ROM-serialized structs where signedness matters.

D4. N64 "interrupts off" is not free on PC; it must be a real lock

osSetIntMask(OS_IM_NONE) … osSetIntMask(saved) on N64 makes a region atomic w.r.t. every interrupt (audio, VI, SI). libaudio, the scheduler and a few others use it as their only mutual-exclusion primitive. On PC the "audio interrupt" is a real preemptible thread (amMain), so a no-op osSetIntMask shim = no mutual exclusion = concurrent linked-list mutation.

  • Symptom: hang (spin) inside a list walk that another thread is editing; e.g. D147: alEvtqPostEvent (main thread, via sndPlaySfx on a door close) vs sndRemoveEvents (amMain) on the same ALEventQueue.
  • Fix: osSetIntMask → one process-wide recursive mutex. OS_IM_NONE acquires; the OS_IM_ALL token returned from that call is what every paired restore passes, so OS_IM_ALL releases; other specific masks (OS_IM_VI) pass through. Safe because the decomp never blocks while holding OS_IM_NONE (N64 contract).
  • D152; the recursive-mutex model is fragile; it CAN deadlock. The "decomp never blocks while holding OS_IM_NONE" assumption fails on heavy ALEventQueue paths: libaudio has unbalanced / early-return osSetIntMask calls, and a transient thread can acquire OS_IM_NONE and exit without the paired OS_IM_ALL, leaving s_imLock owned forever → every later alEvtq* on mainThread + amMain blocks in pthread_mutex_lock → hang. Seen on the mission-failed audio fade-out (sndSetScalerApplyVolumeAllSfxSlot → per-frame alEvtqPostEvent storm) = permanent black screen. Mitigation shipped (M-28): osSetIntMask now tracks owner/depth under a short bookkeeping mutex + condvar; a waiter blocked > 2 s steals the section and logs the stale owner + the stealing caller's return address. Self-heals any leak (worst case ~2 s audio hiccup). Proper narrow fix (dedicated ALEventQueue lock) still owed; do it once the steal-log names the leaking call site. §F D152.
  • D152 addendum (M-31 static audit). Every osSetIntMask(OS_IM_NONE) in the compiled audio code was audited: event.c (alEvtqNextEvent, alEvtqPostEvent; its lone early return at event.c:86 does restore first; alEvtqFlush, alEvtqFlushType likewise), csplayer.c __CSPRepostEvent, synaddplayer.c alSynAddPlayer, snd.c sndRemoveEvents / sndSetupSound / sndDeactivateAllSfxByFlag. All balanced on every path; the §F guess "libaudio has unbalanced early-return mask paths" is not borne out. Two real problems remain and were fixed #ifdef PORT: (1) sndSetSfxSlotVolume (snd.c) walks the live ALSoundState list and posts to the shared ALEventQueue without holding OS_IM_NONE; unlike its structural twin sndDeactivateAllSfxByFlag, which does. On PC that is an unguarded walk racing amMain and one lock acquire/release per matching sound; during the mission-failed fade (sndSetScalerApplyVolumeAllSfxSlot → sndApplyVolumeAllSfxSlot → this, per frame) that is a per-frame lock-acquire storm; exactly what the §F dump means by "hammers the queue hard enough that the lock is left owned." Fix: hold the mask once across the whole walk (nested alEvtqPostEvent then hits the recursive fast path); also wrap sndApplyVolumeAllSfxSlot's slot loop so the whole update is one recursive hold. (2) The actual leak is the "transient thread acquired OS_IM_NONE and exited" case (the dump shows New Thread/exited churn). portThreadWrapper now calls imThreadExitRelease() after the thread's entry returns: if that thread still owns s_imHeld, release the orphaned section immediately (+ LOG_ERROR). Removes the wedge and the 2 s steal hitch, and stops the re-wedge that happens when a new host thread reuses the dead thread's pthread id and imAcquire mis-detects recursion. Steal-lock stays as the last-resort backstop for a genuinely unbalanced same-thread path. Rule: when two sibling functions walk the same list and post to the same queue, they must take the same lock; grep for one holding OS_IM_NONE and the other not.

D5. Loop bounds that assume linker adjacency of two file-scope globals

N64 decomp sometimes ends an array walk with end = &nextGlobal; where nextGlobal is the next file-scope definition in the .c. The N64 toolchain emits .data/.bss in source order so &nextGlobal == array + ARRAY_COUNT(array); mingw/GCC on the PC target reorders globals, so end can land before the array (loop runs 0–1 times) or far past it (walk off the end).

  • Instance: D164; constructor_menu00_legalscreen (front.c) bounds the 12-line legal-screen text loop on &legalscreen_MRD, which mingw links 0x60 bytes before legalpage_text_array → only line 1 renders (== the D76 "disclaimer half-drawn" bug; it was never an image-table issue). Fix: #ifdef PORT uses array + ARRAY_COUNT(array).
  • Grep for = & / (TYPE *)& on the RHS of a loop-terminator compare, and any for/while/do whose end pointer is the address of a different symbol than the one being iterated.

D6. Non-void function with no return; latent until -O2

The decomp has a handful of functions declared to return a value (often Gfx *) whose body ends without a return; the IDO/N64 build happened to leave the intended value in $v0 (usually the result of a tail call), and the caller's x = f(...) kept working by luck. GCC on the PC target does too at -Og, but at -O2 the return register is genuinely undefined and the caller reads garbage. When the value is a display-list cursor, the caller then keeps writing the DL from a stale offset and overwrites whatever the function just emitted; the emitted primitive silently disappears.

  • Instance: D187; set_rgba_redirect_generate_microcode() (src/game/gunfire.c), the sole emitter of the HUD ammo-type icon. Invisible under -Og; vanished the day release switched to -O2 (34885535). Fix: return the tail call under #ifdef AVOID_UB (#else keeps the N64 body verbatim).
  • Instance: D77; sub_GAME_7F0C0BF0() (src/game/mp_music.c), a one-line wrapper around get_mTrack2Vol() with no return. Every in-level music trigger (set_missionstate()'s MISSION_STATE_1/4 cases; i.e. the only path that starts a level's background track; the front-end menu/intro music bypasses this and calls musicTrack1Play() directly, which is why menu music worked while level music didn't) feeds this garbage/zero return straight into musicTrack1ApplySeqpVol() / musicTrack3ApplySeqpVol(). The result isn't a missing on-screen primitive but a silent audio channel that still runs: the compact-seq player keeps processing note-on events (visible in a GE_AUDIOTRACE [MUSICNOTE] capture with normal-looking key/velocity data) but every note's synthesized volume is scaled by the zeroed track volume, so nothing reaches the mixed output. A per-note trace alone looks completely healthy; the tell is a track/channel-level gain feed with a missing return upstream. This class isn't limited to display-list cursors; audit any -O2-only "quietly broken but not crashing" symptom for the same shape. Fix: return under #ifdef AVOID_UB (#else keeps the N64 body).
  • The one flagged in-source with an explicit comment is grep -rn "missing a \"return\"" src/ (gunfire.c). Others (like D77's) exist without the banner; suspect this class whenever an -O2 build silently drops or mutes a small on-screen/audio element that an -Og build shows/plays correctly. One-line #ifdef AVOID_UB fix each.
  • Not caught headless by a symbol/link check or a default-config framediff (the missing primitive is often small). Needs an -O2 build + an eyeball or a targeted crop diff.

D7. va_list is an ARRAY type on x86-64 SysV; never take & of a by-value va_list param

On Windows x64 va_list is a scalar (char *), so passing a va_list parameter by value and later taking its address (&args) to hand a helper a "pointer to the list" happens to work. On the x86-64 System V ABI (Linux/macOS) va_list is __va_list_tag[1], an array, so a by-value va_list parameter has already decayed to __va_list_tag *. &args is then the address of the local pointer slot, not the argument list, and a helper doing va_arg(*args, …) walks garbage → segfault. Latent at -Og, fatal at -O2.

  • Instance: D188; the IDO printf engine _Printf (src/libultrare/libc/xprintf.c) passed &args to _Putfld; crashed at boot on Linux -O2 via bossInitMainthreadData → sprintf. Fix: under #ifdef PORT, thread a real va_list * from the top (sprintf.c passes &args of an actual va_list object; _Printf takes va_list * and passes it straight through). #else keeps the ROM-matching body.
  • Rule: to share one arg-walk between functions, pass va_list * explicitly from the outermost va_start scope. Never & a va_list that arrived as a parameter. va_copy into a fresh local is the other portable option when the callee must not disturb the caller's position.
  • Same "works on MinGW, UB on SysV, exposed at -O2" shape as D6; suspect this class for any Linux-only crash whose backtrace runs through xprintf/_Putfld/vsnprintf-alikes.

D8. Latent fixed-size stack-buffer over/under-runs; fatal only under a stack protector

The decomp has a class of local scratch buffers that game code writes a few slots past (or before) the declared bounds; e.g. a traversal stack whose bail check is looser than its array size, or &buf[i*3] with i starting at -1. The N64 build and the MinGW/Windows build have no stack protector, so the overrun lands in adjacent stack scratch and is (in practice) harmless; the Windows build is stable over thousands of frames. Ubuntu's gcc default is -fstack-protector-strong, which places a canary right after the buffer and turns every such overrun into a fatal *** stack smashing detected *** __stack_chk_fail.

  • Instances (D189): stan.c sub_GAME_7F0B1DDC StandTile *tileStack[39] (bail is if (cat >= 41), checked once per outer loop → writes [40..]); bondview2.c bondviewCalcIntroSwirlCamera f32 pointbuf[10] written [-3 .. 11].
  • Instance (D295/M-148): front.c:2616 char difficultytext[4] receives strcpy("00 Agent")/strcat("\n") — the NUL lands exactly on the adjacent textpos.p[0] (y-coord), which is then re-stored; when y ≥ 0x80 the string carries a high-bit byte into textRender → JPN-glyph path → NULL deref of g_JpnCacheCacheItems (language.c:320). Same class, different fatality: no canary involved — the clobbered neighbour changes control flow. On N64 the identical overflow is cosmetic (the JPN-cache pointer is valid memory in any language); the crash severity is a PC artifact of that global being NULL outside Japanese mode.
  • Fix policy: -fno-stack-protector globally (CMakeLists.txt) so Linux matches the N64 and MinGW builds; the code is byte-matched to the ROM and cannot be "fixed" per site without diverging. Where a case is cleanly bounded and the buffer is pure local scratch (never escapes, no ABI role), widening it under #ifdef PORT is also fine (tileStack[64]).
  • A hardened Linux build (re-enabling the protector) would need the whole class swept first. Grep heuristic for new instances: any Linux-only __stack_chk_fail / __fortify_fail in the backtrace, in a sub_GAME_* or other byte-matched function, during stage load or the first few frames.
  • Distinct from D7 (va_list UB) and D6 (missing return) but the same "works on N64 + MinGW, fatal on hardened Linux" shape.

D9. Changing a PRNG/CRC implementation invalidates persisted derived state — save checksums are de facto file format

D284 (M-139b) fixed port/src/random.c's dsll32/dsrl32 semantics and the entry claimed "savegames unaffected (seed isn't save state)". It was wrong: fileGenerateCRC (src/game/crc.c) — the per-slot validation checksum checked by fileValidateSaves (file2.c:544) — runs randomGetNextFrom off its own local constant seed, so every stored slot CRC is a fingerprint of the PRNG implementation that wrote it. Verified against a real v0.2.x save (GitHub #87): pre-fix PRNG reproduces all five stored CRCs bit-exact, post-fix matches zero → first launch of any post-fix build silently wipes every existing player's entire campaign (D295 M-148). Generalizable rule for this port: before changing anything that feeds persisted derived state (PRNG, CRC/hash routines, endian/byte-order helpers used by serializers), audit all consumers whose output is stored on disk — not just the in-memory streams. A "port-only" fix to a checksum primitive is a save-file-format break unless old and new outputs are made mutually acceptable (dual validation / one-time migration).

D10. A "skip to state X" port shortcut must replicate every side effect of the screens it skips, not just the terminal flags

D216's Game.SkipIntro jumps straight from boot to MENU_FILE_SELECT, skipping the legal screen + Nintendo/Rare/GoldenEye logo attract loop, by directly setting the handful of state-machine flags (is_first_time_on_main_menu, prev_keypresses, maybe_is_in_menu, menu_update) that the normal intro path would have arrived at. Its original comment claimed this "reuses the game's own post-intro route" — true for the menu flags, but the skipped init_menu00_legalscreen() also has a one-time side effect with no flag of its own: it calls fileValidateSaves(), the only place saves[] is ever populated from EEPROM. Skipping it left saves[] all-zero, and file-select's unguarded fileGetIsCheatUnlocked() crashed selecting any folder except the one a zeroed save_data happened to decode to (D299). Generalizable rule: when adding a port shortcut that jumps over N64 screens/states, enumerate every side effect those screens' init_menuXX_*()/update_menuXX_*() functions perform — not just the menu-transition flags — and replicate the non-flag ones (data loads, one-time validation, subsystem init) explicitly. Grepping the skipped init_*/update_* functions for calls with no matching state flag is the concrete check.

D11. A chr's own per-tick animation-position logic can silently overwrite an AI script's scripted position in the same frame (D243)

chrTick (src/game/chr.c:2442) always runs one of three chr->actiontype- gated branches (ACT_ANIM/ACT_STAND/else, chr.c:2578-2616) that call chrUpdateAnim/modelTickAnim — ordinary per-chr animation/locomotion logic with no check for whether the chr is currently being driven by a scripted cutscene camera/AI-script position write instead. If something else (an AI script's GOTO/teleport, a cinema-pad move, D243's CHR_BOND_CINEMA teleport) writes prop->pos earlier in the same frame, chrTick's own animation-position write can silently overwrite it before the model transform is computed — two writers racing for the same field within one tick, no error, no crash, just whichever runs last wins. This produced D243's "frozen render position that doesn't track the smooth scripted sweep" symptom: a first pass (M-159) wrongly attributed it to the on-screen/frustum-visibility gate (posIsOnScreen) skipping the render-transform refresh on off-camera ticks — REFUTED by a live capture that logged BOTH the pre-chrTick position (from the AI script, smoothly sweeping) and the post-chrTick position (frozen) for the same frame numbers, with the visibility gate returning true throughout. The generalizable check: when a rendered position looks "frozen" or lagging behind a scripted/AI-driven prop->pos, don't assume a visibility/culling gate is skipping an update — log the same field both immediately before and immediately after the suspect tick function to see whether something inside that function is overwriting it. A visibility gate stopping updates and a competing writer stomping them produce the same visual symptom but need opposite fixes.

Update (same D243 investigation, later in the same project's life): fixing the field you found the bug in doesn't mean you've fixed what's rendered — check which field the CONSUMER actually reads. Having found chrTick overwrites prop->pos, two live-playtest cycles were spent neutralizing exactly that overwrite (save/restore prop->pos around chrTick) — it worked completely (confirmed in logs: the field went smooth) and yet the visible symptom (camera shake) was completely unchanged. The reason: the camera's look-at filter doesn't read prop->pos at all — it reads field_488.pos, a different field written from a different data path (render_pos, the model's animated-skeleton transform, computed independently of prop->pos inside the same function). Both fields happened to show the same "period-4 cycling" symptom, which made them look like one bug through a probe that only logged prop->pos — they were two independent outputs of the same underlying animation-tick call, and only one of them was what actually got rendered. The generalizable check, sharpened: once you've confirmed a field is being overwritten, don't assume that field is what the visible symptom's consumer (a camera filter, a draw call, a collision check) actually reads — trace the consumer's own read site explicitly and confirm it's the same field before spending a live-test cycle suppressing the wrong one. A quick static check (grep the consumer's exact read expression) is far cheaper than a live playtest.

Update 2 (same investigation, immediately after): freezing a value and freezing a pointer target are different operations — treating them the same crashed twice. Extending the same freeze technique to render_pos (a Model* field, unlike field_488.pos a plain value) by skipping the call that reassigns it (subcalcmatrices) seemed equivalent to M-167's "skip the write" but wasn't: render_pos is a pointer into a per-tick dynamically-reallocated arena, and skipping its reassignment only works if the pointer it's left holding is still valid — which depends on model lifecycle, not just on this tick's logic. Two fixes in a row tried to detect "is it safe to skip" by inspecting either the pointer's value (a sentinel check, != NULL && != -1) or a cached identity (comparing the pointer against a remembered "already validated" pointer) — both crashed, because the allocator reuses model-pointer addresses across genuinely different logical model instances: a freshly-CREATEd model can land at the exact address a previously-removed model occupied, so both the sentinel check and the identity check wrongly conclude "this is already valid" for an instance whose render_pos is still an uninitialized tombstone. This is the same "don't guess validity from a value that can coincidentally look right" trap as the paragraph above, encountered a second time from a different angle (a pointer's target identity, not a field's content). The robust fix: never skip the call that establishes the pointer's validity — let it always run — and apply the freeze as a contents copy afterward, on top of a buffer that's now unconditionally guaranteed valid. If you must "hold something steady" and that something is reached through a pointer that some other code reassigns, freeze the pointee's bytes, not the act of assignment — the assignment is usually also the thing keeping the pointer sane.

D12. Depth precision: the port matches N64 exactly — z-fighting seen on PC is probably faithful (D306/D308)

When a user reports z-fighting, do NOT assume the software renderer has less depth precision than the RDP. Verified for GE: the RSP's vertex transform (rsp/graphics/gmain.s) writes post-transform x/y/z/w back to the vertex buffer as full 32-bit 16.16 fixed-point (sdv stores), not s16 8.8 — the RDP interpolates from full-precision NDC and quantizes once, into its 24-bit z-image. The port keeps floats through gfx_sp_vertex and writes a GL_DEPTH24_STENCIL8 buffer: same or better precision at every stage, same near/far. So coincident geometry authored into the level fights on both platforms; a PC-only explanation needs positive evidence (an N64 reference of the same spot), not just the existence of the symptom. A mitigation like switching to 32-bit float depth would reduce fighting but diverge from N64 behavior — that's a rule-2-style decision, not a silent fix.

Probe technique that worked (vs. three variants that didn't): detecting z-fighting from emitted triangles requires filtering out (a) tessellation gradients — adjacent tris of one surface have near-equal depths by construction — and (b) camera-motion coincidences — as the camera moves, unrelated world points pass through the same screen cell and their depths happen to sit close. What survives both: a coarse screen-cell grid keeping a short per-cell depth history, flagging only non-monotonic (a-b-a) oscillation within a small tolerance that recurs many times at the same cell. Monotonic drift = gradient; one-off close pairs = motion artifact; sustained recurrence = a real fight. GE_ZF in port/fast3d/gfx_pc.cpp implements this (GE_ZFTOL tolerance, GE_ZFBOX region filter).

D13. Per-render accumulation is not associative: the port's 1 tick/frame can reach states the N64's 2–3 ticks/frame never did (D329)

GE on N64 renders gameplay at ~20–30 fps, so g_ClockTimer (= speedgraphframes, 60 Hz ticks per rendered frame) is normally 2–3. The port at 60 fps gives 1. Code that folds numticks ticks into one downstream call is not equivalent at different granularities. modelTickAnim sums playspeed*speed over the ticks, then makes one modelSetAnimFrame2WithChrStuff call, which only moves framea/frameb when floor(frame) crosses an integer. At speed < 1 and 1 tick/frame a clamped (framea == frameb) non-looping anim never crosses, so it stays pinned even after its endframe is raised. At 2+ ticks it crosses on the next frame. Result: permanent guard freezes on PC (D318/D320) that the console could not sustain.

  • Tell: a state that holds forever at 60 fps, is frame-exact (integer frame, fa == fb), and has a per-tick step < 1.0 unit.
  • Repro correctly: force the granularity with GE_D318B_CLK=N (TEMP probe, lockstep N ticks/frame) and A/B N=1 vs N=2. Wall-clock runs on a slow host (WSLg renders ~5 fps → mixed clk 1–6) and GE_DETERM=1 (advances per VI-retrace request, also mixed clk) both give uncalibrated results.
  • Where else to look: any for/while (numticks) accumulator followed by a single threshold/floor consumer; D193 (AI locomotion rate) and D243 (cutscene numticks bursts) touched the same axis from the other side.

E. Process / method notes

  • Investigation loop is: reproduce → env-gated capped probe → root-cause → narrow #ifdef PORT fix → visual verify (GE_PCDUMP + tools_pc/pixcount.py vs docs/reference/n64-footage-*).

  • gdb launch mode is too slow for timing-dependent faults; gdb attach to an already-running process is fine and fast.

  • Check the Perfect Dark port for the PD analogue before writing anything new; same Rare engine family.

  • Don't re-investigate a closed §F finding or re-derive a format spec that already has a converter.

  • When re-implementing a ROM bitstream/codec offline, PORT the derivation from the decomp; never reconstruct it from the comment. A half-right bitstream parser in this codebase does not fail loudly — it returns confident, plausible, wrong output. Three hits in four D236 passes: (1) a texture-header parse that omitted the leading control byte (image.c:2568) still produced sensible-looking formats and dimensions, and was only caught by counting implausible entries (1020/2699 vs 1/2699 when correct) and noticing the format nibble only ever took values 0/4/8/12 — the tell that a field was straddling a boundary; (2) omitting the per-image byte-align at the end of each LOD (image.c:1096: discard the partial byte, and skip a whole byte when already aligned) silently desynchronised every LOD after the first; (3) texInflateRle's run-length fudge was guessed from the doc comment instead of ported from the loop at image.c:1605, and still decoded images — just with the wrong alpha coverage (66.7% vs the correct 43.1%). Practical rules: copy the arithmetic statement-for-statement even when it looks like it could be closed-formed; build a cheap global plausibility counter into the tool and print it every run; and cross-check one decoded artifact against an independent expectation (here, that a LOD chain's dimensions halve exactly and its alpha coverage stays roughly constant) before trusting any number the tool emits.

  • A negative result from a diagnostic is worthless until the diagnostic has been shown capable of producing the positive. Two independent hits: D219's "verify diagnostic caps exempt the real target signature before trusting 'clean'", and D236 pass 21, where a static setup scanner located GE's PROP enum with startswith("typedef enum PROP") — which matches typedef enum PROPFLAG hundreds of lines earlier, yielding an enum with no PROP_* members at all. Every prop id then resolved to ? and every "foliage props placed" count came back 0, which is exactly what a clean, meaningful negative looks like. Build the positive control into the probe: have it print what it is capable of matching (foliage props recognised (9): …) and fail loudly when that set is empty, so the failure mode cannot be read as a finding. Applies equally to grep-based audits over generated C, where near-miss identifier prefixes (PROP / PROPFLAG / PROPDEF_TYPE) are the norm.

  • docs/dev/findings-index.csv is generator-derived (tools_pc/gen_findings_index.py): the status cell is normalised from the ## Dxx entry's §F summary-table row in findings.md, so hand-editing the CSV is silently reverted on the next regen (hit live in the D219/D252 M-158 session). Update the table row, then run the generator.

  • Every game launch rewrites data/ge007.eep (the eeprom shim stores on every write; validation resets, cheat patches and normal saves all write). When restoring a specific save artifact for a controlled test (e.g. an issue reproduction), restore it after any sweep/verify run — or re-hash after each run — not before: tools_pc/level_sweep.sh alone boots the game 21 times against data/ and will clobber a pre-run restore (caught live in the D295 M-149 session; "verified" must mean checked at report time).

  • The port is NOT frame-deterministic (D117): osGetCount() is wall-clock on PC, and GE is a variable-timestep sim (frametiming.c waitForNextFrame → deltaFrames = 60 Hz ticks of real time elapsed per render), so "frame N" differs 15–40 % between runs. Use tools_pc/framediff.py <ppmdir> (structural: 16×12 grid mean-color + non-clear-% + aHash, --mask X0,Y0,X1,Y1 for the HUD, --update to refresh tools_pc/golden/); NOT an exact compare. A GE_DETERM=1 fixed-tick mode was assessed not-narrow (redesigns retrace/tick semantics); design is in §F D117 if someone picks it up, after which framediff.py --exact becomes usable.

  • A "deterministic boot" flag (-level_XX) pins the level and starting state, but NOT the camera's later path through it — don't assume two captures from the same boot flag are comparing the same view. D236 pass 10 (M-155) found a render-mode-class triangle count that looked "run-to-run dependent" across separate captures using the same -level_36 boot; three independent re-launches (including one with a full GE_INPUTSCRIPT sustained-rotation sweep) actually produced byte-identical, fully deterministic per-frame counts — the real variance was the boot's own scripted intro camera walking from an outdoor room into an indoor one at a fixed frame, and the two captures being compared had sampled before vs. after that walk (or against an unrelated live/free-play session entirely). Before concluding a count/state is nondeterministic across runs of the same boot flag, correlate against a room/position probe (GE_D104-style) in the SAME run to rule out "the camera simply isn't where you assumed it was yet" before chasing an instantiation or streaming bug.

  • A level's opening cutscene is not necessarily input-inert, and GE_INPUTSCRIPT stick holds during it are not necessarily useful. D236 pass 11 (M-155): holding SUP/SDOWN from frame 0 of the -level_36 deterministic boot changed which room the player occupied at the very first probe sample (curRoom 12/7/13 depending on the held direction) — the pre-transition window is NOT purely camera-scripted with input discarded, as pass 10 might suggest. But none of forward, backward, or a START-skip attempt could keep the camera in the outdoor room past the scripted indoor transition, which fired at roughly the same real time regardless of held direction and earlier when START was pressed to skip faster — i.e. the transition looks timer/event-driven, not distance-to-a-trigger-driven. Don't assume a scripted sequence's transition point is walkable-around just because input has some visible effect during it — test whether the specific transition you care about is time-gated vs. position-gated before investing in a nav script to avoid it.

  • Anything blocking on the scheduler thread throttles the whole sim (D186, cf. D134). The gfx task runs synchronously on src/sched.c's thread, and that same thread delivers VI-retrace events; any sysSleep / busy-wait on that path (frame-rate cap, a lock, an asset load) stops retrace delivery, so every game thread blocked on osRecvMesg(retraceQ) stalls with it. On console the CPU never waits on RSP/RDP/VI. A PC frame cap must therefore drop presented frames without sleeping that thread; Video.FpsCap < 30 is currently just refused (clamped to uncapped) as a stopgap.

  • D24-implications; host-scheduling nondeterminism / fake priority semantics: the pthread kernel does not enforce N64's 0–31 priorities (osYieldThread = Sleep(0)), so interleavings impossible on console can occur, and frame timing has host jitter. When a flaky timing bug appears (especially Phase 3 audio underruns), reach first for host thread priorities (SetThreadPriority: scheduler thread time-critical, tick normal) and the deferred GE_DETERM mode (D117); not for kernel changes.

  • A verification tool that shares the code's indexing convention proves self-consistency, not correctness (D206; cost eight sessions). The port played every SFX one bank slot too high. Real cause: struct ALInstrumentAlt_s (src/snd.h) is a cast alias over the on-disk ALInstrument, and its soundArray member sits at struct offset 12 on N64 (three 4-byte words) but 16 on PC (8-byte pointer + 8-byte alignment). On N64 that offset-12 array deliberately aliases the on-disk bendRange/soundCount words so soundArray[N] == on-disk entry [N-1] (GE's SFX_IDs are 1-based by design); on PC the alias is broken and every ID resolved one slot high. It survived M-68/M-70's "exact match" of 200+ requests because exact_match.py compared each runtime voice against a ROM decode of the requested index with a decoder that walked the bank 0-based; diff_bank.py likewise walked N64 and PC layouts with identical indexing. Both faithfully reproduced the bug and reported a pass. Two rules: (1) when validating a mapping (index → asset, id → record, offset → field), the reference must be produced independently of the code's own accessor, an external rip, a published table, a hand-decoded sample, or the test only confirms the code agrees with itself; (2) when a count and an enum disagree by one (261 bank entries vs 262 SFX_ID members), suspect a pointer-width struct-layout shift in a ROM-serialized struct (the D3x class) before anything subtler; check every cast-alias struct's member offsets at 32- vs 64-bit.

  • getenv() is expensive on Windows (a locked, locale-aware linear scan via msvcrt.dll); never call it uncached inside a per-frame, per-draw-call, or per-item hot path (D250, M-120). Dozens of GE_* debug-probe checks across the codebase already follow the safe pattern (static int x = -1; if (x < 0) x = getenv("GE_X") != NULL;), but a handful in port/fast3d/gfx_pc.cpp's import_texture() (runs on every texture bind) and per-triangle/per-G_SETCOMBINE combine-mode checks didn't. A live WPR/xperf CPU trace (Microsoft public symbols resolved via -symbols) on a texture-heavy level found msvcrt.dll!getenv alone eating 50% of the hot render thread's total CPU time; the game's own code was only 5%. Fixing the caching took the same scene from a hard ~45-50fps ceiling to a rock-solid 60fps, at every resolution tested, with zero other changes. The lesson generalizes: any new GE_* env-gated probe added to a function that runs more than roughly once per frame must cache its getenv() at that call site, following the existing pattern; don't assume "it's just a debug check" makes it free. tools_pc/perf.ps1 (sister script to debug.ps1) automates this exact trace-and-analyze workflow for the next time a "runs slow but sim/GL-visible-cost checks out fine" report shows up.

  • Count a probe's events by its canonical one-per-call line, never by a bare field match (D204, then D205/M-73 which re-made the identical mistake on the identical sound index). GE_AUDIOTRACE emits two lines per sndPlaySfx (a dumppos=... bank=... line and a sndPlaySfx: t=... soundIndex=... line), so grep -c soundIndex=109 reports exactly double the truth. D204 already corrected M-63's "idx 109 fired 94 times in 60 s" on these grounds; M-73 then built a whole root cause ("guards stuck re-triggering, 256x/29.7 s") on the doubled count and pinned it on converted collision geometry, which M-74 withdrew. Two general rules: (1) grep the timestamped call line (sndPlaySfx: t=), not the field; (2) before calling a rate anomalous, compare it to the rate the data says is legal; the "barrage" was ~6x under the AK47's own SoundTriggerRate ceiling, which one lookup in assets/obseg/gun/gunWeaponStats.inc.c would have shown. Corollary: a perfectly monotone index sweep in a trace looks like a broken random() % n but is often a deliberate round-robin counter in ground-truth code (e.g. male_guard_yelp_counter, chraction.c:2454); check the selector before reporting it.

  • "Regression vs. steady state" is decided by a build-and-compare, not by reading an old handoff line (D133). Handoff prose like "the entire intro renders" is often aspirational; the author's intent, not a measured coverage number. Before bisecting a suspected render regression: git worktree add the commit whose handoff made the claim (COPY data/, never junction; see the data-dir-junction-hazard memory), build, capture the same GE_PCDUMP window, pixcount.py. A pixel-identical non-clear count on a static 2D screen (e.g. the legal/disclaimer framebuffer, 6677 px here) across two independently-built binaries proves the path is unchanged → not a regression, don't bisect. Clean up the worktree with git worktree remove --force (safe only because data/ was copied).

  • D134; a dropped OS_MESG_NOBLOCK "interrupt" event is a permanent stall, and it looks exactly like D117 flakiness. The port's SP/DP task-done events shared the scheduler's 8-slot interruptQ with the 60 Hz VI pacemaker. A gfx task runs the whole frame synchronously on the sched thread, so a slow frame lets the pacemaker fill the queue and the done-post is silently discarded → curRSPTask never clears → no further frames (frames=2 heartbeat, retrace queue pinned valid=8/8 ret=-1). Rule: classify every posted event as droppable (retrace; N64 drops these too) or must-arrive (task done, DMA done); a must-arrive event needs guaranteed delivery, and OS_MESG_BLOCK is wrong whenever the poster runs on the queue's only consumer thread (self-deadlock); drop the oldest droppable message instead, and reserve slots in the pacemaker. Before blaming a hang on D117 / machine load, run it N times and count: this reproduced 2 of 3 boots on an idle machine and had been written off as sweep flakiness for ~8 sessions.

  • Watch item: osYieldThread = Sleep(0). The one place we fake cooperative behavior. If a level-sweep hot loop misbehaves under host load, this shim is the first thing to inspect. Keep as-is until then.

  • D155; an unclamped per-frame deltaFrames from wall-clock osGetCount() spirals the sim into a "hang". The N64 was VI-locked so waitForNextFrame() never returned more than ~2 elapsed frames. On the port osGetCount() is wall-clock (D117), so any real-time stall (asset load at a stage/cutscene boundary, host thrash) makes nextFrameTime → hundreds/thousands. That value flows through speedgraphframes → g_ClockTimer and drives every modelTickAnim while (numticks--) and for (i=0; i<g_ClockTimer; i++) loop in the sim → one render becomes seconds of catch-up → heartbeat "hang", and the slow frame feeds an even bigger delta next time → unrecoverable. Symptom in a hang dump: mainThread parked in modelTickAnim/modelConstrainOrWrap AnimFrame under chrTick/playerTick/lvlRender across every dump. Fix: clamp nextFrameTime under #ifdef PORT in waitForNextFrame() (FRAMETIMING_PORT_MAX_CATCHUP = 6). Any other place the port lets an N64 "number of elapsed ticks" value run unbounded is the same latent trap; grep for g_ClockTimer / speedgraphframes / lvupdate* consumers that loop. §F D155.

  • D156; a NaN / blown-up f32 frame or speed drives an unbounded frame-stepping loop → hang. modelSetAnimFrame2WithChrStuff's while (1) (model.c:3131) walks one anim frame at a time from framea to frameb; frameb comes from modelTickAnim's frame += playspeed * speed per tick. A cutscene anim transition with a near-zero blend/timespeed/unkb0 denominator (model.c:3436/:3477, guarded > 0 but not against tiny values) makes model->speed/playspeed huge or NaN → frameb huge/NaN → floorFloatToInt garbage → ~2^31 iterations → frozen (frames=N constant in the hang dump, distinct from D155's slowly-incrementing spiral). Fix: #ifdef PORT finiteness+magnitude guards (!(x > -1e6f && x < 1e6f) also catches NaN) on frameb before the loop and on frame/frame2 before they reach model state. When an N64 float pipeline feeds a loop bound or an array index on PC, guard it; the console's fixed timestep + bounded anim/physics data never produced the degenerate value, so the decomp never checks. Suspect a misaligned Model/struct field (D100/D140 pun family) as the NaN source before blaming the data. §F D156.

  • D247; a visual "defect" report can be faithful, unmodified N64 behaviour; check the decomp's own constants before assuming a port bug. User reported black bars top/bottom of the screen. The obvious port-side suspects (a present/letterbox path, a framebuffer-vs-window size mismatch) were all clean; no code anywhere sets G_ASPECT_MODE_EXT for GE, so fast3d never letterboxes. The real cause: bondviewGetCurrentPlayerViewportHeight() (bondview2.c:7858) returns VIEWPORT_HEIGHT_DEFAULT = 220 of 240 NTSC scanlines (fr.h:34, ~92% fill) whenever PLAYER_OPTION_SCREEN is SCREEN_SIZE_FULLSCREEN, the decomp's own default value, unmodified, no #ifdef PORT in sight. VIEWPORT_HEIGHT_FULLSCREEN (304, no inset) is a trap name: it's only reachable via the unrelated cameraBufferToggle branch, not normal solo play. This is GoldenEye's real TV-safe-area letterboxing, present on original hardware. Ruled out a stale EEPROM Screen=Wide/Cinema setting from earlier sessions as an alternative explanation by reproducing identically on a fresh EEPROM. Lesson: before chasing a port/ fix for a rendering "bug," grep the reported geometry (viewport height/width, screen offset, etc.) back to its decomp origin; a visually surprising but exactly reproduced N64 quirk isn't a port defect, and the fix is to close the finding, not to write code.

  • D195; a targeted decomp-side fix (D72) blanket-disabled a whole PC-side feature instead of gating it on the GBI bit that actually distinguishes the broken case from the working one. gfx_sp_vertex()'s PD-inherited envmap (G_TEXTURE_GEN) UV-generation block was removed wholesale to fix the Rareware logo (D72.1), on the reasoning "GE never generates texture coordinates from vertex normals." That's true for the logo specifically (lit, but doesn't set G_TEXTURE_GEN) but false for the engine in general: GE uses real RSP envmap generation for every shiny/reflective material (gold/silver weapon skins, chrome trim, reflective glass), all of which DO set the bit. Deleting the feature instead of gating it on rsp.geometry_mode & G_TEXTURE_GEN (which the reference PD port already does correctly) silently broke every one of those materials game-wide; they'd sample a fixed, usually-dark corner texel forever, reading as solid black. Lesson: when a PD-inherited block causes a wrong result for one specific draw, check what GBI bit that draw fails to gate on before assuming the whole mechanism doesn't apply to GE; a feature real N64 hardware supports and GE's own assets exercise is not "PD-only" just because the first investigation happened to hit it on a draw that shouldn't have taken that path. §F D195.

  • D228; when two decode paths exist for the same underlying format, check them against each other for a swapped byte order, not just against the spec. palette_to_rgba32's G_TT_IA16 branch (CI texture whose palette is intensity-alpha, not RGBA16) read intensity = palentry & 0xff, alpha = palentry >> 8; exactly backwards relative to the already correct direct-IA16 decode (import_texture_ia16) a few lines above it in the same file, which reads intensity = addr[2*i] (first/high byte), alpha = addr[2*i+1] (second/low byte), matching what gfx_dp_load_tlut's PD_BE16 byte-swap actually produces. A dim, fully-opaque texel (e.g. intensity=0x58, alpha=0xff) decoded backwards as bright and mostly-transparent; read as "white missing-texture patches" (AK47 and other metallic weapon materials, whose grayscale-metal-plus-opacity look is a natural fit for IA16 palettes). Two importers for the same source format existing side by side in one file is exactly the situation where one silently drifts from the other; when investigating a decode bug, diff the suspect path against its sibling path for the same format before re-deriving the bit layout from scratch. §F D228.

  • A Claude Code session's default Bash tool is NOT the MSYS2 MINGW64 shell the build needs, even when uname -a/$MSYSTEM claim it is (M-140). On this box the tool's shell is Git-for-Windows Bash (root mounted from C:/Program Files/Git), which sets MSYSTEM=MINGW64 and reports MINGW64_NT-... from uname for compatibility — both of AGENTS.md's own “are you in the right shell” checks pass while cmake/ninja/gcc are still missing from PATH (they resolve at /c/msys64/mingw64/bin and /c/msys64/usr/bin, not /mingw64/bin — that path exists but is a Git-Bash stub, not the project's real MSYS2 install). Symptom if you build anyway with only Git-Bash's own gcc/toolchain state: cmake: command not found, or (if some gcc.exe further down PATH resolves) every compile fails with Cannot create temporary file in C:\Windows\: Permission denied — a native Windows exe's GetTempPath falling back to C:\Windows because the child process doesn't actually see a usable TMP/TEMP the way it does when launched from a real MSYS2 shell window. Fix: explicitly export PATH="/c/msys64/mingw64/bin:/c/msys64/usr/bin:$PATH" (adjust if MSYS2 is installed elsewhere) before ./build-pc.sh/ninja/cmake in any Claude Code Bash-tool session; do this once per session, first thing, not reactively after a mystery failure. Also: cd with a backslash Windows path (cd C:\Users\...) is unreliable in this same shell and can silently mis-parse to a mangled path on a later command in the same tool call — prefer cd /c/Users/... (forward slashes) for the build working directory. The PATH fix alone can still leave the link step broken (M-141): compiling succeeds but ld.exe fails with cannot find @C:\Windows\Temp\ ccXXXX: Invalid argument if TMP/TEMP are unset or point at a non-writable/non-real path like a literal C:\Windows\Temp string — the response-file ld uses for the link command has to land somewhere this shell's child process can actually write and re-read. Fix: export TMP=... TEMP=... to a real writable per-session directory (the Claude Code scratchpad path works well) before ninja/build-pc.sh, not just any Windows-looking path string.

  • A backgrounded ge007.x86_64.exe launch from this Bash tool does not reliably die to pkill -f ge007.x86_64.exe (M-155, D288 session): the process appears in ps aux as bare ge007.x86_64, no .exe suffix, so the -f pattern match on the full name silently fails and the game window (and its log-writing) keeps running well past the intended sleep N capture window — one session let a stray instance run ~90s longer than intended before it was caught. Use taskkill //F //IM ge007.x86_64.exe instead to reliably terminate it from this shell, and ps aux | grep -i ge007 to confirm before moving on — don't trust a kill/pkill exit code alone.

  • Headless visual verification of a rendering bug is possible without a live human (M-156/M-157, D252/D219 sessions) — combine GE_INPUTSCRIPT (scripted controller input, e.g. Z pulses to fire a weapon) with GE_PCDUMP="lo-hi:step" (writes ./ppm/frame_NNNNNN.ppm — NOT a separate configurable output dir, just ./ppm relative to cwd; GE_MKDIR creates it) in the same run, then actually open the resulting PPMs (Python PIL.Image.open(...).convert('RGB') reads them fine, no format quirks) instead of only grepping a probe's text log. A text-only probe proves a code path fired; it does not prove what ended up on screen — several long-OPEN findings (D290/D291/D294, and D252/D219's whole "rainbow particle" family before this session) were stalled for lack of eyes on the actual pixels, not for lack of a working probe. Build a low-res contact-sheet montage first (tile many frames small, e.g. 160×120, to scan for the moment of interest cheaply) before zooming into individual full-res frames — full Dam/Bunker1 frames are 3072×1728, too large to eyeball one at a time across a whole capture window.

  • A crash fault address of 0xffffffffffffffff on x86-64 usually means a non-canonical #GP from a garbage-high-bits pointer (bits 63:48 not a sign extension of bit 47), not a computed -1 sentinel (cross-validated against the sibling GEVR VR port, same decomp lineage — see scratchpad/GEVR-TRIAGE.md item C; we independently hit the same register shape in D191). Two companion habits worth keeping: re-read map anchors from the freshly rebuilt map after every rebuild (a stale map silently resolves the wrong symbol), and confirm the fault PC actually disassembles on an instruction boundary before trusting a resolved symbol.

  • Frame-rate assumptions — read before proposing any non-60 Hz mode (cross-validated against GEVR, scratchpad/GEVR-TRIAGE.md item B). GE's logic is delta-scaled through g_GlobalTimerDelta (~216 call sites); the PAL build (×1.2) is a shipping proof that delta-scaled code tolerates other rates. But: Bond's walk is frame-rate dependent by construction — MoveBond (bondview2.c) is an exponential approach toward a target, advanced once per frame with no timestep, so convergence is measured in frames, not seconds (GEVR measured ~1.487× walk speed at 90 Hz vs 60 Hz, ≈90/60 exactly). Turn/aim is delta-scaled; walk is not. High-refresh or uncapped presentation modes are therefore out of scope without a game-logic change, which is forbidden — we tick at fixed 60 Hz (D52 pacing) by design, not as a limitation to fix. Also: g_ClockTimer must stay integer (24 iteration sites depend on it); of 44 /g_GlobalTimerDelta divide sites, 40 are guarded (three different spellings) and 4 are not (front-end cast camera front.c:8271-8273, propobj.c:~2393) — those 4 rely on div-by-zero yielding NaN/inf without trapping on both MIPS and x86-64 SSE, matching retail behavior; do not "fix" them.