mirror of
https://github.com/jkdansereau/goldeneye-pc-port.git
synced 2026-10-23 15:52:42 +02:00
PD-style F10 menu with six groups, checkbox/slider/dropdown controls (D504), wording pass and real-unit sliders (D505-D509), overlay/FPS-counter window scaling (D510, D512), small settings (D511), control scheme Ext/Original (D513) with the game's own N64 styles (D516-D518), emulator-format save import on first launch (D514), build arch tag (D515), converter argv quoting and sidecar size check, one PC options UI (D519) with by-eye follow-ups, antialiased overlay sliders (D520), README settings paths, ROADMAP trim, D441 tier-1 all-level sweep (21/21 pass). Refs: D503-D520 D349 D441
1883 lines
147 KiB
Markdown
1883 lines
147 KiB
Markdown
---
|
||
title: Porting Notes
|
||
description: A field guide to the recurring N64-to-PC bug classes hit while running unmodified big-endian game code on a 64-bit host, each with its symptom and fix.
|
||
---
|
||
|
||
## 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`](https://github.com/jkdansereau/goldeneye-pc-port/blob/main/docs/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](#a-pointer-width-struct-growth-3264--the-dominant-class)
|
||
- [B. 16-byte PC `Gfx` / `Vtx` vs 8-byte N64](#b-16-byte-pc-gfx--vtx-vs-8-byte-n64)
|
||
- [C. Big-endian rodata / ROM data read on little-endian PC](#c-big-endian-rodata--rom-data-read-on-little-endian-pc)
|
||
- [C2. Port-layer / SDL shims](#c2-port-layer--sdl-shims)
|
||
- [D. N64 hardware idioms fast3d does not emulate](#d-n64-hardware-idioms-fast3d-does-not-emulate)
|
||
- [D2. The HUD/model "X-mirror"; RESOLVED](#d2-the-hudmodel-x-mirror-d114d116--resolved-it-was-an-upside-down-capture)
|
||
- [D3. GCC/mingw makes an all-non-negative `enum` UNSIGNED](#d3-gccmingw-makes-an-all-non-negative-enum-unsigned)
|
||
- [D4. N64 "interrupts off" must be a real lock on PC](#d4-n64-interrupts-off-is-not-free-on-pc--it-must-be-a-real-lock)
|
||
- [D5. Loop bounds that assume linker adjacency of two globals](#d5-loop-bounds-that-assume-linker-adjacency-of-two-file-scope-globals)
|
||
- [D12. Collapsing an LOD binding to tile 0 breaks a DETAIL binding](#d12-collapse-an-lod-binding-to-tile-0-is-wrong-for-a-two-texture-detail-binding-d236)
|
||
- [D13. Per-render accumulation is not associative (1 vs 2–3 ticks/frame)](#d13-per-render-accumulation-is-not-associative-the-ports-1-tickframe-can-reach-states-the-n64s-23-ticksframe-never-did-d329)
|
||
- [E. Process / method notes](#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 player` field-run: PC
|
||
`sizeof(struct Model)` grows so the "fields" that aliased `.render_pos` /
|
||
`.scale` / `.animframe1` on N64 no longer overlap → NULL `render_pos` →
|
||
pause-menu crash; fix = real inline `struct Model` + pool, redirect the
|
||
named reads to the member; same as D100/D102),
|
||
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).
|
||
|
||
**A1. Raw-byte or mistyped aliases into a union/struct whose true field
|
||
shifted because a sibling member widened; the silent variant.** The nastiest
|
||
members of this class do not crash: a literal byte
|
||
index into a union that overlays pointer-bearing structs still *reads*, it
|
||
just reads the wrong byte, so the game runs and merely behaves wrong. Grep
|
||
for hardcoded indices into any `padding[]`/`u8[]` union arm.
|
||
|
||
- **D209** (the D193 root cause, and the highest-impact instance found so
|
||
far): `self->act_ubytes.padding[45]` aliased `act_gopos.unk59`, the
|
||
SPEED tier. `act_gopos` = `{coord3d@0, StandTile *@12, waypoint *@16,
|
||
waypoint *[6]@20, u8 curindex@44, u8 unk59@45}` on N64; at 64-bit the
|
||
three pointer members widen and `unk59` moves 45 → 81, so the literal 45
|
||
lands on **byte 5 of `waypoints[1]`**. With a low-4GB arena
|
||
(`0x00000000_70xxxxxx`) that byte is **always 0x00**; a stable, plausible
|
||
value, not garbage. Effect: the locomotion-animation selector received
|
||
tier 0 forever and every AI character in the game was bound to a *walk*
|
||
animation; since GE travel is anim-root-motion driven, all AI moved at
|
||
walk pace on every level. No crash, no log, no NaN; it took a runtime
|
||
probe comparing the *commanded* tier (named field, correct) against the
|
||
*bound* animation (alias, wrong) to see it.
|
||
- **D210** (sibling, found by the post-D209 sweep): `chrToPatrol` inits
|
||
`act_patrol.lastvisible60` via `act_init.padding[0x13]` (union byte `0x4c`).
|
||
`act_patrol` starts with a `patrol_path *path`; widens 4→8, so `padding[0x13]`
|
||
no longer hits `lastvisible60` (now `0x50`); it lands in `waydata` and
|
||
`lastvisible60` is left uninitialised → the patrol "haven't seen the player
|
||
recently" timer reads garbage for the first ticks. Fix: named field under
|
||
`#ifdef PORT`. **NB the alias need not land on a *pointer byte*** (as D209
|
||
did); here it lands on a plain float that just isn't the field intended.
|
||
The tell is the same: a hardcoded index into `padding[]` right next to
|
||
named writes of the *same* arm. The rest of the `padding[N]` sweep
|
||
(`chrlvTickAnim`/`chrlvTickDead` → `act_anim`/`act_dead`, both pointer-free)
|
||
is layout-stable.
|
||
- **D255** (a mistyped-field variant, not a raw-offset variant — same
|
||
mechanism, different syntax): `vtxstore_fix_refs` read a record's
|
||
`Model*` through `((Model*)((ChrRecord*)prop->chr)->chrflags)` —
|
||
`ChrRecord.chrflags` is typed `CHRFLAG`, a **4-byte enum**. On 32-bit
|
||
N64 the cast is bit-exact; on 64-bit PC it reads only the pointer's
|
||
low 32 bits and zero-extends, discarding the real high bits. The
|
||
truncated result is `(nil)` whenever the real pointer's low 32 bits
|
||
happen to be zero — which read exactly like a legitimate NULL-model
|
||
case, and cost two prior investigation sessions chasing a
|
||
"destroy-path race" theory before the field itself was the suspect.
|
||
**Tell that generalizes past D209/D210's `padding[N]` shape:** it
|
||
doesn't have to be a raw numeric offset — a field that is *correctly
|
||
named* but *undersized/mistyped relative to a pointer it's secretly
|
||
meant to hold* (an enum, a `u32`, a `s32` doing double duty as a
|
||
pointer slot) is the same bug through a different syntax. Fix: read
|
||
through the correctly-typed sibling field (`ObjectRecord.model`)
|
||
instead, `#ifdef PORT`, N64 line/cast kept verbatim under `#else`.
|
||
§F **D255** (M-140).
|
||
- **D392** (the firing-guard pose variant): `chrlvUpdateAimendbackShoulders`
|
||
reads `((f32*)arg1)[12/13/16/17]` into a
|
||
`weapon_firing_animation_table` whose *first* member is a union of a
|
||
`s32` and a `ModelAnimation *`. N64's 4-byte union places those slots at
|
||
`max_up/max_down/free_arm_frac_up/free_arm_frac_down`; on PC the 8-byte
|
||
union shifts the floats, so the raw indices read `aim_end_frame/max_up/
|
||
max_right/free_arm_frac_up`. The wrong aim clamp bends the torso ~49° and
|
||
pushes the hands through it. Under `#ifdef PORT`, use the four named
|
||
fields; retain the raw indices for N64. §F D392.
|
||
|
||
**Lesson.** When a value reads as a clean constant (0, 1, NULL) rather
|
||
than garbage, a pointer byte is a prime suspect; the high bytes of a
|
||
low-4GB heap pointer are all zero, so a misaligned or truncated read
|
||
looks like a legitimate "feature off" / "not set" value. **Tell-tale
|
||
signs of this pattern, any one of which should trigger an A1-first
|
||
check before assuming a bug is genuine game-logic behavior:**
|
||
- The observed value is **byte-exact-looking but wrong from the very
|
||
first call/frame** — not intermittent corruption, not "usually right
|
||
but occasionally wrong."
|
||
- A **raw/literal offset or byte-width read** into a union or struct
|
||
that has **any pointer member**, anywhere in that union/struct's
|
||
layout (not just the field being read) — the read doesn't need to
|
||
touch the pointer itself; any member *after* one that widened is at
|
||
risk.
|
||
- A field that is **correctly named but too narrow to hold what it's
|
||
actually storing** (an enum/`u32`/`s32` reused as a truncated pointer
|
||
slot — the D255 shape, distinct from D209/D210's literal-offset shape
|
||
but the same underlying cause).
|
||
- The resulting behavior is **deterministic and internally
|
||
self-consistent, but demonstrably not what the N64 original would
|
||
produce** — the code isn't crashing or producing garbage, it's
|
||
confidently doing the wrong (but stable) thing.
|
||
- Prefer the named field (or a correctly-typed sibling field) under
|
||
`#ifdef PORT`; the alias/pun is only correct at 32-bit pointer width.
|
||
Verify every caller reaches the site with the union/struct arm you're
|
||
naming actually active.
|
||
|
||
**Distinguish from D284** (`port/src/random.c`'s MIPS64
|
||
shift-emulation bug): same *epistemic* shape — deterministic,
|
||
self-consistent, silently not-N64 — but a different *mechanism*
|
||
(instruction semantics, not struct layout/pointer width) and,
|
||
critically, **entirely inside `port/`, no `src/game` touch at all**.
|
||
D284 was never a rule-2 question in the first place; don't fold it
|
||
into this catalogue's "looks like game logic, is really ABI"
|
||
framing — it never looked like game logic to begin with.
|
||
- **Open landmine:** raw hardcoded-offset accessors into `struct player`
|
||
/ `struct hand`; see `docs/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 PC `sizeof` with the pointer slot widened +
|
||
the matching `#ifdef PORT` `sizepropdef`/stride. Instances: `d88_propdefs.py`
|
||
types 30/32/33/35 (`criteria_*` / `setup_objective_text` `->next`),
|
||
22 (`TagObjectRecord.NextTag`, already handled). Check every record type
|
||
the walk can see for a `set_parent_*` / `*_entry_parent` writer.
|
||
|
||
- **D132 corollary; a `union { T *ptr; s32 IndexN; }` field is 8B/8-aligned
|
||
on PC even when the ROM image only ever stores the `s32`.** `LinkRecord`
|
||
(`first`/`Index1`), `LockDoorRecord`, `SafeObjectRecord` etc. pack their
|
||
index words tight (4B) in the N64 setup stream, but the compiler places
|
||
each `IndexN` in 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 makes `pdef->Index1` read
|
||
`Index2`'s value and `Index2` read 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 each `IndexN` at PC offset `8 + 8*(N-1)` and
|
||
size the record with the widened unions + trailing `*next`
|
||
(`d88_propdefs.py` types 14/19/38/44; proposed in §F D132, not yet
|
||
applied). Grep every ROM-serialized record for `union {` 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
|
||
(`fontchar` 24→32B, `romdataFixupFont`) is often re-laid-out *in place*:
|
||
for glyph `i`, `dst = base + PCstride*i`, `src = base + N64stride*i`, so
|
||
`dst − src = (PCstride−N64stride)*i`. For small `i` that delta is *less
|
||
than the per-element field-copy read span*, so `dst` overlaps `src`, and a
|
||
forward field-by-field `*dst_k = f(*src_k)` loop overwrites a not-yet-read
|
||
`src_j` (`j > k`). Result: later fields of the first few elements come out
|
||
as `f(f(earlier field))`; e.g. glyph 1's `width` became `bswap(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 every `romdataFixup*` / in-place relayout for a
|
||
read-write loop whose `dst`/`src` can 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
|
||
before `chars[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-byte
|
||
`pixeldata` reads a wild pointer → fast3d AV. Latent in `textrelated.c`
|
||
(`textRender*`/`textMeasure` ASCII paths); a `GLYPH_IDX` clamp 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`/`.rodata` matrix, e.g.
|
||
`&dword_CODE_bss_8007A100` in `explosionRenderPropSmoke`) it drops the
|
||
`0x1_00000000` module high word, so the GBI w1 becomes `0x40xxxxxx` and
|
||
`seg_addr()` hands fast3d a wild pointer → AV in `gfx_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 in `seg_addr()`:
|
||
restore the high word for a fallthrough `w1 ∈ [0x40000000, 0x70000000)`
|
||
(module is fixed-based at `0x140000000`; DRAM/KSEG0/segmented/phys are all
|
||
handled in earlier branches). `gSPDisplayList(&globalDL_0xNNN)` is NOT
|
||
affected; the port's `Gwords.w1` is 64-bit and `gDma1p` stores the full
|
||
pointer; only `osVirtualToPhysical` truncates.
|
||
|
||
- **D177; a struct passed as `s32 *` and integer-indexed past a leading
|
||
pointer member.** `stanCheckLinkedSpecialTile` takes the caller's
|
||
`struct StandTileLocusCallbackRecord { s32 *rooms; s32 count; … }` typed
|
||
as `s32 *outFlags` and does `outFlags[1] = 1`. On N64 `[1]` is `count`; on
|
||
PC `rooms` is 8 bytes so `[1]` is its *high half* and `count` moved to
|
||
`[2]`; the write is silently lost, the reader (`->count`) sees 0. Two
|
||
tells for this class: (a) a function parameter typed `s32 *` / `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]`/`rooms` often still works by luck
|
||
on LE, low half at +0, which masks the bug for one of the two fields.)
|
||
|
||
- **D248; a hardcoded byte offset used to index "one struct past the end of
|
||
the array" desyncs from a real field write once the struct grows.**
|
||
`OSScClient` (`src/sched.h`) is `{next; msgQ;}`, 8B on N64 / 16B on PC.
|
||
`osScAddClient()` deliberately writes a per-client flag into the *next
|
||
array element's* first field (`c[1].next = next`) instead of adding a
|
||
real member; cheap on N64 since the caller over-allocates the array by
|
||
one slot. The reader, `__scHandleRetrace`, doesn't index the struct back;
|
||
it recomputes the same address by hand as `*((s32*)client + 2)` (a
|
||
hardcoded 8-byte offset). That offset only equals `sizeof(OSScClient)`
|
||
(and thus `&client[1]`) when pointers are 32-bit. On PC it lands 8 bytes
|
||
into `client[0]` itself, inside `msgQ`, a real non-NULL pointer, so the
|
||
flag reads as permanently "set" for every client, silently collapsing a
|
||
60Hz/30Hz per-client split onto whichever branch a nonzero flag takes.
|
||
**General tell:** any place a struct is deliberately under-declared and a
|
||
sibling array slot is used as ad-hoc extra storage (`c[1].field`,
|
||
`arr[i+1].x`) is fine as a *write* (indexing scales with the real
|
||
`sizeof`), but a matching *read* done via a raw `(T*)p + N`/byte-offset
|
||
cast instead of `p[1].field` will silently desync once the struct's size
|
||
changes across the port. Fix: always index the struct on both ends
|
||
(`client[1].next`), never hand-roll the byte math; it's the direct
|
||
decompilation of what the write side already does, `#ifdef PORT` with the
|
||
N64 hand-rolled offset kept verbatim under `#else`. §F **D248**.
|
||
|
||
- **One layout bug often has sibling call sites.** D264 fixed a
|
||
cross-global `ModelRenderData` template read in the watch item preview;
|
||
the identical read in `watchRenderController` (`D_80035D04 + 0x3c`) stayed
|
||
broken until D290. When fixing a raw-offset global read, grep for every
|
||
other read of the same globals (`D_80035D00`/`D_80035D04` here) before closing.
|
||
|
||
- **When a split N64 struct is made inline on PC, its placeholder fields
|
||
become dead, and any code still reading them reads uninitialised memory.**
|
||
D100 turned `player.model` into a real inline `struct Model`, but the
|
||
decomp's `field_59C..field_650` placeholders (raw N64 offsets into that
|
||
model) stayed as separate fields. `bheadAdjustAnimation` kept reading
|
||
`field_5C0` (= N64 `model.animframe1`) and `animFlipFlag` (= `model.gunhand`),
|
||
which nothing ever wrote (D336: garbage anim frames, Frigate scene wrecked,
|
||
sensitive to memory layout). After inlining a split struct, grep every
|
||
placeholder in the old range and remap each read to the real field.
|
||
Diagnostic tell: a hardware watchpoint on the "poisoned" field sees *no
|
||
writes at all*.
|
||
|
||
### A-dup. Renaming an IDO duplicate member re-points every read of it (D307)
|
||
|
||
IDO accepts a struct that redeclares an inherited member name (`inherits ObjectRecord;` then its own `pad`); GCC rejects it, so the port renames one. **The rename changes which field existing code reads**: on N64 the later declaration shadows the inherited one, so `rec->pad` meant the derived record's own field. After renaming it (`lookpad`), unchanged game code silently binds to the inherited field instead. D307: `setupCctv` read the mount pad as the look-at pad and every security camera faced its wall. When a port rename resolves a duplicate, grep every `->name` on that struct type and decide per site which field N64 meant (PD's named-field sources are the tie-breaker).
|
||
|
||
### A-cnt. A record's "count" field may not count what the converter copies (D303)
|
||
|
||
The offline model converter sizes each record's trailing arrays from its count field. Check what the *runtime consumer* does with that count before trusting its name: GE's op-22 `DisplayListPrimaryRecord.numVertices` is really the number of star-flash ARMS, and `dorottex()` reads `count*4` vertices (PD names it `stargunfire.unk00` and reads `unk00*4`). Sizing by the field name dropped 3/4 of every muzzle star; the missing vertices came from neighbouring sidecar bytes and drew as spikes. For every record type, derive the copied extent from the reading loop, and cross-check PD's typed struct.
|
||
|
||
### A-buf. Growing one region of a shared buffer must move every hard-coded offset after it (D342)
|
||
|
||
When a PC-larger asset gets a bigger reservation inside a shared scratch buffer (D45: the wallet model 0xA000 -> 0x17000 in `ptr_logo_and_walletbond_DL`), grep for every other user of that buffer that computes a fixed offset (`base + 4096*10`). Those still point into the old boundary and silently overwrite the enlarged asset; the corruption only shows when the asset is not reloaded.
|
||
|
||
**A1 cross-tag (D441, 2026-09-30):** the 32→64 widening of `(u32)`/`(s32)` *pointer casts* (rebase arithmetic on RAM copies, segment/anim-table bases) is the same class. Pattern: `#ifdef PORT` `(uintptr_t)` arm + untouched `#else`. Exception: `(s32)&ANIM_DATA_x` is an intentional offset (D34: 4 GiB-aligned base, low 32 bits = segment offset) — widen only the *base* it is added to, never the offset. Census of the remaining ~185 sites in the touched files is still owed (D441).
|
||
|
||
**A1 cross-tag (D457, 2026-09-30): a pure pointer-width retype can change behaviour by shifting the allocation layout.** Widening `struct player` (+32 B) / `struct Model` (+16 B, inline x4 in player) flipped the default-boot file-select menu to an all-black frame (no crash, identical log) while `-level_09` was fine; allocating the player block at its old size restored it. The menu depends on the MEMPOOL_STAGE layout (player block precedes `g_GfxBuffers[0]`, D98) through something address-sensitive. After any struct widening, run the default-boot menu with a `GE_PCDUMP` frame (not only a level) and check the frame extrema, not just rc. Also: a hardcoded record stride (`vtxstore.c` `n * 0x14` for a record that is 0x18 bytes on x86-64) is a silent overrun that widening makes worse; grep literals near allocs of retyped structs.
|
||
|
||
**A1 cross-tag (D441, 2026-10-03): the read side, `s32` back to a pointer, sign-extends.** An arena pointer stored in an `s32` (a local, a global, a field, a function parameter named like an id: `pathid`, `opcode`, `resolution`, `helddst`) truncates harmlessly below 4 GiB, but `(T *)s32` and the implicit `s32` -> `T *` conversion (a `-Wint-conversion` "makes pointer from integer", no cast to grep for) sign-extend bit 31. Invisible while the arena sits below 0x80000000; the tier-1 test build (`GE_HIGHARENA_BASE=0x90000000`) turns each one into a 0xffffffff9xxxxxxx fault, in crash order. Fix: `(T *)(uintptr_t)(u32)(EXPR)` under `#ifdef PORT`. Also grep the port layer for address-range **upper bounds**, not only base literals: fast3d's DRAM-vs-C-array texture test ended at a `0x90000000` literal, so with the arena moved there every DRAM texture was bswapped (mirrored texel pairs, no crash). Ranges must come from `portaddr.h` (base + `PORT_DRAM_SIZE`). And pin `GE_RSEED` + `GE_INPUTSCRIPT` before diffing level frames between builds: unseeded runs differ every frame.
|
||
|
||
## 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).
|
||
- **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*)gdl` for the cmd, `((u32*)gdl)[1]` for w1, `((u8*)gdl)[5..7]`
|
||
for `G_TRI1` vtx indices, `((u32*)gdl)[0/1]` nibble reads for `G_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 byte `i` of a word →
|
||
`(word >> (8*(3-i))) & 0xff`). `gdl++` (advances by `sizeof(Gfx)`) is already
|
||
correct. Watch `(s32)ptr` truncation in vtx-base math and `x | 0x80000000`
|
||
KSEG0 folds (identity on PC; just drop the OR, and guard segmented w1).
|
||
Also raw-index **writes** into static DLs: D465 (`unk_092E50.c` water
|
||
controller patched `((u32*)dl)[8]`, meaning N64 `Gfx[4].w0`, which hit
|
||
`Gfx[2]` on PC; use `dl[n].words.w0` instead).
|
||
Instances: `bgTestHitOnObj` (`propobj.c`, FIXED); `bgTestRayIntersectionInRoom`
|
||
+ `bgTestBulletHitBackground` tail (`bg.c`, D154; ported M-28, re-audited +
|
||
bug-fixed M-30, playtest-gated).
|
||
PD ground truth: `pd_port` uses `gdl->dma.cmd` + `GFX_W0_BYTE(i)`/`GFX_W1_BYTE(i)`
|
||
macros (`3-i` / `11-i` on 64-bit LE).
|
||
- **D154 corollary; the PC `Gdma_le` shim's `.par` is NOT the N64 params byte.**
|
||
When porting a room/model DL GBI parser, only `.dma.cmd` is safe to read
|
||
through the `port/shim/PR/gbi.h` `Gfx` union: that shim lays `Gdma_le` out as
|
||
`par:24` (bits 0-23 of word0 = the *packed length* from `gDma1p`), `cmd:8`
|
||
(bits 24-31). The N64 `Gdma` has `cmd:8` (byte 0) then `par:8` (**byte 1**,
|
||
bits 16-23 = the `((n-1)<<4)|v0` G_VTX params). So an N64 `((u8*)gdl)[1]` /
|
||
N64-semantics `.dma.par` becomes `((u32)gdl->words.w0 >> 16) & 0xff` on PC;
|
||
**`gdl->dma.par` gives bits 0-23 (length), and `& 0xf` on it is always 0**
|
||
because `len == 16*n`. The original D154 port had exactly this bug (vtxoff
|
||
forced to 0). Same trap in `bgBuildRoomVtxBounds` (`gdl.dma.par>>4&0xf`
|
||
reads PC bits 4-7 = `n&0xf`, not N64 bits 20-23 = `n-1`): for n ≤ 15 it
|
||
happened to read the whole block (+1 vertex), but for n = 16 (the common
|
||
authored size) it collapsed to `numvertices=1` → single-point batch bounds
|
||
→ the per-batch AABB pre-test in `bgTestBulletHitBackground` rejected the
|
||
batch → its wall triangles were never ray-tested: D312 projectile
|
||
pass-through + D313 missing bullet impacts (FIXED 2026-09-18, recover the
|
||
params byte via `((u32)gdl->words.w0 >> 20) & 0xf`). 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: a `words.w0 << k >> m` bit-extract idiom that relied on 32-bit
|
||
truncation on N64 must be `(u32)`-cast first on PC (`words.w0` is 64-bit).
|
||
- **Also bites RAW hardcoded struct-stride writes, not just Gfx/Vtx.**
|
||
D128: `sub_GAME_7F0B37EC` did `((u8*)g_BgPortals)[(portal<<3)+6] |= 2`;
|
||
the N64 `bg_portal_data_entry` is 8B (`ptr@0, cr1@4..cb2@7`), but on PC
|
||
the widened `offset_portal` ptr 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` / `+ 6` style raw offsets into any struct that gained a
|
||
pointer field.
|
||
D141: `set_enviro_fog_for_items_in_solo_watch_menu` (`gunfire.c:1720`)
|
||
indexed a `ModelNode*` array (`ModelFileHeader.Switches`) as
|
||
`*(ModelNode**)((u8*)Switches + j + 0x48)` with `j += 4`; the `0x48`/
|
||
`0x5c` and 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>)` and
|
||
`arr[i << k]` where the array element is a pointer.
|
||
D191: `bondviewSelectCuff` (`bondview2.c`); the **same** `Switches`
|
||
array, missed by D141. `offset = switchindex << 2` then
|
||
`base = (ModelNode**)((u8*)switches + offset)`, deref'd `base[0..5]`. On PC
|
||
the 8-byte slot stride makes `base[N]` a half-word-swapped pointer
|
||
(`0x70267a90` → `0x70267a9000000000`) → crash in `modelGetNodeRwData` when
|
||
the player fires with a fresh-save Bond model (Bunker ii first guard,
|
||
Statue post-cutscene). Fix: `offset = switchindex * sizeof(ModelNode *)`
|
||
under `#ifdef PORT`. When you fix one `arr[i<<2]` pointer-array site, grep
|
||
the whole file; these travel in packs.
|
||
|
||
## 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]`).
|
||
- **D73 scope is now settled (M-32 D75 triage):** the whole
|
||
`src/libultra/gu/` tree is endian-clean; only `sinf.c`/`cosf.c` used the
|
||
`du` union and both are `DVAL()`-wrapped; `rotate/perspective/ortho/
|
||
lookat/scale/translate/mtxutil/normalize/align` use plain float literals
|
||
or pure integer bit-packing (`FTOFIX32`+shift/mask). The game's own
|
||
`matrixmath.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-down `GE_PCDUMP` capture, not a
|
||
real flip.)
|
||
- 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)word` to grab "the first field" of a struct
|
||
reads the WRONG byte on LE.** N64 code that does `(u8)obj[0]` (obj a
|
||
`u32*`) 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's `extrascale`. `cleanupObjects`
|
||
did this for its walk-termination + type dispatch → never saw
|
||
`PROPDEF_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)*`/`& 0xff` on a `u32`/`s32` that'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 a `str*` primitive.** The watch
|
||
BRIEF/OBJECTIVES pages (and `front.c` mission text) build their strings with
|
||
`strcpy`/`strcat(buf, langGet(id))`. `langGet` returns 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-tolerant `strcpy/strncpy/strcat` under `#ifdef PORT` in `str.c`
|
||
(NULL src → empty string, NULL dst → return). **Gotcha:** these are
|
||
`__nonnull__` **builtins** to GCC; a plain `if (src == NULL)` on the
|
||
parameter is deleted as provably-dead (confirmed in `-Og` disassembly:
|
||
no `test` emitted). 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 in `src/` that a port
|
||
NULL can reach.
|
||
- **D219 (M-112; the finding itself resolved in M-115, kept as a triage lesson), "the shared texture decoder is proven correct" is
|
||
a claim about *pixel format*, not *compression method*, and the two are
|
||
independent knobs on the same runtime `texLoad`.** `src/game/image.c`'s
|
||
non-zlib texture decompressor reads a per-image 4-bit `TEXCOMPMETHOD_*`
|
||
from the bitstream (uncompressed / huffman / huffman-per-channel / RLE /
|
||
lookup / huffman-lookup / RLE-lookup / huffman-blur / RLE-blur) independent
|
||
of the 4-bit pixel-`format` (RGBA32/16, IA16, I8/4, CI8/4 …). Two textures
|
||
sharing a pixel format can still take completely different code paths:
|
||
`_HUFFMAN`/`_HUFFMANPERHCHANNEL`/`_RLE` go through `texChannelsToPixels`
|
||
(per-channel planes in R,G,B,A order), while `_LOOKUP`/`_HUFFMANLOOKUP`/
|
||
`_RLELOOKUP` go through `texBuildLookup`+`texInflateLookup*` (a packed
|
||
color table, bits read directly in final pixel layout). "Every other
|
||
RGBA16 texture in the game renders correctly, so the decoder can't be the
|
||
bug" is **not** a valid inference unless you've confirmed the *comparison*
|
||
texture used the *same* `TEXCOMPMETHOD_*` as the suspect one; they can
|
||
silently diverge into different functions despite an identical
|
||
`G_IM_FMT_RGBA,G_IM_SIZ_16b` DL declaration. Also: whether a texture's
|
||
`G_SETTIMG` marker is baked directly into a compiled display list
|
||
(`gsDPSetTextureImage(...IMAGESEG(id))` in `assets/*.c`, fixed up by
|
||
`gimgFixupGlobalimagetable`'s DL-bswap pass) vs. synthesized at runtime
|
||
from an `sImageTableEntry`'s `index` field (`texSelect`,
|
||
`src/game/othermodemicrocode.c`) is a real, checkable difference in
|
||
*resolution* path worth ruling in/out early; but both funnel into the
|
||
same `texLoadFromDisplayList`→`texLoad` call, so it is a weaker lead than
|
||
the compression-method one. See `docs/dev/findings.md` D219 for the full
|
||
writeup and an env-gated (`GE_D219RAW`) diagnostic left in the tree to
|
||
check this on the next session that can capture runtime output.
|
||
- **D157; a small value in the LOW BYTE of a BE `s32` word, read as `s8` at
|
||
the word's last offset, reads 0 on LE after the converter byte-swaps it.**
|
||
Same family as D151/D139. `struct objective_entry.difficulty` is `s8` at
|
||
offset 0xF; the underlying type-23 propDef word 3
|
||
(`MissionObjectiveRecord.MinDificulty`) is a BE `s32` whose 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 the `s8`@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 PORT` so the
|
||
named byte reads offset 0xC. **Grep every struct that reads a sub-`s32` field
|
||
as `s8`/`u8`/`s16`/`u16` at a NON-zero in-word offset; after a converter
|
||
bswap that offset is wrong.** Offline tools have the same trap
|
||
(`dump_objectives.py` read 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-word `gDPLoadBlock` loads 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 PORT` no-op the function; fast3d wants a plain linear image.
|
||
General rule: any `src/` 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; audit `image.c` / `tex.c` for others (`texAlignIndices` row 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()×4` for all 48 types,
|
||
the propDef stream tiles byte-exactly, and `setupGetPtrToCommandByIndex` /
|
||
`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_POSEND` cinematic
|
||
render (D75 family). `GE_D160=1` diagnostic ships; needs a live Dam-to-exit playthrough.
|
||
**M-106 addendum:** independently re-derived (not just re-cited); `CutsceneRecord`
|
||
(propDef type 46) has zero pointer/union fields, so it cannot exhibit this class at
|
||
all; confirms the M-31 stride audit rather than superseding it. D148/D160's residual
|
||
cause is still unlocated (needs the live trace); see D173 for a related negative
|
||
result on the puppet-position side.
|
||
- **D132/D126 corollary; a zeroed ROM-serialized pointer-width tail slot is not
|
||
automatically a converter bug; check the N64 source literal first (M-106).**
|
||
Before assuming an offline converter wrongly zeroed a widened pointer field
|
||
(the D122/D126/D132 "dead-on-load" pattern), grep the asset `.c` source tables
|
||
(`assets/obseg/**/*.c`) for that field's literal initialiser. `PadRecord.stan`
|
||
(`src/bondtypes.h:1744-1751`) looked exactly like a D126/D132 miss (a ROM-
|
||
serialized tail pointer, zeroed by `tools_pc/d88_emit.py`'s `emit_pad()`),
|
||
but every `PadRecord` in every checked setup table (`UsetupdamZ.c` etc.) initialises
|
||
`stan` to literal `0`; N64 never carried a live pointer there either, so the PC
|
||
zeroing is byte-identical to ground truth, not a regression. The tell that saves
|
||
the trip: if the *source*, not just the ROM binary, always writes `0`/`NULL` for
|
||
a field, it was never "dead-on-load" in the D123-corollary sense (a field that
|
||
reads a real small int/id before being overwritten); it's simply always
|
||
runtime-computed, on both platforms, and a NULL seed into whatever consumes it
|
||
is the original game's normal case, not a port defect.
|
||
- **D151; a ROM-serialized `s32` slot decoded by the struct as `[u16 hi][u16 lo]`
|
||
reads zero on LE.** N64 code frequently splits a 32-bit setup-stream word into
|
||
`u16 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 reads `realvalue` from 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 PORT` widens the field to a full `u32` at
|
||
the word offset; converter unchanged. Grep every ROM-serialized struct for a
|
||
`u16 reserved`/`u16 pad` immediately before a `u16` the runtime actually reads.
|
||
- **Python slice-assignment width is a silent corruptor** (D125):
|
||
`out[a:a+8] = b"\x00\x00\x00\x00"` on a `bytearray` does NOT raise; it
|
||
deletes `(8 - len(rhs))` bytes, shrinking `out` and shifting everything
|
||
after `a` down. A widened pointer/`stan` slot filled with a 4-byte
|
||
literal shrank `d88_emit.py`'s output 4 B per pad; once `len(out)` fell
|
||
below a later region's write offset, `out[off:off+n] = data` clamped to
|
||
an empty range at the current end and *inserted* there; string blobs
|
||
drifted, boundpad names truncated (`p138d2`→`8d2`), `stanPackId` rejected
|
||
→ door `model=NULL` → crash. When emitting into a pre-sized bytearray,
|
||
every `out[a:b] = rhs` MUST have `len(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: `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.
|
||
|
||
- **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 a `struct` overlay 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()` in `language.c`,
|
||
`romdataFixup*()` in `port/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 and `langGet()` 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 `.c`
|
||
source in `assets/obseg/`.
|
||
|
||
## 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'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.c` reads its own RMB/LShift state as the mode proxy
|
||
(exact for hold-to-aim; a toggle scheme needs `g_CurrentPlayer->
|
||
insightaimmode`). GE's native pitch is **inverted** (C-up → look down);
|
||
hide it so mouse-down looks down; `MouseInvertY` flips (D118, M-24).
|
||
- **N64 A/B bit names are not modern pad A/B semantics (D393).** GE's
|
||
gameplay `GE_CONT_A` cycles weapons and `GE_CONT_B` contextually uses or
|
||
reloads. Assigning Xbox A/X both to native A and B/Y both to independent
|
||
crouch leaves *no* use button. For modern controls, map A to a dedicated
|
||
use edge, X to a dedicated reload edge, B to crouch, Y to a weapon-cycle
|
||
edge; preserve native accept/cancel bits in front menus/watch/tank. Track
|
||
held buttons across UI transitions so closing F10 does not trigger an
|
||
unexpected use/reload/cycle.
|
||
- `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.
|
||
- **The in-level watch/pause is not a "menu" to `input.c`** (`current_menu`
|
||
stays `RUN_STAGE`), but it reads the stick as **discrete** input: ±0x2E
|
||
on X picks an option value or flips a page, and Y scrolls rows. Any path
|
||
that turns a continuous device (the mouse) into stick deflection must be
|
||
gated off there (`!outside_watch_menu || pause_state`), or small hand
|
||
motion becomes phantom menu choices. D330/#103: mouse → legacy stick
|
||
fallback silently reset Auto-Aim/Look-Ahead to OFF. The direct-write
|
||
look paths already gate on this; the fallbacks must too.
|
||
|
||
- **A port-side shadow of game data must be re-synced every time the game
|
||
re-derives the original, not just the first time.** D252: `texReset()`
|
||
re-resolves the global image DLs' texture pointers on *every* stage load;
|
||
the compiled shadow copies (`gimgSyncCompiledGlobalDLs`, D68) only accepted
|
||
a write while a slot still held its unresolved marker, so every stage after
|
||
the title kept pointers into freed memory. It looked fine until the new
|
||
stage overwrote that region (rainbow explosions mid-level). Key a sync on
|
||
*which slots* are synced (learned once), never on "still unresolved".
|
||
Headless `-level_XX` boots hide this class (the first stage is the level
|
||
itself): repro through a real boot → title → level sequence.
|
||
|
||
- **A texture re-declared in another format must be normalised with the geometry it was IMPORTED with (D245).** GE's sky water loads a CI8 image and draws it through an RGBA16 tile; fast3d imports it as CI8 (D229) but its triangle path computed the UV divisor from the RGBA16 tile (half the width) and the whole mip-chain height (43 rows, not the 32-row mask period). Anything that changes the importer's format/extent must change the tri-path `tex_width/tex_height` with it, and wrap must equal the N64 mask period.
|
||
- **CPU-built RDP triangles need float vertices, not `Vtx` (D245).** Where GE builds RDP edge/texture coefficients on the CPU (sky/water), 32-bit S/T and positions are normal; squeezing them through s16 `tc`/`ob` costs visible precision on horizon-scale geometry. Use `G_FLOATVTX_EXT` (`port/include/floatvtx.h`).
|
||
- **A first-run-only preset is skipped forever by any earlier launch that created the ini, and window keys are not an "untouched" signal (D283).** Seeding values *before* `configLoad()` only works when no `ge007.ini` exists; one launch in a different environment (SteamOS Desktop Mode has no `STEAMOS`) writes a plain ini and the preset never runs. Apply presets *after* the load, record a one-shot flag key, and gate on keys the user must change deliberately (`Video.Fullscreen`, `Window.Maximized`) — `Window.Width/Height` are rewritten from the live window on every clean exit (`videoSaveWindowState`), so "still at default" never holds for them.
|
||
|
||
## D. N64 hardware idioms fast3d does not emulate
|
||
|
||
- **Appending a port-owned 2D overlay to the game frame: hook inside
|
||
`gfx_run()`, not `videoSubmitCommands`.** `gfx_run()` (`gfx_pc.cpp`) is
|
||
monolithic; it does `start_frame` → `gfx_run_dl` → `gfx_flush` →
|
||
`end_frame` → `swap_buffers_begin` in one call, and the game DL is executed
|
||
from `osSpTaskStartGo` (`libultra.c`), *not* through `videoSubmitCommands`
|
||
(which is dead). So a second `gfx_run()` per frame is not possible (it swaps
|
||
buffers). The clean seam is a one-line C hook between `gfx_run_dl(commands)`
|
||
and `gfx_flush()` that runs a second `gfx_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), then `gDPFillRectangle` / `textRender`
|
||
(game symbols, extern'd like `input.c` externs `current_menu`; UI, not
|
||
logic). 2D pixel space is `viGetX()` x `viGetY()`. Return NULL when the
|
||
overlay is closed so the frame is byte-for-byte unchanged.
|
||
|
||
- **Verbatim RDP triangle commands chopped into `gImmp1(G_RDPHALF_1/_CONT/_2)`
|
||
pairs** (GE's `skyRenderTri`/`skyRenderFull` hand-build an edge-walked
|
||
`G_TRI_FILL`/`G_TRI_SHADE_TXTR` for the modified RSP ucode to reassemble) are
|
||
silently dropped by fast3d; it has no RDP triangle rasteriser. Substitute a
|
||
normal `gSPVertex` + `gSP*Triangle` batch behind `#ifdef PORT`: if the game
|
||
code already screen-space-projected the verts, re-emit them under a
|
||
`guOrtho(l,r,b,t,…)` + identity modelview matching the projection's pixel
|
||
extents, `#else` keeps the stream verbatim (D176(a), M-46). **Caveat
|
||
(D176(a), fidelity pass):** a plain `w=1` ortho is only correct when every
|
||
vertex in the primitive really does share one depth/scale. The RDP's own
|
||
coefficient block for this idiom carries `S·w′/T·w′/w′` per vertex (a real
|
||
perspective-correct texture unit divides it back out per pixel) whenever
|
||
the upstream game math computed S/T from a *world-space* projection without
|
||
dividing by `w` itself (GE's sky does; `unk20/24` are texel coords on the
|
||
world cloud-plane intersection, not pre-divided). Re-emitting those under
|
||
`w=1` makes fast3d's GPU pipeline interpolate texture coords *linearly in
|
||
screen space* instead of perspective-correctly; invisible when the
|
||
primitive's per-vertex `w` barely varies, but a primitive spanning
|
||
near-camera to horizon-grazing geometry can have >30x `w` spread between
|
||
its own vertices, and linear interpolation then smears the entire S/T
|
||
delta evenly across every pixel instead of concentrating it near the
|
||
far/horizon vertices; reads as "tiles too densely / scrolls too fast"
|
||
everywhere instead of a gentle gradient. Fix: build a small custom
|
||
projection matrix that gives each vertex its real `w` (any per-vertex
|
||
camera-space `w` the game math already computed) while keeping the same
|
||
screen-space `x,y` (`clip.xy = ndc.xy·w`, `clip.w = w`; the GPU's normal
|
||
divide recovers the intended `ndc.xy` regardless of `w`, only the
|
||
interpolation changes); `Vtx.ob[]` is `s16` so pre-scale by a per-primitive
|
||
`wScale` and have the matrix multiply it back in. **Follow-on caveat
|
||
(D227, M-95): compute that `wScale` once per shared-vertex group, not
|
||
once per triangle.** If a decompiled routine fans one shared vertex pool
|
||
out across several separate calls to a per-primitive perspective-
|
||
correction helper (one call per triangle instead of one call for the
|
||
whole fan) and each call derives its own scale factor, two triangles
|
||
sharing an edge/vertex can each independently pick a different scale;
|
||
and because the scale feeds into a low-precision (`s16`) vertex field,
|
||
the same logical vertex quantizes to two slightly different values
|
||
depending on which call computed it. The position math stays exact for
|
||
any scale (it's just a rescale that cancels out algebraically), so this
|
||
isn't a logic bug and won't show up as wrong geometry; it shows up as a
|
||
visible crack/seam at the shared edge, easy to misread as "two separate
|
||
things happening" rather than one quantization mismatch. Fix: hoist the
|
||
scale computation to scan every vertex in the whole shared group once,
|
||
before the per-triangle calls, and thread that one value through instead
|
||
of letting each call recompute its own. **Follow-on caveat #2 (D227,
|
||
M-98): fixing ONE per-primitive-derived quantity this way doesn't fix
|
||
every one.** The same routine can derive more than one such "shared state
|
||
from the whole vertex group" value; GE's sky code also floors S/T into a
|
||
per-primitive range fold to keep it in `s16` (`floorf(minS/64)*64`,
|
||
`sky.c`'s `skyPortRenderPoly`). Sharing `wScale` across the fan and
|
||
leaving the fold per-call left the exact same seam-shaped bug alive one
|
||
field over: nominally-identical shared vertices still bake different
|
||
absolute values (here, off by an exact multiple of the fold period) into
|
||
each triangle's own vertex buffer. When you find one "derive this from
|
||
only my local call's inputs, should derive from the whole shared group"
|
||
bug in a routine, grep the same function for every other value computed
|
||
the same way (any per-call min/max/floor/round over a subset of a larger
|
||
shared vertex/primitive pool); don't assume fixing the first one you
|
||
found closes the whole defect class.
|
||
- 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_NONE` tile behaves as I8. fast3d's
|
||
`import_texture()` dispatched purely on `tile.fmt` and did a palette lookup
|
||
against a stale `rdp.palette` → garbage (D161, GE Depot ceiling = blue
|
||
speckle). Fix: when `rdp.palette_fmt == G_TT_NONE`, route CI4/CI8 → I4/I8.
|
||
- **CI texture cache must key on palette CONTENT, not just the TLUT source
|
||
address (D217).** GE assembles weapon/character model DLs in scratch arena
|
||
RAM and reissues `gDPLoadTLUT` from a *repeated* source address with different
|
||
palette content between materials and frames. fast3d keyed CI textures on
|
||
`{texel addr, palette_addrs[0/1], palette_index, size_bytes}` with no content
|
||
check → a later material takes a stale cache HIT and samples the GL texture
|
||
decoded for an earlier palette. Fix: FNV-1a the 512-byte `rdp.palette` in
|
||
`gfx_dp_load_tlut` (rare vs draws) and fold that hash into the CI cache key.
|
||
- **GE model-DL TLUT payloads carry a 2-entry leading zero pad (D217, still
|
||
open).** Every larger weapon/chr model TLUT decodes with `entry[0]==entry[1]
|
||
==0x0000` and real colour from index 2. `count=1` model TLUTs that feed
|
||
palette index 0 (solid-fill weapon parts; the PP7 grip) are the canary: if
|
||
the port's model-TLUT source pointer is even 4 bytes off, index 0 reads
|
||
adjacent texel/blob bytes and the part renders a wrong flat colour (green on
|
||
Facility/Depot, black-by-luck on Bunker1) while larger tiles look plausible
|
||
because they never index the low entries. Fix is upstream (segment resolution
|
||
or `tools_pc/d43_emit.py` blob placement), not in the fast3d decode.
|
||
**M-88 correction: the "segment resolution / `d43_emit.py` blob placement"
|
||
theory above is refuted.** Traced the full reference chain: model files
|
||
store textures as a numeric `TextureID` (`ModelFileTextures`,
|
||
`src/bondtypes.h`) into the **global** `Globalimagetable` (fixed once at
|
||
runtime by `port/src/gimgfixup.c`, D68; not per-model, not per-level, and
|
||
not touched by any `tools_pc/d43_*.py` script); pixel+palette bytes are
|
||
decoded fresh each level load by `texInflateZlib`/`NonZlib`
|
||
(`src/game/image.c`) into a runtime pool, with the palette appended after
|
||
the pixel data (`tex->unk0a` records the boundary); there is no
|
||
ROM-serialized or offline-converted "model-TLUT source pointer" to be off
|
||
by 4 bytes. **General rule this adds to the catalogue: a "same asset,
|
||
different bytes on different levels" symptom for a texture/model resource
|
||
does NOT imply an offline-converter/sidecar bug**; GE's model and image
|
||
pipelines resolve almost everything through small numeric IDs into
|
||
shared, runtime-decoded pools; the offline `tools_pc/d43_*.py` scripts
|
||
only ever touch model *geometry* (nodes/GDL structure/vertex data), never
|
||
texture or palette content. Before chasing a converter offset for a
|
||
texture-content bug, first prove the content is even sidecar-owned (grep
|
||
the relevant `d43_*.py`/`d88_*.py`/`d69_*.py` for the field name; if it
|
||
isn't there, the bug is runtime: `src/game/image.c`/`tex.c` (decode) or
|
||
`port/fast3d/gfx_pc.cpp` (RDP emulation)). Current leading hypothesis for
|
||
the real mechanism (unconfirmed, needs a fast3d-side probe out of this
|
||
session's scope): `gfx_dp_load_tlut`'s palette-source addressing
|
||
(`port/fast3d/gfx_pc.cpp:2202`) uses `pitch = rdp.texture_to_load.width +
|
||
1` from the preceding `gDPSetTextureImage`, which `texWriteLoadToTmemAddr`
|
||
(`src/game/tex.c:503,519`) always calls with a literal `width=1`; a
|
||
software-RDP approximation that may not correctly reproduce hardware
|
||
LOADTLUT byte-addressing for this width=1 special case. See
|
||
`docs/dev/findings.md` §D217 "M-88" for the full trace + empirical
|
||
`GE_TEXDUMP` repro (BUNKER1 `pal[0]=0001` vs FACILITY `pal[0]=1a00`,
|
||
exact match to the originally reported bytes).
|
||
- **RC3 / D167; GL `GL_REPEAT` wraps at the uploaded image size; the N64 RDP
|
||
wraps a render tile at `1<<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 keep `masks`/
|
||
`maskt`. Fix lives behind `Video.WrapFix` (default OFF, `GE_WRAPFIX` env
|
||
override): store the mask, fold the UV at `1<<mask` in 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 tile `mask` fields are the wrap period, NOT the tile-window or image size.
|
||
- 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).
|
||
- fast3d CPU-side "trivial reject" (`v1->clip_rej & v2->clip_rej &
|
||
v3->clip_rej` in `gfx_sp_tri1`) trusts per-vertex outcodes (`x<-w`,
|
||
`x>w`, etc.) that are only valid half-space tests when `w>0`. A vertex
|
||
behind the camera (`w<0`) flips the comparison sense, so its outcode can
|
||
be wrong; if that spurious bit happens to match the other two (correct)
|
||
vertices', the AND-reduction drops a triangle GL's own clipper would
|
||
have rendered correctly. Bites large near-camera polygons (room
|
||
walls/ceilings close to a doorway) far more than small prop models;
|
||
reads as "background geometry flickers away, props keep drawing" (D233).
|
||
The backface-cull code right below already special-cases this same
|
||
w-sign hazard for its cross-product sign; the trivial-reject block
|
||
didn't. Fix pattern: skip the AND-test (don't trivially reject) whenever
|
||
any vertex has `w<0`; safe, since it only ever adds triangles, never
|
||
drops more. General lesson: any CPU-side "skip this triangle" heuristic
|
||
in fast3d needs the same w<0-behind-camera exemption the existing
|
||
backface culling already has, or it'll silently eat near-camera geometry
|
||
exactly where a port is most likely to get scrutinized (doorways,
|
||
tight corridors).
|
||
- `gfx_sp_tri1`'s `G_CULL_BOTH` backface-cull cross-product (`gfx_pc.cpp`,
|
||
right below the D233 trivial-reject guard) divides raw vertex `w` with
|
||
no epsilon guard. A vertex at/near `w≈0` during a near-plane crossing
|
||
can NaN/Inf the cross product, which silently fails both the
|
||
`cross<=0` and `cross>=0` cull branches and lets a triangle draw that
|
||
should have been backface-culled — a candidate for D288 (Silo intro
|
||
stray triangle), not yet confirmed against a live repro (M-152). Unlike
|
||
D233's fix, this one hasn't been shown to actually fire yet; it's a
|
||
code-review lead, not a confirmed mechanism.
|
||
- **When chasing a missing UI element, split "game logic decides to draw
|
||
it" from "fast3d actually rasterizes it."** This codebase's decomp side
|
||
for menu/watch item rendering is consistently byte-matched and rarely
|
||
at fault; the recurring bug class is a combiner/blend-mode or
|
||
texture-format edge case in `port/fast3d/gfx_pc.cpp` for a one-off
|
||
translucent/`PROP_TYPE_PLAYER` draw that doesn't share a code path with
|
||
the common in-level rendering (D172 particle color, D217/D228 CI/IA16
|
||
palette, D229 water blend, D233 invisible-through-doors, and D290's
|
||
leading hypothesis — the watch controller graphic's fade-in uses a
|
||
low-alpha `PROP_TYPE_PLAYER` combiner state nothing else exercises).
|
||
Confirm the `src/game/` side is clean first (item ID, resource-table
|
||
entry, model header, generic equip/gate pipeline) before spending time
|
||
in fast3d — but once it is, look there, not back at the decomp logic.
|
||
- **`portHeartbeatCheck` (`port/src/libultra.c:218`) hard-caps at 3
|
||
reports total per process lifetime, by design** ("stop after a few
|
||
reports"). When chasing a long-idle hang (D287), "the heartbeat stopped
|
||
logging" is that cap firing, not evidence the hang resolved, worsened,
|
||
or changed mechanism — don't read silence from this diagnostic as a
|
||
signal. Gate a raised/unlimited report count behind its own env var for
|
||
these investigations instead of trusting the default cap's silence.
|
||
- **Before citing a `scratch/*.log`/`.txt` capture as "evidence" in a
|
||
finding, grep it for the specific numbers/strings the write-up claims**
|
||
(D287): a file with the right name on disk is not proof it holds the
|
||
claimed capture — this one turned out to be an unrelated normal-exit
|
||
run, not the freeze it was cited for, and the mistake propagated
|
||
through the entry unnoticed until a later static pass checked.
|
||
- **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. Reworked M-30 (hoisted out of the vertex loop,
|
||
indexed by texunit, gated on `Video.WrapFix`); RC3/D167 adds the non-PoT
|
||
mask-period case to the same block. Still default OFF.
|
||
|
||
- **`assert()`-based `SUPPORT_CHECK` is a silent no-op in the release build.**
|
||
`port/fast3d/gfx_pc.cpp:39` defines `SUPPORT_CHECK(x)` as `assert(x)`, and the
|
||
PC build compiles with `NDEBUG`. Every `SUPPORT_CHECK` in 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 assert
|
||
`full_image_line_size_bytes == line_size_bytes` in the `import_texture_*`
|
||
family (D183); treat any `SUPPORT_CHECK` as 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_TEXDUMP` dumps 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 to `import_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.
|
||
|
||
- **D219; a runtime bitstream decoder that stores a "wide" (>1 byte/pixel)
|
||
reconstructed value via a native machine write needs an explicit
|
||
byte-order fixup on PC; a compile-time C-array asset does not. And the
|
||
fixup is per *importer family*, not per texture: scope it by pixel
|
||
format, never by texnum range or compmethod (M-113/M-114/M-115).**
|
||
`src/game/image.c`'s texture decompressor (`texReadUncompressed`,
|
||
`texChannelsToPixels`, `texBuildLookup`, `texInflateLookup`,
|
||
`texInflateLookupFromBuffer`) builds each wide pixel as a shifted-OR
|
||
integer and does a native store; correct on N64 (native == big-endian),
|
||
byte-reversed on x86-64. This is the **opposite** of the usual "BE ROM
|
||
data read as LE" direction in this section: here the PORT *host* writes
|
||
native-endian and a downstream *shared, otherwise-correct* consumer has
|
||
opinions about byte order. The trap M-113→M-114 hit: the fast3d importer
|
||
family is NOT uniform. `import_texture_rgba16`/`_ia16` (G_IM_SIZ_16b:
|
||
RGBA16, RGB15, IA16) read the pool as **manually big-endian bytes**
|
||
(`(addr[0]<<8)|addr[1]`) → the decoder must store `bswap16(value)`;
|
||
`import_texture_rgba32` (G_IM_SIZ_32b: RGBA32, RGB24) does `PD_BE32()`
|
||
on a **native** u32 load → it wants host-order words and a store-time
|
||
swap *inverts* them (that was the muzzle-flash-blue/ammo-pink
|
||
regression). So the correct scope is the decoded `TEXFORMAT_*` (which
|
||
every call site already switches on), not compmethod (the fire frames
|
||
span LOOKUP/RLE/HUFFMANBLUR/RLELOOKUP) and not a hardcoded texnum range.
|
||
Static `assets/*.c` C-array textures don't hit this because
|
||
`gfx_tex_normalize_source` (D71) already normalizes them upstream of
|
||
`import_texture_*`; only assets that are BOTH genuinely multi-byte-per-
|
||
pixel AND decompressed at runtime through `texLoad`'s bitstream path are
|
||
exposed. Offline ROM census (M-115): 94 such images; the 16-bit family
|
||
is exactly `IMAGE_FIRE_0..14` + texnum 1198–1201/2430/2510–2523, and
|
||
every ammo/flare/crosshair/muzzle-flash wide-pixel image is RGBA32.
|
||
**Only the fire frames were eyeball-verified (M-115); texnum
|
||
1198–1201/2430/2510–2523 were corrected by the importer contract, not
|
||
seen on screen.** If a sprite in any level ever renders with wrong or
|
||
inverted colors, check first whether its texnum is in that list (and
|
||
decode it with `GE_D219RAW=1` against `scratch/d219_census.txt`).
|
||
General rule for this bug class: before "fixing" a shared decoder's
|
||
byte order, enumerate **every consumer** of the buffer and check which
|
||
endianness each one assumes; the fix belongs at the store site, keyed
|
||
on the format each consumer family expects.
|
||
Final state (M-115, resolved): `PORT_PIXEL16` = bswap16 under `#ifdef PORT`
|
||
(16-bit family only), `PORT_PIXEL32` = identity, wrapped around every
|
||
wide-pixel store in the five functions above; CI/I4/I8/IA4/IA8 (single
|
||
byte, no byte order) untouched. The intermediate history (M-113's blanket
|
||
swap regressing muzzle flash/ammo HUD (M-114), reverted to identity, then
|
||
M-115's format-scoped re-application) is in `docs/dev/findings.md` D219.
|
||
|
||
- **The tile's declared format is not the source data's format when GE's
|
||
custom ucode transforms TMEM at load time (D229).** `texSelect`
|
||
(`src/game/othermodemicrocode.c`) loads CI8 water images with
|
||
`gDPSetTextureImage(CI,16b)` + `gDPLoadBlock` (the 16b is only the
|
||
4KB-per-block load convention: lrs counts 16-bit units, so 700 × 2 = 1400
|
||
bytes of raw 8-bit indices), and GE's RSP ucode expands indices→RGBA16 in
|
||
TMEM at load; after which `sub_GAME_7F09343C` (`src/game/unk_092E50.c`)
|
||
re-declares the slot as RGBA/16b and samples blue. fast3d has no such
|
||
transform: it trusted the tile declaration, read index byte-pairs as
|
||
16-bit texels (g = 2·idx mod 32 dominates → green mottle), and the water's
|
||
pre-existing `sinf()` PRIM_LOD_FRAC cross-fade read as "pulsating". Fix
|
||
(`port/fast3d/gfx_pc.cpp`, M-116): record the `G_IM_FMT` of each
|
||
SetTexImage into `LoadedTexture.src_fmt` at load time; in `import_texture`,
|
||
an RGBA/16b tile whose slot's last load came from a CI source routes
|
||
through the CI8 palette import; the port equivalent of expand-at-load.
|
||
Fires only on a genuine format disagreement, so real RGBA16 textures are
|
||
untouched. **General rule:** when a GE draw declares a tile format that
|
||
looks wrong for the data it loaded, suspect the ucode's load-time transform
|
||
(ginit.s is missing from this repo; the expansion lives there), not UV,
|
||
shade, or the combiner.
|
||
|
||
## D14. LOD tile selection is a 2-cycle-only RDP feature; stale `G_TL_LOD` must not affect 1-cycle texture rectangles (D341)
|
||
|
||
`G_TL_LOD` is sticky other-mode state. Game code that draws a 1-cycle texture rectangle after an LOD-textured draw never clears it, because on the RDP it is irrelevant outside 2-cycle mode. fast3d's LOD/detail tile folding (D107/D236) must therefore not run for 1-cycle texrects, or they sample a neighbouring tile's stale declaration. Related: textures the CPU rewrites in place at recurring addresses (dyn pool) need a content-hashed cache key, and remember game code reaches them through the 0x80000000 KSEG0 mirror.
|
||
|
||
## D12. "Collapse an LOD binding to tile 0" is wrong for a two-texture DETAIL binding (D236)
|
||
|
||
**M-198 (finding D236 pass 26).** GE's `texLoadFromGdl` resolves a room DL's
|
||
`G_NOOP` texture reference through one of three handlers, chosen by
|
||
`in->words.w0 & 7` (`src/game/tex.c`):
|
||
|
||
| type | handler | TMEM layout |
|
||
|---|---|---|
|
||
| 0 `TEXTURETYPE_LOD` | `texHandleType0` | ONE load block at TMEM 0; tiles 0..n are LOD levels **of that one image** |
|
||
| 1 `TEXTURETYPE_DETAIL` | `texHandleType1` | TWO load blocks: the **detail** texture at TMEM 0, the **base** image at TMEM `texGetSizeInBytes(detail,0)` on tiles 1.. |
|
||
| 2 `TEXTURETYPE_MIPMAP` | `texHandleType2` | as type 0 |
|
||
|
||
fast3d's `gfx_lod_tile_offset` (`port/fast3d/gfx_pc.cpp`) folded **every**
|
||
`G_TL_LOD` draw to tile 0, on the reasoning (D107) that "GE loads the whole mip
|
||
chain at TMEM 0". That is true for types 0 and 2 and **false for type 1**,
|
||
where TMEM 0 holds the detail texture and the image you actually want is on
|
||
tile 1. The port therefore drew the detail texture in place of the base image —
|
||
and never even *imported* the base image, so every probe looking for it came
|
||
back empty.
|
||
|
||
On Surface 1 this was the whole of D236: all four treeline cards
|
||
(`tex1198`–`tex1201`, RGBA5551 64×17 cut-outs) are type-1 bindings sharing one
|
||
detail texture, `tex2465` — an opaque 32×32 IA8 noise tile. Result: an opaque
|
||
tiled noise wall where the treeline belongs, for 20+ investigation passes.
|
||
|
||
**Tells.** (a) A texture the game demonstrably decodes into the pool is never
|
||
imported by the renderer — that is a *binding* bug, not a decode, alpha, mip or
|
||
geometry bug, and no amount of work on the texture's contents will move it.
|
||
(b) The wrong thing drawn is itself a real, correctly-decoded texture from the
|
||
same draw — look for a second texture bound by the same command.
|
||
|
||
**Discriminating the two layouts at draw time.** Prefer an invariant that a mip
|
||
chain can never satisfy. Tiles 1.. of a type-0/2 binding are LOD levels of tile
|
||
0's image, so they necessarily carry the **same `fmt`/`siz`**; a type-1 pair is
|
||
two unrelated textures and generally does not. Deliberately choose the
|
||
asymmetric test — a mip chain can never trip it, and a detail pair that happens
|
||
to share a format merely keeps the old behaviour. Two weaker discriminators
|
||
were tried live and both misfire: "tile 1 sits at a different TMEM address"
|
||
matches ordinary mip chains (their levels are at different TMEM offsets too),
|
||
and "tile 1's TMEM slot has a registered load" matches them as well, because
|
||
`rdp.loaded_texture[]` is persistent state that is never cleared between binds.
|
||
|
||
## 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:
|
||
|
||
```c
|
||
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: **D417** (`enum PROP`: `getPropForHeldItem()` returns -1 for the
|
||
fist and callers test `prop >= 0` -> unsigned enum lets -1 through ->
|
||
`modelLoad(-1)` crash in any 2P+ stage; sentinel `PROP__PORT_SIGNED`);
|
||
**D142**; `LEVEL_SOLO_SEQUENCE` in
|
||
`fileGetHighestStageDifficultyCompletedForFolder` froze the SELECT FILE
|
||
screen. `DIFFICULTY` was 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, `sizeof` stays 4, no stored value changes. Audit any
|
||
`enum` used as a descending / `>= 0` loop 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, via `sndPlaySfx` on a door
|
||
close) vs `sndRemoveEvents` (`amMain`) on the same `ALEventQueue`.
|
||
- Fix: `osSetIntMask` → one process-wide **recursive** mutex.
|
||
`OS_IM_NONE` acquires; the `OS_IM_ALL` token returned from that call is
|
||
what every paired restore passes, so `OS_IM_ALL` releases; other specific
|
||
masks (`OS_IM_VI`) pass through. Safe because the decomp never blocks
|
||
while holding `OS_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
|
||
heavy `ALEventQueue` paths: libaudio has unbalanced / early-`return`
|
||
`osSetIntMask` calls, and a transient thread can acquire `OS_IM_NONE`
|
||
and exit without the paired `OS_IM_ALL`, leaving `s_imLock` owned forever
|
||
→ every later `alEvtq*` on `mainThread` + `amMain` blocks in
|
||
`pthread_mutex_lock` → hang. Seen on the **mission-failed audio
|
||
fade-out** (`sndSetScalerApplyVolumeAllSfxSlot` → per-frame
|
||
`alEvtqPostEvent` storm) = permanent black screen. **Mitigation shipped
|
||
(M-28):** `osSetIntMask` now 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 (dedicated
|
||
`ALEventQueue` lock) 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 early `return` at `event.c:86` **does** restore
|
||
first; `alEvtqFlush`, `alEvtqFlushType` likewise), `csplayer.c` `__CSPRepostEvent`,
|
||
`synaddplayer.c` `alSynAddPlayer`, `snd.c` `sndRemoveEvents` / `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 live `ALSoundState` list and
|
||
posts to the shared `ALEventQueue` **without** holding `OS_IM_NONE`; unlike
|
||
its structural twin `sndDeactivateAllSfxByFlag`, which does. On PC that is an
|
||
unguarded walk racing `amMain` *and* 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 (nested `alEvtqPostEvent` then hits the recursive fast path);
|
||
also wrap `sndApplyVolumeAllSfxSlot`'s slot loop so the whole update is one
|
||
recursive hold.
|
||
(2) The actual leak is the "transient thread acquired `OS_IM_NONE` and
|
||
exited" case (the dump shows `New Thread`/`exited` churn). `portThreadWrapper`
|
||
now calls `imThreadExitRelease()` after the thread's entry returns: if that
|
||
thread still owns `s_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 and `imAcquire` mis-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 holding `OS_IM_NONE` and
|
||
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 *before* `legalpage_text_array` → only line 1 renders
|
||
(== the D76 "disclaimer half-drawn" bug; it was never an image-table
|
||
issue). Fix: `#ifdef PORT` uses `array + ARRAY_COUNT(array)`.
|
||
- Instance: **D491**; the same assumption in struct reads, not loop bounds:
|
||
`*(coord3d *)&D_80044904` (bg.c portal bbox seeds) and
|
||
`*(coord3d *)&D_80035EA4` (gunfire.c casing rot) read three separately
|
||
declared scalars as one struct. mingw emits such runs in **reverse** order,
|
||
so the read picks up a different neighbour. `-Warray-bounds` flags these
|
||
("partly outside array bounds of 's32[1]'"); confirm with `nm -n` on the
|
||
exe. Fix: `#ifdef PORT` reads each named global.
|
||
- Grep for `= &` / `(TYPE *)&` on the RHS of a loop-terminator compare, and
|
||
any `for`/`while`/`do` whose 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: `return` the tail call under `#ifdef AVOID_UB`
|
||
(`#else` keeps the N64 body verbatim).
|
||
- Instance: **D77**; `sub_GAME_7F0C0BF0()` (`src/game/mp_music.c`), a
|
||
one-line wrapper around `get_mTrack2Vol()` with no `return`. Every
|
||
in-level music trigger (`set_missionstate()`'s MISSION_STATE_1/4 cases;
|
||
i.e. the only path that starts a level's background track; the front-end
|
||
menu/intro music bypasses this and calls `musicTrack1Play()` directly,
|
||
which is why menu music worked while level music didn't) feeds this
|
||
garbage/zero return straight into `musicTrack1ApplySeqpVol()` /
|
||
`musicTrack3ApplySeqpVol()`. The result isn't a missing on-screen
|
||
primitive but a **silent audio channel that still runs**: the compact-seq
|
||
player keeps processing note-on events (visible in a `GE_AUDIOTRACE`
|
||
`[MUSICNOTE]` capture with normal-looking key/velocity data) but every
|
||
note's synthesized volume is scaled by the zeroed track volume, so nothing
|
||
reaches the mixed output. A per-note trace alone looks completely healthy;
|
||
the tell is a *track/channel-level* gain feed with a missing `return`
|
||
upstream. This class isn't limited to display-list cursors; audit any
|
||
`-O2`-only "quietly broken but not crashing" symptom for the same shape.
|
||
Fix: `return` under `#ifdef AVOID_UB` (`#else` keeps the N64 body).
|
||
- Instances (latent, 2026-09-25 Q7 warning audit): `constructor_menu07_missionsel()` and
|
||
`constructor_menu0D_missioncomplete()` (`src/game/front.c`), both `DL = f(DL)` at the
|
||
caller. Currently harmless: old/new objects disassemble identically at `-O2` (GCC
|
||
keeps `frontDrawCursor()`'s result in `rax`), but a clang/arm64 build is not bound
|
||
by that. `return DL;` under `#ifdef AVOID_UB`. A `-Wreturn-type` sweep lists the
|
||
remaining candidates; the other 7 current hits have callers that ignore the value.
|
||
- The one flagged in-source with an explicit comment is
|
||
`grep -rn "missing a \"return\"" src/` (`gunfire.c`). Others (like D77's)
|
||
exist without the banner; suspect this class whenever an `-O2` build
|
||
silently drops or mutes a small on-screen/audio element that an `-Og`
|
||
build shows/plays correctly. One-line `#ifdef AVOID_UB` fix each.
|
||
- Not caught headless by a symbol/link check or a default-config framediff
|
||
(the missing primitive is often small). Needs an `-O2` build + 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 `&args` to `_Putfld`; crashed at
|
||
boot on Linux `-O2` via `bossInitMainthreadData` → `sprintf`. Fix: under
|
||
`#ifdef PORT`, thread a real `va_list *` from the top (`sprintf.c` passes
|
||
`&args` of an actual `va_list` object; `_Printf` takes `va_list *` and
|
||
passes it straight through). `#else` keeps the ROM-matching body.
|
||
- Rule: to share one arg-walk between functions, pass `va_list *` explicitly
|
||
from the outermost `va_start` scope. Never `&` a `va_list` that arrived as
|
||
a parameter. `va_copy` into 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 through
|
||
`xprintf`/`_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.c` `sub_GAME_7F0B1DDC` `StandTile *tileStack[39]`
|
||
(bail is `if (cat >= 41)`, checked once per outer loop → writes `[40..]`);
|
||
`bondview2.c` `bondviewCalcIntroSwirlCamera` `f32 pointbuf[10]` written
|
||
`[-3 .. 11]`.
|
||
- Instance (D295/M-148): `front.c:2616` `char difficultytext[4]` receives
|
||
`strcpy("00 Agent")`/`strcat("\n")` — the NUL lands exactly on the adjacent
|
||
`textpos.p[0]` (y-coord), which is then re-stored; when y ≥ 0x80 the string
|
||
carries a high-bit byte into `textRender` → JPN-glyph path → NULL deref of
|
||
`g_JpnCacheCacheItems` (`language.c:320`). Same class, different fatality:
|
||
no canary involved — the clobbered *neighbour* changes control flow. On N64
|
||
the identical overflow is cosmetic (the JPN-cache pointer is valid memory in
|
||
any language); the crash severity is a PC artifact of that global being NULL
|
||
outside Japanese mode.
|
||
- Instance (D420): `mpmenu.c` `mp_watch_menu_display` `char rankbuffer[4]`
|
||
receives "Rank: 1st" / "P<n> KILLS"; GCC puts `scores[4]` next to it, so
|
||
the MP Scores page printed the rank text's bytes as numbers (1932599354 =
|
||
": 1s"). Tell: a displayed number whose hex is printable ASCII from a
|
||
neighbouring string. Widened to `[64]` under `#ifdef PORT`.
|
||
- Fix policy: **`-fno-stack-protector` globally** (`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 PORT` is 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_fail` in the backtrace, in a `sub_GAME_*` or
|
||
other byte-matched function, during stage load or the first few frames.
|
||
- Distinct from D7 (`va_list` UB) and D6 (missing `return`) but the same
|
||
"works on N64 + MinGW, fatal on hardened Linux" shape.
|
||
|
||
## D9. Changing a PRNG/CRC implementation invalidates persisted derived state — save checksums are de facto file format
|
||
|
||
D284 (M-139b) fixed `port/src/random.c`'s `dsll32`/`dsrl32` semantics and the
|
||
entry claimed "savegames unaffected (seed isn't save state)". It was wrong:
|
||
`fileGenerateCRC` (`src/game/crc.c`) — the per-slot validation checksum checked
|
||
by `fileValidateSaves` (`file2.c:544`) — runs `randomGetNextFrom` off its own
|
||
**local constant seed**, so every stored slot CRC is a fingerprint of the PRNG
|
||
implementation that wrote it. Verified against a real v0.2.x save (GitHub #87):
|
||
pre-fix PRNG reproduces all five stored CRCs bit-exact, post-fix matches zero →
|
||
first launch of any post-fix build **silently wipes every existing player's
|
||
entire campaign** (D295 M-148). Generalizable rule for this port: before
|
||
changing anything that feeds persisted derived state (PRNG, CRC/hash routines,
|
||
endian/byte-order helpers used by serializers), audit *all* consumers whose
|
||
output is stored on disk — not just the in-memory streams. A "port-only" fix to
|
||
a checksum primitive is a save-file-format break unless old and new outputs are
|
||
made mutually acceptable (dual validation / one-time migration).
|
||
|
||
## D10. A "skip to state X" port shortcut must replicate every side effect of the screens it skips, not just the terminal flags
|
||
|
||
D216's `Game.SkipIntro` jumps straight from boot to `MENU_FILE_SELECT`,
|
||
skipping the legal screen + Nintendo/Rare/GoldenEye logo attract loop, by
|
||
directly setting the handful of state-machine flags (`is_first_time_on_main_menu`,
|
||
`prev_keypresses`, `maybe_is_in_menu`, `menu_update`) that the normal intro
|
||
path would have arrived at. Its original comment claimed this "reuses the
|
||
game's own post-intro route" — true for the menu *flags*, but the skipped
|
||
`init_menu00_legalscreen()` also has a one-time side effect with no flag of
|
||
its own: it calls `fileValidateSaves()`, the only place `saves[]` is ever
|
||
populated from EEPROM. Skipping it left `saves[]` all-zero, and file-select's
|
||
unguarded `fileGetIsCheatUnlocked()` crashed selecting any folder except the
|
||
one a zeroed `save_data` happened to decode to (D299). Generalizable rule:
|
||
when adding a port shortcut that jumps over N64 screens/states, enumerate
|
||
every side effect those screens' `init_menuXX_*()`/`update_menuXX_*()`
|
||
functions perform — not just the menu-transition flags — and replicate the
|
||
non-flag ones (data loads, one-time validation, subsystem init) explicitly.
|
||
Grepping the skipped `init_*`/`update_*` functions for calls with no
|
||
matching state flag is the concrete check.
|
||
|
||
Second instance (D479): the `GE_STARTMP` split-screen harness skipped the
|
||
file-select screen, whose init sets `selected_folder_num = -1` for an MP
|
||
launch. The harness left the boot default `FOLDER1`, so every stage load ran
|
||
`fileLoadSettingsForFolder(0)` against an unvalidated (all-zero) `saves[]`
|
||
record: options 0, i.e. ammo counter, sight and auto-aim off in harness
|
||
matches only. A real menu launch never applies a Bond file in MP. Symptom
|
||
looked like a HUD bug; the tell was that it never reproduced from the menu.
|
||
|
||
## D11. A chr's own per-tick animation-position logic can silently overwrite an AI script's scripted position in the same frame (D243)
|
||
|
||
`chrTick` (`src/game/chr.c:2442`) always runs one of three `chr->actiontype`-
|
||
gated branches (`ACT_ANIM`/`ACT_STAND`/else, `chr.c:2578-2616`) that call
|
||
`chrUpdateAnim`/`modelTickAnim` — ordinary per-chr animation/locomotion
|
||
logic with no check for whether the chr is currently being driven by a
|
||
scripted cutscene camera/AI-script position write instead. If something
|
||
else (an AI script's `GOTO`/teleport, a cinema-pad move, D243's
|
||
`CHR_BOND_CINEMA` teleport) writes `prop->pos` earlier in the same frame,
|
||
`chrTick`'s own animation-position write can silently overwrite it before
|
||
the model transform is computed — two writers racing for the same field
|
||
within one tick, no error, no crash, just whichever runs last wins. This
|
||
produced D243's "frozen render position that doesn't track the smooth
|
||
scripted sweep" symptom: a first pass (M-159) wrongly attributed it to
|
||
the on-screen/frustum-visibility gate (`posIsOnScreen`) skipping the
|
||
render-transform refresh on off-camera ticks — REFUTED by a live capture
|
||
that logged BOTH the pre-`chrTick` position (from the AI script, smoothly
|
||
sweeping) and the post-`chrTick` position (frozen) for the same frame
|
||
numbers, with the visibility gate returning true throughout. **The
|
||
generalizable check:** when a rendered position looks "frozen" or
|
||
lagging behind a scripted/AI-driven `prop->pos`, don't assume a
|
||
visibility/culling gate is skipping an update — log the same field both
|
||
immediately before and immediately after the suspect tick function to
|
||
see whether something *inside* that function is overwriting it. A
|
||
visibility gate stopping updates and a competing writer stomping them
|
||
produce the same visual symptom but need opposite fixes.
|
||
|
||
**Update (same D243 investigation, later in the same project's life): fixing the field you found the bug in doesn't mean you've fixed what's rendered — check which field the CONSUMER actually reads.** Having found `chrTick` overwrites `prop->pos`, two live-playtest cycles were spent neutralizing exactly that overwrite (save/restore `prop->pos` around `chrTick`) — it worked completely (confirmed in logs: the field went smooth) and yet the visible symptom (camera shake) was completely unchanged. The reason: the camera's look-at filter doesn't read `prop->pos` at all — it reads `field_488.pos`, a *different* field written from a *different* data path (`render_pos`, the model's animated-skeleton transform, computed independently of `prop->pos` inside the same function). Both fields happened to show the same "period-4 cycling" symptom, which made them look like one bug through a probe that only logged `prop->pos` — they were two independent outputs of the same underlying animation-tick call, and only one of them was what actually got rendered. **The generalizable check, sharpened:** once you've confirmed a field is being overwritten, don't assume that field is what the visible symptom's consumer (a camera filter, a draw call, a collision check) actually reads — trace the consumer's own read site explicitly and confirm it's the same field before spending a live-test cycle suppressing the wrong one. A quick static check (grep the consumer's exact read expression) is far cheaper than a live playtest.
|
||
|
||
**Update 2 (same investigation, immediately after): freezing a *value* and freezing a *pointer target* are different operations — treating them the same crashed twice.** Extending the same freeze technique to `render_pos` (a `Model*` field, unlike `field_488.pos` a plain value) by skipping the call that reassigns it (`subcalcmatrices`) seemed equivalent to M-167's "skip the write" but wasn't: `render_pos` is a pointer into a per-tick dynamically-reallocated arena, and skipping its reassignment only works if the pointer it's left holding is still valid — which depends on model lifecycle, not just on this tick's logic. Two fixes in a row tried to detect "is it safe to skip" by inspecting either the pointer's *value* (a sentinel check, `!= NULL && != -1`) or a *cached identity* (comparing the pointer against a remembered "already validated" pointer) — both crashed, because **the allocator reuses model-pointer addresses across genuinely different logical model instances**: a freshly-`CREATE`d model can land at the exact address a previously-removed model occupied, so both the sentinel check and the identity check wrongly conclude "this is already valid" for an instance whose `render_pos` is still an uninitialized tombstone. This is the same "don't guess validity from a value that can coincidentally look right" trap as the paragraph above, encountered a second time from a different angle (a *pointer's* target identity, not a *field's* content). **The robust fix:** never skip the call that establishes the pointer's validity — let it always run — and apply the freeze as a *contents* copy afterward, on top of a buffer that's now unconditionally guaranteed valid. If you must "hold something steady" and that something is reached through a pointer that some other code reassigns, freeze the *pointee's bytes*, not the *act of assignment* — the assignment is usually also the thing keeping the pointer sane.
|
||
|
||
## D12. Depth precision: the port matches N64 exactly — z-fighting seen on PC is probably faithful (D306/D308)
|
||
|
||
When a user reports z-fighting, do NOT assume the software renderer has less depth precision than the RDP. Verified for GE: the RSP's vertex transform (`rsp/graphics/gmain.s`) writes post-transform x/y/z/w back to the vertex buffer as **full 32-bit 16.16 fixed-point** (`sdv` stores), not s16 8.8 — the RDP interpolates from full-precision NDC and quantizes once, into its 24-bit z-image. The port keeps floats through `gfx_sp_vertex` and writes a `GL_DEPTH24_STENCIL8` buffer: same or better precision at every stage, same near/far. So coincident geometry authored into the level fights on both platforms; a PC-only explanation needs positive evidence (an N64 reference of the same spot), not just the existence of the symptom. A mitigation like switching to 32-bit float depth would *reduce* fighting but diverge from N64 behavior — that's a rule-2-style decision, not a silent fix.
|
||
|
||
**Probe technique that worked (vs. three variants that didn't):** detecting z-fighting from emitted triangles requires filtering out (a) tessellation gradients — adjacent tris of one surface have near-equal depths by construction — and (b) camera-motion coincidences — as the camera moves, unrelated world points pass through the same screen cell and their depths happen to sit close. What survives both: a coarse screen-cell grid keeping a short per-cell depth history, flagging only **non-monotonic** (a-b-a) oscillation within a small tolerance that **recurs many times at the same cell**. Monotonic drift = gradient; one-off close pairs = motion artifact; sustained recurrence = a real fight. `GE_ZF` in `port/fast3d/gfx_pc.cpp` implements this (`GE_ZFTOL` tolerance, `GE_ZFBOX` region filter). **Caveat (M-201):** `GE_ZF` actually quantises NDC z to ±255, not 24 bits, so at its default tolerance it flags unrelated nearby surfaces; treat a hit as a lead, not proof. Before calling anything "z-fighting", check the separation against the near plane: at close range 24-bit depth resolves far finer than any non-coplanar model parts, so "whole surfaces in the wrong order" there is depth *state* or geometry, not precision. Use `GE_D306C` (per-triangle depth-state census) for that; D306 turned out to be authored geometry seen from an unusual camera.
|
||
|
||
## D13. Per-render accumulation is not associative: the port's 1 tick/frame can reach states the N64's 2–3 ticks/frame never did (D329)
|
||
|
||
GE on N64 renders gameplay at ~20–30 fps, so `g_ClockTimer` (= `speedgraphframes`, 60 Hz ticks per rendered frame) is normally 2–3. The port at 60 fps gives 1. Code that folds `numticks` ticks into **one** downstream call is not equivalent at different granularities. `modelTickAnim` sums `playspeed*speed` over the ticks, then makes one `modelSetAnimFrame2WithChrStuff` call, which only moves `framea`/`frameb` when `floor(frame)` crosses an integer. At speed < 1 and 1 tick/frame a clamped (`framea == frameb`) non-looping anim never crosses, so it stays pinned even after its endframe is raised. At 2+ ticks it crosses on the next frame. Result: permanent guard freezes on PC (D318/D320) that the console could not sustain.
|
||
|
||
- **Tell:** a state that holds forever at 60 fps, is frame-exact (integer frame, `fa == fb`), and has a per-tick step < 1.0 unit.
|
||
- **Repro correctly:** force the granularity with `GE_D318B_CLK=N` (TEMP probe, lockstep N ticks/frame) and A/B N=1 vs N=2. Wall-clock runs on a slow host (WSLg renders ~5 fps → mixed clk 1–6) and `GE_DETERM=1` (advances per VI-retrace request, also mixed clk) both give uncalibrated results.
|
||
- **Where else to look:** any `for/while (numticks)` accumulator followed by a single threshold/floor consumer; D193 (AI locomotion rate) and D243 (cutscene `numticks` bursts) touched the same axis from the other side.
|
||
- **Frame-counter gates (D427/D451):** a gate that reads the current `g_ClockTimer` to rescale a frame counter (`88C % (rate*(clk>=2?1:2))`) is only exact at steady pacing. Prefer the tick accumulator already kept beside the counter (`field_890`) and test for a threshold *crossing* (`t/P != (t-clk)/P`): stateless and exact on average at any pacing.
|
||
|
||
## D15. A texture-rectangle idiom that is sub-1-native-pixel tall relies on the RDP's floor-based fixed-point scanline stepping — now replicated in `gfx_draw_rectangle` (D397, RESOLVED 2026-09-28)
|
||
|
||
GE's front-end vertical-gradient trick (`titleRenderFolderMenuBackgroundLines`,
|
||
`src/game/title2.c`) draws a tall image as 299 separate 1-scanline-tall
|
||
`gSPTextureRectangle`s, one per source row, each tinted by a per-row
|
||
interpolated `gDPSetPrimColor`. The lower-right Y coordinate uses the
|
||
universal N64 idiom `((y+1)<<2)-1`, which in U10.2 units is **3, not 4** — a
|
||
0.75-native-pixel-tall rect, not 1.0. This is correct *on real hardware*
|
||
specifically because the RDP's scan converter steps by truncating
|
||
(flooring) the fixed-point Y to an integer scanline before testing coverage,
|
||
so `y*4+3` still falls in scanline `y` and nothing else — the "-1" is a
|
||
hardware-truncation idiom, not a literal float height.
|
||
|
||
`gfx_pc.cpp`'s `gfx_draw_rectangle` does not replicate that truncation: it
|
||
converts the U10.2 corners straight to continuous NDC floats
|
||
(`ulyf/lryf = ±(y / (4·HALF_SCREEN_HEIGHT)) + 1`) and hands them to the GPU's
|
||
own continuous rasterizer, which uses pixel-center/coverage rules, not
|
||
floor-stepped scanlines. A rect that is 0.75 (native) pixels tall, stacked
|
||
299 times with each row offset by exactly 1.0 native pixel from the last,
|
||
does not reliably rasterize as "exactly the one scanline the N64 idiom
|
||
intended" under a continuous rasterizer — whether a given row rounds to 0 or
|
||
1 device pixel depends on where its fractional edges land relative to the
|
||
device pixel grid, and that phase drifts row-to-row (worse under any
|
||
non-integer native→window viewport scale, `gfx_adjust_viewport_or_scissor`'s
|
||
`ratioX`/`ratioY`). Stacked over ~300 rows filling the screen, sporadic
|
||
per-row rounding reads as a periodic horizontal comb/interlace — this is the
|
||
leading mechanism for D397's "interlaced" file-select background and intro
|
||
gun-barrel, and is a **first-render, not re-entry-triggered** artifact
|
||
(unlike D182), because it is a rasterization property of the geometry itself,
|
||
not stale state.
|
||
|
||
**Tell:** any GE front-end/menu draw that fakes a large image or gradient by
|
||
stacking many independent 1-scanline (or otherwise sub-1-native-pixel-tall)
|
||
`gSPTextureRectangle`s rather than one multi-row texture — the `(<<2)-1`
|
||
lower-right idiom is the signature to grep for. A single such rect is
|
||
invisible (sub-pixel rounding on one edge); many stacked ones make the
|
||
rounding pattern visible as banding.
|
||
|
||
**Fix (landed 2026-09-28, RESOLVED):** `gfx_draw_rectangle` (`gfx_pc.cpp`) now replicates the RDP truncation. After the sub-pixel offset is applied, it floors the U10.2 top edge to its scanline and the bottom edge to the *last covered* scanline — the RDP scanline range `[top_scan, bot_scan+1]` — before converting to NDC: `top_scan = uly>>2`, `bot_scan = (lry-1)>>2`, then `uly = top_scan<<2`, `lry = (bot_scan+1)<<2`. This is a **no-op for whole-pixel rects** (their edges are already `4k`/`4k−1`, and the `(<<2)−1` bottom idiom already yields `bot_scan` = the intended last row), so only sub-pixel-tall strips change. **Confirmed by a falsifiable 3-scale PCDUMP test:** the comb period is fixed at ≈3 *native* rows across 640/1024/1280 (window-period = 3×scale) — the beat of 0.75-px strips at a 1.0-px pitch — exactly the fixed-point-flooring signature, ruling out the scale-invariant LERP/CC factor. Verified by playtest: gun-barrel + file-select gradient render clean, comb gone.
|
||
|
||
**Not the cause (checked and ruled out for D397):** the CC/LERP scaling of
|
||
`ENV_ALPHA` as `value/255.0f` (`gfx_pc.cpp` around the `G_CCMUX_ENV_ALPHA`
|
||
case) is byte-identical to the Perfect Dark PC port's (`gfx_pc.cpp` in the
|
||
local `pd_port` checkout) — same formula, no special-cased "5-bit RDP
|
||
coefficient" scaling in either port. The real RDP color combiner's A/B/C/D
|
||
inputs are 8-bit values (PRIM/ENV alpha come from an 8-bit `gDPSetEnvColor`
|
||
field); there is no coarser-precision quirk to correct here. A uniform
|
||
darkening from a nonzero `ENV_ALPHA` LERP factor is very likely faithful
|
||
(possibly authored/original-dev-intentional tinting), not a bug — do not
|
||
"fix" it without an N64/1964-GEPD reference screenshot proving otherwise.
|
||
|
||
## D16. Split-screen: port-side "one full-screen viewport" assumptions break 2P+ (D418)
|
||
|
||
Two traps from the #99 split-screen bring-up:
|
||
|
||
- **Safe-area crop (`gfx_adjust_viewport_or_scissor`) caches the LAST SP
|
||
viewport's Y range and stretches it over the whole window.** Right for the
|
||
solo 220-line viewport, wrong for 2P+ (each player's viewport is a sub-rect):
|
||
every player's view filled the window and the last-drawn (order is shuffled
|
||
per frame, `get_nth_player_from_shuffled`) covered the rest -> alternating
|
||
full-screen flicker. Fix = `gfx_set_split_screen()`. Any new port feature that
|
||
assumes "the viewport is the screen" needs the same 2P+ gate.
|
||
- **Per-frame port hooks must sit on the path frames really take.** The
|
||
scheduler calls `gfx_run` directly (`port/src/libultra.c`), NOT through
|
||
`videoSubmitCommands`; a hook placed only in the latter silently never ran.
|
||
Log once on the first state change when adding such a hook.
|
||
|
||
Debug recipe that found it: capture consecutive frames after the stage starts
|
||
(GE_PCDUMP range, or the harness `GE_STARTMP=<players>[,<stageIdx>]` +
|
||
`GE_MPVIRT=<players-1>` to start a match with no one at the menu) and look at
|
||
them — the symptom was obvious in one montage.
|
||
|
||
## D17. Un-stubbing a `-1` sentinel re-activates every dormant consumer of the value (D431/D434)
|
||
|
||
A port stub that forces a value to `-1` (D154 `texnum`, D135) can hide latent layout bugs in its other readers. When restoring a real value, grep ALL readers of the field first and check each for raw-layout reads and table-bounds assumptions. Instance: D431 made light-fixture hits return real image ids, which reached `chrprop.c`/`propobj.c` reading `((u8*)&g_Textures[n])[0] & 0xf` as a hit type -- on PC byte 0 is the low byte of `dataoffset` (LE bitfield layout, D67), so it indexed `g_HitTypeSounds[13]` with 0..15. Use the `.hitTexture`/`.hitSound` bitfields (as `chr.c` does).
|
||
|
||
## D18. A port feature that widens a shared range (draw distance, FOV) leaks into gameplay through every reader of the state it widened (D466)
|
||
|
||
`Video.DrawDistance` scaled the one far-clip value that both the renderer and gameplay read, so `room_rendered`, `PROPFLAG_ONSCREEN`, `CHRFLAG_HAS_BEEN_ON_SCREEN` and the fog intensities all grew with it, and AI scripts ("on screen", "seen", target-in-sight, spawn out of view) fired earlier than on N64. Rule: when adding a "see farther / wider" option, grep every reader of the state it feeds and split render from gameplay; the render consumer keeps the extended value, the gameplay readers get a stored copy computed at the authored value.
|
||
|
||
- **Tell:** `PROPFLAG_ONSCREEN`-style flags are dual-use (render gate AND AI input), so never gate the flag itself; store a separate verdict bit.
|
||
- **Techniques that worked:** run the original traversal twice with a port flag that pins the single far read (a static read, never `viSetZRange`) and snapshot the result; keep the "gameplay verdict" in an unused bit of a u8 flag byte (`prop->flags & 0x80`) written by every setter of the dual-use flag; store the authored fog values at load time instead of recomputing from the live multiplier.
|
||
- **Triage trap:** check whether a `PROPFLAG_*` hit is on `prop->flags` (runtime u8) or on `obj->flags` / pdef flags (u32 definition bits) before treating it as a reader (`propobj.c:8417`, `prop.c` 233-467 are the latter).
|
||
- **Remaining class member:** FOV/widescreen widening of `camIsPosInScreen` and `c_lodscalez` (D222).
|
||
- **Fixed-size per-frame budgets are readers too (D500):** the dyn.c vtx pool (`-mvtx`, as low as 40 KB) is bump-allocated with no bounds check and sized for what the N64 could see; widened views overran it (Aztec triangles). Look for unchecked per-frame pools sized by per-level tokens when a widening feature shows corruption only in busy scenes.
|
||
- **A pixel ratio is not a fog factor (D503):** "~97% fogged" was a pixel-colour ratio; derive any fog snap cutoff from measured vertex fog, not from it. And before blaming fog for a far object, do the cheap 4:3 + N64-FOV test: if it vanishes, the cause is the widened frustum, not fog.
|
||
- **Reveal, not regression (D503):** the N64's 16-bit framebuffer hid 2-5 level differences (near-full fog against haze); 32-bit output shows them, plus the room-scissor edges of nearly fogged objects. Measure pixel values against the fog colour before blaming draw distance.
|
||
|
||
## D19. A "click after the shot" report needs the trigger-hold duration, not just the state machine (D433/D467)
|
||
State-machine audits of a gun's fire/dry-fire path (GE: `gunTickHandState`, CLICKY weapons) are tick-driven and match the console at any tick/frame ratio; an extra click/sound that appears "with the shot" is usually the trigger still being down when the recoil state ends (about 14 ticks for the Golden Gun). Measure with `GE_STARTWEAPON` + `GE_INPUTSCRIPT` holds of 4/9/16 frames and a `state/mag/88C/890/clk` log at the sound-play sites before suspecting the audio layer; and remember `GE_QUITFRAME` counts frames from boot, the level's `g_GlobalTimer` starts ~270 later.
|
||
|
||
## D20. Running an RCP task inline on the scheduler thread delays retrace delivery (D481)
|
||
The N64 scheduler (`src/sched.c` `__scMain`) both starts RSP/RDP tasks and forwards each VI retrace to the game. On hardware the task runs on separate processors, so the scheduler is never busy. If the port executes the task synchronously inside `osSpTaskStartGo`, any retrace that arrives during it reaches the game late, and `boss.c`'s half-frame tick gate then skips the following retrace (33 ms frames). Tell: frame rate stuck below 60 at every graphics setting, moderate render and GPU times, and the lost frames failing the tick check (`skip_tick`), not the pending-gfx check. It only shows when the task takes a large fraction of a frame (slow hardware), so a fast dev box never sees it. Run hardware-side work off the scheduler thread and signal completion with the same events the hardware would.
|
||
|
||
## D21. `gDPLoadBlock` with dxt = 0 depends on the RDP's odd-row TMEM read swap; static assets need it emulated, the tex.c pipeline must not get it (D75)
|
||
|
||
The RDP swaps the two 32-bit halves of every 8-byte group on odd TMEM rows when *reading*, and compensates on *load* only when LoadBlock's dxt counter advances. A `gsDPLoadBlock(..., dxt = 0)` of a multi-row image therefore samples as "memory with odd rows pair-swapped". Rare's compiled-in assets that load this way (the Rareware logo's RAREWARE text, `DL_RAREWARETEXT`) are pre-swapped in the data and look scrambled ("interleaved/combed", identical at every texture-filter setting) if fast3d uploads them linearly. `tex.c` uses dxt = 0 for the whole runtime texture pipeline too, but there `texSwapAltRowBytes` was the matching pre-swap and D159 no-ops it, so those images are already linear. fast3d (`import_texture`) therefore swaps only dxt = 0 loads whose source is a compiled-in C array (`gfx_tex_source_is_c_array`). Generalisable tells: a texture that looks identically garbled at filter 0/1/2, interlaced along ROWS with a 2-texel horizontal shift, drawn from `assets/*.c`; decode the C array offline both ways before suspecting filtering/clamp. Counting how often a new texture-path branch fires on a level boot (a temporary env-gated log) caught a ~300-per-level over-reach before it shipped.
|
||
|
||
## D22. Per-frame counters: gate them with `portN64FrameStep()` (D486)
|
||
|
||
Decomp code that advances a counter once per *call* (per rendered frame) instead of by `g_ClockTimer` runs 2-3x faster in real time at the port's 1 tick/frame (and every frame when uncapped, `clk == 0`). D486 found three on the explosion/turret path: autogun shot counter (`unkAC++`), explosion part spawning, explosion/smoke shake decay. `port/include/porttick.h` `portN64FrameStep()` is the shared Rule-2 gate: true on every call at >= 2 ticks/frame (N64-identical), every second tick below, so the counter keeps the N64's 2-ticks-per-frame pace (the D427/D451 reference). If the gated counter also scales a per-event quantity by `g_GlobalTimerDelta` (autogun damage per shot), charge the full 2-tick frame on gated calls or the rate halves. Verify by A/B at `Video.FpsCap` 60 vs 30: the two must match. Any such gate is game logic and needs Rule-2 sign-off.
|
||
|
||
## D23. Region bring-up: tooling and port code silently assume NTSC-U (D258)
|
||
|
||
PAL/JP had never been built. What broke was all port-side: (1) asset emitters hard-coded `scripts/filelist.u.csv` (so on a PAL/JP ROM they would use US offsets with no error); (2) `romdata.c` treated "not PAL" as NTSC-U (rejecting a JP ROM's country byte); (3) port code referenced globals that exist only under a region-specific define (`LEFTOVERDEBUG` is US/JP-only), which surfaces as a PAL-only link error; (4) the decomp's PAL/JP filelists were incomplete and mislabelled. With a real ROM, prove every filelist row by bytes (same-name US file comparison, constant-shift search, header-walk for variable files) instead of trusting labels. Whenever port code touches a symbol inside a `#if` region block, check all three `REGION_DEFS` sets.
|
||
|
||
## D24. Decomp out-of-bounds writes that the N64 stack layout absorbed (D490)
|
||
|
||
Some decomp functions write past a local array in edge cases. On the N64, IDO's stack layout put a padding local (often a `pad[]` the decomp keeps for matching) right after the array, so the overflow was harmless. GCC lays the stack out differently, so the same write corrupts a live neighbour. D490: `sub_GAME_7F03ECC0` writes up to 6 entries into `s32 rem[4]` when an object's bbox is flat, overwriting `pts[0]` and giving every glass pane a phantom collision vertex (guards circled pads they could not reach). Tells: a bug that only appears for degenerate inputs (flat or zero-size boxes, coincident points); a value that is exactly 0 or a tiny denormal where a real number belongs; a `pad[]` local next to a fixed-size array in the decomp. Fix pattern: enlarge the array under `#ifdef AVOID_UB`, keep the original under `#else`, change no reads, so the result matches the N64's uncorrupted path.
|
||
|
||
Also the read side: D496 (`generate_language_specific_text_for_weapon` strcat'd onto an uninitialised caller stack buffer for 3+ players; init under `AVOID_UB`).
|
||
|
||
## E. Process / method notes
|
||
|
||
- **Never `exit()` while another thread may be inside the GL driver (D344).**
|
||
The GL context lives on the render (scheduler) thread; quit events arrive on
|
||
other threads. Request the quit (`videoRequestQuit`), let the render thread
|
||
`glFinish` + unbind at a frame boundary and park, then exit from the host.
|
||
An unclean GL teardown bugchecked the maintainer's PC (0x119, HAGS on).
|
||
Harness runs should end with `GE_QUITFRAME=<n>`, not a `timeout` kill.
|
||
- **Headless front-end scripts: turn the mouse off (D343).** In menus the
|
||
pointer follows the real OS mouse (WI-2 absolute tracking), so the desktop
|
||
mouse moves the in-game cursor mid-`GE_INPUTSCRIPT`. Put
|
||
`[Input] MouseEnabled = 0` in the pinned test ini. Script "frames" were
|
||
about one per rendered frame on the file select screen; calibrate with a
|
||
log, not by assumption.
|
||
- **Per-slot headless scripts: `GE_INPUTSCRIPT_P1..P3` drive seats 2-4.**
|
||
Same entry syntax as `GE_INPUTSCRIPT` (slot 0); "frame" counts that
|
||
controller's reads. Mouse tokens and CHOLD/CREL are ignored on slots 1-3
|
||
(one warning per script). Recipe for a scripted 4P split-screen run:
|
||
`GE_STARTMP=4,<stage> GE_MPVIRT=3 GE_INPUTSCRIPT_P1=... GE_INPUTSCRIPT_P2=... GE_INPUTSCRIPT_P3=... GE_QUITFRAME=...`.
|
||
- **`textMeasure` height is 0 without a trailing newline (D343).** It only
|
||
counts completed lines. Measure `"text\n"` when you need a height (front.c's
|
||
folder text does this); the width is unaffected.
|
||
- **Periodic audio glitches: measure the host audio device before touching
|
||
the pipeline (D322).** A clock-regular overflow/garble pattern (~5 min period,
|
||
~45 s bursts, independent of level and load) looked like a port timing beat
|
||
and cost several game runs to chase. `tools_pc/sdl_drain_monitor.c` opens SDL
|
||
exactly like `port/src/audio.c` with no game and no window; on the
|
||
maintainer's Focusrite USB interface it showed the same ~312 s drain dips
|
||
(to 88 %) outright, and running it on several SDL backends at once showed the
|
||
culprit was SDL2's WASAPI path, not the device (DirectSound was clean; the
|
||
port now prefers it on Windows). Run it first on any "audio gets garbled"
|
||
report, and compare backends with `SDL_AUDIODRIVER` before blaming the hardware.
|
||
- 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 the [Perfect Dark port](https://github.com/fgsfdsfgs/perfect_dark) 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.
|
||
- **When re-implementing a ROM bitstream/codec offline, PORT the derivation
|
||
from the decomp; never reconstruct it from the comment.** A half-right
|
||
bitstream parser in this codebase does not fail loudly — it returns
|
||
confident, plausible, wrong output. Three hits in four D236 passes:
|
||
(1) a texture-header parse that omitted the leading control byte
|
||
(`image.c:2568`) still produced sensible-looking formats and dimensions,
|
||
and was only caught by counting implausible entries (1020/2699 vs 1/2699
|
||
when correct) and noticing the format nibble only ever took values
|
||
0/4/8/12 — the tell that a field was straddling a boundary;
|
||
(2) omitting the per-image byte-align at the end of each LOD
|
||
(`image.c:1096`: discard the partial byte, and skip a whole byte when
|
||
already aligned) silently desynchronised every LOD after the first;
|
||
(3) `texInflateRle`'s run-length `fudge` was *guessed* from the doc comment
|
||
instead of ported from the loop at `image.c:1605`, and still decoded
|
||
images — just with the wrong alpha coverage (66.7% vs the correct 43.1%).
|
||
Practical rules: copy the arithmetic statement-for-statement even when it
|
||
looks like it could be closed-formed; build a cheap global plausibility
|
||
counter into the tool and print it every run; and cross-check one decoded
|
||
artifact against an independent expectation (here, that a LOD chain's
|
||
dimensions halve exactly and its alpha coverage stays roughly constant)
|
||
before trusting any number the tool emits.
|
||
|
||
- **A negative result from a diagnostic is worthless until the diagnostic has
|
||
been shown capable of producing the positive.** Two independent hits: D219's
|
||
"verify diagnostic caps exempt the real target signature before trusting
|
||
'clean'", and D236 pass 21, where a static setup scanner located GE's `PROP`
|
||
enum with `startswith("typedef enum PROP")` — which matches
|
||
`typedef enum PROPFLAG` hundreds of lines earlier, yielding an enum with **no
|
||
`PROP_*` members at all**. Every prop id then resolved to `?` and every
|
||
"foliage props placed" count came back 0, which is exactly what a clean,
|
||
meaningful negative looks like. Build the positive control **into** the probe:
|
||
have it print what it is capable of matching (`foliage props recognised (9):
|
||
…`) and fail loudly when that set is empty, so the failure mode cannot be
|
||
read as a finding. Applies equally to grep-based audits over generated C,
|
||
where near-miss identifier prefixes (`PROP` / `PROPFLAG` / `PROPDEF_TYPE`) are
|
||
the norm.
|
||
|
||
- **`docs/dev/findings-index.csv` is generator-derived** (`tools_pc/gen_findings_index.py`): the status cell is normalised from the `## Dxx` entry's §F summary-table row in `findings.md`, so hand-editing the CSV is silently reverted on the next regen (hit live in the D219/D252 M-158 session). Update the table row, then run the generator.
|
||
- **Every game launch rewrites `data/ge007.eep`** (the eeprom shim stores on
|
||
every write; validation resets, cheat patches and normal saves all write).
|
||
When restoring a specific save artifact for a controlled test (e.g. an issue
|
||
reproduction), restore it *after* any sweep/verify run — or re-hash after
|
||
each run — not before: `tools_pc/level_sweep.sh` alone boots the game 21
|
||
times against `data/` and will clobber a pre-run restore (caught live in the
|
||
D295 M-149 session; "verified" must mean checked at report time).
|
||
- **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-color +
|
||
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.
|
||
- **A "deterministic boot" flag (`-level_XX`) pins the level and starting
|
||
state, but NOT the camera's later path through it — don't assume two
|
||
captures from the same boot flag are comparing the same view.** D236
|
||
pass 10 (M-155) found a render-mode-class triangle count that looked
|
||
"run-to-run dependent" across separate captures using the same
|
||
`-level_36` boot; three independent re-launches (including one with a
|
||
full `GE_INPUTSCRIPT` sustained-rotation sweep) actually produced
|
||
byte-identical, fully deterministic per-frame counts — the real
|
||
variance was the boot's own scripted intro camera walking from an
|
||
outdoor room into an indoor one at a fixed frame, and the two captures
|
||
being compared had sampled before vs. after that walk (or against an
|
||
unrelated live/free-play session entirely). **Before concluding a
|
||
count/state is nondeterministic across runs of the same boot flag,
|
||
correlate against a room/position probe (`GE_D104`-style) in the SAME
|
||
run** to rule out "the camera simply isn't where you assumed it was
|
||
yet" before chasing an instantiation or streaming bug.
|
||
- **A level's opening cutscene is not necessarily input-inert, and
|
||
`GE_INPUTSCRIPT` stick holds during it are not necessarily useful.**
|
||
D236 pass 11 (M-155): holding `SUP`/`SDOWN` from frame 0 of the
|
||
`-level_36` deterministic boot changed which room the player occupied
|
||
at the very first probe sample (`curRoom` 12/7/13 depending on the held
|
||
direction) — the pre-transition window is NOT purely camera-scripted
|
||
with input discarded, as pass 10 might suggest. But none of forward,
|
||
backward, or a `START`-skip attempt could keep the camera in the
|
||
outdoor room past the scripted indoor transition, which fired at
|
||
roughly the same real time regardless of held direction and *earlier*
|
||
when `START` was pressed to skip faster — i.e. the transition looks
|
||
timer/event-driven, not distance-to-a-trigger-driven. **Don't assume a
|
||
scripted sequence's transition point is walkable-around just because
|
||
input has *some* visible effect during it** — test whether the specific
|
||
transition you care about is time-gated vs. position-gated before
|
||
investing in a nav script to avoid it.
|
||
- **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; any `sysSleep` /
|
||
busy-wait on that path (frame-rate cap, a lock, an asset load) stops
|
||
retrace delivery, so every game thread blocked on `osRecvMesg(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 < 30` is 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 deferred `GE_DETERM` mode (D117); not for kernel changes.
|
||
- **A verification tool that shares the code's indexing convention proves
|
||
self-consistency, not correctness** (D206; cost eight sessions). The port
|
||
played every SFX one bank slot too high. Real cause: `struct
|
||
ALInstrumentAlt_s` (`src/snd.h`) is a cast alias over the on-disk
|
||
`ALInstrument`, and its `soundArray` member sits at struct offset **12** on
|
||
N64 (three 4-byte words) but **16** on PC (8-byte pointer + 8-byte
|
||
alignment). On N64 that offset-12 array deliberately aliases the on-disk
|
||
`bendRange`/`soundCount` words so `soundArray[N]` == on-disk entry `[N-1]`
|
||
(GE's `SFX_ID`s are 1-based by design); on PC the alias is broken and every
|
||
ID resolved one slot high. It survived M-68/M-70's "exact match" of 200+
|
||
requests because `exact_match.py` compared each runtime voice against a ROM
|
||
decode *of the requested index* with a decoder that walked the bank
|
||
0-based; `diff_bank.py` likewise walked N64 and PC layouts with identical
|
||
indexing. Both faithfully reproduced the bug and reported a pass. Two rules:
|
||
(1) when validating a **mapping** (index → asset, id → record, offset →
|
||
field), the reference must be produced **independently of the code's own
|
||
accessor**, an external rip, a published table, a hand-decoded sample, or
|
||
the test only confirms the code agrees with itself; (2) when a count and an
|
||
enum disagree by one (261 bank entries vs 262 `SFX_ID` members), suspect a
|
||
**pointer-width struct-layout shift** in a ROM-serialized struct (the D3x
|
||
class) before anything subtler; check every cast-alias struct's member
|
||
offsets at 32- vs 64-bit.
|
||
- **`getenv()` is expensive on Windows (a locked, locale-aware linear scan
|
||
via msvcrt.dll); never call it uncached inside a per-frame, per-draw-call,
|
||
or per-item hot path** (D250, M-120). Dozens of `GE_*` debug-probe checks
|
||
across the codebase already follow the safe pattern
|
||
(`static int x = -1; if (x < 0) x = getenv("GE_X") != NULL;`), but a
|
||
handful in `port/fast3d/gfx_pc.cpp`'s `import_texture()` (runs on every
|
||
texture bind) and per-triangle/per-`G_SETCOMBINE` combine-mode checks
|
||
didn't. A live WPR/xperf CPU trace (Microsoft public symbols resolved via
|
||
`-symbols`) on a texture-heavy level found `msvcrt.dll!getenv` alone
|
||
eating **50% of the hot render thread's total CPU time**; the game's own
|
||
code was only 5%. Fixing the caching took the same scene from a hard
|
||
~45-50fps ceiling to a rock-solid 60fps, at every resolution tested, with
|
||
zero other changes. The lesson generalizes: **any new `GE_*` env-gated
|
||
probe added to a function that runs more than roughly once per frame must
|
||
cache its `getenv()` at that call site**, following the existing pattern;
|
||
don't assume "it's just a debug check" makes it free. `tools_pc/perf.ps1`
|
||
(sister script to `debug.ps1`) automates this exact trace-and-analyze
|
||
workflow for the next time a "runs slow but sim/GL-visible-cost checks out
|
||
fine" report shows up.
|
||
- **Count a probe's events by its canonical one-per-call line, never by a bare
|
||
field match** (D204, then D205/M-73 which re-made the identical mistake on the
|
||
identical sound index). `GE_AUDIOTRACE` emits *two* lines per `sndPlaySfx`
|
||
(a `dumppos=... bank=...` line and a `sndPlaySfx: t=... soundIndex=...` line),
|
||
so `grep -c soundIndex=109` reports exactly double the truth. D204 already
|
||
corrected M-63's "idx 109 fired 94 times in 60 s" on these grounds; M-73 then
|
||
built a whole root cause ("guards stuck re-triggering, 256x/29.7 s") on the
|
||
doubled count and pinned it on converted collision geometry, which M-74
|
||
withdrew. Two general rules: (1) grep the timestamped call line
|
||
(`sndPlaySfx: t=`), not the field; (2) **before calling a rate anomalous,
|
||
compare it to the rate the data says is legal**; the "barrage" was ~6x
|
||
*under* the AK47's own `SoundTriggerRate` ceiling, which one lookup in
|
||
`assets/obseg/gun/gunWeaponStats.inc.c` would have shown. Corollary: a
|
||
perfectly monotone index sweep in a trace looks like a broken `random() % n`
|
||
but is often a deliberate round-robin counter in ground-truth code (e.g.
|
||
`male_guard_yelp_counter`, `chraction.c:2454`); check the selector before
|
||
reporting it.
|
||
- **"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 add` the
|
||
commit whose handoff made the claim (COPY `data/`, never junction; see the
|
||
data-dir-junction-hazard memory), build, capture the *same* `GE_PCDUMP`
|
||
window, `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 with `git worktree remove --force` (safe
|
||
only because `data/` 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-slot `interruptQ` with 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 → `curRSPTask` never clears → no further frames
|
||
(`frames=2` heartbeat, retrace queue pinned `valid=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, and `OS_MESG_BLOCK` is 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 `deltaFrames` from wall-clock `osGetCount()`
|
||
spirals the sim into a "hang".** The N64 was VI-locked so `waitForNextFrame()`
|
||
never returned more than ~2 elapsed frames. On the port `osGetCount()` is
|
||
wall-clock (D117), so any real-time stall (asset load at a stage/cutscene
|
||
boundary, host thrash) makes `nextFrameTime` → hundreds/thousands. That value
|
||
flows through `speedgraphframes` → `g_ClockTimer` and drives *every*
|
||
`modelTickAnim` `while (numticks--)` and `for (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: `mainThread` parked in `modelTickAnim`/`modelConstrainOrWrap
|
||
AnimFrame` under `chrTick`/`playerTick`/`lvlRender` across every dump. Fix:
|
||
clamp `nextFrameTime` under `#ifdef PORT` in `waitForNextFrame()`
|
||
(`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
|
||
for `g_ClockTimer` / `speedgraphframes` / `lvupdate*` consumers that loop.
|
||
§F **D155**.
|
||
- **D156; a NaN / blown-up `f32` frame or speed drives an unbounded
|
||
frame-stepping loop → hang.** `modelSetAnimFrame2WithChrStuff`'s `while (1)`
|
||
(`model.c:3131`) walks one anim frame at a time from `framea` to `frameb`;
|
||
`frameb` comes from `modelTickAnim`'s `frame += playspeed * speed` per tick.
|
||
A cutscene anim transition with a near-zero blend/`timespeed`/`unkb0`
|
||
denominator (`model.c:3436`/`:3477`, guarded `> 0` but not against tiny
|
||
values) makes `model->speed`/`playspeed` huge or NaN → `frameb` huge/NaN →
|
||
`floorFloatToInt` garbage → ~2^31 iterations → frozen (`frames=N` constant
|
||
in the hang dump, distinct from D155's slowly-incrementing spiral). Fix:
|
||
`#ifdef PORT` finiteness+magnitude guards (`!(x > -1e6f && x < 1e6f)` also
|
||
catches NaN) on `frameb` before the loop and on `frame`/`frame2` before they
|
||
reach `model` state. **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 misaligned `Model`/struct field (D100/D140 pun family) as
|
||
the NaN source before blaming the data. §F **D156**.
|
||
- **D247; a visual "defect" report can be faithful, unmodified N64 behaviour;
|
||
check the decomp's own constants before assuming a port bug.** User reported
|
||
black bars top/bottom of the screen. The obvious port-side suspects (a
|
||
present/letterbox path, a framebuffer-vs-window size mismatch) were all
|
||
clean; no code anywhere sets `G_ASPECT_MODE_EXT` for GE, so fast3d never
|
||
letterboxes. The real cause: `bondviewGetCurrentPlayerViewportHeight()`
|
||
(`bondview2.c:7858`) returns `VIEWPORT_HEIGHT_DEFAULT` = 220 of 240 NTSC
|
||
scanlines (`fr.h:34`, ~92% fill) whenever `PLAYER_OPTION_SCREEN` is
|
||
`SCREEN_SIZE_FULLSCREEN`, the decomp's own default value, unmodified,
|
||
no `#ifdef PORT` in sight. `VIEWPORT_HEIGHT_FULLSCREEN` (304, no inset) is
|
||
a trap name: it's only reachable via the unrelated `cameraBufferToggle`
|
||
branch, not normal solo play. This is GoldenEye's real TV-safe-area
|
||
letterboxing, present on original hardware. Ruled out a stale EEPROM
|
||
`Screen=Wide/Cinema` setting from earlier sessions as an alternative
|
||
explanation by reproducing identically on a fresh EEPROM. **Lesson:**
|
||
before chasing a `port/` fix for a rendering "bug," grep the reported
|
||
geometry (viewport height/width, screen offset, etc.) back to its decomp
|
||
origin; a visually surprising but *exactly reproduced* N64 quirk isn't a
|
||
port defect, and the fix is to close the finding, not to write code.
|
||
- **D195; a targeted decomp-side fix (D72) blanket-disabled a whole PC-side
|
||
feature instead of gating it on the GBI bit that actually distinguishes the
|
||
broken case from the working one.** `gfx_sp_vertex()`'s PD-inherited
|
||
envmap (`G_TEXTURE_GEN`) UV-generation block was removed wholesale to fix
|
||
the Rareware logo (D72.1), on the reasoning "GE never generates texture
|
||
coordinates from vertex normals." That's true for the logo specifically
|
||
(lit, but doesn't set `G_TEXTURE_GEN`) but false for the engine in
|
||
general: GE uses real RSP envmap generation for every shiny/reflective
|
||
material (gold/silver weapon skins, chrome trim, reflective glass), all of
|
||
which DO set the bit. Deleting the feature instead of gating it on
|
||
`rsp.geometry_mode & G_TEXTURE_GEN` (which the reference PD port already
|
||
does correctly) silently broke every one of those materials game-wide;
|
||
they'd sample a fixed, usually-dark corner texel forever, reading as solid
|
||
black. **Lesson:** when a PD-inherited block causes a wrong result for one
|
||
specific draw, check what GBI bit *that draw* fails to gate on before
|
||
assuming the whole mechanism doesn't apply to GE; a feature real N64
|
||
hardware supports and GE's own assets exercise is not "PD-only" just
|
||
because the first investigation happened to hit it on a draw that
|
||
shouldn't have taken that path. §F **D195**.
|
||
- **D228; when two decode paths exist for the same underlying format,
|
||
check them against each other for a swapped byte order, not just against
|
||
the spec.** `palette_to_rgba32`'s `G_TT_IA16` branch (CI texture whose
|
||
palette is intensity-alpha, not RGBA16) read `intensity = palentry & 0xff`,
|
||
`alpha = palentry >> 8`; exactly backwards relative to the *already
|
||
correct* direct-IA16 decode (`import_texture_ia16`) a few lines above it
|
||
in the same file, which reads `intensity = addr[2*i]` (first/high byte),
|
||
`alpha = addr[2*i+1]` (second/low byte), matching what `gfx_dp_load_tlut`'s
|
||
`PD_BE16` byte-swap actually produces. A dim, fully-opaque texel (e.g.
|
||
intensity=0x58, alpha=0xff) decoded backwards as bright and
|
||
mostly-transparent; read as "white missing-texture patches" (AK47 and
|
||
other metallic weapon materials, whose grayscale-metal-plus-opacity look
|
||
is a natural fit for IA16 palettes). Two importers for the same source
|
||
format existing side by side in one file is exactly the situation where
|
||
one silently drifts from the other; when investigating a decode bug, diff
|
||
the suspect path against its sibling path for the same format before
|
||
re-deriving the bit layout from scratch. §F **D228**.
|
||
- **A Claude Code session's default `Bash` tool is NOT the MSYS2 MINGW64
|
||
shell the build needs, even when `uname -a`/`$MSYSTEM` claim it is (M-140).**
|
||
On this box the tool's shell is Git-for-Windows Bash (root mounted from
|
||
`C:/Program Files/Git`), which sets `MSYSTEM=MINGW64` and reports
|
||
`MINGW64_NT-...` from `uname` for compatibility — both of AGENTS.md's own
|
||
“are you in the right shell” checks pass while `cmake`/`ninja`/`gcc` are
|
||
still missing from `PATH` (they resolve at `/c/msys64/mingw64/bin` and
|
||
`/c/msys64/usr/bin`, not `/mingw64/bin` — that path exists but is a
|
||
Git-Bash stub, not the project's real MSYS2 install). Symptom if you build
|
||
anyway with only Git-Bash's own `gcc`/toolchain state: `cmake: command not
|
||
found`, or (if some `gcc.exe` further down PATH resolves) every compile
|
||
fails with `Cannot create temporary file in C:\Windows\: Permission
|
||
denied` — a native Windows exe's `GetTempPath` falling back to `C:\Windows`
|
||
because the child process doesn't actually see a usable `TMP`/`TEMP` the
|
||
way it does when launched from a real MSYS2 shell window. Fix: explicitly
|
||
`export PATH="/c/msys64/mingw64/bin:/c/msys64/usr/bin:$PATH"` (adjust if
|
||
MSYS2 is installed elsewhere) before `./build-pc.sh`/`ninja`/`cmake` in any
|
||
Claude Code Bash-tool session; do this once per session, first thing, not
|
||
reactively after a mystery failure. Also: `cd` with a *backslash* Windows
|
||
path (`cd C:\Users\...`) is unreliable in this same shell and can silently
|
||
mis-parse to a mangled path on a later command in the same tool call —
|
||
prefer `cd /c/Users/...` (forward slashes) for the build working directory.
|
||
**The PATH fix alone can still leave the *link* step broken (M-141):**
|
||
compiling succeeds but `ld.exe` fails with `cannot find @C:\Windows\Temp\
|
||
ccXXXX: Invalid argument` if `TMP`/`TEMP` are unset or point at a
|
||
non-writable/non-real path like a literal `C:\Windows\Temp` string — the
|
||
response-file `ld` uses for the link command has to land somewhere this
|
||
shell's child process can actually write and re-read. Fix: `export
|
||
TMP=... TEMP=...` to a real writable per-session directory (the Claude
|
||
Code scratchpad path works well) before `ninja`/`build-pc.sh`, not just any
|
||
Windows-looking path string.
|
||
- **A backgrounded `ge007.x86_64.exe` launch from this Bash tool does not
|
||
reliably die to `pkill -f ge007.x86_64.exe`** (M-155, D288 session): the
|
||
process appears in `ps aux` as bare `ge007.x86_64`, no `.exe` suffix, so
|
||
the `-f` pattern match on the full name silently fails and the game
|
||
window (and its log-writing) keeps running well past the intended
|
||
`sleep N` capture window — one session let a stray instance run ~90s
|
||
longer than intended before it was caught. Use `taskkill //F //IM
|
||
ge007.x86_64.exe` instead to reliably terminate it from this shell, and
|
||
`ps aux | grep -i ge007` to confirm before moving on — don't trust a
|
||
`kill`/`pkill` exit code alone.
|
||
- **Headless visual verification of a rendering bug is possible without a
|
||
live human** (M-156/M-157, D252/D219 sessions) — combine `GE_INPUTSCRIPT`
|
||
(scripted controller input, e.g. `Z` pulses to fire a weapon) with
|
||
`GE_PCDUMP="lo-hi:step"` (writes `./ppm/frame_NNNNNN.ppm` — NOT a separate
|
||
configurable output dir, just `./ppm` relative to cwd; `GE_MKDIR` creates
|
||
it) in the *same* run, then actually open the resulting PPMs (Python
|
||
`PIL.Image.open(...).convert('RGB')` reads them fine, no format quirks)
|
||
instead of only grepping a probe's text log. A text-only probe proves a
|
||
code path fired; it does not prove what ended up on screen — several
|
||
long-OPEN findings (D290/D291/D294, and D252/D219's whole "rainbow
|
||
particle" family before this session) were stalled for lack of eyes on
|
||
the actual pixels, not for lack of a working probe. Build a low-res
|
||
contact-sheet montage first (tile many frames small, e.g. 160×120, to
|
||
scan for the moment of interest cheaply) before zooming into individual
|
||
full-res frames — full Dam/Bunker1 frames are 3072×1728, too large to
|
||
eyeball one at a time across a whole capture window.
|
||
- **A crash fault address of `0xffffffffffffffff` on x86-64 usually means a
|
||
non-canonical #GP from a garbage-high-bits pointer (bits 63:48 not a sign
|
||
extension of bit 47), not a computed `-1` sentinel** (cross-validated
|
||
against the sibling GEVR VR port, same decomp lineage — see
|
||
`scratchpad/GEVR-TRIAGE.md` item C; we independently hit the same register
|
||
shape in D191). Two companion habits worth keeping: re-read map anchors
|
||
from the freshly rebuilt map after every rebuild (a stale map silently
|
||
resolves the wrong symbol), and confirm the fault PC actually disassembles
|
||
on an instruction boundary before trusting a resolved symbol.
|
||
- **Frame-rate assumptions — read before proposing any non-60 Hz mode**
|
||
(cross-validated against GEVR, `scratchpad/GEVR-TRIAGE.md` item B). GE's
|
||
logic is delta-scaled through `g_GlobalTimerDelta` (~216 call sites); the
|
||
PAL build (×1.2) is a shipping proof that delta-scaled code tolerates
|
||
other rates. But: **Bond's walk is frame-rate dependent by construction**
|
||
— `MoveBond` (`bondview2.c`) is an exponential approach toward a target,
|
||
advanced once per frame with no timestep, so convergence is measured in
|
||
frames, not seconds (GEVR measured ~1.487× walk speed at 90 Hz vs 60 Hz,
|
||
≈90/60 exactly). Turn/aim *is* delta-scaled; walk is not. High-refresh or
|
||
uncapped presentation modes are therefore out of scope without a
|
||
game-logic change, which is forbidden — we tick at fixed 60 Hz (D52
|
||
pacing) by design, not as a limitation to fix. Also: `g_ClockTimer` must
|
||
stay integer (24 iteration sites depend on it); of 44
|
||
`/g_GlobalTimerDelta` divide sites, 40 are guarded (three different
|
||
spellings) and 4 are not (front-end cast camera `front.c:8271-8273`,
|
||
`propobj.c:~2393`) — those 4 rely on div-by-zero yielding NaN/inf without
|
||
trapping on both MIPS and x86-64 SSE, matching retail behavior; do not
|
||
"fix" them.
|
||
|
||
- **x64 Windows backtraces: unwind with the PE's .pdata, never walk RBP.**
|
||
`-fno-omit-frame-pointer` on MinGW-w64 GCC does not give an RBP chain:
|
||
most prologues set `RBP = RSP + N` (Win64 allows an offset frame
|
||
pointer), so `[RBP]` is not the saved RBP and a manual walk dies at
|
||
frame 1. `RtlLookupFunctionEntry` + `RtlVirtualUnwind` from the
|
||
exception CONTEXT is reliable and needs no dbghelp/symbols (crash.c,
|
||
2026-09-30). Print `va = preferred ImageBase + RVA` per frame so
|
||
`addr2line -e ge007.x86_64.exe` resolves it even if the image was
|
||
relocated. Test with `GE_CRASHTEST=<N>`.
|
||
- **Crossing msys -> native: pass files, not quoted strings; get paths from
|
||
bash's own `pwd -W`.** The msys argv conversion re-escapes embedded `"`
|
||
as `\"`, which `cmd.exe` does not understand, so an inline
|
||
`cmd //c "... \"C:\path\" ..."` silently never runs (build-pc.sh's TMP
|
||
probe was dead this way). And with Git Bash plus MSYS2 `usr/bin` on one
|
||
PATH, `/tmp`, `mktemp` and `cygpath` can belong to different runtimes
|
||
(different Windows dirs). Never trust `command -v cmake` for the MSYS2
|
||
toolchain either: a pip-installed cmake can shadow it (2026-09-30).
|
||
|
||
### Poll-thread input must never re-point `g_CurrentPlayer` (D419)
|
||
|
||
`inputComputePad` runs on the scheduler thread (`joyPoll`), a real pthread, not the game thread. Any save/swap/restore of `g_CurrentPlayer`/`player_num`/`g_playerPerm` from there races the game tick's own `set_cur_player`: the restore can leave the game thread on the wrong player and it then spins in list walks that compare against `g_CurrentPlayer->...` (seen: `bondinvCycleForward` infinite loop after a split-screen respawn). Resolve the owning player explicitly (`g_playerPointers[slot]`) and touch its fields directly; for game functions that take no player argument, present a native button bit instead of calling them.
|
||
|
||
**A1 cross-tag (D492, 2026-10-03): serialized multi-byte fields are host-endian in this port.** Structs copied raw to a file (the EEPROM save via `geEepromRW`) store s32/u16 fields little-endian, while N64 images are big-endian. A raw-byte CRC over the region gives the same value on both, which hides the difference until the stored checksum words themselves are compared. Tell: data that is intact but fails validation, with `calc_crc` equal to the big-endian reading of the stored word. Any field inside the CRC range must be swapped *before* recomputing the checksum.
|
||
|
||
**A1 cross-tag (D455, 2026-09-30):** an exe-static address (array/global/function in `.data`/`.rodata`/`.text`) held in a `u32`/`s32` truncates on Windows (image base `0x140000000`; Linux 0x20000000 hides it). Binary tell: `mov r64,[rip+.refptr.X]` ... `mov r32,r32` before the pointer is used or passed. The address load may precede a `jmp` join, so an adjacent-instruction scan misses it; follow jumps. A census of the whole exe found only the D454 site; `_*Segment*` linker symbols are ROM constants (`.set` in `romassets_u.s`), not exe addresses, and are benign in `(u32)` casts.
|
||
|
||
**`GE_MEMPREDZONE=1` (D464): catch allocator overruns deterministically.** Set it before any run to pad every `mempAlloc*` block with a 64-byte pattern trailer and check at level unload, every 60 frames and on orderly quit. A hit prints `MEMPREDZONE: HIT ... caller=0x<addr>`; resolve with `addr2line -f -C -i -e build-pc/ge007.x86_64.exe 0x<addr>` to the allocation site, then compare its size expression with `sizeof` of how the buffer is indexed (an N64 literal stride vs a widened struct is the D461/D462 class). Zero hits is only "no overrun reached the trailer"; whole-bank allocations are unpadded until shrunk. Unset = one cached branch per allocation.
|
||
|
||
**Data-driven input mapping: factor the pure core, keep the old code as a test oracle (D469).** To refactor a hard-wired input block into a table without changing default behaviour, move the decision into a pure function over (raw source bits, bindings, previous-state, incoming mask) and keep a verbatim transcription of the old block next to it, used only by an env-gated boot self-test (`GE_PADMAPTEST`) that compares them exhaustively over the (small) input space. 2^15 source combinations x prev x ctx x incoming masks is ~21M cheap calls, ~1 s, and gives a byte-identity proof no live pad is needed for. Watch the side inputs the old code mutated implicitly (here the incoming `button` mask: the southpaw swap acted on keyboard fire too, so the pure function takes it as a parameter). Table edits arrive from other threads: the poll thread must rebuild into a spare buffer keyed by a cheap config signature and publish by atomic pointer, never parse (no `strtok`) or re-point shared state in place.
|
||
|
||
**Audio device/gain changes belong on the producer thread (D470).** The SDL audio path is a queue (`SDL_QueueAudio`), fed and sized only from `audioSetNextBuffer`/`audioGetAiLengthBytes`. To change the device live, have UI/event threads only set atomics and do the close/reopen at the top of `audioSetNextBuffer`; never close `dev` from the UI thread. Apply master gain to a scratch copy (never the game's buffer) and pass the original pointer through at 100 so the default stays byte-identical. `SDL_AUDIODEVICEREMOVED.which` is the opened device id for output devices.
|
||
|
||
**Overlay/menu input must name its owner pad; never hard-code seat 0 (D472).** When several pads can open a shared UI, keep a per-pad edge array (one shared `prev` flag masks edges and double-toggles), record the opener, and resolve the effective owner (opener -> lowest attached -> 0) at read time so a detach cannot strand the UI. Port-layer 2D that must pillarbox under native widescreen: wrap it in `PORT_HUD_ASPECT(CENTER)` and map the mouse through `vis = (4/3)/portNativeAspect()`.
|
||
|
||
### The options row table has two UIs; new row kinds need a front-end answer (menu PD alignment)
|
||
`port/src/optionsoverlay.c`'s `rows[]` is consumed by the F10 overlay and by `frontoptions.c` through the `optionsRow*` accessors. A row kind that only one UI understands (separator, Back, hub-only actions) must be hidden from the other in `optionsRowIsShown()`, and should be a `ROW_ACTION` with a key prefix so the section-reset and reset-probe loops (which skip `ROW_ACTION`) stay correct. Moving a row between sections silently changes what that section's "Reset to defaults" resets: re-run `GE_WSPROBE_RESET` after any regroup.
|