Files
goldeneye-pc-port/docs/PORT-LEARNINGS.md
T
James Dansereau 9d118e0b13 M-13: 21-level sweep matrix + D123 (C1 fix) + D124 (C2 Jungle fix)
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).
2026-08-29 16:36:18 -04:00

9.0 KiB
Raw Blame History

PORT-LEARNINGS — recurring N64→PC bug classes

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

A. Pointer-width struct growth (32→64) — the dominant class

A decomp struct with pointer fields, or one pun-allocated into a fixed N64-sized hole / hardcoded byte count, is larger on x86-64. Reading it from ROM bytes misaligns; allocating it N64-sized overruns adjacent state.

  • Symptom: garbage pointer deref, corrupted neighbour field, delayed fault far from the cause.
  • Fix: store embedded ROM addresses as u32, cast at use site (PD ground truth); or give real inline storage / sizeof()-based alloc under #ifdef PORT.
  • Instances: D53.2 (ModelSlot pun), D56 (watch Model raw offsets), D57 (rwdata record count), D67 (image_entry), D79 (bg_room_data), D98 (struct player alloc), D100 (player.model inline Model), D101/D102 (ModelNode*/weapon Model puns), D115 (gunfire THROW* raw offsets), D119 (weapons_held[]->chr punned as ChrRecord* to read .act_*.attack_item — aliased WeaponObjRecord.weaponnum at 0x80 on N64 via act-union@0x2C+84; act union moves to ~0x38 on PC).
  • Open landmine: raw hardcoded-offset accessors into struct player / struct hand — see docs/AUDIT-M6-player-offsets.md.

B. 16-byte PC Gfx / Vtx vs 8-byte N64

Any buffer reservation, memcpy size, slot stride, or pool budget expressed in N64 Gfx/Vtx units is half-size on PC.

  • Instances: D50.6 (texCopyGdls copied only w0 of each 16-byte slot), D58 (DL reserve 0x100→0x200), D85 (bgWidenRoomGdl 8→16 + bswap), D95 (2× master-DL buffer + raised mempool ceiling).

C. Big-endian rodata / ROM data read on little-endian PC

ROM assets and compiled-in .rodata are big-endian. Anything not run through a converter or a runtime bswap fixup reads scrambled.

  • f32 values are BE word pairs — a naive byte reversal off-by-one corrupts every float: D73 (sinf/cosf du pairs → DVAL() macro), D112 (d43_emit.py put_f32 src[doff:doff+4][::-1]).
  • Header offset tables / pointers: D54 (cseq ALMidiHdr), D68 (Globalimagetable), D87 (ramromfilestructure), D88 (Usetup* tables).
  • Negative-terminated index chains (PointUsage[]) in converted model rodata cycle forever if element endianness/stride is wrong: D120 (opcode-0x18 collision record, d43_emit.py — guarded, not fixed).
  • Packed bitfields cross byte boundaries differently: D78 / D83 (StandTile id/room, header mid/tail).
  • Rule: prefer an offline sidecar converter (D43, D69, D88 pattern, tools_pc/d*_emit.py) over a runtime fixup for a whole format.
  • A one-shot "sync patched values from the ROM copy into a compiled shadow array" pass must key its slot-detection on a field that is still intact at sync time. D124: gimgSyncCompiledGlobalDLs looked for the 0xABCDxxxx IMAGESEG marker in the ROM copy — but texLoad() had already overwritten every one of those with a real pointer, so the sync silently copied nothing and the compiled globalDL_0xNNN explosion DLs kept their link-time markers (latent on all levels; only tripped when an explosion/smoke/particle DL is first drawn). Detect the slot from the destination (compiled) array, which still holds the marker.
  • A polymorphic-record converter with a per-type handler table + a "generic" fallback silently corrupts any type the table forgot: the fallback's word-granular bswap is wrong for sub-word fields. D122 — d88_propdefs.py had no arm for 6 inherits ObjectRecord propDef types (TINTED_GLASS/VEHICHLE/AIRCRAFT/TANK/AUTOGUN/AMMO); the generic arm bswap32'd the [s16 obj][s16 pad] word as one u32, putting the model id in the wrong half → OOB table deref. When you add a converter, enumerate every type byte a level can emit and assert the handler set is total; [u16|u16] / [s16|s16] packed words need a half-swap (_hh_word), never a 32-bit swap.
  • D123 corollary: a pointer-width tail slot is not always runtime-populated. Some hold a small integer id in the ROM image that game code reads via that same field before overwriting it (VehichleRecord/AircraftRecord.ailist: prop.c:1764/1786 does x->ailist = ailistFindById(x->ailist)). A converter that lumps every pointer-width tail slot into one "widen 4→8B, emit zeroes" bucket destroys the id → ailistFindById(0) silently returns global list 0 (GAILIST_AIM_AT_BOND) → ai() runs a CHR aim list with a NULL chr → NULL deref in chrIsNotDeadOrShot. Before zeroing a widened slot, confirm the setup code writes it unconditionally; if it reads-then-writes, emit the value (_bswap32 into the low 4 bytes, LE). Decide per field, not per type.

