Files
goldeneye-pc-port/docs/PORT-LEARNINGS.md
T

30 KiB
Raw Blame History

PORT-LEARNINGS — recurring N64→PC bug classes

One-screen index into docs/PCPortResearch.md §F. Every investigation subagent reads this first and appends any new generalisable quirk. Details, evidence, and the fix for each instance live in §F under the cited Dxx label.

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

  • Open landmine: raw hardcoded-offset accessors into struct player / struct hand — see docs/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.

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); bg.c room-geometry hit-test sub_GAME_… (~3373-3646, still latent — shooting walls). 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).
  • 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); 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.

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

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

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

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

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.

D. N64 hardware idioms fast3d does not emulate

  • Z buffer cleared by pointing the colour 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).
  • 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).
  • 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. Fix deferred (M-11) — logged in docs/GRAPHICS-BACKLOG.md.

D2. The HUD/model X-mirror (D114/D116) — DO NOT re-static-trace

Sessions M-6/M-7/M-8/M-11 all reached the same wall: textRenderGlyph → GBI → fast3d gfx_dp_texture_rectangle → buf_vbo → GL are each runtime-verified non-mirrored ([D116/vbo] probe: x-left↔u=0), yet glyphs render X-flipped. M-11 additionally confirmed the ammo digits use the same textrelated.c path (via gunfire.c:5906), no separate renderer. Every prior attempt to "fix" it drifts toward a global S-swap that mirrors the whole screen — a bad trade for a cosmetic bug. Next attempt needs a RenderDoc/apitrace capture of one glyph texrect OR the asymmetric-1-texel-texture experiment — nothing else. Cosmetic, deprioritised below level-progression / crash work. See docs/GRAPHICS-BACKLOG.md.

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.

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.

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 PD_PORT_CHECKOUT 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.
  • 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-colour + 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.
  • 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.
  • "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.