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
- B. 16-byte PC
Gfx/Vtxvs 8-byte N64 - C. Big-endian rodata / ROM data read on little-endian PC
- C2. Port-layer / SDL shims
- D. N64 hardware idioms fast3d does not emulate
- D2. The HUD/model “X-mirror” — RESOLVED
- D3. GCC/mingw makes an all-non-negative
enumUNSIGNED - D4. N64 “interrupts off” must be a real lock on PC
- D5. Loop bounds that assume linker adjacency of two globals
- E. Process / method notes
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 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/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 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. - D177 — a struct passed as
s32 *and integer-indexed past a leading pointer member.stanCheckLinkedSpecialTiletakes the caller’sstruct StandTileLocusCallbackRecord { s32 *rooms; s32 count; … }typed ass32 *outFlagsand doesoutFlags[1] = 1. On N64[1]iscount; on PCroomsis 8 bytes so[1]is its high half andcountmoved to[2]— the write is silently lost, the reader (->count) sees 0. Two tells for this class: (a) a function parameter typeds32 */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]/roomsoften still works by luck on LE — low half at +0 — which masks the bug for one of the two fields.)
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);bgTestRayIntersectionInRoombgTestBulletHitBackgroundtail (bg.c, D154 — ported M-28, re-audited + bug-fixed M-30, playtest-gated). PD ground truth:pd_portusesgdl->dma.cmd+GFX_W0_BYTE(i)/GFX_W1_BYTE(i)macros (3-i/11-ion 64-bit LE).
- D154 corollary — the PC
Gdma_leshim’s.paris NOT the N64 params byte. When porting a room/model DL GBI parser, only.dma.cmdis safe to read through theport/shim/PR/gbi.hGfxunion: that shim laysGdma_leout aspar:24(bits 0-23 of word0 = the packed length fromgDma1p),cmd:8(bits 24-31). The N64Gdmahascmd:8(byte 0) thenpar:8(byte 1, bits 16-23 = the((n-1)<<4)|v0G_VTX params). So an N64((u8*)gdl)[1]/ N64-semantics.dma.parbecomes((u32)gdl->words.w0 >> 16) & 0xffon PC —gdl->dma.pargives bits 0-23 (length), and& 0xfon it is always 0 becauselen == 16*n. The original D154 port had exactly this bug (vtxoff forced to 0). Same trap latent inbgBuildRoomVtxBounds(gdl.dma.par>>4&0xfreads PC bits 4-7, not N64 bits 20-23 — currently tolerated). 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: awords.w0 << k >> mbit-extract idiom that relied on 32-bit truncation on N64 must be(u32)-cast first on PC (words.w0is 64-bit). - 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]).- D73 scope is now settled (M-32 D75 triage): the whole
src/libultra/gu/tree is endian-clean — onlysinf.c/cosf.cused theduunion and both areDVAL()-wrapped;rotate/perspective/ortho/ lookat/scale/translate/mtxutil/normalize/alignuse plain float literals or pure integer bit-packing (FTOFIX32+shift/mask). The game’s ownmatrixmath.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-downGE_PCDUMPcapture, not a real flip.)
- D73 scope is now settled (M-32 D75 triage): the whole
- 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. -
D157 — a small value in the LOW BYTE of a BE
s32word, read ass8at the word’s last offset, reads 0 on LE after the converter byte-swaps it. Same family as D151/D139.struct objective_entry.difficultyiss8at offset 0xF; the underlying type-23 propDef word 3 (MissionObjectiveRecord.MinDificulty) is a BEs32whose 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 thes8@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 PORTso the named byte reads offset 0xC. Grep every struct that reads a sub-s32field ass8/u8/s16/u16at a NON-zero in-word offset — after a converter bswap that offset is wrong. Offline tools have the same trap (dump_objectives.pyread 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-wordgDPLoadBlockloads 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 PORTno-op the function — fast3d wants a plain linear image. General rule: anysrc/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 — auditimage.c/tex.cfor others (texAlignIndicesrow 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()×4for all 48 types, the propDef stream tiles byte-exactly, andsetupGetPtrToCommandByIndex/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_POSENDcinematic render (D75 family).GE_D160=1diagnostic ships; needs a live Dam-to-exit playthrough. - 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. - 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 astructoverlay 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()inlanguage.c,romdataFixup*()inport/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 andlangGet()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.csource inassets/obseg/.
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
- Appending a port-owned 2D overlay to the game frame: hook inside
gfx_run(), notvideoSubmitCommands.gfx_run()(gfx_pc.cpp) is monolithic — it doesstart_frame→gfx_run_dl→gfx_flush→end_frame→swap_buffers_beginin one call, and the game DL is executed fromosSpTaskStartGo(libultra.c), not throughvideoSubmitCommands(which is dead). So a secondgfx_run()per frame is not possible (it swaps buffers). The clean seam is a one-line C hook betweengfx_run_dl(commands)andgfx_flush()that runs a secondgfx_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), thengDPFillRectangle/textRender(game symbols, extern’d likeinput.cexternscurrent_menu— UI, not logic). 2D pixel space isviGetX()xviGetY(). Return NULL when the overlay is closed so the frame is byte-for-byte unchanged.
- a full-screen
- 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_NONEtile behaves as I8. fast3d’simport_texture()dispatched purely ontile.fmtand did a palette lookup against a stalerdp.palette→ garbage (D161, GE Depot ceiling = blue speckle). Fix: whenrdp.palette_fmt == G_TT_NONE, route CI4/CI8 → I4/I8. - RC3 / D167 — GL
GL_REPEATwraps at the uploaded image size; the N64 RDP wraps a render tile at1<<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 keepmasks/maskt. Fix lives behindVideo.WrapFix(default OFF,GE_WRAPFIXenv override): store the mask, fold the UV at1<<maskin 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 tilemaskfields are the wrap period, NOT the tile-window or image size. - 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. Reworked M-30 (hoisted out of the vertex loop, indexed by texunit, gated onVideo.WrapFix); RC3/D167 adds the non-PoT mask-period case to the same block. Still default OFF. -
assert()-basedSUPPORT_CHECKis a silent no-op in the release build.port/fast3d/gfx_pc.cpp:39definesSUPPORT_CHECK(x)asassert(x), and the PC build compiles withNDEBUG. EverySUPPORT_CHECKin 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 assertfull_image_line_size_bytes == line_size_bytesin theimport_texture_*family (D183); treat anySUPPORT_CHECKas 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_TEXDUMPdumps 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 toimport_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.
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_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. - D152 addendum (M-31 static audit). Every
osSetIntMask(OS_IM_NONE)in the compiled audio code was audited:event.c(alEvtqNextEvent,alEvtqPostEvent— its lone earlyreturnatevent.c:86does restore first —,alEvtqFlush,alEvtqFlushType),csplayer.c__CSPRepostEvent,synaddplayer.calSynAddPlayer,snd.csndRemoveEvents/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 liveALSoundStatelist and posts to the sharedALEventQueuewithout holdingOS_IM_NONE— unlike its structural twinsndDeactivateAllSfxByFlag, which does. On PC that is an unguarded walk racingamMainand 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 (nestedalEvtqPostEventthen hits the recursive fast path); also wrapsndApplyVolumeAllSfxSlot’s slot loop so the whole update is one recursive hold. (2) The actual leak is the “transient thread acquiredOS_IM_NONEand exited” case (the dump showsNew Thread/exitedchurn).portThreadWrappernow callsimThreadExitRelease()after the thread’s entry returns: if that thread still ownss_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 andimAcquiremis-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 holdingOS_IM_NONEand 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 beforelegalpage_text_array→ only line 1 renders (== the D76 “disclaimer half-drawn” bug; it was never an image-table issue). Fix:#ifdef PORTusesarray + ARRAY_COUNT(array). - Grep for
= &/(TYPE *)&on the RHS of a loop-terminator compare, and anyfor/while/dowhose 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:returnthe tail call under#ifdef AVOID_UB(#elsekeeps the N64 body verbatim). - The one currently flagged in-source is
grep -rn "missing a \"return\"" src/(justgunfire.ctoday). Others may exist without the banner — suspect this class whenever an-O2build drops a small on-screen element that an-Ogbuild shows. One-line#ifdef AVOID_UBfix each. - Not caught headless by a symbol/link check or a default-config framediff
(the missing primitive is often small). Needs an
-O2build + 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&argsto_Putfld; crashed at boot on Linux-O2viabossInitMainthreadData→sprintf. Fix: under#ifdef PORT, thread a realva_list *from the top (sprintf.cpasses&argsof an actualva_listobject;_Printftakesva_list *and passes it straight through).#elsekeeps the ROM-matching body. - Rule: to share one arg-walk between functions, pass
va_list *explicitly from the outermostva_startscope. Never&ava_listthat arrived as a parameter.va_copyinto 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 throughxprintf/_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.csub_GAME_7F0B1DDCStandTile *tileStack[39](bail isif (cat >= 41), checked once per outer loop → writes[40..]);bondview2.cbondviewCalcIntroSwirlCameraf32 pointbuf[10]written[-3 .. 11]. - Fix policy:
-fno-stack-protectorglobally (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 PORTis 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_failin the backtrace, in asub_GAME_*or other byte-matched function, during stage load or the first few frames. - Distinct from D7 (
va_listUB) and D6 (missingreturn) but the same “works on N64 + MinGW, fatal on hardened Linux” shape.
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 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.
- 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-color + 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. - 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; anysysSleep/ busy-wait on that path (frame-rate cap, a lock, an asset load) stops retrace delivery, so every game thread blocked onosRecvMesg(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 < 30is 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 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.