C2. Port-layer / SDL shims

  • #include <PR/os.h> in a port .c/.h that also sees <errno.h> breaks: OSContStatus/OSContPad have a u8 errno; field vs errno.h's macro. libultra.c wraps the include in #pragma push_macro("errno") / #undef errno; cleaner for a new module is to duplicate the handful of CONT_* bits it needs (D118 input.c).
  • GE 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 whole samples[i].pads array (D118).
  • Controller state has one source: port/src/input.c. libultra.c's SI section marshals inputComputePad() into g_contPad[]; it is driven by osContStartReadData (per logic tick), no separate video.c frame hook.

D. N64 hardware idioms fast3d does not emulate

  • Z buffer cleared by pointing the colour image at it + fill-rect → does nothing in fast3d; must emit G_CLEAR_DEPTH_EXT (D105).
  • LOD / detail mip tiles: fast3d fabricates a crop when detail textures are off → force base tile (D107).
  • K0 segment-address folds (OS_K0_TO_PHYSICAL | 0x80000000) and hand-inlined BG_SEG_TO_PTR need the (u32) 32-bit wrap the macro has (D58, D84).
  • Portal near-plane: z==0 clip points project to ±1e20; x86-64 float garbage can come back min>max / non-finite and slip past a degenerate-box check that clamps to full-screen on N64 (D106).
  • D74 wrap-block is DEAD CODE (gfx_pc.cpp:1546/1551): guard is cms & G_TX_WRAP but G_TX_WRAP == 0, so always false (line 1887 does it right with cms == G_TX_WRAP). And if it did run it indexes tex_width2[i] etc. (arrays [2], per-texunit) with the vertex loop index i 0..2 → OOB. Latent; harmless for CLAMP glyphs, would matter for wrapped textures on tris. Fix deferred (M-11) — logged in docs/GRAPHICS-BACKLOG.md.

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

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

E. Process / method notes

  • Investigation loop is: reproduce → env-gated capped probe → root-cause → narrow #ifdef PORT fix → visual verify (GE_PCDUMP + tools_pc/pixcount.py vs docs/reference/n64-footage-*).
  • gdb launch mode is too slow for timing-dependent faults; gdb attach to an already-running process is fine and fast.
  • Check PD_PORT_CHECKOUT for the PD analogue before writing anything new — same Rare engine family.
  • Don't re-investigate a closed §F finding or re-derive a format spec that already has a converter.
  • The port is NOT frame-deterministic (D117): osGetCount() is wall-clock on PC, and GE is a variable-timestep sim (frametiming.c waitForNextFrame → deltaFrames = 60 Hz ticks of real time elapsed per render), so "frame N" differs 15–40 % between runs. Use tools_pc/framediff.py <ppmdir> (structural: 16×12 grid mean-colour + non-clear-% + aHash, --mask X0,Y0,X1,Y1 for the HUD, --update to refresh tools_pc/golden/) — NOT an exact compare. A GE_DETERM=1 fixed-tick mode was assessed not-narrow (redesigns retrace/tick semantics); design is in §F D117 if someone picks it up, after which framediff.py --exact becomes usable.