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

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.

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:

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.

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.

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.

C2. Port-layer / SDL shims

D. N64 hardware idioms fast3d does not emulate

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:

for (s = SP_LEVEL_EGYPT; s >= SP_LEVEL_DAM /* == 0 */; s--)   // never ends

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.

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

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.

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.

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.

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

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 gSPTextureRectangles, 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) gSPTextureRectangles 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:

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.

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

D25. Cross-platform goldens: a fixed frame stem is only comparable at settled gameplay (D521)

GE_PCDUMP/GE_QUITFRAME step the port’s sim frame counter (port/src/video.c:1406 ++frames, one per portRenderGfxTask() at port/src/libultra.c:1256), and retrace messages are dropped at the posting site when the game’s queue is nearly full (port/src/libultra.c:753), so a slow box stretches wall time without advancing frames – frame stems are comparable, wall time is not. What is NOT comparable is any stem inside the intro flyby, whose settle point is wall-clock paced (D117): on a ~54 fps Linux box the flyby was still running at frame 900 (settles ~830-1130 run to run) while the Windows golden at 900 was settled, which reads as a 92.9% “different scene” delta if reported bare. Tells: over-tol2 % >30% at one stem and 1-7% at the next; phash Hamming in the 80s; a dense dump whose consecutive diffs sit at 80-95% and drop to 1-5% once settled. Never compare across platforms with GE_DETERM=1 (call-sequenced retraces: stable run to run, different frame -> sim mapping). Before calling a residual band a renderer bug, classify it: a constant ~20 delta confined to a screen band (Archives’ top 3 grid rows, all three stems) is systematic and needs a same-sim-state capture pair; a uniform d 3-6 over the whole frame with cell means <= 11 (Cuba) is the dither/quantization noise floor. RESOLVED 2026-10-05 (D525): both residual bands were save state, not renderer — the pair that closed them was a re-round of the whole linux set with data/ge007.eep pinned present (Archives 19.956-24.554% -> 3.590-3.859%, worst cell 50.8 -> 2.31; Streets 1200’s 16.0-148.4 cell-mean band -> 3.616-3.983%, worst cell 4.43). The diagnostic tell that separates the two: re-capture one side with the save present and the band collapses to the noise floor, while a renderer defect survives identical sim state.

E. Process / method notes

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.