First real human WS6 playthrough. Each crash is a subsystem the 21-level
boot sweep never exercises.
- D135 (propobj.c) bgTestHitOnObj: unported N64 GBI parser — raw 8-byte-Gfx
byte/word indices desync on the PC 16-byte Gfx stream, walk off the DL.
Ported #ifdef PORT to gdl->words.w0/.w1 shifts (mirrors pd_port bg.c:3635).
Sub-fix: objHit:9717 div-by-0 — texnum recovery not portable, force -1.
- D137 (gunfire.c) gunDrawSight: s32 sp54 holds a Gfx*; 4-byte slot splices
adjacent stack into the pointer -> wild DL write on right-mouse aim.
#ifdef PORT: Gfx *sp54.
- D138 (snd.c) sndCreatePostEvent: parked audio never drains evtq->allocList,
per-objTick positional-sound posts stall the main thread ("1 room from the
end" freeze). #ifdef PORT no-op until audio lands (D77).
- D139 (cleanup_objects.c) cleanupObjects: read the propDef type via
(u8)obj[0] — correct only big-endian (type = low byte of the header word).
On LE that's extrascale -> walk never hits PROPDEF_END -> runs off the blob
-> frees garbage -> stage-unload crash. #ifdef PORT uses pdef->type.
UNVERIFIED (fast teardown test hit D140 first).
Open: D140 pause-menu watch-model NULL matrix; D118c aim-mode mouse-down
-> crouch. New harness tools_pc/repro_gdb.sh + build-pc/d136_cmds.txt.
-level_09 unregressed (91.65%). Docs: PCPortResearch §F D135-D140,
PORT-LEARNINGS §B/§C, HANDOFF M-23 + M-24 next-task.
20 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), 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.
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. - 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 aims with digital C-buttons, no analog-aim hook outside
src/. Mouse-look is bridged by integrating the relative-mouse delta into a clamped per-axis accumulator and emitting a C-button per poll while |accum| ≥ 0.5, draining one unit → proportional press dwell. Non-linear (GE's own accel curve) but playable (D118). 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.
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.