Files
goldeneye-pc-port/docs/PORT-LEARNINGS.md
T
James Dansereau ffc3112037 D117: framediff.py visual-regression tool; root-cause frame nondeterminism
Root cause (HIGH confidence): pure variable-timestep frame pacing, not
PRNG/uninit state. osGetCount() is wall-clock on PC (libultra.c:88), and
frametiming.c waitForNextFrame advances game logic by however many 60Hz
ticks of real time elapsed per render -> two runs diverge 32-69% of
pixels by frame 200-440. random.c seeding verified deterministic and
correct; the PRNG stream only diverges as a victim of the pacing jitter.

GE_DETERM fixed-tick mode: assessed NOT narrow (frame-coupled virtual
clock deadlocks the boss.c retrace gate / pre-first-frame waits;
robust version redesigns VI retrace + pacemaker semantics). Deferred
with a full design in PCPortResearch.md F/D117 per AGENTS.md.

tools_pc/framediff.py: structural/tolerant frame compare (16x12 grid
mean-colour delta + non-clear-% swing + 16x16 aHash), --mask for the
animated HUD, --exact for a future deterministic build, --update to
refresh tools_pc/golden/. Reads .ppm and .png (built-in PNG decoder).
Validated: two nondeterministic re-runs pass structural, --exact
reports the real 32-69%, a wrong-frame pair fails.

No game-logic or port-layer C changes; no probes left in tree.
2026-08-29 10:49:15 -04:00

4.2 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).
  • 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).
  • 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.

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).

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.