WS4 level sweep driven to completion: 12/21 solo levels PASS (load + render + no-crash). Full matrix in docs/LEVEL-STATUS.md, reusable runner tools_pc/level_sweep.sh, WS6 checklist docs/LEVEL-PLAYTEST.md. Fixes: - D123 (tools_pc/d88_propdefs.py): crash class C1 on 6 levels (Dam/Runway/Frigate/Statue/Streets/Cradle). D122 fallout - the widened Vehichle/AircraftRecord.ailist slot was zeroed instead of carrying its read-before-write integer AI-list id, so prop.c -> ailistFindById(0) -> GAILIST_AIM_AT_BOND -> ai() ran a CHR aim list with a NULL chr -> chrIsNotDeadOrShot(NULL). New OBJ_ID_WORDS arm. 4 levels -> PASS, Runway falls through to C2. BUNKER1/Silo unregressed. - D124 (port/src/gimgfixup.c): crash class C2 on Jungle. Compiled explosion-DL pointer-sync keyed on an IMAGESEG marker that texLoad() had already erased -> compiled globalDL_0xNNN arrays kept link-time IMAGESEG words -> 0xABCDxxxx into fast3d on first explosion draw. Latent on every level. Detect the slot from the compiled array instead. Diagnosed, NOT fixed (write-ups in PCPortResearch.md SF): - C2-GDL (D124-Facility addendum): Facility/Runway - model texture-pool path yields a garbage tex->data; every model file loads at an odd bank base (mempAllocBytesInBank does no alignment). Suspect d43_emit.py/pcmodels sidecar texture-blob offsets (N64 8B vs PC 16B Gfx). - C3/C6 (D125): Aztec/Bunker2/Surface2 - converted propDefs blob in RAM doesn't match d88_propdefs.py offline output (garbage after record 0); runtime sizepropdef() walk drifts ~100 records -> wrong door/modelid. d88_propdefs.py convert_stream + sizepropdef() PORT strides verified correct; suspect a dest-offset/pd_end bug in d88_emit.py propdefs emit. Remaining: 9 crashes / 6 classes (C2-GDL, C3/C6, C2m Jungle G_MTX, C5 Control BG portal, C4 Depot BG tile/room, C7 Surface1 sndSetupSound). Priority order in docs/LEVEL-STATUS.md. Also folds in the in-progress docs restructure (HANDOFF/AGENTS/CLAUDE, PCPortResearch D121/D122 SF/SH entries, PLAN-linear-level-sweep, HANDOFF-ARCHIVE, BACKLOG).
9.0 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.
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).
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. - 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.