After D155 the user re-ran the playthrough and it hung again at the end of Facility -- same stack but frames=12962 frozen CONSTANT for minutes = a true infinite loop, not D155's slow spiral. The `while (1)` at model.c:3131 in modelSetAnimFrame2WithChrStuff steps root-motion accumulation 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 -> endframe-curframe ~= 2^31 -> effectively infinite. #ifdef PORT guards (N64 unchanged): - modelSetAnimFrame2WithChrStuff: if frameb non-finite or |frameb| >= 1e6, snap to framea (loop does ~0 iterations; modelConstrainOrWrapAnimFrame at :3211 still clamps model->frameb to a real frame). - modelTickAnim: after the tick loop, if frame/frame2 went non-finite, fall back to the pre-loop model->animframe1/animframe2 so the NaN can't poison model state every frame. `!(x > -1e6f && x < 1e6f)` catches NaN too. Anim frames are 0..few-hundred so 1e6 is a safe ceiling. Likely deeper cause (needs the repro): these speed/blend fields are Model members -- a D100/D140-style struct-pun misalignment would feed garbage exactly when a cutscene sets an unusual transition. Dump model->speed/playspeed/timespeed/unkb0 in modelTickAnim during the cutscene if it still hangs. Verified: build green; -level_09 frame 200 unregressed (guards never fire in normal play). PORT-LEARNINGS §E.
28 KiB
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 playerfield-run: PCsizeof(struct Model)grows so the "fields" that aliased.render_pos/.scale/.animframe1on N64 no longer overlap → NULLrender_pos→ pause-menu crash; fix = real inlinestruct Model+ pool, redirect the named reads to the member — same as D100/D102), D119 (weapons_held[]->chrpunned asChrRecord*to read.act_*.attack_item— aliasedWeaponObjRecord.weaponnumat 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— seedocs/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 PCsizeofwith the pointer slot widened + the matching#ifdef PORTsizepropdef/stride. Instances:d88_propdefs.pytypes 30/32/33/35 (criteria_*/setup_objective_text->next), 22 (TagObjectRecord.NextTag, already handled). Check every record type the walk can see for aset_parent_*/*_entry_parentwriter. -
D132 corollary — a
union { T *ptr; s32 IndexN; }field is 8B/8-aligned on PC even when the ROM image only ever stores thes32.LinkRecord(first/Index1),LockDoorRecord,SafeObjectRecordetc. pack their index words tight (4B) in the N64 setup stream, but the compiler places eachIndexNin 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 makespdef->Index1readIndex2's value andIndex2read 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 eachIndexNat PC offset8 + 8*(N-1)and size the record with the widened unions + trailing*next(d88_propdefs.pytypes 14/19/38/44 — proposed in §F D132, not yet applied). Grep every ROM-serialized record forunion {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 (
fontchar24→32B,romdataFixupFont) is often re-laid-out in place: for glyphi,dst = base + PCstride*i,src = base + N64stride*i, sodst − src = (PCstride−N64stride)*i. For smallithat delta is less than the per-element field-copy read span, sodstoverlapssrc, and a forward field-by-field*dst_k = f(*src_k)loop overwrites a not-yet-readsrc_j(j > k). Result: later fields of the first few elements come out asf(f(earlier field))— e.g. glyph 1'swidthbecamebswap(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 everyromdataFixup*/ in-place relayout for a read-write loop whosedst/srccan 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 beforechars[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-bytepixeldatareads a wild pointer → fast3d AV. Latent intextrelated.c(textRender*/textMeasureASCII paths) — aGLYPH_IDXclamp 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/.rodatamatrix, e.g.&dword_CODE_bss_8007A100inexplosionRenderPropSmoke) it drops the0x1_00000000module high word, so the GBI w1 becomes0x40xxxxxxandseg_addr()hands fast3d a wild pointer → AV ingfx_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 inseg_addr(): restore the high word for a fallthroughw1 ∈ [0x40000000, 0x70000000)(module is fixed-based at0x140000000; DRAM/KSEG0/segmented/phys are all handled in earlier branches).gSPDisplayList(&globalDL_0xNNN)is NOT affected — the port'sGwords.w1is 64-bit andgDma1pstores the full pointer; onlyosVirtualToPhysicaltruncates.
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 (
bgWidenRoomGdl8→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*)gdlfor the cmd,((u32*)gdl)[1]for w1,((u8*)gdl)[5..7]forG_TRI1vtx indices,((u32*)gdl)[0/1]nibble reads forG_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 byteiof a word →(word >> (8*(3-i))) & 0xff).gdl++(advances bysizeof(Gfx)) is already correct. Watch(s32)ptrtruncation in vtx-base math andx | 0x80000000KSEG0 folds (identity on PC — just drop the OR, and guard segmented w1). Instances:bgTestHitOnObj(propobj.c, FIXED);bg.croom-geometry hit-testsub_GAME_…(~3373-3646, still latent — shooting walls). PD ground truth:pd_portusesgdl->dma.cmd+GFX_W0_BYTE(i)/GFX_W1_BYTE(i)macros (3-i/11-ion 64-bit LE). - Also bites RAW hardcoded struct-stride writes, not just Gfx/Vtx.
D128:
sub_GAME_7F0B37ECdid((u8*)g_BgPortals)[(portal<<3)+6] |= 2— the N64bg_portal_data_entryis 8B (ptr@0, cr1@4..cb2@7); on PC the widenedoffset_portalptr 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/+ 6style raw offsets into any struct that gained a pointer field. D141:set_enviro_fog_for_items_in_solo_watch_menu(gunfire.c:1720) indexed aModelNode*array (ModelFileHeader.Switches) as*(ModelNode**)((u8*)Switches + j + 0x48)withj += 4— the0x48/0x5cand 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>)andarr[i << k]where the array element is a pointer.
- misaligned slot → bogus non-NULL node → crash in
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.
f32values are BE word pairs — a naive byte reversal off-by-one corrupts every float: D73 (sinf/cosfdupairs →DVAL()macro), D112 (d43_emit.py put_f32src[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)wordto grab "the first field" of a struct reads the WRONG byte on LE. N64 code that does(u8)obj[0](obj au32*) 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'sextrascale.cleanupObjectsdid this for its walk-termination + type dispatch → never sawPROPDEF_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)*/& 0xffon au32/s32that'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 astr*primitive. The watch BRIEF/OBJECTIVES pages (andfront.cmission text) build their strings withstrcpy/strcat(buf, langGet(id)).langGetreturns 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-tolerantstrcpy/strncpy/strcatunder#ifdef PORTinstr.c(NULL src → empty string, NULL dst → return). Gotcha: these are__nonnull__builtins to GCC — a plainif (src == NULL)on the parameter is deleted as provably-dead (confirmed in-Ogdisassembly: notestemitted). 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 insrc/that a port NULL can reach. - D151 — a ROM-serialized
s32slot decoded by the struct as[u16 hi][u16 lo]reads zero on LE. N64 code frequently splits a 32-bit setup-stream word intou16 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 readsrealvaluefrom 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 PORTwidens the field to a fullu32at the word offset; converter unchanged. Grep every ROM-serialized struct for au16 reserved/u16 padimmediately before au16the runtime actually reads. - Python slice-assignment width is a silent corruptor (D125):
out[a:a+8] = b"\x00\x00\x00\x00"on abytearraydoes NOT raise — it deletes(8 - len(rhs))bytes, shrinkingoutand shifting everything afteradown. A widened pointer/stanslot filled with a 4-byte literal shrankd88_emit.py's output 4 B per pad; oncelen(out)fell below a later region's write offset,out[off:off+n] = dataclamped to an empty range at the current end and inserted there — string blobs drifted, boundpad names truncated (p138d2→8d2),stanPackIdrejected → doormodel=NULL→ crash. When emitting into a pre-sized bytearray, everyout[a:b] = rhsMUST havelen(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:
gimgSyncCompiledGlobalDLslooked for the0xABCDxxxxIMAGESEG marker in the ROM copy — buttexLoad()had already overwritten every one of those with a real pointer, so the sync silently copied nothing and the compiledglobalDL_0xNNNexplosion 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.pyhad no arm for 6inherits ObjectRecordpropDef 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 everytypebyte 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/1786doesx->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 inchrIsNotDeadOrShot. Before zeroing a widened slot, confirm the setup code writes it unconditionally; if it reads-then-writes, emit the value (_bswap32into the low 4 bytes, LE). Decide per field, not per type.
C2. Port-layer / SDL shims
#include <PR/os.h>in a port.c/.hthat also sees<errno.h>breaks:OSContStatus/OSContPadhave au8 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 ofCONT_*bits it needs (D118input.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.creads its own RMB/LShift state as the mode proxy (exact for hold-to-aim; a toggle scheme needsg_CurrentPlayer-> insightaimmode). GE's native pitch is inverted (C-up → look down) — hide it so mouse-down looks down;MouseInvertYflips (D118, M-24). osContGetReadData(pad)must fill one OSContPad per channel (MAXCONTROLLERS-long array), not just controller 0 — joy.c passes the wholesamples[i].padsarray (D118).- Controller state has one source:
port/src/input.c.libultra.c's SI section marshalsinputComputePad()intog_contPad[]; it is driven byosContStartReadData(per logic tick), no separatevideo.cframe 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-inlinedBG_SEG_TO_PTRneed 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 iscms & G_TX_WRAPbutG_TX_WRAP == 0, so always false (line 1887 does it right withcms == G_TX_WRAP). And if it did run it indexestex_width2[i]etc. (arrays[2], per-texunit) with the vertex loop indexi0..2 → OOB. Latent; harmless for CLAMP glyphs, would matter for wrapped textures on tris. Fix deferred (M-11) — logged indocs/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_SEQUENCEinfileGetHighestStageDifficultyCompletedForFolderfroze the SELECT FILE screen.DIFFICULTYwas 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,sizeofstays 4, no stored value changes. Audit anyenumused as a descending />= 0loop 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, viasndPlaySfxon a door close) vssndRemoveEvents(amMain) on the sameALEventQueue. - Fix:
osSetIntMask→ one process-wide recursive mutex.OS_IM_NONEacquires; theOS_IM_ALLtoken returned from that call is what every paired restore passes, soOS_IM_ALLreleases; other specific masks (OS_IM_VI) pass through. Safe because the decomp never blocks while holdingOS_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 heavyALEventQueuepaths: libaudio has unbalanced / early-returnosSetIntMaskcalls, and a transient thread can acquireOS_IM_NONEand exit without the pairedOS_IM_ALL, leavings_imLockowned forever → every lateralEvtq*onmainThread+amMainblocks inpthread_mutex_lock→ hang. Seen on the mission-failed audio fade-out (sndSetScalerApplyVolumeAllSfxSlot→ per-framealEvtqPostEventstorm) = permanent black screen. Mitigation shipped (M-28):osSetIntMasknow 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 (dedicatedALEventQueuelock) still owed — do it once the steal-log names the leaking call site. §F D152.
E. Process / method notes
- Investigation loop is: reproduce → env-gated capped probe → root-cause
→ narrow
#ifdef PORTfix → visual verify (GE_PCDUMP+tools_pc/pixcount.pyvsdocs/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_CHECKOUTfor 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. Usetools_pc/framediff.py <ppmdir>(structural: 16×12 grid mean-colour + non-clear-% + aHash,--mask X0,Y0,X1,Y1for the HUD,--updateto refreshtools_pc/golden/) — NOT an exact compare. AGE_DETERM=1fixed-tick mode was assessed not-narrow (redesigns retrace/tick semantics); design is in §F D117 if someone picks it up, after whichframediff.py --exactbecomes 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 deferredGE_DETERMmode (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 addthe commit whose handoff made the claim (COPYdata/, never junction — see the data-dir-junction-hazard memory), build, capture the sameGE_PCDUMPwindow,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 withgit worktree remove --force(safe only becausedata/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-slotinterruptQwith 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 →curRSPTasknever clears → no further frames (frames=2heartbeat, retrace queue pinnedvalid=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, andOS_MESG_BLOCKis 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
deltaFramesfrom wall-clockosGetCount()spirals the sim into a "hang". The N64 was VI-locked sowaitForNextFrame()never returned more than ~2 elapsed frames. On the portosGetCount()is wall-clock (D117), so any real-time stall (asset load at a stage/cutscene boundary, host thrash) makesnextFrameTime→ hundreds/thousands. That value flows throughspeedgraphframes→g_ClockTimerand drives everymodelTickAnimwhile (numticks--)andfor (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:mainThreadparked inmodelTickAnim/modelConstrainOrWrap AnimFrameunderchrTick/playerTick/lvlRenderacross every dump. Fix: clampnextFrameTimeunder#ifdef PORTinwaitForNextFrame()(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 forg_ClockTimer/speedgraphframes/lvupdate*consumers that loop. §F D155. - D156 — a NaN / blown-up
f32frame or speed drives an unbounded frame-stepping loop → hang.modelSetAnimFrame2WithChrStuff'swhile (1)(model.c:3131) walks one anim frame at a time fromframeatoframeb;framebcomes frommodelTickAnim'sframe += playspeed * speedper tick. A cutscene anim transition with a near-zero blend/timespeed/unkb0denominator (model.c:3436/:3477, guarded> 0but not against tiny values) makesmodel->speed/playspeedhuge or NaN →framebhuge/NaN →floorFloatToIntgarbage → ~2^31 iterations → frozen (frames=Nconstant in the hang dump, distinct from D155's slowly-incrementing spiral). Fix:#ifdef PORTfiniteness+magnitude guards (!(x > -1e6f && x < 1e6f)also catches NaN) onframebbefore the loop and onframe/frame2before they reachmodelstate. 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 misalignedModel/struct field (D100/D140 pun family) as the NaN source before blaming the data. §F D156.