Documents the four hotfix-batch changes since v0.2.1 (D255, D285, D282,
D293) in README Status and .github/release-notes.md, and folds in
2026-09-16 real-Deck confirmation that D285's audio-thread lock holds
under sustained overlapping-explosion combat (Archives, dual grenade
launchers + rocket-launcher-cheat guards).
No discoverable in-UI way to quit (Deck/controller-only setups had only
window-close/Alt+F4). Adds a ROW_ACTION row kind (not config-backed, same
pattern as the existing __Resolution row) that triggers the identical
configSave()+exit(0) path SDL_QUIT already uses. Build-verified on Windows
and Linux; verify.sh dam PASS, no regression.
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
sndHandleEvent's pool-exhaustion preempt-scan read/walked the shared
D_800243E4 active-ALSoundState list with no osSetIntMask lock, while
sndSetupSound (the list's insert path, reached from sndPlaySfx calls
across 15 game files) already locks it -- an asymmetric-locking gap,
the same D4/D147/D152 "N64 interrupts-off isn't a real lock on PC"
class. Wraps the scan in the identical lock/unlock pair sndSetupSound
already uses, unconditionally (no #ifdef PORT, matching that function's
own style -- osSetIntMask is genuine N64 API and the port's shim is a
process-wide recursive lock, safe to nest). Audited the rest of the
function: the scan is the only D_800243E4 touch point in it.
Build green; -level_09/-level_20 unregressed (1400+ frames, no crash,
no D152 lock-stuck warnings). Not yet gdb/live-confirmed against a
real reproduced crash -- a GE_INPUTSCRIPT-driven stress attempt this
session didn't visibly activate the scripted-input harness for a
reason not root-caused; the honest bar met here is "correct by D4/D152
precedent, build/sweep-clean," not "reproduced-then-fixed."
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
Menus read the same stick_x/stick_y channel as in-level gameplay, and
naturalPitchMode (SOLITARE-style, default-on) routes that channel to
the right stick for continuous look. Since front.c's menu nav shares
the channel, it silently inherited the right-stick routing, which is
unintuitive on a modern pad/Deck outside a running stage. The gamepad
poll already computed an unused padMenuMode flag for shoulder-button
gating; reused it to always source stick_x/stick_y from the left
stick outside RUN_STAGE, regardless of naturalPitchMode. Port-layer
only, inert during gameplay -- verified via -level_09/-level_20
unregressed; in-menu feel needs a human controller playtest.
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
Root-caused via a live Steam Deck session over SSH: sub_GAME_7F09BAC4
(vtxstore_fix_refs) read an active OBJ record's Model* through
((Model*)((ChrRecord*)prop->chr)->chrflags) - a union pun onto
ChrRecord.chrflags, which is typed CHRFLAG, a 4-byte enum. On 32-bit
N64 this is bit-exact (the enum and a pointer are both 4 bytes, so
casting the enum's value to Model* reproduces the real stored bytes).
On 64-bit PC, Model* is 8 bytes but the enum read is still only 4 -
the cast reads just the pointer's low 32 bits as an int and
zero-extends it back to a 64-bit address, discarding the real high
bits. This is the D3x pointer-width ABI class from AGENTS.md - a
struct field misread across the 32-to-64-bit pointer-width transition.
This was actually flagged as suspect (a) in the very first D255
investigation, then wrongly deprioritized in favor of a "destroy-path
race" theory once a register capture happened to show Rdi=(nil) and
looked like it confirmed a different, unrelated NULL-model theory. It
didn't: (nil) is exactly what a 32-bit-truncated read produces
whenever the real pointer's low 32 bits happen to be zero - a
coincidence that pointed two sessions' worth of investigation at the
wrong field. Proof: reading the same record through the correctly
64-bit-typed ObjectRecord.model field (this session's diagnostic
probe) was always a valid, non-NULL pointer - the object's model was
never actually missing.
Fix: read the pointer through ObjectRecord.model (the same union
memory, correctly typed) instead of through the mistyped
ChrRecord.chrflags alias, at both use sites in the function. Identical
bytes and identical result on N64; correct on 64-bit PC.
Verified live on the user's own Steam Deck hardware over SSH, running
their own prepared ROM assets: held through the entire Facility level
including repeated terminal destruction (the most consistent prior
repro) and through Caverns' radio-objective terminals (the second
confirmed level), zero crashes. Windows rebuild + full 21-level
verify.sh sweep also clean.
The separate audio-thread crash (D285, caught during this same live
session) is unrelated and unaffected by this fix.
Batched pass across README/release-notes/Pages (one commit instead of the
scattered per-fix pushes earlier this session, per new commit-cadence
preference):
- New known issues surfaced that weren't documented anywhere: no real
widescreen (16:9 stretches 4:3 rather than expanding FOV), no controller
rebinding UI / macOS-ARM builds, culling/LOD dropout on Streets/Egyptian
(D249), gunshot SFX oddities under sustained fire (D240/D241).
- Fixed a stale claim (in three places) that a wrong-sounding-instrument bug
was still open; it was fixed in v0.2.0 (D230).
- Softened the D259 "All unlocked" fix to reflect reality: still breaks
audio/aim if enabled before any save exists; flagged experimental
(new finding D281).
- Corrected D255's repro to the precise, deterministic trigger found this
session (Facility's door-opening computer terminals, Linux-wide not
Deck-specific) instead of the vaguer original combat-crash description.
- Logged D282 (front-end menus need right stick not left on gamepad) and
D283 (Steam Deck first-run preset skipped by an early Desktop Mode
launch) with user-facing workarounds.
- Refined D263 (Ourumov/Trevelyan) internally with a position-sensitivity
caveat; no public bullet since it doesn't block progression.
- Added pre-1.0 framing and a new README Roadmap section (known-issues
cleanup, PAL/JP, real widescreen, rebinding, macOS/ARM, LAN multiplayer
explicitly "under consideration, no ETA", plus an explicit "not currently
planned" list) across README/release-notes/Pages.
- Added an AI-development disclosure (agentic development as a genuine
study of the process, not just a port) across the same three docs.
- Pinned the release-notes hero GIF to the v0.2.1 tag instead of v0.2.0.
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
Bing Webmaster re-flagged the site after the first SEO pass (32f193a0):
- Every Jekyll-processed doc page (building.md, dev-process.md, internals.md,
porting-notes.md, dev/agentic-development.md, plus index.md caught last
time) opens with its own "# Heading" on top of the Cayman theme's masthead
<h1 class="project-name">, so all of them emitted two h1 tags, not just the
homepage. Demoted each page's opening heading to h2 -- anchor slugs are
unaffected (kramdown ids don't depend on heading level).
- index.md's meta description was 161 chars (1 over Bing's 160 limit) and
still said v0.2.0; re-trimmed to 133 chars and bumped to v0.2.1.
- README.md and docs/index.md's Status lines/comparison table bumped from
v0.2.0 to v0.2.1 to match the actual current release.
Verified against a live, cache-busted fetch of the deployed Pages site
(the first Bing report was reading a stale CDN edge cache from before the
32f193a0 deploy, per its own Last-Modified header) that the underlying fix
was already live; this pass addresses what was still genuinely wrong.
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
User testing found the slider "basically useless" -- viShake() only scales
explosion/effect shake (explosion.c), never the always-on walking head-bob
or a getting-shot reaction, which is what "Screen shake" implies to a
player. Removed the menu row until it covers all screen-shake/view-bob
sources, not just explosions. Config var + fr.c hook untouched.
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
User spot-check of the v0.2.1 draft found that enabling Skip intro breaks
audio. It was already off by default (D216); removing the menu row so
players can't enable it until the audio interaction is root-caused. The
config var and lv.c hook are untouched (dead unless an existing ini already
has it set).
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
The v0.2.1 docs commit (572ab7bf) added GE-ENV-PROBES.md rows for the
D266-D280 tree-investigation probes, but that probe code itself was
deliberately left uncommitted (still mid-investigation, unswept). CI's
tools_pc/gen_env_probes.py --check caught the mismatch: 13 documented
vars with no live getenv() site in the committed tree. Reverting the
doc rows to match what's actually committed; they'll return together
with the probe code once the D236 investigation lands.
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
Records the v0.2.1 hotfix batch (D259-D264, this commit's code) and the
ongoing D236 Surface-1 tree-wall investigation (D265-D280): still open as of
D280 -- fog math verified N64-faithful, the noise class confirmed as the
weave's painter, the portal-culling over-draw fix (D271) real but
insufficient, no quick win identified. The D266-D280 probe code and the
D266 IA4 decode / portal-culling fixes themselves stay uncommitted pending
the game-wide regression sweep GRAPHICS-BACKLOG.md flags.
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
_config.yml's description was ~230 chars once jekyll-seo-tag spliced it
into the masthead tagline and home <title>; index.md's page description
was ~277 chars for the same og/meta tag. Also removed index.md's manual
"# GoldenEye 007 PC Port" h1 -- the Cayman theme already renders the site
title as an h1, so the page was emitting two.
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
User playtest reports against v0.2.0:
- D259: geEepromPatchAllCheats left an all-zero fresh save at volume 0
(silence) instead of seeding max volume like fileResetSave would.
- D260: swap wheel-up/wheel-down to match the PC wheel-menu's on-screen
order (was inverted vs. user expectation).
- D261: watch-menu stick-held navigation had no repeat past the smooth-scroll
band, so holding W/S or a pegged stick crawled one item per tap; added a
bounded auto-repeat.
- D263: GE_INPUTSCRIPT stick tokens were applied after the stick was already
written out and silently discarded (test-harness fix, kept from the D263
Facility/Ourumov investigation, which itself found no port bug).
- D264: watch item-preview ModelRenderData template read across two globals
that no longer sit adjacent on PC, zeroing renderdata.flags and gating all
geometry emission in the preview's subdraw pass.
Claude-Session: https://claude.ai/code/session_01KwZViAFaXua3PKgHHjNgvi
All six TCRF article bodies were extracted (backlog/cut-content/articles/,
gitignored) and cross-referenced against src/ + assets/obseg/. Full verified
findings with file:line refs land in backlog/cut-content/RESEARCH.md.
Verified 1:1: the 11 unused cheats (CHEAT_ID enum vs cheat.c grant table),
music track index (MUSIC_TRACKS enum vs Notes-page GS index), MP character
table (~40 entries, 1.0446 eye height), all unused text strings (LmiscE/LgunE/
LpropobjE/LtitleE), build dates (compiletime.c), Spectrum emulator plumbing
(front.c MENU_SPECTRUM_EMU — only the transition is missing).
Corrections to TCRF/first pass: bg_dest is shipped Frigate geometry (not an
early version); cryp/dish are shipped Egypt/Temple; Library/Stack share
Basement's bg_ame; 28 of 29 unused items exist in the object table.
Tracked doc updated to point at the research file; backlog/ gitignored.
Cross-reference the local TCRF 'cutting room floor' export
(scratchpad/backlog-scrape/restore-cut-content-mod, gitignored) against the
decomp source and extracted asset tables. Article bodies were lost to a
Cloudflare challenge at scrape time; findings rest on the ~170 intact article
images + all image filenames, each mapped to ROM object/level evidence:
- A: full debug menu (U+D C-hold) decompiled in debugmenu_handler.c — free cam,
level switch, ramrom record/replay, screenshot grabs; all compiled into PC build
- B: cut weapons (taser/rocket/grenade/P90/spectre/autoshot) have models + stats
+ SFX; rocket/grenade/P90 grantable via existing cheat codes (cheat.c)
- C: 'unused items' page maps 1:1 to file_resource_table entries with use-logic
- D: 8 commented-out MP map entries in front.c with dev notes (Citadel/Statue/
Cradle/Aztec/Runway/Dam/Depot/Frigate)
- E: per-level unused props + orphaned level geometry (Crypt has bg+briefing but
no LEVELID; len/dish/cat/oat/arec/ame/ark/ref dirs beyond shipped set)
- F/G/H: unused faces, prerelease comparison pairs, dev-build video embeds
Feasibility tiers 0-3 with the port-layer-only constraint called out.
2026-09-14 00:48:30 -04:00
21 changed files with 1050 additions and 102 deletions
| **Linux** (x86_64) / **Steam Deck** | [linux tarball](/jkdansereau/goldeneye-pc-port/releases) | SDL2 is bundled, so it runs as-is on any distro, and sideloads onto a Deck with nothing installed. |
| **Linux** (x86_64) / **Steam Deck** | [linux tarball](https://github.com/jkdansereau/goldeneye-pc-port/releases) | SDL2 is bundled, so it runs as-is on any distro, and sideloads onto a Deck with nothing installed. |
Both bundles contain **no ROM and no game assets**: you supply your own
(see [Requirements](#requirements)), which keeps the release legal to
distribute. Earlier builds: v0.1.0 alpha, same page. You can also build it
distribute. Earlier builds: v0.2.1, v0.2.0 and v0.1.0 alpha, same page. You can also build it
yourself; see [Building](#building).
### Quick start
You need a GoldenEye 007 N64 ROM (`.z64`, big-endian). This release supports
the **NTSC-U (US)** version; PAL and JP are on the roadmap ([issue
#85](/jkdansereau/goldeneye-pc-port/issues/85)). No ROM or game asset is
#85](https://github.com/jkdansereau/goldeneye-pc-port/issues/85)). No ROM or game asset is
included or distributed. Then:
1. Download the Windows or Linux bundle from [Releases](/jkdansereau/goldeneye-pc-port/releases) and unpack it.
1. Download the Windows or Linux bundle from [Releases](https://github.com/jkdansereau/goldeneye-pc-port/releases) and unpack it.
2. Make a `data/` folder next to the executable and drop the ROM in as `ge007.ntsc-final.z64`.
3. Launch the executable from that folder. The first run takes a few extra seconds: it detects the ROM and generates the derived asset folders once (no Python or other tooling needed).
Read the [Status](#status) caveats first: v0.2.0 has known rough edges,
Read the [Status](#status) caveats first: v0.2.2 has known rough edges,
listed plainly there.
## Status
**v0.2.0 - playable, with known rough edges.** The full single-player
**v0.2.2 - playable, with known rough edges.** The full single-player
campaign is completable end to end (all 21 missions, Agent difficulty,
playtested), at a steady 60 fps; all 21 solo missions load, render and run
crash-free, verified on Windows, Linux and real Steam Deck hardware. Feedback
is very welcome.
**Working:** boot sequence and front end (menu → mission select → briefing →
start); all 21 solo missions load, render and are crash-free (full campaign
start), front-end menu navigation on the left stick to match the F10 overlay
(D282); all 21 solo missions load, render and are crash-free (full campaign
playtested end to end at Agent difficulty); steady 60 fps
(software RSP off the presentation critical path); full audio: in-level music and SFX; keyboard + mouse (click-to-lock, proportional aim mode) and a modern
dual-stick controller layout; file-backed saves; faithful N64 progression by default
(F10 → *All unlocked* opens every level, 007 mode and the full cheat menu); F10 in-game
- No macOS or ARM support; no controller rebinding UI.
Root causes and fix status for every item: the [release notes](/jkdansereau/goldeneye-pc-port/releases)
Root causes and fix status for every item: the [release notes](https://github.com/jkdansereau/goldeneye-pc-port/releases)
and the finding log in [`docs/dev/findings.md`](docs/dev/findings.md).
### Steam Deck
The Linux bundle is the Deck build. SFTP it over from your PC, or download
it straight from the [releases page](/jkdansereau/goldeneye-pc-port/releases) on the Deck itself:
it straight from the [releases page](https://github.com/jkdansereau/goldeneye-pc-port/releases) on the Deck itself:
unzip, drop your ROM in `data/`, launch it once (the first run generates the
derived assets), and add the executable as a non-Steam game. SDL2 is bundled, so no dependencies need
installing. On SteamOS the first launch seeds `ge007.ini` with Deck-friendly
defaults: native 1280×800 fullscreen, VSync, MSAA 4, and 150% draw/LOD
distance (the authored N64 fade distances read short on the close-up panel);
everything is changeable in the options overlay and persists afterwards. The renderer is CPU-bound (software RSP); expect original N64-era
everything is changeable in the options overlay and persists afterwards.
**Do that first launch in Game Mode, not Desktop Mode** — an ini created by
an earlier Desktop Mode launch (e.g. while testing before adding it as a
Steam shortcut) permanently skips the Deck preset, since any existing ini
always wins over it (D283). If your resolution isn't 1280×800 on first
Game Mode boot, just set it manually: F10 → *Resolution*. The renderer is
CPU-bound (software RSP); expect original N64-era
performance at 60 fps rather than more. This release was playtested on real
Deck hardware; the v0.1.0-era Facility crash (D203) did not recur: its root
cause was identified and fixed (D253), verified on the Deck; one intermittent
@@ -123,6 +163,27 @@ options, **A** steps the selected option forward, **B** steps it back, and
filtering cycle, sliders step in increments. With a keyboard attached the same
overlay is `F10` + arrows/Enter.
## Roadmap
No fixed timeline or committed feature list — this is spare-time work — but
directionally, on the way to v1.0:
- Working through the [known issues](#status) above and the fuller list in
[`docs/dev/findings.md`](docs/dev/findings.md).
- **PAL and JP ROM support** ([issue #85](https://github.com/jkdansereau/goldeneye-pc-port/issues/85)); NTSC-U is the only supported region today.
- **Real widescreen** (properly expanding the field of view at 16:9, rather
than today's 4:3-stretch).
- **Controller rebinding UI**, and macOS/ARM builds.
- **LAN multiplayer**: reviving GoldenEye's original split-screen/deathmatch
netplay across multiple PCs on a local network. Genuinely under
consideration, but early and not started; no ETA.
- General polish: performance, remaining rendering/audio defects, save/config
robustness.
Not currently planned: new game modes GE never shipped (e.g. co-op), online (non-LAN)
multiplayer, ray tracing. If any of these matter to you, open an issue —
it helps prioritize.
## Beyond playing
- **Tweak it**: `ge007.ini` and the F10 in-game overlay expose resolution,
@@ -170,7 +231,7 @@ code with this one.
| **How** | Decompilation-based source port: human-reconstructed C, compiled for the host; game logic runs as written | Static binary recompilation: the shipped machine code is auto-translated to C; no source-level understanding |
| **Lineage** | [GoldenEye 007 decompilation](https://github.com/n64decomp/007) + [Perfect Dark PC port](https://github.com/fgsfdsfgs/perfect_dark) engine family | Xbox 360 "…Recompiled" static-recompilation family |
| **Status** | v0.2.0 public release; full campaign playable at 60 fps (see [Status](#status)) | Playable full game with online multiplayer |
| **Status** | v0.2.2 public release; full campaign playable at 60 fps (see [Status](#status)) | Playable full game with online multiplayer |
| **Why it exists** | To run the *original* N64 game from source, and as a [case study in AI-agent collaboration](#background) on a hard low-level codebase | To get a playable PC release of the remaster |
They answer a different question: how to get the *remaster* onto PC by
@@ -190,7 +251,7 @@ big-endian (`.z64`) format, matching one of:
This release supports the US (NTSC-U) version; PAL and JP ROMs are
recognised by region but not yet supported (on the roadmap, [issue
description:The investigation workflow behind the port; task budgets, file partitioning, and the finding-log discipline used across sessions and agents.
---
# How this port is developed
## How this port is developed
Most of this port's work is not writing code; it is **diagnosis**: figuring
out which N64 hardware or ABI behavior a piece of unmodified game code is
| **1 — port-layer hook** | Grant taser/spectre/autoshot + unused items (watch identifier, bomb case, …) via config toggle replicating the cheat grant path (`bondinvAddInvItem` + `give_cur_player_ammo`) | small; follow F10-toggles pattern (`feat/f10-game-toggles`) | `port/src/` only |
| **2 — data/asset mod** | Place unused props per level; extra MP faces; PR texture swap | needs level-editor or object-table patching; separate project | data mod, not this repo's code |
@@ -25,7 +25,7 @@ once the game is playable start-to-finish without crashes.
| ~~**D233**~~ | ~~All levels (intermittent): near doors — especially doors you can't see through to the inside — the indoor level geometry vanishes and you can see straight through it into the room, while world objects (computer terminals, furniture) in that space still draw.~~ | **FIXED + VISUALLY VERIFIED (M-107 fix, M-108 verify).**`port/fast3d/gfx_pc.cpp``gfx_sp_tri1` was trivial-rejecting visible triangles that had a behind-camera (`w<0`) vertex. Status cell here was stale at "not investigated" — corrected M-119. §F D233. |
| ~~**D234**~~ | ~~Dam: the guard towers along the actual dam structure are invisible except for the first one.~~ | **FIXED + VISUALLY VERIFIED (M-108).** Same underlying mechanism as D233. Status cell here was stale at "not investigated" — corrected M-119. §F D234. |
| **D235** | Wrong textures load after dying/restarting a level — clarified: not Facility-specific, seen on multiple levels; trigger is death/restart (level reload), not a general build regression. | **Observed, not investigated** (M-106, user QA report, clarified same session). This is a state-leak on level re-entry, not a one-level texture-decode bug — prime suspect is the texture pool / CI-cache (D217/D228 neighbourhood) not fully resetting between a level teardown and reload (stale arena base, a palette-cache entry keyed by a reused address, or a texpool index not rewound). Needs: confirm it does NOT repro on a fresh level boot (only after death/restart); which surfaces; a screenshot. Next: instrument `texInitPool`/the CI-cache clear path for what does/doesn't reset on reload vs cold boot. |
| **D236** | Surface 1: the 2D billboard trees that appear at the start of the level and around the map don't draw as discrete tree sprites — they render as a wall/sheet of tree texture instead of the proper 2D tree objects. | **Observed, not investigated** (M-106, user QA report). Billboards = camera-facing quads; "wall of tree texture" reads as either (a) the billboard orientation/quaternion missing so all quads emit coplanar in world space, or (b) a UV/texture-rect setup error mapping the whole sheet onto each quad. Distinct from D176(b)'s tree-*line* backdrop static (that's the painted perimeter wall; this is discrete 2D tree objects). First checks: `GE_PCDUMP` at the Surface 1 opening; inspect one tree billboard's emitted vertices + texture coords in fast3d; check whether the quads are camera-facing on N64 reference footage. |
| **D236** | Surface 1: the 2D billboard trees that appear at the start of the level and around the map don't draw as discrete tree sprites — they render as a wall/sheet of tree texture instead of the proper 2D tree objects. | **OPEN — second pass active (D266–D269); first-pass decode fix was reverted after a negative user re-test (D265)** (M-106, user QA report). The earlier IA16 framing was a misidentification: the cards are flat quads by design (no billboard opcode; byte-matched ROM emission) and bind an opaque IA8 64×16 strip plus an alpha-blended **IA4 128×16** strip (texdump r117) that is a row of ~13 faint conifer silhouettes. The wall came from `import_texture_ia4` decoding the RDP's I2:A2 nibble as I3:A1 — A2=1 canopy pixels went fully opaque, A2=2 dithering vanished (bug inherited verbatim from the PD port). **Second pass (D266–D269) re-framed the bug: the visible wall band is CI8 32×32 gradient/blob cards, not IA at all** — two draw classes per room stream (OPA + XLU), BILERP/TEXTLOD/2-cycle/**FOG-on**; texel-alpha blending proven correct by force-alpha A/B (D268). Current ranked suspects: fog range/application (D218 family) > TEXTLOD/mip sampling > draw density/culling > palette. The I2:A2 decode + AC-aware edge fixes are re-applied in-tree (uncommitted) and still owe a game-wide IA4 regression sweep before commit. |
| **D249** | Streets and Egyptian (the two biggest/most-open levels) show culling problems on far-away geometry at *default* FOV — distant geometry drops/pops incorrectly. Also a QoL ask: improve LOD in general, but gate it behind a toggle so N64-accurate ("old school") LOD stays selectable. | **Observed, not investigated** (M-119, user QA report). Distinct from D222 (high-FOV near-edge culling) and D218 (fog/far-clip tint). Two candidate mechanisms: (a) portal/room-visibility cutting off too aggressively at range on large multi-room layouts (D233/D234 neighbourhood — check for a far-plane/depth-range sibling to D233's w<0 trivial-reject fix), or (b) GE's own LOD/detail-swap system not ported faithfully (mip-tile / simple-model substitution at distance, `gfx_lod_tile_offset` family). First checks: `GE_PCDUMP` a long sightline on Streets/Egyptian where geometry visibly pops; determine which of (a)/(b) is firing before designing the fix or the requested toggle. §F D249. |
| **D217** | 1P weapon viewmodel + character-model CI textures render with a wrong palette on many levels / multiple weapons — PP7 grip + hand render flat green on Facility & Depot, "weird or missing" gun textures elsewhere; Bond's watch face too. Surfaced by D215 (viewmodel now draws). Clean on Bunker1. | **Defect ① FIXED (M-90), pending human playtest eyeball — full detail in `findings.md` §H "D217 — M-90" and §F D217.** Root cause: weapon texpool arena bases land %8==4 on PC (memp bump drift from pointer bloat); `texAlignIndices` absolute 8-byte row padding then shifts every palette 4 bytes early while model-DL `G_LOADTLUT` reads the N64 offset (grip count=1 load lands on next tenant's header slot → `0x1A00` green; larger loads read a 2-entry zero pad — the M-86 tell). Fix: `texInitPool` rounds the base up to 8 under `#ifdef PORT` (no-op on N64); 21/21 sweep PASS. Two defects: ① **visible green = misaligned texpool arena** (fixed as above). ② **CI texture-cache collision** (no palette-content key) — proven, **fix landed on branch `fix/d217-model-texture-palette` `f6fc0399`** (palette FNV-1a hash in the CI cache key), owes a 21-level `verify.sh sweep`; does not fix ①. |
| **D174** | "No blood effect" — user reports no visible blood when shooting guards / into surfaces. | **User QA report; likely D120, unverified.** Decals: the D120 `PointUsage[]` converter fix (M-30) was never interactively verified and only takes effect after a full sidecar regen (`d43 && d69 && d88 --regen`) — check whether the playtest build had regenerated sidecars before concluding the fix failed. Spray: D172 observed the particle spray *drawing* (wrong color, magenta/cyan) on Silo — if the user sees no spray either, that's new info (different weapon/range, or the D172 decode is worse than "wrong color"). First step: BUNKER1 firefight with regenerated sidecars; if decals still absent, instrument the `chr.c:3328` decal walk. |
description: A case study in two AI coding agents porting a Nintendo 64 game to PC; the setup, timeline, handoff workflow, and an honest assessment of what worked.
---
# Agentic development: two AI coding agents porting GoldenEye 007
## Agentic development: two AI coding agents porting GoldenEye 007
*How this port was actually built: a local open-weight model (Qwen 3.8 on a
single RTX 5090) and a hosted frontier model (Claude / Claude Code) handing
D260-D265,PARTIAL,"v0.2.1 hotfix batch: D260 wheel direction swap (fixed) · D261 watch-menu nav auto-repeat (fixed, feel-check owed) · D262 watch item-preview model missing → roo…",partial — see rows
D266-D269,OPEN,"D236/D265 wall, second pass:","OPEN — probes live in tree (GE_D266/GE_D266W/GE_D267/GE_D268_FORCEA), fixes uncommitted"
D270,?,Backfilled (M-132) — this label was never given its own row; only code comments and the HANDOFF archive documented it.,dead-ended into D271/D272; infrastructure kept in tree
D271,FIXED,"D236 wall, third pass —","FIXED-but-insufficient (src/game/bg.c #ifdef PORT, uncommitted) —"
D276,OPEN,"D236 wall, eighth pass —","REFRAMED — oml=0xc81049d8/fmt=3 is the primary suspect now, not D269's CI8 pair; 0x0c184b50 unattributed; wall still open, see D236"
D279,?,"Backfilled — this label was consumed by an undocumented partial session (2026-09-14 ~23:00) that left artifacts in scratch/ (d279_run.log, d279_imgmap_run.log,…",undocumented — artifacts only
D281,OPEN,"Game.AllUnlocked (D257) still breaks audio, and can also break right-mouse aim, when enabled before any save has ever been written (v0.2.1 user testing, 2026-0…","OPEN — user-reported regression, not root-caused; mitigation is user-facing guidance only (release notes:"
D282,FIXED,"QoL ask (v0.2.1 user testing, 2026-09-15):","FIXED (port/src/input.c, #ifdef not needed —"
D283,OPEN,"Steam Deck first-run preset (D-unnumbered in port/src/video.c/main.c, videoApplySteamOSDefaults()) did not apply for a user who added the game via Steam in Des…","OPEN — mechanism understood, root cause (Desktop-vs-Game-Mode STEAMOS env var difference) unverified on real hardware; user-facing workaround documented in README/release notes."
D280,?,"D236 wall, ninth pass —","REFRAMED again — no fix; next steps in detail section, see D236"
D275,?,"D236 wall, eighth pass —","done — probes live, uncommitted; see D276 for what they found"
D274,OPEN,"D236 wall, seventh pass —","d267_slot_addr fix live (always-on); content/decode question CLOSED; mip-presence CLOSED; wall still open, see D236/D276"
@@ -118,7 +132,7 @@ D164,PROPOSED,"Disclaimer / legal screen only draws the first line (D76 root cau
D165,FIXED,"Front-end menu cursor doesn't feel like a mouse pointer (M-31, user bug B5 follow-up).","FIXED (port/src/input.c; Input.MenuPointerMode, Input.HipfirePitchSpeed added)."
D166,FIXED,Hipfire mouse pitch is digital while yaw is analog —,FIXED (port/src/input.c).
D235,OPEN,Wrong textures load after dying/restarting a level —,OPEN — needs: does it repro on a *fresh* level boot (rules out reload-state) vs only after death/restart (confirms it); which surfaces; a screenshot.
D236,OPEN,"Surface 1: 2D billboard trees render as a wall/sheet of tree texture instead of discrete camera-facing tree sprites (M-106, user QA report).",OPEN — not investigated
D236,OPEN,"Surface 1: 2D billboard trees render as a wall/sheet of tree texture instead of discrete camera-facing tree sprites (M-106, user QA report).",OPEN — ninth pass closed with no fix; see D276/D280
D237,OPEN,"QoL ask (M-106, user QA):",OPEN — not designed; port-only
D238,OPEN,"QoL ask (M-106, user QA):",OPEN — not designed; port-only (port/src/input.c + config.c)
D239,?,"QoL ask (M-106, user QA):",CLOSED — executed in the v0.2.0 prep pass (port-only):
D252,?,"Particle effects cycle through a rainbow palette (M-124, user playtest of the v0.2.0-pre candidate).",
D253,FIXED,Facility (and any level with a FORCECROUCH/LADDER linked special tile) SIGSEGV on Linux/Steam Deck —,FIXED — Deck-verified:
D254,FIXED,Linux crash handler destroyed its own logs:,FIXED — field-verified.
D255,OPEN,"Facility firefight SIGSEGV on Steam Deck (v0.2.0-pre CI bundle, D253 fix confirmed working —","OPEN — case identified (NULL model on an active OBJ record; destroy-path ordering), reproducible on Deck in combat (Bunker + Frigate); fix pending rule-#2 sign-off."
D255,?,"Facility firefight SIGSEGV on Steam Deck (v0.2.0-pre CI bundle, D253 fix confirmed working —",
D256,FIXED,Saves (and config) silently failed to persist on Linux whenever the game was not launched from the directory containing data/ —,FIXED (path half) — save-flow detail still owed from the user.
D257,FIXED,New Game.AllUnlocked option (F10; default flipped OFF for v0.2.0 —,FIXED — verified by user playtest (PR #79 merged); default flipped OFF for v0.2.0 (D259).
D259,OPEN,Game.AllUnlocked (D257) breaks audio on a fresh save:,OPEN — quirk documented; default flipped OFF for v0.2.0 (port/src/video.c); fix deferred.
D259,FIXED,Game.AllUnlocked (D257) breaks audio on a fresh save:,FIXED in v0.2.1 —…
D263,RESOLVED,v0.2.1 (user report):,"RESOLVED (not a bug), but refined (v0.2.1 further user testing, 2026-09-15) —…"
D265,?,"D236 investigation (port-layer, port/fast3d/gfx_pc.cpp):",PARKED — reverted to baseline; evidence + suspect list above.
D258,OPEN,PAL/JP asset conversion is broken at the source-data level —,"OPEN — repair path verified, not applied (out of v0.2.0 scope)."
D285,FIXED,Steam Deck SIGSEGV during D255 live-testing = SEPARATE bug:,"FIXED (M-141, src/snd.c) —"
D293,FIXED,"QoL ask: no discoverable way to quit the game from the UI (M-141, Steam Deck user playtest —","FIXED (M-141) — ""Quit to desktop"" row added to the F10 overlay; build-verified Win+Linux, not yet live-confirmed.…"
1
# GENERATED by tools_pc/gen_findings_index.py -- do not hand-edit.
@@ -438,14 +438,14 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
| D173 | **Third-person Bond model spawns too high** (user QA report): the player-representing figure at level start floats well above the ground "a lot of the time" while the actual player spawn is correct; same on the Cuba end credits — JB ~6 ft in the air above Natalya. D75 animated-model family but a *position* defect, not absence (M-33: gun-barrel Bond renders fine). Suspects: intro/credits puppet chr spawn position vs model base transform; anim root-motion accumulation (`modelSetAnimFrame2WithChrStuff`, cf. D156); packed-float `coord3d` decode in intro setup. "Player spawning OK" localises it to the render-side model, not `g_CurrentPlayer`. See `GRAPHICS-BACKLOG.md` D173. | OPEN — observed, not investigated. **M-87 user playtest: still present** — "Bond floating in the sky / cutscene glitch thing still exists." Related, newly reported: **in some cutscenes Bond spins in circles** (named instance: end of Cradle, when Trevelyan falls) — likely the same actor-position/anim-root class rather than a new bug; logged under D148/D160 (`GRAPHICS-BACKLOG.md`) since it's cutscene-triggered. **M-106 investigation (static only — no build/runtime access in this worktree, see note below):** checked the two most D209-shaped candidates for a puppet Y-position aliasing bug and RULED OUT both. (1) `chrSpawnAtPad`/`chrSpawnAtCoord`/`chrAdjustPosForSpawn` (`src/game/chraction.c:10653-10794`) — the puppet-spawn path a cutscene `guard_play_animation`-style AI cmd would use. `PadRecord.stan` (`src/bondtypes.h:1744-1751`) is a ROM-serialized *pointer* field (`StandTile *stan` at offset 0x28) — exactly the tail-pointer shape D122/D126/D132 warn about — and `tools_pc/d88_emit.py`'s `emit_pad()` zeroes it on conversion with a comment claiming it's "dead-on-load, always set before use." **Verified this claim is TRUE, not a converter bug**: every `PadRecord` literal in the N64 source asset tables (checked `assets/obseg/setup/UsetupdamZ.c:37-51`) initialises `stan` to the literal `0` — the ROM binary never carries a live pointer there on N64 either, so zeroing it on PC is byte-identical behaviour, not a regression. `chrAdjustPosForSpawn` receiving a NULL seed `StandTile*` is therefore the original game's normal case, not a port defect. (2) The anim-root-motion Y-accumulation chain (`modelTickAnim`→`modelSetAnimFrame2WithChrStuff`, `model.c:3060+`, `pos24 = pos34 + rotate(pos)` at `model.c:3322`) was already exhaustively audited for exactly this class of bug during D193/D209 (M-80/M-81) and found self-consistent, with the `padding[]`-union sweep that found D209/D210 explicitly noting `act_anim`/`act_dead` (the union arms active during a played-animation/dead puppet) are "layout-stable" (`porting-notes.md` §A, D210 entry) — i.e. that sweep already covers the cutscene-puppet actiontype, and it came back clean. So the two most probable D209-style aliasing sites for this specific symptom are both cold. **Did not find the actual root cause.** Did not get to: the *separate* Cuba end-credits case (`front.c``init_menu18_displaycast`/`constructor_menu18_displaycast`, `front.c:7821-8320`+ — a **different**, single-actor "cast roster" display model shared by the intro roster and the credits roster, using `getsuboffset`/`subcalcpos`/`cast_rootpos_smoothed` root-motion smoothing, not the in-level chr path at all) — spot-checked `getsuboffset` (`model.c:684-700`) and it is `#MATCH`-verified clean, but did not trace the smoothing math or confirm this is even the code path the "JB above Natalya" report hits (that report implies two actors on screen at once, which this single-`cast_model` code doesn't obviously support — may be a distinct, not-yet-located two-actor cutscene-puppet path). **Confidence: LOW-MEDIUM that this is the D209-style struct-aliasing family at all** — the two most likely aliasing sites check out clean, which argues *against* the shared-root-cause theory for D173 specifically (unlike D160, where the analogous check also came back clean but for a different reason — no pointer fields to alias in the first place). Next investigator: get a live Cradle (`-level_41`) capture of Trevelyan's chr during the death sequence with a position-logging probe (none shipped yet — would need a new `#ifdef PORT` env-gated trace, not added this session per the no-build-verification constraint), and separately locate the actual two-actor Cuba credits code path (grep for wherever `intro_char_table`/cast display is invoked with two simultaneous models, if it exists — not found this session). |
| D179 | **Packaged build crashes after the logos — the D43/D69 model + bg sidecars are ROM-derived and absent from any build that has no ROM (M-34, alpha-release QA).** The first `goldeneye-pc-port-*-win64.zip` from CI (bundle-win.sh) shows the Rare/Nintendo logos (compiled-in `assets/rarewarelogo.c`) then AVs at `0x6b157a88`/`0x70157a88` — symbolised: `modelPromoteNodeOffsetsToPointers` (`model.c`) ← `load_object_fill_header` (`objecthandler_2.c`) ← first prop/item model load. Root cause is **not** a code regression: the port loads PC-layout model geometry from `data/pcmodels-<region>/{pcmodels.bin,manifest.csv}` and stage bg/stan from `data/pccg-<region>/{pccg.bin,manifest.csv}`, both produced offline by `tools_pc/d43_emit.py` / `d69_emit.py` from the ROM (see `port/src/pcmodels.c` / `pccg.c`; `pcmodelsReserveSize` logs *"pcmodels.bin not found — model loads will fail"* and returns 0). CI has no ROM so it never runs the emit scripts, and the two `data/` dirs are (correctly) gitignored ROM-derived game data — **cannot be committed or shipped** (converted Nintendo/Rare geometry, DLs, collision, stan nav; = distributing assets). A local build with those dirs present runs clean (verified M-34: 1741 frames, `romdataInit ... mapped at 0x10000000`, `pcmodels: 512 sidecars`, `pccg: 73 sidecars`, no crash — so `0x10000000` is *not* the problem here). The `docs/building.md` sidecar-gen step was also missing entirely. **Fix = release-side, backlogged** (`docs/BACKLOG.md` → "Alpha release"): bundle the emit scripts + their committed inputs as a pure-stdlib-Python asset-prep tool the user runs once against their own ROM (~5 MB output, no MIPS toolchain). Secondary/latent: the fixed-address `VirtualAlloc((LPVOID)0x10000000)` in `romdata.c` has no working fallback (`"using heap copy — direct ROM reads will fail"` then limps into the same crash) — didn't bite M-34 but will on a machine where something occupies `0x10000000`; harden separately (reserve earliest in `main`, or retry low bases and derive all segment math from the base obtained). | Diagnosed, not fixed — release packaging gap + latent `romdata.c` fallback |
| D180 | **Native-PC input pass (M-XX QoL run, `qol/native-pc-input-menu`, port-only).** Three parts, all config-gated with default = prior behaviour: **(WI-1) `Input.MouseCaptureMode`** (0 = legacy always-grab; 1 = Quake-style click-to-lock) in `port/src/input.c` + `port/src/video.c` — in mode 1 the OS cursor is free until a click lands in the game window (`SDL_MOUSEBUTTONDOWN` → `inputNotifyClick`), and ESC / focus-loss / entering any front-end menu (`current_menu != MENU_RUN_STAGE`) frees it again; re-entering a stage while still "armed" re-locks so unpausing needs no click. `reconcileGrab()` runs once per controller-0 poll. Mouse buttons are suppressed from the game while the cursor is free in-stage (no phantom fire). Controller paths untouched. **(WI-2) absolute-cursor menu tracking** — when capture mode is on and the cursor is free in a menu, the D165/D169 pointer P-controller takes its target from the real cursor's absolute window position mapped onto the live virtual front-end rect (`getPlayer_c_screen*`), giving true 1:1 tracking instead of the relative-delta estimator. Relative path unchanged for legacy/grabbed. **(B3) `Input.MouseAimSpeed` default 25 → 16** (aim mode still overshot at 25 per the backlog). | LANDED, port-only, no `#ifdef PORT`. Build 241/241, `-level_09` framediff 3/3 within threshold (unregressed), `GE_STARTMENU` menu boot crash-free. **Feel-checks owed** (headless can't drive the mouse): click-to-lock ergonomics, absolute menu tracking, the new aim-speed default. |
| D181 | **`Game.ScreenShakeIntensity` — first route-(b) `src/` gameplay-cosmetic hook (M-XX QoL run).** `src/fr.c viShake()` gains a single `#ifdef PORT` line — `intensity *= portScreenShakeScale;` (extern `f32`, defined + `configRegisterFloat`'d 0.0–10.0 in `port/src/video.c`) — before the existing clamp. Default `1.0f` ⇒ exact no-op, every headless golden dump byte-unaffected; `0.0` disables explosion/effect screen shake, up to `10.0` exaggerates it. N64 build (no `-DPORT`) keeps the original line verbatim. Precedent per `docs/BACKLOG.md` "Fun features → screen-shake intensity slider" and AGENTS.md #2's documented-exception path. Same policy class as a future FOV hook. | LANDED behind config, default = original. Build green. |
| D181 | **`Game.ScreenShakeIntensity` — first route-(b) `src/` gameplay-cosmetic hook (M-XX QoL run).** `src/fr.c viShake()` gains a single `#ifdef PORT` line — `intensity *= portScreenShakeScale;` (extern `f32`, defined + `configRegisterFloat`'d 0.0–10.0 in `port/src/video.c`) — before the existing clamp. Default `1.0f` ⇒ exact no-op, every headless golden dump byte-unaffected; `0.0` disables explosion/effect screen shake, up to `10.0` exaggerates it. N64 build (no `-DPORT`) keeps the original line verbatim. Precedent per `docs/BACKLOG.md` "Fun features → screen-shake intensity slider" and AGENTS.md #2's documented-exception path. Same policy class as a future FOV hook. | **Menu row pulled (v0.2.1, user testing).** The hook is correct on its own narrow terms — `viShake()` is called only from `explosion.c`, so the slider does exactly what it says (scales explosion/effect shake) — but that's a much narrower scope than the "Screen shake" label implies to a player: it does not touch the always-on walking head-bob or any getting-shot reaction, so a user swinging it from 0 to max during ordinary play sees nothing and reasonably calls it broken. Removed from `port/src/optionsoverlay.c`'s row table until it covers all screen-shake/view-bob sources, not just explosions; config var (`portScreenShakeScale`) and the `fr.c` hook are untouched. Build green. |
| D184 | **F10 port-layer options overlay — the approach-(C) surface from `OPTIONS-MENU-PLAN.md` (M-37).** New `port/src/optionsoverlay.c` + `port/include/optionsoverlay.h`: an immediate-mode overlay drawn as its own fast3d 2D DL, appended after the game DL in `gfx_pc.cpp gfx_run()` (`optionsOverlayEmit()` → NULL when closed ⇒ **zero bytes appended, golden dumps byte-identical**). F10 toggles (`video.c videoPumpEvents`, next to F12); while open, `input.c inputComputePad(0)` returns a neutral pad and routes arrows/enter/wheel/mouse-buttons to `optionsOverlayHandleInput()` (mirrors the WI-1/D180 swallow). ESC closes; `configSave()` on close. `config.c` gains `configForEachOption()` + a `configSetOptionMeta()` label/step/enum side table (no per-key knowledge in config.c). Live knobs (VSync/FpsCap/TextureFilter via `videoRequestLiveConfig()`; mouse + screen-shake via the registered pointer) apply immediately; MSAA tagged "(restart)". v1 rows per plan §3: VSync, FpsCap, MSAA, TextureFilter, MouseAimSpeed, MouseTurnSpeed, MouseInvertY, MouseCaptureMode, ScreenShakeIntensity. | **LANDED, port-only, no `#ifdef PORT` in `src/`.** Build links 241/241. **Runtime verification NOT run this session** — the headless env kills the GUI process after ~8 s and `gfx_opengl_dump_bound_fbo` writes 0-byte PPMs here, so framediff / 60 s soak / overlay-frame eyeball are all owed on an interactive machine. Overlay-closed no-op is code-evident (emit returns NULL before touching the DL buffer). |
| D211 | **PC FOV slider — `Video.FovScale` (M-83 QoL wave, port-only, `WIDESCREEN-FOV-PLAN.md` Phase 4).** fast3d applies a multiplicative half-angle scale to every *perspective* projection matrix as it is loaded (`gfx_apply_fov_scale()` in `gfx_pc.cpp gfx_sp_matrix`, on the `G_MTX_PROJECTION | G_MTX_LOAD` path only — GE always reloads its view projection via `guPerspectiveF` at `fr.c:725`, so a relative `G_MTX_MUL` onto an already-scaled matrix cannot compound). Columns 0 and 1 are scaled by the same factor `tan(half_orig)/tan(half_adj)` → H and V FOV widen uniformly, aspect ratio exactly preserved (no stretch), and because the game rewrites fovy every frame for aim-zoom the scale composes with zoom for free. Ortho matrices (`P[3][3]==1`) are left untouched so HUD/menu 2D is unaffected. `Video.FovScale` = percent of the original vertical FOV, 50–150, **default 100 ⇒ `g_fov_scale == 1.0f` ⇒ guarded early-return, zero FP ops on the matrix ⇒ golden dumps byte-identical**. F10 overlay row "FOV scale %". Math checked offline (`s=1.25 → fovy 60°→75°`, aspect held at 1.3333). | **PARTIAL — M-84 refix: game-side FOV scale (`src/fr.c`, `#ifdef PORT`), front-end gated, user-verified.** Original M-83 note follows. LANDED, port-only; build clean; `bunker1` framediff worst_cell 12.219 vs 12.221 baseline (= render noise, no-op confirmed). **M-84 refix — the fast3d hook only scaled *pure* perspective loads; GE draws the world through `g_CurrentPlayer->field_10E0`, a projection × view matrix pre-combined on the CPU (`bondview2.c:8224`) that failed the `gfx_apply_fov_scale` guard, so only the pause/watch model + sky FOV changed (user report).** Moved the scale game-side to the `guPerspectiveF` chokepoint (`fr.c:718`, `#ifdef PORT`, `frFovY *= portFovScale`, clamp 160°), which lands *before* the CPU combine; fast3d `gfx_apply_fov_scale`/`gfx_set_fov_scale` removed; `port/src/video.c` sets `f32 portFovScale`. Also gated to `lvlGetCurrentStageToLoad() != LEVELID_TITLE` — the front end (main menu / file+mission select / briefing) renders through the same path (`lv.c lvlRender`) and its 3D is authored at a fixed FOV. **User-verified: front end unaffected + in-level view widens (Dam tunnel at high FovScale).** M-85 headless nit check (`-level_09`, FovScale 100 vs 120): **HUD-static confirmed** — the ammo counter ("7 | 93") sits at the identical bottom-right screen position in both, and the door-arrow stays square (no 4:3 stretch); viewmodel scales down + stays bottom-right anchored. **aim-zoom-compose — M-86 static analysis: correct by construction.**`bondview2.c:3123` lerps `zoominfovy` then `viSetFovY()` stores it in `g_ViBackData->fovy`; `fr.c:737` applies `frFovY = fovy * portFovScale`**once**, at the per-frame chokepoint, *after* the zoom lerp. Hipfire and zoomed states scale by the same factor so the zoom magnification ratio is preserved exactly; no double-application; the smooth zoom interpolation is unaffected (scale is post-lerp). Residual human eyeball (low risk, cosmetic) at FovScale ~120–150 + full aim-zoom: the viewmodel (`c_perspfovy`, `bondview.c:644`) and frustum-cull (`currentPlayerSetCameraScale`) use the *unscaled* fovy, so a subtle viewmodel-scale or edge-cull-early mismatch is possible; the scope reticle is 2D screen-space, unaffected. Caveat (same root): edge geometry culls a hair early at large scale (cosmetic). **User-verified good at big FovScale.** New adjacent report → **D220**: 1P muzzle flash position shifts upward at higher FovScale (Jungle, M16) — likely the same unscaled-viewmodel-fovy root as the caveat above. Wide FOV also exposes the per-level draw-distance boundary → **D218** (logged, parked). **M-87: the frustum-cull residual is a real bug, not cosmetic** → split to **D222**. |
| D212 | **Anisotropic-filtering config wiring — `Video.Anisotropy` (M-83 QoL wave, port-only).** The fast3d GL backend already implemented `set_anisotropy_level` / `get_max_anisotropy_level` and applied `GL_TEXTURE_MAX_ANISOTROPY` on mipmapped tiles with a hardcoded `current_anisotropy_level = 4`; there was no way to configure it. Added the `gfx_set_anisotropy_level()` extern-C wrapper in `gfx_pc.cpp` (clamps to `[1, GL max]`, `reset_texture_state()` so live changes take), declared in `gfx_api.h`, and `Video.Anisotropy` (1–16) in `port/src/video.c` wired through `videoApplyImageOptions()` on init + live-config. **Default 4 = the value fast3d already applied ⇒ no visual delta at defaults.** F10 overlay row "Anisotropic". | **LANDED, port-only.** Build clean. **Owed:** eyeball at 16× on a grazing surface (Depot roof / Dam walkway) vs 1×. |
| D213 | **On-screen FPS readout — `Video.DisplayFPS` (M-83 QoL wave, port-only, PD parity).**`port/src/optionsoverlay.c` samples frame time once per emitted frame (`fpsTick()` via `sysGetMicroseconds()`, ~0.5 s window) and, when enabled, emits a small top-right green "N FPS" DL even while the F10 panel is closed. Config-only knob (kept off the 13-row panel, which is at its documented layout limit — matches PD, whose `DisplayFPS` is also file-only). **Default 0 ⇒ `optionsOverlayEmit()` still returns NULL when the panel is closed ⇒ golden dumps byte-identical.** | **LANDED, port-only.** Build clean; `bunker1` no-op confirmed. **Owed:** eyeball that the counter reads a sane ~30 and doesn't overlap the HUD. |
| D214 | **Keyboard rebinding — `[Input.Bind]` config section (M-83, port-only, PD parity, Phase 4 debt).**`port/src/input.c` gains 12 gameplay actions (Forward/Back/StrafeLeft/StrafeRight/TurnLeft/TurnRight/Fire/Aim/Action/Cancel/LeanLeft/Start), each a comma-separated list of up to 4 SDL scancode names (`Input.Bind.Forward = W,Up` …). `inputRebuildBinds()` (called from `inputInit()`, after `configLoad()`) parses them via `SDL_GetScancodeFromName`; unknown names log a warning and are skipped. The old hardcoded `keyDown(ks, SDL_SCANCODE_*)` block in `inputComputePad` is replaced by `actHeld(ks, IA_*)`. Mouse buttons (fire = LMB, aim = RMB) stay hardwired; the mouse-wheel weapon-cycle A-pulse is untouched. **Defaults reproduce the previous FPS layout exactly — verified: all 20 default key names round-trip to the identical `SDL_Scancode` the old code used (`SDL_GetScancodeFromName` test, 0 mismatches).** Config-file only (no in-overlay text entry — matches PD, whose rebinding is also file-driven); a fresh `ge007.ini` gets the full `[Input.Bind]` block with the defaults visible. | **PARTIAL — M-84: code PASS (alt/ctrl); one doc-only fix.** LANDED, port-only, no `#ifdef PORT`; build clean. One doc bug fixed (no code change): `config.c` groups keys by the substring *before the last `.`*, so the section header is **`[Input.Bind]`**, not `[Bind]` — earlier finding/commit/QOL text was wrong. Working syntax: put `Fire = Left Alt` under `[Input.Bind]`, or write a fully-qualified `Input.Bind.Fire = Left Alt` line anywhere (`config.c:307`). Separately: the pause→**watch menu** does not wire Esc→back (still Enter to open/close/back) — pre-existing, `options.c`, minor. |
| D215 | **1P weapon viewmodel never drew in-level — `gunRenderFirstPersonGunModels` builds its `ModelRenderData` from a reinterpret across separately-declared adjacent globals that only works on N64 (M-83, D3x/ABI class; retires VIEWMODEL-RESEARCH.md).** The N64 line `renderdata = *(ModelRenderData *)&D_80035CC0;` (`gunfire.c`) casts the lone `u32 D_80035CC0 = 0;` to a whole `ModelRenderData` and reads on into the next declared global, `u32 D_80035CC4[] = {1, 3, 0, 0, …}` (`gun.c`) — yielding `{ basemtx=0, zbufferenabled=1, flags=3, rest=0 }`**only because the N64 linker lays the two globals out contiguously in declaration order and pointers are 4 B**. On x86-64 it breaks twice: `ModelRenderData`'s three pointer members (`basemtx`/`gdl`/`mtxlist`) widen 4→8 B so `flags` is no longer at struct offset 8, and C guarantees nothing about the two globals' relative placement. Measured (`GE_DVM=1`, BUNKER1): `renderdata.flags == 0`, so **every** weapon-model DL node was gated off at `modelRenderNodeGundl` (`(renderdata->flags & 1)`), and `subdraw()` for the weapon emitted just its `gSPSegment` — 1 gfx command instead of ~150. All three VIEWMODEL-RESEARCH.md ranked suspects were ruled out by the same probe first: `field_87F==1` (gate open), `RootNode` valid, `numRecords 22 < 192` (pool fine), node walk visits 24+ valid `DL`/`SWITCH`/`BSP` nodes. **Fix:**`#ifdef PORT` → set the three fields explicitly (`zbufferenabled=TRUE; flags=3;`), N64 line verbatim under `#else`. ABI/layout only, no logic change. | **FIXED (`src/game/gunfire.c`, `#ifdef PORT`).** Build clean. `GE_DVM=1` post-fix: `flags=0x3`, weapon `subdraw()` now emits **153** gfx commands (was 1). Regression sweep crash-free. `GE_DVM` gate+render probes left in, env-gated (cf. `GE_D193*`). **Owed:** one BUNKER1 eyeball that the gun model is actually visible + correctly posed (headless can't see pixels; the DL is now provably generated). The committed `bunker1` golden will need re-baselining — the gun now renders in-frame. |
| D216 | **`Game.SkipIntro` — boot straight to the file-select menu (M-83, port + one `#ifdef PORT` game-code line, PD parity, Phase 4 quick win).** New `s32 portSkipIntro` (`port/src/video.c`, `configRegisterInt("Game.SkipIntro", 0, 1)`), read once in `src/game/lv.c` at `LEVELID_TITLE` stage load — in the `else` of the existing `GE_STARTMENU``#ifdef PORT` block. When set it does what every intro stage's own `!is_first_time_on_main_menu` branch does (`front.c`): `is_first_time_on_main_menu = FALSE; prev_keypresses = TRUE; maybe_is_in_menu = TRUE; menu_update = MENU_FILE_SELECT;` — i.e. it reuses the game's own post-intro route rather than inventing one, so the legal screen + Nintendo/Rare/GoldenEye logo attract loop is skipped. `GE_STARTMENU` still wins when both are set. Default 0 ⇒ untouched. | **LANDED + VERIFIED this session.** Build clean. Headless: default boot frame 90 = the TWYCROSS classification screen (10% non-black), `SkipIntro=1` frame 90 = the **SELECT FILE menu** (folders/photos/cursor all render, 89% non-black), no crash across repeated boots. Screenshots captured + eyeballed. **Owed:** nothing critical — a human boot to confirm the menu is fully interactive (cursor moves, a save slot opens). |
| D216 | **`Game.SkipIntro` — boot straight to the file-select menu (M-83, port + one `#ifdef PORT` game-code line, PD parity, Phase 4 quick win).** New `s32 portSkipIntro` (`port/src/video.c`, `configRegisterInt("Game.SkipIntro", 0, 1)`), read once in `src/game/lv.c` at `LEVELID_TITLE` stage load — in the `else` of the existing `GE_STARTMENU``#ifdef PORT` block. When set it does what every intro stage's own `!is_first_time_on_main_menu` branch does (`front.c`): `is_first_time_on_main_menu = FALSE; prev_keypresses = TRUE; maybe_is_in_menu = TRUE; menu_update = MENU_FILE_SELECT;` — i.e. it reuses the game's own post-intro route rather than inventing one, so the legal screen + Nintendo/Rare/GoldenEye logo attract loop is skipped. `GE_STARTMENU` still wins when both are set. Default 0 ⇒ untouched. | **LANDED + VERIFIED this session.** Build clean. Headless: default boot frame 90 = the TWYCROSS classification screen (10% non-black), `SkipIntro=1` frame 90 = the **SELECT FILE menu** (folders/photos/cursor all render, 89% non-black), no crash across repeated boots. Screenshots captured + eyeballed. **Owed:** nothing critical — a human boot to confirm the menu is fully interactive (cursor moves, a save slot opens).**REGRESSED (v0.2.1 user testing, 2026-09-15): user reports enabling `Skip intro` breaks audio.** Not root-caused this session — leading suspect is that the intro/attract stages perform some audio-subsystem init (title/attract music kickoff, or a first-touch of the mixer) that `portSkipIntro`'s jump to `MENU_FILE_SELECT` bypasses. **Mitigation applied:** the `Game.SkipIntro` row was pulled from the F10 options overlay (`port/src/optionsoverlay.c`) so players can no longer enable it from the menu; the config var, default (0/off), and `lv.c` hook are untouched (so an existing `ge007.ini` with it already set would still trigger the bug — worth a follow-up hard-disable if that turns out to matter). Do not re-add the menu row without root-causing the audio interaction first. |
| D217 | **1P weapon viewmodel + third-person Bond model textures render garbled — wrong/stale palette on model-DL CI textures (M-84, user QA; surfaced by D215 now that the viewmodel draws).** BUNKER1/Facility: the PP7 viewmodel has yellow+blue scrambled texels and a solid **green** patch by the hammer; Bond's in-game **watch face** shows the same green mismatch; user reports the **third-person Bond world model** does this "sometimes" too. Signature matches the **D161 family** — a CI-format tile drawn without the expected TLUT state, so fast3d's `import_texture` indexes a **stale `rdp.palette`**. D161 fixed the explicit `G_TT_NONE` case; this looks like model DLs not emitting `gDPLoadTLUT` through the port path, or the palette segment not resolving for weapon/character geometry. **Not a D215 regression** — D215 only un-gated the DL. | **FIXED (M-90/M-92/M-97; PR #47 texpool alignment + PR #49 CI-cache palette key) — the green-texture defect is fixed and merged, confirmed by two independent human playtests. The white missing-texture residual (AK47 / NPC-held world-model weapons) is split out as D228.** Status cell had gone stale at OPEN; corrected M-105. History: root cause characterised M-86; headless repro: `-level_34` FACILITY ~frame 300–400, PP7 **grip = flat green block** + green ring at the barrel joint + green-cast hand; barrel/slide upper correct black. Also green on `-level_30` DEPOT. **Clean on `-level_09` BUNKER1** (A/B control — same PP7, identical DL). **Two separate defects found:** ① **Wrong palette bound (the visible green — upstream of fast3d).** The grip is a CI4 solid-fill of palette index 0. Green `(24,65,0)` == RGBA5551 `0x1A00` == `rdp.palette[0]` at grip-import time. The grip's `count=1` model `G_LOADTLUT` is fed **different source bytes per level** for the same tile: BUNKER `00 01`→`PD_BE16`→`0x0001` (black ✓), FACILITY `1a 00`→`0x1A00` (green ✗). fast3d byteswaps + stores faithfully (proven correct by BUNKER) — the wrong bytes *arrive* from the model-DL / segment-address path. Strong tell: every *larger* model TLUT has `raw[0]==raw[1]==0x0000` with real colour from index 2 ⇒ GE's model-TLUT payload likely carries a **4-byte (2-entry) leading zero pad**, OR the port's model-TLUT source pointer is **4 bytes / 2 entries too low** (then the `count=1` grip load reads the tail of the preceding CI blob — BUNKER lucky, FACILITY not; fits "widespread across levels + weapons"). Ruled out this session: not tail-overflow (index 0 is in-range; matches M-85 `GE_D217Z`), not the fast3d byteswap. **Next:** dump the grip TLUT `G_SETTIMG` target vs the ROM/`pcmodels` sidecar PP7-grip material bytes; confirm the 4-byte offset vs N64; fix in model-DL segment resolution or `tools_pc/d43_emit.py` blob placement (sidecar regen). ② **CI texture-cache collision (proven, real, separate; fix landed).**`gfx_texture_cache_lookup` (`gfx_pc.cpp:522`) keyed CI textures on `{addr, palette_addrs[0/1], palidx, size_bytes}` with **no palette-content check**; GE reissues `gDPLoadTLUT` from repeated scratch-arena addresses with different content, so a later material could take a stale HIT and sample another material's decoded palette. **Fix on branch `fix/d217-model-texture-palette` (commit `f6fc0399`):** FNV-1a hash of the 512-byte `rdp.palette` computed only in `gfx_dp_load_tlut()`, added to the CI cache key (`port/fast3d/gfx_pc.{cpp,h}`, fast3d-internal, no `#ifdef`). Cheap, cannot worsen a decode; **owes a full 21-level `verify.sh sweep`** before merge (touches every palettized world texture's import path — cache-churn risk only). Does **not** fix defect ①. Screenshots: session scratchpad `d217/`, `fac_gun_crop.png`. Cross-ref `docs/dev/GRAPHICS-BACKLOG.md`. **User playtest matrix (M-87, raw notes — not yet triaged):** DAM/PPK: watch still green; AK47 viewmodel has white (missing-texture) patches on the magazine + barrel. FACILITY: PPK garbled **green AND blue**; 3rd-person Bond world model shows greenness on level load-in; AK47 has "weird blue / glitched" textures. JUNGLE: M16 viewmodel is orange in spots. Confirms multi-weapon (PP7/AK47/M16), multi-color (green/blue/white/orange) — consistent with the upstream model-TLUT-source theory (different trailing garbage per weapon/material) rather than one single fixed offset. **Scope clarified (M-87):** not just Bond's 1P viewmodel / 3P world model — **NPC-held weapon models (other characters' guns in third person) show the same glitched-color/white-missing texture symptom.** So the defect is in the shared weapon-model-DL/TLUT path used by *any* character holding that weapon, not something specific to Bond's model or the 1P viewmodel rig — widens the likely fix surface (weapon model loading in general) beyond "Bond's gun." **M-87 clarification:** not uniform across levels — severity and glitch *type* both vary per level (some levels show it lightly, some not at all in a given session, and the specific wrong-color/missing pLine truncated
| D218 | **Wide FOV exposes the per-level draw-distance / fog boundary — "blue artifacting" down long peripheral sightlines (M-84, user QA on the D211 FOV slider).** BUNKER→**Dam main tunnel** at `FovScale` ~150: geometry / backdrop past the level's fog-end distance shows at the widened frustum edges (blue = Dam's fog tint). Not a render bug — `fogLoadCurrentEnvironment` (`bgfog.c:305`) does `viSetZRange(Visibility.BlendMultiplier, Visibility.FarFog)`, so **`Visibility.FarFog` is both the far clip plane and the fog-saturation distance**; levels tune it for the stock ~60° FOV. | **Logged (M-84), fix proposed + parked.** Plan: `Video.DrawDistance` (percent, 100–400, default 100 ⇒ no-op) `#ifdef PORT` multiplier on `FarFog` at `bgfog.c:305` (+ the direct `arg0->Visibility.FarFog` reads just below, so fog stays consistent) plus `Video.DrawDistanceAutoFov` (0/1, default 1 — couples draw distance to `FovScale` unless `DrawDistance` is set explicitly). Clamp effective multiplier ≲2.5× (small znear ⇒ far-field z-fighting risk). **M-87: user confirms still occurring** at high FOV, and — reversing the earlier "not implemented by user request" — **now wants `Video.DrawDistance` implemented as a real QoL feature**, plus scope it to cover **LOD/detail-distance in general**, not just the fog/far-clip boundary this row root-caused. Open question for that broader ask: does GE have a *separate* geometry/object LOD-swap or draw-culling distance mechanism (distinct from `Visibility.FarFog`) worth exposing too, or is fog-distance the only meaningful "how far can you see" knob in this engine? Check for a per-object visibility-radius / simple-model-swap system before assuming `FarFog` is the whole story. **FIXED as designed, awaiting visual verification (M-121).** Implemented exactly the parked plan: `f32 portDrawDistanceMultiplier(void)` (`port/src/video.c`) — an explicit `Video.DrawDistance != 100` wins outright, otherwise `Video.DrawDistanceAutoFov` (default on) couples the multiplier 1:1 to `Video.FovScale`, clamped to `[1.0, 2.5]` (never shrinks draw distance; the 2.5x ceiling is the finding's own far-field z-fighting caution). Applied in `fogLoadCurrentEnvironment` (`bgfog.c`) to a new local (`scaledFarFog`) used at *both* the `viSetZRange` far-clip call and the `g_CurFogDetails.scaled_far_fog_dist` fog-blend read just below it, so the clip plane and the fog math stay consistent per the original plan's own note — the level's `EnvironmentRecord` itself is never mutated (it's shared/reloaded, not a per-frame copy). Both new config keys added to the F10 overlay (`Video.DrawDistance` slider, `Video.DrawDistanceAutoFov` toggle) alongside the existing `FovScale` row. Identity at both defaults (`DrawDistance=100`, `FovScale=100` ⇒ `portFovScale==1.0f`) by construction. **Broader LOD-swap question from M-87 ANSWERED — yes, and now exposed too (same session, M-121 continued).**`src/game/model.c` has a real, distinct geometry-LOD system: `MODELNODE_OPCODE_LOD` nodes whose children are shown/hidden per-frame by `modelUpdateDistanceRelations()` testing a view-space `distance` (scaled by `getPlayer_c_lodscalez()`, itself now FOV-consistent per this session's D222 fix) against each node's `MinDistance`/`MaxDistance` — a genuine detail-swap mechanism, separate from the fog-visibility-fade this row already fixed. It even has an existing ground-truth multiplier hook, `g_ModelDistanceScale`, already used transiently by the game itself (`chr.c`'s 0.3125f close-up override, `front.c`/`title.c`'s menu-model `modelSetDistanceDisabled`). Rather than write to that shared global (would fight those existing overrides), added a second, composed multiplier at the same call site: `f32 portLodDistanceMultiplier(void)` (`port/src/video.c`), driven by new `Video.LodDistance` (percent, 25-400, default 100) and `Video.LodDistanceAutoFov` (**default OFF**, unlike `DrawDistanceAutoFov` — this is meant as a standalone performance lever the user can pull *down*, not something that silently adds cost as FovScale widens) — `distance *= portLodDistanceMultiplier()`, inverse-scaled so `LodDistance=200` keeps full detail twice as far (more cost) and `LodDistance=50` drops to the lower LOD twice as soon (less cost). Both new keys in the F10 overlay alongside `DrawDistance`. Identity at `LodDistance=100`. Rebuilt clean, crash-free at `LodDistance=40` (aggressive) on Dam and at defaults on bunker1. **M-121 live playtest, iterated to a working state.** Two real issues found and fixed while testing live on Dam with the user: (1) **regression** — the D222 fix (above) correctly widened `c_lodscalez` for the screen-edge cull planes, but that same value is *also* read directly by `propobj.c`'s `chrobjFogVisRangeRelated()`/`sub_GAME_7F054C58()` (the character/prop fog-visibility-fade cutoff) — uncompensated, this made guards fade/disappear *sooner* at wide FOV than before D222, the opposite of the intent. Fixed by dividing that same Line truncated
| D219 | **PPK explosions render purple on Dam (M-87, user QA, "not 3D but noting").** Effect-color defect, not a texture-decode one — likely the D172 particle-color family (`gfx_lod_tile_offset` / `G_CC_INTERFERENCE` channel-order class) recurring on a different particle type/weapon than the D172 fix covered, or a fresh instance of the same class. | **RESOLVED (M-115, 2026-09-12)** — byte-order fix in `src/game/image.c` scoped by pixel format (16-bit family bswap16-at-store, 32-bit identity); user playtest-confirmed. Superseded history below, which began: **OPEN — observed, not investigated.** Next: `GE_PCDUMP`/`GE_TEXDUMP` a Dam PPK explosion; check whether it's the same `gfx_lod_tile_offset` tile-sampling defect D172 fixed for blood, on an explosion DL D172's fix didn't touch. **Status: re-checked at runtime M-90 — mechanism covered by the D172 fix on current `main`; close pending a human by-eye pass (folds into PR #47 playtest).****M-89 triage (local-Qwen, static analysis only, not yet runtime-confirmed):** the explosion DLs (`assets/oddtextures.c``globalDL_0x078/0x120/0x1c8/0x270…`) use the same `G_CYC_2CYCLE`+`G_CC_INTERFERENCE,G_CC_MODULATEIA2`+`G_TL_TILE`, tile0=IA smoke/tile1=RGBA fire mechanism as the already-fixed D172 blood/spark records — no separate/unfixed code path found in `explosion.c` or the asset DLs. If still purple after D172's fix (which the user's Dam report postdates — needs re-confirming on current `main`), the next suspect is the GLSL combiner implementation of `G_CC_INTERFERENCE`+`G_CC_MODULATEIA2` for cycle 2 specifically (`gfx_generate_cc`, `port/fast3d/gfx_pc.cpp:312-494`), not the tile-offset fix. **Next, concrete:** run Dam (`-level_33`) with `GE_D172=1` while triggering a PPK/grenade explosion and check for "BUG" lines in the log (silence = D172's fix already covers this DL and the row can likely close on a re-test; needs a scripted-fire repro, not yet built). **M-90 runtime re-check DONE (scripted-fire repro built with `GE_INPUTSCRIPT`): the D172 fix demonstrably covers Dam's particle DLs — row can close on a human by-eye pass.** Ran `-level_33` headless with `GE_D172=1`, 64 scripted Z-pulses (`GE_INPUTSCRIPT="200:Z;215:Z;..."`, ~15 controller-reads apart) and a `GE_PCDUMP` window over the same span. Result: **40/40 particle multitex tris log `SAMPLED1=tile1 (tmem=392)`** — the correct RGBA fire tile, i.e. exactly the post-D172-fix behaviour (pre-fix these read `SAMPLED1 = tile0 (tmem=0 fmt=IA)`, the smoke texture); **zero** "BUG"/unknown-GBI/D146-abort lines; **0** purple/magenta pixels (`r>120, b>120, g<90, r-g>50, b-g>50`) across all 28 captured frames. Caveat: the user's M-87 "purple" report may predate the D172 fix on their binary (playtests run on whatever build was current); this re-check proves the sampling path is correct on current `main`, which is as far as a headless pass can take it — a final by-eye look at a live Dam PPK firefight is the honest closer (cheap for the user, folds into the D217 PR #47 playtest). **M-108 — REOPENED, the by-eye closer FAILED.** User played a live Dam (`-level_33`) PPK firefight on the current `main` build (post-D233/D234 fixes, same session as D243) and confirmed explosions are **still blue/purple**, directly contradicting M-90's clean scripted-repro result (40/40 correct-tile samples, 0 purple pixels, 28 frames). This means the M-90 `GE_INPUTSCRIPT` scripted-fire repro was very likely **not exercising the same explosion instance/DL the live firefight hits** — plausible gaps: a different explosion variant (grenade vs. bullet-impact spark vs. enemy-death vs. PPK-specific muzzle/hit effect) using a DL the scripted test's Z-pulses didn't trigger, or a state/lighting/prim-color condition (M-89's alternate suspect: the `G_CC_INTERFERENCE`+`G_CC_MODULATEIA2` cycle-2 GLSL combiner implementation, `gfx_generate_cc`, `port/fast3d/gfx_pc.cpp:312-494`) that only manifests under conditions the synthetic repro didn't reproduce. **Do not trust the M-90 all-clear for this finding going forward** — it proved one particle path clean, not that Dam's live-fire purple is fixed. Next: a live `GE_PCDUMP`/`GE_D172=1` capture during an actual purple explosion in real play (not a scripted approximation) is now the only way forward — need to pin down exactly what triggers it (PPK gunshots hitting a surface/enemy? a thrown/tossed explosive? which). **User has now confirmed (2026-09-12) this is NOT Dam-exclusive — game-wide, not just suspected.** Treat as confirmed scope, not pending a second-level check. **User wants this actively fixed, not just triaged** — next session should prioritize a live `GE_PCDUMP`/`GE_D172=1` capture during a real explosion (any level) over further static analysis, since the M-89/M-90 static passes are exhausted on this finding without producing a fix. **New HIGH-interest lead (M-110): `explosion.c` never clears `G_LIGHTING` before drawing any of the 15 particle DLs** (`globalDL_0x078` through `0Line truncated
@@ -478,6 +478,20 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
| D117 | frame nondeterminism root-caused; `GE_DETERM` deferred; `framediff.py` structural. **M-52: `GE_DETERM=1` attempted — boot deadlock fixed, determinism NOT achieved (load-phase fallback still real-time-dependent). See addendum below.** | OPEN (mode attempted, not achieved) |
| D118 | SDL input layer (`port/src/input.c`); M-24 mouse-look rework: mode-aware map (aim mode = analog stick, hipfire = digital C-pitch), `config.c` INI now real. D118b/c FIXED; D118a residual (hipfire pitch only) | resolved (D118a residual) |
| D260–D265 | v0.2.1 hotfix batch: D260 wheel direction swap (fixed) · D261 watch-menu nav auto-repeat (fixed, feel-check owed) · D262 watch item-preview model missing → root-caused to D264 (fixed at Gfx level, visual check pending) · D263 Facility Ourumov/Trevelyan beat — **resolved: not a bug**, proximity/LOS-triggered as on N64; full chain incl. execution shot verified firing on PC (teleport probe + user playtest) · D264 watch preview renderdata.flags zeroed by cross-global template read on PC (ABI/layout class; fixed) · D236/D265 Surface 1 tree "wall": IA16 importer dimensions transposed (fixed); decode attempt reverted after raw-byte proof that byte-pair is the authored image; root cause = `import_texture_ia4` decoding RDP I2:A2 as I3:A1, inverting the tree strip's alpha ramp → opaque repeating silhouettes; IA4 I3:A1→I2:A2 decode fix applied and user re-tested — **still a wall; reverted, parked** with evidence + suspect list in D265 | partial — see rows |
| D266–D269 | D236/D265 wall, second pass: **the wall is CI8 32×32, not IA** (D269) — band-gated probe finds two draw classes per room stream (OPA oml=0xc4112078 + XLU oml=0xc41049d8), both BILERP/TEXTLOD/2-cycle/**FOG-on**; cards are soft gradient blobs, stripe period ≈ one card per visible room. D266: re-applied the reverted I2:A2 IA4 decode + replaced the hardcoded 0.19 edge quantize with AC-aware handling (uncommitted; census shows zero IA4 draws at boot). D267: IA8 dump infra — the three IA8 images are noise/mast sprites, not the wall. D268: force-alpha A/B proves the blend pipeline works. Ranked suspects now: fog range/application (D218 family) > TEXTLOD/mip sampling > draw density/culling > palette | OPEN — probes live in tree (`GE_D266`/`GE_D266W`/`GE_D267`/`GE_D268_FORCEA`), fixes uncommitted |
| D270 | **Backfilled (M-132) — this label was never given its own row; only code comments and the HANDOFF archive documented it.** D236 wall, between-pass instrumentation toolkit for the fog hypothesis (D269 ranked suspect #1) and general A/B isolation: `gfx_sp_tri1` per-vertex fog min/max attribution; `GE_D270SKIP`/`GE_D270SKIPOML`/`GE_D270KEEPOML`/`GE_D270KILLALL` (drop-by-oml/fmt A/B rules); `GE_D270PIX` (single-pixel draw-order attribution); `GE_D270FOG` (per-frame fog-color-change log); and the `GE_TEXRAW` dump cap raise 400→600 to reach the wall cards' upload index. No independent conclusion of its own — superseded by D271's culling fix and D272's later per-card audit, both of which reused this toolkit's skip/keep hooks. | dead-ended into D271/D272; infrastructure kept in tree |
| D271 | **D236 wall, third pass — suspect #3 (draw density/culling) CONFIRMED as a real over-draw, and the D106 guard was the cause.** At Surface 1's boot camera, 11 portals around room 13 straddle the near plane every frame; their z==0 clip points project to ±1e20–1e26-scale FINITE screen coords. The original D106 guard (`lim=1e5`) mapped any such bound to a full-screen box — but on N64 there is no guard: those finite values flow into `bgRectIntersect` (pure comparisons), which either clamps to the parent box (portal covers view → keep) or yields an empty box (portal projects off-screen → **cull**). 9 of the 11 portals clamp empty on N64; PC was force-drawing those rooms. Fix: guard `lim` 1e5→1e38 (catches only NaN/inf, which this projection can't reach — max ~1e26); finite min>max still hits the original `degenerate` check (N64's own path, D106's real regression case unchanged). Verified: `d106Fallback` 396–781/600 ticks → **0** (Surface 1 + BUNKER1); roomsDrawn 17–18→10–13 at the boot view; per-portal oor-finite dumps confirm every culled portal's finite portion is far off-screen (N64 clamps identically); BUNKER1 room drops traced to the same N64-matching empty clamp. No crashes in long unattended runs. **USER RE-TEST (2026-09-14): NEGATIVE — trees still render as a big wall; user's read: "rather than the texture being mapped correctly"** → suspect #3 was a real over-draw but NOT the wall's root cause; fix kept for N64 fidelity. D236 thread continues — top suspect now per-card S/T / UV windowing (D265 note: all captured cards sampled the same u/v window) | FIXED-but-insufficient (`src/game/bg.c``#ifdef PORT`, uncommitted) — probes live (`GE_D271`, extended `GE_D104`); wall still open, see D236 |
| D276 | **D236 wall, eighth pass — the true painter of the visible weave, at confirmed weave pixels, is a fully-opaque IA8 blocky-noise texture sampled with a fixed, identical 2×-wrapped UV window and low fog — NOT either of D269's named CI8 classes.** Multi-point pixel attribution + per-vertex fog (D275's `GE_D270PIX` list extension) at 4 pixels picked directly off the reproduced weave screenshot found the D269 OPA/XLU classes absent at 2 of them; the dominant front-most class there (`oml` low bits coincide with the XLU class but `fmt=3`/IA, not CI8) decodes to blocky gray static, matches D267's already-dumped-but-dismissed "seq 113" texture, and reproduces D265's original "all cards sample u:0..64/v:0..16" observation to the exact texel — that lead was reframed away from prematurely in D269's second pass. A third class (`0x0c184b50`, CI8) is present everywhere with erratic z/w (`-0.04..0.99`) and high fog when it does land normally — flagged as the possible real "tree" layer, with its own likely occlusion/billboard bug, meaning D236 may have two separate root causes, not one. | REFRAMED — `oml=0xc81049d8`/`fmt=3` is the primary suspect now, not D269's CI8 pair; `0x0c184b50` unattributed; wall still open, see D236 |
| D279 | **Backfilled — this label was consumed by an undocumented partial session (2026-09-14 ~23:00) that left artifacts in `scratch/` (`d279_run.log`, `d279_imgmap_run.log`, `d279_g110.png`, `d279_g220*.png`, `d279_gray_1500.png`) but no written conclusion; the logs carry no D279-tagged lines, so its exact probe/finding cannot be reconstructed from the tree.** Next available label after it: D280. | undocumented — artifacts only |
| D281 | **`Game.AllUnlocked` (D257) still breaks audio, and can also break right-mouse aim, when enabled before any save has ever been written (v0.2.1 user testing, 2026-09-15) — a distinct regression from D259, which only fixed the all-zero-slot volume case.** User's empirical workaround: complete a level normally first (e.g. Dam on Agent, which writes a real save), THEN enable `All unlocked`; enabling it on a completely fresh install (no save yet) still reproduces both symptoms. Not root-caused this session — D259's `geEepromPatchAllCheats` all-zero-slot seeding was verified against exactly the "fresh eep" case, so either that fix doesn't cover every code path a truly-first-launch toggle can hit (e.g. a race between the F10 toggle taking effect and the very first save-slot creation/read), or there's a second, independent mechanism. The right-mouse-aim symptom is new information with no prior lead — cross-ref D211/D194 (aim-mode sensitivity/mode family) for where a save-state-dependent aim regression could originate. | OPEN — user-reported regression, not root-caused; mitigation is user-facing guidance only (release notes: don't enable until a save exists, treat as experimental). |
| D282 | **QoL ask (v0.2.1 user testing, 2026-09-15): front-end menu navigation (main menu, file select, mission-select map) currently requires the RIGHT analog stick — matches N64 default control schemes but is unintuitive on a modern controller/Steam Deck, where the left stick is the expected primary-navigation input.** Distinct from the F10 options overlay, which is already left-stick/D-pad navigable (`port/src/optionsoverlay.c`). Scope: front-end menu input in `src/game/front.c` (or wherever N64 stick reads are gated by the active `controldef`) — likely wants a port-layer input remap for these menu contexts specifically, not a general control-scheme change. **M-141: root cause found + fixed, port-only.**`front.c`'s menu-nav code reads `joyGetStickX/Y` — the same emulated N64 "Control Stick" channel (`stick_x`/`stick_y`) that in-level gameplay uses. `port/src/input.c`'s gamepad poll routes that channel to the LEFT physical stick by default, but `naturalPitchMode` (D194/D238's SOLITARE-style scheme, **default-on**) reroutes it to the RIGHT stick for continuous look — and since menus share the channel, they silently inherited that reroute too. The poll already computed an unused `padMenuMode` flag (`current_menu != GE_MENU_RUN_STAGE/GE_MENU_INVALID`, previously only gating shoulder-button weapon-cycle edges); moved it earlier and added a `padMenuMode` branch that always sources `stick_x`/`stick_y` from the left stick outside a running stage, regardless of `naturalPitchMode` — matching the F10 overlay's existing left-stick convention (`optionsoverlay.c`). Zero effect on in-level control feel (the branch is inert whenever `current_menu == GE_MENU_RUN_STAGE`). Verified: build green; `-level_09`/`-level_20` unregressed (1100+ frames each, no crash) — bare `-level_XX` launches skip the front end entirely, so this only proves the gameplay path (unaffected) stays clean. **In-menu feel not interactively verified this session** (no physical gamepad available in this environment) — needs a human playtest with a controller to confirm the menu now navigates on the left stick as expected. | FIXED (`port/src/input.c`, `#ifdef` not needed — port-layer-only file), feel unconfirmed. |
| D283 | **Steam Deck first-run preset (D-unnumbered in `port/src/video.c`/`main.c`, `videoApplySteamOSDefaults()`) did not apply for a user who added the game via Steam in Desktop Mode and switched to Game Mode afterward — resolution stayed at some non-native value instead of the Deck's native 1280×800 (v0.2.1 user testing, 2026-09-15).** Root mechanism, confirmed by code inspection: the preset only applies when `getenv("STEAMOS")` is truthy AND no `ge007.ini` exists yet (`main.c:126-129`, before `configLoad()`); once any ini exists, it always wins over the preset on every future launch (by design, so user F10 changes persist). **Leading hypothesis (not verified on real hardware in this environment):** SteamOS's Desktop Mode (a normal Plasma/X11 session) likely does not export the same `STEAMOS` env var that the Game Mode/gamescope session does, so a Desktop Mode test-launch (a natural step when adding a non-Steam game via its file browser) creates `ge007.ini` with ordinary Linux defaults *before* the user ever reaches Game Mode — permanently skipping the preset for that install, since the ini already exists on the first real Game Mode launch. This exact caveat was already known internally (`docs/HANDOFF.md`: "a Deck that already has an ini needs `-fresh` or F10 to pick the preset up") but was never surfaced in user-facing docs (README/release notes) — same class of gap as the stale D230 release-notes reference. **Workaround (lighter than `-fresh`, which also wipes the save):** F10 → Resolution → 1280×800 manually; takes seconds, no data lost. | OPEN — mechanism understood, root cause (Desktop-vs-Game-Mode `STEAMOS` env var difference) unverified on real hardware; user-facing workaround documented in README/release notes. |
| D280 | **D236 wall, ninth pass — fog math VERIFIED N64-faithful (±2 truncation over all 2505 verts of `0xc81049d8`; D276 owed step #1 closed), the IA8 noise class VISUALLY confirmed as the weave's painter (`GE_D278C` collapse: band HF 14.8→2.9, no trees appear without it — first unconfounded test), full census at both confirmed weave points (only 3 real classes; two log entries are probe artifacts), D276's depth-race read INVERTED (`0x0c184b50` tree cards are in FRONT of the noise quad, z/w smaller=closer), `0xc8102078` ruled out as trees (terrain, 1.4M tris), and `0x0c184b50` shown to barely instantiate under deterministic `-level_36` boot (~4 tris vs 720 free-run) with CPU-baked bimodal fog 25/255. **No quick win for trees** — remaining work is CPU-side instantiation/room-streaming, not a port fix. | REFRAMED again — no fix; next steps in detail section, see D236 |
| D275 | **D236 wall, eighth pass — probe upgrades:**`GE_D270PIX` extended from a single point to a semicolon-separated list (each line now tagged `pix=(x,y)` + D274's reliable `addr=`); `GE_D270KEEPOML` gained a "kept N tris" counter so a black A/B capture is diagnosable (tick-misaligned vs. a genuine render-interaction bug). Mechanical groundwork for D276; the class-isolation A/B itself is still owed. | done — probes live, uncommitted; see D276 for what they found |
| D274 | **D236 wall, seventh pass — probe identity (`seq`) proven unreliable both mid-run (cache-hit staleness) and cross-run (shared runtime-arena address reuse); fixed with an unconditional per-slot address stamp.** Resolved the prior pass's open "content vs. decode" question as a bug in this session's own offline analysis (wrong palette byte order — native/little-endian, not big-endian), not a port bug: the port's existing CI palette decode, applied correctly, reproduces a plausible soft gradient on the real, address-confirmed wall-card texture. Confirmed mips are generated (`genmip=1`) and enabled (`tex_lod=1`) for that texture, with correct `GL_REPEAT` wrap — ruling out "mips absent" as the mechanism. Attempted class-isolation A/B was inconclusive (black frame). | `d267_slot_addr` fix live (always-on); content/decode question CLOSED; mip-presence CLOSED; wall still open, see D236/D276 |
| D273 | **D236 wall, fifth pass — user-supplied N64 reference screenshot obtained (discrete tree silhouettes, not a sheet); a pre-existing, undocumented `GE_D273` palette-decode A/B probe found in the uncommitted tree was tested against it and RULED OUT.** Standard-ARGB1555 vs. inherited-shifted CI palette decode: wall pattern visually unchanged either way; unrelated CI8 textures (snow path, viewmodel skin) corrupted by the swap. Not the wall's cause; `GE_D273` stays default-off. Flags a process gap: a live investigative lead sat in the working tree undocumented across a handoff boundary. | RULED OUT (`port/fast3d/gfx_pc.cpp``palette_to_rgba32`, pre-existing/uncommitted `GE_D273`) — wall still open, see D236/D272 |
| D272 | **D236 wall, fourth pass — per-card S/T audit (HANDOFF next-step #1) RULES OUT "identical UV window" and "converter bug"; the wall cards genuinely sample a wide, per-instance-varying UV range, verified authored in ROM, not injected by `d43_emit.py`.** New `GE_D272` raw per-vertex UV+tile probe (`gfx_sp_tri1`), filtered to D269's exact two oml classes. Both are confirmed CI8 (fmt=2 siz=1, matches D269). Findings: (1) per-instance UV is **not identical** across cards sharing the same bound texture (`seq`) — small (~0.3 texel) jitter for the OPA class, larger (~1.6 texel) jitter for the XLU class — consistent with genuinely distinct authored per-vertex S/T, not a converter collapse; D265's "identical u:0..64/v:0..16" observation traces to a different (non-wall) draw class caught by the old unfiltered band gate, not these cards. (2) The XLU class's U range spans **~143–145 texels — ~4.5× the tile's 32-texel wrap period** (`mask=5,5`); the OPA class stays within roughly one tile width. (3) Read `tools_pc/d43_emit.py`'s vertex emit path (`emit_main_vtx`): `tc[0]/tc[1]` (offsets 8/10 of the 16-byte `Vtx`) go through a plain `be16` byteswap with no scale/offset — **no converter bug possible here**; the ROM must already encode this ~4.5× wrap. Reframes the leading hypothesis: this heavy horizontal wrap is by design (Rare tiling one texture across a long tree-line quad), so the "wall" comes from *how* the port renders that wrap, not from wrong UV data — TEXTLOD/mip generation and GL wrap-boundary sampling for a highly-minified, wide-wrapped CI8 tile is now the top suspect again (D269 hypothesis #2), ahead of fog/palette. | OPEN — probe `GE_D272` added to `port/fast3d/gfx_pc.cpp` (env-gated, uncommitted); wall still open, see D236 |
| D137 | right-mouse (aim-sight) crash: `gunDrawSight` (`gunfire.c:6235`) `s32 sp54` holds a `Gfx*` the whole function (`sp54 = *gdl`; `texSelect(&sp54,…)`/`display_image_at_position(&sp54,…)` take `Gfx**`). As N64 `s32` it is 4 B, so `*(Gfx**)&sp54` reads 4 B of the adjacent stack float as the pointer high word → `_g = 0x03e4fca0_70081220` → wild DL write in `texSetRenderMode` (`othermodemicrocode.c:177`) the moment the crosshair raises. §A. | FIXED (`src/game/gunfire.c``#ifdef PORT` — `sp54` is `Gfx *`) |
| D120 | **Blood-decal `PointUsage[]` chain — converter now emits it (M-30).**`d43_emit.py` reserved the opcode-0x18 `PointUsage` region in the layout pass (`add_region(puo, 2*nv)`) but the **emit pass never wrote it** — every entry stayed zero. `chr.c`'s decal walk `index = PointUsage[index]` then cycled 0→PointUsage[0]→0 forever (capped by the `#ifdef PORT` guard at `chr.c:3328`, so decals were missing/wrong rather than a true hang). Fix: `op24_pointusage[puo] = nv` in layout + a byteswap loop (`put_s16(remap(puo)+2k, be16(src, puo+2k))` for `nv` = `numVertices` entries — a negative-terminated `s16` index chain, no remap) in the emit pass. Full regen chain re-run, round-trip validation passes. The `chr.c` guard stays as a safety net (SMALL-FIXES B3). | FIXED (`tools_pc/d43_emit.py`). Interactive verify pending — blood decals on a BUNKER1 firefight should now appear + no hang. |
@@ -514,7 +528,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
| D233 | **All levels (intermittent): indoor level geometry invisible near doors — you can see through walls/floors into rooms you can't see into, while world objects (terminals, furniture) in the room still draw (M-106, user QA report; screenshots `docs/img/bugs/20260912-cullingbug-bugged.png` vs `-notbugged.png`).** The geometry-vs-object split localises it to room/portal background visibility or frustum culling in fast3d, not textures/models. Logged in `GRAPHICS-BACKLOG.md`. **Duplicate row — see the full root-cause writeup above (`gfx_sp_tri1` w<0 trivial-reject bug, `port/fast3d/gfx_pc.cpp:1455-1460`).** | **FIXED + VISUALLY VERIFIED (M-107 fix, M-108 verify).** See the other D233 row for full detail. |
| D234 | **Dam: guard towers along the dam structure invisible except the first one (M-106, user QA report).** Repeated instances → per-instance transform decode, shared model/texture resource bound only by the first instance, or cull-volume issue. Same neighbourhood as D233. Logged in `GRAPHICS-BACKLOG.md`. **Duplicate row — see the full entry above for the M-107 investigation + M-108 verification.** | **VISUALLY VERIFIED FIXED (M-108), mechanism unexplained.** See the other D234 row for detail. |
| D235 | **Wrong textures load after dying/restarting a level — not Facility-specific, seen on multiple levels (M-106, user QA report; clarified same session).** Original report ("Facility: wrong textures now") read as a build regression; user clarified the actual trigger is **death/restart** (or a similar level-reload path), and it has recurred on other levels too — this is a state-leak on level re-entry, not a one-level texture-decode bug and likely not caused by #47/#49 at all. Prime suspect: the texture pool / CI-cache (D217/D228 neighbourhood) not being fully reset between a level teardown and its reload — a stale arena base, a leftover palette-cache entry keyed by an address that gets reused with different content, or a texpool index not rewound — surfacing as "wrong texture" only after the reload path runs, never on a fresh level boot. Logged in `GRAPHICS-BACKLOG.md`. | OPEN — needs: does it repro on a *fresh* level boot (rules out reload-state) vs only after death/restart (confirms it); which surfaces; a screenshot. **M-108 static-analysis pass: the leading "stale CI-cache not reset on restart" hypothesis is likely REFUTED, don't re-propose it as-is.** The theory was that `g_TexCacheItems`/`g_TexCacheCount` (`src/game/image.c:38-40`, cleared only in `set_mt_tex_alloc()`, `src/game/initmttex.c:8`) survives a death/restart because the restart path skips `set_mt_tex_alloc`. But tracing the actual call graph: `set_mt_tex_alloc()` runs inside `lvlStageLoad()` (`src/game/lv.c:369`), which has exactly **one** call site (`src/boss.c:485`) inside `bossMainloop`'s outer `while (!done)` loop; that loop re-runs on *every* stage transition, driven by `g_StageNum = g_MainStageNum` (`boss.c:693`) which is set by `bossSetLoadedStage()` (`boss.c:722`) — the same function the front-end's level-select/continue path calls (`front.c:6947`). Nothing in this call graph is level-select-specific vs. restart-specific; both look like they go through the identical full reload (including the `set_mt_tex_alloc` cache reset). Have not yet found the actual death/restart trigger's call site to be 100% certain it doesn't have its own bypass, so this isn't fully closed out — but the single-reload-path structure makes the "cache survives restart" mechanism unlikely as stated. Next: find the actual death-handler call site (search for where a life/continue/retry decision is made, likely `chr.c`/a game-state machine, not yet located) and confirm whether it calls `bossSetLoadedStage`/goes through `bossMainloop`'s reload path same as normal transitions, or has a distinct lighter-weight path — if it's the same path, the real D235 mechanism is still unknown and this CI-cache theory should be dropped. |
| D236 | **Surface 1: 2D billboard trees render as a wall/sheet of tree texture instead of discrete camera-facing tree sprites (M-106, user QA report).**Suspects: missing billboard orientation (quads coplanar in world space) or UV/texture-rect setup mapping the whole sheet per quad. Distinct from D176(b)'s painted tree-line backdrop. Logged in `GRAPHICS-BACKLOG.md`. | OPEN — not investigated |
| D236 | **Surface 1: 2D billboard trees render as a wall/sheet of tree texture instead of discrete camera-facing tree sprites (M-106, user QA report).**Eight passes so far (D265, D266–D270, D271, D272, D273, D274, D275–D276). Ruled out: IA4/IA8 decode (as originally scoped), the blend pipeline, fog combine (as originally scoped), portal-culling over-draw (D271, real bug, fixed, not the cause), converter/UV-conversion bugs (D272, code-read clean), the CI palette bit-layout (D273/D274, tested twice against real content), "mips absent" (D274, confirmed present/enabled), "identical UV window" *for the CI8 classes* (D272). **Current leading picture (D276, M-134): the actual visible weave at confirmed weave pixels is painted by a different, previously-dismissed class — an IA8 blocky-noise texture (`oml=0xc81049d8`/`fmt=3`) sampled with a fixed, byte-identical 2×-wrapped UV window and low fog, reproducing D265's original "same window" observation exactly (D269's CI8-classes framing had reframed away from that lead prematurely).** A third, unattributed CI8 class (`0x0c184b50`) may be the reference's actual discrete-tree layer and shows an erratic z/w suggesting its own occlusion/billboard bug — D236 may split into two root causes. **Ninth pass (D280, M-135): fog math verified N64-faithful (±2 truncation over 2505 verts), the IA8 noise class visually confirmed as the weave's painter (HF 14.8→2.9 when collapsed), `0xc8102078` ruled out (terrain, 1.4M tris), and the depth-race read inverted — `0x0c184b50` tree cards are IN FRONT of the noise quad (z/w smaller=closer) yet still don't visibly win; under deterministic `-level_36` boot they barely instantiate at all (~4 tris vs 720 in a free run). No quick win: remaining work is CPU-side instantiation/room-streaming, not a port fix.** Distinct from D176(b)'s painted tree-line backdrop. Logged in `GRAPHICS-BACKLOG.md`. | OPEN — ninth pass closed with no fix; see D276/D280 |
| D237 | **QoL ask (M-106, user QA): F10 options overlay — make it scrollable with the mouse wheel, expose more settings into it (e.g. screen-flash / no-hit-flash `Game.NoHitFlash`, and other easily-exposed config keys), and organise rows by category (mouse settings together, video together, etc.).** The overlay (`port/src/optionsoverlay.c`) already renders over the registered `config.c` option list; what's missing is wheel-scroll nav, a broader row set, and grouping headers. Cross-ref `OPTIONS-MENU-PLAN.md` §3 (v1 minimum set) + QOL-INVENTORY "PD has, GE lacks". | OPEN — not designed; port-only |
| D238 | **QoL ask (M-106, user QA): mouse sensitivity rework toward normal PC-shooter feel.** Three parts: (1) a *separate* RMB aim-mode sensitivity in addition to hipfire turn; (2) ideally ONE single master mouse-sensitivity setting driving both, **linked by default**, with an option to set them independently later (today `Input.MouseAimSpeed` and `Input.MouseTurnSpeed` are two independent keys, defaults 16/100 — see `input.c:188-190`); (3) up/down should feel mostly the same as left/right (`Input.MouseYScale` exists but the axis asymmetry in the gain constants `AIM_GAIN`/`MOUSE_TURN_GAIN` + smoothing path is the likely culprit). User's reference for "feels better": GEPD Mouse Injector. Lump with the overall mouse/input fixes (D194/D165/D166 lineage; SOLITAIRE natural-pitch scheme switch may subsume part of this — see D166 M-87 note). | OPEN — not designed; port-only (`port/src/input.c` + `config.c`) |
| D239 | **QoL ask (M-106, user QA): click-to-lock vs always-grab parity.** User: make click-to-lock the default *if it isn't already* — it IS: `mouseCaptureMode = 1` is the shipped default (`input.c:185`). The real content: "always grab" feels bad on the file-select menu, not 1:1 with click-to-lock; if making them feel identical is overly complicated, **drop always-grab entirely and remove the setting**. This re-confirms the existing M-82 user decision to rip out `MouseCaptureMode=0` (see `docs/BACKLOG.md` "M-82 playtest feedback" + D192: mode 0's P-controller path can't reach the outer front-end grid cells — a known mode-0-only defect). Recommendation on this new report: take the user's fallback — remove mode 0 and the `Input.MouseCaptureMode` key, keep click-to-lock only. | CLOSED — executed in the v0.2.0 prep pass (port-only): `mouseCaptureMode` + the config key, `reconcileGrab`'s legacy branch, the `!haveAbs` relative menu path and its now-dead P-controller state (`menuEst/TgtH/V`, `menuCursorStep`, `MENU_P_GAIN`/`MENU_EST_DELTA`/`MENU_MOUSE_TO_PX`), the F10 "Mouse capture" row, and the unused `inputMouseCaptureActive()` all removed from `port/src/input.c` / `optionsoverlay.c` / `port/include/input.h`. Click-to-lock is the only mode; existing ini files setting the old key are ignored. Closes D192 with it |
@@ -532,11 +546,21 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
| D252 | **Particle effects cycle through a rainbow palette (M-124, user playtest of the v0.2.0-pre candidate).** Shooting repeatedly makes the bullet-impact "sparks" slowly progress through the full hue wheel (~10 shots to sweep it); other lingering particle effects — smoke / explosion residue — show the same rainbow colouration instead of their intended grey/orange palette. **Mechanism context (verified this session):** particle tris carry no per-primitive material; their colour comes entirely from the ROM-resident state records `globalDL_0x078 … globalDL_0xa50` (`assets/oddtextures.c`, 15 of them) replayed per particle batch (`explosion.c:894`). Each record binds a **distinct** texture pair — `IMAGE_SMOKE_N` (IA, tile 0 @ TMEM 0) ×`IMAGE_FIRE_N` (RGBA16, tile 1 @ TMEM 0x188) under `G_CC_INTERFERENCE` (TEXEL0·TEXEL1), and `explosionRenderPart` uses a fixed all-white default vertex (`explosion.c:215`) — so the intended per-stage colour is whatever those 15 fire/smoke pairs encode, and particles **age through the records** (`var_s2` 14→0 at `frame/flareanimspeed`), so a per-texture decode error surfaces exactly as a hue sweep over time / across shots. **Ruled out this session:** (a) the M-82/D172 tile-offset fix is correctly configured for this path — `gsDPSetTextureLOD(G_TL_TILE)` does NOT set the `G_TL_LOD` bit, so `rdp.tex_lod` stays false (`gfx_pc.cpp:2860`) and `gfx_lod_tile_offset` returns `i`, i.e. TEXEL1 genuinely samples tile 1; (b) the RGBA16/5551 texel decode in `import_texture_rgba16` is correct (big-endian, R=bits15-11 G=10-6 B=5-1 A=bit0). **Prime suspect: the width/height derivation in `import_texture_rgba16`** — `width = rdp.texture_tile[tile].line_size_bytes / 2`, `height = size_bytes / line_size_bytes`. The fire tiles are 16 px wide with a MASK_16 TMEM line (64 bytes), so `width` computes 32 (true: 16) and, with `size_bytes` = the LOADBLOCK's 16×14×2 = 448, `height` computes 7 (true: 14) — the GL texture would be built from the right pixels at the wrong dimensions, scrambling each of the 15 fire sprites into a different wrong-hued smear; that fits "slow rainbow progression" precisely (each aging stage = a different scrambled sprite). **Not yet confirmed** — needs one imported fire tile dumped and compared against the raw `IMAGE_FIRE_N` bytes (`GE_TEXDUMP`/`GE_PCDUMP`, or a targeted probe at the `upload_texture` call). **Per user instruction: documented only, NOT fixed for v0.2.0-pre** — ships as a known issue; pick up afterwards (cheap if the suspect holds: derive width/height from the LOADBLOCK's dtx/dlh rather than the tile line size, or special-case non-32-texel-wide RGBA16 tiles). D172/M-82 lineage. |
| D253 | **Facility (and any level with a FORCECROUCH/LADDER linked special tile) SIGSEGV on Linux/Steam Deck — `stanCheckLinkedSpecialTile` stores to a garbage `outFlags` pointer (D203 root cause).** The B-callback typedef `standTileLocusCallback_B_t` (`bondtypes.h:573`) declares the middle three params as `f32 f32 f32`, so on x86-64 SysV the caller (`sub_GAME_7F0B1DDC`, `stan.c:2228`) passes the 6th arg (`record`) in **`%rdx`** (3rd integer slot; the floats take xmm0–2). But the actual implementation `stanCheckLinkedSpecialTile` was decompiled with an all-`s32` prototype, so it reads `outFlags` from **`%r9`** (6th integer slot) — a stale scratch value left by earlier callees. On the N64 (MIPS o32) both prototypes agree: everything flows through GPRs a0–a5 in order, 6th arg = a5 either way, so the original is fine and the decompilation looked consistent. The mismatch stays dormant until a tile actually hits the FORCECROUCH or LADDER branch and *stores* through `outFlags` → SEGV; that's why it was level-specific (Facility's opening crouch tile) and input-dependent, and why idle repros were negative. The sibling B-callback `stanLocusEdgeIsAboveY` already had the correct float prototype, which is why only this path was booby-trapped. **Fix (ABI-class, semantics-preserving):** declare the three middle params of `stanCheckLinkedSpecialTile` as `f32` in both the forward decl and definition (`stan.c:152`, `stan.c:2407`) to match the typedef; the body never uses them, and on N64 o32 f32 args occupy the same GPRs, so N64 codegen is unaffected. **Verified:** gdb run on WSL (gcc 15) — pre-fix, SIGSEGV at `stan.c:2441` with `r9=0x3` and valid `record=0x409fea00` in the caller frame; post-fix, the same walk route completes with no crash. Side benefit: the FORCECROUCH/LADDER branches were effectively dead-on-arrival on PC (any hit = crash); they now function. **Also observed post-fix (separate, WSLg-only):** a segfault *inside libgallium* during `gfx_opengl_upload_texture` (32×48 RGBA + mipmaps) while walking further — GL-driver-side under WSLg's Mesa; not reproducible on Windows; park as a new finding if it recurs on real hardware. | FIXED — Deck-verified: user got past the crouch tile with the re-cut bundle (a *new*, later crash is D255). |
| D254 | **Linux crash handler destroyed its own logs: `ge007.crash.log` only ever contained the SIGABRT block, never the original SEGV.** The POSIX `crashHandler` (`port/src/crash.c`) finished by calling `sysFatalError()` → `abort()` → SIGABRT, which re-entered the *same* handler (SIGABRT was one of the installed signals); the second pass reopened the log with `"wb"` and overwrote the first pass's SEGV block — faulting PC, signal, everything. The Deck firefight crash (D255) came back as a bare `SIGNAL: 6` abort record for exactly this reason, forcing offline disassembly of the CI binary to recover what the log should have said. **Fix (port-only):** re-entrancy guard (`inCrashHandler`, second pass `_exit(128+sig)` immediately) + terminate directly with `_exit()` instead of `sysFatalError()` (the Windows path already terminated directly for the same reason) + the Linux block now dumps the faulting context: `FAULT ADDR` (`si_addr`) and the full general register set (Rax/Rcx/Rdx/Rsi/Rdi/R8–R11, Rsp/Rbp) from the `ucontext`, so a field crash now names the bad pointer on its face. **Field-verified 2026-09-13:** the D255 Deck crash came back as `SIGNAL: 11` + `FAULT ADDR: 0x10` + full register set — exactly the format designed here; it directly identified the NULL-model case (see D255). | FIXED — field-verified. |
| D255 | **Facility firefight SIGSEGV on Steam Deck (v0.2.0-pre CI bundle, D253 fix confirmed working — user got past the opening crouch tile).** Crash during gunplay; abort-unwind backtrace points at `sub_GAME_7F09BAC4` (`vtxstore.c:158`) ← `sub_GAME_7F09BBBC` (`:220`) ← `lvlManageMpGame` (`lv.c:1302`) ← `bossMainloop` (`boss.c:567`). Disassembling the exact CI binary at the fault PC (`0x200db135`) gives `mov 0x14(%rax),%edi; mov 0x10(%rdi),%rax`: the function walks the active PropRecord list keeping `type == 1` — which is **PROP_TYPE_OBJ, not CHR** (the decompiler cast to the union's first member and misnamed the field `ChrRecord.chrflags`; it is actually `ObjectRecord.model` at `0x14`) — then dereferences `model->obj`. Two port-relevant wrinkles: (a) the load is **32-bit zero-extended** because the decomp types the field through the `CHRFLAG` enum, so a model pointer ≥ 4 GB would truncate to garbage; (b) the loop skips records whose `model->obj` is NULL but *dereferences*`model` unconditionally, so an active object with a NULL/freed `model` faults — plausible in a firefight where destructibles (crates etc.) die. Not reproduced on WSL after equivalent play. **Register capture (D254 format, 2026-09-13):** two more Deck crashes — Bunker mid-firefight, then Frigate with ~3 NPCs alive; log: `SIGNAL: 11 / FAULT ADDR: 0x10 / Rax=0x701e5f54 Rdi=(nil) / PC 0x200db135`. `Rdi` = the record's model value = **exactly NULL** → case (b) confirmed: an active PROP_TYPE_OBJ record with a NULL `model`, dereferenced by the unconditional `mov 0x10(%rdi),%rax`; truncation (a) and stale/garbage pointers are ruled out. Faulting PC matches the recorded disassembly (`sub_GAME_7F09BAC4` = `vtxstore_fix_refs`). Grep-verified: nothing in game code ever writes NULL into `ObjectRecord.model` — `clear_model_obj()` nulls `model->obj` only, and `objInit`/spawn paths only activate a record once model ≠ NULL — so the NULL comes from either a fresh/uninitialized record slot being activated or a destroy-path ordering race (model freed/nulled while the record is still active this tick). **Fix constraint:** any guard lands in game code (`vtxstore.c`) → needs rule-#2 sign-off; `port/` cannot intercept this dereference. Next: (1) decision on whether a NULL-model skip in `vtxstore_fix_refs` qualifies as an ABI-class exception (semantics-preserving defensive guard, PD analogue check); (2) if yes, add it plus a `#ifdef PORT` trace at the deactivation/model-free sites to name the path that leaves the record model-less. | OPEN — case identified (NULL model on an active OBJ record; destroy-path ordering), reproducible on Deck in combat (Bunker + Frigate); fix pending rule-#2 sign-off. |
| D256 | **Saves (and config) silently failed to persist on Linux whenever the game was not launched from the directory containing `data/` — reported as "saves do not work at all" on Steam Deck.**`romdataInit` finds the ROM in `$S/` (CWD `data/`) *or*`$E/` (exe dir) *or*`./`, but `sysResolvePath`'s POSIX `$S` branch (used by `ge007.eep` and `ge007.ini`) only ever produced a CWD-relative path — its "fallback" was `./data/`, identical to the primary. Launch from any other CWD (Steam non-steam-game shortcut, terminal in `~`): ROM loads via the exe-dir candidate, game runs, but every EEPROM/config write hits `fopen(..., "wb")` on a nonexistent `./data/` and fails with a stderr-only "cannot write" — saves never persist, and **every launch also starts with default settings** (F10 changes don't stick), which presents as "draw distance/LOD is low" on levels like Dam where the user had raised `Video.DrawDistance` on another machine. The Windows branch already did CWD-else-exedir; the POSIX branch now mirrors it (`readlink("/proc/self/exe")`) and creates the directory best-effort so first-run writes don't fail. **Verified on WSL:** with exe+`data/` in `/tmp/fb2` and CWD=`/`, config/eeprom/ROM all resolve into `/tmp/fb2/data/` and a 25 s run completes clean; the pre-fix binary in the same layout fails to find the ROM at all. **Caveat:** if the user's Deck launch *was* from the bundle dir (CWD had `data/`), this fix does not explain their save failure — need the save-flow detail (does the memory-card screen appear? slot confirm? "saved" message?) before closing the report. Side note found while testing: a `data/` without the generated sidecars (`pccg-*/`, `pcmodels-*/` from `tools_pc/d69_emit.py`/`d43_emit.py`) starts fine but faults in `load_bg_file` (`bg.c:865`) on level load — the reserve functions warn "stage loads will fault" and return 0; expected degraded mode, not a new bug. | FIXED (path half) — save-flow detail still owed from the user. |
| D257 | **New `Game.AllUnlocked` option (F10; default flipped OFF for v0.2.0 — see D259): skip N64 progression entirely — all 21 solo levels selectable at every difficulty, 007 mode available, and the cheat menu fully populated. Port-layer only; no game-code edits.** Three mechanisms, all in `port/`: (1) `main.c` seeds the two live `LEFTOVERDEBUG` RAM flags after `configLoad()` — `debug_enable_all_levels_flag`(all levels) and `debug_007_unlock_flag` (007 mode; both in `src/game/debugmenu_handler.c`, same class as the existing `GE_UNLOCK_ALL` getenv hook in the getter); (2) the eep shim (`libultra.c geEepromPatchAllCheats`) patches the save block at read time — special-casing `fileValidateSaves`'s exact pattern (block 4, 5×96 bytes) — setting all 20 progression-gated cheat-unlock bits per slot (`unlocked_cheats_1/2/3`; the cheat menu row on mode-select only renders when ≥1 is set, `front.c interface_menu06_modesel`) **and** filling every *empty* stage/difficulty completion-time field with the max time 0x3FF (existing player times preserved); both are then CRC'd per slot with the game's own `fileGenerateCRC` or `fileValidateSaves` would wipe the slots. (3) `optionsoverlay.c` row so users can toggle OFF for faithful progression. **Why the completion-time half is required (found by user repro):** when ANY single-player cheat is active, `g_AppendCheatSinglePlayer` makes mission-select (`get_highest_unlocked_difficulty_for_level`, front.c) accept only levels with status `STAGESTATUS_COMPLETED (3)`, not merely `UNLOCKED (1)` — AllUnlocked's debug-flag path returns 1, so enabling a cheat locked every level ("could only do Silo"). Reading the save as a fully-completed campaign satisfies both branches. Time bit-math mirrors `fileGet/SetSaveStageDifficultyTime` verbatim (10-bit fields, difficulties 0–2; 007 times are virtual). **Pitfall hit during implementation:** the first mirror of `save_data` used `times[80]` instead of `(SP_LEVEL_MAX-1)*4 = 76`, making `sizeof` 100≠96 so the block-read size check silently never matched — now enforced by a compile-time `ge_save_slot_size_check`. **Semantics:** unlocked cheats/completions persist to `ge007.eep` on any subsequent save write (self-consistent; mirrors N64 where completing a level permanently unlocks its cheat). No *active* cheats are enabled (invincibility/all-guns stay OFF); weapons remain per-mission pickups. **Verified:** fresh eep — all 5 slots validate `stored==calc` under `GE_SAVELOG=1`; user playtest: all levels selectable at each difficulty, 007 mode present, cheat menu populated, cheats toggle and work (time-modifying cheats untested by user), mission select intact with all cheats ON. | FIXED — verified by user playtest (PR #79 merged); default flipped OFF for v0.2.0 (D259). |
| D259 | **`Game.AllUnlocked` (D257) breaks audio on a fresh save: with it ON, music and SFX load at volume 0 (silence). v0.2.0 ships with `AllUnlocked` default OFF.** User-observed during v0.2.0 artifact testing ("all unlocked seems to break sound"). **Mechanism:** a fresh `ge007.eep` is zero-filled, so every save slot is all-zero. On N64 such a slot fails `fileValidateSaves`' CRC check and is rewritten as `BLANKSAVEDATA` (`src/bondconstants.h:2148`), whose `music_vol`/`sfx_vol` are `0xFF` (max). With AllUnlocked ON, `geEepromPatchAllCheats` (`port/src/libultra.c`) patches the zeroed slots (cheat bits + completion times) and recomputes a **valid** per-slot CRC, so `fileValidateSaves` accepts them as-is and never calls `fileResetSave`. The volume bytes therefore stay 0, and `fileLoadSettingsForFolder` (`src/game/file2.c:1289-1290`) loads them verbatim: `set_mTrack2Vol((save->music_vol << 7) | (save->music_vol >> 1))` = 0 → silence. With AllUnlocked OFF the zeroed slots fail CRC and reset to `BLANKSAVEDATA`, which is why sound works in the default configuration. **Disposition for v0.2.0:** flip `portAllUnlocked` default to 0 (`port/src/video.c`; `SkipIntro` was already 0) — faithful N64 progression out of the box, F10 *All unlocked* still enables the goodie. The quirk then only affects users who deliberately toggle it ON with a fresh save (recoverable: raising the in-game volume writes a nonzero slot, after which the patch leaves volumes alone). **Deferred fix candidate (port-only, ~6 lines):** in `geEepromPatchAllCheats`, detect all-zero slots and seed `music_vol`/`sfx_vol = 0xFF` before the CRC recompute, emulating what `fileResetSave` would have done; real (nonzero) saves incl. a user-muted one stay untouched. | OPEN — quirk documented; default flipped OFF for v0.2.0 (`port/src/video.c`); fix deferred. |
| D258 | **PAL/JP asset conversion is broken at the source-data level — `scripts/filelist.e.csv` and `filelist.j.csv` carry naming defects, so `gen_romassets.py e/j` (and the d43/d69/d88 emitters' `find_row`) cannot resolve PAL/JP builds. v0.2.0's drop-in ROM feature therefore ships NTSC-U only.** Discovered while scoping issue #6 (drop-in ROM). **PAL:** 31 of the 512 model rows in `filelist.e.csv` have truncated/renamed basenames (an extraction artifact — e.g. `Pbridge_console`×6 where the resource table says `Pbridge_console1aZ…3bZ`, `Pchrgrenadelauncher` for `PchrgrenadelaunchZ`). **JP:** one font row is mislabeled — `fontBankGothic_fontchartable.bin`@3044276 is named `fontZurichBold_fontchartable.bin` (a duplicate label). Both were proven without a PAL/JP ROM: (a) greedy two-pointer sequence alignment of the 512-row `file_resource_table.inc.c` model order against each CSV's model rows gives an exact 1:1 match (481 by-name + 31 positional for PAL), offsets strictly non-decreasing, and zero size anomalies vs US (all within [0.5×, 2×]); (b) JP font row proven by the contiguity invariant — Gothic kerning @3043600+676 = 3044276 exactly hits the mislabeled row, whose size (8716) equals the US Gothic chartable, while Zurich's own chain is contiguous one row later. **Consequence:** no PAL or JP PC exe has ever been buildable from this tree (latent — only NTSC was ever exercised); the CSV `file` column is a label only (emitters look up by basename, never read the path), so the repair is a pure data fix: rename the 31 PAL model rows + 1 JP font row to the exact table symbols. **Scope decision (v0.2.0):** ship drop-in ROM support for NTSC-U only — `prepare-assets.py`/the frozen `ge007-convert` gate non-US ROMs with a clear message after SHA-1 region detection; the CSV repair + multi-region matrix + per-region exes are deferred to the Part B follow-up. | OPEN — repair path verified, not applied (out of v0.2.0 scope). |
| D255 | **Facility firefight SIGSEGV on Steam Deck (v0.2.0-pre CI bundle, D253 fix confirmed working — user got past the opening crouch tile).** Crash during gunplay; abort-unwind backtrace points at `sub_GAME_7F09BAC4` (`vtxstore.c:158`) ← `sub_GAME_7F09BBBC` (`:220`) ← `lvlManageMpGame` (`lv.c:1302`) ← `bossMainloop` (`boss.c:567`). Disassembling the exact CI binary at the fault PC (`0x200db135`) gives `mov 0x14(%rax),%edi; mov 0x10(%rdi),%rax`: the function walks the active PropRecord list keeping `type == 1` — which is **PROP_TYPE_OBJ, not CHR** (the decompiler cast to the union's first member and misnamed the field `ChrRecord.chrflags`; it is actually `ObjectRecord.model` at `0x14`) — then dereferences `model->obj`. Two port-relevant wrinkles: (a) the load is **32-bit zero-extended** because the decomp types the field through the `CHRFLAG` enum, so a model pointer ≥ 4 GB would truncate to garbage; (b) the loop skips records whose `model->obj` is NULL but *dereferences*`model` unconditionally, so an active object with a NULL/freed `model` faults — plausible in a firefight where destructibles (crates etc.) die. Not reproduced on WSL after equivalent play. **Register capture (D254 format, 2026-09-13):** two more Deck crashes — Bunker mid-firefight, then Frigate with ~3 NPCs alive; log: `SIGNAL: 11 / FAULT ADDR: 0x10 / Rax=0x701e5f54 Rdi=(nil) / PC 0x200db135`. `Rdi` = the record's model value = **exactly NULL** → case (b) confirmed: an active PROP_TYPE_OBJ record with a NULL `model`, dereferenced by the unconditional `mov 0x10(%rdi),%rax`; truncation (a) and stale/garbage pointers are ruled out. Faulting PC matches the recorded disassembly (`sub_GAME_7F09BAC4` = `vtxstore_fix_refs`). Grep-verified: nothing in game code ever writes NULL into `ObjectRecord.model` — `clear_model_obj()` nulls `model->obj` only, and `objInit`/spawn paths only activate a record once model ≠ NULL — so the NULL comes from either a fresh/uninitialized record slot being activated or a destroy-path ordering race (model freed/nulled while the record is still active this tick). **Fix constraint:** any guard lands in game code (`vtxstore.c`) → needs rule-#2 sign-off; `port/` cannot intercept this dereference. Next: (1) decision on whether a NULL-model skip in `vtxstore_fix_refs` qualifies as an ABI-class exception (semantics-preserving defensive guard, PD analogue check); (2) if yes, add it plus a `#ifdef PORT` trace at the deactivation/model-free sites to name the path that leaves the record model-less. **Corrected, deterministic repro (v0.2.1 user testing, 2026-09-15, superseding the initial report): the trigger is Facility's computer terminals — the ones you ACTIVATE (interact with, not shoot/destroy) to open a door for level progression.** User confirms this is consistent/reliably reproducible on those specific terminals, on Steam Deck **and** Linux generally (not Deck-hardware-specific). This is a much stronger lead than the original "sometimes during firefights" — a terminal activation deactivating/freeing its associated object model while the record briefly stays active this tick is fully consistent with the destroy-path-ordering theory above, just via a scripted trigger instead of combat destruction, and gives a deterministic repro instead of a probabilistic one. **Next session should reproduce via terminal activation specifically**, not generic combat — much cheaper to instrument. **M-140: added a diagnostic probe (`GE_D255`, off by default, `#ifdef PORT`) — `vtxstore.c`'s `sub_GAME_7F09BAC4` now skips-and-logs instead of dereferencing when it finds a NULL model on an active OBJ record (identity + state fields), and `propobj.c`'s `objFree` logs every free call for correlation. Attempted a live Windows repro this session (5 terminal activations across 3 Facility launches, one direct `-level_34` boot each time): zero probe hits, zero crashes — Windows does not reproduce this, consistent with the existing WSL-negative result. Two negatives (WSL, native Windows) against one positive (Linux/Steam Deck) points at something genuinely platform-specific (allocator/timing), not a Windows-reachable path — don't spend further Windows session time chasing a live repro.** The probes are harmless (env-gated, zero behavior change when unset) and committed so a Linux/Deck session can set `GE_D255=1` and get the trace directly. **User report, 2026-09-15 (same day, later, real Steam Deck hardware on the published v0.2.1 release — unverified by this session, logging as reported, not re-asserting the activation theory as wrong):** Facility crashes **consistently on terminals being destroyed** (shot), not (only) on activation. Frigate crashed once during this same testing pass, specifics not recorded. Shooting terminals elsewhere/other attempts did not reliably reproduce. This is closer to the *original* pre-M-139 framing (combat/destructibles triggering the NULL-model race) than the M-139 "corrected, acLine truncated
**ROOT-CAUSED AND FIXED, same day, live Deck session over SSH (M-140).** The `objFree`/destroy-path-race theory above was a red herring — set up a real Deck test (SSH access, key-based auth, WSL-cross-compiled local Linux build deployed and run live against the user's own prepared ROM assets), reproduced the crash directly, and symbolized it on-device against the build's own debug info. **First capture with the `GE_D255` probe active:** the probe's `ObjectRecord.model == NULL` guard did NOT fire, yet the game crashed anyway at `vtxstore.c:199` (`var_v1->RootNode`), `FAULT ADDR: 0x10`, `Rdi=(nil)` — a *different* pointer than the one the probe was checking. **Actual bug:**`sub_GAME_7F09BAC4` (`vtxstore_fix_refs`) reads the record's `Model*` through `((Model*)((ChrRecord*)prop->chr)->chrflags)`— a union pun onto `ChrRecord.chrflags`, which is typed `CHRFLAG`, a **4-byte enum**. On 32-bit N64 this is bit-exact (both the enum and a pointer are 4 bytes, so casting the enum's value to `Model*` reproduces the real stored bytes). On 64-bit PC, `Model*` is 8 bytes but the enum read is still only 4 — the cast reads just the pointer's low 32 bits as an `int` and zero-extends it back to a 64-bit address, discarding the real high bits entirely. This is the **exact D3x pointer-width ABI class** documented in AGENTS.md/porting-notes — a struct field misread across the 32→64-bit pointer-width transition — and it was *already flagged* as suspect (a) in this very row's first paragraph, back in the original v0.2.0-pre investigation, then wrongly deprioritized in favor of suspect (b) (the NULL-model theory) once a live register capture happened to show `Rdi=(nil)` and looked like it confirmed (b). It didn't: `(nil)` is exactly what a 32-bit-truncated read produces whenever the real pointer's low 32 bits happen to be zero — a coincidence that pointed the whole investigation at the wrong field for two sessions. **Proof it was the field, not the object:** this session's `GE_D255`probe read the SAME record through the *correctly*-typed `ObjectRecord.model` field (`Model*`, 8 bytes) and it was **always a valid, non-NULL pointer** — the object's model was never actually missing. **Fix (ABI-class, semantics-preserving, `#ifdef PORT`; `src/game/vtxstore.c`, `sub_GAME_7F09BAC4`):** read the pointer through `ObjectRecord.model` (the same union memory, correctly typed as `Model*`) instead of through the mistyped `ChrRecord.chrflags` alias, at both use sites in the function (the `RootNode` walk and the `modelGetNodeRwData` call). Identical bytes and identical result on N64 (both fields are 4 bytes there); correct on 64-bit PC. The earlier `GE_D255` diagnostic skip-guard (which checked the wrong field and would never have actually fired) is removed — it's no longer needed, the record's model was never really missing. **Verified live, in order:** (1) diagnostic build with the guard checking BOTH fields caught the exact moment of divergence — `ObjectRecord.model` valid, `ChrRecord.chrflags`-cast pointer producing the crash — nailing the mechanism beyond doubt; (2) the real fix (no guard, just the corrected field) held through the *entire* Facility level including multiple terminal destructions (the most consistent prior repro) and through Caverns' radio-objective terminals (the second confirmed level) with zero crashes, live on the user's own Steam Deck hardware over the ROM/assets they already had prepared. Windows rebuild + full 21-level `verify.sh sweep` also run this session as the standard regression gate. | **FIXED** — root-caused (D3x-class 32-bit pointer truncation via a mistyped enum-to-pointer cast, not a destroy-path race) and verified live on real Steam Deck hardware across both confirmed-repro levels (Facility, Caverns), zero crashes; Windows sweep clean. The separate audio-thread crash (D285) is untouched by this fix. |
| D256 | **Saves (and config) silently failed to persist on Linux whenever the game was not launched from the directory containing `data/` — reported as "saves do not…** — full `## D256` entry at file tail | FIXED (path half) — save-flow detail still owed from the user. |
| D257 | **New `Game.AllUnlocked` option (F10; default flipped OFF for v0.2.0 — see D259): skip N64 progression entirely — all 21 solo levels selectable at every…** — full `## D257` entry at file tail | FIXED — verified by user playtest (PR #79 merged); default flipped OFF for v0.2.0 (D259). |
| D259 | **`Game.AllUnlocked` (D257) breaks audio on a fresh save: with it ON, music and SFX load at volume 0 (silence). v0.2.0 ships with `AllUnlocked` default OFF.** — full `## D259` entry at file tail | **FIXED in v0.2.1** —… |
| D260 | **v0.2.1 (user request): mouse-wheel weapon-select direction swapped.** — full `## D260` entry at file tail | FIXED — build green; one-line branch swap, trivially reviewable. |
| D261 | **v0.2.1 (user request: "fix watch menu sensitivity for wasd/controller buttons"): holding W/S (or a fully-pressed stick axis) on the watch inventory page…** — full `## D261` entry at file tail | FIXED — feel-check received; W/S sensitivity follow-up logged above. |
| D262 | **v0.2.1 (user report): "3D models missing on watch menu screen item select" — the selected-item 3D preview does not appear on the watch inventory page.** — full `## D262` entry at file tail | FIXED (primary cause, D264) — pixel-level confirmation pending user run. |
| D264 | **D262's root cause (ABI/layout class, cf. D140/D52): the watch item preview's `ModelRenderData` template is read across two adjacent globals that no longer sit…** — full `## D264` entry at file tail | FIXED — build green, probes removed; visual confirmation pending. |
| D263 | **v0.2.1 (user report): Facility — "Ourumov not shooting Trevelyan"; the scripted opening beat never triggers. RESOLVED: not a port bug — the beat is…** — full `## D263` entry at file tail | RESOLVED (not a bug), but refined (v0.2.1 further user testing, 2026-09-15) —… |
| D265 | **D236 investigation (port-layer, `port/fast3d/gfx_pc.cpp`): the tree cards are IA8/IA4, not IA16 — and `import_texture_ia4` decoded the RDP's I2:A2 texel as…** — full `## D265` entry at file tail | PARKED — reverted to baseline; evidence + suspect list above. |
| D258 | **PAL/JP asset conversion is broken at the source-data level — `scripts/filelist.e.csv` and `filelist.j.csv` carry naming defects, so `gen_romassets.py e/j` (and…** — full `## D258` entry at file tail | OPEN — repair path verified, not applied (out of v0.2.0 scope). |
| D285 | **Steam Deck SIGSEGV during D255 live-testing = SEPARATE bug: the `sndHandleEvent` preempt-scan walks the active-sound list unlocked (M-140; fixed M-141).** — full `## D285` entry at file tail | **FIXED (M-141, `src/snd.c`)** — `osSetIntMask` lock added around the preempt-scan, matching `sndSetupSound`'s existing unconditional (no `#ifdef PORT`) pattern in the same file; build green, `-level_09`/`-level_20` unregressed (1400+ frames, no crash, no D152 lock-stuck warnings). Not yet gdb/live-confirmed against a real reproduced crash — see M-141 body. Do not conflate with D255 — different thread, different subsystem. |
| D293 | **QoL ask: no discoverable way to quit the game from the UI (M-141, Steam Deck user playtest — "couldn't see a way to exit the game. Maybe build this into the…** — full `## D293` entry at file tail | FIXED (M-141) — "Quit to desktop" row added to the F10 overlay; build-verified Win+Linux, not yet live-confirmed.… |
Phase 2 replaced the Phase-1 demo loop with the real `mainproc()` on real OS
threads, compiled GE's real `src/sched.c`, and brought in PD's fast3d software
@@ -9900,3 +9924,228 @@ Useful control: the AK47 is not a level-start weapon on Facility, so its
textures load later than the PP7's — compare against a level where the AK47
*is* an early/starting weapon. If it renders clean there, load ordering /
pool exhaustion is implicated directly. D217 · D161 lineage.
## D266 — M-125: re-applied the two reverted D265 port fixes (I2:A2 IA4 decode + AC-aware edge handling); new tri-time census shows **zero IA4 draws from the boot camera** — the D265 "IA4 tree strip" class does not reproduce at the wall
Re-applied, on top of the committed baseline: (a) `import_texture_ia4` I3:A1→I2:A2 decode (`intensity = part>>2`, `alpha = part&3`, new `SCALE_2_8`) — hardware-correct RDP IA4 layout, necessary per D265; (b) `gfx_opengl.cpp` edge path: the inherited hardcoded `if (texel.a > 0.19) texel.a = 1.0; else discard;` replaced with alphacompare-keyed semantics — AC_DITHER (`opt_noise`) → noise-dithered discard; AC_THRESHOLD → discard only fully-transparent; AC_NONE → discard below 0.5, survivors keep alpha for blending (the old line forced every survivor opaque, defeating ALPHA_CVG_SEL); the plain `opt_alpha && opt_noise` dither line is now gated off when edge is also set. `gfx_pc.cpp`: `texture_edge` no longer set for AC_DECAL tiles. Both are port-only; identical latent lines exist in the PD port (PD's art never exercises them — PD has **no billboards at all**, full audit this session).
New env-gated census probe (`GE_D266`, in `gfx_sp_tri1`) logging per-draw tile fmt/line-width/RM class for alpha-bearing draws. **Decisive result: over 1.5M tris from the Surface 1 boot camera, ZERO IA4 draws.** The dominant alpha classes are IA8 line=32B/64B XLU 2-cycle cards. D265's census (class (b) = "IA4 128×16 tree strip") either came from a different camera position or was misattributed — the visible wall band is not drawn by IA4 at boot (see D269 for what actually draws it).
**State: both fixes remain in-tree, UNCOMMITTED, with the probes.** They are necessary-but-unverified for the wall (the wall turned out to be a different texture class); the I2:A2 decode still owes a game-wide IA4 regression sweep before any commit. Nothing user-visible changed at the wall — consistent with D265's negative re-test.
## D267 — M-126: IA8 dump probe + per-tile-slot upload-sequence tracking — the three IA8 images in play are an opaque noise card and two sparse mast sprites; none of them is the wall band
Probe infra (`GE_D267`, `gfx_pc.cpp`): PPM dumper + alpha histogram for 32px-wide IA8 strips in `import_texture_ia8`; per-tile-slot upload sequence tracking (`d267_seq` counter + `d267_slot_img[128]` stamped in the `import_texture()` dispatch) so tri-time probes can name which uploaded image a draw uses. Caveat discovered: `gfx_texture_cache_lookup()` returns early **before** the stamp on cache hits (key = fmt+addr+size+pal), so a draw's logged imgseq can be stale — the actual image is the earlier import that populated the cache (traceable via the GE_TEXI census log).
Results (level_36, 15 s): three distinct IA8 images — seq 113 (32×32, **fully opaque**, 1023/1024 texels at A=F; blocky noise pattern), seq 165 (32×64, sparse mast/pole sprite: 1344 transparent / 592 opaque / ~112 mid-alpha), seq 170 (32×64, another sparse sprite). Dumps in `scratch/d267_ia8_tile0_seq*.ppm`. None of these matches the horizon stripe band → the wall is not an IA8 card either. Combined with D266: **the entire IA-focused framing of D236/D265 was examining the wrong texture class.**
## D268 — M-127: force-alpha A/B test proves the texel-alpha blend pipeline works end-to-end — the wall is NOT a broken blend path
`GE_D268_FORCEA=0|15` in `import_texture_ia8` forces every IA8 texel alpha to 0 (transparent) or F (opaque). Three captures at frame 730 (`scratch/d268/{normal,alpha0,alphaF}_730.ppm`): the horizon band region vanishes with alpha=0 and becomes a solid bright block with alpha=15. The GL blend state, texel-alpha path, and shader generation all composite correctly — whatever renders the wall does so with *correct* blending of *something*. (Side note: the user's 9/14 BSOD 0x119 was HWiNFO64/nvml.dll, not the game — game log clean.)
## D269 — M-128: **the wall is CI8 32×32, not IA at all** — band-gated all-format probe identifies two draw classes per room stream (OPA + XLU), both BILERP/LOD/2-cycle/**FOG-on**; the cards are soft gradient blobs, and the stripe period ≈ one card per visible room
`GE_D266W` v4 (`gfx_sp_tri1`, env-gated): tracks **all** draw classes whose projected vertices fall in the top 3–9% of the viewport (the measured stripe band), logging fmt/line-width/oml/omh/zmode/cyc/blender/imgseq + first-vertex screen position. Results per room stream (imgseq cycling ~174→179→264→269→282…, shared ROM addresses 0x70173840 / 0x70173de8):
- **Class A (OPA):** CI8 32px, oml=0xc4112078, omh=0x0099ac20, first vertex screen(1.30, 0.07) — off-screen right
- **Class B (XLU):** CI8 32px, oml=0xc41049d8 (m1=CLR_IN·A_IN / m2=CLR_MEM·1MA = standard straight-alpha), omh=0x0099ac60, zmode=XLU, first vertex screen(0.61, 0.08)
**omh decode (GE gbi.h field layout: TEXTFILT@12, TEXTLUT@14, TEXTLOD@16, TEXTPERSP@19, CYCLETYPE@20):** both classes are **BILERP + TEXTLOD-on + perspective-corrected + 2-cycle**, and **bit 13 = FOG enabled**. The band is ~19 repeats across a 1957px frame (period ≈101px) ≈ one card per visible room in the row.
Texture content (`GE_TEXDUMP`/`GE_TEXRAW` + offline decode of `texdump/r*.bin`): the CI8 cards are **soft vertical gradients / sparse blobs** (index histograms concentrated at low indices, e.g. idx 6–14; ASCII renders show gradients and small clumps, not tree silhouettes). NCC correlation of every dumped card against the frame-730 capture at scales 1/2/4: nothing above 0.35 — the cards are not visible as rigid copies (consistent with bilerp magnification + fog wash).
**Reframed hypotheses, ranked:**
1. **Fog.** Both classes have FOG on. On N64 these distant cards are presumably washed toward the sky colour into a smooth pale band; if the PC's fog range (`rsp.fog_mul`/`fog_offset`; per-vertex fog computed at `gfx_pc.cpp:1591`, `fog_z = z*winv*rsp.fog_mul + rsp.fog_offset`) or its shader application differs, the raw dark gradient cards show through as hard stripes. D218 family ("fog/far-clip tint"). **Cheapest test:** extend the band probe to log per-vertex fog for class A/B tris (if vFog≈1 but screen shows dark → fog not applied in shader; if vFog small → range mismatch), or env-gate a fog disable and compare.
2. **TEXTLOD / mipmap sampling.** N64 samples mip levels when minifying; check whether the port generates GL mipmaps for these tiles (tex_lod path) and which min/mag filter is used — and first measure the *projected quad extent* (the probe only logs one vertex; if cards are minified rather than magnified, mips matter directly).
3. **Draw density / portal culling.** PC may draw cards from rooms N64 culls → denser wall. Compare total band-tri count vs expected visible-room count.
4. **Palette (D217 family).** GE_TEXRAW now also writes `.pal` (512 B = 256 RGB555) for CI formats; the aborted run never produced them. Decode class A/B cards as RGB and check colours against the N64 ref (`docs/img/bugs/d227-surface-emulator-ref.png`: smooth bright band + organic clusters centred x≈0.35–0.75).
**Owed next (concrete):** (1) re-run `rm -rf texdump && mkdir texdump && GE_TEXDUMP=1 GE_TEXRAW=1 GE_D267=1 timeout 15 ./build-pc/ge007.x86_64.exe -level_36` and confirm `.pal` files land; (2) decode `texdump/r173..r282_f2_*` CI8 cards + palettes offline to RGB PNGs for the user's eyeball (this model cannot view images — programmatic analysis only; a vision-capable session could do the N64-ref comparison directly); (3) add projected quad extent (min/max screen x over the 3 vertices) + per-vertex fog to the `GE_D266W` band probe — one more run decides hypothesis 1 vs 2.
## D271 — M-129: **the wall, third pass — suspect #3 (draw density / portal culling) confirmed as a real over-draw; the D106 guard was the cause.** Near-plane-straddling portals produce huge *finite* bounds that N64 clamps/culls in `bgRectIntersect`; the PC guard mapped them all to full screen, force-drawing whole rooms N64 never submits. Guard narrowed to non-finite-only; culling now matches N64
**Context.** D269's ranked suspects for the Surface 1 tree wall ended with "#3 draw density / portal culling — PC may draw cards from rooms N64 culls → denser wall". This pass tests exactly that, using the boot camera (`-level_36`), where the wall is most prominent and the scene is (nearly) static.
**Instrumentation (`#ifdef PORT`, `src/game/bg.c`, all env-gated, kept in tree).**
- `GE_D271` in `sub_GAME_7F0B5864` (the portal-bounds builder): per-point capture of every clip point's `z` and its `transform3Dto2DWithZScaling` projection; on each D106-guard hit, dumps the portal number, connected rooms, flags, scale, and all points (`D271 d106 …` / `D271 ptN …` lines). A second probe logs **finite** out-of-range bounds that now flow through to `bgRectIntersect` (`D271 oor-finite …`, budget 8) so their clamp outcome can be checked against N64 semantics.
- `GE_D104` print line extended with `hasPortals=… ordDegen=… d106Fallback=…` — the two counters separate the *ordinary* degenerate case (genuinely edge-on portal, min>max on a real axis — expected, N64 does the same) from the D106-fallback case.
**Findings.**
1. It is not one portal: at the Surface 1 boot camera, **11 different portals around room 13** straddle the camera near plane every frame (a clip point lands exactly at z==0; `transform3Dto2DWithZScaling`'s `inv_z = -1e20f` sends it to ±1e20–1e26-scale screen coords). The D106 guard (`lim=1e5`) fired on **every frame** (~396–781 hits per 600-tick print window) — occlusion culling was effectively defeated for that viewpoint.
2. **The N64 semantics the guard got wrong:** there is no such guard in the N64 code. The huge values are *finite*, and `bgRectIntersect` is pure min/max comparisons: a bound pair that brackets the screen clamps to the parent/screen box (portal covers the view → room kept); a pair whose finite portion falls off-screen yields an **empty** box (room culled). The original guard collapsed both cases to "full screen" — correct for the first, **over-inclusive for the second**.
3. Per-portal check of the over-included case (`D271 oor-finite`): every culled portal's finite portion is far off-screen — e.g. Surface 1 p54 (r22) min.x=997 (>319, entirely right), p68 (r8) max=(-282,-391) (entirely left/below), p73 (r14) min.x=500; BUNKER1 p28 (r25) y-range 339.85..2.9e24 (entirely below the 10..230 screen). With identical f32 values N64 produces the identical empty clamp → **N64 culls these rooms from these viewpoints; the old guard was drawing them.**
**Fix (one constant, `src/game/bg.c``sub_GAME_7F0B5864`, `#ifdef PORT`).** D106 guard `lim`: `100000.0f` → `1e38f`. Rationale: this projection can reach ~1e26 at most, so 1e38 catches only non-finite values (NaN/inf), which are genuinely unsafe to let through (NaN poisons the downstream comparisons). Finite out-of-range bounds now flow into `bgRectIntersect` exactly as on N64. The finite min>max case is untouched — it still hits the original `degenerate` check (the N64 code path itself), so D106's actual regression (BUNKER1 sky-fill voids from *discarded* portals) stays fixed.
**Verification.**
- `d106Fallback`: ~396–781/600 ticks → **0** on both Surface 1 (`-level_36`) and BUNKER1 (`-level_09`); `ordDegen` unchanged (ordinary edge-on portals still map to full screen, as on N64).
- roomsDrawn at the Surface 1 boot view: **17–18 → 10–13**; per-room boxes now narrowed correctly (e.g. r8 = right sliver `246,10-319,230`, r12 full-screen via a genuine bracketing clamp) instead of ~18 full-screen boxes.
- BUNKER1: roomsDrawn 4→3 (r6 state, lost r5) and 3→1–2 (r29 state, lost r25/r28). Traced per portal above — the losses are the N64-matching empty clamps, i.e. moves *toward* N64 fidelity, not regressions; the D106 void case (discarded min>max portals) is untouched.
- Long unattended runs (`timeout 90+`): no crashes on either level; intro camera progresses normally to later states (curRoom=18, roomsDrawn=4).
- **Pixel-level A/B — inconclusive, owed to the user.** Frame captures at matched `curRoom` state (frame 1500, room-13 state: `scratch/d271_1500_sbs.png`, baseline left / fixed right) show a 14.7% changed-pixel difference — but a same-build/same-state pair differs by 14.8% with an *identical* spatial grid (top-heavy band = animation/camera micro-motion at this viewpoint, not the fix). The boot view is not static enough for programmatic A/B; the user's eyeball on the tree wall is the deciding check.
**OUTCOME (user re-test 2026-09-14): NEGATIVE.** With the fix in place the trees still render as one big wall — and the user's read is that **the texture itself isn't being mapped correctly**, not merely an over-draw/alpha problem. So suspect #3 was a real N64-divergent over-draw (kept — it is a fidelity fix, do not revert) but it is NOT the wall's root cause. The D265 note gains weight: *"all captured cards sampled the same u:0..64/v:0..16 window — confirm against N64"* — if every card samples the same region of a shared texture, the result is exactly a repeating sheet. Top suspect for the next pass: **per-card S/T coordinates / UV windowing** (model DL vertex S/T, tile setup foldS/foldT/tcShift, or `d43_emit.py`'s S/T conversion), then TEXTLOD/mip sampling, then palette.
**Owed / next.**
1. **Per-card S/T audit** — dump the actual vertex S/T + tile for each wall card at draw time (extend `GE_D266W`) and check whether distinct cards carry distinct S/T windows; compare against the N64 ref (`docs/img/bugs/d227-surface-emulator-ref.png`: smooth bright band + sparse organic clusters, not a continuous sheet).
2. Regression sweep of other levels' room counts is cheap insurance (`GE_D104=1` spot-checks); BUNKER1 already re-verified above.
3. Probes are budget-limited and env-gated; keep until the user check lands, then remove with the D266–D269 probe set if the wall closes.
## D272 — M-130: **the wall, fourth pass — per-card S/T audit (D271's owed next-step #1).** Distinct cards carry distinct (not identical) UV windows; the XLU class wraps its CI8 tile ~4.5× horizontally; traced to genuinely-authored ROM vertex data, not a `d43_emit.py` conversion bug — hypothesis shifts back to TEXTLOD/mip handling of a heavily-wrapped, highly-minified tile
**Context.** D271's fix (portal-culling guard) was verified but the user's re-test was negative: "trees are a big wall rather than the texture being mapped correctly." HANDOFF's next-steps for this thread ranked **per-card S/T / UV windowing** as the top suspect, driven by a D265 note that "all captured cards sampled the same u:0..64/v:0..16 window." This pass builds the audit HANDOFF asked for and answers the decisive question it posed: do distinct card instances carry distinct S/T windows, or identical ones?
**Instrumentation (`#ifdef PORT`, `port/fast3d/gfx_pc.cpp`, `gfx_sp_tri1`, env-gated `GE_D272`, budget 200 lines, kept in tree).** A new raw per-triangle dump, filtered to D269's exact two wall-card classes by low-24-bit `other_mode_l` (`0x112078` OPA / `0x1049d8` XLU — matching regardless of the top-byte pipeline bits, which vary between otherwise-identical-looking draws sharing the same blend equation). For each matching tri inside the measured wall band, logs: `oml`/`omh`, the bound texture's `seq` (image identity), tile `fmt`/`siz`/`line`/`width`×`height`/`mask`/`shift`, the tile's own UV window (U10.2 → texels, `/4`), and all three vertices' UV (vertex ST is s10.5 → texels, `/32`).
**Findings.**
1. **Both classes confirmed CI8** (`fmt=2 siz=1`), matching D269 exactly — the earlier unfiltered run of this same probe (before the oml filter was narrowed) picked up an unrelated `fmt=3` (IA) class sharing the same low-24-bit blend bits under a different top byte; that class is NOT one of D269's wall cards and is excluded by construction once the filter checks the full pair of known `oml` values.
2. **UV windows are NOT identical across instances of the same class/texture.** OPA class (`seq=4`, 8 samples): first-vertex U ranges 5.25→5.56 (spread ≈0.3 texel), consistent quad shape (u≈0..32, v≈0..11) each time but never byte-identical. XLU class (`seq=168/169`, multiple samples): U range spans ~143.16–144.81 (spread ≈1.6 texel) across different physical card instances. This is genuine per-vertex variation, not a converter collapsing every card onto one hardcoded window — **D265's "identical window" observation did not come from these cards**; it most likely came from a different draw class caught by the old unfiltered/undifferentiated band probe (D266W has no oml filter and this screen band is crossed by several unrelated classes — see the `fmt=3`/`oml=0x0f0a4000`/`0x00504340` families logged in this session's first (unfiltered) D272 run, several of which *do* show identical `(0,0)` UV across every instance — degenerate/filler geometry, not wall cards).
3. **The XLU class's U range is ~143–145 texels wide — ~4.5× its own tile's 32-texel wrap period** (`mask=5` ⇒ `1<<5=32`). The OPA class stays within roughly one tile width (u≈0..32). This is a large, consistent multi-wrap pattern, not noise.
4. **Traced to `tools_pc/d43_emit.py`'s vertex emit path — no converter bug found.**`emit_main_vtx` (the function that writes every model's `Vtx` array) copies `tc[0]`/`tc[1]` (the S/T fields, offsets 8/10 of the 16-byte N64 `Vtx`) via `put_s16(q+8, be16(src, o+8))` / `put_s16(q+10, be16(src, o+0xA))` — a plain big-endian→little-endian 16-bit byteswap, **no scale, no offset, no reinterpretation**. Every other field in the same struct (`ob[3]`, `flag`, `cn[4]`) goes through the identical plain-copy pattern. There is no code path here that could inflate, rescale, or corrupt a small authored S/T into a ~4.5×-wrap value — the ~144-texel figure must already exist in the ROM's ilbytes at that vertex. **This rules out the converter as the source of the wide wrap** (cf. the D108–D112 `put_f32` byte-reversal precedent this thread was checking against — that bug class is not present here).
**Reframed hypothesis (supersedes D271's "top suspect").** Since the wide UV wrap is real, ROM-authored data (Rare's own design: tile one CI8 texture ~4.5× across a long background quad), the wall is not a windowing/converter bug. The candidate mechanism shifts to *how* the port renders a heavily-wrapped, heavily-minified (TEXTLOD-on) tile: GL's `GL_REPEAT` + whatever mip level(s) OpenGL selects/generates for this tile vs. the RDP's own per-scanline TMEM address wrap + LOD blending. D269's hypothesis #2 (TEXTLOD/mip sampling) is now the leading suspect again, ahead of fog range/application and palette.
**Owed / next.**
1. Measure the actual on-screen minification factor for these cards (projected quad extent vs. tile texel count) — if the quad is only a handful of screen pixels wide while sampling 144+ texels, GL's automatic mip selection is likely picking a coarse level, and any difference from the RDP's own LOD blend range would show up exactly as "hard repeating pattern" vs. "soft distant band."
2. Check whether `glGenerateMipmap` (`gfx_opengl.cpp:748`) is actually invoked for these specific CI8+`GL_REPEAT` tiles, and whether the generated mip chain wraps correctly at the tile's `1<<mask` period rather than at the raw 32×32 upload (should be identical for a power-of-two tile, but confirm — this is the one open question RC3/D167 didn't fully close).
3. Cross-check against the N64 reference (`docs/img/bugs/d227-surface-emulator-ref.png` / `mupen64/`) to confirm whether the intended look really is "soft blended repeats" (mip-driven) rather than a different UV entirely — do not assume without a direct look.
4. Probe is budget-limited (200 lines) and env-gated (`GE_D272`); keep until the wall closes, then remove with the D266–D272 probe set.
## D273 — M-131: **N64 ground-truth screenshot obtained; palette-decode A/B (pre-existing, undocumented `GE_D273` probe found in the uncommitted tree) tested against it and RULED OUT** — the standard ARGB1555 palette layout does not change the wall's appearance at all, but corrupts unrelated CI8 textures (path, viewmodel skin tone)
**Context.** The user supplied two reference screenshots (`docs/img/screenshots/gepd-surface.PNG` — 1964 N64 emulator, GEPD edition ROM, same Surface 1 corridor; `docs/img/screenshots/port-surface.PNG` — our PC port, same location) for a direct side-by-side the thread had been missing (D271/D272 both flagged "no direct N64 look yet" as an open risk). **N64 reference: discrete conifer silhouettes (trunk+canopy visible) lining both sides of a snow corridor, blending softly into a dusk sky — sparse, not a sheet.****Our port: a continuous, uniform, fine-grained diagonal herringbone/houndstooth weave covering the same wall area, flat-topped, no tree silhouettes at all** (`scratch/d273_base_1500.png`, `-level_36` frame 1500, matches the user's `port-surface.PNG` framing).
**Also found this session (not written up by whoever added it): an existing, uncommitted `GE_D273` A/B probe already in `port/fast3d/gfx_pc.cpp`'s `palette_to_rgba32`** (env-gated, default off, part of the pre-session diff — not authored this session, and not mentioned in HANDOFF.md or findings.md before now). Its comment hypothesizes the inherited PD `RGBA16`/CI-palette decode (`a=bit0, r=15:11, g=10:6, b=5:1`) is one bit off the N64 hardware `ARGB1555` TLUT layout (`a=15, r=14:10, g=9:5, b=4:0`) that GE's RSP ucode actually uses for CI palette expansion, and that for one sampled wall-card palette entry (`0x0011`) this flips the AC_NONE alpha-test outcome from discard (sparse, N64-correct) to keep (opaque sheet, the wall bug) — a plausible, concrete mechanism, matching the HANDOFF's long-parked "side suspect: palette" line, but never actually tested against a live frame.
**Test performed this session.** Captured matched-tick frames (`GE_PCDUMP=1500-1500`, `-level_36`) with and without `GE_D273=1`.
- **Wall region: visually unchanged.** Same herringbone pattern, same density, same silhouette (none). A pixel diff of the wall crop shows a non-trivial mean delta (~28/255), but this is consistent with D271's already-documented run-to-run boot-camera/room-streaming jitter (~14–15% baseline noise between two *identical*-build runs at this same viewpoint) — not a content change; eyeballing both frames side by side shows the identical weave.
- **Everything else got worse.** The snow path (also CI8-textured) turned into a garish cyan/magenta moiré, and the first-person hand/viewmodel skin tone rendered teal-green instead of flesh tone — both regressions exactly where you'd expect if the "standard" ARGB1555 reading is in fact the wrong one for these particular palette sources, or the reinterpretation is simply not selective enough (the probe applies unconditionally to every CI4/CI8 palette lookup, `ignoring palette_fmt` per its own comment, which is a blunt instrument for testing one specific texture's TLUT).
**Conclusion.** The palette-bit-layout swap is **not** the wall's root cause: it does not visibly touch the wall at all, while breaking unrelated known-good CI8 content. `GE_D273` should stay default-off; do not enable it or pursue this specific decode-layout change further for the wall. The underlying question the probe's comment raised (is GE's RSP CI-palette expansion really "standard" ARGB1555 regardless of `palette_fmt`, per `gmain.s`) may still be worth resolving someday for its own sake, but it is decoupled from D236 now.
**Process note.** This probe existed in the working tree with a full, specific, plausible root-cause theory and was never mentioned in `docs/HANDOFF.md`'s CURRENT TASK or `docs/dev/findings.md` — a live investigative lead sat undocumented across at least one handoff boundary. Whenever picking up an in-flight investigation, `git diff` the suspect files (not just what the day's handoff narrates) before assuming the handoff is a complete inventory of uncommitted work.
**Owed / next.**
1. Get an actual reference capture from `mupen64/` (this session used a third-party 1964-based build the user had on hand, GEPD edition — a second, known-clean N64 emulator source would strengthen the ground truth) for a pixel-level comparison, not just an eyeball read.
2. D272's TEXTLOD/mip hypothesis is still the live lead — this session only closed off the palette branch. Proceed with D272's owed next-steps (minification measurement, confirm `glGenerateMipmap` actually fires for this tile).
3. Given the N64 reference shows genuinely discrete tree shapes (not merely "softer" versions of the same repeating pattern), also keep open the possibility that the port is sampling entirely wrong texture content for this tile (not just filtering it differently) — the herringbone weave does not resemble D269's offline-decoded "soft gradient / sparse blob" content at all, which is itself a discrepancy worth reconciling before committing further to the mip-filtering theory.
## D274 — M-132: **seq/addr identity is unreliable across runs (both are runtime-arena, not stable ROM, identifiers) — fixed with an unconditional per-slot address stamp; the "content vs. decode" discrepancy D273 flagged is RESOLVED as an offline-analysis bug in this session, not a port bug — mips confirmed generated + enabled for the real wall-card tile, narrowing the live suspect to mip-blend *quality*, not presence**
**Context.** An external review of the D273 write-up (this session) flagged that all three passes analyzing "the wall cards" (D269, D272, and a `gfx_pc.cpp` code comment) reported different `seq` ranges, and correctly identified the cause: `d267_slot_img[tile]` (D267) is stamped in `import_texture()`*after* the texture-cache-hit early return (`gfx_texture_cache_lookup`, line 1131) — on a cache hit the stamp is never refreshed, so it can read back the identity of whatever last triggered a real upload into that TMEM slot, not the texture actually bound for the current draw. The review recommended anchoring identity on the ROM source address instead.
**Fix (`port/fast3d/gfx_pc.cpp`, `#ifdef PORT`).** Added `d267_slot_addr[128]`, a companion array stamped **unconditionally**, immediately after `orig_addr` is known and *before* the cache-lookup early return — so it always reflects the real source address of whatever is currently bound to that tile slot, cache hit or miss. Threaded into every D272-family log line (`D272[...]`, `D272S`, `D272U`, `D272P`) as `addr=%p`.
**This fix immediately surfaced a second, independent identity hazard: the runtime address itself is not a stable cross-run identifier either.** Two hard confirmations from live runs against the same build/level:
1. **Same `seq`, different `addr`, same run.** Isolating the XLU wall class alone (`GE_D270KEEPOML=1049d8 GE_D272=1`), `seq=65` was logged against **two different addresses** (`0x701587c8`×161, `0x70155738`×17) in one 15 s capture — direct proof the seq stamp is stale mid-run, not just across runs.
2. **Same `addr`, different declared format, different run.**`0x70155738` (one of the two addresses `seq=65` produced) showed up in a *separate* run (with `GE_TEXDUMP=1`) as `GE_TEXI[4] fmt=3 siz=1 tile=54x54` (IA8) — a completely different format/size than the CI8 32×32 the tri probe recorded for it moments earlier in a different run. GE's texture/model pipeline decodes into a shared runtime arena (the M-88 lesson already in `docs/porting-notes.md`: "GE's model and image pipelines resolve almost everything through small numeric IDs into shared, runtime-decoded pools"); load-order timing (already documented as non-deterministic across runs by D271) evidently reuses the same arena address for different content run-to-run. **Neither `seq` nor the raw runtime `addr` is a stable identifier across separate process runs — only within a single run, and even then only outside a cache-hit window for `seq`.** D269's recorded "shared ROM addresses 0x70173840/0x70173de8" should be read as *that run's* addresses, not persistent identifiers to search for in a fresh capture.
**Practical consequence for this pass:** identify and decode the real wall-card texture **within one run**, correlating `D272U`'s `addr=` (confirmed CI8, confirmed actively drawn as a wall card in that same run) against that same run's `GE_TEXI[N]`/`texdump/rNNN_*.bin(.pal)` dump by address match, not by `seq`. Done: `GE_TEXDUMP=1 GE_TEXRAW=1 GE_D270KEEPOML=1049d8 GE_D272=1` in one combined pass (per the review's efficiency suggestion) → `GE_TEXI[11] addr=0x701587c8 fmt=2 siz=1 tile=32x32`, matching `D272U`'s live ci8-upload log for the same address in the same run → `texdump/r011_f2_s1_32x32.bin(.pal)` is unambiguously the real, currently-drawn wall-card texture.
**Decoding it exposed a bug in this session's own offline analysis, not a port bug.** First decode attempt (`struct.unpack('>256H', ...)` — big-endian) produced multi-hued rainbow noise under *both* candidate palette layouts (port's shifted decode and the standard ARGB1555 from D273) — which looked like a genuine new "wrong content" finding until traced back: `rdp.palette` is an in-process `uint16_t[256]` array, and `GE_TEXRAW`'s `.pal` dump (`fwrite(rdp.palette, 1, 512, pf)`) writes it as **raw native-endian bytes** — little-endian on x86-64, not the big-endian the ROM's own on-disk TLUT would be. Re-decoding with `'<256H'` (little-endian) immediately produced a coherent image under the port's existing default (shifted) decode: a smooth gray/brown diagonal gradient wedge, closely matching D269's original "soft vertical gradients" description. The **same ARGB1555 alternate that D273 already ruled out for the live scene** also decodes this specific real texture into a broken, speckled result (mostly-transparent with scattered noise along the edges) — a second, independent confirmation that D273's conclusion was correct and this session's earlier "content vs. decode" open question (from the D273/D236-sixth-pass HANDOFF entry) is now **resolved: there is no decode discrepancy.** The port's existing palette decode is correct for this texture; D269's "soft gradient" description was accurate.
**Mip/LOD state confirmed nominal for the real tile** (closes D272's owed next-step #2 for this specific texture): `D272U[4] ci8 upload 32x32 genmip=1` (mip generation *is* requested at upload) and `D272S ... addr=0x701587c8 ... cms=0 cmt=0 tex_lod=1` (LOD/mip sampling *is* enabled at draw time; `cms=cmt=0` = `G_TX_NOMIRROR|G_TX_WRAP` = `GL_REPEAT`, as expected for the ~4.5× wrap D272 measured). **Rules out "mips are simply missing/disabled" as the mechanism.** The remaining candidate narrows to *quality*, not *presence*: whether GL's automatic trilinear mip chain, sampling a small (32×32) diagonal-gradient tile repeated ~4.5× within a small screen-space quad, actually reproduces the RDP's explicit `TEXTLOD`/lod-frac blend closely enough — a smooth gradient wedge tiled many times, if under-blended at the wrap seams, is a plausible mechanism for a regular diagonal herringbone/houndstooth artifact (the wedge's own diagonal edge, repeated, is exactly that shape).
**Attempted, inconclusive: the two-class moiré A/B** the review also asked for (render with `GE_D270KEEPOML` isolating first the OPA class, then the XLU class, to see whether the weave is a single-class tiling artifact or an interference beat between the two overlapping classes). `GE_D270KEEPOML=112078` at the boot-camera frame 1500 produced a **fully black frame** — not just "wall cards missing," *nothing* drew, including geometry that should be unrelated to the wall (this needs its own triage: either the low-24 match genuinely excludes everything in view at this exact tick, or the keep-list interacts with something upstream of the wall class in a way that blanks the frame). Not investigated further this pass; flagged as owed, not blocking — the palette/mip findings above stand independent of it.
**Owed / next.**
1. **Debug the `GE_D270KEEPOML` all-black result** before relying on it — log which `oml` values actually cross the screen at the target tick (the existing unfiltered `GE_D266W` can do this) and confirm `112078`/`1049d8` are really present, then redo the class-isolation A/B.
2. **Mip-blend quality, not presence, is now the question.** Compare a forced-coarse-mip render (LOD bias, or temporarily forcing `GL_NEAREST_MIPMAP_NEAREST`) against the current trilinear result at the wall — if a coarser/blurrier mip visibly smooths the weave into something closer to the N64 reference's soft band, that confirms the mechanism and points at LOD selection/bias tuning as the fix; if it doesn't change materially, mips are a dead end and the remaining candidates are GL_REPEAT-wrap seam continuity across mip levels, or something in how the two overlapping (OPA+XLU) classes composite.
3. Regenerate `docs/dev/findings-index.csv` (`tools_pc/gen_findings_index.py`) — done this pass, but re-run again after any further findings.md edits.
4. **Process note, separate from the palette-decode-byte-order bug above**: `D270` (the skip/keep-oml hooks, the `D270P`/`D270KEEPOML`/`D270SKIPOML` machinery, and the `tdc` cap raise 400→600) has never had its own findings.md row — only code comments and HANDOFF-archive mentions. Same undocumented-lead class D273 flagged. A one-line backfill row is owed whenever someone next touches that thread; not done here to stay focused on D236. **Now backfilled as its own index row, see below.**
## D275 — M-133: probe upgrades for the eighth pass — `GE_D270PIX` extended from one point to a list, `GE_D270KEEPOML` gained a "kept N tris" counter (an external review's step 1/2 groundwork)
**Context.** A second external review of the D274 write-up proposed a four-step eighth pass: (1) multi-point pixel attribution + fog state inside the weave to name the actual front-most painter and close D269's never-done "log per-vertex fog" step; (2) an aligned class-isolation A/B with a visible "kept" counter so a black capture can be told apart from a genuine render bug; (3) an LOD-quality A/B; (4) stay alert to the framing risk that the confirmed wall-card content (a gradient wedge, D274) cannot by itself explain the N64 reference's discrete conifer silhouettes — a second, unattributed draw class is likely responsible for those, and D236 may split into two separate defects. This entry covers the mechanical probe work for steps 1–2; D276 covers what step 1 actually found.
**Changes (`port/fast3d/gfx_pc.cpp`, `#ifdef PORT`, both still env-gated/opt-in).**
- `GE_D270PIX="x0,y0[;x1,y1;...]"` (was single-point `"x,y"`): up to 8 normalized screen points, semicolon-separated. Each `D270P[ptN][...]` line now also carries `pix=(x,y)` (which point) and `addr=%p` (D274's reliable per-slot address, not `seq`).
- `GE_D270KEEPOML`: was silent. Now logs `D270KEEPOML: kept tri #N (oml24=0x...)` for the first 4 kept tris and every 5000th thereafter — a black capture with a nonzero kept-count is a genuine render-interaction bug; a black capture with `kept==0` at that tick is tick misalignment or an empty keep-set, not a probe failure.
**Offline groundwork before the live run:** picked 4 candidate weave pixels directly from `scratch/d273_base_1500.png` (the confirmed reproduction of the user's reported wall) by eyeball + sampled RGB to confirm they land in the gray weave band, not the snow floor or the black letterbox bars: `(0.05,0.10)`, `(0.25,0.15)`, `(0.85,0.12)`, `(0.50,0.06)` (the last one turned out to be sky, not wall — a miss, harmless, see D276).
**Attempted, still open:** the class-isolation A/B itself (review's step 2) was not completed this pass — D276's step-1 finding reframed the whole "which class is the wall" question first, which the review itself said should gate everything else ("step 1 first because it's the only step that can invalidate the current framing"). Re-run step 2 with the *correct* class identified in D276, not the D269-named OPA/XLU pair.
## D276 — M-134: **the true painter of the visible weave, at the confirmed weave pixels, is a fully-opaque IA8 blocky-noise/static texture (`oml=0xc81049d8` low bits, `fmt=3 siz=1`) sampled with a fixed, identical UV window and comparatively low fog — NOT either of D269's named CI8 classes, which don't even appear at two of the four sampled points.** D265's very first "all cards sample the same u:0..64/v:0..16 window" observation matches this class exactly; D269 reframed away from it prematurely.
**Method.** Ran the D275-extended `GE_D270PIX` at the 4 confirmed weave pixels with tick-aligned captures (`GE_PCDUMP=1300-1700:25`, 17 frames, all visually confirmed non-black/weave-consistent — ruling out tick misalignment as an explanation this time), logging every triangle covering each point across the whole 35 s run, then grouped by `oml` and summarized `z/w` and fog ranges per point offline.
**Findings.**
1. **At the two points confirmed inside the weave band** (`(0.05,0.10)`, `(0.25,0.15)`), **neither D269-named class (`0xc4112078` OPA, `0xc41049d8` XLU) appears at all.** The dominant, front-most (`z/w≈0.98–0.998`, excluding the sky quad's construction-mandated `z/w=0`), consistently-drawn class is `oml=0xc81049d8` (note: **low bits `0x1049d8` match the XLU class's low-24 bits, but the full oml/top-byte and — critically — `fmt=3` (IA) differ**; this is the same false-friend class D272/M-130 already had to filter out of its budget) with **average fog 39.5–63.3 out of 255** — mostly-unfogged, so its raw texel color reaches the screen largely unmodified.
2. **Decoded that exact texture within the same run** (`GE_TEXI[106] addr=0x70118d68 fmt=3 siz=1 tile=32x32` → `texdump/r106_f3_s1_32x32.bin`, IA8: high nibble intensity, low nibble alpha per `import_texture_ia8`): **a fully-opaque (alpha nibble mostly 0xf), high-frequency blocky gray static/noise pattern** — visually nothing like D274's gradient wedge, and nothing like a tree. This is almost certainly the same texture D267 already dumped and dismissed ("seq 113, 32×32, fully opaque, 1023/1024 texels at A=F, blocky noise pattern... none of these matches the horizon stripe band") — D267's dismissal was a single-instance visual judgement; it did not test what this content looks like *tiled*.
3. **Every single instance of this class, across every screen position sampled in an earlier unfiltered capture (`scratch/d274_run.log`), uses the byte-identical UV window**`u:0..64, v:0..16` (a 2× horizontal wrap of the 32-wide tile, sampling only its top half vertically) — **this is D265's original "all captured cards sampled the same u:0..64/v:0..16 window" observation, verified to the exact texel, and it belongs to this IA8 noise class, not the CI8 classes D269 and D272 spent three passes on.** D269's second-pass reframing away from D265's own lead was premature: it correctly proved the CI8 classes it found don't share one window, but never established those CI8 classes were the ones D265 was describing in the first place.
4. **A third class, `oml=0x0c184b50` (CI8, `fmt=2 siz=1`) — the "C-cards" the reviewing pass explicitly flagged as unattributed and a candidate for the N64 reference's actual discrete tree silhouettes — is present at every sampled point, with `z/w` ranging wildly from `-0.0381` to `0.9940`** (most other classes stay in a tight 0.96–1.00 band). Where it does land in the normal distant-geometry range, its fog is **high** (up to 255, i.e. fully fog-washed — plausible tree-fading-into-haze behavior) — but the erratic near-camera/behind-camera excursions suggest it is not reliably reaching the screen where it should, consistent with the review's flagged risk: a second, separate class may be responsible for the reference's actual tree shapes, and it may be missing or occluded rather than merely mis-filtered.
**Reframed conclusion.** The visible "wall/weave" the user reported is most directly explained by: a tiled, high-frequency, mostly-opaque IA8 noise texture (2× UV wrap, fixed window, low fog) reaching the screen essentially raw. D274's gradient-wedge CI8 texture was real and correctly analyzed, but is very likely a secondary/different layer, not the one producing the dominant visual artifact. The `0x0c184b50` CI8 class remains an open, unattributed thread that may be the reference's actual "trees" and may itself have a missing/occlusion bug — do not assume D236 has one root cause.
**Not yet done:** confirm this IA8 class's own UV-wrap-vs-fog behavior against the N64 reference directly (does N64 also wrap this noise 2× with low fog, or should the fog be much higher here, matching the `0c184b50` class's 193–255 range, which would wash it to near-invisible as intended); the class-isolation A/B (D275's still-owed step 2) should now isolate `0xc81049d8`/`fmt=3` vs. `0x0c184b50`, not the D269 CI8 pair. The mip-blend-quality experiment (D274's owed next-step, this pass's proposed step 3) is now lower priority — the newly-identified content (blocky noise, not a smooth gradient) makes a filtering-quality explanation less necessary than it seemed against D274's wedge; a wrong/insufficient fog blend is now at least as plausible a single-mechanism explanation and is cheaper to test (it's already logged, no new probe needed).
**Owed / next.**
1. **Fog-correctness check for the `0xc81049d8`/IA8 class**, now that D269's owed "log per-vertex fog" step is finally done: is `39.5–63.3` average fog the *correct* N64 value for this vertex position (compute what `fog_mul`/`fog_offset`/world-z *should* give and compare), or is it a computation bug specific to this draw path? If N64's own fog math would also produce a low value here, the wall is arguably N64-faithful in isolation and the real bug is elsewhere (e.g. the `0c184b50` occluder not painting over it); if the port's fog is wrong, that is a direct, cheap fix.
2. **Attribute and fix-scope the `0x0c184b50` CI8 class's erratic `z/w`** (`-0.0381` to `0.9940` at a single screen point) — likely candidate for a billboard/orientation bug (camera-facing quad computed with a bad or momentarily-invalid basis), separate from the noise-texture fog question. If this class is meant to draw the discrete tree shapes, understand why it doesn't reliably reach these pixels before concluding it's simply "the wrong layer."
3. Re-run the class-isolation A/B (D275) on the newly-identified classes, with the D275 kept-counter, at the D275-established tick-aligned window (`GE_PCDUMP=1300-1700:25`) so a black frame is diagnosable.
4. Regenerate `docs/dev/findings-index.csv` after this edit (done).
## D280 — M-135: **D236 wall, ninth pass — fog math VERIFIED N64-faithful (owed step #1 closed), the noise class VISUALLY confirmed as the weave's painter, and the "trees" question reframed: `0x0c184b50` is in FRONT of the noise quad (depth-race read inverted), its fog is CPU-baked bimodal 25/255, and it barely draws at all under deterministic `-level_36` boot — no quick win for trees; the remaining work is CPU-side instantiation/room-streaming, not a port fix**
1. **Fog math verified (D276 owed step #1, closed).** Computed the expected per-vertex fog from the game's own constants: RSP fog ramp 0..154 → `fmul=32000`, `foff=-31744`; the noise class's z/w ∈ [0.992, 1.0] maps to expected fog ≈ 26–28. Compared against the port's actual per-vertex fog for all **2505 vertices** of `0xc81049d8`: mean abs deviation ±2 (integer truncation only). D276's "low fog" is therefore the *correct* N64 value — on hardware this quad would also reach the screen largely unfogged. **Fog is not a bug; no port-side fix exists here.**
2. **A/B visual confirmation of the painter (D276 owed step #3, closed).**`GE_D278C=1` (collapse `0xc81049d8` to a point) + `GE_PCDUMP=1500-1500:1`: the weave band's high-frequency energy drops **14.8 → 2.9**, mean 108 → 121; the band becomes a smooth bright haze (the fully-fogged CI8 cards behind it). The pixel-attribution-only evidence from D276 is now corroborated visually — `0xc81049d8`/IA8 is definitively what paints the visible weave. **No trees appeared with it removed** — the first clean test of that question, unconfounded by the noise quad (all prior A/Bs, D268/D273, were behind it).
3. **Full class census at the confirmed weave pixels** (`(0.05,0.10)`, `(0.25,0.15)`): only three real classes reach either point — `0xc81049d8` fmt=3 IA8 (the noise painter), `0x0c184b50` fmt=2 CI8 (720 tris; fog bimodal 25/255, **gfog=0** → CPU-baked per-vertex fog), and `0xc8102078` fmt=2 CI8 (69 tris). Two further "classes" in the logs are probe artifacts: `0x00552048` (z/w=0, dl=1 placeholder) and `0x00504340` (z/w=-1, behind camera).
4. **Depth-race correction — D276's "occluder" framing was inverted.** N64 z/w: *smaller = closer*. At pt0, **278/356**`0x0c184b50` tris are CLOSER than the nearest noise tri (z/w < 0.9908); at pt1, 310/356. The tree cards are IN FRONT of the noise quad, not behind it. The open question is therefore "why don't the closer textured cards show?", not "does the noise win the depth race?".
5. **`0xc8102078` ruled out as a tree layer.** `-level_36` + `GE_D270KEEPOML=c8102078`: **1.4M triangles** fill the lower half of the screen (y ≥ 0.35) — terrain/ground, not conifers.
6. **The tree class barely draws under deterministic boot.**`-level_36` + `GE_D270KEEPOML=0c184b50` + `GE_TEXRAW`: ~3–4 tris/frame at tick 1500, zero visible pixels, empty `texdump/` (no CI8 upload). Meanwhile the non-`-level_36` fog run logged 720 covering tris. **`0x0c184b50`'s instantiation is strongly run-to-run dependent and `-level_36` does not pin it** — the room-streaming state that feeds this class varies even with the boot flag.
**Conclusion: no quick win for trees.** The visible weave is the IA8 noise class (N64-faithful fog, confirmed painter — arguably N64-faithful *in isolation*). The discrete conifers of the reference come from `0x0c184b50`, which (a) barely instantiates in deterministic runs and (b), when it does instantiate, sits in front of the noise yet still doesn't visibly win. Making trees appear requires understanding this class's CPU-side placement/instantiation (room streaming / billboard emission) — multi-step game-logic investigation, not a port-layer fix.
**Owed / next.**
1. Single instrumented run: `-level_36` + `GE_D270PIX` (both weave points) + `GE_TEXRAW` + `GE_PCDUMP=1400-1600:1`; correlate per tick — does a fog=25 `0x0c184b50` tri cover the point AND is the pixel dark?
2. Log `curRoom`/`roomsDrawn` (D271 infra) alongside `GE_D270PIX` to correlate `0x0c184b50` presence with room-streaming state.
3. Decode `0x0c184b50`'s CI8 texture offline — needs a run in which it actually uploads (TEXRAW has not captured it yet).
## D285 — M-140: a real Steam Deck SIGSEGV during D255 live-testing turned out to be a SEPARATE, previously-undocumented bug — an unprotected shared-list read in `sndHandleEvent`'s preempt-scan (`src/snd.c:366`), classic D4-class main-thread-vs-audio-thread race, case identified but NOT fixed
**Context.** While setting up a live Deck test session for D255 (SSH access, `GE_D255=1`), found a fresh `ge007.crash.log` on the user's Deck (mtime inside the user's active testing window, confirmed by the user as "any crashes from the last hour or two on that build is my testing") from the **published v0.2.1 binary**, which still had its own debug symbols (`with debug_info, not stripped`). Symbolized directly on-device with `addr2line` against the exact crashing binary — not a rebuild, not a guess:
**This is a different crash from D255.** D255's known signature is `FAULT ADDR: 0x10`, PC inside `vtxstore_fix_refs` (render/game-logic thread, via `bossMainloop`). This one is `FAULT ADDR: 0x62`, entirely inside the **audio thread** (`portThreadWrapper` → `amMain`), a different subsystem and a different thread. The user's testing session (explosions/terminal-destroy repro) apparently hit *this* bug at least once, not necessarily D255 — the two may have been conflated in earlier reports because both surface as a SIGSEGV during the same kind of combat/explosion testing.
**Root cause (static, code-read only — not yet gdb-confirmed on a live crash).** `src/snd.c:366` is `iterState->unk3e` inside `sndHandleEvent`'s pool-exhaustion preempt-scan:
`D_800243E4` (`struct D_800243E4_s D_800243E4 = { {NULL, NULL}, NULL };`) is the sentinel head of the active-`ALSoundState` linked list; `node.prev` is genuinely `NULL` when the list is empty (verified: `sndDisposeSound`, `snd.c:868-875`, resets `node.next`/`node.prev` to `NULL`-adjacent state as the list empties; `sndSetupSound`, `snd.c:802-824`, is the only inserter, always under a lock — see below). This do-while dereferences `iterState`**before** its own `!= NULL` check ever runs (do-while always executes the body once) — safe *only* if the caller can guarantee the list is non-empty. The scan is gated on `limitReached` (the 8-voice pool is full), which *should* imply at least 8 live nodes — so a `NULL` here means the list state and the pool-full accounting have desynced.
**Prime suspect: unprotected concurrent access, the established D4/D147/D152 race class** (`docs/porting-notes.md` §D4 — "N64 `osSetIntMask(OS_IM_NONE)` is their only mutual-exclusion primitive; on PC the 'audio interrupt' is a real preemptible thread, so a no-op shim = no mutual exclusion = concurrent linked-list mutation" — already fixed generally via a process-wide recursive mutex, D4/D152). Checked directly this session: `sndSetupSound` (the `D_800243E4` list-insert path, reached via `sndPlaySfx`, which **15 different `src/game/*.c` files call directly** including `explosion.c` — i.e. from whatever thread the caller runs on, not queued through the audio thread) wraps its list mutation in `osSetIntMask(OS_IM_NONE)`/restore (`snd.c:807,827`). **`sndHandleEvent`'s entire body (`snd.c:263-668`) — including the preempt-scan that reads/walks/mutates the same `D_800243E4` list — never calls `osSetIntMask` at all.** A writer (`sndSetupSound`, potentially running on the main game thread via a direct `sndPlaySfx` call) is lock-protected; this reader (`sndHandleEvent`, running on the audio thread) is not — a classic asymmetric-locking gap, exactly the shape that lets one thread observe another's list mutation mid-update.
**Fits the reported repro pattern.** Explosions synthesize a burst of near-simultaneous SFX triggers (explosion + debris/glass + metal), i.e. a burst of `sndPlaySfx` calls in a tight window — the highest-contention case for a race that needs the audio thread's preempt-scan (only entered when the 8-voice pool is *exhausted*, itself a busy/high-SFX-load state) to overlap a main-thread insert/remove. This matches the user's report of explosions/terminal-destruction reproducing it and Frigate (presumably lower simultaneous SFX load in that test) not reproducing it — a timing race, not a deterministic trigger, fits both observations.
**NOT fixed — two open questions before it should be.**
1. **Is the missing lock a genuine N64-decomp-fidelity gap, or ground-truth-faithful?** Unlike D4's already-fixed cases, this would be a *new*`osSetIntMask` call site in decompiled game code (`src/snd.c`), not a shim-implementation change. If the real N64 assembly at this function's address (comment header cites `9630 70008A30`-style N64 addresses elsewhere in this file — check `sndHandleEvent`'s own) already has an interrupt-mask pair around the equivalent scan that the decompiler dropped, restoring it is a decomp-fidelity fix (expected, not a rule-#2 case). If N64's real code genuinely never masks interrupts there (plausible if the real audio ISR's preempt-scan only ever runs from within the ISR itself, inherently serialized against the main CPU by hardware, with no real race possible on N64), then adding a new lock here is the same class of exception D4 already established (a PC-only concurrency fix, not a behavior change) — but that determination needs an MIPS-disassembly check against the real ROM, not a code read of the decomp alone.
2. **Not yet reproduced/confirmed via gdb on a live crash** — this is a strong static lead from one real crash's symbolized backtrace plus a code read, not a confirmed root cause. The next Deck session should `gdb attach` (per D253's established recipe) while reproducing via a burst of simultaneous SFX (an explosion is the natural trigger) and confirm `D_800243E4.node.prev == NULL` (or a torn/partially-updated node) at the fault, and check the exact other thread's state at that moment if possible.
**Do NOT conflate with D255** — different fault address, different thread, different subsystem, different code path. Both may be independently real; testing one does not rule out the other, and a fix for one will not affect the other.
**Next, concrete:** (1) settle question 1 above (N64 disassembly check or accept the D4-class port-layer exception); (2) if a lock is warranted, wrap `sndHandleEvent`'s preempt-scan (and audit the rest of the function for the same gap against every other `D_800243E4` touch point in the file) in `osSetIntMask(OS_IM_NONE)`/restore, matching `sndSetupSound`'s existing pattern; (3) live-verify via gdb on a real reproduced crash, not just the static read.
**M-141: FIXED, per the accepted D4-class port-layer exception; not yet live-verified.** Question 1 is settled by precedent rather than a fresh N64 disassembly read: `sndSetupSound` (the paired writer) already takes `osSetIntMask(OS_IM_NONE)`/restore completely unconditionally — **no `#ifdef PORT` at all** — around the identical `D_800243E4` list mutation, in this same file, with no N64-behavior caveat noted anywhere near it. Since `osSetIntMask` is genuine N64 API (not a port shim call added out of nowhere) and the port's own `osSetIntMask` implementation (`port/src/libultra.c`, "Interrupt mask -> global recursive lock", D147/D152) is a **process-wide recursive** critical section — safe to nest inside `alEvtqPostEvent`/`alSynSetVol` calls without deadlock risk — adding the identical lock/unlock pair around `sndHandleEvent`'s preempt-scan follows the exact same already-accepted shape as its sibling writer, unconditionally, no `#ifdef PORT` needed (matches the surrounding code's own style). Audited the rest of `sndHandleEvent` (`snd.c:263-680`) for other `D_800243E4` touch points per the open item above: **the preempt-scan is the only one** — nothing else in the function reads or writes that list. Fix: `src/snd.c`, wraps `ALSoundState *iterState = ...` through the closing `} while (limitReached && iterState != NULL);` in `OSIntMask d285Mask = osSetIntMask(OS_IM_NONE);` / `osSetIntMask(d285Mask);`. **Verified:** build green; `-level_09`/`-level_20` boot 1400+ frames each with zero crashes and zero `D152: osSetIntMask lock stuck` warnings (which would fire if this new lock ever wedged). **Not verified:** a `GE_INPUTSCRIPT`-driven rapid-fire stress test (modelled on D219/M-90's scripted-Z-pulse approach) was attempted to force the 8-voice pool exhaustion this scan requires, but the script's own parse-confirmation log line never appeared in this run's output — the scripted-input harness did not visibly activate for a reason not root-caused this session (worth a quick look next time `GE_INPUTSCRIPT` is used: confirm it still fires on current `main`/`release/v0.3.0` before trusting a "no crash" result from it as a real stress test). No live Deck/gdb reproduction attempted this session (no Deck SSH session active). Given the D4/D152 precedent's exact-shape match, confidence in the fix is HIGH; confidence that it's been exercised under real load is LOW — the honest bar met here is "correct by precedent, build/sweep-clean," not "reproduced-then-fixed." Next: an interactive session (ideally the same live Steam Deck setup as D255/M-140, explosion-heavy combat, e.g. Facility/Frigate) should confirm the crash no longer reproduces.
**Live-verified, 2026-09-16, real Steam Deck hardware, v0.2.2 hotfix build.** User ran Archives with dual grenade launchers plus guards spawned with the rocket-launcher cheat — sustained, dense overlapping-explosion combat, exactly the 8-voice-pool-exhaustion / SFX-burst scenario this fix targets — with no crash. This is user-reported (not gdb-attached or `D_800243E4`-state-captured at the moment of stress), so it does not pin the exact race window the way a live-fault capture would, but it is a real, on-hardware, high-load stress test of the fix's actual trigger condition, not just the build/sweep-clean bar noted above. Confidence upgraded from "correct by precedent, unexercised under real load" to "correct by precedent, exercised under real load with no failure."
## D286 — M-140: user-confirmed, real (not system-side) ~8s frame freeze on Caverns, Steam Deck, same live session as D255/D285 — self-recovered, not a crash; case not yet root-caused
**Context.** Caught live during the same Deck SSH session used to fix D255: the game's own hang watchdog (`portHeartbeatCheck`, `port/src/libultra.c`) fired once, logging `kernel heartbeat: no frame rendered for 8008866 us (frames=46404); state:` (unit bug in the log line, see below — the raw value is *microseconds*, printed with a `ms` suffix and no `/1000`; fixed this session, `port/src/libultra.c`) followed by a per-thread/per-queue state dump and a `crashDumpThreads` backtrace of the watchdog thread itself (`portTickThread` → `portHeartbeatCheck` → `crashDumpThreads`, `port/src/libultra.c:266/315`, `port/src/crash.c:458` — this is the watchdog reporting on *itself*, a diagnostic dump, not a fault). **User confirmed by direct observation: this was a real, visible freeze** (not a display/system hiccup — the Deck's own WiFi had one unrelated sleep/wake event earlier in the session, so system-side causes were a live alternative hypothesis, now ruled out by the user's own eyes on the screen), happening "pretty early" in a Caverns playthrough, not tied to an explosion or terminal (ruling out D255/D285 as the cause — this is a third, distinct issue).
**Outcome: self-recovered.** VI-post/frame-render logging resumed normally within the same log stream immediately after the watchdog fired, and the session continued (the user later closed the game normally, no crash log, all 5 game threads still `started=1`/`exited=0` at the time of the dump — nothing was permanently deadlocked). This is a genuine ~8s *stall*, not a hang or a crash.
**Not root-caused.** No distinguishing log line immediately precedes the stall (just routine `D51 vi post` lines) — nothing points at a specific subsystem yet. Caverns is one of the larger/more complex levels (cross-ref the D250 "FPS inconsistent" lineage, though D250's own symptom was sustained sub-60fps, not a multi-second total stall, so likely a different mechanism — a one-time heavy room/asset stream-in stall is the leading guess, unconfirmed). A single occurrence with no repro trigger identified; **do not spend session time trying to force a repro** without a better lead. If it recurs, the next capture should note (a) exact in-level location/trigger if any pattern emerges, (b) whether `Rooms`/`roomsDrawn` or a texture/model load was in flight via existing debug logging, and (c) whether it's Caverns-specific or level-general (test other large/open levels — Streets, Egyptian — the D249/D250 neighbourhood).
**Also fixed in passing (cosmetic, no behavior change):** the heartbeat log's unit label — `now - g_lastFrameUs` is computed in microseconds but was printed as `%llu ms` without converting; now divided by 1000 so the log actually reads correct milliseconds. | OPEN — one user-confirmed real freeze on Caverns (~8s, self-recovered, no crash), not root-caused, no repro trigger identified; distinct from D255/D285. Log-unit cosmetic fix landed alongside.
## D293 — QoL ask (M-141, Steam Deck user playtest): no discoverable way to quit the game from the UI on a controller-only setup
**Context.** Deck/controller-only setups had only window-close/Alt+F4 to quit; no in-UI affordance.
**FIXED.** Added a `ROW_ACTION` row kind (not config-backed, same pattern as the existing `__Resolution` row) to the F10 options overlay ("Quit to desktop") that triggers the identical `configSave()`+`exit(0)` path `SDL_QUIT` already uses. Build-verified on Windows and Linux; `verify.sh dam` PASS, no regression.
description: Architecture behind the PC port; the software RSP-emulation approach, GoldenEye-vs-Perfect-Dark engine differences, and the phased development plan.
---
# GoldenEye 007 PC Port: architecture & plan
## GoldenEye 007 PC Port: architecture & plan
> This began as the pre-implementation research note and is kept as the
> architecture reference. Sections 1–9 describe the design; section 10 lists
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
## 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
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.