diff --git a/.github/release-notes.md b/.github/release-notes.md
index c9f087d7..14b1c015 100644
--- a/.github/release-notes.md
+++ b/.github/release-notes.md
@@ -1,217 +1,329 @@
-## GoldenEye 007 PC Port
+# GoldenEye 007 PC Port — v0.4.0
-
+
-> The full single-player campaign runs at a steady 60 fps with audio (music +
-> SFX) playing throughout, on Windows and Linux including Steam Deck, and it
-> is completable end to end: the whole campaign has been playtested through
-> all 20 missions (Agent difficulty). It's an early public cut: all 20 solo
-> missions load and run crash-free, but the known issues below are real;
-> feedback is very welcome. **This is a pre-1.0 release, not a finished
-> product** — expect rough edges and missing features until v1.0; see the
-> [README's Roadmap section](https://github.com/jkdansereau/goldeneye-pc-port#roadmap)
-> for where this is headed.
+Platforms: **Windows x86-64** and **Linux x86-64 (including Steam Deck)**.
+Region: **NTSC-U (US) only** — see [Things to know](#things-to-know).
-### What's new since v0.2.2
+v0.4.0 is a large feature and fidelity release. The full campaign runs at a
+steady 60 fps with music and SFX throughout, and has been playtested end to
+end (all 20 missions, Agent difficulty) on Windows and on Steam Deck with a
+physical controller. This is a pre-1.0 release: the known issues below are
+real, and some areas (multiplayer, PAL/JP, HD assets) are still ahead of us.
-- **Bond is fixed in cutscenes** — the most visible remaining glitch in
- prior releases: Bond no longer floats or glitches out during cutscenes,
- his animation no longer breaks or repeats, and the camera correctly
- tracks him through scripted sequences including all three Dam end-of-level
- abseil shots (D243). Root cause: a 32-bit-era struct-size constant in the
- cutscene body-model setup never accounted for 64-bit pointer widening,
- corrupting the model's own animation-timing fields on creation. Fixing
- that also fixed the associated camera shake, which had previously been
- worked around with a camera-freeze hack that's no longer needed. **Two
- related reports also cleared up as a side effect**: Bond's third-person
- model floating above the ground at level start, and death animations
- sinking him into the floor, both shared an underlying mechanism with this
- bug — Bond now ends up in the right place the large majority of the time,
- with only a small positional drift on the rare occasion he's off (D173/
- D292).
-- **Particle colors mostly fixed**: bullet-impact sparks and lingering
- smoke/explosion residue no longer cycle through a rainbow palette (D219) —
- root cause was a hardcoded raw-memory-offset read into the spark's stored
- color that landed on the wrong bytes once the containing struct grew from
- 64-bit pointer widening. A second, separate cause was found and fixed in
- explosion rendering itself (an out-of-bounds vertex-buffer write
- corrupting adjacent particle data). This fix is real and stays in, but the
- rainbow effect can still occur intermittently on any platform, not every
- time and not on every level — a second cause hasn't been found yet; see
- Known Issues.
-- **Automatic widescreen FOV scaling**: the game now scales its field of
- view to match your display's aspect ratio automatically, instead of
- requiring the old manual FOV-scale slider. Toggle off in the F10 overlay
- (`Widescreen Auto`) if you prefer the original 4:3 framing. The scaled
- vertical FOV is clamped to a 20°–160° range as a guard against degenerate
- values on unusual aspect ratios. (This is FOV scaling, not a native 16:9
- world/HUD re-render — see Known Issues.)
-- **TV overscan artifacts removed**: the black bars top/bottom and the thin
- pixel strips left/right of the gameplay view are gone (D247/D246) — both
- were the original N64 TV-safe-area margin, invisible on a CRT under real
- overscan but visible on a PC monitor. New `Video.SafeAreaCrop` toggle
- (default on) if you want the original framing back.
-- **Two more crash fixes**: a campaign-halting crash at the end of Control
- Center on 00 Agent difficulty, which would then crash on every subsequent
- relaunch until the save was reset (D295); and a crash selecting most save
- slots with `Skip Intro` enabled (D299).
-- **The elusive complete freeze is root-caused and fixed.** Players had hit
- a total, non-self-recovering lockup (most recently when returning from
- Caverns to the intro screen) that was real but never reproduced on demand.
- It turned out to be a latent defect in the original game's own AI command
- stream — a record type the engine mis-measures, which our level-teardown
- timing can trigger and the N64 never presents. Fixed with a minimal change
- under the project's game-code exception process (D309/D310); a second,
- unrelated freeze mechanism found during the same playtest is now guarded
- as well (D311).
-- **A NULL-pointer guard was added** around a save-data lookup that two
- players hit with an identical crash address (GitHub #87, D301) — this
- should close that crash, though we weren't able to reproduce the exact
- trigger condition ourselves to confirm it end-to-end.
-- **Facility's execution softlock is caught and recovered from**: a rare
- race in the original game's own AI script (present on the N64 too) could
- leave Ourumov frozen mid-execution after Objective C, with no way out short
- of reloading. A port-side watchdog now detects the exact unrecoverable
- state and re-seeds the attack so the scene plays out as authored; it
- re-arms once the scene has cleared, so replaying Facility later in the
- same session is still covered (D318).
-- **A Steam Deck crash is fixed**: an audio-thread bug could crash the game
- early in a level (`sndHandleEvent`'s sound-preemption scan could
- dereference a pointer without checking it was valid first). Live-verified
- crash-free on real Steam Deck hardware, including with `All unlocked`
- toggled on.
-- **Mouse input and F10 controls fixed**: mouse input was reworked to
- follow the Perfect Dark PC port's architecture — slow mouse motion is no
- longer barely recognized and fast motion no longer saturates almost
- immediately (D300, `Input.MouseDirectLook`, on by default). The separate
- aim-speed and turn-speed sliders — which could be set to inconsistent
- values against each other — are now a single `Mouse sensitivity` control
- with a wider range. Also fixed three F10 menu bugs found along the way:
- clicking a row could unexpectedly scroll the whole list, a click near
- certain rows could silently activate a different option than the one you
- clicked (most noticeably, clicking `All unlocked` could instead trigger
- `Quit to desktop`), and the panel's bottom row no longer duplicates
- whatever item is currently selected (D251).
-- **Random-number generation now matches the N64 original exactly** (D284) —
- a shift-operation bug in the PRNG had desynced the PC's random stream from
- N64's since the very first release, affecting loot placement, AI variance,
- and other randomized elements. **This changes the random sequence from
- every prior build**, so old input recordings/replays will diverge, but
- your save files are unaffected: **a migration shim automatically upgrades
- any save written by an older build the first time you load it** (D297) —
- no manual action needed.
-- **Assorted texture/rendering fixes**: an incorrect IA4 texture-format
- decode and an overly-tight near-plane portal-culling guard were both
- corrected (D266/D271) — reduces some texture-edge and visibility artifacts
- on affected levels.
+---
-### Known issues
+## New features
-- The front-end **Nintendo logo renders as two white blobs**, and the
- Rareware logo is close but its texture filtering looks off (D75).
-- **Particle colors: a real fix landed this release, but the rainbow effect
- can still occur intermittently.** One genuine, reproducible cause (an
- out-of-bounds vertex-buffer write in explosion rendering) is fixed. A
- second, still-unidentified cause remains, on both Windows and Steam Deck —
- it doesn't reproduce every time or on every level, so treat this as
- improved, not fully resolved (D252).
-- **Some muzzle flashes draw an extra, erroneous long flash straight up from
- the gun** (seen on the M16, among others), on top of the normal, correctly
- drawn flash. Cosmetic only (D303).
-- **Surface 1: the 2D billboard trees near the start render as a solid wall
- of tree texture** instead of discrete sprites (D236). Under active
- investigation across ten+ passes; no fix yet.
-- **No native widescreen render**: v0.3.0's automatic FOV scaling (above)
- expands the *field of view* for 16:9+ displays, but the world geometry and
- HUD are still authored/laid out for 4:3 — this is not yet a true
- edge-to-edge 16:9 render.
-- **No controller rebinding UI, and no macOS or ARM builds.**
-- **Distant geometry can drop out on the biggest open levels** (Streets,
- Egyptian) at default FOV — a culling/LOD issue that sometimes
- self-corrects as you keep moving (D249).
-- **Gunshot SFX can sound off during sustained/rapid fire**: cadence can
- drift from the N64 original's rate, and PP7/AK47 fire can occasionally go
- silent under heavy automatic fire near another looping sound (e.g. an
- alarm klaxon) (D240/D241).
-- **`All unlocked` is highly experimental — do not enable it until you have
- at least one save written.** Complete a level normally first (e.g. Dam on
- Agent), then turn the toggle on. Enabling it on a brand-new install with
- no prior save can still cause silent audio and odd right-mouse-aim
- behavior (D259/D257). (The Steam Deck crash fix above addresses a
- separate, now-fixed symptom that had also been associated with `All
- unlocked` use; this audio/aim caveat is unrelated and still open.)
-- Water on `IsWater` levels shows a moving seam between two patterns (D245).
-- **Occasional z-fighting on some levels' geometry** (D308) — a Dam intro/
- truck-wheel instance showing odd transparent-looking areas has also been
- seen and may be related (D306). Minor, cosmetic; not investigated for
- this release.
-- **In-level security camera props can occasionally end up facing backwards**
- (the wrong direction) on some levels, seen on Bunker — this is the
- gameplay security-camera object, not the player's own view (D307). Not
- investigated for this release.
-- **A rare, self-clearing visual quirk**: on an occasional animation
- transition, Bond's model can briefly render out of place. It has no effect
- on gameplay or your save, and it clears on its own or by re-entering the
- level; a safeguard added this release guarantees it can never affect game
- stability (D311).
-- **Two collision oddities found in final playtesting**: throwable items
- (mines, grenades) can occasionally clip through walls in certain spots
- (D312), and bullet impacts don't appear on some surfaces — doors and
- windows are unaffected (D313). Both are queued for the next release; if
- you hit either, an issue with the level and spot helps a lot.
+### Native widescreen *(on by default)*
-### Downloads
+The world now renders natively at your display's aspect ratio instead of
+stretched 4:3: geometry is undistorted (wider view, same vertical FOV), the
+in-level HUD keeps its shape and anchors to the screen edges, and the 4:3
+front-end menus and ending-credits sequence are pillarboxed. Turn it off
+with the **`Native widescreen`** toggle (F10 → Display) to restore the old
+stretched frame. (D334/D335)
+
+### A true, stable 60 fps
+
+v0.3.0 could settle at 30 fps game-wide on some setups. The cause was a
+pointer-width bug in the game's frame scheduler: a "present every frame"
+flag was read back at a hardcoded byte offset that the 32→64-bit transition
+silently moved, so the render path ran on the audio client's every-other-
+frame cadence. Fixed at the root; the game now holds a rock-stable 60 fps
+(measured with the in-game FPS readout) on both platforms. (D248)
+
+### A complete aim system for mouse *and* controller
+
+- **`Aim style`** — **N64** (default): the crosshair deflects and the
+ camera follows once it reaches the screen edge. **Centred (PC)**: the
+ crosshair is fixed at screen center and your mouse — or your right stick —
+ moves the camera directly. In v0.3.0 the centred mode only worked with a
+ mouse; it now works with a controller too, using the N64 original's own
+ stick-response curve so full stick matches the original's top turn rate.
+ (D332/D333/D337/D404)
+- **`Aim range`** (shown while Centred is selected) — **PC** (default,
+ full-screen feel) or **N64** (the original 65% stick limits). (D338)
+- **Reticule jitter in aim mode is gone** — mouse aim now runs on the
+ Perfect Dark PC port's aim model. (D332)
+- Per-device sensitivity: `Mouse horizontal/vertical sensitivity`, and for
+ controllers `X axis / Y axis look sensitivity (controller)` plus
+ `Look smoothing (controller)`.
+
+### Rebuilt options (F10 overlay + front-end PC Options)
+
+- Both options screens are reorganized into functional sections — **Input,
+ Gameplay, HUD, Graphics, Audio, Display** — each with its own *Reset to
+ defaults*; per-profile settings scoping; and the old "save file" wording
+ is now **profile**. (D353–D356)
+- The **F10 in-game overlay** was refreshed GE-style: category headers,
+ full controller support (value-adjust with hold-to-repeat), fixed pointer/
+ keyboard navigation, and new rows: **Show FPS**, **Skip intro
+ (experimental)**, pad
+ **Invert look / Southpaw / Deadzone / Trigger threshold**. (D237/D345–
+ D347/D360–D370)
+- The front end gained a **PC Options screen** beside the file-select bar,
+ and save-file **Copy/Erase** moved into the bottom bar. Long sections
+ (Input) page within the screen -- a "Page 1/2" marker plus a bottom hint
+ on every page -- so every row stays on screen and stays reachable; the
+ mouse wheel and W/S also page the list, and the selection clamps at page
+ edges. (D343/D406/D407)
+- Watch-only settings — **Auto-aim**, **Look ahead** and the rest — are now
+ editable from the PC options and **persist correctly** (this closes the
+ "auto-aim won't stay on" report, issue #103). (D330/D350/D352/D354)
+- **`HUD scale`** (75–150%, default 100%): scales the ammo counter, pickup
+ text and subtitle bars for smaller or larger displays. (D226)
+
+### Crosshair customization *(defaults leave the N64 crosshair untouched)*
+
+**Show crosshair** (on/off), **Crosshair colour** (the original red,
+or green / blue / yellow / cyan / magenta / white, or custom RGB), **Crosshair size**
+(default 100% of the original drawing), and **Crosshair style** (Original /
+Thin cross). (D373/D379/D381/D382)
+
+### In-game key rebinding + new default layout
+
+A two-group key editor (**Movement**, **Actions**, each with its own *Reset
+to defaults*) lives in both options screens. The new default **Actions**
+layout is the GEPD-style preset (e.g. Action/next-weapon on the keys you'd
+expect from a modern layout); existing INI bindings are honored and older
+ones migrate automatically. (D371/D374/D383/D385)
+
+### Controller improvements
+
+- **Sane pad defaults**: **A = use/interact, X = reload, Y = weapon cycle**
+ (v0.3.0 mapped two buttons to crouch and had no in-game use button). (D393)
+- Crouch uses the engine's real crouch input, with a **`Crouch mode`**
+ toggle (hold / latched). (D375)
+- Xbox-variant controller parity, **Southpaw**, and per-stick
+ **deadzone** + **trigger threshold** rows. (D237/D394)
+- **The N64 Rumble Pak now drives real gamepad haptics** (Steam Deck
+ included), scaled by a global **`Vibration`** slider (default 50%). (D401)
+- The F10 overlay is fully gamepad-driven. (D347/D395/D396)
+
+### `All unlocked` ships as a documented EXPERIMENTAL feature
+
+F10 → Gameplay → **`All unlocked (EXPERIMENTAL)`** opens every mission, 007
+mode and the full cheat menu. On a fresh install it now yields the game's
+own fresh-slot layout instead of corrupting state (D281) — but enabling it
+can still bake synthetic cheat/completion data into your save that switching
+it off does not undo: **complete a level normally first, and back up
+`data/ge007.eep` before enabling it.** (D387)
+
+### Steam Deck / Linux
+
+The first clean SteamOS/glibc build, plus a first-launch preset for decks
+without a config (native 1280×800, 2× MSAA, vsync, 250% draw/LOD
+distances). (D402/D283)
+
+---
+
+## Bug fixes
+
+### Fidelity (verified against the N64 original / era-correct references)
+
+- **Water** on the `IsWater` levels (Dam, Frigate, Surface 2, …) matches
+ the original: the moving seam between two patterns and the pattern
+ "resetting" as you moved are gone. (D245)
+- The **particle "rainbow" effect** (intermittently recoloured sparks and
+ explosion residue) no longer occurs. (D252)
+- **Frigate: "every polygon breaks except the gun"** after turning — and
+ the rare "Bond briefly out of place" quirk — were the same never-written
+ fields read by the head-bob animation; both are fixed. (D336/D311)
+- Surface 1/2: the tree backdrop that rendered as a solid wall of texture
+ now renders as proper camera-facing tree cards. (D236)
+- Ejected **shell casings** render again. (D331)
+- The intro **gun-barrel blood drip** draws correctly. (D341)
+- The extra erroneous **long muzzle flash** on some guns is gone. (D303)
+- Occasional **z-fighting** fixed; **security-camera props** no longer face
+ backwards; the front-end **Nintendo logo and copyright page** render
+ correctly. (D308/D307/D403)
+- The **train-intro soldier pose** is correct. (D392)
+- **Tanks** (Runway, Streets) can be boarded and exited again — the new
+ use/reload button split had removed the B-button tap the engine's tank
+ handlers use; B is presented again in tank states, and use keeps its
+ no-reload-fallback semantics everywhere else. A short input lockout during
+ the sit-down animation means a double-tap can't cancel boarding, and in-tank
+ mouse aim has its own **`Tank aim speed`** setting (`Input.TankAimScale`,
+ default 100) so the turret matches your on-foot feel. (D407)
+- File-select background / gun-barrel comb rendering fixed. (D397)
+- Stale-texture artifacts after level transitions fixed. (D235)
+- Distant-geometry dropout on the biggest open levels (Streets, Egyptian)
+ substantially improved at default settings. (D249)
+- The **Facility execution-scene watchdog** from v0.3.0 stays enabled as a
+ safeguard; the animation-pinning that motivated it is root-caused (a tick
+ granularity divergence) and fixed, so the scene plays out as authored.
+ (D329/D318)
+
+### Front end & UI
+
+- Randomly **flickering F10 menu items** are gone. (D314)
+- The watch's **controller-page graphic** renders again. (D290)
+- File-select **folders and Bond photos** no longer vanish after backing
+ out of a file. (D342)
+- File-select bottom row (SELECT FILE / Copy / Erase / PC Options) aligned.
+ (D398–D400)
+
+### Audio
+
+- **Gunshot SFX** cadence matches the N64 original's rate, and the
+ intermittent silence of the PP7/AK47 under sustained fire near a looping
+ sound is gone. (D240/D241)
+- The silenced **PPK "slap"** and the broader real-time mixer corruption
+ behind it are fixed. (D202)
+- Windows audio now prefers the **DirectSound** backend over WASAPI (lower,
+ more robust latency). (D322)
+- In-stage music **re-synchronizes** correctly at stage transitions and
+ level entry. (D355)
+- Long-session audio degradation (issue #87): pool hardening shipped, plus
+ an optional `GE_D322=1` telemetry probe if it ever recurs.
+
+### Stability & renderer
+
+- **Orderly game exit**: quitting no longer lands inside the graphics
+ driver (a class of Windows driver bugchecks seen during testing). (D344)
+- A **non-self-recovering Windows freeze** after the window sat idle and
+ lost focus is fixed (mouse-mode changes now applied on the window
+ thread). (D287)
+- **MSAA above your driver's maximum no longer black-screens** (the request
+ is clamped to `GL_MAX_SAMPLES`), and two framebuffer-state bugs in the
+ render/resolve paths were fixed — community contribution, PR #106,
+ security-reviewed. (D405)
+- Port-wide code audit: missing returns in front-end menus, missing
+ prototypes, undefined-behaviour shifts.
+
+---
+
+## Options reference
+
+All settings available in v0.4.0, in both options screens (front-end
+**PC Options** and the in-game **F10 overlay**). Defaults in parentheses.
+
+| Setting | Section | Default |
+|---|---|---|
+| `Native widescreen` | Display | On |
+| `Frame rate cap` | Display | 60 (30 / 60) |
+| `Show FPS` | Display | Off |
+| `MSAA` | Display | 2× (1/2/4/8, clamped to driver max) |
+| `Aim style` | Input | N64 (N64 / Centred (PC)) |
+| `Aim range` | Input | PC (shown while Centred) |
+| `Mouse horizontal/vertical sensitivity` | Input | calibrated midpoint |
+| `Invert look (mouse)` / `Invert look (controller)` / `Southpaw` | Input | Off |
+| `X / Y axis look sensitivity (controller)` | Input | 100% (native) |
+| `Look smoothing (controller)` | Input | 0 |
+| `Tank aim speed` | Input | 100% (`Input.TankAimScale`) |
+| `Deadzone (left/right stick)` | Input | 70% |
+| `Trigger threshold` | Input | 23% |
+| `Vibration` | Input | 50% |
+| `Crouch mode` | Input | Hold |
+| `Bindings…` (Movement / Actions editors) | Input | GEPD-style preset |
+| `Auto-aim` / `Look ahead` | Gameplay | Off (watch-backed) |
+| `Skip intro` / `No hit flash` | Gameplay | Off |
+| `All unlocked (EXPERIMENTAL)` | Gameplay | Off |
+| `HUD scale` | HUD | 100% (75–150%) |
+| `Show crosshair` / `Crosshair colour` / `Crosshair size` / `Crosshair style` | HUD | N64-intact (size 100%) |
+
+---
+
+## Things to know
+
+- **NTSC-U (US) ROMs only.** PAL and JP ROMs are not supported in this
+ version; asset repair for those regions is on the post-release roadmap.
+ (D258)
+- **The default Actions key layout is new** (GEPD-style). If you preferred
+ the v0.3.0 defaults, rebind them in *Input → Bindings…* or reset the
+ group. Existing INI bindings are auto-migrated and still honored.
+- **Back up `data/ge007.eep` before using `All unlocked`** (see above).
+- Steam Deck: the first launch in Game Mode applies the Deck preset only if
+ no `ge007.ini` exists yet; F10 changes always win afterwards.
+
+## Known issues
+
+- On Facility, if gas leaks during Ourumov's monologue he can pause for up
+ to ~10 s before resuming the scripted shootout — a latent race that exists
+ in the N64 original too (where it softlocks permanently); the port detects
+ and auto-recovers it. (D318)
+- With *Native widescreen* on, the F10 options overlay stretches with the
+ window instead of pillarboxing like the front-end menus (legible;
+ cosmetic). (D335b)
+- The front-end Rareware logo shows a subtle texture-filtering artifact
+ (the Nintendo logo and legal page are clean). Cosmetic only. (D75)
+- **`All unlocked` is experimental** (see above); the fresh-install
+ corruption case is fixed, but saves made while it is on are not
+ guaranteed recoverable by switching it off. (D387)
+- **`Skip intro` is experimental — not recommended for regular use yet.**
+ With it on, a failed or aborted mission skips the post-mission failure
+ report screen and returns straight to the menus (the mission still records
+ correctly). An older audio quirk on some saves is also still being chased.
+ (D408/D216)
+
+## Roadmap since v0.3.0 (research, not yet features)
+
+- ROM-level **mod support** investigated and found viable (the game's file
+ table lives in the ROM); HD texture packs and the drop-in-ROM flow are
+ tracked post-1.0. (D325–D328)
+- A measured CPU budget on low-end hardware (issue #92) reshaped the
+ performance investigation toward the GPU. (D339)
+- Controller options wave 2 (presets, pad reassignment UI) and PAL/JP
+ support are planned post-v0.4.0.
+
+---
+
+## Downloads
| File | Platform |
|---|---|
-| `goldeneye-pc-port--win64.zip` | Windows x86-64 |
-| `goldeneye-pc-port--linux-x86_64.tar.gz` | Linux x86-64 (incl. Steam Deck) |
+| `goldeneye-pc-port-0.4.0-win64.zip` | Windows x86-64 |
+| `goldeneye-pc-port-0.4.0-linux-x86_64.tar.gz` | Linux x86-64 (incl. Steam Deck) |
Each contains the engine executable, a README, license texts, and the
-`prepare-assets` tool. **No ROM, no game assets.** The Windows bundle carries
-its runtime DLLs; the Linux bundle carries SDL2, so on both platforms nothing
-needs to be installed first.
+`prepare-assets` tool. **No ROM, no game assets.** The Windows bundle
+carries its runtime DLLs; the Linux bundle carries SDL2, so on both
+platforms nothing needs to be installed first.
-### Running it
+## Running it
You supply your own **GoldenEye 007 N64 ROM** (`.z64`, big-endian) that you
-legally own: the **NTSC-U (US)** release is what this build supports (PAL / JP
-are on the roadmap). No Python, no toolchain, nothing to install.
+legally own (NTSC-U only, as noted above). No Python, no toolchain, nothing
+to install.
1. Unpack the archive.
2. Make a `data/` folder next to the executable and put the ROM in it, named
`ge007.ntsc-final.z64`.
3. Run the executable **from that folder**. The first run takes a few extra
seconds: it detects your ROM, generates the two derived asset folders
- (`data/pcmodels-ntsc-final/`, `data/pccg-ntsc-final/`) once, and saves them
- for every future run. (The generator is `prepare-assets/ge007-convert`
- inside the bundle; you can also run it manually; it prints what it's doing.)
+ (`data/pcmodels-ntsc-final/`, `data/pccg-ntsc-final/`) once, and saves
+ them for every future run. (The generator is
+ `prepare-assets/ge007-convert` inside the bundle; you can also run it
+ manually; it prints what it's doing.)
-**Steam Deck:** sideload the unpacked folder (USB or a file manager), do steps
-2–3, then add the executable to Games → *Add Game* as a non-Steam game.
+**Steam Deck:** sideload the unpacked folder (SFTP, USB or a file manager), do
+steps 2–3, then add the executable to Games → *Add Game* as a non-Steam
+game. The first launch in Game Mode picks up the Deck preset (native
+1280×800, 2× MSAA, vsync) automatically if no config exists yet.
-**In-game settings on the Deck:** the options overlay is fully gamepad-driven:
-it opens with **Select**, the D-pad or left stick (up/down) moves between
-options, **A** steps the selected option forward, **B** steps it back, and
-**Start** (or Select again) closes. No keyboard needed.
+**In-game settings on the Deck:** the options overlay is fully
+gamepad-driven: it opens with **Select**, the D-pad or left stick (up/down)
+moves between options, **A** steps the selected option forward, **B** steps
+it back, and **Start** (or Select again) closes. No keyboard needed.
Full steps are in the bundled `README.md`.
-### Verify the download
+## Verify the download
```
-sha256sum -c goldeneye-pc-port--win64.zip.sha256
-sha256sum -c goldeneye-pc-port--linux-x86_64.tar.gz.sha256
+sha256sum -c goldeneye-pc-port-0.4.0-win64.zip.sha256
+sha256sum -c goldeneye-pc-port-0.4.0-linux-x86_64.tar.gz.sha256
```
-### Source & docs
+## Source & docs
, built on the
-[GoldenEye 007 decompilation](https://github.com/n64decomp/007), architecture
-after the [Perfect Dark PC port](https://github.com/fgsfdsfgs/perfect_dark).
+[GoldenEye 007 decompilation](https://github.com/n64decomp/007),
+architecture after the [Perfect Dark PC port](https://github.com/fgsfdsfgs/perfect_dark).
Non-commercial fan preservation/research project; not affiliated with any
rights holder. **AI disclosure:** built through agentic AI coding (Claude
Code + a local open-weight model), directed by one person in their spare
diff --git a/.gitignore b/.gitignore
index ff95f231..61e855ba 100644
--- a/.gitignore
+++ b/.gitignore
@@ -170,6 +170,9 @@ docs/img/shots/CONTACT-SHEET.jpg
/audiodump.raw
/audiotrace.log
/mixertrace.log
+# texture-dump probe artifacts (regenerable via GE_TEXDUMP)
+/texdump/
+/texdump_p23_old/
# Claude Code local state / agent worktrees
.claude/
@@ -188,3 +191,12 @@ build-pyinst/
# Local working junk (frame captures, timerun state, PPM dumps) - moved here
# from repo root / scratch on 2026-09-18; never commit.
/local/
+build-pc-cmd.bat
+
+# v0.4.0 modern-options wave: per-landing sweep capture dirs (capture.sh output)
+tools_pc/sweep-captures/capture-*/
+
+# Stray full CPython install that landed in repo root (2026-09-28);
+# referenced by no project tooling. Never commit.
+/Python/
+docs/media/*.mp4
diff --git a/AGENTS.md b/AGENTS.md
index 680ee103..36ca548b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -39,6 +39,59 @@ Needs CMake + SDL2 + zlib + OpenGL, and must run from the MSYS2 MINGW64 shell
(see `build-pc.sh` header and `docs/building.md`). ROM goes in `./data/`
(not distributed); assets must be extracted from it first (`docs/building.md`).
+**Windows build environment (three recurring failure modes — diagnose in this order):**
+
+1. **`Cannot create temporary file in C:\Windows\: Permission denied`** at the
+ link step. The PE toolchain (ninja → cmd → gcc/ld) needs a writable
+ TMP/TEMP; the msys→native env conversion drops it in non-login shells
+ (agent harnesses; the `C:/msys64` tree here was built for `D:/M/msys64`,
+ so its conversion is unreliable). `build-pc.sh` now **self-heals**: it
+ probes a native child's TMP (via a file — piped `cmd.exe` stdout is
+ unreliable under msys console emulation) and, if broken, re-runs
+ cmake+build under PowerShell with `TMP`/`TEMP` set natively (the
+ native→native boundary passes env through intact; only msys→native is
+ broken). If you run cmake/ninja *by hand* from a broken shell: run them
+ from cmd/PowerShell with `C:\msys64\mingw64\bin` on PATH.
+ **Guard revision (2026-09-28):** the re-exec writes a self-contained
+ `.ps1` to a temp file and runs `powershell -File` with plain path/word
+ args only (the msys→native argv conversion mangles `$`-bearing
+ `-Command` strings; a file write is conversion-free). Inside: PATH is
+ built explicitly (never trust the inherited value); TMP is chosen from
+ writable candidates (`%LocalAppData%\Temp` first —
+ `[System.IO.Path]::GetTempPath()` honours the inherited *broken* TMP,
+ observed as `C:\Windows\`); every native step self-logs to
+ `build-pc/ge007-native-reexec-{diag,cmake,build}.log`; a null
+ `$LASTEXITCODE` ("did not execute") is a distinct failure (exit 2/3) —
+ in PS 5.1 a piped native command errors out non-terminating and never
+ sets it, so an unguarded `if ($LASTEXITCODE -ne 0)` passes vacuously.
+ In `.ps1`, `-DROMID=$Var` is a literal (bare tokens don't expand) — it
+ must be `"-DROMID=$Var"`. Launch powershell plainly — `env -i` before it
+ breaks the nested cmake launch (verified 2026-09-28).
+ **OPEN (next session):** the guard still fails *inside* `build-pc.sh`
+ (cmake "did not execute": the `.ps1` runs, but `$LASTEXITCODE` stays
+ empty) while the *identical* standalone invocation succeeds (cmake runs
+ and the build completes — that is how the D401 builds were made). First
+ test: diff the heredoc-generated `.ps1` against a hand-extracted copy
+ (suspect: heredoc line-ending/whitespace corruption).
+ **Working workaround:** extract the heredoc `.ps1` and run
+ `powershell -NoProfile -ExecutionPolicy Bypass -File "C:\msys64\usr\bin" "C:\msys64\mingw64\bin" C:/msys64 build-pc `.
+ Re-confirmed 2026-09-28: in-script re-exec fails again (cmake configure
+ dies inside the ps1; re-exec logs left stale) while the identical
+ standalone invocation reconfigured and built cleanly (extract with
+ `awk "/<<'PS1'\$/{f=1;next} /^PS1\$/{f=0} f" build-pc.sh > /tmp/ps1`
+ — mind the leading whitespace, and `export PATH` to include mingw/bin
+ first, failure mode 3).
+2. **`cannot open output file ge007.x86_64.exe: Permission denied`.** A
+ **running** `ge007.x86_64.exe` locks the output file (Windows rule; you
+ can't relink over a live PE). Check with `Get-Process | Where-Object {
+ $_.ProcessName -like '*ge007*' }` and close the game before rebuilding.
+ An agent must not kill the user's game process to make a link succeed.
+3. **`cc1.exe: ... libmpfr-6.dll: cannot open shared object file`.** The
+ calling PATH lacks `C:\msys64\mingw64\bin` (the gcc driver finds cc1 via
+ its own directory, but the child needs the mingw DLL dir on PATH). In an
+ msys shell: `export PATH="/c/msys64/mingw64/bin:/c/msys64/usr/bin:$PATH"`
+ before building.
+
## Verification ritual (after any build-affecting change)
1. **Undefined symbols.** Every symbol referenced by the compiled set (see `CMakeLists.txt`: `SRC_GAME`, `SRC_ENGINE`, `SRC_LIBAUDIO`, `SRC_LIBULTRARE_AUDIO`, `SRC_LIBULTRARE_DATA`, `SRC_GU`, `SRC_PORT*`) must be defined exactly once in the compiled set or in `port/`. Symbols that live in EXCLUDED files (`libultra/io/*`, `libultrare/io/*` except `vitbl.c`, `libultra/os/*`, `libultrare/os/*`, `sched.c`, `rmon.c`, `vi.c`, `src/*.s`) must be provided by `port/src/libultra.c`, `gesched.c`, `n64stubs.c`, `random.c`, or `ucode.c`.
diff --git a/README.md b/README.md
index 69febc4f..336f7552 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,12 @@
# GoldenEye 007 PC Port
[](https://github.com/jkdansereau/goldeneye-pc-port/actions/workflows/ci.yml)
-
+
+
+GoldenEye 007 (Nintendo 64, 1997) on the PC —
+decompiled, ported, and playable at 60 fps.
+
+[Download](#download) · [News](#news) · [Status](#status) · [Roadmap](#roadmap) · [Building](#building) · [Docs](#documentation) · [Legal](#legal)
A native PC port of _GoldenEye 007_ (Rare, 1997, Nintendo 64), compiled from
the [GoldenEye 007 decompilation](https://github.com/n64decomp/007): the
@@ -12,25 +17,24 @@ in a dedicated `port/` layer, following the architecture of the
[Perfect Dark PC port](https://github.com/fgsfdsfgs/perfect_dark), the same
Rare "Indy" engine family, one hardware generation apart.
-**v0.3.0** is out for Windows and Linux (including Steam Deck, where the
-Linux bundle sideloads as-is) and runs the full campaign at a steady 60 fps
-with known rough edges ([Status](#status)). Free to download, build on and
-modify (you bring the ROM).
+**v0.4.0** (released 2026-09-28) is out for Windows and Linux (including
+Steam Deck, where the Linux bundle sideloads as-is). It is the most complete
+release to date: the full campaign runs at a steady 60 fps with the known
+issues now few and mostly cosmetic ([Status](#status)). Free to download,
+build on and modify (you bring the ROM).
**This is a pre-1.0 release, not a finished product.** v1.0 is the target
-for a polished, feature-complete build; until then, expect rough edges,
-missing features, and breaking changes between versions. See
-[Status](#status) for what works today and [Roadmap](#roadmap) for where
-this is headed.
+for a polished, feature-complete build; until then, expect missing features
+and the occasional breaking change between versions. See [Status](#status)
+for what works today and [Roadmap](#roadmap) for where this is headed.
**AI disclosure:** development here was agentic - Claude Pro plus a local
open-weight model on a single RTX 5090, as of August–September 2026. This
project is as much a study of *that process* as it is a port: whether
current LLMs can carry a codebase like this, and what actually goes wrong
-along the way. Judge the result for yourself.
-I'm one person doing this in my spare time, not a team. See
-[Background](#background) for the full setup, timeline, and an honest
-account of what worked and what didn't.
+along the way. Judge the result for yourself. I'm one person doing this in my
+spare time, not a team. See [Background](#background) for the full setup,
+timeline, and an honest account of what worked and what didn't.
> [!IMPORTANT]
> **You must supply your own GoldenEye 007 ROM.** This repository contains no
@@ -39,11 +43,31 @@ account of what worked and what didn't.
- All in-engine, running in the port — a ~12 s gameplay montage
- from live v0.3.0 play sessions.
+ alt="~24 s gameplay montage from live play sessions">
+ All in-engine, running in the port — a ~24 s gameplay montage
+ from live v0.4.0 play sessions, opening on the Runway tank ·
+ 12 level stills in the project index
+---
+
+## News
+
+- **2026-09-28** — **v0.4.0**: native widescreen, a complete aim system for
+ mouse and controller, in-game key rebinding, crosshair customization,
+ rumble-pak haptics, a rebuilt options overlay, and a broad fidelity-fix
+ pass (water, particles, billboard trees, front-end logo, gunshot SFX, a
+ true stable 60 fps). [Release notes](https://github.com/jkdansereau/goldeneye-pc-port/releases/tag/v0.4.0) ·
+ [downloads](#download).
+- **2026-09-20** — **v0.3.0**: the first release with the complete campaign
+ playable end to end at 60 fps on Windows, Linux, and Steam Deck.
+ [Release notes](https://github.com/jkdansereau/goldeneye-pc-port/releases/tag/v0.3.0).
+- **2026-09-04 → 2026-09-16** — **v0.1.0 – v0.2.2**: the alpha and beta
+ cycle — build chain, software RSP, first rendered frames, front end, and
+ per-level stabilization across the campaign.
+
+---
+
## Download
| Platform | Bundle | Notes |
@@ -53,8 +77,8 @@ account of what worked and what didn't.
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.2.2, v0.2.1, v0.2.0, and v0.1.0 alpha, same
-page. You can also build it yourself; see [Building](#building).
+distribute. Earlier builds: v0.3.0, v0.2.2, v0.2.1, v0.2.0, and v0.1.0
+alpha, same page. You can also build it yourself; see [Building](#building).
### Quick start
@@ -67,17 +91,22 @@ included or distributed. Then:
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.3.0 has known rough edges,
-listed plainly there.
+Read the [Status](#status) section first: the small number of known issues
+in this release are listed plainly there.
+
+---
## Status
-**v0.3.0 - playable, with known rough edges.** The full single-player
-campaign is completable end to end (all 20 missions, Agent difficulty,
-playtested), at a steady 60 fps; all 20 solo missions — plus the
-end-of-campaign credits sequence — load, render and run crash-free, verified
-on Windows, Linux and real Steam Deck hardware. Feedback
-is very welcome.
+**v0.4.0 - fully playable, with a small set of known caveats.** The full
+single-player campaign is completable end to end (all 20 missions, Agent
+difficulty, playtested), at a steady 60 fps; all 20 solo missions — plus
+the end-of-campaign credits sequence — load, render and run crash-free,
+verified on Windows, Linux and real Steam Deck hardware. The most common
+defect classes from earlier releases — particle colour drift, water seams,
+z-fighting, billboard trees, muzzle flashes, the front-end Nintendo logo,
+gunshot SFX — are fixed in this version; what remains is a short list,
+below. Feedback is very welcome.
What a release actually installs (no networking, no telemetry, no ROM or
game assets shipped) and how faithfully the port tracks the original N64
@@ -87,62 +116,44 @@ game's logic: [Security & fidelity status](docs/security-and-fidelity-status.md)
start), front-end menu navigation on the left stick to match the F10 overlay
(D282); all 20 solo missions load, render and are crash-free (full campaign
playtested end to end at Agent difficulty, including the ending sequence); 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, a single
-simplified sensitivity control) and a modern dual-stick controller layout;
-automatic widescreen FOV scaling; Bond is fixed in cutscenes (no more
+(software RSP off the presentation critical path); full audio: in-level music and SFX; keyboard + mouse (click-to-lock, an aim style of your
+choice — N64 or centred FPS-style — with per-device sensitivity) and a modern
+dual-stick controller layout (use/reload/weapon-cycle on A/X/Y, rumble-pak
+vibration on supported pads); native widescreen at any aspect ratio
+(undistorted world, HUD anchored to the screen edges, 4:3 menus pillarboxed);
+in-game key rebinding (keyboard/mouse) with a GEPD-style default layout;
+crosshair customization (opt-in); Bond is fixed in cutscenes (no more
floating or spin-glitching) and his third-person model positioning generally
is right the large majority of the time now, at most a small drift when off; file-backed saves; faithful N64 progression
by default (F10 → *All unlocked* opens every level, 007 mode and the full
-cheat menu); F10 in-game options overlay (resolution, frame cap, MSAA,
-filtering, FOV, sensitivity); Windows and Linux, including Steam Deck.
+cheat menu); F10 in-game options overlay (video, input, gameplay, HUD,
+graphics, audio; frame cap, MSAA, filtering, FOV, sensitivity, key rebinding,
+crosshair, vibration); Windows and
+Linux, including Steam Deck.
**Known issues:**
-- Particle colours: a real fix landed (a corrupted-vertex-buffer bug), but
- the rainbow effect can still occur intermittently on either platform, not
- every time or on every level — a second cause hasn't been found yet
- (D252).
-- Some muzzle flashes draw an extra, erroneous long flash straight up from
- the gun (seen on the M16, among others), overlaid on the normal, correctly
- drawn flash. Cosmetic only (D303).
-- Water levels show a moving seam between two water patterns (D245).
-- Occasional z-fighting on some levels' geometry (D308) — a Dam intro/
- truck-wheel instance showing odd transparent-looking areas may be related
- (D306). Minor and cosmetic.
-- In-level security camera props (not the player's own view) can
- occasionally end up facing backwards on some levels, seen on Bunker
- (D307).
-- Rarely, Bond's model can briefly render out of place after an animation
- transition — purely visual: no effect on gameplay or saves, and it clears
- on its own or by re-entering the level (D311).
- On Facility, if gas leaks during Ourumov's monologue he can pause for up to
~10 s before resuming the scripted shootout — a latent race that exists in
the N64 original too (where it softlocks permanently); the port detects and
auto-recovers it (D318).
-- Some front-end 3D models are off: the spinning Nintendo logo renders as two
- white blobs and the Rareware logo's texture filtering looks wrong (D75).
-- Surface 1's 2D billboard trees render as a solid wall of tree texture
- instead of discrete sprites (D236). Under active investigation.
-- **Widescreen is stretched, not native**: on non-4:3 windows the whole
- frame (world and HUD) is stretched horizontally to fill your display —
- about 33% wider than original at 16:9, like a 4:3 video in a TV's "stretch"
- mode. Automatic FOV scaling keeps the framing comfortable and gameplay is
- completely unaffected (all game logic runs in world space), but shapes are
- subtly wider than on the N64 — most visible on round objects (barrels, the
- radar). A distortion-free native widescreen render is on the roadmap.
-- Distant geometry can drop out on the biggest open levels (Streets,
- Egyptian) at default FOV — a culling/LOD issue that sometimes
- self-corrects as you keep moving (D249).
-- Gunshot SFX can sound off during sustained/rapid fire: cadence can drift
- from the N64 original's rate, and PP7/AK47 fire can occasionally go silent
- under heavy automatic fire near another looping sound (D240/D241).
-- **`All unlocked` is highly experimental — don't enable it until you have
- at least one save written** (complete a level normally first, e.g. Dam on
- Agent). Enabling it on a brand-new install with no prior save can still
- cause silent audio and odd right-mouse-aim behavior (D257/D259/D281).
+- With native widescreen on, the F10 options overlay stretches with the
+ window instead of pillarboxing like the front-end menus (legible; cosmetic;
+ the F10 *Native widescreen* toggle restores the old stretched frame
+ throughout). The world/HUD widescreen rendering itself is correct (D335b).
+- The Rareware front-end logo shows a subtle texture-filtering artifact
+ (Nintendo logo and legal page are clean). Cosmetic only (D75).
+- This release ships NTSC (US) assets; PAL/JP ROMs are not supported in this
+ version (D258).
+- **`All unlocked` is experimental: back up `data/ge007.eep` before enabling
+ it.** Any save while ON, even a profile-settings change, can permanently
+ write artificial cheat unlocks and completion times into the EEPROM;
+ switching OFF does not undo them (D387). Do not use it on a save whose
+ original progression you need to preserve.
- Assorted further cosmetic defects are tracked in
[`docs/dev/GRAPHICS-BACKLOG.md`](docs/dev/GRAPHICS-BACKLOG.md).
-- No macOS or ARM support; no controller rebinding UI.
+- No macOS or ARM support. Keyboard/mouse rebinding shipped in v0.4.0;
+ controller-button rebinding is not supported yet.
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).
@@ -151,23 +162,23 @@ and the finding log in [`docs/dev/findings.md`](docs/dev/findings.md).
The Linux bundle is the Deck build. SFTP it over from your PC, or download
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
+extract, 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.
-**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), and a separate intermittent SIGSEGV
-in heavy firefights/terminal destruction (D255) and an audio-thread crash
-(D305) are both fixed and live-verified on real hardware as of this release.
+defaults: native 1280×800 fullscreen, VSync, 2× MSAA, and 250% draw/LOD
+distance (the N64-authored fade distances read short on the close-up 7"
+panel); 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), and a separate intermittent SIGSEGV in heavy
+firefights/terminal destruction (D255) and an audio-thread crash (D305) are
+both fixed and live-verified on real hardware as of this release.
**In-game settings on the Deck.** The options overlay is fully gamepad-driven:
it opens with **Select**, the D-pad or left stick (up/down) moves between
@@ -176,6 +187,8 @@ 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
@@ -184,10 +197,8 @@ 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.
-- **Native widescreen** — a distortion-free render at your display's aspect
- (correct-aspect projection and a wide-frame HUD), replacing today's
- stretched 4:3 frame + FOV compensation.
-- **Controller rebinding UI**, and macOS/ARM builds.
+- **Controller (pad) button rebinding UI** (keyboard/mouse rebinding shipped
+ in v0.4.0), 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.
@@ -198,11 +209,14 @@ Not currently planned: new game modes GE never shipped (e.g. co-op), online (non
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,
- frame cap, MSAA, texture filtering, FOV/draw distance and mouse feel;
- launch with `-fresh` for a clean-slate run.
+ frame cap, MSAA, texture filtering, FOV/draw distance, mouse feel, key
+ rebinding, crosshair and vibration; launch with `-fresh` for a clean-slate
+ run.
- **Read it**: [`docs/internals.md`](docs/internals.md) maps the
architecture and the software RSP; [`docs/porting-notes.md`](docs/porting-notes.md)
is the catalogue of N64→PC bug classes hit along the way. Game logic in
@@ -213,6 +227,8 @@ it helps prioritize.
the ground rules for getting changes in, and [`docs/dev/`](docs/dev/) is
the raw engineering record behind every fix.
+---
+
## Background
The port was built by two coding agents, a local open-weight model
@@ -222,8 +238,8 @@ asset pipeline, first frames), and **Claude Code** (Sonnet 5, Opus 5 for the
hardest bugs) joining for the collaborative phase (the 21-level sweep, the
ABI finding catalog, SDL input, front end), handing work back and forth
through shared written notes, directed by one person part-time. In short:
-36 days (16 Aug – 20 Sep), ~835 commits, 199 findings root-caused and
-logged (`D1`–`D321`).
+43 days (16 Aug – 28 Sep), 997 commits, 286 findings root-caused and logged
+(`D1`–`D408`).
The full write-up (timeline, handoff mechanism, effort breakdown, an honest
"what worked / what didn't"): [`docs/dev/agentic-development.md`](docs/dev/agentic-development.md).
@@ -231,6 +247,8 @@ The workflow itself: [`docs/dev-process.md`](docs/dev-process.md). To cite the
project or its findings: [`CITATION.cff`](CITATION.cff) (GitHub's "Cite this
repository" menu).
+---
+
## How this differs from the other GoldenEye PC projects
This is a native port of the original 1997 Nintendo 64 game, built from its
@@ -246,13 +264,15 @@ 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 |
| **Renderer** | Software RSP → OpenGL | Hardware (Vulkan) |
-| **Status** | v0.3.0 public release; full campaign playable at 60 fps (see [Status](#status)) | Playable full game |
+| **Status** | v0.4.0 public release; full campaign playable at 60 fps (see [Status](#status)) | Playable full game |
| **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
machine translation. This project answers how to get the *original 1997
game* onto PC, running from its reconstructed source.
+---
+
## Requirements
You need a GoldenEye 007 (Nintendo 64) ROM that you legally own, in
@@ -272,6 +292,8 @@ The port also relies on the decompilation's asset-extraction step, which pulls
the level, model, texture and music data out of your ROM at build time. That
step, too, requires your ROM and is part of [Building](#building).
+---
+
## Building
Prerequisites: CMake >= 3.16, a C/C++ toolchain, SDL2, zlib, OpenGL, Python 3,
@@ -311,6 +333,8 @@ cd goldeneye-pc-port
The executable is written to `build-pc/ge007.x86_64` (on Windows,
`build-pc/ge007.x86_64.exe`).
+---
+
## Running
1. Create a `data/` directory in the repo root.
@@ -329,21 +353,33 @@ lives in `ge007.eep`. Launch with `-fresh` to wipe both before starting
|---------------------|--------------------------|---------------|
| Move / strafe | `W` `A` `S` `D` / arrows | Left stick (or D-pad) |
| Aim / look | Mouse | Right stick |
-| Fire (Z) | Left mouse / `LCtrl` | Right trigger |
-| Aim mode (R) | Right mouse / `LShift` | Left trigger |
-| Use / accept (A) | `Space` / `E` / `X` | A / X |
-| Reload / cancel (B) | `R` / `F` | B / Y |
-| Next weapon | Mouse wheel up | RB |
-| Previous weapon | Mouse wheel down | LB |
+| Fire | Left mouse | Right trigger |
+| Aim mode | Right mouse / `LShift` | Left trigger / LB |
+| Use / interact | `E` | A |
+| Reload | `R` | X |
+| Crouch | `LCtrl` | Left or right stick click |
+| Cycle owned gadgets | Watch inventory | B |
+| Next weapon | Mouse wheel down / `Q` | Y |
+| Previous weapon | Mouse wheel up | — |
| Start | `Enter` / `Tab` | Start |
-| Options overlay | `F10` | Select (D-pad/stick + A/B navigate, Start closes) |
+| Options overlay | `F10` | Select (A toggles/steps; B backs; D-pad/stick adjusts sliders; Start closes) |
-The controller layout follows the modern dual-stick scheme used by the
-console re-releases (left stick move, right stick look, triggers fire/aim,
-shoulders cycle weapons).
+The default layout is the GEPD-style preset on the keyboard and the standard
+dual-stick scheme on controllers (left stick move, right stick look,
+triggers fire/aim, A use, X reload, B gadget cycle, Y weapon cycle, stick
+click crouch). The watch gadget cycle selects the next owned gadget in the
+N64 inventory. The controller layout matches Rare's Xbox 1.1 (Jinx) button
+roles (user-tested on a physical pad; D394), and the remaining Xbox schemes
+(1.2 Christmas, 1.3 Frost, 1.4 Elektra) are planned as selectable presets
+and not yet available. Keyboard → Bindings edits **keyboard/mouse only**; a
+controller can navigate those pages and use B to go back, but
+controller-button rebinding is not supported yet.
Mouse sensitivity, Y-inversion and the aim/turn split are tunable in the
-`[Input]` section of `ge007.ini`.
+F10 overlay (*Keyboard → Sensitivity*) or the `[Input]` section of
+`ge007.ini`.
+
+---
## How it works
@@ -376,6 +412,8 @@ tools_pc/ PC-port helper + analysis scripts
docs/ see below
```
+---
+
## Documentation
Key docs are also published as a site:
@@ -391,6 +429,8 @@ Key docs are also published as a site:
| [`docs/dev/`](docs/dev/) | The raw engineering record: the full finding log, per-level status, graphics backlog, playtest matrices, and [`docs/dev/game-behavior-reference.md`](docs/dev/game-behavior-reference.md) (a secondary-sourced playtest reference for how the retail game is meant to behave; repo-only, code is ground truth). |
| [`docs/SetupGuide.md`](docs/SetupGuide.md), [`docs/StyleGuide.md`](docs/StyleGuide.md) | Inherited from the decompilation this repo forks. |
+---
+
## Credits
This port is a thin layer on a large amount of other people's work.
@@ -448,6 +488,8 @@ This port is a thin layer on a large amount of other people's work.
- [pi](https://pi.dev/): the local coding-agent harness.
- [Claude / Claude Code](https://claude.com/claude-code) (Anthropic).
+---
+
## Legal
This is a non-commercial fan preservation/research project, in the same
@@ -480,3 +522,7 @@ The original work in this repository, the port layer (`port/`), the PC build
system, `tools_pc/`, and the documentation, is released under the MIT License;
see [`LICENSE`](LICENSE). Everything inherited from the upstream decompilation
is covered by [`NOTICE`](NOTICE), not by that license.
+
+---
+
+*Last updated 2026-09-28 — v0.4.0 · GitHub · Releases *
diff --git a/build-pc.sh b/build-pc.sh
index 34a6388c..6e0cc3a8 100644
--- a/build-pc.sh
+++ b/build-pc.sh
@@ -14,7 +14,7 @@
# Example (Linux):
# sudo apt install cmake libsdl2-dev zlib1g-dev libgl1-mesa-dev
# Example (macOS):
-# brew install cmake sdl2 zlib
+# brew install cmake libsdl2-dev
# Example (Windows/MSYS2):
# pacman -S mingw-w64-x86_64-toolchain mingw-w64-x86_64-SDL2 \
# mingw-w64-x86_64-zlib mingw-w64-x86_64-cmake
@@ -24,6 +24,132 @@ set -euo pipefail
ROMID="${1:-ntsc-final}"
BUILD_DIR="${BUILD_DIR:-build-pc}"
+# ---------------------------------------------------------------------------
+# Windows toolchain env guard (see AGENTS.md "Build" — the recurring
+# "Cannot create temporary file in C:\Windows\: Permission denied" link
+# failure).
+#
+# The PE toolchain (ninja -> cmd -> gcc/ld) creates temp files under the
+# child's TMP/TEMP. A full MSYS2 MINGW64 login shell arranges that for
+# native children; other shells (agent harnesses, non-login shells) do not
+# -- the msys->native env conversion silently drops/breaks TMP and the
+# linker falls back to C:\Windows\. Probe what a native child actually
+# sees (via a file: msys console emulation makes piped cmd.exe stdout
+# unreliable), and if broken, re-run the cmake+build steps under
+# PowerShell. Crucially the temp vars are set INSIDE PowerShell: the broken
+# boundary is msys->native, while native->native (powershell -> cmake ->
+# ninja) passes env vars through intact.
+# GE_PC_BUILD_VIA_NATIVE=1 prevents a re-exec loop.
+# ---------------------------------------------------------------------------
+if [ -n "${MSYSTEM:-}" ] && [ -z "${GE_PC_BUILD_VIA_NATIVE:-}" ]; then
+ _probe="$(mktemp -d "${TMPDIR:-/tmp}/gebuildprobe.XXXXXX")"
+ _probe_win="$(cygpath -w "${_probe}" 2>/dev/null || echo 'C:\\msys64\\tmp')"
+ /c/Windows/system32/cmd.exe //c "if exist \"%TMP%\" (echo ok > \"${_probe_win}/ok.txt\") else (echo bad > \"${_probe_win}/bad.txt\")" >/dev/null 2>&1 || true
+ if [ ! -s "${_probe}/ok.txt" ]; then
+ command -v powershell >/dev/null 2>&1 || {
+ echo "ERROR: native TMP is unusable from this shell and powershell.exe was not found for the fallback." >&2
+ echo "Manual fix: run cmake+ninja from cmd/PowerShell with C:\\msys64\\mingw64\\bin on PATH, or" >&2
+ echo "set the user env vars (setx TMP \"C:\\msys64\\tmp\"; setx TEMP \"C:\\msys64\\tmp\") and re-open the shell." >&2
+ rm -rf "${_probe}"; exit 1
+ }
+ echo "==> native TMP unusable from this shell; re-running cmake+build via PowerShell"
+ export GE_PC_BUILD_VIA_NATIVE=1
+ _here="$(pwd -W 2>/dev/null || cygpath -w "$(pwd)")"
+ # Resolve the Windows-side toolchain paths from what msys found (no
+ # hardcoded install dir: C:\msys64 on this box, D:\M\msys64
+ # elsewhere). usr/bin first: mingw gcc.exe needs msys-2.0.dll from
+ # there.
+ _cmake_root="$(dirname "$(command -v cmake)")" # .../mingw64/bin
+ _msys_root="$(cd "${_cmake_root}/../.." && pwd -W 2>/dev/null || cygpath -w "$(cd "${_cmake_root}/../.." && pwd)")" # msys install root (.. twice: bin -> mingw64 -> root)
+ _bin_w="$(cygpath -w "${_cmake_root}")"
+ _usrbin_w="$(cygpath -w "${_msys_root}/usr/bin")"
+ # The PowerShell script is written to a .ps1 FILE, not passed via
+ # -Command: the msys->native argv conversion mangles $-bearing
+ # strings (a '\$LASTEXITCODE' check arrived as an empty token and a
+ # failed cmake would have exited 0 silently). A file write is
+ # conversion-free; only plain path/word arguments cross the boundary.
+ _ps1="$(mktemp "${TMPDIR:-/tmp}/ge007-rerun.XXXXXX.ps1")"
+ cat > "${_ps1}" <<'PS1'
+param([string]$UsrBin, [string]$Bin, [string]$MsysRoot, [string]$Here, [string]$BuildDir, [string]$RomId)
+# Self-logging: a headless shell loses this process's console output, so
+# every fact that matters goes to diag/log files in the build dir.
+$diag = Join-Path $BuildDir "ge007-native-reexec-diag.log"
+$cmakeLog = Join-Path $BuildDir "ge007-native-reexec-cmake.log"
+$buildLog = Join-Path $BuildDir "ge007-native-reexec-build.log"
+# The inherited native PATH may be mangled/dropped by the broken msys->native
+# env conversion, so build it explicitly: the toolchain only needs the two
+# msys bin dirs plus the usual system dirs. (usr/bin first: mingw gcc/cmake
+# need msys-2.0.dll from there.)
+$env:PATH = "$UsrBin;$Bin;C:\Windows\System32;C:\Windows;C:\Windows\System32\Wbem"
+# Writable temp for the native toolchain. [System.IO.Path]::GetTempPath()
+# CANNOT be used here: it honours the inherited (broken) TMP/TEMP env vars
+# and returned C:\Windows\. Pick the first writable candidate instead.
+$tmpdir = $null
+foreach ($cand in @((Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'Temp'), (Join-Path $MsysRoot 'tmp'))) {
+ try {
+ if (-not (Test-Path $cand)) { New-Item -ItemType Directory -Force -Path $cand -ErrorAction Stop | Out-Null }
+ $probe = Join-Path $cand 'ge007-tmp-write-test.tmp'
+ 'x' | Out-File -FilePath $probe -ErrorAction Stop
+ Remove-Item $probe -ErrorAction SilentlyContinue
+ $tmpdir = $cand
+ break
+ } catch { }
+}
+if ($null -eq $tmpdir) { "no writable temp dir found" | Out-File $diag -Encoding ascii; Write-Host 'no writable temp dir for the native toolchain'; exit 4 }
+$env:TMP = $tmpdir
+$env:TEMP = $tmpdir
+"start UsrBin=[$UsrBin] Bin=[$Bin] MsysRoot=[$MsysRoot] Here=[$Here] BuildDir=[$BuildDir] RomId=[$RomId] tmp=[$tmpdir]" | Out-File $diag -Encoding ascii
+$exe = "$Bin\cmake.exe"
+"exe=[$exe] exists=[$(Test-Path -LiteralPath $exe)]" | Out-File $diag -Append -Encoding ascii
+Set-Location $Here
+"cwd=[$([System.IO.Directory]::GetCurrentDirectory())]" | Out-File $diag -Append -Encoding ascii
+try {
+ # -DROMID must be a DOUBLE-QUOTED string: a bare `-DROMID=$RomId` token
+ # does NOT expand the variable in PowerShell (cmake got the literal
+ # "$RomId" and failed with "Unknown ROMID").
+ $out = & $exe -S . -B $BuildDir "-DROMID=$RomId" 2>&1
+ "cmake ran LASTEXITCODE=[$LASTEXITCODE]" | Out-File $diag -Append -Encoding ascii
+ $out | Out-File -FilePath $cmakeLog -Encoding ascii
+} catch {
+ "cmake TERMINAL ERROR: $($_.Exception.Message)" | Out-File $diag -Append -Encoding ascii
+}
+if ($null -eq $LASTEXITCODE) { Write-Host "cmake.exe did not execute (diag: $diag)"; exit 2 }
+if ($LASTEXITCODE -ne 0) { Write-Host "cmake re-configure failed (rc=$LASTEXITCODE; log: $cmakeLog)"; exit $LASTEXITCODE }
+Write-Host "cmake reconfigured (TMP=$($env:TMP))"
+try {
+ $out = & $exe --build $BuildDir -j 2>&1
+ "build ran LASTEXITCODE=[$LASTEXITCODE]" | Out-File $diag -Append -Encoding ascii
+ $out | Out-File -FilePath $buildLog -Encoding ascii
+} catch {
+ "build TERMINAL ERROR: $($_.Exception.Message)" | Out-File $diag -Append -Encoding ascii
+}
+if ($null -eq $LASTEXITCODE) { Write-Host "cmake --build did not execute (diag: $diag)"; exit 3 }
+if ($LASTEXITCODE -ne 0) { Write-Host "build failed (rc=$LASTEXITCODE; log: $buildLog)"; exit $LASTEXITCODE }
+"done" | Out-File $diag -Append -Encoding ascii
+Write-Host "cmake + build ok (native re-exec, TMP=$($env:TMP))"
+PS1
+ _ps1_win="$(cygpath -w "${_ps1}")"
+ rc=0
+ # NOTE: do NOT launch powershell via `env -i` (tried 2026-09-28): a
+ # native child started with a scrubbed msys env cannot launch
+ # cmake.exe (CreateProcess fails silently; LASTEXITCODE stays null).
+ powershell -NoProfile -ExecutionPolicy Bypass -File "${_ps1_win}" "${_usrbin_w}" "${_bin_w}" "${_msys_root}" "${_here}" "${BUILD_DIR}" "${ROMID}" || rc=$?
+ rm -f "${_ps1}"
+ if [ "${rc}" -ne 0 ]; then
+ # Surface the .ps1's own diagnostics (console output is lost in a
+ # headless shell) plus whatever the failing step wrote.
+ for _lg in "${BUILD_DIR}/ge007-native-reexec-diag.log" "${BUILD_DIR}/ge007-native-reexec-cmake.log" "${BUILD_DIR}/ge007-native-reexec-build.log"; do
+ if [ -s "${_lg}" ]; then
+ echo "---- ${_lg} (last 40 lines) ----" >&2
+ tail -40 "${_lg}" >&2
+ fi
+ done
+ fi
+ exit "${rc}"
+ fi
+ rm -rf "${_probe}"
+fi
+
echo "==> Configuring PC port (ROMID=${ROMID})"
cmake -S . -B "${BUILD_DIR}" -DROMID="${ROMID}"
diff --git a/docs/_config.yml b/docs/_config.yml
index c4e66b6c..14895403 100644
--- a/docs/_config.yml
+++ b/docs/_config.yml
@@ -6,8 +6,10 @@ title: GoldenEye 007 PC Port
# long version made a ~230-char title. It also renders as the masthead
# tagline on every page. Per-page meta/og descriptions come from each page's
# front matter `description:` (see index.md), not from here.
-description: N64 GoldenEye 007, decompiled for PC.
-theme: jekyll-theme-cayman
+description: GoldenEye 007 (Nintendo 64, 1997) on the PC - decompiled, ported, and playable.
+# No GitHub theme: the site uses a hand-rolled _layouts/default.html plus the
+# design in _includes/head-custom.html (charcoal/off-white/amber, square and
+# flat.)
show_downloads: false
# Canonical site location - used by jekyll-seo-tag (canonical URLs, og: tags)
diff --git a/docs/_includes/head-custom.html b/docs/_includes/head-custom.html
index 253dcf8d..7f7e687c 100644
--- a/docs/_includes/head-custom.html
+++ b/docs/_includes/head-custom.html
@@ -3,267 +3,242 @@
-
+
diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html
new file mode 100644
index 00000000..f1fd73f4
--- /dev/null
+++ b/docs/_layouts/default.html
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+ {%- seo -%}
+ {%- include head-custom.html -%}
+
+
+
+
+
+ {{ content }}
+
+
+
+
+
diff --git a/docs/dev/CONTROLLER-INPUT-PLAN.md b/docs/dev/CONTROLLER-INPUT-PLAN.md
new file mode 100644
index 00000000..d6e6ff7c
--- /dev/null
+++ b/docs/dev/CONTROLLER-INPUT-PLAN.md
@@ -0,0 +1,246 @@
+# Controller & input options wave (port plan)
+
+Status: **Wave A IMPLEMENTED** (code complete, clean Windows/MSYS2 build; pending
+user by-ear/by-eye feel-check + Deck tuning of item 5) — the "low-hanging
+fruit" from the Nightdive *Turok* PC-port options menu that is cheap, well-
+scoped, and
+**port-layer only** (zero `src/` / `src/game` edits → rule 2 and the ABI
+exception do not apply). Sequenced after v0.4.0 ships. This is the *input*
+wave; the sibling *video/audio/graphics* Tier-A items (borderless window,
+primary display, brightness, master volume, button glyphs, uncapped-FPS) are a
+separate follow-on wave — see §Out of scope.
+
+Reference: `docs/dev/RUMBLE-PLAN.md` (D401) is the closest precedent — a
+controller option that lives entirely in `port/src/input.c` +
+`port/src/optionsoverlay.c` + a `configRegister*` key + a `kResetDefaults`
+entry, with the game side (`src/joy.c`, `src/` stick consumers) untouched.
+`docs/dev/GEPD-INPUT-PLAN.md` documents the pad poll path this wave extends.
+
+---
+
+## Ground truth (current state, verified 2026-09-27)
+
+**Pad poll = `inputComputePad()`** (`port/src/input.c:1375`), runs on the game
+thread (`osContStartReadData`). It reads the SDL axes/buttons for pad `idx`,
+writes out a 16-bit button mask plus `*stick_x` / `*stick_y` (the N64
+`signed char` stick, clamped to `STICK_MAX`). All the knobs in this wave are
+applied **inside** `inputComputePad`, before the two `*stick_x`/`*stick_y`
+writes at `:2063-2064` — i.e. the port re-shapes the *input the game already
+consumes*, never the game's stick→turn math.
+
+**Two pitch modes drive the look stick** (selected by `Input.NaturalPitch`,
+the default-on "SOLITARE" mode, D194/D238):
+- **Continuous (natural-pitch, the default):** the **right stick** is the
+ continuous look axis — `input.c:1927-1933`:
+ `rxs = scaleAxis(rx);` … `rys = -scaleAxis(ry); if (padLookInvertY)
+ rys = -rys; sx = rxs; sy = rys;`. The left stick becomes digital-step
+ movement. **This is the path the X/Y look-sensitivity + look-smoothing +
+ right-stick deadzone knobs target.**
+- **Digital (native, natural-pitch off):** the left stick is analog
+ movement/turn and the right stick is digitized into the C-button look
+ (`:1935-1947`, thresholds at `RSTICK_THRESHOLD`). Look here is the faithful
+ N64 *digital* C-button turn — **not sens-scalable** (there is no analog look
+ to scale), so the new look-sens/smoothing knobs are inert in this mode,
+ exactly as `Input.AimMode`/`Input.AimRange` already are per mode.
+
+**Existing pad knobs** (all `configRegister*` in `input.c`, rows in
+`optionsoverlay.c` INPUT section): `Input.PadDeadzone` (single,
+`padDeadzone = STICK_DEADZONE`, `:524`), `Input.PadTriggerPct` (`:525`),
+`Input.PadLookInvertY` (`:526`), `Input.RumbleScale` (D401). `scaleAxis()`
+applies the shared deadzone + `STICK_MAX` scale to a raw `int16` axis.
+
+**Already covered elsewhere** (do not re-plan): mouse sens/invert/smoothing
+(`Input.MouseSensitivity`/`MouseInvertY`/`MouseSmoothing`), **uncapped FPS**
+(`docs/dev/UNLOCKED-FPS-PLAN.md` — parked; has a real perf prerequisite, D250),
+**controller rebinding** (PR #109 / D383 — the hard, open gap, out of scope).
+
+---
+
+## Items (all port-layer)
+
+### 1. `Input.PadLookSensX` — right-stick look **horizontal** sensitivity *(S)*
+New int key, default **100** (= 1×, current behaviour), range ~**25–200 %**.
+Applied in the continuous-look branch only: after `rxs = scaleAxis(rx)`,
+`rxs = clamp(rxs * padLookSensX / 100, ±STICK_MAX)` before `sx = rxs`
+(`input.c:1928/1932`). Pure gain on the emitted look X. In digital mode: inert.
+- Row: INPUT, "Look sensitivity (left/right)", slider, after "Rumble strength".
+- Risk: none (monotonic gain; 100% = byte-identical to today). Deck-testable.
+
+### 2. `Input.PadLookSensY` — right-stick look **vertical (pitch)** sensitivity *(S)*
+Same shape, applied to `rys` (`input.c:1930/1933`): `rys = clamp(rys *
+padLookSensY / 100, ±STICK_MAX)`. Default **100**, range ~**25–200 %**.
+Kept separate from X (Turok splits them; pitch and yaw feel different on a
+stick). Independent of the existing `Input.HipfirePitchSpeed` (that one is the
+hipfire/mouse pitch rate, a different path). In digital mode: inert.
+- Row: INPUT, "Look sensitivity (up/down)", slider.
+- Risk: none (same as item 1).
+
+### 3. `Input.PadSouthpaw` — swap L/R triggers *(S)*
+New toggle, default **off**. Today (`input.c:1954-1958`): `TRIGGERRIGHT >
+trigPt → GE_CONT_G`; `TRIGGERLEFT > trigPt → GE_CONT_R`. Under southpaw, swap
+the two `GE_CONT_G`/`GE_CONT_R` assignments (left trigger becomes the
+right-shoulder action and vice-versa). Two lines. Lets left-handed /
+southpaw players put their index finger on the primary fire trigger.
+- Row: INPUT, "Southpaw", toggle.
+- Risk: none at default off; trivial to verify by re-aiming.
+
+### 4. `Input.PadDeadzoneL` / `Input.PadDeadzoneR` — per-stick deadzone *(S–M)*
+Split the single `Input.PadDeadzone` into a **left (movement)** and a
+**right (look)** deadzone. Thread the deadzone through `scaleAxis()` (it is
+currently the global `padDeadzone`) as a per-call argument: left-stick calls
+use `padDeadzoneL`, right-stick calls use `padDeadzoneR`.
+- **Back-compat/migration:** on load, if `Input.PadDeadzoneL`/`R` are absent
+ but legacy `Input.PadDeadzone` is set, seed both from it (one-time, like the
+ `AimStyle`→`AimMode` migration at `input.c:449-454`). Keep `Input.PadDeadzone`
+ registered so old inis keep loading; it becomes hidden/superseded (D337/`D333`
+ precedent for a superseded key).
+- Rows: INPUT, "Deadzone (left stick)" + "Deadzone (right stick)", sliders.
+- Risk: low — a deadzone is a per-axis floor; splitting it cannot change
+ behaviour when both equal the current single value. Watch the menu-navigation
+ path (`:1918-1922`, left stick in menus) to use the **left** deadzone there.
+
+### 5. `Input.PadLookSmoothing` — right-stick look low-pass *(M)*
+New key, default **0 (off)**, range 0–10 (0 = off = today). When >0, low-pass
+filter the *emitted* right-stick look deflection so the look axis eases rather
+than tracking the stick 1:1 — the pad analogue of `Input.MouseSmoothing`
+(which smooths the *relative* mouse delta). Because the continuous stick is
+**absolute** (not a delta), implement as a per-pad exponential moving average
+on the emitted `rxs`/`rys` (state kept per `idx`, reset on pad hot-unplug and
+on aim-mode exit so releasing the stick recentres cleanly). The higher the
+value, the heavier the easing.
+- Row: INPUT, "Look smoothing", slider (0 = off).
+- Risk: **medium** — an EMA on an absolute stick can feel laggy or fail to
+ recentre if the filter isn't driven back to 0 when the stick is centred;
+ needs hands-on tuning on the Deck (the natural test device). Keep default off
+ so it ships inert until tuned.
+
+### 6. Mouse **vertical** sensitivity row *(trivial — folds in)*
+`Input.MouseYScale` already exists as a registered key; it just has no F10
+row. Add one slider row (INPUT) so the asymmetric X/Y mouse sensitivity is
+user-adjustable without editing the ini. No `input.c` change — row only.
+
+---
+
+## Shared plumbing (do once, not per item)
+- **`configRegisterInt`/`Float`** for each new key in `input.c` next to the
+ existing pad-key registrations (~`:2536-2538`).
+- **Static state** next to `padDeadzone`/`padLookInvertY` (`:524-526`):
+ `padLookSensX/Y`, `padSouthpaw`, `padDeadzoneL/R`, `padLookSmoothing` + the
+ per-pad EMA state for item 5.
+- **F10 rows** in `optionsoverlay.c` `rows[]` INPUT section (after
+ "Rumble strength", with the other controller rows) + matching **front options**
+ rows via the shared `optionsRow*` accessors (both UIs read `rows[]` — D401
+ precedent, so one definition covers F10 *and* the front options screen).
+- **`kResetDefaults`** entries for each new key (neutral: sens 100, smoothing
+ 0, southpaw 0, deadzones = `STICK_DEADZONE`) so INPUT "Reset to defaults"
+ covers them (D401 item-3 precedent).
+
+## Ground rules
+1. **Port-layer only** — every edit is in `port/src/input.c`,
+ `port/src/optionsoverlay.c`, `port/src/frontoptions.c`, and `CMakeLists.txt`
+ (nothing new to compile — no new files). Zero `src/` / `src/game` edits →
+ rule 2 and the pointer-width ABI exception are not engaged.
+2. **Off-by-default = today's behaviour.** Every new key defaults to a neutral
+ value that leaves the emitted stick/buttons byte-identical to the current
+ build, so the golden gate (D117/`golden_gate.sh`) is unaffected at defaults
+ and the v0.4.0 playtest baseline is preserved.
+3. **Per-mode honesty.** The continuous-look knobs (1, 2, 5) apply only in
+ natural-pitch mode; in digital mode they are inert — document this on the
+ rows (mirrors how `Input.AimRange` is hidden in Centred mode, D338), rather
+ than pretending they affect the digital C-button look.
+4. **Deck is the reference device** for the pad wave (native Steam Controller,
+ the real-world target); Windows + a plugged gamepad as the second check.
+
+## Verification
+1. `./build-pc.sh ntsc-final` clean (no new symbols/dupes — the `configRegister*`
+ + `kResetDefaults` additions are the only new references).
+2. `/linkcheck` + the golden gate (`tools_pc/golden_gate.sh`) — must pass
+ **unchanged at defaults** (proves the neutral-default invariant).
+3. Hands-on, on a pad / the Steam Deck: each knob at a non-neutral value
+ changes feel as expected and at default leaves it identical; southpaw
+ re-maps the triggers; per-stick deadzones floor the correct stick; look
+ smoothing eases and **recentres on stick release** (item 5's known risk).
+4. New finding (D4xx, next free label) recording the wave + per-item
+ Deck-test result, cross-ref'd to D401 (precedent), D337/D338 (per-mode
+ gating pattern), D333 (`AimStyle` migration pattern), PR #109 (out-of-scope
+ rebind).
+
+## Out of scope for this wave (see the assessment above)
+- **Sibling Tier-A, other sections (separate follow-on wave):** borderless
+ windowed, primary-display selection, brightness slider, master audio volume,
+ button-prompt glyphs.
+- **Tier B (medium, contained):** radial fog toggle, HUD opacity, flash-
+ intensity slider (promote `Game.NoHitFlash`), show-HUD master toggle, audio
+ output device, low-pass filter, SMAA/FXAA.
+- **Uncapped FPS** — `docs/dev/UNLOCKED-FPS-PLAN.md` (parked; D250 perf
+ prerequisite means it is not quick).
+- **Tier C (rule-2 / big-feature / out-of-scope):** head-bobbing sliders,
+ death-cinematic / switch-on-pickup / walk toggles (all `src/game` flow),
+ bloom / light-scattering / water refraction-reflection / simple shadows,
+ violence / blood colour, multiple graphics APIs, and **full controller
+ rebinding** (PR #109 — the genuinely hard input item).
+
+## Suggested sequencing (v0.5.0)
+1. **Wave A (this doc):** items 1–4 + 6 (sens X/Y, southpaw, per-stick
+ deadzone, mouse-Y row) — all low-risk, shippable behind neutral defaults.
+2. **Item 5 (look smoothing)** lands in the same wave but may ship default-off
+ pending Deck feel-tuning.
+3. **Wave B:** the sibling video/audio/graphics Tier-A items.
+4. Revisit Tier C only with explicit sign-off (rule-2 items need the
+ `docs/dev-process.md` procedure).
+
+---
+
+## Implementation status (2026-09-28)
+
+Wave A code is complete and compiles clean (Windows/MSYS2 `ninja` incremental,
+exit 0, no new warnings — only the pre-existing `ramrom.c` / `gfx_pc.cpp` ones).
+Every edit is in `port/src/input.c` + `port/src/optionsoverlay.c`;
+`frontoptions.c` needed **no** change (both UIs read the shared `rows[]` via the
+`optionsRow*` API). Zero `src/` edits, no new files/symbols — only file-local
+statics + `configRegisterInt` calls — so the link is unchanged.
+
+Implemented (all off/neutral-by-default → byte-identical at defaults):
+- **1/2. `Input.PadLookSensX` / `Input.PadLookSensY`** (25–200 %, dflt 100):
+ right-stick look, applied in the natural-pitch branch of `inputComputePad()`
+ after `scaleAxis()`, re-clamped to `STICK_MAX`. Inert in digital mode (the
+ right stick is digitized there, no analog look to scale).
+- **3. `Input.PadSouthpaw`** (toggle, dflt 0): swaps the fire (`GE_CONT_G`)
+ and grenade (`GE_CONT_R`) trigger actions *after* the raw edges are captured,
+ so edge-detection stays consistent.
+- **4. `Input.PadDeadzoneL` / `Input.PadDeadzoneR`** (0–30000, dflt 7000):
+ `scaleAxis()` now takes a per-stick deadzone param; left-stick (movement)
+ calls pass `L`, right-stick (look) calls pass `R`. The legacy
+ `Input.PadDeadzone` key stays registered (no row) so old inis load; a
+ one-time migration in `inputInit()` seeds any unset L/R (C-init sentinel
+ `PAD_DZ_UNSET = -1`) from the legacy value, before any `configSave`. The
+ single "Stick deadzone" row is replaced by "Deadzone (left stick)" /
+ "(right stick)".
+- **5. `Input.PadLookSmooth`** (0–10, dflt 0 = off, shown "N/10"): per-pad EMA
+ low-pass on the natural-pitch look output (`padSmSX/padSmSY[MAX_PADS]`), reset
+ on hot-unplug (`inputRescanPads`) so a snapped-back stick can't lag from a
+ stale value; otherwise the EMA decays to 0 as the stick recentres. Turok has
+ this as on/off; GE ships a graduated 0-10 knob instead. Ships **default-off**
+ pending Deck feel-tuning.
+- **6. `Input.MouseYScale`** row (dflt 100): the pre-existing registered key
+ finally gets an INPUT slider row ("Mouse vertical sensitivity") — row only,
+ no `input.c` change.
+
+**Verbiage + display baseline (Turok PC, 2026-09-28):** row labels now match the
+Turok options menu verbatim where GE has an analogue — *Mouse horizontal /
+vertical sensitivity*, *X axis / Y axis look sensitivity*, *Look smoothing*,
+*Southpaw*, *Vibration* (was "Rumble strength"), *Deadzone (left/right stick)*.
+The display is baselined to Turok's scales: sensitivity + deadzone knobs read
+**0-100 with 50 = the default** (the existing `calibratedDefault` "N/100" path),
+and look smoothing reads **0-10** ("N/10"). (Turok's Mouse/Controller subsection
+split is not mirrored — GE keeps one flat INPUT section per D356, so the two
+invert rows retain their "(mouse)"/"(controller)" suffixes to stay unambiguous.)
+
+`kResetDefaults` covers all six so INPUT "Reset to defaults" is complete;
+`calibratedDefault()` gives the sensitivity + deadzone rows their 50/100 default
+midpoint, and `dispMax` renders the smoothing knob as "N/10".
+
+**Still owed (verification, not code):** user by-ear/by-eye feel-check of the
+new knobs on a real pad (esp. item 5 smoothing strength + item 1/2 sens range),
+the Deck cross-check, and confirming the per-stick deadzone migration on an
+old-ini user (L/R seed from `Input.PadDeadzone`).
diff --git a/docs/dev/CUT-CONTENT-BACKLOG.md b/docs/dev/CUT-CONTENT-BACKLOG.md
index 13b12787..b8e57282 100644
--- a/docs/dev/CUT-CONTENT-BACKLOG.md
+++ b/docs/dev/CUT-CONTENT-BACKLOG.md
@@ -16,6 +16,48 @@ export at `scratchpad/backlog-scrape/restore-cut-content-mod/` (gitignored).
> table as pickup+prop pairs except heroine; (4) TCRF's 11 unused cheats, music
> track index, MP character table and build dates all verify 1:1 against
> `bondconstants.h`/`cheat.c`/`front.c`/`compiletime.c`.
+>
+> **Round 2 (decomp-only discoveries — not in TCRF's documentation):**
+>
+> 1. **The shipped game still calls the M16 an "AR33"** — pickup text
+> `"an AR33 assault rifle."` (`LpropobjE.c` PROPOBJ_STR_2A, used at
+> `propobj.c:10471`), plus unused strings `"US AR33 Assault Rifle"`/
+> `"US AR33"` (`LgunE.c` GUN_STR_74/75) and `PROP_CHRM16 /* AR33 Assault
+> Rifle */` comments. TCRF's prerelease page mentions the AR33 name in early
+> footage but not that it survived into retail text.
+> 2. **Klobb rename trace verified**: weapon-name strings in `LgunE.c` are
+> alphabetical except GUN_STR_6B `"Klobb"` (between "Spy File" and "Staff
+> List") — exactly the last-minute Skorpion→Klobb scar TCRF describes.
+> 3. **Two cut weapons TCRF's unused-items page doesn't list**: `cartrifle` and
+> `wristdart` — full pickup+prop resources (`GcartrifleZ`, `GwristdartZ`,
+> `PchrwristdartZ /* (BETA) */`) but no `ITEM_*` entry and no WeaponStats,
+> i.e. unusable as weapons. The wrist dart is the hardware behind TCRF's
+> `AMMO_DARTS` ($0E).
+> 4. **All four Bonds, fully mapped**: `CUFF_CONNERY/DALTON/MOORE` +
+> `BOND_BROSNAN/CONNERY/DALTON/MOORE` enums; the actor-switch code
+> (`bondview2.c:391-460`) survives with every branch flattened to Brosnan;
+> the save struct keeps the per-dossier actor field. **No Connery/Moore/
+> Dalton models exist in the retail ROM** (80 `C*Z` chr models enumerated —
+> none for the former actors); only enum slots + the `SW_CONNERY`/`SW_DINNER`
+> switches on the watch-arm model remain. TCRF's "four dossiers, four
+> actors" was pre-release state.
+> 5. **Cuba is the one orphan level with recoverable content**: `LlenE.c` holds a
+> cut Natalya dialogue exchange, the "C U B A N J U N G L E" title card, and a
+> full alternate credits cast. The other 12 unused level text files are empty
+> stubs.
+> 6. **Orphan-level geometry IS in the ROM**: `assets/obseg/obseg.h` declares all
+> nine text-only orphans' bg segments (`bg_sho_all_p_seg[]` …) — a
+> byte-matching decomp can't fabricate them. What's missing is mission setup
+> (briefings exist only for the 21 solo levels), not geometry.
+> 7. **Moneypenny's two risqué Frigate-briefing lines are gone from the ROM**
+> (final `LdestE.c` keeps only the toned-down "thighs of steel" paragraph);
+> recoverable only from TCRF's transcription. "Destroyer"→"Frigate" rename is
+> complete; the Manticore yacht mission has zero ROM trace.
+> 8. **Music enum ↔ Notes-page GS index fully aligned**: indices 0–48 match
+> slot-for-slot; four local mismatches past 0x2C (49 `M_CUBA` vs "Ending
+> theme", 52 `M_MPTHEME3` vs "Surface 2 X", 54 `M_GUITARGLISS` vs "MP Death
+> (Alt)", 59 `M_SURFACE2X` vs "Surface 1 X") — in-game `DEB_MUSIC` audition
+> needed to resolve.
## 1. Findings
@@ -250,8 +292,9 @@ Cross-reference:
- Head model dirs exist under `assets/obseg/chr/`: `headken` (the Ken face),
`headbrosnan*` (4 costume variants), `moonfemale`, plus ~30 named heads.
The actor-named suits (Connery/Dalton/Moore) do **not** appear as chr dirs —
- likely cut before model export, or embedded in a shared body file.
- **Unverified** which exact models the TCRF face shots show.
+ **resolved in round 2**: the ROM holds exactly 80 `C*Z` character models and
+ none is a former-Bond actor; only the enum slots + the flattened actor-switch
+ code (`bondview2.c:391-460`) survive (see banner, item 4).
**Status: Tier 2/3.** Cosmetic mod (extra MP faces) = asset-level work if the
models are in the ROM; verify by dumping `assets/obseg/chr/*/Model.c` headers.
diff --git a/docs/dev/D356-SETTINGS-REGROUP-PLAN.md b/docs/dev/D356-SETTINGS-REGROUP-PLAN.md
new file mode 100644
index 00000000..a31735bb
--- /dev/null
+++ b/docs/dev/D356-SETTINGS-REGROUP-PLAN.md
@@ -0,0 +1,369 @@
+# D356 — Settings regroup: functional sections, save-file scoping, per-section reset
+
+Status: **LANDED (D356, 2026-09-27, port-only)** — signed off and implemented
+this session; verification in `docs/dev/findings.md` D356 (GE_WSPROBE_RESET
+front + in-stage probes, regressions, build). Supersedes the "BOND FILE"
+section introduced in D353. Baselines: Turok PC port options (Nightdive, list
+provided by the maintainer), the PD port's `port/src/optionsmenu.c`
+(`C:\Users\james\Source\Repos\pd_port`), and plan §6 of
+`docs/dev/OPTIONS-MENU-PLAN.md` (the original "align with Turok" note).
+
+## 1. Goal
+
+The D353 "BOND FILE" section is the one section named after *where a value is
+saved* — an implementation detail — while every other section is functional
+(DISPLAY, GRAPHICS, MOUSE/AIM, CONTROLLER, GAME). Its contents are also a
+mishmash (aim/look toggles + HUD toggles + audio), and it exposes N64-watch
+rows that **collide with or duplicate** the PC-native rows the port added
+(watch look-invert vs `Input.MouseInvertY`/`Input.PadLookInvertY`; watch aim
+control vs `Input.AimMode`). The baseline ports (Turok, PD) never name a
+section after the save and never re-expose legacy overrides.
+
+D356 rewrites the row table so that:
+
+1. Every section is functional, using the baseline names:
+ **INPUT, GAMEPLAY, GRAPHICS, AUDIO, VIDEO**.
+2. Save-file scope is communicated the PD way — *in the section title*
+ ("GAMEPLAY (File 1)"), generalized from the D353 BOND-FILE annotation —
+ plus a single **Save file: File N [change]** row at the top of the
+ options screen (PD "Player N" pattern; D354's default resolution applies).
+ "Bond file" appears nowhere in the UI.
+3. Redundant/colliding watch rows are removed from the **menu surface only**
+ (the established D181/D216/D304 pattern): keys, in-game watch behavior,
+ and ini hand-editing all remain untouched. No game-code change (rule 2).
+4. A **Reset to defaults** action row per section (Turok's per-category
+ reset; plan §6 Gate E, made concrete) with a **confirmation contract**:
+ edge-triggered activation (no auto-repeat), two-step arm→confirm, never
+ repeatable (WATCH-SETTINGS-PLAN §6 gate: "separate confirmation and
+ persistence policy before wiring a button" — §5.4 below is that policy).
+5. A **context-aware file accessor** (front-end target vs active stage
+ target) with the "none" chooser position retired, so "Save file: File N"
+ is always a true statement (§5.5).
+6. Section resets iterate the section's **declared** rows (including
+ conditionally hidden ones, §5.6) and reset verification exercises the UI
+ action itself, headlessly (§5.8).
+
+## 2. Non-goals
+
+- No `src/game` changes; the N64 watch menu keeps all its options and its
+ N64 wording (ground truth).
+- No key rebinding (Phase 4) — but the section layout leaves room for a
+ "KEY BINDINGS" page exactly where Turok/PD have one.
+- Master volume stays M3-backlog; the AUDIO section is shaped for it
+ (Turok's Master/Sound/Music trio).
+- N64 build untouched.
+
+## 3. Exposure filter (menu surface only)
+
+| Row (key) | Verdict | Reason |
+|---|---|---|
+| `Bond.Look` ("Look up/down (watch; stacks)") | **Hide** | Duplicates `Input.MouseInvertY` + `Input.PadLookInvertY`; stacking both produces the double-negation trap. |
+| `Bond.AimControl` ("Aim control") | **Hide** | Collides with `Input.AimMode` (N64 vs PC aim model) — two rows fighting over the same domain. |
+| `Bond.AutoAim` | Keep | Unique gameplay assist; orthogonal to aim style/range. |
+| `Bond.LookAhead` | Keep | Unique; no PC counterpart, no conflict. |
+| `Bond.Sight`, `Bond.Ammo` | Keep | Unique HUD-visibility options; compose with `Game.HudScale`. |
+| `Bond.Music`, `Bond.FX` | Keep | Core of D350–D355. |
+
+Hidden rows are commented out in `rows[]` with a D356 note (D181/D216 style:
+config keys + watch path stay live; ini power users can still set them).
+
+## 4. New layout (5 pages + save-file row)
+
+`MAX_PAGES=8`, `MAX_PROWS=15` — all pages below the caps (D353 overflow log
+still guards).
+
+Top row (drawn by `frontoptions.c` above the paged content, not paged):
+**`Save file: File 1 [change]`** — cycles files via `watchSettingsChooseFile`
+(D354 default resolution: explicit pick > viewed folder > first valid;
+auto-init fallback). Hidden in F10 (existing `optionsRowIsBondChooser`
+mechanism, generalized). F10 targets the active file, as today.
+
+| Page | Rows (key · kind) | Title annotation |
+|---|---|---|
+| **INPUT** | `Input.MouseSensitivity` · `Input.MouseInvertY` · `Input.AimMode` · `Input.AimRange` · `Input.PadLookInvertY` · `Input.PadDeadzone` · `Input.PadTriggerPct` · *Reset to defaults* | |
+| **GAMEPLAY** (File N) | `Bond.AutoAim` · `Bond.LookAhead` · `Bond.Sight` · `Bond.Ammo` *(save, dim "(save)" tag)* · `Game.SkipIntro` · `Game.NoHitFlash` · `Game.AllUnlocked` · `Game.HudScale` · *Reset to defaults* · `__QuitToDesktop` | (File 1) |
+| **GRAPHICS** | 11 rows unchanged from D353 · *Reset to defaults* | |
+| **AUDIO** (File N) | `Bond.Music` · `Bond.FX` *(save; title annotation only — homogeneous section, PD-style)* · (master volume, M3) · *Reset to defaults* | (File 1) |
+| **VIDEO** | `Video.Fullscreen` · `__Resolution` · `Video.VSync` · `Video.FpsCap` · `Video.DisplayFPS` · *Reset to defaults* | |
+
+Moves from D353 layout: mouse/aim + controller rows merge into **INPUT**
+(the two old headers `__HdrMouse`/`__HdrPad` are dropped); DISPLAY → **VIDEO**
+(rename, `Video.DisplayFPS` moves from GAME); GAME section is renamed
+**GAMEPLAY** and absorbs the surviving watch toggles; the `__HdrBond` header
+and `__BondFile` chooser row are retired (replaced by the top save-file row);
+`Bond.Look`/`Bond.AimControl` rows are commented out (§3).
+
+Scope annotation: the D353 "(File N)"/"(no file)" title annotation — today
+special-cased to the BOND FILE page via `optionsRowIsBondChooser()` — is
+generalized: **any page containing per-file rows annotates**. Sections with
+per-file rows: GAMEPLAY, AUDIO.
+
+F10: same table (F10 already shows all rows, chooser hidden). The
+(Gameplay/Audio) annotation in-stage shows the active file number (the
+context-aware accessor, §5.8 — *not* the raw chooser value). "Reset to
+defaults" rows stay visible in F10 (O2) under the same confirmation
+contract (§5.4).
+
+**(save) tag (O1):** the four mixed-scope GAMEPLAY save rows carry a subtle
+dim "(save)" suffix — the only place a section mixes scopes. AUDIO is
+homogeneously per-file, so its title annotation is sufficient (no tags).
+Implementation: one `int saveScoped` field on `struct Row` + dim-ink suffix in
+the row-draw path (frontoptions.c already draws the dim annotation).
+
+## 5. Code changes
+
+All in `port/` (D350-family); files: `port/src/optionsoverlay.c`,
+`port/src/frontoptions.c`, `port/include/optionsoverlay.h`,
+`port/src/watchsettings.c` (only if reset needs a new enqueue helper —
+expected not to, see below).
+
+### 5.1 Row table (`optionsoverlay.c`)
+
+- Rename headers: `__HdrDisplay`→VIDEO (last page), add `__HdrInput`
+ (INPUT), `__HdrGameplay` (GAMEPLAY); delete `__HdrBond`, `__HdrMouse`,
+ `__HdrPad`, and rename `__HdrGame`→GAMEPLAY. New section order in the
+ table: INPUT, GAMEPLAY, GRAPHICS, AUDIO, VIDEO (Turok's order: Input,
+ Gameplay, Graphics, Audio, Video).
+- Move the 7 mouse/aim + 3 controller rows under INPUT; move the 4 surviving
+ `Bond.*` toggles + music/FX into GAMEPLAY/AUDIO; comment out
+ `Bond.Look`/`Bond.AimControl` (§3).
+- New `ROW_ACTION` rows, one per section: `.key="__Reset"`,
+ `.label="Reset to defaults"`. All five use one shared handler
+ (`rowResetSection(section)`).
+
+### 5.2 Save-file top row (`frontoptions.c`)
+
+- New non-paged line above the page content: "Save file: File N" + a
+ "change" control (left/right or Enter cycles, reusing
+ `watchSettingsChooseFile(dir)`; D354 lazy default fills N). Dim ink, same
+ visual language as the D353 annotation. `selected_folder_num` unchanged —
+ the row never touches the game's active file (plan Gate B invariant).
+ **Display value reads through the context-aware accessor (§5.5), never
+ the raw chooser value.**
+- **Unavailable state (maintainer consistency fix):** the D354 auto-init
+ fallback creates the blank file on a *later game tick*, so at an
+ empty/corrupt-save startup the accessor can return -1 for a few ticks.
+ The row then shows the unavailable state ("(no file)"), never a file
+ number the write path cannot use. Once creation succeeds the same tick
+ the row shows "File 1".
+- F10 context: row suppressed (extend the D353 accessor to cover it).
+
+### 5.3 Reset to defaults (Gate E)
+
+- **Per-file rows** (GAMEPLAY/AUDIO save rows): defaults = **BLANKSAVEDATA**
+ (`src/game/file2.c` — the game's own factory save; per WATCH-SETTINGS-PLAN
+ §6, watch rows reset to BLANKSAVEDATA/`DEFAULT_OPTIONS` for the selected
+ file, *not* from a global ini snapshot; new saves start Music/FX 0xFF,
+ auto-aim/sight/look-ahead/ammo enabled — read the fields from
+ `BLANKSAVEDATA` at reset time, no hardcoded table). Reset re-applies live
+ via the existing `watchsettings` apply path **and** persists field-scoped
+ to the selected file (`watchSettingsPersistField`), exactly like a normal
+ commit. In-stage: enqueued through the D352 command queue (N field commits
+ with `commit=1`) — no new threading; game thread applies + persists.
+- **Ini rows** (INPUT/GRAPHICS/VIDEO/GAMEPLAY-ini): `config.c` has no
+ central default table (first-write "defaults" = current values,
+ config.c:280), so the section handler uses an explicit
+ `static const struct { const char *key; double def; }` reset table in
+ `optionsoverlay.c`, mirroring the port's C initializers (each entry
+ commented with its source variable; **verified against the initializer at
+ implementation time** — e.g. `Game.AllUnlocked` = **0**,
+ `port/src/video.c:116,348`; the D257 comment in optionsoverlay.c that
+ says "default ON" is stale and is corrected as part of D356). Front-end:
+ write live + `configSave()` (existing). In-stage ini resets: applied
+ **immediately** (O3 — consistent with how every other ini row already
+ behaves when changed in F10; video.c applies most display changes on the
+ fly).
+- **Resolution is excluded from the VIDEO reset (maintainer item 2).**
+ `__Resolution` is an action backed by `s_resSel` +
+ `videoRequestWindowSize()` — not a registered config row, so the numeric
+ default table cannot reset it, and a "default window size" is a
+ display-capability choice, not a tunable. The D356 findings entry
+ documents the exception explicitly: VIDEO reset covers Fullscreen, VSync,
+ FpsCap, DisplayFPS — Resolution keeps the player's current choice.
+- **Scope-aware by construction:** a section's reset touches each row
+ **by its own scope** (file rows → that file's BLANKSAVEDATA values;
+ ini rows → ini defaults). No cross-scope surprise (Gate E: "do not make
+ a global reset wipe per-file watch settings" — a reset of the selected
+ file never touches other files; §5.8 verifies this).
+- **Activation contract (§5.4) applies to every section's reset row.**
+
+### 5.4 Reset activation contract (maintainer item 1)
+
+A reset is a *destructive bulk write* — GAMEPLAY's in particular changes
+both ini values **and** the selected save in one action. WATCH-SETTINGS-PLAN
+§6 gates confirmation + persistence policy; this is that policy:
+
+- **Edge-triggered:** reset actions fire only on a *fresh* event. The
+ frontoptions left/right key repeat (held keys re-issue the action) is
+ suppressed for `ROW_ACTION` reset rows — the arming/confirm events must
+ carry a no-repeat flag (SDL keydown with `repeat==0` / a fresh click),
+ so a held key or held mouse button can never arm *and* confirm, nor
+ re-trigger.
+- **Two-step confirm:** first activation **arms** the row (label swaps to
+ "Confirm reset…", armed highlight); a second activation within **3 s**
+ commits. Timeout, or navigating to another row, disarms (label
+ restores). Arm state is per-row, per-open; opening/closing F10 or the
+ options screen clears all armed resets.
+- **One commit per confirmed activation.** No auto-repeat, no re-arming on
+ the same event. F10 and the options screen share the identical contract
+ (O2: rows stay visible in F10; confirmation makes accidental in-game
+ resets unlikely).
+- The confirmed action then dispatches the section handler (§5.3) exactly
+ once.
+
+### 5.5 Context-aware file accessor; retire "none" (maintainer item 3)
+
+`watchSettingsFolder()` returns the raw `chosen`, which may be -1 before
+`frontFolder()` lazily resolves, and in-stage `chosen` can differ from the
+active file (`snapFolder`). The generalized title/top-row accessor must be
+**context-aware**, not a repurposed chooser-value read:
+
+- New `watchSettingsActiveFolder(void)` (name tentative):
+ - **Front-end contexts** (options screen, F10-on-file-select, title):
+ the *resolved front target* — explicit `chosen` if set, else
+ `selected_folder_num` if valid, else first valid folder (i.e. the
+ `frontFolder()` resolution; the accessor is pure-read and does not
+ mutate `chosen` — lazy mutation stays in `frontFolder()`/the
+ auto-init fallback).
+ - **In-stage (F10):** the *active stage target* — `selected_folder_num`
+ (the file the stage loaded, `snapFolder` at apply time).
+ - The D353 title annotation and the §5.2 top row both read through this
+ accessor, in both surfaces.
+- **The chooser's "none" position is retired.** `watchSettingsChooseFile`
+ cycles FOLDER1..MAX only (dropping the -1 stop). Rationale: D354's
+ default resolution + boot-time BLANKSAVEDATA slots + the auto-init
+ fallback guarantee a valid target, so "none" was only reachable by
+ deliberate cycling and serves no remaining purpose — and "Save file:
+ File N" (the §5.2 invariant) is not true when `chosen == -1`.
+ `watchSettingsFolder()`'s "-1 = none" contract is updated/superseded by
+ the new accessor.
+
+### 5.6 Reset iterates declared rows (maintainer item 4)
+
+The section handler iterates the section's **declared row range in
+`rows[]`** (from its header to the next header), *not* `s_visIdx`/the
+currently visible list — conditionally hidden rows (GRAPHICS draw/LOD
+sliders while their auto-FOV toggles are on) must be reset too, or they
+silently survive a purported section reset. Hidden-row defaults come from
+the same default table; their *live* values are written directly (the
+`ptr`/`rowSet` path), visibility is irrelevant. The `(save)` tag, hidden-row
+resets, and the reset table are all keyed off `rows[]` indices, never the
+visible list.
+
+### 5.7 F10
+
+Unchanged machinery: shared table, save-file row suppressed (the D353
+accessor pattern extended to the §5.2 top row). Annotation shows the active
+file via §5.5. "Reset to defaults" rows stay **visible** in F10 (O2) under
+the §5.4 confirmation contract.
+
+### 5.8 Reset verification probe (maintainer item 5)
+
+New env-gated probe **`GE_WSPROBE_RESET=`** (GE_D314/GE_WSPROBE
+pattern): at tick N of a *front-end* boot it drives the **UI action path**
+through the same handler the confirm step calls (arm → confirm → dispatch,
+no bypass). **Every section being verified is itself dispatched:** the
+probe dispatches all five section resets (INPUT, GAMEPLAY, GRAPHICS, AUDIO,
+VIDEO) via arm → confirm, and after *each* dispatch verifies that
+section's rows:
+
+- **INPUT / GRAPHICS / VIDEO (ini rows):** values == the default table.
+ GRAPHICS specifically covers the **hidden** draw/LOD sliders (hidden
+ while their auto-FOV toggles are on) — a section reset must land them
+ even while they are not visible; VIDEO's `__Resolution` is the documented
+ exclusion (unchanged, logged).
+- **GAMEPLAY (mixed scope):** the four save rows == BLANKSAVEDATA
+ (auto-aim/sight/look-ahead/ammo) *and* the ini rows (SkipIntro/
+ NoHitFlash/AllUnlocked/HudScale) == the ini default table — the two
+ scopes land independently in one action.
+- **AUDIO (save rows):** Music/FX == BLANKSAVEDATA (0xFF raw = 32767,
+ the `VOLUME_MAX` endpoint), not the probe's pre-dirtied 4096.
+- **Scope isolation (after all dispatches):** a *second* folder's
+ `save_data` is byte-identical to its pre-reset snapshot (a reset of the
+ selected file never touches other files).
+
+Front-end prep (tick N) first dirties live values (Music/FX → 4096,
+VSync/HudScale → non-defaults) and snapshots the second folder, so the
+verifications at tick N+1 prove the resets actually moved values back.
+
+In-stage dispatch is covered by the same env var on a `-level_XX` run:
+arm → confirm at tick N (file rows enqueue through the D352 queue — the
+probe logs the enqueued command count), drain at N+1, then the same
+per-section value verification against the post-apply state.
+
+### 5.9 No changes
+
+- `buildPages` in `frontoptions.c` (generic; 5 pages < MAX_PAGES),
+ `watchSettingsGameTick`/queue (D352/D354/D355 logic untouched), the D355
+ track-1 resync, `BLANKSAVEDATA` itself.
+
+## 6. Threading / persistence gates (unchanged, restated)
+
+- D350: no GE writes from the F10 input/render thread except the sanctioned
+ front-end commit path; in-stage edits queue to the game thread.
+- D352: queue holds across transitions; per-field coalescing.
+- D354: front contexts (including F10-on-file-select) route through the
+ chooser path; `frontFolder()` default resolution; auto-init fallback.
+- D355: Music commit + level-entry resync of the BGM (track 1) player.
+- D356 adds no new write path: reset = N existing commits (file rows) +
+ existing ini writes (ini rows).
+
+## 7. Docs
+
+- `docs/dev/findings.md` D356 entry + §F index row; regenerate
+ `findings-index.csv`.
+- `docs/dev/WATCH-SETTINGS-PLAN.md`: status + D353/D354 "Bond File" section
+ references updated; Gate E marked landed (subset: per-section rows).
+- `docs/dev/OPTIONS-MENU-PLAN.md` (tracked) + local notes: §6 "align with
+ Turok" annotated as landed via D356 (M3 master volume + key bindings
+ remain).
+
+## 8. Verification
+
+1. Clean NTSC build (`./build-pc.sh` / `cmake --build build-pc`).
+2. Headless smoke: boots to front end; log page count (5, under MAX_PAGES),
+ no `buildPages` overflow warning, save-file row resolves to File 1 by
+ default (D354), annotation on GAMEPLAY/AUDIO pages, no "none" reachable
+ (chooser cycles files only, §5.5).
+3. `GE_WSPROBE` / `GE_WSPROBE_FRONT` regression: identical pass as D355
+ (queue/commit paths untouched).
+4. **`GE_WSPROBE_RESET` (§5.8) exercises the UI action, not just direct
+ commits:** per-section arm → confirm → dispatch of all five sections,
+ with each section's rows verified right after its own dispatch
+ (ini == table, save == BLANKSAVEDATA, hidden GRAPHICS rows included,
+ VIDEO resolution untouched), then the second-folder byte-identity
+ check. In-stage dispatch: `GE_WSPROBE`-style queue check on a
+ `-level_XX` run (enqueued command count + post-apply state).
+5. **Confirmation contract check (§5.4):** a held key/click arms but never
+ confirms (edge-triggered); second activation within 3 s commits exactly
+ once; timeout/navigate disarms. Headlessly assertable via the probe
+ (arm-then-timeout leaves values untouched).
+6. Manual visual pass (owed, same list as D353): arrows through 5 pages,
+ save-file cycling updates the annotation live, `(save)` tag on the four
+ GAMEPLAY save rows, F10 in-stage, reset in stage vs front (including the
+ armed/confirm visual state), rapid slider drag.
+
+## 9. Open questions — RESOLVED (maintainer sign-off, 2026-09-27)
+
+- **O1:** **yes** — subtle dim "(save)" tag on the four mixed-scope
+ GAMEPLAY rows; AUDIO's title annotation alone is sufficient (homogeneous
+ section).
+- **O2:** **visible in F10**, provided resets require the §5.4 confirmation
+ step (which they do).
+- **O3:** **immediate** in-stage ini resets, consistent with existing ini
+ edits in F10.
+- **O4:** `__QuitToDesktop` stays in GAMEPLAY, **literally last — after the
+ Reset row** (section order: rows…, *Reset to defaults*, *Quit to desktop*;
+ the §4 page table is corrected accordingly).
+
+Layout itself is signed off by the maintainer (2026-09-27): five-page
+regroup + menu-only exposure filter, with the §5.4–§5.8 contracts as
+specified above.
+
+## 10. Estimate
+
+One commit. ~5 files (`optionsoverlay.c`, `frontoptions.c`,
+`watchsettings.c`, `port/include/optionsoverlay.h`,
+`port/include/watchsettings.h`), net +400/−140 (table reshuffle + reset
+contract + context-aware accessor + `GE_WSPROBE_RESET` are the new code).
+Findings/csv/plan updates ride along.
diff --git a/docs/dev/D357-SETTINGS-VALUES-PLAN.md b/docs/dev/D357-SETTINGS-VALUES-PLAN.md
new file mode 100644
index 00000000..2cad493c
--- /dev/null
+++ b/docs/dev/D357-SETTINGS-VALUES-PLAN.md
@@ -0,0 +1,160 @@
+# D357 — Settings thresholds & values: align display units/defaults with the PD port + Turok PC
+
+Status: **PROPOSAL — awaiting sign-off** (2026-09-27). Baselines: the PD port
+checkout (`C:\Users\james\Source\Repos\pd_port`, `port/src/optionsmenu.c` +
+`input.c`/`video.c` config registrations), the Turok PC (Nightdive) list from
+the maintainer's notes (`docs/dev/notes/OPTIONS-MENU-PLAN.md` §6 — *taken from
+user notes, not checked independently*), and the N64 watch (ground truth for
+the `Bond.*` rows only). Triggered by maintainer report: the current values
+are "a bit confusing".
+
+Companion change landed with this doc's review: the terminology settled on
+**"Profile"** as the end-user term for a per-player game file -- the
+front top row reads **Profile**, the per-file row tag reads
+**"(per profile)"** and the F10 unavailable value reads **"Select a
+profile"** (§4).
+
+## 1. Problem
+
+The D356 regroup fixed the *layout*; the *values* still mix three unit
+families with no consistent rule:
+
+- **raw internal units shown as-is**: `Input.MouseSensitivity` (1–500, 100 =
+ 1× — the "100" is a % of a 1.0 multiplier, not a count),
+ `Input.PadDeadzone` (raw 0–30000, `dispDiv=300` → "23%" but the step of 500
+ raw is 1.67 display-%, so the slider lands on 23, 25, 26…),
+ `Bond.Music`/`Bond.FX` (0–32767, `dispDiv=328`, step 128 = 0.39 display-%);
+- **N64-era vocabulary on PC rows**: texture filter "Nearest / Bilinear /
+ **3-Point**" (the N64 watch term for trilinear), "FOV scale %" instead of
+ degrees;
+- **defaults that don't read as defaults**: MSAA shows "4x" fine, but
+ `FpsCap` 0–1000 with step 1 invites 117 FPS; `Video.LodDistance` default
+ 150% vs `DrawDistance` 150% with a 25-step grid (100/125/150/175…).
+
+The PD port is the house style to copy: per-stick sensitivity as a **±10
+multiplier (default 1.0, negatives invert)** (`input.c:1537`), mouse speed
+**-30..30 (default 2.5)**, deadzone **raw 0–32767 per stick+axis**, rumble
+**0–1**, MSAA as a **2x/4x/8x/16x dropdown**, frame cap as **"N FPS"**,
+volumes as 0–100 %. Turok's (user's list) conventions: single mouse
+sensitivity knob, per-section resets (landed, D356), master/sound/music
+volume trio (master still M3-backlog).
+
+## 2. Principles
+
+1. **Display, don't rescale the world.** Every change is port-only (row
+ table, `dispDiv`/step/`unit` fields, value-text, `kResetDefaults`): no
+ `src/game` change, and config *storage units* are kept wherever a change
+ would migrate existing ini files (principle 3).
+2. **Human units in the value column**: `%` on a 0–100 grid, `×` multipliers
+ with 1.0 as the default, `N FPS`, `Off/2x/4x/8x`, `Unlimited`. A slider
+ step must map to a **whole display unit** (the deadzone 1.67%-step bug is
+ the canonical example to fix).
+3. **Ini stability**: renaming a config key's storage unit is a migration.
+ Where the plan needs it, register a *new* key (e.g.
+ `Input.PadDeadzonePct`) and keep the old key as a read-once legacy alias —
+ the D333 `Input.AimMode`→`Input.AimStyle` alias pattern (`input.c:1962`).
+ Each migration gets a findings note.
+4. **`kResetDefaults` stays in lockstep** with the config C initializers
+ (D356 Gate E): every default change in this plan updates the table in
+ `optionsoverlay.c` in the same commit; the `GE_WSPROBE_RESET` probe
+ re-verifies.
+5. **Defaults = the config C initializers**, never a new menu-only default.
+
+## 3. Row-by-row audit & proposal
+
+Current state from `optionsoverlay.c` `rows[]` + the `configRegister*`
+ranges (2026-09-27). "Display" = what the value column shows today.
+
+### INPUT
+
+| Row | Now (default · range · step · display) | PD port | Turok (notes) | Proposal |
+|---|---|---|---|---|
+| Mouse sensitivity | 100 · 1–500 · 5 · raw `100` | `MouseSpeedX/Y` float −30..30, def 2.5 (sign = invert) | single knob | **Display as a × multiplier**: keep storage (100 = 1.0×), value text `%.1f×` (v/100); range 10–300 (0.1–3.0×), step 10 (0.1×). Label stays "Mouse sensitivity". |
+| Invert look (mouse) | Off/On | folded into the −/× sign of MouseSpeed | — | **Keep On/Off** (PD's negative-multiplier style is an alternative; a separate invert is simpler next to the × slider). Decision: confirm. |
+| Aim style / Aim range | N64/Centred (PC) · PC/N64 | — (GE-only, D337/D338) | — | Keep as-is (GE-specific; hiddenIfOn already sane). |
+| Invert look (controller) | Off/On | sign of stick scale | — | Keep. |
+| Stick deadzone | 7000 raw · 0–30000 · 500 · ÷300 → `23%` (1.67%-steps) | raw 0–32767 per stick+axis, `DEFAULT_DEADZONE` | MISSING | **New key `Input.PadDeadzonePct` 0–100, step 1, default 23** (7000/30000×100); `input.c` scales back to raw at apply; old key becomes a legacy alias (principle 3). Display `N%` on a whole-% grid. |
+| Trigger threshold | 23 · 1–99 · 1 · `%` | — (GE extra) | — | Keep. |
+
+### GAMEPLAY
+
+| Row | Now | Baseline | Proposal |
+|---|---|---|---|
+| Auto-aim / Sight / Look ahead / Ammo (profile) | Off/On (N64 watch 0/1) | N64 watch = ground truth | Keep; tag now reads **(profile)** (this doc's companion change). |
+| Skip intro / No hit flash / All unlocked | Off/On | Turok: screen-shake/hit-flash family | Keep. |
+| HUD scale | 100 · 75–150 · 5 · `%` | — | Keep (whole-% grid already; range cap D226). |
+
+### GRAPHICS
+
+| Row | Now | Baseline | Proposal |
+|---|---|---|---|
+| Anti-aliasing | 1/2/4/8, def 4, shows `None/2x/4x/8x`, restart | PD dropdown 2x/4x/8x/**16x**, def 4x | **Add 16x to the sequence** (`kMsaaSeq`, range clamp 1–16) — SDL/GL supports it; PD has it. Decision: perf cost on mid GPUs (restart already flagged). |
+| Texture filter | Nearest / Bilinear / **3-Point** | PC convention: Nearest/Linear/Bilinear | **Rename the enum names to `Nearest / Linear / Bilinear`** (3-point *is* trilinear; the N64 term is the last one left on a PC row). Storage 0/1/2 unchanged. |
+| Anisotropic filtering | 4 · 1–16 · 1 · `x` | Turok 1–16 | Keep; **dim/disable when filter = Nearest** (anisotropy only applies to filtered sampling) — `hiddenIfOff`-style guard, UI-only. |
+| FOV scale | 100 · 50–150 · 5 · `%` | Turok 3: 60–120° slider | **Display in degrees**: the 100% baseline is the N64 vertical FOV scaled to the port's widescreen math — value text `N°` (v/100 × baseline, 1 decimal); storage unchanged. Decision: degrees vs % (Turok uses degrees; % is the existing mental model — pick one, recommend degrees). |
+| Native widescreen / Widescreen auto FOV / Crop overscan | Off/On | — | Keep. |
+| Draw distance | 150 · 100–400 · 25 · `%` (hidden under auto-FOV) | Turok: draw-distance row | Keep the % grid (25% steps read fine); **step 25 → display already whole-%**. No change. |
+| LOD distance | 150 · 25–400 · 25 · `%` | — | Keep; note default 150 vs DrawDistance 150 is intentional parity. |
+
+### AUDIO
+
+| Row | Now | Baseline | Proposal |
+|---|---|---|---|
+| Music volume (profile) | 32767 · 0–32767 · 128 · ÷328 → 0.39%-steps | Turok: 0–100% | **step 128 → 328** (exactly 1 display-%; 32767/328 = 99.9 ≈ "100%"). Storage unchanged (N64 `VOLUME_MAX` semantics). |
+| FX volume (profile) | same | same | same. |
+| *(master volume)* | — | Turok trio | M3-backlog, out of scope (shape the section for it, D356 note). |
+
+### VIDEO
+
+| Row | Now | Baseline | Proposal |
+|---|---|---|---|
+| Fullscreen | Off/On | Turok: window mode (borderless missing) | Keep; borderless = separate backlog item. |
+| Resolution | action row (presets) | both | Keep. |
+| VSync | On/Off | both | Keep. |
+| Frame rate cap | 60 · 0–1000 · 1 · `Uncapped/N FPS` | Turok: "up to 120 FPS" / uncapped | **Preset grid**: 0/30/60/120/144 (step = next preset, wraps to Uncapped); value text already `N FPS`/`Uncapped`. Decision: preset grid vs free 1-step (recommend presets; 117 FPS has no purpose). |
+| Show FPS | Off/On | both | Keep. |
+
+## 4. Terminology (end-user term = "Profile")
+
+Decision (2026-09-27, UX review): the end-user term for a per-player
+game file is **"Profile"** — the per-player bundle of progress +
+per-file settings. Rejected: **"game file"** (the instruction manual's
+current term) reads, in modern PC usage, as the *program's* own files
+("Verify integrity of game files"), not the user's save; **"Save"** was
+the fallback (universally understood) but reads as a verb in the row tag
+and as progress-only, which these files are not. "Profile" covers both
+and fits the dossier styling of the screen.
+
+Final string set (both UIs: front options + F10 overlay):
+
+| String | Where |
+|---|---|
+| **"Profile"** + value `N` | front options top row (was "Save file: File N") |
+| **"(Profile N)"** / **"(none)"** | section-title annotation on save-scoped sections (was "(File N)"/"(no file)") |
+| **"(per profile)"** | per-file row tag (was "(save)", then "(profile)", then "(game file)") |
+| **"Select a profile"** | F10 unavailable value (was "Select file") |
+
+**Manual sync TODO:** the instruction manual's "game file" should be
+updated to "profile" (the manual is a port document we own).
+
+"Bond file" remains in code identifiers/config keys (`Bond.*` rows,
+`watchSettingsChooseFile`, ini keys) only -- never user-visible.
+
+## 5. Gates
+
+1. Port-only per principle 1; the deadzone key migration (principle 3) is
+ the only config-surface change and follows the D333 alias pattern.
+2. `kResetDefaults` updated in the same commit as every default/range
+ change; `GE_WSPROBE_RESET` (front + `-level_33`) re-run, all sections
+ `failures=0`.
+3. Build via `build-pc-cmd.bat` (the pi-shell MSYS env gap, D356 note).
+4. Visual check owed (maintainer): front options screen + F10 in all five
+ sections; the overlap fix from the same session (the level-1 save-file
+ row no longer paints under the first content row).
+
+## 6. Out of scope
+
+- Key rebinding (Phase 4), master volume (M3), output-device selection,
+ borderless window, per-axis controller sensitivity (PD-style four-knob —
+ GE's one-knob design is D238/D304).
+- Any `src/game` change; the N64 watch keeps its own values and wording.
diff --git a/docs/dev/F10-GE-STYLE-PLAN.md b/docs/dev/F10-GE-STYLE-PLAN.md
new file mode 100644
index 00000000..ac14238e
--- /dev/null
+++ b/docs/dev/F10-GE-STYLE-PLAN.md
@@ -0,0 +1,129 @@
+# F10 visual refresh — GoldenEye dossier styling (proposal)
+
+Status: **superseded by D363 after user playtest** — the dossier/paper
+concept below was implemented as D362, then replaced with a dark
+watch-green/Bank Gothic version. This is the historical proposal for a
+port-layer F10 overlay proposal, not a replacement for GE's pause watch or the
+front-end PC Options screen. Reference surfaces: `src/game/front.c`
+`constructor_menu15_cheat`/`frontSetupMenuBackground`, the existing
+`port/src/frontoptions.c` cheat-list-inspired options screen, and GE's in-stage
+watch. Current overlay: `port/src/optionsoverlay.c` `optionsOverlayEmit`,
+`overlayRowAtY`, `sliderBarSpan`, `maxVisibleRows`.
+
+## 1. Direction and boundaries
+
+Make F10 read as an **MI6 dossier laid over the game scene**, not a generic
+SDL debug panel: dark translucent scrim, warm paper/ivory content card,
+black Zurich-style menu text, red for active options/slider fills and an
+occasional muted gold title/accent. The front-end PC Options already uses
+GE's actual 3D folder and Zurich text; use it as the visual reference, not
+as a display-list dependency. In-stage F10 must remain an inexpensive flat
+2D overlay: no loading a 3D wallet model, new ROM assets, front-end init
+functions or `src/game` modifications. Preserve the current two-level
+category -> settings navigation and the shared settings/commit API.
+
+The initial port-only implementation lives in `optionsOverlayEmit()` and
+uses one shared `OvLayout` for both 320x240 stage and 440x330 front canvases.
+Headless captures live under `scratch/d362-test/` (not distributed). Manual
+visual review on a bright stage and controller/mouse feel-check remain owed.
+
+## 2. Concrete visual hierarchy
+
+- **Backdrop/card:** keep an adjustable translucent dim over the scene;
+ replace the opaque blue-black rectangle with a near-opaque ivory panel
+ (subtle border and one offset shadow). Do not mimic the actual folder
+ texture unless a later, measured prototype proves the simple card
+ insufficient. Preserve legible contrast against both dark and bright
+ levels, including safe-area crop and widescreen pillarboxes.
+- **Masthead:** small gold/black `MI6 / OPTIONS` or `PC OPTIONS` heading,
+ fine black rule (a restrained `CLASSIFIED` stamp can be added later if
+ measured text fits) rather than a blue title/instruction block. Keep the
+ category name on the **same
+ page above its first setting**, especially INPUT over Mouse sensitivity.
+- **Root:** numbered `1. Input` ... `5. Video`, GE's dossier/menu numbering
+ and title case as on `frontoptions.c`; a narrow translucent dark selection
+ box behind the focused item. Category order and labels stay unchanged in
+ the shared row table; casing is presentation-only.
+- **Section:** a compact `PREVIOUS`/Back tab, title and optional
+ `(Profile N)` annotation, then two columns: label at left, value at right.
+ Unselected values in black, active/toggled values in GE's dark red
+ (`0xA00000FF` is the cheat-screen reference). Slider tracks are thin
+ charcoal with a red fill and an obvious knob; show the numerical value
+ outside the bar. A mixed-scope `(per profile)` marker must remain legible
+ without colliding with the value column; measure it and, if necessary,
+ use a small second line rather than silently removing the distinction.
+- **Actions:** Reset retains its two-press/three-second confirmation and
+ displays `CONFIRM` in red only while armed. Quit remains visually distinct
+ from resets (and retains its existing activation semantics). Back/Close
+ affordances should match the actual click targets, not become decoration.
+- **Footer:** one quiet, short line for mouse/arrows/controller, with an
+ explicit `ESC: Back / F10: Close` cue. No newly invented glyphs or fonts
+ that may be missing from the game's text bank. FPS readout is independent.
+
+Suggested layout sketch (not pixel positions):
+
+```
+ MI6 / PC OPTIONS CLOSE
+ -----------------------------------------------
+ PREVIOUS GRAPHICS
+ Anti-aliasing 2x
+ Texture filter Bilinear
+ Draw distance [------|----] 50/100
+ ...
+ Reset to defaults [ENTER]
+ ESC Back / F10 Close
+```
+
+## 3. Implementation boundaries
+
+1. Extract a port-only layout helper for fonts, palette,
+ panel rectangles and spacing; preserve `rows[]`, `rowAdjust`, persistence,
+ file scoping, reset logic and input bindings. Use GE's loaded Zurich Bold
+ font for body *only after* validating it exists on both stage and front
+ contexts; Bank Gothic can remain a fallback/header font. Text clipping
+ must use the whole viewport as `textRender` expects.
+2. Replace only the fill/text passes in `optionsOverlayEmit()` first. Emit
+ fills before text as currently, reset GBI state explicitly and keep
+ `OV_BUF_CMDS`/DL size bounds. Avoid mutable texture uploads or new assets.
+3. One shared geometry calculation must drive panel/card positions,
+ `maxVisibleRows`, row Y, slider-bar span, close/back hitboxes, and
+ `overlayRowAtY`. In particular, don't change visual row spacing without
+ updating hit-testing (D304/D316), and don't use naive OS-window pixel
+ mapping instead of `gfx_get_ui_screen_rect` (D316/D335). When a page
+ scrolls, keep its section title visible; a clipped row cannot be clicked.
+4. Use measured text widths to reserve space for values, `Confirm`,
+ `(Profile N)` and restart markers. At 320x240, allow scrolling/short
+ contextual hints, not overlapping text or tiny glyphs. At 440x330 and
+ ultrawide, centre or constrain the card rather than stretching the
+ columns across the entire viewport.
+5. Make any GE-like UI sound effects opt-in to a *separate* follow-up: F10
+ runs in controller polling, and D361 prohibits game save/audio calls
+ from that thread. A cosmetic refresh must not reintroduce that lockup.
+
+## 4. Acceptance gates
+
+- Compare screenshots with the existing front Options/cheat dossier and the
+ in-stage watch; user reviews the live palette/layout before closing the task.
+- Native 320x240, front-end 440x330, windowed 4:3, 16:9 and 21:9,
+ fullscreen, safe-area crop on/off: INPUT title visible; no text clipping,
+ unreadable values, wrong hitboxes, phantom/duplicated bottom rows or
+ overflow in the display list. Check graphics's longest labels and AUDIO's
+ per-profile markers with and without a valid file.
+- Mouse click/drag/wheel, arrows and D-pad/stick, ESC back, F10/Start/X
+ close, focused/armed reset, and quit action all behave **exactly** as
+ before. F10 Audio on file select still commits via D361's game-thread
+ queue; test both fresh and existing EEPROM without altering the user's
+ save (backup/restore for probes).
+- With overlay closed, append no panel DL and retain existing golden frame
+ hashes (unless FPS display is explicitly enabled). Run `./build-pc.sh
+ ntsc-final`, link/duplicate-symbol checks, front-end + stage smoke,
+ `GE_WSPROBE_F10FRONT` and targeted live playtest. Log the result in
+ `docs/dev/findings.md` after implementation, not in advance.
+
+## 5. Explicit non-goals
+
+No fullscreen 3D folder in gameplay, changes to the N64 watch, new config
+keys, row wording/value changes, save-format changes, controller remapping,
+new persistent assets, or `src/game` behavioral edits. The front-end PC
+Options screen is a **visual reference**, not part of the first styling
+patch; harmonizing its tiny decorative details is a later optional pass.
diff --git a/docs/dev/GE-ENV-PROBES.md b/docs/dev/GE-ENV-PROBES.md
index b420b7fe..bfb87b0e 100644
--- a/docs/dev/GE-ENV-PROBES.md
+++ b/docs/dev/GE-ENV-PROBES.md
@@ -3,7 +3,7 @@
Every `getenv("GE_…")` in the tree. The table prose is hand-curated;
**`tools_pc/gen_env_probes.py`** re-greps the live sites and reports drift
(NEW / GONE vars + a fresh file:line map) — run it before trusting the
-File:line cells. Last reconciled 2026-09-21 (D322 probe added; v0.3.0 release review was 2026-09-20). All are **env-gated**: unset = zero behavior
+File:line cells. Last reconciled 2026-10-07 (v0.4.0 release sweep: rows added for probes introduced since 09-21 — D383 bind-capture smoke, D388 option-tree walk, D406 front-options diagnostics, D354/D344 watch-settings & quit-frame harnesses, D318B boundary suite, z-fight grid; the drift checker now also sees the cached env-flag wrapper reads from `port/include/envflag.h`). All are **env-gated**: unset = zero behavior
change, so they are safe to leave in a release build (they only cost a
`getenv` on the guarded path). "Dead" below means *the finding it was built
for is closed* — the probe is strip-candidate scaffolding, not that it does
@@ -20,7 +20,7 @@ Two classes:
| Env var | File:line | What it does | Status |
|---|---|---|---|
| `GE_PCDUMP` | `port/fast3d/gfx_opengl.cpp:1079` (`is_pcdump`), `gfx_opengl.cpp:700`, `port/src/video.c:188`, `port/src/config.c:46` (`configGetFrameDump`) | Per-frame framebuffer PPM dump to `$PCDUMP` dir (verification-ritual golden captures, `tools_pc/framediff.py`/`pixcount.py`). Also settable via `[Debug] FrameDump` ini (env wins). **M-33/D168: the writer now emits rows top-to-bottom — captures before that fix are vertically flipped.** | **live** |
-| `GE_INPUTSCRIPT` | `port/src/input.c:306` | Headless scripted controller-0 input: `" :,…;…"` (`SUP/SDOWN/SLEFT/SRIGHT/SNONE` sustain, buttons pulse). Sole input source when set. | **live** |
+| `GE_INPUTSCRIPT` | `port/src/input.c:306` | Headless scripted controller-0 input: `" :,…;…"` (`SUP/SDOWN/SLEFT/SRIGHT/SNONE` sustain, buttons pulse; `ZHOLD`/`ZREL` sustain fire, D207 M-201). Sole input source when set. | **live** |
| `GE_INPUTLOG` | `port/src/input.c:329`, `port/src/config.c:53` (`configGetInputLog`) | Logs computed pad state (buttons + stick) each poll when non-zero. Also `[Debug] InputLog` ini. Also traces D165 `menuptr est/tgt/eff/stick`. | **live** |
| `GE_STARTMENU` (+ `GE_STARTMENU_PAGE`, `GE_STARTMENU_DIFF`) | `src/game/lv.c:398-402` (`#ifdef PORT`) | Boot straight into a front-end menu id (13=MISSION_COMPLETE, 10=BRIEFING, 7=MISSION_SELECT, 12=MISSION_FAILED, 6=MODE_SELECT); `_PAGE`=folder row (def 1=Dam), `_DIFF`=0..3. Crash-test a screen fast. | **live** |
| `GE_UNLOCK_ALL` | `src/game/debugmenu_handler.c:1098` (`get_debug_enable_all_levels_flag`, `#ifdef PORT`) | Mission-select shows every solo level (playtest jump-to-any-level aid). Cached in a `static int c`. | **live** |
@@ -33,7 +33,6 @@ Two classes:
| `GE_PULLTRACE` | `src/libultra/audio/load.c` (`alAdpcmPull`) | `[PASTEND]` assertion: fires if a voice's `f->sample` exceeds its wave's total samples while `nOver == 0` (i.e. an unstopped voice keeps decoding past the end instead of zero-filling). Rate-limited to 40 lines. M-65: 0 hits — exhausted voices go silent correctly. | **live** (D202) |
| `GE_DMEMWIPE` | `port/src/mixer.c` (`aClearBufferImpl`) | Zeroes the whole software DMEM at the frame boundary (detected as `alMainBusPull`'s clear of `AL_MAIN_L_OUT`, addr 1088). Tests whether audio state survives across frames in DMEM. M-65: no-op on output — ruled out. | **live** (D202) |
| `GE_NOWET` | `port/src/mixer.c` (`aSetVolumeImpl`) | Mutes the reverb send (`wetamt = 0`), isolating the reverb delay lines as a possible source of persistent signal. M-65: drone unchanged — ruled out (NB `aPoleFilterImpl` is still an unimplemented no-op, D199). | **live** (D202) |
-| `GE_OBJT` | `src/game/chraction.c` (`chrSetStageFlags`/`chrUnsetStageFlags`), `src/game/propobj.c` (GAS_RELEASING destruction) | D321 stage-flag writer probe: logs every low-16-bit write to `objectiveregisters1` (tick, writing chr, actiontype/aioffset, AI-list ID) plus each toxic-gas prop destruction timestamp — used to map the Facility tanks→combat-bit trigger chain. Capped, read-only, inert unset; post-release removal candidate (M-192 item 7). | **live** (D321) |
| `GE_BANKDUMP` | `port/src/romdata.c` | `BANKSRC` lines: the source ROM-layout bank tree per `soundIndex` (instrument → sound → envelope pointers + `decayTime`). Used to prove sound 203's `decay=-1` is genuine ROM data, not a conversion bug. One-shot at load. | **live** (D202) |
| `GE_FORCEALARM` | `src/game/propobj.c` (`handle_alarm_gas_timer_calldamage`, `#ifdef PORT`) | **D207 diag (temporary).** Forces the in-game alarm klaxon (`alarmActivate`) on a frame counter — on ~2–10 s, off, on again — and emits a per-frame `[D207] f=… active=… ptr_alarm_sfx=… playstate=… locked=…` line to `audiotrace.log`. Repro harness for the "alarm SFX overrides other audio" report when a level's own alarm can't be triggered headlessly. Remove when D207 closes. | **live** (D207) — temporary |
| `GE_KEYMAPDUMP` | `port/src/romdata.c` | `KEYMAP` + `ENVSRC` lines: raw source bytes of every `ALKeyMap` / `ALEnvelope` in the ROM-layout blob, for byte-wise comparison against runtime values. One-shot at load. | **live** (D202) |
@@ -42,6 +41,7 @@ Two classes:
| `GE_MIXERTRACE` | `port/src/mixer.c`, `src/audi.c` (`amDmaCallback`), `src/libultra/audio/load.c` (`_decodeChunk`, `alLoadParam`) | Per-opcode mixer trace to `mixertrace.log` (DMEM addresses, ADPCM books, decoded frames, `[DMAREQ]`/`[DMAHIT]`/`[DMAMISS]`/`[BINDTABLE]`). **WARNING: unbuffered per-opcode `fprintf`, ~25 MB/min. It slows the process enough to starve the audio thread — M-63 measured "audio at 13 % of real time" that was entirely this probe's own artifact. NEVER use it for timing-sensitive measurement; use `GE_D204`.** Grep it, never read it whole. | **live** (D202) — timing-unsafe |
| `GE_TEXPITCH` | `port/fast3d/gfx_pc.cpp:924` (`gfx_tex_pitch_fix`, cached) | `=0` disables the D183 defensive de-stride in `import_texture` (default on). RC2/RC3 texture-shear A/B knob. | **live** (RC3 not finished) |
| `GE_TEXRAW` | `port/fast3d/gfx_pc.cpp:1079` | With `GE_TEXDUMP`, also dumps the raw pre-import source bytes (`texdump/rNNN_f…_s…_WxH.bin`) so a decode bug can be told from a source-data bug offline (D183). | **live** (RC3 not finished) |
+| `GE_TEXDUMP_ADDR` | `port/fast3d/gfx_pc.cpp` (`import_texture`, cached env check) | D75/M-197: with value ``, dumps the raw source bytes of **every** import from that exact address (bypassing the 400-dump cap) to `texdump/aNNNNN_WxH.bin` + a `GE_TEXA[…]` log line with fmt/siz/size/min/max/nonzero byte stats. Distinguished "never loaded" from "loaded zeros" for the Nintendo-logo texture buffer (root-caused to the D50 sidecar converter dropping embedded blobs). | **live** (D75) |
| `GE_D219RAW` | `src/game/image.c` (`texReadUncompressed`/`texChannelsToPixels`, `#ifdef PORT`) | Diagnostic-only, print-only (no behavior change): logs the runtime texture decompressor's bitstream header (texnum/format/width/height/compmethod) for every non-zlib wide-pixel image, plus a dump of the first row of decoded texels exactly as stored in the pool (post-`PORT_PIXEL16`, i.e. byte-swapped for the 16-bit family) for RGBA16/RGB15/IA16. No texnum filter — lets any suspect texture be cross-referenced against the offline ROM census (`scratch/d219_census.txt`) without narrowing by hand. | **live** (D219, closed — kept for future runtime-decoder triage) |
| `GE_WRAPFIX` | `port/fast3d/gfx_pc.cpp:3166` (`gfx_set_wrap_fix`) | RC3 test override for the `Video.WrapFix` D74/D167 wrap-block path (`atoi != 0` wins over the ini). | **live** (RC3 not finished) |
| `GE_SAVELOG` | `src/game/file.c:46`, `src/game/file2.c:16` (`SAVELOG` macro), `src/boss.c:749`, `src/game/objective_status.c:297,343` | Traces the campaign save/unlock chain (objective completion → `end_of_mission_briefing` → `fileWriteSave` → EEPROM). Used to confirm D157. `g_savelogObjOnce` gates the per-criterion dump to the `bossReturnTitleStage` call. | **live** (strip once a few more level boundaries are playtested) |
@@ -58,20 +58,48 @@ Two classes:
| `GE_D193B` | `src/game/model.c` (`sub_GAME_7F06D3F4`, `#ifdef PORT`) | Rate-limited (1/97) raw anim root-motion decode trace (D193): `joint=` / `flip=` / `base=` mtx channel index / `frame=` / `tmp=` decoded s16 translation triple from the anim bitstream / `angle=` / `anim` ptr / `bd=`+`bs=` the D32 u32 bitDescriptors/bitStream addresses / `njoints`. M-80 Facility: `base=0`, `angle=0` **always** (root-motion facing delta never non-zero — top lead), `tmp` ≈ (±small, ~1086 hip-height, ±small). Compare `tmp`/`angle` magnitudes against a clean N64 decomp build of the same anim+frame. | **live** (D193) |
| `GE_D250` | `src/boss.c` (`bossMainloop`, wraps `lvlRender()`, `#ifdef PORT`) | Per-wall-second line: `[D250] lvlRender avg=Nus n=M` — average + count of `lvlRender()` (DL-building: culling/animation/scene traversal) calls that wall-second, isolated from `gfx_run()`'s software-RSP interpretation cost (already covered by the existing "frame rendered in Nus" line). Added M-120 to chase D250 (fps stuck ~45-50 on Dam, ruled resolution/MSAA/vsync/build-flags out already) — found `lvlRender` itself is cheap (~0.5-1.5ms avg), so it is NOT the missing ~10-15ms/frame either; the still-unexplained cost must be elsewhere in the per-retrace-message loop (scheduler/thread round-trip, audio, or something not yet isolated by a targeted timer). Cheap; safe for a full playtest. | **live** (D250) |
| `GE_D172` | `port/fast3d/gfx_pc.cpp` (`gfx_sp_tri1` — SETCOMBINE dedup log + non-LOD multitex tri dump; `#ifdef PORT`) | Particle magenta/cyan colour (D172). Logs each distinct SETCOMBINE + active cycletype, and — for a 2-cycle tri that consumes TEXEL1 with `tex_lod=0` and a distinct 2nd tile — the tile fast3d *samples* for each texunit vs what the DL *configured*. **Found D172**: 40/40 particle tris configured tile 1 @ tmem 392 (fire) but sampled tile 0 (smoke) → `smoke*smoke`. FIXED (`gfx_lod_tile_offset` `return rdp.tex_lod ? 0 : i`). Probe kept inert. | **live** (D172 FIXED, probe kept) |
-| `GE_D157` | `port/fast3d/gfx_pc.cpp` (`gfx_sp_vertex` + `import_texture`; `#ifdef PORT`) | D219/D252's "rainbow particle" family — bullet-impact spark on Bunker1 door confirmed by screenshot to cycle magenta/green/cyan (M-157). Three sub-probes: original exact-color-match log (`g_BulletSparkColors[]` byte match → geometry_mode/LIGHTING + shaded RGB); **`D157T`** texture-identity-keyed vertex full-path log (tile 0 holds a loaded texture ≤16 KiB in frame window [90,240] → raw `cn[]`, post-shade RGBA, LIGHTING, geometry_mode, combine_mode, tile-0 tmem/fmt/siz/addr/size/line, uv; cap 400 + summary/200); **`D157I`** import census (small textures imported in the window, deduped per source address → addr/fmt/siz/size/tile/tmem). M-158: bug did NOT reproduce in 6 identical dev-box runs — probes left live to capture the full RGBA path on the next occurrence (user Steam Deck run with `GE_D157=1`). | **live** (D219/D252, non-reproducible as of M-158) |
+| `GE_D157` | `port/fast3d/gfx_pc.cpp` (`gfx_sp_vertex` + `import_texture`; `#ifdef PORT`) | D219/D252's "rainbow particle" family — bullet-impact spark on Bunker1 door confirmed by screenshot to cycle magenta/green/cyan (M-157). Three sub-probes: original exact-color-match log (`g_BulletSparkColors[]` byte match → geometry_mode/LIGHTING + shaded RGB); **`D157T`** texture-identity-keyed vertex full-path log (tile 0 holds a loaded texture ≤16 KiB in frame window [90,240] → raw `cn[]`, post-shade RGBA, LIGHTING, geometry_mode, combine_mode, tile-0 tmem/fmt/siz/addr/size/line, uv; cap 400 + summary/200); **`D157I`** import census (small textures imported in the window, deduped per source address → addr/fmt/siz/size/tile/tmem). M-158: bug did NOT reproduce in 6 identical dev-box runs — probes left live to capture the full RGBA path on the next occurrence (user Steam Deck run with `GE_D157=1`). **M-201:** F12 opens a ~90-frame full-detail `D157TB` burst; continuous logging cut to a 2000-line baseline + 500 colour-match hits (the uncapped white match ran live play at 4–6 fps). | **TEMP** (D252 fixed M-201; remove after release) |
| `GE_DVM` | `src/game/gunfire.c` (`gunUpdateAndFire` gate dump + `gunRenderFirstPersonGunModels` render dump; `#ifdef PORT`) | 1P weapon viewmodel (D215). `[DVM]` line: the `field_87F` show/hide gate terms + `Gun_hand_without_item` sub-terms + `copy_of_body_obj_header` RootNode/Switches/numRecords. `[DVMr]` line: `87F` + `cmds_emitted` = gfx commands the weapon `subdraw()` produced (healthy ≈ 150; the D215 bug showed **1**). Rate-limited ~1/s per hand. **Found + verified D215** (`renderdata.flags` read 0 → all weapon DL nodes gated off). Probe kept inert. | **live** (D215 FIXED, probe kept) |
| `GE_DTEX` | `port/fast3d/gfx_pc.cpp:1001` | Per-`import_texture` param log (dims / line / size / lod / gen_mipmaps). Used to root-cause RC2 and D159. Kept as an inert diagnostic. | **live** (RC2/RC3 not finished) |
| `GE_TEXDUMP` | `port/fast3d/gfx_pc.cpp:1020`, `port/fast3d/gfx_opengl.cpp:700` | PPM dump of every uploaded texture + a `fmt/siz/palfmt/palidx/pal[0..3]` line. Root-caused D161 (Depot ceiling). Kept as an inert diagnostic. | **live** (RC3 not finished) |
| `GE_D116` | `port/fast3d/gfx_pc.cpp:1710,2362`, `src/game/textrelated.c:265,541` (cached `ge_d116`) | HUD/text "X-mirror" (D114/D116) vbo/uv trace. **D114/D116 CLOSED — NOT A BUG (M-33/D168): the captures were upside-down, not mirrored.** Probe is now dead scaffolding — strip on the next cleanup pass. | dead (D114/D116 closed) |
-| `GE_D288` | `port/fast3d/gfx_pc.cpp` (`gfx_sp_tri1`, right after the D233 trivial-reject guard; cached `ge_d288`) | M-152 diag for the Silo intro stray triangle: logs any triangle whose clip-space NDC bbox covers >15% of the screen or goes non-finite, with each vertex's `w` and `any_behind_camera`. **Investigation in progress** — narrowed to two candidates (an unguarded `w`-division NaN leak in the backface-cull test just below, or the D106/D271 portal-BFS family), not yet root-caused; needs a live/headless `-level_20` run to disambiguate. | **live** (D288) |
-| `GE_D236RM` | `port/fast3d/gfx_pc.cpp` (`gfx_sp_tri1`, unconditional getenv, no state kept when unset) | M-155/pass-10 diag for D236: per-frame (`num_dls`) triangle count of the `0x0c184b50` render-mode class (`other_mode_l` exact match), heartbeat-logged every 300 frames even at zero. **REFRAMED D280's "run-to-run dependent instantiation" as ordinary room-based occlusion**, not a CPU-side gating bug — see `## D236` pass-10 writeup. | **live** (D236 OPEN, kept for future passes) |
-| `GE_D236ORDER` | `port/fast3d/gfx_pc.cpp:1844` (`gfx_sp_tri1`, cached `s_d236order`) | D236 pass-12 diag: per-DL sequence log of the tree-class triangles (DL index, screen-space depth, noise min/max census) to test the draw-order hypothesis. Zero cost unset; remove once D236 concludes. | **live** (D236 OPEN) |
-| `GE_D236ALPHA` | `port/fast3d/gfx_pc.cpp:1896` (`gfx_sp_tri1`, cached `s_d236alpha`) | D236 pass-14 diag: for the `0x0c184b50` tree-class render mode, logs the combine/alpha state of up to 4000 matching triangles (deduped on combine change) so the captured draw can be cross-checked against the source. Zero cost unset; remove once D236 concludes. | **live** (D236 OPEN) |
-| `GE_D236ZFIX` | `port/fast3d/gfx_pc.cpp:1970` (`gfx_sp_tri1`, cached `s_d236zfix`) | D236 pass-12 test override: forces `depth_update=true` for the `0x0c184b50` tree-class render mode — a cheap mechanism test before deciding what a real fix looks like. Off by default; remove once D236 concludes. | **live, test-only** (D236 OPEN) |
-| `GE_D236RAW` | `src/game/bg.c:2437` (`#ifdef PORT`) | D236 pass-16 diag: one-shot census of the raw vertex table's alpha-byte distribution (5 buckets + min/max), camera-independent, to check whether any vertex in the whole table exceeds the low ceiling. Zero cost unset; remove once D236 concludes. | **live** (D236 OPEN) |
+| `GE_D288` | `port/fast3d/gfx_pc.cpp` (`gfx_sp_tri1`, right after the D233 trivial-reject guard; cached `ge_d288`) | M-152 diag for the Silo intro stray triangle: frame-gated to `num_dls` 550–755 (M-155), now **skips `any_behind_camera==1` triangles** (M-155 follow-up — the x/w divide is meaningless for w≤0; that subset was pure false-lead noise) and logs the render-mode word (`oml`) so a live capture can ID the culprit asset against level tables. Logs any triangle whose clip-space NDC bbox covers >50% of the screen or goes non-finite, with each vertex's `w`. Bare `-level_20` re-run: 1965 sane hits in the window, still zero for the target color RGB(83,62,44) — consistent with M-155's working theory that the bare boot's scripted camera never reaches the bug state; **headless probing is at its limit, live capture is next**. | **live** (D288) |
| `GE_D245V` | `src/game/sky.c:962` (`skyPortRenderPoly`-adjacent, `#ifdef PORT`) | D245 water-seam diag: per-call min/max of the recomputed S/T texture-coordinate ranges for the water fan vertices (rate-limited), to check whether the seam tracks a tc-shift boundary. Inert unset. | **live** (D245 OPEN) |
| `GE_D245_FIXEDSHIFT` | `src/game/sky.c:946` (`#ifdef PORT`) | D245 test override: pins the water tc shift `k` to the given integer (0..SKY_TC_MAX_SHIFT) instead of the computed per-vertex value, to A/B the allowShift=TRUE behavior. Off by default (unset = current behavior). | **live, test-only** (D245 OPEN) |
| `GE_D252` | `port/fast3d/gfx_pc.cpp:733` (`import_texture`, cached `s_d252`) | D252 rainbow-particle diag: one-shot per (address,size) import log of the tile state (tmem/fmt/siz/addr/size/line) for small textures so a suspect particle tile can be checked against its source bytes by hand. Remove once D252 is resolved. | **live** (D252 OPEN — non-reproducible) |
+| `GE_D252POOL` | `src/game/image.c` (`texLoad`), `src/game/initmttex.c`, `port/src/gimgfixup.c` | **TEMP** (M-201): one line per new texture load (pool main/other, free bytes) + `POOL-FULL` events + stage pool range; `D252SYNC: N slots re-synced, M changed` per stage load (the D252 fix at work: M>0 from the 2nd stage on). Load-time only, no fps cost. | **TEMP** (D252 fixed; remove after release) |
+| `GE_D245_OLDVTX=1` / `GE_D245_OLDTEX=1` | `src/game/sky.c` (`skyPortRenderPoly`) / `port/fast3d/gfx_pc.cpp` (CI8 import + tri-path dims) | **A/B** (M-201): restore the pre-fix s16 sky/water vertex path / the pre-fix 32x43, 16-wide water texture geometry. | **TEMP** (D245 fixed; remove post-release) |
+| `GE_DYNTEXHASH_OFF=1` | `port/fast3d/gfx_pc.cpp` (`import_texture` cache key) | **A/B** (M-201, D341): disable the content-hashed cache key for dyn-pool textures (reverts to the stale address-only key). | **TEMP** (A/B switch) |
+| `GE_D207S` | `src/game/gunfire.c` (player shot-sound block, `#ifdef PORT`) | **TEMP** (M-201): one line per player shot-sound decision: `D207S t= hand item rate -> H1|H2|SKIP` (SKIP = both per-hand sound handles still held). Read-only, uncapped, cheap; use instead of `GE_AUDIOTRACE` for live captures (that probe does file I/O on the audio path). | **TEMP** (D207/D241) |
+| `GE_D306C="f0 f1 [wmax]"` | `port/fast3d/gfx_pc.cpp` (after `zf_note_emit`) | **TEMP** (M-201): depth-state census of every emitted triangle in frames f0..f1 (GL depth mode, geometry_mode, other_mode_l, tile-0 texture, NDC centroid, z/w range, min w); optional `wmax` keeps near geometry only. Cap 60000 lines. | **TEMP** (D306 closed; remove post-release) |
+| `GE_INTROCAM=` | `src/game/bondview_r.c` (`#ifdef PORT`, intro camera pick) | **TEMP/test-only**: force the level intro's first camera index (Dam truck shot = 5). The PRNG draw is still consumed. Logs `D306: intro camera index` when set (or with `GE_D306C`). | **TEMP** (useful repro tool) |
+| `GE_STARTWEAPON=` | `src/game/bondview_r.c` (`#ifdef PORT`, after the start-inventory setup) | **TEMP/test-only** (M-201, D303): adds the item + 400 rounds of its ammo and makes it the starting right-hand weapon (M16 = 13). Pair with `GE_INPUTSCRIPT` `ZHOLD`. | **TEMP** (useful repro tool) |
+| `GE_D235` | `src/boss.c` (per-stage `while (!done)` block, after `memaReset`; `#ifdef PORT`) | D235 evidence probe: logs how many GL-texture-cache entries the per-stage `videoResetTextureCache()` call purges at each level load. A cold boot into a level should log 0; a death/restart (or any transition from a level that uploaded textures) logs >0 — direct evidence of the stale-entry mechanism. The clear itself is **not** gated on this var (it's the fix, see below); the var only controls the log line. | **live** (D235) |
+| `GE_D235_OFF` | `src/boss.c` (same block) | **TEMP** A/B switch: skips the per-stage `videoResetTextureCache()`, i.e. reverts the D235 fix for one run. For comparing a restart with and without the fix. | **TEMP** (D235; remove when D235 closes) |
+| `GE_D235_RELOAD=` | `src/boss.c` (same block + `bossMainloop` retrace branch) | **TEMP** reload harness: once the title stage loads after the first level (e.g. after a scripted watch Abort), wait `` retraces, then re-enter that level via `bossSetLoadedStage()`, the front end's own mission-start call. A headless death/restart without the pointer-menu walk. Pair with `GE_INPUTSCRIPT="800:START;1200:A;1300:RIGHT;1400:A"` (abort) on `-level_XX`. | **TEMP** (D235; remove when D235 closes) |
+| `GE_D235_NEXT=` | `src/boss.c` (with `GE_D235_RELOAD`) | **TEMP**: re-enter a *different* stage after the abort (level A → title → level B), used for D336 (Runway → Frigate). `GE_D85TEX=1` now also logs each stage's texture-pool size and address range (`src/game/initmttex.c`). | **TEMP** |
+| `GE_D320R="tick:action;..."` | `port/src/d320repro.c` (hooked in `src/game/lv.c` per-tick PORT block) | **TEMP** forced-repro harness keyed on `g_GlobalTimer`. Actions: `roster`, `jump=,list=,off=`, `bit=`, `trace=`, `rename=,`, `seed=,` (D320); `explode=,` (explosionCreate ahead of Bond) and `cheat=gl|rl|inv|` (game cheat activation) for D252 headless explosion/debris captures. | **TEMP** (D320/D252) |
+| `GE_AIMLOG=1` (+ `GE_INPUTSCRIPT` tokens `MDX` `MDY` `RHOLD` `LHOLD` `RREL`) | `port/src/input.c` | D337 aim-model harness: scripted sustained mouse deltas (treated as a grabbed mouse) and aim holds, plus a per-poll line with aim state, active device, the game's drawn crosshair x, `crosshair_x/y_pos` and camera angles. Measures range, edge-scroll and jitter headlessly. | **live** |
+| `GE_PERFSTAT=1` | `port/fast3d/gfx_pc.cpp` (`gfx_run`/`gfx_end_frame`), `port/fast3d/gfx_opengl.cpp` (start/end_frame), `src/boss.c` (main loop) | #92/D339 perf probe: per-300-frame averages of render phases (pre / display-list / post / swap+pace), tris and draw batches per frame, game-thread busy time per scheduler message, and a GL_TIME_ELAPSED GPU timer (meaningful only when GPU-bound). | **live** |
+| `GE_D75V` | `port/fast3d/gfx_pc.cpp` (`gfx_sp_vertex`, end of the copy/transform loop; cached env check) | D75 (Nintendo logo) per-batch vertex probe: for each `G_VTX` batch in frames `lo..hi`, logs frame#, source pointer, n_vertices, original dest_index, raw ob[0..2] of the first vertex, min/max of the transformed x/y/z/w across the batch, and the MP translation row. Used to prove the transform is correct (M-195) and that every batch encodes v0=0 (slot-overwrite hypothesis). Value form `"lo-hi"` (e.g. `300-900` = the logo screen); unset = off. | **live** (D75) |
+| `GE_D75D` | `port/fast3d/gfx_pc.cpp` (`gfx_sp_vertex` G_VTX case + `gfx_sp_tri1` rejection/emit gates; cached env check) | D75 (Nintendo logo) per-frame draw-path probe (M-196): for frames `lo..hi`, logs one line/frame with the V/T token stream (`seq`), max triangle vertex index, cumulative vertices loaded, and per-triangle accounting — trivial-reject / backface-cull counts, how many tris reach GL, how many of those are on-screen (NDC bbox ⊂ [−1,1]²), invisible-flag count, vertex-color min/max, first oml word, depth z/w range + NaN count, texture-sampler presence (`utex` = GL shader, `cutex` = combiner), combine mode, prim/env color. Used to REFUTE the slot-overwrite hypothesis (DL is streamed, max index 15) and narrow the bug to a texture-modulate material rendering black. M-197 added logo-mode `lg_*` fields (sampled only on tris with the logo combine word — the frame-level `utex`/`cutex` are first-tri-wins and were the source of the "combiner claims 0" false anomaly): `lg_utex`/`lg_tex` + tile-0 descriptor + texture buffer address/id/size for the logo material. Value form `"lo-hi"` (logo window ≈ `244-320` on a bare front-end boot); unset = off. Companion one-shot `D75DCC:` log in `gfx_generate_cc` fires when `combine_mode==0x009ffe4f19ffe4f1` to decode the logo's combine slots. | **live** (D75) |
+
+| `GE_PDMOUSEAIM` | `port/src/input.c:487` (`pdMouseAimEnabled`) | Force on/off the PD-style mouse-aim model (D194 lineage); unset = default (on in N64 aim mode unless legacy GEPD aim is selected). | **live** |
+| `GE_QUITFRAME` | `port/src/video.c:966` | D344 harness clean self-quit: exits after frame `` completes and swaps (replaces `timeout` kills; the host never exits under an in-flight frame). Cached. | **live** (harness) |
+| `GE_ROOMPOOL` | `port/src/video.c:371` (`portRoomPoolScale`) | Scales the port's room pool by a float factor — before/after captures, and a user opt-out if the larger pool ever causes trouble on a level. | **live** |
+| `GE_BINDPROBE` | `port/src/input.c:885` | One-shot dump of the resolved PC bindings after setup (D383 bind-capture verification). | **live** (diagnostic) |
+| `GE_BINDCAPTUREPROBE` (+ `GE_BINDCAPTUREACTION`) | `port/src/optionsoverlay.c:1000,1005` | D383 isolated host→menu smoke probe: when the overlay force-opens, injects a scripted bind-capture keypress (ESC/DEL/F2). Test data dir must be private — capture persists the key. | **live** (diagnostic) |
+| `GE_OPTIONSOVERLAY_SECTION` | `port/src/optionsoverlay.c:989` | Isolated screenshot/probe of a nested F10 page by row key (opens that header; never a menu mode). | **live** (diagnostic) |
+| `GE_OPTIONSOVERLAY_SELECTBACK` | `port/src/optionsoverlay.c:995` | Screenshot-only: pins the selection to the Back row when the overlay auto-opens, to check it against a normally selected setting on the same page. | **live** (diagnostic) |
+| `GE_OPTIONTREEPROBE` | `port/src/optionsoverlay.c:888` | D388: opt-in in-memory walk of the real F10 page tree; writes no settings, binds or profile bytes; restores UI state afterward. | **live** (diagnostic) |
+| `GE_OVPOINTERMAP` | `port/src/optionsoverlay.c:1726` | D391 pointer-map diagnostic: on LMB-down (and every 120 samples) logs SDL mouse coords vs GL drawable pixel coords + the hover row, to separate hit-band/scroll errors from coordinate-space mismatch. | **live** (diagnostic) |
+| `GE_FOOTERPROBE` | `port/src/optionsoverlay.c:2232` | One-shot log of the overlay footer/help-line metrics (x extents). | **live** (diagnostic) |
+| `GE_FRONTOPTIONS_SECTION` | `port/src/frontoptions.c:295` | Isolated headless screenshot of a nested front-end options page by key (changes navigation only; never edits profile or config values). | **live** (diagnostic) |
+| `GE_FRONTNAVLOG` | `port/src/frontoptions.c:430,573` | Logs each front-options navigation select (section/row/item number). | **live** (diagnostic) |
+| `GE_FRONTOPTS_WIDTHS` | `port/src/frontoptions.c:673` | D406b: one-shot label-metrics log for the options rows (layout diagnostics). | **live** (diagnostic) |
+| `GE_FRONTOPTIONS_AUTO` | `port/src/frontoptions.c:818` | D406 test hook: enters the options screen without a mouse click so headless runs (`GE_INPUTSCRIPT` D-pad stepping + `GE_FRONTNAVLOG`) can exercise it. Never active without the env. | **live** (diagnostic) |
+| `GE_WSPROBE` (+ `GE_WSPROBE_FRONT`, `GE_WSPROBE_RESET`, `GE_WSPROBE_F10FRONT`) | `port/src/watchsettings.c:616,568,512`, `port/src/optionsoverlay.c:1566` | D354 watch-settings probe family: `WSPROBE[=tick]` logs before/after the queued settings commit one tick apart; `_FRONT[=tick]` drives a headless front-end change scenario; `_RESET` prepares an options-reset after 120 front ticks and verifies persistence once the stage drains; `_F10FRONT` fires a one-shot F10→front poll after the file-select save becomes available (avoids the joyDisablePoll deadlock). | **live** (diagnostic) |
+| `GE_ZF` (+ `GE_ZFTOL`, `GE_ZFBOX`) | `port/fast3d/gfx_pc.cpp:1653-1657` | Z-fight grid probe: per tri emit, buckets the centroid cell and flags cells that fight ≥5 times (cap 600 hits); `ZFTOL`=tolerance, `ZFBOX="x0 y0 x1 y1"`=world-box filter. | **live** (diagnostic) |
## Dxx diagnostic probes — finding closed unless noted
@@ -112,6 +140,12 @@ Two classes:
| `GE_D318T` | `port/src/d318watchdog.c:270` (`#ifdef PORT`) | D318 watchdog timeline probe: per-tick log of the Facility ai_22 softlock signature window (c78 list/offset/attack state, c67 health) while the watchdog watches. Inert unset. | **live** (D318 verification) |
| `GE_D322` | `port/src/audio.c` (`audioSetNextBuffer`), `src/snd.c` (`sndD322PoolSummary`) | D322 pool-telemetry probe: one line per 5 s of wall clock with the three pool layers a long session can exhaust — `sfx=count/max` (8-voice SFX soft cap), `evtq=n/64` (event queue occupancy; full = silent post drops, M-71), `pv free/lame/alloc` (24 physical synth voices shared by SFX + music; `-1`s before sndInit), plus the D204 pipeline fields. Cheap (~30 reads/s), campaign-safe; read together with `GE_D204=1`. | **live** (D322 capture) |
| `GE_D318W` | `port/src/d318watchdog.c:136` (`#ifdef PORT`) | **Not a probe — the D318 port-side watchdog itself, ON by default.** Detects the Facility ai_22 hold-pose softlock signature (a latent N64 race, see findings.md D318) for 600 consecutive ticks and re-seeds c78's attack via the game's own fresh-attack entry point. `GE_D318W=0` opts out; logs `D318W:` lines when it acts. **Permanent fix, not strip-candidate.** | **live** (D318 fix — permanent) |
+| `GE_D294` | `src/boss.c:498` (`#ifdef PORT`) | One line per stage reset: stage number + current MA malloc value (D294 pool investigation). | **live** (D294) |
+| `GE_D294BANK` | `src/game/bg.c:605` | D294: restarts the bank-window counter on stage change (pairs with the `GE_D104` windowing). | **live** (D294) |
+| `GE_D303` | `port/fast3d/gfx_pc.cpp:1717` | D303: logs up to 200 tri emits with per-vertex bbox + w-sum (degenerate-triangle hunt). | **live** (D303) |
+| `GE_D307` | `src/game/prop.c:709` | D307: load-time dump of every CCTV record's inputs + derived orientation, for word-for-word PC-vs-ROM comparison. | **live** (D307) |
+| `GE_D318B` (+ `GE_D318B_ALL`, `GE_D318B_CLK`) | `port/src/d318bprobe.c:91,97,210` | D318/D320 boundary probe suite (M-199): per-tick ground truth of the aim-hold/derail/anim-pin state machine for env-selected chrs (`"5,9"`, max 4); `_ALL` logs every ACT_ATTACK tick; `_CLK=N` locksteps N sim ticks per rendered frame. Read-only. **TEMP — remove when D318/D320 conclude.** | **TEMP** (D318/D320) |
+| `GE_D329` (+ `GE_D329LOG`) | `src/game/model.c:3516,3519` (`#ifdef PORT`) | **The D329 anim-pin fix gate — ON by default** (unlike the other probes here): re-derives a stale pinned frameb when the endframe no longer blocks it. `GE_D329=0` A/B-disables; `GE_D329LOG` logs each trigger (cap 2000). Cached. | **live** (D329 fix) |
## Notes
diff --git a/docs/dev/GRAPHICS-BACKLOG.md b/docs/dev/GRAPHICS-BACKLOG.md
index 6f1a2bf4..c629fae5 100644
--- a/docs/dev/GRAPHICS-BACKLOG.md
+++ b/docs/dev/GRAPHICS-BACKLOG.md
@@ -10,7 +10,8 @@ once the game is playable start-to-finish without crashes.
|----|---------|----------------|
| ~~**D114/D116**~~ | ~~HUD text + ammo digits render each glyph individually X-mirrored~~ | **CLOSED — NOT A BUG (M-33, §F D168).** The `GE_PCDUMP` PPM writer never reversed `glReadPixels` rows, so every capture was upside-down; "mirrored" was a misread of inverted asymmetric content. The game renders correctly on hardware (developer-confirmed). Writer fixed (`gfx_opengl.cpp`). "Every stage verified non-mirrored" was correct — there was no mirror. |
| ~~**D74 wrap-block**~~ | ~~`gfx_pc.cpp:1546/1551` PORT block is dead code (guard `cms & G_TX_WRAP` always false; indexes per-texunit arrays with the vertex index → OOB if reached).~~ | **RESOLVED (M-30, RC3/D167).** The in-vertex-loop wrap block was reworked and hoisted to a per-texunit pre-wrap window (`gfx_pc.cpp` ~1574, now `cms`-correct and indexed by texunit not vertex), behind the `Video.WrapFix` knob (default OFF). No longer dead code or OOB. See §F "RC3 · D167". |
-| **D75** | ~~Nintendo logo mispositioned; gun-barrel Bond model absent~~; cast-roll character models — status unknown. (Rareware logo OK.) | **Shrunk (M-33, upright captures after D168).** The "overlaps D114 shared mirror" note is withdrawn. **Gun-barrel Bond RENDERS correctly** (walk + fire, upright) — the "absent" reports were the flipped capture. **Nintendo logo IS still broken** — renders as two overlapping white ellipsoids shifted left, no wordmark/geometry (a real `logoinst` model transform/geometry bug, not a flip). Cast roll not re-captured — recheck with `GE_PCDUMP="1400-2200:25"`. `findings.md` §F "D75 Bug 2 — M-33 UPDATE". |
+| **D392** | In the opening attract-mode Train demo, a soldier consistently looks more hunched on PC than in 1964/GEPD; his hand intersects his body at the wrong position. User comparison: `docs/img/animation-glitch-pc-port.png` and `docs/img/animation-glitch-1964gepd.png`. | **FIXED — user visually verified (D392).** Train's normal opening (`-level_25`) has a central firing guard (`chr=5`) whose torso/shoulder aim offsets reached `aimupback=-0.855`, `shoulder=-0.606,+0.868` radians by tick 100. `chrlvUpdateAimendbackShoulders` used N64 `((f32*)arg1)[12/13/16/17]` offsets into a firing table whose first member widens from a 4-byte to an 8-byte pointer union on PC; it read *other* floats and bent the torso/arms to wrong angles. `#ifdef PORT` now reads `max_up/max_down/free_arm_frac_up/free_arm_frac_down` by name; with the same short Train intro probe the torso offset stays 0, shoulders ~0.028/+0.014 at tick 100. User's original comparison: `docs/img/animation-glitch-pc-port.png` vs `docs/img/animation-glitch-1964gepd.png`. The user subsequently confirmed the soldier's pose looked fixed in-game; this session's image tool itself cannot display PNGs. No ramrom demo override needed; ordinary `-level_25` reproduces. |
+| **D75** | ~~Nintendo logo mispositioned; gun-barrel Bond model absent~~; cast-roll character models — status unknown. (Rareware logo OK.) | **Nintendo logo + GoldenEye logo + legal page FIXED (M-197, user-confirmed live 2026-09-21).** Root cause: the D50 sidecar converter (`tools_pc/d43_emit.py`) reserved + remapped the model's embedded texture-blob span but never copied the pixel bytes into the emitted RZ image → TEXEL0 sampled a zeroed buffer → the texture-modulate logo rendered black (M-195/M-196 had proven emission/transform/combiner all correct). Same latent bug hit `PgoldeneyelogoZ` + `PlegalpageZ`. Fix = tooling-only (blob raw-copy + round-trip check); ntsc sidecars regenerated; logo window now renders 23k px (7.59%) vs 0–33 pre-fix. Gun-barrel Bond renders correctly (M-33). Cast roll still unverified — recheck with `GE_PCDUMP="1400-2200:25"`. `findings.md` §F D75 (M-195/M-196/M-197 updates) + M-197 at file tail. |
| **D76** | 2D disclaimer screen only partially drawn. | §F D76. |
| ~~**D77**~~ | ~~No audible music on PC.~~ | **RESOLVED (M-104, PR #42) — in-level music now plays.** Root cause was a D6-class missing `return` in `sub_GAME_7F0C0BF0` (`mp_music.c`) that zeroed the seqp volume on the only in-level trigger path; menu/intro music bypassed it and was always fine. The remaining audio-*quality* gap (wrong-sounding instruments on some tracks — elevator named specifically — plus occasional garbling) is tracked as **D230**. §F D77/D230. |
| ~~**D116 (HUD)**~~ | ~~proportional-font strings + ammo counter mirrored~~ | **CLOSED — NOT A BUG (M-33, §F D168).** Same capture-orientation artifact as D114. |
@@ -25,15 +26,19 @@ 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. | **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. |
+| **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. | **FIXED (M-198, pass 26) — awaiting user play-test sign-off.** Root cause was a texture-BINDING bug, not decode/alpha/mip/geometry: GE binds the four Surface 1 treeline cards (`tex1198`-`tex1201`, RGBA5551 64x17 cut-outs) as `TEXTURETYPE_DETAIL`, where `texHandleType1` (`tex.c`) loads the *detail* texture at TMEM 0 and the real base image at TMEM `texGetSizeInBytes(detail,0)` on tiles 1+. fast3d's `gfx_lod_tile_offset` folded every `G_TL_LOD` draw to tile 0 (D107, correct for TEXTURETYPE_LOD/MIPMAP), so the port drew the shared detail texture -- `tex2465`, an opaque 32x32 IA8 noise tile, which is exactly the 'noise painter' D267/D276 measured at 1023/1024 texels opaque -- and never imported the cards at all. Fix: when a tile pair differs in both TMEM address and `fmt`/`siz` under `G_TL_LOD`, sample the base image (tile 1). `Video.DetailBaseTile`, default on. Live-verified on Surface 1 (discrete snow-covered pines) and Surface 2; Depot clean; bunker1 golden bit-identical with the fix on/off; `type=1` bindings exist only in sevx/depo/sevb/sev, so every other level is provably untouched. Full trail: `findings.md` D236 pass 26; generalised as `porting-notes.md` §D12.
| **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. |
| **D173** | Third-person Bond model (the player-representing figure shown at level start) spawns too high — floats well above the ground "a lot of the time" while the actual player spawn is correct. Same symptom on the Cuba end credits: JB stands ~6 ft in the air above Natalya. | **Confirmed still present across the full 21-level playthrough (M-49).** **Observed, not investigated** (user QA report). D75 animated-model family but a *position* defect, not absence — M-33 confirmed the gun-barrel Bond renders fine upright. Suspects: (a) intro/credits puppet chr spawn position vs the model's base transform; (b) anim root-motion accumulation (`modelSetAnimFrame2WithChrStuff`, D156 area) offsetting the rendered joints above the body origin; (c) a `coord3d`/packed-float decode in the intro camera/model setup. "Player spawning is OK" localises it to the render-side model, not `g_CurrentPlayer` state. |
-| **D182** | SELECT FILE screen degrades after navigating away and back (main menu → file select, or backing out of file select and re-entering). On the fresh screen the 007 gun-barrel spiral / Bond silhouette renders behind the file boxes; after a round-trip **the Bond image stops drawing entirely** and the whole background gains the **comb / interlacing** artifact (horizontal line scramble). File boxes, text and the Copy/Erase icons still draw. | **Observed, screenshot committed** (M-35, user QA; `docs/img/bugs/d182-file-select-backout-20260902.png`). Two symptoms, likely linked: **(1)** background 3D image stops rendering after re-entry → a front-end model/DL whose `render_pos` / segment binding is only valid on first construction (D149 / D75 `constructor_menuXX` family — the dossier/wallet models have the same "renders once" behavior). **(2)** interlacing = **M-36 triage: Family A**, the `import_texture_*` `line` vs `full_image_line` shear (see D176(b) / `docs/dev/M-36-TRIAGE.md`) — **not** the D159 `texSwapAltRowBytes` path (that is a full PORT no-op). Open question: why only *after* a round-trip — first import may land `line == width`, re-import may not, or the tile descriptor is stale on re-entry. Repro: `GE_STARTMENU` to file-select, back out with ESC/B, re-enter; `GE_DTEX` on the spiral texture across both visits. Shares defect (2) with D176(b). |
+| **D182** | SELECT FILE screen degrades after navigating away and back (main menu → file select, or backing out of file select and re-entering). On the fresh screen the 007 gun-barrel spiral / Bond silhouette renders behind the file boxes; after a round-trip **the Bond image stops drawing entirely** and the whole background gains the **comb / interlacing** artifact (horizontal line scramble). File boxes, text and the Copy/Erase icons still draw. | **Observed, screenshot committed** (M-35, user QA; `docs/img/bugs/d182-file-select-backout-20260902.png`). Two symptoms, likely linked: **(1)** background 3D image stops rendering after re-entry → a front-end model/DL whose `render_pos` / segment binding is only valid on first construction (D149 / D75 `constructor_menuXX` family — the dossier/wallet models have the same "renders once" behavior). **(2)** interlacing = **M-36 triage: Family A**, the `import_texture_*` `line` vs `full_image_line` shear (see D176(b) / `docs/dev/M-36-TRIAGE.md`) — **not** the D159 `texSwapAltRowBytes` path (that is a full PORT no-op). Open question: why only *after* a round-trip — first import may land `line == width`, re-import may not, or the tile descriptor is stale on re-entry. Repro: `GE_STARTMENU` to file-select, back out with ESC/B, re-enter; `GE_DTEX` on the spiral texture across both visits. Shares defect (2) with D176(b). **2026-09-27 (D397):** user re-reported the comb/interlace on the file-select background AND on the **intro gun-barrel** — the latter is a *first-time* render, so the defect is persistent (not only after a round-trip), refuting the "stale descriptor on re-entry" hypothesis. D159 (odd-row swap) is no-op'd and D183 pitch shear is disproven for level_36 + a no-op for the 299×440×1 row-strip. Next step is a live `GE_PCDUMP` capture + ROM ground-truth decode of the gun-barrel RLE image to separate ground-truth dithered content from a GL sampling/texture-cache artifact. See findings.md D397. |
| **D197** | Character face/head textures wrap wrong — the face texture repeats around the side of the head "and continues weird" instead of clamping to the front. Seen on **Silo** (M-49 playtest), intermittent ("sometimes"). Reads as **`G_TX_CLAMP` not honoured** for the character-skin tile in fast3d (→ `GL_REPEAT`, so the face UV region tiles around the skull), or wrong `shifts`/`shiftt` / LOD-tile selection on the head texture, or the D167 non-PoT wrap-period issue (head textures are often non-PoT) hitting skinned models. | **Observed, not investigated** (M-49, user bug report — needs a capture + which chr/level). Distinct from D176(b)/D182(2) (those are *bg wall* pitch shear). Related: RC3/D167 (`Video.WrapFix`, non-PoT wrap period) + the `texWriteTileLods` `shifts=shiftt=lod` note in findings §F "D176(b) → 4. candidate 2". First checks: does `gfx_pc.cpp` map N64 `cms/cmt` `G_TX_CLAMP` → `GL_CLAMP_TO_EDGE` for character DLs; is the head tile getting the base tile vs an LOD tile (D107); try `Video.WrapFix=1` on a repro. |
| **D195** | Transparency / alpha-blended surfaces render wrong — reported on **Control** (Severnaya bunker basement — Natalya's console room; heavy on glass partitions and the translucent projected/holographic wall displays). Exact failure mode not yet pinned by the reporter (M-49 playtest): candidates are alpha-blended geometry drawing fully opaque, translucent panels drawing invisible, or draw-order/sort so translucent surfaces occlude what's behind. | **Observed, not investigated** (M-49, user bug report — needs a `GE_PCDUMP` capture + which surfaces). fast3d render-mode / combiner path. Same neighborhood as D161 (CI8 tile + `G_TT_NONE` → garbage), D172 (particle-sprite channel order), D176(b) (texture pitch shear) — but this is a *blend/alpha* defect, not a texture-decode one. Note D128 already touched Control's `specialportalarray` (portal struct stride) — check whether the affected surfaces are portal-adjacent. First checks: does `gfx_pc.cpp` honour `G_RM_*` XLU render modes + `G_AC_THRESHOLD`/`G_CC` alpha for these DLs; is `gDPSetRenderMode` `w1` being parsed (cf. findings §F "zbuf-clear w1 scribble"); is Z-write-vs-blend ordering right for XLU surfaces. Get the reporter to say: opaque-where-should-be-clear, invisible, or wrong edges? |
| **D252** | Particle effects cycle through a rainbow palette — bullet-impact sparks and lingering smoke/explosion residue drift through the full hue wheel over time (~10 shots to sweep it) instead of holding their intended grey/orange palette (M-124, user playtest). Full mechanism + suspect in findings §F **D252**: colour comes from 15 distinct `IMAGE_SMOKE_N`×`IMAGE_FIRE_N` (RGBA16) pairs the particles age through; prime suspect is `import_texture_rgba16` deriving width/height from the tile line size (32×7 instead of 16×14 for the fire tiles), scrambling each stage's sprite into a different wrong hue. | **Documented only — not fixed for v0.2.0-pre** (user instruction). Confirm with one dumped fire tile vs raw `IMAGE_FIRE_N` bytes, then derive width/height from the LOADBLOCK dtx/dlh. D172/M-82 lineage. |
+| **D323** | Distant geometry on 15 levels samples driver-generated mips from the finest texture level; N64 samples Rare's hand-authored mip chain for explicit-LOD textures bound under `G_TL_LOD` (heaviest: Statue 378 binds, Aztec 806). Overwhelmingly CI8/IA8 — subtle fidelity gap, no reported artifact; the only RGBA5551 cut-outs are the D236 set (already handled). | **OPEN / parked** — mechanism confirmed, census in `tools_pc/bg_lod_audit.py`, fix path documented in findings §F **D323** (upload GL mip chains for TEXTLOD-on bindings). Not worth doing speculatively; revisit only if a playtest flags mushy distant detail. |
+| **D335a** | **RESOLVED 2026-09-23.** Gun-barrel backdrop (Bond's spotlight) leaked into the pillarbox bars while sliding in from off-canvas. Fix: `menu_jump_constructor_handler` emits a full-canvas `gDPSetScissor` right after the CENTER wrap (only when wider than 4:3), which fast3d squeezes to the pillarbox, so off-canvas draws clip at the 4:3 edge as the N64 RDP clips at the screen edge. Verified with a 16:9 barrel capture. |
+| **D335b** | Native widescreen: the F10 options overlay still stretches with the window (legible, pre-existing look). Pillarboxing it needs `optionsoverlay.c`'s click mapping (`gfx_get_ui_screen_rect`) remapped into the centred region in step. Polish, post-playtest. |
+| **#105** | Dam: uncovered water-reflection pixels show the procedural cyan fill instead of the stage sky (residual after the water/wall-reflection work; visible boundary between reflected geometry and sky in the settled Enhanced/Remaster capture). | Backlogged 2026-09-28 from [issue #105](https://github.com/jkdansereau/goldeneye-pc-port/issues/105); post-v0.4.0 graphics queue. Note: the issue's "D329" cross-ref is the issue author's own numbering — our D329 is the anim-pinning fix. |
| **1P weapon viewmodel** | **RESOLVED (M-83, D215).** In-level first-person weapon models drew nothing. Root cause: `gunRenderFirstPersonGunModels` built its `ModelRenderData` via `*(ModelRenderData*)&D_80035CC0` — a reinterpret across two separately-declared adjacent globals that only lands right on the N64 linker + 4-byte pointers; on x86-64 `renderdata.flags` read 0, gating every weapon DL node off in `modelRenderNodeGundl`. All 3 ranked suspects in VIEWMODEL-RESEARCH.md were ruled out first by the `GE_DVM=1` probe. Fixed `#ifdef PORT` (explicit field init). `subdraw` 1→153 gfx cmds; owed one BUNKER1 eyeball. |
## Resolved (for reference)
diff --git a/docs/dev/M7-ALLUNLOCKED-PLAN.md b/docs/dev/M7-ALLUNLOCKED-PLAN.md
new file mode 100644
index 00000000..3c78d17e
--- /dev/null
+++ b/docs/dev/M7-ALLUNLOCKED-PLAN.md
@@ -0,0 +1,45 @@
+# M7 — Reversible AllUnlocked: investigation and fix plan
+
+**v0.4.0 release decision (2026-09-28, user):** ship AllUnlocked as a
+**loudly-documented, EXPERIMENTAL, irreversible** option for this release rather
+than hiding/disabling it or landing a safe architecture. The toggle stays (default
+OFF); the in-game row is now labelled **"All unlocked (EXPERIMENTAL)"** with the ON
+value displayed as **"ON - UNSAFE"** in both the F10 overlay and the front PC
+Options screen, and the README Known-issues bullet already tells users to back up
+`data/ge007.eep` before enabling it. A proper fix (Candidate A port-only shadow or
+Candidate B non-mutating virtual query) remains the v0.5.0 work item.
+
+Status: **investigation, NO FIX APPROVED OR LANDED**. Root cause/repro: `docs/dev/findings.md` D387; original feature: D257, fresh-save repair: D281, old-CRC migration: D297. This plan supersedes the M7 "no-code proof" in `docs/dev/notes/MODERN-OPTIONS-PLAN.md`. Follow `AGENTS.md` rule 2 and `docs/dev-process.md` §7 before any `src/game` edit.
+
+## User-visible contract to settle
+
+Recommended: OFF (after restart, as presently documented) must display only genuinely earned mission progress/cheats; ON may *temporarily* expose every level, difficulty, 007 mode and cheat, without modifying durable progress. Achievements **earned while ON without an active cheat** should survive OFF, including difficulty inheritance, timed cheats and profile operations. Enabling/disabling must never silently delete existing genuine save records. Do not auto-repair an EEPROM already containing baked fake unlocks: the bytes lack provenance. The user has not explicitly signed off on a game-code alternative.
+
+## Confirmed facts (source + isolated data)
+
+- `port/src/libultra.c:geEepromRW` first migrates legacy CRCs on the five-slot block-4 read, then `geEepromPatchAllCheats` fills unlocked-cheat bits and every zero completion time with 0x3FF and re-CRCs a **temporary copy**. `fileValidateSaves` (`src/game/file2.c:527`) accepts that copy into `saves[]`. `fileWriteSave` (`file2.c:86`) writes the entire 96-byte live slot, not only the setting/progress that changed.
+- Private data under `AppData/Local/Temp/ge-m7-20260927/`: the older `data/ge007 copy.eep` has two genuine times and only cheat `04 00 00` in active folder 0 (slot 4). ON or OFF boot without a save yields identical EEPROM bytes after the same legacy CRC migration. ON plus existing `GE_WSPROBE_FRONT=600` performs an ordinary Music-volume save to slot 4: cheats become `ff ff 0f`, 58 empty times become 0x3FF, two real times remain. OFF + restart validates the written CRC and retains all fakes. Full repro/logs: D387. Current `data/ge007.ini` and `data/ge007.eep` were never opened for write; copies and SHA-256s in that private test directory.
+- `main.c` seeds debug unlock flags only at boot; an in-session OFF toggle leaves those RAM flags set until restart. `Game.AllUnlocked` defaults OFF; the relevant UI currently gives no explicit restart warning.
+- More than a write-mask problem: a successful mission runs `end_of_mission_briefing` (`src/game/file.c:41–68`) → `fileUnlockStageInFolderAtDifficulty` (`file2.c:828`) → `fileOverwriteSaveSlotWithNewSave` (`file2.c:794`), **rotating the profile to a free EEPROM slot** and freeing the old slot. `fileCheckSaveStageDifficultyTime` only updates if the real time beats the synthetic 0x3FF. Genuine lower-difficulty completions and long (>1023-second) runs can also have legitimate 0x3FF times; value alone is not provenance. Fake cheat bits cause `fileGetIsCheatUnlocked` to return true, so `end_of_mission_briefing` skips a genuinely earned timed-cheat award. A port-only sanitizer keyed by physical slot or by `time==0x3FF` is incorrect.
+- The current user EEPROM already has two nonfree slots with 60/60 max times and all cheat bits, consistent with an earlier write-back. Two separate older EEPROM backups exist in `data/`, but replacing or auto-merging them would risk later real progress; do not touch or auto-repair originals.
+
+## Architecture decision before code
+
+**Candidate A — port-only EEPROM shadow/merge:** keep authoritative *raw* progress per logical profile outside patched `saves[]` and translate every write to durable real-only state. Must cover direct settings writes, profile rotation, copy/delete/reset, earned times (including 0x3FF), timed cheat grants currently suppressed by fake reads, five-slot CRCs, and old-CRC migration. Physical-slot-only interception demonstrably fails. A read/write-only adapter may have insufficient information to recognize some genuine completions or cheat awards; prove a full event/provenance model in an isolated prototype before promising this path. No new sidecar that can be lost/desynced without a recovery design.
+
+**Candidate B — non-mutating virtual query:** retire `geEepromPatchAllCheats`, keep raw `saves[]` and EEPROM intact, and make the *opt-in* UI/gameplay availability queries report AllUnlocked without changing saved bits/times. Likely small PORT-gated touch points: `fileGetIsCheatUnlocked` (`src/game/file2.c:391`, existing D225 developer-only query override), `fileIsStageUnlockedAtDifficulty` (`file2.c:681`; cheat-enabled mission selection requires `STAGESTATUS_COMPLETED`, not merely the debug-flag `UNLOCKED`), and the raw earned-cheat check in `end_of_mission_briefing` (`src/game/file.c:41–68`), which must still record real cheat awards while virtual availability is ON. Audit every caller and the Rare/PD analogue first. A live-only toggle may require restoring debug flags safely; simplest contract is **takes effect on restart** and an explicit UI label. This alters opt-in `src/game` behavior and is **not** the ABI/layout exception: no implementation or implicit sign-off. If port-only is ruled out, prepare the exact file/line, reference, reason, scope and precedent required by `docs/dev-process.md` §7; obtain explicit user approval and document the grant in a new RULE-2-SIGNOFF finding before edits.
+
+**Release fallback:** if neither safe architecture is ready, do not present the current toggle as a reversible enhancement. Get an explicit release decision on hiding/disabling it vs shipping a loudly irreversible option; default-off alone does not protect users who toggle it on. Keep existing saves intact.
+
+## Test matrix (private files ONLY)
+
+1. Capture baseline hashes of copied current ini/EEPROM; separate valid older-progress, fully fresh zero EEPROM, already-contaminated, legacy-CRC, and mixed/multiple-folder fixtures. Do not copy old saves back into `data/`.
+2. No-write ON boot then OFF restart: raw five slots unchanged except *explicit* D297 migration; correct UI availability in both states.
+3. ON settings writes via `GE_WSPROBE_FRONT` (normal slot), in-stage F10 watch writes, and save-file chooser/reset: only real settings/intentional reset persist; CRC valid; no synthetic completions/cheats in raw file.
+4. Real mission completion at agent and higher difficulty (including inherited lower difficulty), fast timed-cheat award, slow/capped 0x3FF completion, and cheat-enabled mission selection. Verify actual new earned records after OFF restart, no artificial ones. Build an isolated gameplay harness if manual replay is impractical; do not use a settings-write test as a substitute for mission completion.
+5. Save-slot rotation (`fileOverwriteSaveSlotWithNewSave`), profile copy/delete, all four folders plus free slot, and reopen across process restart. Confirm all five CRCs via `GE_SAVELOG=1` and raw bit-level diff; compare copied baseline profile identity rather than just physical slot index.
+6. Default-OFF NTSC/PAL/JP builds and post-landing 21-level default-render comparison; config/EEPROM originals hash-verified **after** all tests. Human test of ON/OFF UI text and behavior. Existing contaminated saves need an explicit *separate*, opt-in recovery plan; never silently rewrite them.
+
+## Other wave item
+
+M5 remains under user review: FPS cap and MSAA are independently exposed already; dropping the two-setting Low-end preset is a separate decision, not a dependency of this M7 investigation.
diff --git a/docs/dev/OPTIONS-MENU-PLAN.md b/docs/dev/OPTIONS-MENU-PLAN.md
index be3c7eed..cfadcc5a 100644
--- a/docs/dev/OPTIONS-MENU-PLAN.md
+++ b/docs/dev/OPTIONS-MENU-PLAN.md
@@ -1,6 +1,11 @@
# In-game PC options menu — design + resume point
Status: **config-system foundation landed (M-35); menu surface not built.**
+(Updated 2026-09-27: the menu surface *has* since been built and iterated
+as D346 -> D356; the current layout and per-section reset live in
+`docs/dev/D356-SETTINGS-REGROUP-PLAN.md` + `docs/dev/WATCH-SETTINGS-PLAN.md`
+and `docs/dev/findings.md` D346–D356. This doc remains the historical
+M3-scope record.)
Pattern follows `docs/dev/AUDIO-PLAN.md` — a plan doc that a later session
executes.
@@ -85,6 +90,12 @@ or are flagged "restart".
## 4. Alternative if an in-fiction surface is required later
+> **2026-09-27: route B chosen (v0.4.0 intake).** The entry point is on the
+> file select screen, not a main options menu (GE's front end has none). M1
+> landed as D343: an "Options" label that opens the F10 overlay, via one
+> Rule-2 `#ifdef PORT` draw hook. M2 (a GE-styled screen that reuses the
+> overlay's rows) follows.
+
Route (B): a new front-end screen `constructor_menuXX_pcoptions` +
`menuXX_pcoptions_navigation` in `front.c` under `#ifdef PORT`, reached from
a new "PC OPTIONS" row on the main options menu. Each row calls a
diff --git a/docs/dev/QOL-INVENTORY.md b/docs/dev/QOL-INVENTORY.md
index cf528d02..590ae158 100644
--- a/docs/dev/QOL-INVENTORY.md
+++ b/docs/dev/QOL-INVENTORY.md
@@ -33,7 +33,7 @@ ScreenShakeIntensity. Debug: FrameDump, InputLog.
| **DisplayFPS** _(DONE M-83, D213)_ | config `Video.DisplayFPS` | P | S | Top-right readout, config-only; ~0.5 s sample window. |
| **Anisotropic filtering** _(DONE M-83, D212)_ | `Video.Anisotropy` (1–16, dflt 4) | P | S | fast3d already had the GL hook + a hardcoded 4×; M-83 exposed it as config. MipmapFilter / TextureFilter2D still not surfaced. |
| Per-pad tuning: per-stick deadzones, stick scale, rumble scale, device index, swap sticks, C-button mapping | `input.c` padsCfg block | P | M | Medium. Matters for real-controller users; PD pattern is a config section per pad. **M-87 survey addendum:** PD does *incremental* hot-plug (`SDL_CONTROLLERDEVICEADDED/REMOVED` opens/closes individual pads into the first free slot, remembers `deviceIndex=-1` on removal so a replugged pad returns to its configured seat, `input.c:473-491`); GE's port instead does a full rescan-close-and-reopen-everything (`inputRescanPads()`, `input.c:983-1000`) and the game itself latches the controller mask at boot, so a pad added later merges into controller 0 for play. PD's incremental-with-remembered-seat approach is the natural implementation shape for this row and sidesteps the boot-latch merge — fold into the design when this row gets built, not a separate item. |
-| **Rumble → D224 (M-87). DEPRIORITIZED (M-87, user call) — gamepad-only, this release targets mouse/keyboard as primary.** GE's port fully no-ops the N64 Rumble Pak (`port/src/libultra.c:1220-1223` `osMotorInit/Start/Stop` all stubbed "no accessories on the PC") — GE genuinely has a rumble subsystem (`src/motor.c`, `src/joy.c`) that never reaches any output. **PD's PC port wires this straight through to real gamepad rumble**: `inputRumbleSupported()`/`inputRumble()` call `SDL_GameControllerRumble()` when the connected pad reports haptics, `Input.PadN.RumbleScale` config + an options-menu slider, `osMemSize`-style "pretend the accessory is there" pattern applied to a real one instead of faked. | P | M | Concrete PD-port precedent, straightforward port-layer wiring, ready whenever gamepad support is prioritized (no `src/` game-logic touch — GE's `motor.c` calls already exist, just need `osMotorStart/Stop` in `libultra.c` to route to a real `SDL_GameControllerRumble` call the way PD's do). |
+| **Rumble** _(DONE D401 — D224 / M-87, re-promoted 2026-09-27)_ | `port/src/input.c` + `port/src/libultra.c` + `port/src/optionsoverlay.c` | P | M | D401 implements the PD precedent per `docs/dev/RUMBLE-PLAN.md`: `inputRumbleSupported()`/`inputRumble()` → `SDL_GameControllerRumble()` when the pad reports haptics (SDL ≥ 2.0.18 `HasRumble`, else `SDL_JoystickIsHaptic` + known-type Windows fallback), per-pad haptics detection (`SDL_GameControllerHasRumble` / `SDL_JoystickIsHaptic` + known-type Windows fallback), a single global `Input.RumbleScale` (0..1, dflt 0.5) config + one `Rumble strength` slider row; `libultra.c` sets `CONT_CARD_ON` in `g_contStatus` for rumble-capable pads and routes `osPfsInit` → `PFS_ERR_DEVICE`, `osMotorInit/Start/Stop` → the haptics API — the two M-87 addendum blockers that kept `joyRumblePakInit` from arming. Zero `src/` edits. **Real-pad haptics confirmed working by the user (2026-09-28).** Plan item 3 landed the same day: the original four per-player rows were replaced (user decision) with one global `Rumble strength` slider in the F10 overlay + front options screen (`optionsoverlay.c`, shared `rows[]`), 0.05 detents, `kResetDefaults` 0.5; the four `Input.PlayerN.RumbleScale` ini keys were dropped (stale keys self-clean on the next save). A disabled-when-unsupported row state is deferred with the per-pad tuning row (needs a generic row-disabled mechanism). Unverified: `RumbleScale=0` silence check (trivial early-out). |
| FakeGamepads / FirstGamepadNum / UseHIDAPI | `input.c` | P | S–M | Niche (local MP on PC); defer until MP is actually played. |
| Window polish: DefaultFullscreen/Maximize, CenterWindow, AllowHiDpi, ExclusiveFullscreen | `video.c` vid* block | P | S | HiDpi + center are the useful ones on modern Windows. |
| VSync adaptive (−1..10) | `Video.VSync` range | P | S | Note for FPS plan — GE's 0/1 toggle is fine until then. |
diff --git a/docs/dev/RUMBLE-PLAN.md b/docs/dev/RUMBLE-PLAN.md
new file mode 100644
index 00000000..a7375309
--- /dev/null
+++ b/docs/dev/RUMBLE-PLAN.md
@@ -0,0 +1,264 @@
+# Rumble Pak → real gamepad haptics (port plan)
+
+Status: **IMPLEMENTED — finding D401 (2026-09-27/28).** Items 1+2+3 all
+landed (port layer only; zero `src/` edits), re-promoting the
+DEPRIORITIZED finding **D224** (M-87) to DONE. **Real-pad haptics confirmed
+working by the user (2026-09-28).** Menu shape: **one global `Rumble
+strength` slider** (`Input.RumbleScale`, user decision 2026-09-28 — the
+four per-player rows were dropped). Only the `RumbleScale=0` silence check
+remains unverified (trivial early-out path).
+
+Scope: wire the N64 Rumble Pak path (already present in `src/`) through to
+`SDL_GameControllerRumble()` on the PC. **Port-layer only — zero `src/game` or
+`src/` logic edits**, so rule 2 / ABI-exception do not apply.
+
+Reference: the PD PC port (local checkout `../pd_port`, `port/src/input.c`
++ `port/src/libultra.c`) implements the identical feature for the same Rare
+engine family. Copy its shape; the only friction is that **GE's `src/joy.c`
+calls the classic libultra names** (`osMotorInit/Start/Stop`, `osPfsInit/
+osPfsIsPlug`) while PD's engine renamed them (`osMotorProbe`,
+`__osMotorAccess`, `osPfsInitPak`). We translate names, not logic.
+
+---
+
+## Ground truth (current state)
+
+Game side — **already correct, untouched:**
+- `src/joy.c` full Rumble-Pak state machine: `joyRumblePakInit` (:178),
+ `joyRumblePakTick` (:298), `joyRumblePakStart`/`Stop`. Call sites (verified
+ 2026-09-27 for D401) live in `gunfire.c:3284` (start) / `:3288` (stop),
+ `bondview2.c:9722` (start) / `:9726` (stop, incl. 2P second pad),
+ `lv.c:1688` (stop at level start), plus `sched.c:272` (stop on level
+ teardown).
+- `src/motor.c` is **EXCLUDED** from the PC build (N64 SI-DMA driver), replaced
+ by no-op stubs. CMakeLists.txt:525 documents this.
+
+Port side — **all no-oped today, in `port/src/libultra.c`:**
+- `osPfsInit` (:1413) → `return PFS_ERR_NOPACK;`
+- `osPfsIsPlug` (:1415) → `*pattern = 0; return 0;`
+- `osMotorInit` (:1417) → `return -1;`
+- `osMotorStart` (:1419) → `return -1;`
+- `osMotorStop` (:1420) → `return -1;`
+- `g_contStatus[i].status = 0;` (:1102) — `CONT_CARD_ON` **never set**.
+
+Gamepad side — **exists, no change needed:**
+- `port/src/input.c` owns `static SDL_GameController *pads[MAX_PADS]`
+ (MAX_PADS = 4, aligned with `MAXCONTROLLERS`), open/close, hotplug,
+ `inputComputePad()`. This is the exact object `SDL_GameControllerRumble()`
+ needs.
+
+## The two upstream blockers (D224 M-87 addendum — MUST land together)
+
+`joyRumblePakInit` (src/joy.c:178) only drives the motor if BOTH pass:
+1. `g_ContStatus[index].status & CONT_CARD_ON` → today always 0, so init
+ never even runs.
+2. `osPfsInit(...)` returns `PFS_ERR_ID_FATAL` **or** `PFS_ERR_DEVICE` →
+ today returns `PFS_ERR_NOPACK`, so `osMotorInit` is never attempted.
+
+If the SDL wiring is done without (1)+(2), the state machine parks at
+`RUMBLEPAKINITSTATE_NOT_READY` forever and nothing rumbles despite "looking
+wired." Both fixes are in the same file we already touch, so no extra cost.
+
+---
+
+## Work items
+
+### 1. `port/src/input.c` — detection + API
+
+Add per-pad fields (GE uses flat statics, not PD's `padsCfg[]` array):
+```c
+static int padRumbleOn[MAX_PADS]; // set where pads[i] is opened (line ~582)
+static float padRumbleScale[MAX_PADS] = { 0.5f, 0.5f, 0.5f, 0.5f };
+```
+On pad open (in the `SDL_GameControllerOpen` path, line ~575-589), mirror PD:
+```c
+#if SDL_VERSION_ATLEAST(2, 0, 18)
+ padRumbleOn[i] = SDL_GameControllerHasRumble(pads[i]);
+#else
+ padRumbleOn[i] = SDL_JoystickIsHaptic(SDL_GameControllerGetJoystick(pads[i]));
+ if (!padRumbleOn[i]) {
+ const SDL_GameControllerType t = SDL_GameControllerGetType(pads[i]);
+ padRumbleOn[i] = t && (t != SDL_CONTROLLER_TYPE_VIRTUAL); // Win no-haptic fallback
+ }
+#endif
+```
+On pad close, `padRumbleOn[i] = 0;`. GE has no `inputCloseController`
+(PD-only) — the close sites are `inputDestroy()` and the close loop in
+`inputRescanPads()`, both of which zero it (`inputOpenPads` re-detects on
+open anyway).
+
+New public fns (declare in `port/include/input.h`, define in input.c):
+```c
+int inputRumbleSupported(int idx); // 0<=idx= MAX_PADS || !pads[idx]) return;
+if (padRumbleScale[idx] <= 0.f) return;
+if (padRumbleOn[idx]) {
+ strength *= padRumbleScale[idx];
+ if (strength <= 0.f) { strength = 0; time = 0; }
+ else { strength *= 65535.f; time *= 1000.f; }
+ SDL_GameControllerRumble(pads[idx], (Uint16)strength, (Uint16)strength, (Uint32)time);
+}
+```
+In init, add the two PS4/5 hints (PD input.c:696-697) so BT pads report
+haptics:
+```c
+SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS4_RUMBLE, "1");
+SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS5_RUMBLE, "1");
+```
+Register per-pad config (in the existing `configRegister*` block, ~line 2457+):
+```c
+char key[48];
+for (int c = 0; c < MAX_PADS; ++c) {
+ sprintf(key, "Input.Player%d.RumbleScale", c + 1);
+ configRegisterFloat(key, &padRumbleScale[c], 0.f, 1.f);
+}
+```
+Note: **`strFmt` is NOT available in `input.c`** (0 uses, no decl in
+`port/include`) — use a local `sprintf` buffer as above. `configRegisterFloat`
+is declared in `port/include/config.h` (already included by input.c:80); it's
+used elsewhere (video.c) so the symbol is already in the link set.
+
+### 2. `port/src/libultra.c` — rewire the shims
+
+`contSnapshotFromKeyboard` (:1096): set the status bit for connected,
+rumble-capable pads (need a per-channel capability check → call
+`inputRumbleSupported(i)`; it's declared in input.h so include it):
+```c
+g_contStatus[i].type = connected ? CONT_TYPE_NORMAL : 0;
+g_contStatus[i].status = (connected && inputRumbleSupported(i)) ? CONT_CARD_ON : 0;
+```
+Replace the 5 accessory stubs (:1413-1420) with real routing (PD libultra.c:235-351):
+```c
+s32 osPfsInit(OSMesgQueue *q, OSPfs *pfs, int channel) {
+ (void)q;
+ // "pretend the Rumble Pak is present" when the real pad can rumble:
+ // returning PFS_ERR_DEVICE is what joyRumblePakInit (src/joy.c:186-190)
+ // needs to proceed to osMotorInit.
+ return inputRumbleSupported(channel) ? PFS_ERR_DEVICE : PFS_ERR_NOPACK;
+}
+s32 osPfsIsPlug(OSMesgQueue *q, u8 *pattern) {
+ (void)q;
+ if (pattern) {
+ *pattern = 0;
+ for (int i = 0; i < MAXCONTROLLERS; ++i)
+ if (inputRumbleSupported(i)) *pattern |= (u8)(1 << i);
+ }
+ return 0;
+}
+s32 osMotorInit(OSMesgQueue *mq, OSPfs *pfs, int channel) {
+ if (pfs && inputRumbleSupported(channel)) {
+ pfs->queue = mq;
+ pfs->channel = channel;
+ pfs->activebank = 0xff; // PD's osMotorProbe stores this too
+ // NOTE: PFS_MOTOR_INITIALIZED is NOT defined in GE's headers, and
+ // joy.c never reads pfs->status — it only checks (osMotorInit()==0)
+ // to set RUMBLEPAKINITSTATE_READY (src/joy.c:190). Skip the status store.
+ return 0;
+ }
+ (void)mq; (void)channel;
+ return PFS_ERR_NOPACK;
+}
+s32 osMotorStart(OSPfs *pfs) {
+ if (!pfs) return PFS_ERR_NOPACK;
+ // N64 motor has no duration; the game's joyRumblePakTimer60 (joy.c) turns it
+ // off. Issue one generous window, PD-style ("hope the timer stops it"):
+ inputRumble(pfs->channel, 1.0f, 5.0f);
+ return 0;
+}
+s32 osMotorStop(OSPfs *pfs) {
+ if (!pfs) return PFS_ERR_NOPACK;
+ inputRumble(pfs->channel, 0.0f, 0.0f); // zero-duration / zero-strength = stop
+ return 0;
+}
+```
+**JPN note (2026-09-27):** `_HW_VERSION_1` defines `MAXCONTROLLERS` as 6
+while `MAX_PADS` is 4 — the `MAXCONTROLLERS`-bounded loops above are safe
+because `inputRumbleSupported()` bounds-checks against `MAX_PADS` and reports
+unsupported (silent) for `i >= 4`. `osPfsIsPlug` has **no GE game-side call
+sites** — its rewiring is completeness/hygiene only; the load-bearing stubs
+are `osPfsInit`/`osMotor*`.
+
+Headers confirmed: `OSPfs` (`include/PR/os.h:282`) has `queue` + `channel`; the
+error macros `PFS_ERR_DEVICE` (:642) and `PFS_ERR_ID_FATAL` (:641) and
+`PFS_ERR_NOPACK` (:631) are all defined there. **`PFS_MOTOR_INITIALIZED` is NOT
+defined** anywhere in GE's headers and `joy.c` never reads `pfs->status`, so it
+is not needed. `input.h` is already included by libultra.c (line 50), so
+`inputRumbleSupported` is directly callable there.
+
+### 3. (DONE 2026-09-28) options-menu slider — single global
+
+Landed in `port/src/optionsoverlay.c` (not PD's `optionsmenu.c` — GE's F10
+overlay + front options screen share `rows[]`, walked by `frontoptions.c`
+via the `optionsRow*` accessors): ONE `ROW_SLIDER` row `Rumble strength`
+on `Input.RumbleScale` (0.05 detents, generic `CONFIG_OPT_FLOAT` display
+`%.2f`), in the INPUT section after the controller rows, plus a
+`kResetDefaults` entry (0.5, the `gRumbleScale` C initializer) so INPUT's
+"Reset to defaults" covers it.
+
+**Decision (user, 2026-09-28):** the original four-per-player-row design
+(`Rumble (Player 1..4)` on `Input.Player%d.RumbleScale`) was coded and
+reviewed, then replaced with the single global row **before its first
+compile**: the scale applies to every rumble-capable pad, and the four
+`Input.PlayerN.RumbleScale` ini keys are dropped — stale keys in an
+existing ini log four harmless `unknown key` `LOG_NOTE` lines at config
+load and self-clean on the next `configSave` (which rewrites the whole
+file with the registered keys). Per-player rumble tuning stays a G-class
+stretch goal (QOL-INVENTORY) — and a per-pad override-on-top-of-global
+design would be a no-op for everyone anyway, because `configSave`
+rewrites all registered keys and the "user explicitly set this?" signal
+is burned as soon as any key exists at the 0.5 default. The
+row-disabled-when-unsupported state is deferred with the per-pad tuning
+row (the overlay has no generic row-disabled mechanism); the row is inert
+(no-op) on non-rumble setups.
+
+**Original plan (for reference):** PD wires the per-pad scale into the
+options menu (`optionsmenu.c:533-540`): `inputRumbleGetScale(player)`,
+`inputRumbleSetScale(player, v/10)`, disabled when
+`!inputRumbleSupported(player)`. In GE this maps onto
+`port/src/optionsoverlay.c` / the per-pad tuning row (QOL-INVENTORY.md).
+Config file value alone is enough for the feature; the UI is the polish.
+
+---
+
+## Verification ritual (AGENTS.md)
+
+- [x] `./build-pc.sh ntsc-final` compiles clean (2026-09-27). New
+ `inputRumble*` defined once in input.c, declared in input.h; no new
+ undefined/duplicate symbols (full `/linkcheck` sweep pending at D401
+ close).
+- [x] `CONT_CARD_ON` + `PFS_ERR_DEVICE` land (the two-blocker addendum);
+ the state machine's READY path is provable by code read — the runtime
+ proof is folded into the real-pad test below.
+- [x] **By-hand, real pad:** user confirmed the rumble works on a real
+ controller (2026-09-28) — fire a weapon (`gunfire.c:3284` recoil),
+ explosion rumble, 2P second pad (`bondview2.c:9722/9726`). Non-rumble
+ pad and mouse/keyboard-only stay silent by construction (`pads[idx]`
+ NULL / `padRumbleOn` 0 ⇒ `inputRumble` no-op).
+- [ ] `Input.RumbleScale=0` fully silences (unverified — the trivial
+ `gRumbleScale <= 0` early-out in `inputRumble`); max value loud
+ (confirmed implicitly by the working playtest at scale 0.5).
+
+## Finding log
+
+Done (2026-09-27): **D401** added to `docs/dev/findings.md` (§F index +
+full entry) documenting the D224 implementation + the two-blocker
+resolution; the `QOL-INVENTORY.md` M-87/D224 row re-promoted
+DEPRIORITIZED → DONE (D401); `docs/dev/findings-index.csv` regenerated.
+
+---
+
+## Effort
+
+- Items 1+2: **~1–2 h** coding (mostly a rename + copy from PD) + build/linkcheck.
+- Hands-on pad testing: **~30 min–1 h** (the only genuinely open-ended part —
+ feels-right tuning).
+- Item 3 (UI slider): **~1 h**, landed 2026-09-28 as the single global
+ `Rumble strength` row.
+
+**Total for working core: half a day max, <2 h of pure code.**
diff --git a/docs/dev/V0.4.0-PLAN.md b/docs/dev/V0.4.0-PLAN.md
new file mode 100644
index 00000000..135ac554
--- /dev/null
+++ b/docs/dev/V0.4.0-PLAN.md
@@ -0,0 +1,515 @@
+# v0.4.0 Plan — local working document (NOT pushed; update as items move)
+
+**2026-09-27 user release decision:** defer M7 / AllUnlocked save-safety work
+for v0.4.0 (D387); do not ask for a live-save test or treat the feature as
+safe. Before shipping, hide/disable the unsafe toggle or explicitly agree
+on a release limitation — default OFF alone does not prevent a user from
+turning it ON. The user will do a full campaign playtest *instead of* the
+21-level automated sweep; record the campaign result as the release gate,
+not as a sweep PASS. **Playtest interruption D393/D394:** physical-pad
+A use / X reload / Y weapon cycle was user-tested. After the user supplied
+the StrategyWiki Xbox 1.1 Jinx mapping, D394 moved crouch to either stick
+click, LB to aim, B to a port-side owned-gadget cycle, and retired LB/RB
+weapon cycling. Build linked; user physically confirmed the new button roles work, including
+stick-click crouch, LB aim, RB no weapon cycling and B gadget cycling (the
+N64 port has no separate gadget HUD icon). **D395:** both controller-accessible
+binding menus now refuse pad-initiated keyboard/mouse capture and allow B to
+cancel an active modal, pending pad-only retest; this is not gamepad rebinding.
+**D396:** F10 B now backs/closes, A selects/toggles without changing sliders;
+left/right adjusts sliders. NTSC linked; physical pad retest owed. Inspect B's item category/order during campaign play if multiple mission
+items are owned: the current ID-range filter includes some mission objects.
+RB's remastered-graphics toggle cannot be replicated with N64 assets, and
+Back remains the PC options shortcut.
+
+Created 2026-09-21 from a fresh triage of `findings-index.csv`,
+`GRAPHICS-BACKLOG.md`, `docs/BACKLOG.md`, `ROADMAP-1.0.md` (M-139 phase order),
+`QOL-INVENTORY.md`, and the 2026-09-20 HANDOFF.
+
+**Release goal (user-set):** fix everything that still makes the port not 1:1
+with the N64 original; *then* improvements outside the OG game, plus stability.
+Sequencing follows M-139's R → Q → F order, split into four phases below.
+
+## Baseline
+
+- **v0.3.0 is shipped** (tag on origin at `4827bc86`); `main` is 7 docs-only
+ commits ahead. Branch `release/v0.4.0` off `main` when Phase 1 starts.
+- All 20 solo missions (plus the ending-credits sequence) load/render/crash-free; campaign completable end-to-end
+ (Agent, playtested). Steady 60 fps. Audio done. Saves done.
+- Known-issue list = README "Known issues" section (D303, D245, D308/
+ D306, D307, D311, D318 disclosure, widescreen-stretch, D249,
+ D240/D241, D257/D259/D281 AllUnlocked caveat). ~~D252~~ retired (M-188),
+ ~~D75~~ fixed (M-197, user-confirmed), ~~D236~~ fixed (M-198, pass 26 —
+ awaiting play-test sign-off).
+
+## Triage of every open item (as of this triage)
+
+### Bucket A — Fidelity (port diverges from N64 behavior/appearance/sound)
+
+| ID | Item | State / notes |
+|----|------|---------------|
+| D320 | 17 aim-hold AI lists in 8 levels carry the as-authored D318 softlock race; Facility `ai_19`, Control `ai_9`, Depot `ai_12` high/med-high | Static sweep done; **live probe-verification owed** (GE_D318T-style timeline on the 3 lists). If one freezes: generalize `d318watchdog.c` (parameterize list/offset/chr/entityid), not per-list copies. N64 softlocks permanently — watchdog stays a port-side recovery, disclosed like D318. |
+| D252 | Rainbow particles still intermittent on both platforms after the vertex-buffer fix | Second cause unrooted; prime suspect `import_texture_rgba16` fire-tile width/height from tile line size (32×7 vs 16×14). README known issue. |
+| D245 | Water seam between two patterns, moves with view (REOPENED) | M-142 probe: tc-shift k flips during ordinary rotation; opt-in `GE_D245_FIXEDSHIFT` candidate build-verified, needs A/B vs N64 reference before making it default. |
+| D303 | Wrong extra long muzzle flash (M16 + others) | **Investigated — pipeline + data ruled out.** Flash path (`gunUpdateAndFire` → `rwmtx[1]/[2]`, `dorottex` DLPRIMARY arms) is byte-identical game code; M16's `MuzzleFlashExtension`=6.0 is the legitimate max stat; Gm16Z flash-node data byte-faithful through d43. Headless runs can't fire weapons (guards don't shoot an idle player), so a TEMP `GE_D303=1` probe in `gfx_pc.cpp` logs every very-tall-thin emitted triangle with full RDP state — **blocked on a live firing capture** (playtest brief item 17). |
+| D306/D308 | Z-fighting: Dam intro/truck wheels + general/broader instances | **Investigated — real fighting confirmed, port-vs-faithful open.** Depth precision is equivalent to N64 (24-bit both sides; GE's RSP writes full 16.16 post-transform coords, not s16), so level-authored coincident geometry is the prime suspect and likely fights on N64 too. TEMP `GE_ZF=1` flicker probe in `gfx_pc.cpp` (recurrence-filtered; `GE_ZFBOX` region filter) shows sustained oscillation (counts ~1000) in the Dam-intro truck region. Closing it as faithful needs an N64 reference of the same spot; a PC-only mitigation (float depth) would diverge from N64 and needs a rule-2-style decision. |
+| D307 | In-level security camera props face backwards on some levels (Bunker) | **Investigated 2026-09-21 — no port-side bug found.** Full orientation path traced (`setupCctv` → per-frame `RotY(unkC8)×unk84`); every input verified N64-faithful against the ROM (PcctvZ switch data, boundpads incl. the legitimate load-time room-scale ×1.8542, record config words); all math helpers are byte-identical `src/game` code. Remaining suspect if it genuinely differs from N64: fast3d handling of the non-orthonormal lens matrix. **Blocked on user repro detail** (which camera/level + screenshot + ideally N64 compare). `GE_D307=1` diagnostic kept in-tree. |
+| D236 | Surface 1 billboard trees render as a solid wall of texture | **FIXED (M-198, pass 26, `425237f9`):** root cause was a texture-BINDING bug — `TEXTURETYPE_DETAIL` cards drawn from the detail tile (opaque IA8 noise) instead of the base image; `gfx_lod_tile_offset` now samples tile 1 when the pair differs (`Video.DetailBaseTile`, default on). Live-verified Surface 1/2 + Depot; other levels provably untouched. Awaiting user play-test sign-off; probes stripped (`0503d335`). |
+| D235 | Wrong textures after death/restart (level reload), multiple levels | **Headless verification 2026-09-23 (`2a6bc2bb`)**: fix clean on reload; bug NOT reproduced even with the fix off. Needs the real die → mission select → restart A/B (`GE_D235_OFF=1`). |
+| D288 | Silo intro: huge flat untextured triangle sweeps the frame (frames ~562–741 of bare `-level_20`) | **Does NOT reproduce on the current build (2026-09-23, `30cf76b8`)**; D271 A/B ruled out as the fixer. Close if the Silo playtest does not see it. |
+| D294 | Statue: CCCP monument ground renders solid black, self-recovers in 10–30 s | **FIXED pending user playtest** (2026-09-22). Note: its "3× pool corrupts anim" symptom was D336, so the 2× cap can be revisited. |
+| D291 | Dam: texture glitches when the truck drives past | Not investigated. |
+| D249 | Distant geometry drops out on Streets/Egyptian at default FOV | M-142 ruled out far-clip + LOD-multiplier; narrowed to portal/room-visibility BFS. **First check overlap with D271's portal-culling fix** (branch state, see Phase 0). Needs live screenshot+location. |
+| D195 | Control: transparency/alpha-blended surfaces render wrong (glass partitions, holographic displays) | **FIXED** (index: gfx_sp_vertex fix) — plan row was stale (2026-09-23 reconcile). |
+| D197 | Character face textures wrap around the head instead of clamping (Silo, intermittent) | **CLOSED** (user playtest v0.2.0-pre) — plan row was stale (2026-09-23 reconcile). |
+| D182(2)/D176(b) | File-select background degrades after round-trip (comb/interlacing); Surface tree-line static — shared "texture pitch/shear" defect | M-36 Family A; `import_texture_*` line-size assumption. Distinct from the disproven D183 hypothesis. |
+| D176(a) | Sky: RDPHALF tri-raster decode; horizon-band coverage (Defect 2) still open | Path B draft in `scratchpad/sky.patch`, phase-fold fix applied, **never visually verified** — one `-level_22` screenshot owed. Branch `land/d176a-sky-perspective-scroll` exists (forgejo remote) — audit + port to main. |
+| D75 | Front-end logos: Nintendo logo degenerate (tiny orbiting fragment); Rareware filtering wrong | **FIXED (M-197, user-confirmed live 2026-09-21)**: D50 sidecar converter dropped embedded texture blobs. Remaining: pal/jpn sidecar regen on ROM-holding machines. |
+| D290 | Watch in-game "controller" section shows no control-pad graphic | **FIXED 2026-09-23 (`a5804d10`)**: D264-pattern template read in `watchRenderController` + 3 pointer-width/raw-Gfx sites. Sibling sweep also found + fixed **D331** (shell casings never rendered, `94dff811`). |
+| D174/D172 | Blood: decals never interactively verified (D120 needs sidecar regen); spray color fixed but owed an eyeball | BUNKER1 firefight with regenerated sidecars (`d43 && d69 && d88 --regen`). |
+| D240/D241 | Gunshot SFX cadence drifts; PP7/AK47 goes silent under heavy fire near looping sounds | D241 high-confidence = D207 reproduced (check D207 entry status first — index row is stale/ambiguous). Fold into the D207 voice-cap family. |
+| D322 | Long-session audio degradation (issue #87, v0.3.0 full campaign): from Silo onward audio worsens; by Caverns/Cradle OST inaudible + SFX "come and go"; restart cures → session-accumulating state | OPEN — static triage done (teardown audit clean, mixer stateless, D202 leak class covered only for ownerless `count==-1` loops and only validated single-level; D305 already records a pool/list desync). Ranked: voice-pool exhaustion/counter drift > queue starvation > evtq saturation. `GE_D322` pool-telemetry probe shipped 2026-09-21; needs a campaign capture with `GE_D322=1 GE_D204=1`. |
+| D311 (residual) | Upstream anim-poison source still open (freeze class guarded) | **Likely root-caused by D336** (head-bob alias read); headless Caverns clean at 2× pool. Close after one live Caverns combat session. |
+| D258 | PAL/JP asset conversion broken at the source-data level | Repair path verified, not applied. Region fidelity — NTSC-only users unaffected, but it blocks "faithful in every region" and any JP/PAL ROM use. |
+| D160 (residual) | Cutscene reliability PARTIAL (D243 family mostly fixed via M-189/M-190) | Residual = whatever the live GE_D160/GE_D243 campaign pass shows. |
+
+### Bucket B — Stability (crashes/freezes/hangs; not N64-parity per se)
+
+| ID | Item | State / notes |
+|----|------|---------------|
+| D289 | Dam computer-room crash (Deck, one occurrence, never repro'd) | Needs a human re-test on a current build (D255+). If it never recurs → close as untracked. |
+| D287 | Windows: real, non-self-recovering freeze after ~15–20 min idle + click-back | One confirmed occurrence; `gfxFrameMsgQ` full is a lead. Consider crash-dump wiring on Windows for the next hit. |
+| D286 | Deck: ~8 s self-recovering frame freeze on Caverns | One confirmed occurrence, no repro trigger. |
+| D175 | Brief stutter/hang (door-open on Runway/Surface) | Matches known transient-hang classes; reproduce under `tools_pc/debug.ps1` idle-box before believing it's new. |
+| D285 (residual) | Audio-thread race fix was PARTIAL | Re-check the thread-lock state vs D305's NULL guard coverage. |
+| D281 | AllUnlocked breaks audio + RMB aim when enabled with zero saves written | User-facing mitigation shipped; proper fix = make the feature robust or gate it harder (see Phase 3 decision). |
+| — | getenv caching in hot paths (**DONE 2026-09-23, `d9d7cd35`**) (post-release cleanup, HANDOFF M-192) | D302 fixed one instance; sweep for stragglers. Cheap perf/stability hygiene. |
+| D117 | Frame nondeterminism (GE_DETERM attempted, not achieved) | Parked as a research item; do not block the release on it. |
+
+### Bucket C — Improvements / QoL (outside OG game; after fidelity)
+
+| ID | Item | Notes |
+|----|------|-------|
+| Widescreen | Currently **stretched 4:3**, not native — README discloses this | **DONE 2026-09-23 (D334 world `d5d9a36a`, D335 HUD/front end `f62bceaf`)**: native at any aspect, default on, F10 toggle; Rule-2 approved. Owed: user HUD playtest at 16:9/21:9; D335a cosmetic. |
+| D237 | F10 overlay: mouse-wheel scroll, more settings, category grouping | **DONE 2026-09-23 (`91bd0f84`)**: F10 category headers + 5 more rows (wheel scroll already existed). |
+| D314/D315 | F10 flicker (user-deprioritized) + main-vs-in-game menu scaling | D315 deferred from v0.3.0; bigger design question — lands naturally with the overlay work. |
+| D238 | Mouse sensitivity rework (master sens, RMB aim sens, Y-gain parity) | Partly superseded: aim modes folded (D337, N64 default via PD integration; Centred (PC) opt-in), Aim range PC/N64 (D338). Remaining: sensitivity feel tuning. |
+| D239 | Click-to-lock parity: delete always-grab mode 0 + config key | Small; closes D192 by deletion. |
+| GEPD controls (M3) | **IMPLEMENTED, targeted playtest owed (D378/D380/D383–D386).** One effective keyboard/mouse layout, no `Input.Layout` selector: Ctrl crouches without aim; E interacts without reloading; R reloads without interacting. Input → Bindings → Movement/Actions edits two Primary/Secondary keyboard-or-Mouse 1–5 slots in both UIs (D388). Mouse 3 (right) is Aim default; wheel still cycles weapons. Menu arrows/Enter/Escape and mouse clicks remain fixed. See `MODERN-OPTIONS-PLAN.md` M3; earlier preset/B-button text there is historical. |
+| Modern controller presets (D394) | **1.1 Jinx button roles physically confirmed; precise gadget category still open**: A action, X reload, B cycle owned gadgets, Y cycle weapons, L3/R3 crouch, RT fire, LT/LB aim, D-pad movement, Start pause. RB updated-graphics and Back scores are XBLA remaster-specific: no HD assets/score view in the N64 port; reserve RB, keep Back→PC options. **Planned** named selector: 1.1 Jinx (L move+strafe, R look+turn), 1.2 Christmas (L move+turn, R look+strafe), 1.3 Frost (L look+strafe, R move+turn), 1.4 Elektra (L look+turn, R move+strafe). Face/trigger roles remain fixed across schemes. Add an `Input.ControllerPreset` enum with default 1.1, F10/front-options selector, preserve per-pad inversion/deadzone, separate keyboard `NaturalPitch` from the pad selector, adapt both stick axes and D-pad per scheme, and verify in watch/menu/tank and physical controller before labeling each selectable. Do not treat these four presets as already implemented. |
+| Modern options wave (parity set, 2026-09-27) | **In progress, not a single opt-in Modern page (D388).** M1: Input → Bindings and Gameplay → HUD, shared by F10/front; M2: Original red crosshair plus White/custom RGB, style, size and visibility implemented (D379/D381/D382); M3: single effective layout and rebinding (above). **M5: redundant Low-end toggle removed (D388); Frame rate cap and Anti-aliasing remain independently adjustable, stored values preserved on migration.** M4 GE+ knockback/hitstun/damage-sound/death-cam toggles parked for v0.5.0, not v0.4.0 blockers. **M7 BLOCKED (D387):** AllUnlocked ON can persist synthetic cheats/times after any profile-settings save; OFF does not remove them. Do not run on live saves; a safe design and multi-profile fixture tests are owed. PNG screenshot upgrade dropped from this wave; dev PPM dump remains. The M1–M7 detailed notes retain their historical design record in `MODERN-OPTIONS-PLAN.md`. |
+| D226 | Player-adjustable HUD scale (ammo counter, pickup text, dialogue) | **DONE 2026-09-24 (`30720354`)**: Game.HudScale 75–150% (user-verified), fast3d rect scaling about anchors, dialogue/bottom width-capped. |
+| Controller rebinding UI (D395) | **Not implemented.** The two Input → Bindings → Movement/Actions pages edit keyboard/mouse slots only. Pad-only users could previously enter a capture modal and get stuck; pad-triggered capture is now disabled in both UIs and B cancels a capture started by keyboard/mouse (NTSC build linked; physical pad-only retest owed). Controller presets (D394) are separate from arbitrary pad rebinding; design an explicit controller-actions page, persistence/migration and capture/cancel semantics before enabling pad capture. | Post-v0.4 scope decision; no promise that keyboard binding slots can hold pad buttons. |
+| Rumble (D224) | Wire `osMotor*` → `SDL_GameControllerRumble` (PD precedent exists) | Deprioritized M-87; pick up if gamepad support is in scope for v0.4.0 — user call. |
+| Per-pad tuning / hot-plug | PD incremental hot-plug + remembered seats; deadzone/scale/rumble per pad | Medium; real-controller users. |
+| Window polish | HiDpi, center window, maximize/default-fullscreen | Small P-class wins. |
+| Unlocked FPS | Plan parked; premise changed (D248/D250 fixed — default is now a true 60) | Phase 0 re-measurement before any work; motion interpolation stays out. |
+
+### Bucket D — Infra / housekeeping
+
+- **D244** self-hosted CI runner link failure — fix before the release tag so
+ CI can build win+linux bundles (or confirm GitHub runners suffice).
+- Post-release v0.3.0 cleanup (HANDOFF): remove GE_OBJT probes
+ (`chraction.c` chrSet/UnsetStageFlags, `propobj.c` GAS line); M-183/M-185
+ clamp removal pending full-cutscene re-verification; D309 probe decision
+ (keep for v0.4.0 debugging or remove).
+- Working-tree archaeology: ~~uncommitted D236 second-pass state~~ **RESOLVED
+ before this plan was written — `532092c7`/`6807555c` (Sep 15) already landed
+ the D266 IA4/texedge decode + D271 portal-culling fixes on main and dropped
+ the D267–D280 probes.** Consequence: Phase 1 item 9 (D249) can proceed with
+ the D271 fix in-tree; the D236 tree-wall work continues from D276's oml/fmt=3
+ lead on top of the landed decode fixes.
+
+## Session additions (2026-09-23/24, local)
+
+- **D336** Frigate "everything breaks but the gun" (uninitialised head-bob alias fields, D100 follow-up): FIXED, user-confirmed.
+- **D337/D338** aim modes folded + Aim range option: user-approved ("Seems good").
+- **D339** #92 low-end perf: `GE_PERFSTAT` probe; CPU budget measured (~2 ms/frame), GPU the likely bottleneck; needs target hardware.
+- **#105** (Enhanced/Remaster water reflection) refers to a `feature/graphics-final-stack` branch that isn't on origin or local; not v0.4.0 scope. Its "D329" label collides with ours, so watch for that if that work merges.
+
+## Phases
+
+### Phase 0 — Hygiene (½–1 session; do before any fidelity work)
+1. Branch `release/v0.4.0` off `main`.
+2. ~~Resolve the uncommitted D236/D271 tree state~~ already landed (`532092c7`).
+3. Remove GE_OBJT probes + playtest-folder `.bat`; decide D309 probe fate;
+ getenv-cache sweep.
+4. Fix/confirm CI runner (D244) so release tooling works end-to-end.
+5. Carry-over verifications from the v0.3.0 handoff that were owed:
+ Runway AllUnlocked toggle re-test, menu-click spot check (D316/D317 both
+ closed — just confirm no regression on the new tree).
+
+### Release gate (independent of the phases below)
+
+**D322 (long-session audio degradation, issue #87)** is a campaign-breaking
+defect in shipped v0.3.0 and does not wait for the phase ladder: the
+`GE_D322` probe is already built and smoke-tested — request the campaign
+capture (`GE_D322=1 GE_D204=1`) from the reporter now, and run its decision
+rules as soon as the log lands. If the capture implicates a port-side leak,
+the fix slots into Phase 2; if it's a rule-2 behavioral difference, it gets
+its own sign-off item.
+
+### Phase 1 — Fidelity wave (the release's core)
+Ordered by (crash risk → visibility → effort); each item ends with its own
+verification (headless where possible, human eyeball/ear as merge gate):
+
+1. **D320 live verification + watchdog generalization** — the only open
+ item that can hard-lock progression on 8 levels; probes already exist.
+2. **D289 re-test** (crash class; one human pass, then close or chase).
+3. **Batch "one-off geometry/texture anomalies": D288 + D294 + D291** —
+ all are single-location, bounded, visually documented; likely share a
+ fast3d import/culling root cause. One investigation thread.
+4. ~~**D252 rainbow particles second cause**~~ — **retired (M-188):** the
+ user's symptom closed as a duplicate of the D219 fix (live retest clean);
+ decode + dimensions proven correct (M-141/M-156). Re-open only on a
+ fireball-rainbow re-report.
+5. **D245 water seam** — candidate fix exists; A/B against N64 reference,
+ then flip default (A1-class fidelity call).
+6. **D303 + D306/D308 z-fighting family** — small visual parity items; batch
+ per commit cadence. (~~D307 camera flip~~ investigated 2026-09-21: no
+ port-side bug found, all inputs verified N64-faithful — blocked on user
+ repro detail; see Bucket A row.) D303 + D306/D308 both investigated
+ 2026-07-19: no port-side bug found in either (pipeline/data/precision all
+ verified N64-faithful); capture probes `GE_D303` and `GE_ZF` are in-tree —
+ both now blocked on user-side captures (firing repro / N64 reference).
+ Nothing further to do agent-side until those land.
+7. ~~**D236 tree wall**~~ — **done (M-198, pass 26):** `TEXTURETYPE_DETAIL`
+ tile-binding fix in fast3d (`Video.DetailBaseTile`); live-verified,
+ probes stripped. Only the user play-test sign-off remains.
+8. ~~**D235 texture reload leak**~~ — **done (M-193):** port-side
+ `TextureCacheMap` now cleared on every stage transition via
+ `videoResetTextureCache()` in boss.c; 21/21 sweep pixel-identical; live
+ death/restart confirm is a playtest item.
+9. **D249 culling** — *after* the D271 overlap check; live screenshot+location
+ first, BFS instrumentation second.
+10. **Audio parity: D322 long-session degradation first** (issue #87 — the
+ only audio report that breaks a full campaign; `GE_D322` probe already
+ shipped, get the campaign capture, then apply the decision rules in the
+ finding), **then D240/D241** (fold into D207 family) + verify D230's bass
+ fix by ear on the elevator track.
+11. **Front-end fidelity batch: D290 watch pad graphic, D182(2)/
+ D176(b) pitch-shear defect, D176(a) sky Defect 2** (verify the patch
+ visually first — it's been unverified for weeks). (~~D75 logos~~ fixed
+ M-197; ~~D195 Control alpha~~ FIXED per findings; ~~D197 face wrap~~
+ CLOSED at v0.2.0-pre playtest — both were stale in this list.)
+12. **D174/D172 blood verification** (regen sidecars, BUNKER1 firefight).
+13. **D258 PAL/JP asset conversion repair** — apply the verified repair path;
+ re-run the region sweep. Decide scope: ship JP/PAL-accurate or document
+ NTSC-only for v0.4.0 (user call).
+14. **D311 upstream anim-poison source** — close out the last open thread of
+ the freeze class so the known-issue bullet can be dropped.
+
+Exit criteria: every README "Known issues" bullet either fixed or explicitly
+re-disclosed with a v0.5.0 pointer; no new fidelity regressions (21-level
+sweep after each landing).
+
+### Phase 2 — Stability wave
+1. **D287/D286 freeze investigation** — wire Windows crash dumps (or an
+ in-process fault log) so the next occurrence is diagnosable; pattern-match
+ against `gfxFrameMsgQ` lead and the D309 teardown-race class.
+2. **D175 stutter** — idle-box repro under `tools_pc/debug.ps1`; likely a
+ known transient-hang class, confirm and close or chase.
+3. **D285 residual** — re-audit audio thread-locking vs D305's guard.
+4. **D281 AllUnlocked no-save path** — make it robust (root-cause the
+ sndHandleEvent/aim interaction) or gate the toggle until a save exists.
+ User call on which.
+5. Long-idle soak test (both platforms) as the stability acceptance bar.
+
+### Phase 3 — Improvements / QoL (only after Phases 1–2 are green)
+Order by user-visible value (2026-09-27 refresh — several rows since DONE, marked):
+1. ~~**Native widescreen**~~ — **DONE 2026-09-23** (D334/D335; playtest sign-off owed).
+2. ~~**F10 overlay wave: D237 + D314/D315**~~ — D237 **DONE 2026-09-23**; D314/D315 still open (user-deprioritized; lands with further overlay work).
+3. **Input feel: D238 sensitivity rework, D239 delete always-grab, window
+ HiDpi/center; rumble + per-pad tuning if gamepad is in scope (user call).**
+4. ~~**D226 HUD scale**~~ — **DONE 2026-09-24**.
+5. Unlocked FPS: re-measure Phase 0 against the post-D248/D250 baseline and
+ decide whether v0.4.0 or v0.5.0.
+6. **Modern options wave — substantially implemented, NOT release-complete**
+ (D377–D389). Five functional roots; Input → Bindings → Movement/Actions
+ and Gameplay → HUD work in both menus. One effective PC layout with
+ dedicated E/R/Ctrl; two bind slots including Mouse 1–5; crosshair
+ visibility, Original/White/colour, style and size. Redundant Low-end
+ preset retired, but FPS cap/MSAA remain. M4 GE+ toggles deferred to
+ v0.5.0, M6 PNG screenshot removed from this release. **M7 blocked by
+ persisted fake save progress (D387); do not test with a live save**.
+ Physical acceptance, M7 release decision, 21-level visual gate and
+ documentation/commit reconciliation are still open. Earlier "one
+ opt-in section/default-off/preservation proof" estimates were superseded;
+ the original research remains in `MODERN-OPTIONS-PLAN.md`.
+
+### Phase 4 — Release ritual
+1. `./build-pc.sh ntsc-final` clean; `/linkcheck`; full 21-level
+ `tools_pc/verify.sh sweep --json`.
+2. Full campaign playtest sign-off (user), incl. the D320-probe levels and a
+ water level + Surface + Silo (the Phase 1 visual fixes).
+3. Refresh README Known Issues, `.github/release-notes.md`, findings index;
+ regenerate `agentic-development.md` stats from git (still owed since
+ v0.3.0 prep).
+4. Push `release/v0.4.0` → tag `v0.4.0` → CI draft bundles → validate both
+ artifacts → fast-forward main.
+
+## Explicitly out of scope for v0.4.0
+- macOS/ARM, LAN multiplayer.
+- Motion interpolation / sim-rate changes (UNLOCKED-FPS non-goals).
+- D117 frame determinism (research-parked).
+- **Mod support and HD texture packs — both post-1.0, both researched; see below.**
+
+### Post-1.0, researched and tracked (do not start in v0.4.0)
+
+Both were investigated 2026-09-22 against the Perfect Dark PC port fork
+`perfect-dark-dabs-mod-dabs-mod` (**MIT**, Ryan Dwyer — same license as this
+tree; `AGENTS.md` already names the PD port a standing reference, so code may be
+adapted with attribution). Fuller sequencing notes live in the local
+`docs/dev/notes/MODERN-PORT-FEATURES-BACKLOG.md` §5/§6, which is
+gitignored — the
+durable record is `docs/dev/findings.md` and the summary here. The
+drop-in mod support design itself is expanded in the local
+`docs/dev/notes/MOD-LOADER-PLAN.md` (2026-09-27: C1–C4 components, v1
+scope, acceptance bar).
+
+**Mod support (ROM patches) — viable, medium–large.** Findings **D325–D328**.
+- Community mods *repack* the ROM, so vanilla offsets are useless: Goldfinger 64
+ (12→24 MB, 797/803 slices differ) and even the content-neutral Random Eye
+ randomizer (identical ROM size, yet 803/803 slices differ — content shifted
+ +7392). Both die in `tools_pc/d43_emit.py:273` on a vanilla-offset zlib slice.
+- **The fix is not a loader rewrite.** PD's `port/src/modimport.c` states our own
+ constraint verbatim and instead diffs the patched ROM against stock, extracting
+ what differs into a **mod directory** its loader already reads. The game keeps
+ running its stock ROM at fixed compiled offsets.
+- GE's file table is in the ROM after all (D327 said otherwise; corrected by
+ D328): 1172-compressed data segment at ROM `0x21990` — one raw inflate, which
+ `port/src/rzdecomp.c` already wraps — table at `0x252c4`, 727 records of 12
+ bytes, pointers `0x80020d90`-based, size by differencing adjacent addresses,
+ exactly `obInit` (`src/game/ob.c:126`). Verified: **681 exact offset+size
+ matches vs `filelist.u.csv`, 0 mismatches**; discovery needs **no hardcoded
+ offsets** and reads Random Eye's own table to **704/726 files byte-identical**.
+- Remaining work: extraction in C (proven), patch decoding (PD's `rompatch.c` is
+ an MIT VCDIFF/BPS/IPS decoder — **removes the GPLv2 xdelta3 constraint**),
+ converters taking the extracted table, and the real effort: a
+ **mod-directory override layer** — `port/src/romdata.c` reads the ROM only and
+ has no analogue of PD's `romfile.moddir` file slots.
+- **Hard limit:** mods that *add levels* need new stage-table entries in
+ `src/game` — rule 2, not the ABI/layout exception. Either a sign-off or scope
+ down to the existing 20 slots. ASM/code mods stay impossible (ROM code is never
+ executed here).
+
+**HD texture packs — medium, well-scaffolded. Answers issue #100** ("High
+Resolution Textures", open, `enhancement` — a user asking whether HD textures can
+be used with the port). Architecture studied from PD's
+`port/include/texpack.h` + `port/fast3d/gfx_pc.cpp`; no finding written yet, so
+this entry and backlog §6 are the record.
+- **Identity is the whole problem, not decoding.** The renderer only ever sees
+ the pointer `gDPSetTextureImage` was handed, so PD keeps an address →
+ texture-number registry populated where `texLoad()` runs and invalidates it
+ when the pool drops (a stale entry names the *wrong* texture). Then three-tier
+ matching: texture number → texel checksum (for textures embedded in model
+ files) → per-record. Packs are `textures/.png`; decode happens on a
+ worker so loading does not hitch; the hook is inside `import_texture`, gated so
+ it costs one lookup when off.
+- Most scaffolding exists here: our `gfx_pc.cpp` is the same lineage
+ (`gfx_texture_cache`, `:92,579`), `g_Textures[].texturenum` exists
+ (`src/game/chr.c:3894`), and **`GE_TEXDUMP` already dumps textures**
+ (`port/fast3d/gfx_pc.cpp:1205`) — it just names them by sequence
+ (`r000_f3_s1_64x64.bin`) rather than texture number, so dumps cannot round-trip.
+ Renaming that is the single change turning the probe into a pack dumper. A PNG
+ decoder is the one new dependency, and zlib is already required.
+- Step 1 (the identity registry) is the only novel design decision, and it
+ records identity where textures load — a `src/game` touch needing `#ifdef PORT`
+ plus a §F note. Settle that at design time, not mid-implementation.
+
+## Session log (agent-driven, 2026-09-20 evening)
+
+**Phase 0 — done except user-side items:**
+- `release/v0.4.0` cut off `main` (which already carried the D266/D271 fixes,
+ `532092c7` — the "uncommitted tree state" item was stale; plan updated).
+- D322 probe work committed (`7bb43260`); GE_OBJT probes removed (D321 closed,
+ M-192 item 7, `56ac2ab8`); D309 probe **kept** (inert, cheap, recurrence
+ value for the D309/D310 freeze class).
+- getenv hot-path sweep (D302 follow-up): function-level scan of all GE_*
+ sites found no frame/triangle-rate stragglers in fast3d; cached four
+ per-frame/per-hit guards in `src/game/bg.c` (GE_D104 room pass, GE_D63
+ primary+secondary render logs, GE_D154 inner GDL walk) — all `#ifdef PORT`,
+ mechanical.
+- D244 (self-hosted CI runner): needs `pacman -Syu` on the box itself —
+ **user-side action**, not locally fixable; fallback (`#ifdef PORT` thin
+ wrapper for the `#pragma weak` alias) only if the toolchain update fails.
+- 21-level sweep gate: running on the new tree (see `scratchpad/sweeps/`).
+- Carry-over D316/D317 + Runway AllUnlocked re-tests are interactive → folded
+ into the big playtest brief below.
+
+**Phase 1 — in flight:**
+- Item 1 (D320): generic pin-signature detector added to `d318TimelineTick`
+ (`GE_D318T=1` now covers any chr: ACT_ATTACK + live-fire attacktype +
+ byte-stable animframe for 10 s → one `D320T: PIN?` line per chr, repeating
+ every 10 s while pinned). Detection only — the recovery watchdog stays
+ hardcoded to ai_22 per D320's own instruction until a second list freezes.
+ Live verification of Facility ai_19 / Control ai_9 / Depot ai_12 beats =
+ playtest work (see brief).
+- Item 3 (D288/D294/D291 batch): D288 probe refined per M-155 follow-up
+ (any_behind_camera==0 only — the w<0 bbox math was a false lead; oml word
+ now logged for asset ID). D294: `GE_D104` room-visibility trace confirmed
+ sufficient (curRoom/roomsDrawn/budget + per-room load state every 10 s);
+ needs live Statue repro at FovScale≈130 + max draw distance. D291: still
+ needs live Dam truck details — lowest value of the three.
+- Item 13 (D258 PAL/JP): **deferred pending user scope call** (ship
+ multi-region or document NTSC-only for v0.4.0) — no PAL/JP ROMs available
+ here to verify a blind CSV rename against.
+- Item 4 (D235 wrong textures after death/restart): **mechanism found + fix
+ applied (M-193).** M-108's refutation of the game-side CI-cache theory
+ stands, but the *port* has its own `TextureCacheMap` (source-address keyed,
+ no content check) that was never invalidated on stage transitions —
+ `videoResetTextureCache()` existed with zero callers. Now wired into the
+ per-stage reset block in boss.c (`#ifdef PORT`, after `memaReset`).
+ Pixel-identical re-uploads; golden sweep running to confirm. Live
+ death/restart confirmation + a `GE_D235` >0 capture are playtest items.
+- D311 upstream poison source (M-194): static pass done — all
+ `sub_GAME_7F06FE90` callers pass arg2=16.0f (denominator safe); the
+ old→new lerp only circulates values; `bheadAdjustAnimation` is the one
+ path where a live player-velocity corruption reaches `model->speed`. Both
+ speed-write choke points (`modelSetAnimSpeed`, `modelSetAnimation2`) now
+ log `D311: … ANOMALY` on |v|>1e4/NaN — next Caverns occurrence is a
+ one-line diagnosis. No env var (D156-guard pattern).
+- Item 4 (D252 rainbow particles): **effectively closed — no agent work
+ left.** Re-read the finding this session: M-141 refuted the width/height
+ suspect (line=4 → correct 16×14, cross-confirmed by the GE_TEXDUMP
+ is_fire_shape filter), M-156 proved the ROM bytes decode to a clean
+ white-hot→ember progression, and **M-188 closed the user's actual symptom
+ (lingering residue sprites) as a duplicate of the D219 fix** — user
+ retested live: "seems fine now." Only a *fireball* rainbow re-report would
+ re-open it. Plan item 4 retired.
+- Item 10 (D207 alarm klaxon): **blocked on user repro info, not agent-
+ workable.** M-77b implemented the designed last-resort LOOPED-preemption
+ pass + `[D207-DROP]`/`[D207-YIELD]` counters, BUNKER1 forced-alarm soak
+ showed zero drops / zero yields (pass 1 always finds a victim), and the
+ change was reverted. The finding now asks for: which level, which
+ weapon/situation, and a `GE_AUDIOTRACE=1` capture (or clip) of the failing
+ alarm — then decide between heavy-level 8/8 starvation (re-apply the
+ reverted pass), an alarm-stop-path leak, or pure perception (klaxon mix
+ volume vs N64). Added to the playtest brief as a low-effort ask.
+- Full 21-level golden sweep: **21/21 PASS** on the final tree (`bb508e5c`),
+ confirming D235 fix + D311 probe + all Phase 0 changes are pixel-identical.
+- **D75 (Nintendo logo) investigation — M-195, no root cause yet but two
+ suspects eliminated.** (a) Fresh `GE_D63` run: all 23 logo Primary GDLs
+ emitted every frame, zero unknown-opcode warnings → emission + DL
+ consumption fine; PC GDLs byte-faithful to N64 source. (b) New `GE_D75V`
+ per-batch vertex probe: transformed coords match the wordmark geometry
+ exactly (MP translation matches the lookat) → basematrix / f32→s32 /
+ matrix-list suspects eliminated. **Leading hypothesis:** every `G_VTX`
+ batch encodes v0=0, so multi-batch nodes overwrite shared vertex slots
+ 0..n−1 (only the last batch survives; higher-indexed triangles read stale
+ slots) — consistent with the single tiny orbiting fragment observed. Next:
+ ROM-dump one node's GDL stream and cross-reference `G_TRI4` index ranges
+ against per-batch counts; fix would be port-layer fast3d slot management.
+ Probe code (`gfx_pc.cpp`, `videoGetFrameCount()` in video.c/h) uncommitted
+ at end of session — commit with the doc updates.
+- **D307 (CCTV facing backwards) investigated — no port-side bug found.**
+ Env-gated `GE_D307=1` probe in `setupCctv` dumps every input + derived
+ orientation per record. Bunker1 (`UsetupsevbunkerZ`) decode vs live: model
+ switch offset = `PcctvZ` (NOT `GcameraZ`, that's the gun) sw[0]
+ bit-for-bit; boundpads match the pccg sidecar once the legitimate load-time
+ room-scale (`pad->pos *= get_room_data_float2()`, ≈1.8542 here) is applied;
+ record config words (incl. `unkCC`) match; all matrix/atan2 helpers are
+ byte-identical `src/game` code. Conclusion: PC orientation values == what
+ the N64 computes; if the symptom is real vs N64, the remaining suspect is
+ fast3d's handling of the non-orthonormal lens matrix (`unk84` row 0 scaled
+ 0.1×). Blocked on user repro detail (which camera/level + screenshot).
+ Findings entry updated; probe kept in-tree (D60/D88 pattern).
+- **D303 (extra long muzzle flash) investigated — no port-side bug found yet,**
+ blocked on live capture. Mapped the full flash pipeline (`gunUpdateAndFire`
+ builds `rwmtx[1]` = yaw×scale×flashext and `rwmtx[2]` from `Switches[2]`; the
+ flare arms are `dorottex` DLPRIMARY nodes) — all byte-identical game code,
+ matrix helpers verified index-for-index. Ruled out: weapon stats (M16's
+ flashext=6.0 is the legitimate max, sniperrifle matches; the ext≥3 ⊆
+ CAN_DUAL_WIELD overlap is design coincidence) and model conversion (Gm16Z
+ DLPRIMARY records + vertex blocks byte-faithful per d43 round-trip). Added a
+ TEMP `GE_D303=1` probe in `gfx_pc.cpp` (cached-getenv; logs very-tall-thin
+ emitted triangles with blend/oml/comb/prim/env/w) — 75 s unattended runs on
+ Bunker1/Dam/Depot show only static scene geometry, so the artifact needs a
+ live firing repro. Next: user fires the M16 under `GE_D303=1` and shares the
+ log lines; the spurious quad's RDP state will identify its source.
+- **D306/D308 (z-fighting family) investigated — real fighting confirmed, cause
+ framed.** Verified GE's RSP writes post-transform x/y/z/w as full 32-bit 16.16
+ (not s16 8.8), so the port's float pipeline + `GL_DEPTH24_STENCIL8` matches N64
+ depth precision — no port-side precision deficit exists. Built a TEMP
+ `GE_ZF=1` flicker probe in `gfx_pc.cpp` (96×54 screen-cell grid, per-cell depth
+ history, requires non-monotonic a-b-a oscillation within GE_ZFTOL recurring ≥5×
+ at the same cell; `GE_ZFBOX` region filter) — earlier center-pair and simple
+ flicker variants saturated on tessellation noise. Dam (`-level_33`) unattended:
+ sustained hits in the lower-left truck region with per-cell oscillation counts to
+ ~970 → D306's "transparent areas" are real depth-test alternation. Prime suspect
+ is level-authored coincident geometry (fights on N64 too); confirming port-only
+ needs an N64 reference of the same spot, and a mitigation (e.g. float depth)
+ would diverge from N64 — parked pending that.
+- **Plan/doc refresh:** D75 (M-197) and D236 (M-198) marked fixed throughout
+ (Bucket A rows, Phase 1 items 6/7/11, playtest brief); findings index
+ regenerated; README Known Issues to match.
+
+### Big playtest brief (for the user, when the tree is final)
+
+Build: `./build-pc.sh ntsc-final`. Set **`GE_D322=1 GE_D204=1`** for the whole
+campaign (D322 pool telemetry — the release gate). For the three D320 beats,
+add **`GE_D318T=1`** on: Facility (the captive-scientists monologue/execution
+beat), Control (the ai_9 guard beat), Depot (the execution beat) — watch for
+`D320T: PIN?` lines; a frozen run keeps printing every 10 s.
+
+Checklist beyond the normal play:
+1. **D322**: full campaign, share `ge007.log` after (decision rules in the
+ finding).
+2. **D316/D317 regression confirm**: click menu rows at a few window sizes,
+ esp. low rows (crop overscan bars) with SafeAreaCrop on/off; toggle
+ Game.AllUnlocked back and forth in-game.
+3. **D289**: Dam computer room — interact with the terminals (was a one-off
+ crash, likely D255-class, already fixed — confirm it stays gone).
+4. **D294**: Statue, set FovScale ≈130 + max DrawDistance/LodDistance, walk
+ past the CCCP monument at ground level; if the black-ground window hits,
+ note the time (a `GE_D104=1` run there is the follow-up capture).
+5. **D291**: Dam — watch for texture glitches as the truck drives past;
+ screenshot + which surface.
+6. **D175**: door-open on Runway/Surface — note any stutter/hang.
+7. **D287**: leave a level idle+unfocused 15–20 min, click back (the
+ non-self-recovering freeze; one prior occurrence).
+8. **D320 beats** as above with `GE_D318T=1`.
+9. Audio by-ear: elevator track bass (D230 verify), PP7/AK47 under heavy fire
+ near looping sounds (D240/D241).
+10. **D235**: on any level, die and restart (or reload a save) in the same
+ session, then look around — textures should be correct (no swapped/wrong
+ stage textures). Run once with `GE_D235=1` and share the log if anything
+ looks off (a `>0` count means a stale-cache hit slipped through).
+11. **D288 (Silo)**: play Silo normally; if you see the wrong-color target /
+ geometry anomaly, set `GE_D288=1 GE_PCDUMP=1` for a 30–60 s run around it
+ and share log + captures (the probe logs the offending oml word for asset
+ ID).
+12. **D311 (Caverns)**: play Caverns ~2 min of normal combat; if the anim-
+ speed freeze/poison recurs, the log will now carry a one-line
+ `D311: … ANOMALY` naming the corrupting call site — share it.
+13. **D207 (alarm klaxon)**: if gunfire goes quiet during an alarm, note the
+ level + weapon + situation; ideally re-run that spot with
+ `GE_AUDIOTRACE=1` and share the log (or a short clip).
+14. **D75 (front-end logos)**: confirm the Nintendo/Rareware logos + legal
+ page still render correctly (fixed M-197; user-confirmed once already).
+15. **D236 (Surface trees)**: Surface 1/2 — confirm the treeline renders as
+ discrete tree sprites, not a wall (fixed M-198; probes stripped).
+16. **D307 (security cameras)**: if any in-level CCTV camera looks like it's
+ facing backwards, note which level + which camera (screenshot) — the
+ orientation math is verified N64-faithful, so a concrete repro is what's
+ needed to keep chasing it.
+17. **D303 (extra long muzzle flash)**: run with `GE_D303=1` in your terminal,
+ fire the M16 (and any other gun that shows the extra upward flash) until
+ you see it, then share the `D303:` log lines from around that moment —
+ each line carries the RDP state of a candidate quad and will identify the
+ spurious draw call's source.
+18. **AllUnlocked preservation (M7) BLOCKED — D387.** Do not run this
+ against a real save: even changing a profile setting while ON persists
+ synthetic cheats and 0x3FF completion times; OFF requires a restart and
+ does not undo already-written fakes. First define safe earned-progress
+ semantics, fix in `port/`, and verify on private copies with settings
+ writes, mission completion, OFF restart and multi-slot CRC checks.
+ M4 modern toggles are parked to v0.5.0.
+
+## Conventions reminder
+- One findings entry per investigation; the index tail moves constantly —
+ it ended at **D370** as of 2026-09-27 (the F10 drag entry), so new
+ labels start **D371+** (the old "after D322" note is long stale; always
+ confirm the tail of `findings-index.csv` before filing).
+- Fidelity fixes: port-layer or documented A1/ABI exception only; genuine
+ behavioral differences need the rule-2 sign-off (`docs/dev-process.md`).
+- Every subagent reads `docs/porting-notes.md` first and appends to it.
diff --git a/docs/dev/WATCH-SETTINGS-PLAN.md b/docs/dev/WATCH-SETTINGS-PLAN.md
new file mode 100644
index 00000000..d0993580
--- /dev/null
+++ b/docs/dev/WATCH-SETTINGS-PLAN.md
@@ -0,0 +1,63 @@
+# Watch-backed settings in PC Options and F10 — proposal for review
+
+Status: **watch-backed subset implemented (D350), robustness fixes landed (D352), Turok-style regrouping done (D353), front-end F10 routing + transparent default Bond-file selection landed (D354: non-stage contexts use the chooser path, default = explicit pick > `selected_folder_num` > first valid file, with a one-shot `fileBuildWriteNewSave(FOLDER1)` fallback; GE_WSPROBE/GE_WSPROBE_FRONT headless probes), the in-stage BGM resync landed (D355: the Music commit also drives the track-1 BGM player, matching the game's own MP-path resync; by-ear confirmed), and the settings regroup landed (D356: functional sections INPUT/GAMEPLAY/GRAPHICS/AUDIO/VIDEO, PD-style "(File N)" scoping annotations, N64-watch rows off the menu surface, per-section reset-to-defaults = Gate E; GE_WSPROBE_RESET headless probes front + in-stage).** Wider M3 remains outstanding: master volume, key rebinding. PC-only per-file persistence and explicit Bond-file chooser approved; details below. See `docs/dev/findings.md` D350–D356 for verification. This is the watch-integration part of M3, not a replacement for the native watch menu. The older tracked `docs/dev/OPTIONS-MENU-PLAN.md` and local `docs/dev/notes/OPTIONS-MENU-PLAN.md` describe the wider M3 scope (master volume, window mode/display, resets).
+
+## Goal and ownership
+
+Show GE's existing watch settings in both PC settings surfaces (`MENU_PC_OPTIONS` and F10) without maintaining a second copy of each value. Both surfaces already consume one row table in `port/src/optionsoverlay.c`; the watch remains an independent UI editing the **same underlying GE value**. Changes made in either direction must be visible on the next read. Preserve the N64 watch, its behavior, and the N64 build.
+
+**Approved ownership:** per-file GE settings belong in the Bond save; machine-wide PC settings belong in `ge007.ini`. GE's original watch edits are *not* persisted on normal play (D349 below): **watch-made changes remain session-only**, while **PC-UI-made changes persist to the selected Bond file**. This is a deliberate port-side QoL divergence, not existing GE behavior. Do not register watch-backed settings as ini variables, copy watch values into config globals, or overwrite a newly loaded Bond file with stale ini values. A proposed PC master volume is separate: it multiplies the final output and does not replace the watch's Music/FX volumes.
+
+## Inventory and proposed rows
+
+Source of truth: `src/game/options.c` (`game_options_entries`, `cur_player_get/set_*`, `get_mTrack2Vol`/`set_mTrack2Vol`, `sub_GAME_7F0A91A0`), `src/game/file2.c:1246-1350` (per-folder serialization), and `port/src/input.c:~939` (forced control style). Audit exact getter/setter availability and initialization before implementing each row.
+
+| Watch control | PC presentation | Notes |
+|---|---|---|
+| Music / FX volume | Audio section, separate Music and FX sliders | GE range 0..32767, stored in the Bond save at 8-bit precision; use actual GE APIs, not an SDL channel gain in place of either. Master volume stays a distinct global row. |
+| Look up/down (reverse/upright), auto-aim, aim control (hold/toggle), sight on screen, look ahead, ammo on screen | Bond file / gameplay subsection, with GE-specific labels | These are the watch's `game_options_entries` and save bits. Watch look-up/down is distinct from PC MouseInvertY and PadLookInvertY: the independent inversions compose (XOR). Label/help text must explain the stacking; do not combine their stored values. |
+| Screen (Full/Wide/Cinema), Ratio (Normal/16:9) | Separate clearly marked GE presentation controls, if useful | Not PC window resolution/mode or `Video.NativeWidescreen`. Verify interactions with native widescreen/crop before exposing. |
+| Controller style / controller diagram | Defer pending input design | The diagram is not a setting. `inputComputePad(0)` currently reasserts Honey or Solitaire from `Input.NaturalPitch` every poll, including after save load or watch changes; `bondview.c` and multiplayer load also force Honey in some contexts. A naive style row would be immediately undone. Decide whether the watch selector should be informational, limited to supported PC mappings, or integrated with the port mapping; no row until this is reconciled. Dual-controller styles need their own decision. |
+
+This inventory is not permission to expose every field automatically. Especially do not silently merge identically named PC and GE toggles that have different input paths or persistence.
+
+### Persistence ground truth (D349)
+
+`fileSaveSettingsForFolder` (`src/game/file2.c:1246`) has one caller, `fileClearSavefileForFolder` (`:1379`), reached from the erase path. Normal watch edits change live values but do **not** write the file's `options`/`music_vol`/`sfx_vol` fields or EEPROM. `init_watch_at_start_of_stage` (`src/game/options.c:415`) calls `fileLoadSaveSettingsForSelectedFolder`, so the next stage reloads those fields and discards unsaved watch edits. This is a GE/N64 quirk, not a PC regression; Gate A should re-audit indirect writes before implementation and record any correction to D349.
+
+Thus a front-end row that edits only live watch state will be silently undone before gameplay. The maintainer approved **port-side per-file persistence of PC-UI changes**, without changing the N64 save format or the N64 watch's session-only behavior. Do not auto-persist edits made directly in the watch.
+
+## UI / data design
+
+1. Add a **watch-backed row type** to the existing shared row model, not another settings menu or fake config registration. Its read/write/availability callbacks operate on GE's real option API; existing config-backed rows continue to work unchanged. Keep enum labels, range/step and value formatting in the shared row table so F10 and the GE-styled screen cannot diverge.
+2. Use GE's existing getters/setters for **all eight proposed entries** (`src/game/options.c:526-598`) and both volume controls (`get_mTrack2Vol`/`set_mTrack2Vol`, `call_sndGetSfxSlotFirstNaturalVolume`/`sub_GAME_7F0A91A0`). For FX use the same game volume API as the watch, not an SDL channel gain. No `src/game` bridge or Rule-2 game-code edit is expected for these rows. Preserve the watch's side effects; persistence, if approved, is a separate port-side operation.
+3. **Threading is a design gate:** F10 input runs on the scheduler thread (`inputComputePad(0)`); the GE-styled screen and watch run on the game thread. Do not directly mutate game/watch state from the F10 thread merely because a getter or pointer is accessible. Route F10 writes as bounded requests to a safe game-thread point, and publish a safe, current snapshot for F10 rendering/reads, or establish an equivalently reviewed synchronization scheme. Cancel or retarget queued writes when the active folder/stage changes; never apply a write to the next folder by accident. Check audio/music initialization before **writing** Music/FX in the front end: the music getter reads a global, but the setters may touch not-yet-ready playback state.
+4. Make scope visible to the player: use a dedicated **Bond file** section/page in `MENU_PC_OPTIONS` for GE watch options, with an **explicit Bond-file chooser**; never trust `selected_folder_num` on that screen (frontoptions init even assigns it a fallback). F10 in-stage targets the active file with no chooser. **D354 supersedes the "do not silently choose folder 1" stance:** all non-stage contexts (options screen *and* F10 on file select) lazily default to *explicit pick > `selected_folder_num` > first valid folder* so the section works transparently; an explicit "Edit file" cycle (still including "none") always overrides, and a game-thread `fileBuildWriteNewSave(FOLDER1)` one-shot builds a blank file 1 when no valid save exists. Switching chooser folders must not change the game's active file. D350 reads saved fields directly without calling `fileLoadSettingsForFolder` (which would audibly change front-end music). Unvalidated/absent slots are skipped. Test new/blank files in a normal boot as well as the D299 `GE_STARTMENU` shortcut.
+5. **Approved port-side persistence:** resolve the actual save-slot pointer with `fileGetSaveForFoldernum(folder)`; `saves[folder]` is **not** generally the folder's physical slot (`fileWriteSave` requires a pointer into `saves[SAVESLOT1..SAVESLOTRAMROM)`). For F10, use a validated in-place path: serialize the live settings into a **temporary copy** with `fileSaveSettingsForFolder(©)`, then copy only the PC-UI-edited field/bit(s) into the real save-slot pointer and call `fileWriteSave(save)` with existing CRC/EEPROM handling. In the front end, where no GE player is active, merge only the selected field directly into the chosen saved record instead of loading it into live watch state. Retain `OPTION_CONTROLTYPE` from the real save: the port reasserts Honey/Solitaire every poll and must not overwrite stored controller style. This field-scoped merge also prevents a watch-only edit to a *different* field from piggybacking onto the next PC-UI save, preserving the approved session-only asymmetry. Validate the path against absent/free slots and ensure the live state belongs to **only** the chosen folder (do not load or copy values from a different folder). Never call `fileClearSavefileForFolder` as a generic settings save. Toggle/enum changes persist on value change; slider drags persist on release, not each detent. F10 requests must reach the correct game-thread/file target before write. The GE watch's own edits remain session-only. Test stage load, file switch, copy/erase, and restart for cross-file contamination; no N64 game-code changes. **Known erase edge:** GE's `fileClearSavefileForFolder` serializes *current* live settings into the reset file; after editing B, erasing A could copy B's live options into wiped A (also possible on N64 after watch edits). Document/test, do not change game code.
+6. For future per-section reset-to-defaults: ini rows reset from registered PC defaults; watch rows reset to **`BLANKSAVEDATA`/`DEFAULT_OPTIONS`** (`bondconstants.h:2151`, `file2.h:23`) for the selected file, not from a global ini snapshot. New saves start with Music/FX 0xFF (full volume) and auto-aim/sight/look-ahead/ammo enabled. Separate confirmation and persistence policy before wiring a button.
+
+## Sequence / review gates
+
+- **Gate A — behavior audit + D349:** re-verify the erase-only write and stage-start reload (including indirect save paths); record the ground-truth quirk, defaults, stored control-type preservation, erase cross-file edge and exact API for each row. Trace folder-to-physical-slot mapping, FX/music setter readiness, watch-to-gameplay consumers, composed inversion and the F10/game-thread boundary. Approved policy: explicit chooser + PC-UI edits persist; watch edits remain session-only.
+- **Gate B — foundation: IMPLEMENTED in D350**, including explicit file chooser, CRC guard, stage/file identity and thread-safe F10 command/snapshot path. D352 (review of `1303c0eb`) fixed the remaining robustness gaps: pending commands now survive stage transitions (hold-not-drop), the UI-thread chooser state is spinlocked with a front-screen guard, the queue coalesces to one pending command per field, and every silent no-op path logs a warning. Manual gamepad/mouse playtest still owed.
+- **Gate C — audio: ROWS IMPLEMENTED in D350**, Music/FX save path headless-verified. Watch↔F10 live and audible checks still owed. Global master gain is a separate, not-yet-implemented M3 item.
+- **Gate D — watch gameplay: ROWS IMPLEMENTED in D350** for six GE watch booleans; auto-aim and watch look-bit per-file saves headless-verified. Each watch/in-game effect and two-file isolation still need hands-on checks. Screen/Ratio remain a separate product decision.
+- **Gate E — control style and resets:** design decisions first, then separate work. Avoid exposing a control-style row that the port immediately overwrites. Do not make a global reset wipe per-file watch settings.
+
+## Acceptance checks
+
+- Watch changes show in both PC surfaces without reopening/restarting, and PC changes show in the watch; no extra watch-key values in `ge007.ini`.
+- With two Bond files, change Music/FX and a gameplay toggle in A via PC UI; switch to B, verify B retains its own values; return to A, start a stage, quit/restart and reload, verify A's values. Repeat an edit via F10 while in-stage. Drag sliders: only one save on release, not per detent. PC-UI persistence is a *port QoL test*, not a claim about N64; watch-only edits should still revert on stage reload. Check that stored controller-style bits survive PC saves and that file copy and erase retain GE behavior (including the erase cross-file edge above).
+- F10 changes are not lost or applied to another file across a stage/menu transition. Check full-range sliders, watch's save quantization, no unexpected audio spikes, and no race/crash on quit.
+- Menu layouts: use a dedicated Bond file section/page plus Audio; `MAX_PROWS=15` is a **per-page** cap in `frontoptions.c`, with `MAX_PAGES=8`; F10 scrolls. Check the 440x330 front-end page, F10 visible rows, mouse, keyboard, and real pad navigation; ASCII-only new text for region safety. Check NTSC headless captures, widescreen, golden gate, and PAL/JP build where possible (only NTSC ROM locally).
+- Build/link checks and the normal verification ritual after implementation. Document any game-side change as its own Rule-2 request before editing it.
+
+## Decisions and remaining scope
+
+- **Approved:** PC-UI edits persist per selected Bond file; watch-only edits stay session-only. The front screen gets an explicit file chooser; F10 uses the active file. Preserve stored controller type; toggles/enums save on change, slider drags on release. No `src/game` behavior change or N64 save-format change.
+- **Still separate product decisions:** whether/how to expose GE Screen/Ratio alongside PC video settings; whether to expose control style at all given the port's forced mapping (deferred); whether Bond-file reset needs its own confirmation rather than being bundled with global PC defaults.
+- **Landed 2026-09-27 (D352/D353, port-only):** bridge robustness fixes (D352) and the Turok-style row-table regrouping (D353: six sections — DISPLAY, GRAPHICS, BOND FILE, MOUSE / AIM, CONTROLLER, GAME; the music/FX sliders sit in BOND FILE; the page title annotates the selected file; `buildPages` overflow logs).
+- **Landed 2026-09-27 (D354, port-only):** front-end F10 routing + transparent default Bond-file selection (see item 4 above); in-stage F10 verified working by the `GE_WSPROBE` probe.
+- **Landed 2026-09-27 (D355, port-only):** the Music commit now also resyncs the in-stage BGM (track 1) via `musicTrack1ApplySeqpVol`, plus a once-per-activation resync on level entry; by-ear confirmed. N64 watch path unchanged.
+- **Landed 2026-09-27 (D356, port-only):** the settings regroup (plan `docs/dev/D356-SETTINGS-REGROUP-PLAN.md`): Turok-style pages renamed to functional sections (INPUT/GAMEPLAY/GRAPHICS/AUDIO/VIDEO, all below MAX_PROWS), the redundant N64-watch rows (`Bond.Look`, `Bond.AimControl`) commented out of the menu surface (keys + watch paths stay live, D181/D216 style), the `__HdrBond`/`__BondFile` rows retired for the front-only top save-file row, per-file sections annotated `"(File N)"` via the `saveScoped` flag, and the Turok reset rows replaced by a real per-section Reset-to-defaults (edge-triggered two-step arm->confirm within 3 s; watch rows -> BLANKSAVEDATA through the D352 commit path, ini rows -> the port's C initializers; `__Resolution` a documented exclusion). GE_WSPROBE_RESET front + in-stage probes headless-verified.
+- A standalone AUDIO section (master volume + the config-side volume keys) and per-section reset-to-defaults (Gate E) are the next settings-scope items; key rebinding stays Phase 4.
diff --git a/docs/dev/agentic-development.md b/docs/dev/agentic-development.md
index 2e6caee4..6bba6d5c 100644
--- a/docs/dev/agentic-development.md
+++ b/docs/dev/agentic-development.md
@@ -11,6 +11,10 @@ work back and forth through shared written notes, under one person's
part-time direction. The goal, the setup, the timeline, and an honest read on
what did and didn't work.*
+> *Snapshot as of the v0.3.0 milestone (20 Sep 2026; findings through D321).
+> v0.4.0 (28 Sep) extended the timeline with the options/input work -- the
+> README's Background section carries the final project numbers.*
+
## Contents
- [Why this project exists](#why-this-project-exists)
diff --git a/docs/dev/findings-index.csv b/docs/dev/findings-index.csv
index 9f9ba4d1..4e1c35b0 100644
--- a/docs/dev/findings-index.csv
+++ b/docs/dev/findings-index.csv
@@ -7,36 +7,37 @@ D43-D50,RESOLVED,"model-file loading → offline sidecar ""Plan B"" (d43_emit.py
D51-D58,RESOLVED,"font pixeldata, osGetCount tick rate, model RW pools, cseq BE header, synth slots, RLE menu bg, watch raw offsets, gun-barrel DL",resolved
D59-D68,RESOLVED,"intro render: blood-RLE clobber, DMA validate, OSMesgQueue, HEADS/BODIES sentinels, romCopy width, image_entry, Globalimagetable BE→LE",resolved
D70-D74,RESOLVED,"intro-logo pixels: C-array bswap, UV path, sinf/cosf DVAL(), texture-import truncation",resolved
-D75,OPEN,front-end 3D model transforms (D77 audio split out below —,"OPEN (parked). M-32 triage (see §F ""D75 ADDENDUM""):"
+D75,PARTIAL,front-end 3D model transforms (D77 audio split out below —,PARTIAL (M-197 fixed the logo texture blobs; v0.4.0 campaign 2026-09-28:
D76 · D164,FIXED,Legal screen only draws line 1 —,"FIXED — this row and the ## D164 entry below were stale; the proposed one-line #ifdef PORT fix was actually committed 8dfc578f4 (2026-08-31), well before this correction (2026-09-18)."
D159,FIXED,"front-end wallet-Bond photo ""interlaced""/combed (RC1 / D149) —",FIXED (#ifdef PORT no-op the swap in image.c)
D161,FIXED,Depot (-level_30) ceiling = bright-blue speckle + radial rays (B2).,FIXED (port/fast3d/gfx_pc.cpp)
D165 · D166,FIXED,"Input-layer polish (M-31, port-only):",FIXED
-D180 · D181,PARTIAL,Native-PC input pass (QoL run):,"LANDED, config-gated, feel-checks owed"
-D186,PARTIAL,"Video.FpsCap throttles the sim, not just presentation (M-39 diag, M-42 clamp).","CLAMP LANDED (port/fast3d/gfx_sdl2.cpp, port/src/video.c); real fix owed"
+D180 · D181,FIXED,Native-PC input pass (QoL run):,FIXED (2026-09-26 bookkeeping:
+D186,OPEN,"Video.FpsCap throttles the sim, not just presentation (M-39 diag, M-42 clamp).","OPEN (2026-09-26, user direction:"
D187,FIXED,"HUD ammo-type/weapon icon vanished at -O2 (M-46 root-cause, M-47 fix + verify).","FIXED (src/game/gunfire.c, #ifdef AVOID_UB); PR #19"
D188,FIXED,Linux -O2 boot crash:,"FIXED (src/libultrare/libc/xprintf.c, src/sprintf.c, #ifdef PORT); PR #22"
D189,FIXED,Linux -fstack-protector aborts on the decomp's latent stack-buffer overruns (M-48) —,"FIXED (CMakeLists.txt, src/game/stan.c #ifdef PORT); PR #23"
D190,CLOSED,"Linux -level_45 SIGSEGV at load in add_ptr_to_objective (objective.c:64) (M-48, open).","CLOSED — not a port bug; level 45 is MP-only and has no solo setup file in the ROM (root-caused M-105, reproduced on Windows)."
D191,FIXED,Windows -O2 release-bundle crash:,FIXED + PLAYTEST-VERIFIED (M-79).…
D193,FIXED,NPC AI locomotion is too slow —,FIXED + user-playtest-verified (M-81) —…
-D194,PARTIAL,Mouse input needs another tuning pass —,PARTIAL — M-120 human playtest…
+D194,FIXED,Mouse input needs another tuning pass —,FIXED (2026-09-26: user reports the v0.3.0→v0.4.0 mouse changes feel solid and glitch-free; code-side validation owed).
D195,FIXED,"Transparency / alpha surfaces broken on Control (M-49, user playtest) —","FIXED (port/fast3d/gfx_pc.cpp, gfx_sp_vertex) —…"
D197,CLOSED,"Character face/head textures wrap around the head, seen on Silo (M-49, user playtest).",CLOSED — user playtest v0.2.0-pre
D198,FIXED,alAdpcmPull/_decodeChunk used K0_TO_PHYS (an unconditional & 0x1FFFFFFF mask) on 64-bit heap pointers (src/libultra/audio/load.c).,FIXED (src/libultra/audio/load.c) —
-D199,PARTIAL,Phase-3 software audio mixer:,"LANDED (port/src/mixer.c, port/include/mixer.h, include/PR/abi.h) —"
+D199,FIXED,Phase-3 software audio mixer:,FIXED (2026-09-26 bookkeeping:
D200,FIXED,-level_09 segfault: reverb's ALDelay.input/output are u32 —,FIXED — 5-line cast in reverb.c (this session)
D196,FIXED,"OS mouse cursor stays visible after closing the F10 options overlay in click-to-lock mode (M-49, user playtest).","FIXED (6e758f1d, merged to main) —…"
D192,CLOSED,Front-end mission/level-select grid pointer still can't reach the outer cells —,"CLOSED — the buggy mode 0 was removed entirely in the v0.2.0 prep pass (see D239); click-to-lock is now the only capture mode, so the unreachable-cells path no longer exists"
D221,FIXED,SELECT FILE mouse hit-box offset —,"FIXED (M-91) — explicit {min,max} coord2d pairs under #ifdef PORT; human mouse-playtest confirmed."
D222,FIXED,High Video.FovScale breaks culling —,"FIXED, awaiting visual verification (M-121)."
D223,FIXED,QoL ask: mouse-wheel weapon cycling should be directional like a normal PC shooter —,"FIXED (M-105, port/src/input.c) —…"
-D224,?,"QoL gap (M-87, from a PD-PC-port survey):","DEPRIORITIZED (M-87, user call)."
+D224,PARTIAL,"QoL gap (M-87, from a PD-PC-port survey):","LANDED via D401 (2026-09-27, re-promoted from DEPRIORITIZED)."
D225,FIXED,"Dev-tooling QoL (M-87, user ask):","FIXED (2026-09-18) — GE_DEBUG_UNLOCKALL=1 short-circuits fileGetIsCheatUnlocked() (src/game/file2.c, #ifdef PORT, cached getenv) to always return TRUE; default off, no save mutation."
-D226,OPEN,"QoL ask: player-adjustable HUD scale for the ammo counter, bottom-left pickup/item status text, and top-of-screen dialogue/subtitle text.","OPEN — root cause / call sites identified, not designed or implemented."
+D226,FIXED,"QoL ask: player-adjustable HUD scale for the ammo counter, bottom-left pickup/item status text, and top-of-screen dialogue/subtitle text.","FIXED (2026-09-24): Game.HudScale 75–150% (user-verified, capped at 150) via fast3d G_HUDSCALE_EXT rect scaling about per-element anchors; dialogue/bottom messages width-capped."
D227,FIXED,Sky renders as two independently-moving halves —,"FIXED (M-100b, PR #43) —"
D229,FIXED,"IsWater-level water renders green, pulses blue<->green (M-101; Frigate -level_26).","FIXED (port-layer) — ZLIB+CI8 water image now imports via the CI8 palette path (gfx_pc.cpp); Frigate verified live (""looks better""); sweep + Surface 2 by-eye still owed."
D230,FIXED,In-level music quality:,"FIXED (M-121, v0.2.0) —"
+D231,CLOSED,"QoL batch: mute-on-focus-loss + screenshot-hotkey productization (M-105, roadmap Tier 3, port-only).","CLOSED — PARKED/DEPRIORITIZED (user, 2026-09-25)."
D233,FIXED,All levels (intermittent):,"FIXED (port/fast3d/gfx_pc.cpp, gfx_sp_tri1) —…"
D234,FIXED,"Dam: guard towers along the dam structure invisible except the first one (M-106, user QA report).",VISUALLY VERIFIED FIXED (M-108) —…
D244,OPEN,Self-hosted CI runner (corpyvt6agent01) link failure:,"OPEN, not yet fixed. Next…"
@@ -44,9 +45,9 @@ D228,FIXED,AK47 white missing-texture patches after D217's texpool fix —,"FIXE
D204,FIXED,PC audio ran ~2 % below real time permanently (AI feedback loop's 3 ms setpoint vs PC jitter).,"FIXED (port/src/audio.c, port/src/libultra.c, port/include/audio.h) —"
D203,CLOSED,"Steam Deck (SteamOS, x86_64 Linux) v0.1.0 release bundle:",CLOSED — root cause + fix in D253.
D201,FIXED,"src/libultra/audio/bnkf.c bank/instrument/sound/wavetable relocation offsets were s32, truncating the real 64-bit base pointer.",FIXED (src/libultra/audio/bnkf.c) —
-D202,?,"Silenced PPK ""slap"" + broader real-time mixer corruption (M-52→M-67).",ROOT CAUSE ESTABLISHED (M-66); Disposition C implemented + measured (M-66b) —
+D202,FIXED,"Silenced PPK ""slap"" + broader real-time mixer corruption (M-52→M-67).","FIXED (2026-09-26, user by-ear:"
D206,FIXED,PC played every SFX one bank slot too high —,"FIXED (M-76, src/snd.c:1001, #ifdef PORT) —"
-D207,?,"Alarm klaxon starves combat SFX and doesn't recover after it stops (surfaced by D206, M-77).",MONITORING (M-77c: user reports the alarm is acceptable in play after D206; no fix shipped) —
+D207,FIXED,"Alarm klaxon starves combat SFX and doesn't recover after it stops (surfaced by D206, M-77).",MONITORING — user 2026-09-26:
D208,FIXED,Player weapon-fire sound goes permanently silent mid-firefight (all other SFX unaffected) —,FIXED + user playtest-verified (M-78d) —
D209,FIXED,Every AI character in the game is locked to a *walk* animation regardless of the speed the ailist commands —,FIXED + user-playtest-verified (M-81).
D210,FIXED,chrToPatrol() leaves act_patrol.lastvisible60 uninitialised on PC —,"FIXED (chraction.c:3872, #ifdef PORT → self->act_patrol.lastvisible60 = -1;, N64 line under #else).…"
@@ -55,33 +56,33 @@ D152+,FIXED,D152 addendum (M-31):,"FIXED (src/snd.c, port/src/libultra.c, #ifdef
D168,FIXED,GE_PCDUMP / F12 PPM captures were vertically flipped —,FIXED (port/fast3d/gfx_opengl.cpp); D114 / D116 reclassified as capture-orientation artifacts (below).
D169,FIXED,Front-end mouse pointer can't reach the outer cells of the mission/level-select grid —,"FIXED (M-33, port/src/input.c, port-only, no #ifdef PORT)."
D178,FIXED,Pre-mission briefing screen:,FIXED (M-36) — high confidence
-D175,OPEN,In-game stutter / brief hang during normal play —,"OPEN — observed, not investigated"
+D175,CLOSED,In-game stutter / brief hang during normal play —,"CLOSED (v0.4.0 campaign sign-off 2026-09-28, user:"
D176,FIXED,"Surface exterior renders wrong (-level_36), two independent defects.","(a) scroll-speed FIXED (fidelity pass; HIGH confidence on root cause, MODERATE on N64 parity); M-93 playtest + M-98 Defect-2 starburst screenshot in the sections."
D177,FIXED,Ladders non-functional —,FIXED (M-36) — high confidence; interactive climb test owed
D172,FIXED,"Bullet-impact / blood / spark particles render magenta or cyan instead of dark red (M-82, ROOT CAUSE FOUND + FIXED).",FIXED (port/fast3d/gfx_pc.cpp gfx_lod_tile_offset)…
-D174,OPEN,"""No blood effect"" (user QA report).",OPEN — observed; first step = BUNKER1 firefight with regenerated sidecars
-D183,OPEN,"M-36 Family A (""texture line/pitch shear"") is DISPROVEN for the Surface repro (-level_36).",Hypothesis DISPROVEN + diagnostics shipped; D176(b)/D182(2) still OPEN
+D174,CLOSED,"""No blood effect"" (user QA report).","CLOSED — faithful (2026-09-26, user side-by-side on 1964:"
+D183,CLOSED,"M-36 Family A (""texture line/pitch shear"") is DISPROVEN for the Surface repro (-level_36).","CLOSED (2026-09-26, user-approved:"
D173,FIXED,Third-person Bond model spawns too high —,"MOSTLY FIXED as a side effect of D243 (2026-09-18, user-confirmed)."
D179,FIXED,Packaged build crashes after the logos —,"Diagnosed, not fixed —"
-D180,PARTIAL,"Native-PC input pass (M-XX QoL run, qol/native-pc-input-menu, port-only).","LANDED, port-only, no #ifdef PORT.…"
-D181,?,Game.ScreenShakeIntensity —,"Menu row pulled (v0.2.1, user testing)."
-D184,PARTIAL,F10 port-layer options overlay —,"LANDED, port-only, no #ifdef PORT in src/."
-D211,PARTIAL,"PC FOV slider — Video.FovScale (M-83 QoL wave, port-only, WIDESCREEN-FOV-PLAN.md Phase 4).",PARTIAL — M-84 refix…
-D212,PARTIAL,Anisotropic-filtering config wiring —,"LANDED, port-only. Build clean."
-D213,PARTIAL,On-screen FPS readout —,"LANDED, port-only. Build clean; bunker1 no-op confirmed."
-D214,PARTIAL,"Keyboard rebinding — [Input.Bind] config section (M-83, port-only, PD parity, Phase 4 debt).",PARTIAL — M-84: code PASS (alt/ctrl); one doc-only fix.
+D180,FIXED,"Native-PC input pass (M-XX QoL run, qol/native-pc-input-menu, port-only).",FIXED (2026-09-26 bookkeeping:
+D181,FIXED,Game.ScreenShakeIntensity —,FIXED (2026-09-26 bookkeeping:
+D184,FIXED,F10 port-layer options overlay —,FIXED (2026-09-26 bookkeeping:
+D211,FIXED,"PC FOV slider — Video.FovScale (M-83 QoL wave, port-only, WIDESCREEN-FOV-PLAN.md Phase 4).",FIXED (2026-09-26 bookkeeping:
+D212,FIXED,Anisotropic-filtering config wiring —,FIXED (2026-09-26 bookkeeping:
+D213,FIXED,On-screen FPS readout —,FIXED (2026-09-26 bookkeeping:
+D214,FIXED,"Keyboard rebinding — [Input.Bind] config section (M-83, port-only, PD parity, Phase 4 debt).",FIXED (2026-09-26 bookkeeping:
D215,FIXED,1P weapon viewmodel never drew in-level —,"FIXED (src/game/gunfire.c, #ifdef PORT)."
-D216,PARTIAL,"Game.SkipIntro — boot straight to the file-select menu (M-83, port + one #ifdef PORT game-code line, PD parity, Phase 4 quick win).",LANDED + VERIFIED this session.
+D216,FIXED,"Game.SkipIntro — boot straight to the file-select menu (M-83, port + one #ifdef PORT game-code line, PD parity, Phase 4 quick win).",FIXED (2026-09-26 bookkeeping:
D217,FIXED,1P weapon viewmodel + 3P Bond model textures garbled —,FIXED + merged (PR #47 + PR #49); white residual split out as D228.
-D218,PROPOSED,Wide FOV exposes the per-level draw-distance / fog boundary —,"Logged (M-84), fix proposed + parked."
+D218,FIXED,Wide FOV exposes the per-level draw-distance / fog boundary —,FIXED (2026-09-26 reclassified:
D219,RESOLVED,"PPK explosions render purple on Dam (M-87, user QA, ""not 3D but noting"").","RESOLVED (M-115, 2026-09-12) for the original PPK-purple symptom."
-D220,PARTIAL,"1P muzzle flash position shifts upward at higher Video.FovScale (M-87, user QA, Jungle/M16).","ANALYZED (M-91) — the suspected unscaled-viewmodel root does NOT hold; this is correct perspective behavior, likely cosmetic/QoL."
-D85,OPEN,Room primary/secondary DL → texLoadFromGdl garbage.,"OPEN (safety-netted; widen/pool fixes landed, geometry now renders)"
+D220,CLOSED,"1P muzzle flash position shifts upward at higher Video.FovScale (M-87, user QA, Jungle/M16).","CLOSED — merged into D303 (2026-09-26, user:"
+D85,CLOSED,Room primary/secondary DL → texLoadFromGdl garbage.,"CLOSED (2026-09-26, user-approved:"
D86 · D87,RESOLVED,modelInitRwData truncated ptr · attract-demo BE ramromfilestructure.,resolved
D88.4,RESOLVED,propDefs polymorphic record stream not byteswapped → setupDoor crash.,resolved (d88_propdefs.py); layout audit M-20 → D132
D132,PROPOSED,D88 propDefs layout audit (M-20):,proposed fix (not applied) —
D157,FIXED,"Campaign never unlocks the next level (M-30, user bug —","FIXED (src/bondtypes.h, #ifdef PORT —…"
-D160,PARTIAL,Dam level-end cutscene (Bond off the dam / bungee) never plays —,"DIAGNOSTIC SHIPPED, not root-caused (c95713f5, #ifdef PORT + GE_D160=1)…"
+D160,FIXED,Dam level-end cutscene (Bond off the dam / bungee) never plays —,"FIXED (user, 2026-09-26:"
D89-D92,RESOLVED,"stage-load→frame: stan zero-fill overrun, portal address trunc, chr/AI spawn ptrs",resolved
D93-D102,RESOLVED,struct-pun / hardcoded-size pass:,resolved (D95 pool bump partial)
D103-D107,RESOLVED,"BUNKER1 viewport height, depth-buffer clear (G_CLEAR_DEPTH_EXT), portal near-plane, LOD mip tile",resolved
@@ -90,20 +91,20 @@ D113-D116,FIXED,portal BFS ok · matrix chain ok · player raw-offset audit + gu
D117,OPEN,"Frame nondeterminism root-caused; GE_DETERM attempted, not achieved.","OPEN (mode attempted, not achieved)"
D118,RESOLVED,"SDL input layer; M-24 mouse-look rework (mode-aware map, real INI); D118b/c FIXED.",resolved (D118a residual)
D150,FIXED,watch OBJECTIVES / BRIEF page crash:,FIXED (src/str.c #ifdef PORT); interactive re-verify pending
-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 (the wall itself) —
-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
+D260-D265,CLOSED,"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…",CLOSED (2026-09-26 bookkeeping:
+D266-D269,CLOSED,"D236/D265 wall, second pass:",CLOSED (2026-09-26 bookkeeping:
+D270,CLOSED,Backfilled (M-132) — this label was never given its own row; only code comments and the HANDOFF archive documented it.,CLOSED (2026-09-26 bookkeeping:
D271,FIXED,"D236 wall, third pass —",FIXED-but-insufficient (src/game/bg.c #ifdef PORT) —
-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… —,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… —","OPEN — user-reported regression, not root-caused; mitigation is user-facing guidance only (release notes:"
+D276,CLOSED,"D236 wall, eighth pass —",CLOSED (2026-09-26 bookkeeping:
+D279,CLOSED,Backfilled — this label was consumed by an undocumented partial session (2026-09-14 ~23:00) that left artifacts in scratch/ (d279_run.log… —,CLOSED (2026-09-26 bookkeeping:
+D281,FIXED,"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… —","FIXED (2026-09-23, port/src/libultra.c):"
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… —","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"
-D273,OPEN,"D236 wall, fifth pass —","RULED OUT (port/fast3d/gfx_pc.cpp palette_to_rgba32, pre-existing/uncommitted GE_D273) —"
-D272,OPEN,"D236 wall, fourth pass —","OPEN — probe GE_D272 added to port/fast3d/gfx_pc.cpp (env-gated, uncommitted); wall still open, see D236"
+D280,CLOSED,"D236 wall, ninth pass —",CLOSED (2026-09-26 bookkeeping:
+D275,CLOSED,"D236 wall, eighth pass —",CLOSED (2026-09-26 bookkeeping:
+D274,CLOSED,"D236 wall, seventh pass —",CLOSED (2026-09-26 bookkeeping:
+D273,CLOSED,"D236 wall, fifth pass —",CLOSED (2026-09-26 bookkeeping:
+D272,CLOSED,"D236 wall, fourth pass —",CLOSED (2026-09-26 bookkeeping:
D137,FIXED,right-mouse (aim-sight) crash:,FIXED (src/game/gunfire.c #ifdef PORT —
D119 · D120,FIXED,guard-attack weapons_held[]->chr pun crash · blood-stain PointUsage[] chain hang,D119 fixed · D120 FIXED (M-30)
D120,FIXED,Blood-decal PointUsage[] chain —,FIXED (tools_pc/d43_emit.py).
@@ -121,9 +122,9 @@ D136,RESOLVED,"Stage-unload crash — objFree obj->prop reads float garbage (M-2
D139,FIXED,Stage-unload crash root cause + fix (M-23).,"FIXED (src/game/cleanup_objects.c, #ifdef PORT —…"
D140,FIXED,Pause-menu crash (M-23 open → M-27 fixed).,"FIXED (src/game/bondview.h, src/game/bondview2.c; #ifdef PORT)."
D141,FIXED,"Watch-menu item-model crash (M-27), exposed by D140.","FIXED (src/game/gunfire.c, #ifdef PORT)."
-D152,?,"Mission-failed / objective-failed → permanent black screen (M-28, user bug report —","MITIGATED (port/src/libultra.c, M-28, #ifdef PORT/port-only).…"
+D152,FIXED,"Mission-failed / objective-failed → permanent black screen (M-28, user bug report —","FIXED (user, 2026-09-26:"
D154,?,"bg.c room-geometry ray/hit-test GBI parser port (M-28, WRITTEN / UNVERIFIED / UNCOMMITTED).",M-30 re-audit + fixes (still playtest-gated for final verify).
-D156,PARTIAL,"Facility outro-cutscene ""hang"" —","GUARDED (src/game/model.c, #ifdef PORT)…"
+D156,FIXED,"Facility outro-cutscene ""hang"" —","FIXED (user, 2026-09-26:"
D155,FIXED,"Facility outro-cutscene end → ""hang"" = unclamped deltaFrames spiral (M-29, user bug report).","FIXED (src/game/frametiming.c, #ifdef PORT —…"
D153,RESOLVED,"-level_09 frame ~900–1400 0xc0000005 = D117/D134 load flakiness, NOT a bug (M-28).",NOT A BUG — closed. Run anchors on a quiet machine.
D151,FIXED,Watch data all blank —,"FIXED (src/bondtypes.h, #ifdef PORT —…"
@@ -131,23 +132,23 @@ D130,RESOLVED,"crash class C2 (Facility -level_34, Runway -level_35):",resolved
D164,FIXED,"Disclaimer / legal screen only draws the first line (D76 root cause, M-31).",FIXED — corrected 2026-09-18:
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, sixteenth pass (2026-09-18) —"
-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)
+D235,CLOSED,Wrong textures load after dying/restarting a level —,"CLOSED (user, 2026-09-26:"
+D236,CLOSED,"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).","CLOSED (M-198, pass 26, 425237f9; probes stripped 0503d335):"
+D237,FIXED,"QoL ask (M-106, user QA):","FIXED (2026-09-23): category headers + 5 more settings (SkipIntro, Show FPS, pad invert/deadzone/trigger); wheel scroll already existed."
+D238,FIXED,"QoL ask (M-106, user QA):",FIXED (2026-09-26: covered by D337/D338 aim work; user reports mouse feel good; code-side validation owed).
D239,CLOSED,"QoL ask (M-106, user QA):",CLOSED — executed in the v0.2.0 prep pass (port-only)…
-D240,OPEN,"Player gunshot SFX timing/cadence reportedly does not match the N64 original's rate (M-106, user QA report).","OPEN — not root-caused; static call-site tracing only, no runtime verification possible in this environment."
-D241,OPEN,Player gunshot SFX goes silent under some conditions —,"OPEN — not newly root-caused; high confidence this is D207 reproduced, not an independent bug."
-D242,?,Player-death sequence UX regression vs N64 original:,NOT A PORT-LOGIC BUG as far as static analysis can tell.
+D240,CLOSED,"Player gunshot SFX timing/cadence reportedly does not match the N64 original's rate (M-106, user QA report).","CLOSED (v0.4.0 campaign sign-off 2026-09-28, user:"
+D241,CLOSED,Player gunshot SFX goes silent under some conditions —,"CLOSED as the D207 family (v0.4.0 campaign sign-off 2026-09-28, user:"
+D242,FIXED,Player-death sequence UX regression vs N64 original:,"FIXED/CLOSED (2026-09-26, user:"
D243,FIXED,"Dam end-of-level cutscene (the abseil/bungee sequence, D148/D160's subject):","FIXED, live-confirmed by the user (M-189/M-190, 2026-09-18)."
-D245,OPEN,IsWater water: two competing patterns with a visible seam that moves as the player looks around (mouse/aim) —,"OPEN — REOPENED, M-142 headless probe found the tc-shift k measurably flips during ordinary view rotation (new root-cause evidence); an opt-in GE_D245_FIXEDSHIFT candidate is build-verified but needs…"
+D245,FIXED,IsWater water: two competing patterns with a visible seam that moves as the player looks around (mouse/aim) —,"FIXED (M-201, 2026-09-26, user-verified vs 1964):"
D246,FIXED,"Tiny pixel strips at the left and right edges of the screen, ""like overscan"" —","FIXED (2026-09-18 session, port-only, Video.SafeAreaCrop)."
D247,FIXED,Black bars top and bottom of screen —,"FIXED (2026-09-18 session, port-only, Video.SafeAreaCrop)."
D248,FIXED,"Port runs a stable 30 FPS instead of 60 FPS game-wide (M-117, user asked to verify FPS after the D209 timing-keystone fixes).","FIXED, root-caused to a genuine pointer-width ABI bug in src/sched.c (compiled for the PC build, not excluded)."
-D249,OPEN,"LOD/culling breaks down on large open levels (Streets, Egyptian named specifically) at *default* FOV —","OPEN — M-142 static review ruled out the unscaled-far-clip and LOD-multiplier suspects (both already correct); narrowed to the portal/room-visibility BFS, needs a live screenshot+location, not more c…"
+D249,OPEN,"LOD/culling breaks down on large open levels (Streets, Egyptian named specifically) at *default* FOV —","OPEN — M-142 static review ruled out the unscaled-far-clip and LOD-multiplier suspects (both already correct); CLOSED (v0.4.0 campaign sign-off 2026-09-28, user:"
D250,FIXED,"FPS is inconsistent/low on many levels after 60 Hz shipped (M-119, user QA; D248 follow-on).","FIXED — per-call getenv() cost was the bottleneck; rock-steady 60-61 fps after, user re-tested 1440p/4K clean."
D251,FIXED,F10 options overlay QoL pass:,"FIXED (port-only, port/src/optionsoverlay.c)."
-D252,PARTIAL,"Particle effects cycle through a rainbow palette (M-124, user playtest of the v0.2.0-pre candidate).","PARTIALLY FIXED — a real, distinct manifestation was found and fixed, but the bug family is not fully closed, and is intermittent on BOTH platforms, not Deck-specific (corrected 2026-09-18)."
+D252,FIXED,"Particle effects cycle through a rainbow palette (M-124, user playtest of the v0.2.0-pre candidate).","FIXED (M-201, 2026-09-26, user-verified):"
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,FIXED,Facility firefight SIGSEGV on Steam Deck (v0.2.0-pre CI bundle; D253 fix confirmed working).,"FIXED (M-140) — D3x-class 32-bit pointer truncation (vtxstore_fix_refs read Model* through the mistyped ChrRecord.chrflags 4-byte-enum alias instead of ObjectRecord.model), not the destroy-path race…"
@@ -159,44 +160,128 @@ D261,FIXED,v0.2.1 (user request:,FIXED — feel-check received; W/S sensitivity
D262,FIXED,v0.2.1 (user report):,"FIXED (primary cause, D264) —"
D264,FIXED,"D262's root cause (ABI/layout class, cf.","FIXED — build green, probes removed; visual confirmation pending."
D263,RESOLVED,v0.2.1 (user report):,"RESOLVED (not a bug), 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)."
+D265,CLOSED,"D236 investigation (port-layer, port/fast3d/gfx_pc.cpp):",CLOSED (2026-09-26 bookkeeping:
+D258,PROPOSED,PAL/JP asset conversion is broken at the source-data level —,DEFERRED (user call 2026-09-28:
D284,FIXED,port/src/random.c's MIPS64 dsll32/dsrl32 shift helpers were backwards —,"FIXED, 21-level sweep clean, no crashes —"
-D285,PARTIAL,Steam Deck SIGSEGV during D255 live-testing = SEPARATE bug:,"PARTIALLY FIXED, RECURRED PAST THE FIX (see D305, 2026-09-18)."
-D286,OPEN,"User-confirmed real ~8s frame freeze on Caverns, Steam Deck —","OPEN — one confirmed occurrence, no repro trigger identified, not root-caused; distinct from D255/D285."
-D287,OPEN,"User-confirmed real, non-self-recovering freeze on Windows (M-141, 2026-09-15/16) after ~15-20 minutes with no input, triggered by clicking back into the… —","OPEN — one confirmed occurrence, real (not self-recovering), not root-caused; gfxFrameMsgQ full is a lead, not a confirmed cause."
-D288,OPEN,"Silo intro: a huge, flat-shaded, untextured brown/tan triangle sweeps across the frame during the camera pan down the missile shaft (M-141, user report —… —","OPEN — visually confirmed and precisely bounded (frames ~562-741 of a bare -level_20 boot), root cause not yet identified; promoted from the long-parked GRAP… |"
-D289,OPEN,"Dam: a crash in the computer room, not reproducible on a second attempt (M-141, Steam Deck user playtest).","OPEN — needs a human re-test specifically on a build with the D255 fix (this session's release/v0.3.0, not any earlier build) to see if it still happens.…"
-D290,OPEN,"Watch's in-game ""controller"" section shows no control-pad graphic (M-141, Steam Deck user playtest).","OPEN — observed, not investigated."
-D291,OPEN,"Dam: texture glitches when the truck drives past (M-141, Steam Deck user playtest).","OPEN — observed, not investigated."
+D285,FIXED,Steam Deck SIGSEGV during D255 live-testing = SEPARATE bug:,"FIXED (user, 2026-09-26:"
+D286,CLOSED,"User-confirmed real ~8s frame freeze on Caverns, Steam Deck —","CLOSED (2026-09-26, user-approved:"
+D287,FIXED,"User-confirmed real, non-self-recovering freeze on Windows (M-141, 2026-09-15/16) after ~15-20 minutes with no input, triggered by clicking back into the… —",FIXED (user playtest 2026-09-26:
+D288,CLOSED,"Silo intro: a huge, flat-shaded, untextured brown/tan triangle sweeps across the frame during the camera pan down the missile shaft (M-141, user report —… —","CLOSED — not reproduced on the current build (2026-09-23 capture) and the 2026-09-25 user Silo playtest confirms it gone (""silo fixed"")."
+D289,FIXED,"Dam: a crash in the computer room, not reproducible on a second attempt (M-141, Steam Deck user playtest).","FIXED (user, 2026-09-26:"
+D290,FIXED,"Watch's in-game ""controller"" section shows no control-pad graphic (M-141, Steam Deck user playtest).","FIXED (2026-09-23): D264-pattern template read in watchRenderController (flags read 0), plus 3 pointer-width/raw-Gfx sites on the same path; controller model verified rendering on Windows."
+D291,CLOSED,"Dam: texture glitches when the truck drives past (M-141, Steam Deck user playtest).","CLOSED — duplicate of D306, faithful (M-201, 2026-09-26:"
D292,FIXED,Death animations are glitched —,"MOSTLY FIXED as a side effect of D243 (2026-09-18, user-confirmed alongside D173) —"
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.…"
-D294,OPEN,"Statue: ground geometry near the large CCCP/hammer-and-sickle monument briefly renders solid black over a wide area, then self-recovers 10-30 seconds later… —",OPEN — not investigated.…
+D294,FIXED,"Statue: ground geometry near the large CCCP/hammer-and-sickle monument briefly renders solid black over a wide area, then self-recovers 10-30 seconds later… —","FIXED, user playtest confirmed (2026-09-25:"
D295,FIXED,"Campaign-halting crash at the end of Control Center on 00 Agent difficulty, then a crash on every subsequent relaunch/resume (GitHub #87, user campaign playtes…",FIXED (M-149) — M-148 root-caused + reproduced on HEAD; fix landed:
-D296,OPEN,"Control Center, 00 Agent:","OPEN — single live report, un-triaged; observed only under a gdb-attached debug-harness launch (tools_pc/debug.ps1), not the packaged release artifact —"
+D296,CLOSED,"Control Center, 00 Agent:","CLOSED (2026-09-26, user-approved:"
D297,FIXED,v0.3.0 silently wipes ALL pre-fix player saves on first launch (D284 side effect; found during D295/M-148).,FIXED (M-150) — migration shim landed:
-D298,?,Full HUD (not just the aim-mode crosshair) missing in a v0.3.0 dev build —,"s — regression reported 2026-09-16 during v0.3.0 work (build ce8427cf); user says ""don't fix, just for context""; not yet investigated —"
+D298,CLOSED,Full HUD (not just the aim-mode crosshair) missing in a v0.3.0 dev build —,"CLOSED (2026-09-26, user:"
D299,FIXED,Game.SkipIntro crashed selecting any save slot except one —,"FIXED — fileValidateSaves() (only called from the skipped legal screen) never ran under SkipIntro, leaving saves[] all-zero; added the missing call to the SkipIntro shortcut (src/game/lv.c, #ifdef PO…"
D300,FIXED,Hipfire look feels non-uniform (issue #89):,"FIXED, user-confirmed live —"
D301,FIXED,"fileGetIsCheatUnlocked() NULL-deref, unguarded on every call site, not just the D299/SkipIntro one (GitHub #87, two reports with an identical crash PC/fault ad…",FIXED locally on release/v0.3.0 (not pushed) —
D302,FIXED,"Render FPS regression to ~40-45fps on release/v0.3.0 (user QA on the local v0.3.0 pre-release build, 2026-09-18) —","FIXED — 4 uncached getenv() calls added by D236 passes 10/12/14/16 directly inside gfx_sp_tri1 (port/fast3d/gfx_pc.cpp, per-triangle) cached with the same static int x = -1 pattern D250 established e…"
-D303,OPEN,"Some muzzle flashes (M16 named specifically, ""and some others"") draw wrong —","OPEN — reported only, not yet investigated."
+D303,FIXED,"Some muzzle flashes (M16 named specifically, ""and some others"") draw wrong —","FIXED (M-201, 2026-09-26):"
D304,FIXED,"Mouse sensitivity menu overhaul (user request, 2026-09-18):","FIXED — see full entry for both the range/parity pass and the same-day follow-up (collapsed to a single Input.MouseSensitivity menu row + fixed overlayUpdateScroll()'s unconditional bottom-anchor, th…"
D305,FIXED,"Steam Deck SIGSEGV on the v0.3.0 pre-release build (Cavern, early in the level, Game.AllUnlocked previously enabled on the session/save) —",FIXED (defensive guard) —
-D306,OPEN,"Dam intro/truck wheels render with odd transparent-looking areas, possibly z-fighting (user QA, 2026-09-18).","OPEN — reported only, not investigated."
-D307,OPEN,"In-level security camera entities can be flipped facing the wrong direction (backwards) on some levels, e.g.","OPEN — reported only, not investigated."
-D308,OPEN,"Occasional z-fighting confirmed on some levels' geometry, general/broader than the D306 truck-wheel instance (user QA, 2026-09-18).","OPEN — reported/confirmed by the user, not investigated."
+D306,CLOSED,"Dam intro/truck wheels render with odd transparent-looking areas, possibly z-fighting (user QA, 2026-09-18).","CLOSED — faithful, confirmed on 1964 (M-201):"
+D307,FIXED,"In-level security camera entities can be flipped facing the wrong direction (backwards) on some levels, e.g.","FIXED (M-201, 2026-09-26, user-verified):"
+D308,CLOSED,"Occasional z-fighting confirmed on some levels' geometry, general/broader than the D306 truck-wheel instance (user QA, 2026-09-18).","CLOSED — faithful (2026-09-26, user:"
D309,FIXED,Complete non-self-recovering freeze (Windows) returning from Caverns to the intro screen —,FIXED via D310 — user-playtest-confirmed 2026-09-18 (Caverns→intro clean; the spin record itself observed resuming correctly in the post-fix probe log).
D310,FIXED,"RULE-2-SIGNOFF: chraiitemsize() mis-sizes 1-byte AI_PRINT records (NUL-scans forward), corrupting AI execution for any list containing an active PRINT —",FIXED — user-playtest-confirmed 2026-09-18 (v2:
-D311,PARTIAL,Complete freeze in modelSetAnimFrame2WithChrStuff root-motion loop —,"GUARDED (#ifdef PORT, D156-guard extension in the same file) —"
+D311,FIXED,Complete freeze in modelSetAnimFrame2WithChrStuff root-motion loop —,FIXED — root cause D336 (bheadAdjustAnimation read never-written placeholder fields); the #ifdef PORT guard is retained as a safety net.
D312,FIXED,"Throwable items (mines, grenades, etc.) can clip through walls in certain spots (user report, v0.3.0 pre-release playtest, 2026-09-18).",FIXED — bgBuildRoomVtxBounds mis-reads the G_VTX vertex count on PC (Gfx_le.dma.par bit-layout shift):
D313,FIXED,Bullet impacts do not appear on some walls/surfaces —,"FIXED — same root cause as D312, merged diagnosis; same one-line fix."
-D314,OPEN,"F10 menu items flicker randomly/intermittently (reported on the main-menu context, user report, v0.3.0 playtest build, 2026-09-18).",OPEN — confirmed still present post-D316 fix (2026-09-18); user deprioritized chasing it further for now.
-D315,OPEN,Menu scaling differs between main menu and in-game —,"OPEN — disclosed as a known issue, deferred to v0.3.1 (bigger design question, not a mechanical fix)."
+D314,FIXED,"F10 menu items flicker randomly/intermittently (reported on the main-menu context, user report, v0.3.0 playtest build, 2026-09-18).",FIXED (user playtest 2026-09-26) —
+D315,CLOSED,Menu scaling differs between main menu and in-game —,"CLOSED (2026-09-26, user:"
D316,FIXED,"Mouse-clicking some menu items glitches and lands on the item below instead (user report, v0.3.0 playtest build, 2026-09-18).",FIXED — root-caused to two compounding bugs (naive mouse->logical mapping ignoring the safe-area crop; asymmetric row hit-test band) in port/src/optionsoverlay.c + port/fast3d/gfx_pc.cpp.
D317,CLOSED,"Crash when toggling Game.AllUnlocked back and forth in-game (user report, v0.3.0 playtest build, 2026-09-18) —","CLOSED — symptom of D316 (confirmed instant window close, no WER report; the D316 hit-band bias let a click on ""All unlocked"" fall through to the adjacent ""Quit to desktop"" row)."
-D318,RESOLVED,Facility (regular play path):,RESOLVED (2026-09-19):
+D318,FIXED,Facility (regular play path):,FIXED via D329 (2026-09-26 bookkeeping:
D319,FIXED,"Frame cap slider was mostly a dead zone; replaced with a plain 30/60 toggle (user ask, 2026-09-18).",FIXED — new ROW_FPSCAP cycle (30/60 only) in port/src/optionsoverlay.c; default changed to 60 in port/src/video.c.
-D320,OPEN,"D318-class sweep: aim-hold → _update AI lists in 8 levels carry the same as-authored softlock race (static analysis, 2026-09-20).","OPEN — static sweep complete (Facility ai_19, Control ai_9, Depot ai_12 flagged high/med-high; Bond-combat loops likely safe); no live confirmation beyond D318 itself; probe-verify path + generalized…"
-D321,CLOSED,D318 trigger chain fully mapped:,CLOSED — trigger chain mapped and faithful; D318 watchdog validated in live play.
-D322,OPEN,Long-session audio degradation:,"OPEN — static triage done (teardown audit, mixer statelessness, D202-coverage check); ranked hypotheses:"
+D320,FIXED,"D318-class sweep: aim-hold → _update AI lists in 8 levels carry the same as-authored softlock race (static analysis, 2026-09-20).",FIXED via D329 (2026-09-26 bookkeeping:
+D321,FIXED,D318 trigger chain fully mapped:,FIXED via D329 (2026-09-26 bookkeeping:
+D322,FIXED,Long-session audio degradation:,"FIXED (Windows) for the periodic ""garbled"" bursts —"
+D323,CLOSED,G_TL_LOD bindings of explicit-LOD textures:,"CLOSED — parked as a fidelity enhancement, not a bug (2026-09-26, user-approved:"
+D324,FIXED,"GE's stub declares nothing, so TUs that include it get NO CRT prototypes —",FIXED (2026-09-22) — proven impact:
+D325,CLOSED,ROM-mod (xdelta) compatibility:,CLOSED as a question —
+D326,CLOSED,Random Eye randomizer repacks the ROM too —,CLOSED as a question —
+D327,RESOLVED,"Mod support, resolved plan:",RESOLVED — superseded by D328; approach (a) is NOT ruled out (these scans missed the 1172-compressed data segment).
+D328,CLOSED,"Mod support is viable and D327's ""ruled out"" was wrong:",CLOSED as a question —
+D329,FIXED,The D318/D320 guard anim pin is a tick-granularity divergence:,"FIXED — Rule-2 high-FPS fix in model.c; clk=1 no longer pins (WSL + native Windows), 0 triggers at clk=2; bunker1 golden failure predates it (on/off A/B); user playtest confirms N64-matching Ourumov…"
+D330,FIXED,"#103 (v0.3.0, Win 11):",FIXED (port/src/input.c) —
+D331,FIXED,Ejected shell casings never rendered on PC:,"FIXED (gunfire.c #ifdef PORT, explicit template); PP7 casing verified on native Windows (before:"
+D332,FIXED,Reticule jitter in aim mode (RMB) —,FIXED (fix/mouse-pd-model) —
+D333,FIXED,"#104 (v0.3.0, Linux):",FIXED (2026-09-26 bookkeeping:
+D334,FIXED,"Native widescreen, world side (WIDESCREEN-FOV-PLAN Option B Phase 2):",FIXED (2026-09-26 bookkeeping:
+D335,FIXED,"Native widescreen, HUD + front end (WIDESCREEN-FOV-PLAN Phase 3):",FIXED (2026-09-26 bookkeeping:
+D336,FIXED,"Frigate (and any level) ""all polygons break except the gun"" after turning:","FIXED (src/game/bondhead.c #ifdef PORT, ABI/layout); headless repro (Runway → exit → Frigate → turn, D294 2× room pool) clean, D156 0 fires; golden 3/3; Win + Linux builds."
+D337,FIXED,Aim modes folded (user decision):,FIXED (2026-09-26 bookkeeping:
+D338,FIXED,"Input.AimRange (F10 ""Aim range""):",FIXED (2026-09-26 bookkeeping:
+D339,OPEN,"#92 (low-end ~30 fps, Celeron N3060 / HD 400) —","OPEN (2026-09-26, user:"
+D341,FIXED,"Intro gun-barrel blood drip draws as static blocks/lines instead of dripping (user, v0.4.0, 2026-09-26; death blood fine).","FIXED (M-201, user-verified 2026-09-27):"
+D342,FIXED,"File select: folder bodies and Bond photos vanish after selecting a file and backing out (user, v0.4.0, 2026-09-26).","FIXED (M-201, user-verified):"
+D343,FIXED,"File select: PC ""Options"" label opens the settings overlay (v0.4.0 feature, M1 of the route-B options screen, 2026-09-27); also fixes F10 on file select jumpin…","FIXED (M1, closed): one Rule-2 #ifdef PORT draw hook (user sign-off 2026-09-27); headless + user-verified (mouse, F10, >30 s, widescreen, physical controller on the bottom-bar label and options scree…"
+D344,FIXED,Host PC bugchecks (BSOD 0x119 VIDEO_SCHEDULER_INTERNAL_ERROR) during game test runs; probable cause:,"FIXED (probable cause; orderly quit + GE_QUITFRAME), headless + user-verified; no dump, watch for recurrence."
+D345,FIXED,"Front-end crosshair snaps away from the pointer on screen/page changes (file select → mode select setCursorPOSforMode(0), front.c:2516; every MENU_PC_OPTIONS p…","Port-only, user-verified (parts a–g):"
+D346,CLOSED,PC-settings wording pass:,CLOSED (2026-09-28). Shipped in v0.4.0; accepted in the user's final review / campaign playtest.
+D347,FIXED,F10 overlay controller:,"FIXED (port-only, optionsoverlay.c):"
+D348,?,"File-select ""PC Options"" label:",SUPERSEDED by D398 (glyph replaced with the IMG_DOT texture; no new art).
+D349,CLOSED,GE watch settings are not normally saved to the Bond file:,CLOSED (2026-09-28). Shipped in v0.4.0; accepted in the user's final review / campaign playtest.
+D350,CLOSED,Watch-backed PC options:,CLOSED (2026-09-28). Shipped in v0.4.0; accepted in the user's final review / campaign playtest.
+D351,FIXED,Existing D346 value/unit rows had positional initializers shifted into hidePtr:,"FIXED (port-only): designated row initializers restore unit, deadzone divisor and hide-if-auto fields; build clean, headless smoke clean."
+D352,FIXED,D350 review of 1303c0eb:,"FIXED (port-only, port/src/watchsettings.c):"
+D353,FIXED,Turok-style settings regrouping:,"FIXED (port-only, optionsoverlay.c/frontoptions.c):"
+D354,FIXED,Front-end F10 Bond rows silently disabled:,"FIXED (port-only, port/src/watchsettings.c):"
+D355,FIXED,F10/options Music slider inaudible in-stage:,"FIXED (port-only, port/src/watchsettings.c):"
+D356,FIXED,"Settings regroup: Turok-style pages renamed to functional sections (INPUT/GAMEPLAY/GRAPHICS/AUDIO/VIDEO) with PD-style ""(File N)"" scoping annotations, the redu…","FIXED (port-only, port/src/optionsoverlay.{h,c} + port/src/watchsettings.{h,c} + port/src/frontoptions.c; plan docs/dev/D356-SETTINGS-REGROUP-PLAN.md):"
+D358,FIXED,D356 level-1 overlap:,"FIXED (port-only, port/src/frontoptions.c):"
+D359,CLOSED,"Settings UI file terminology settled on the end-user term ""Profile"":",CLOSED (2026-09-28). Shipped in v0.4.0; accepted in the user's final review / campaign playtest.
+D360,CLOSED,F10 INPUT heading scrolled away; F10 navigation and distance defaults (2026-09-27).,CLOSED (2026-09-28). Shipped in v0.4.0; accepted in the user's final review / campaign playtest.
+D361,FIXED,Main-menu F10 Audio freezes the game:,"FIXED (port-only, port/src/watchsettings.c + F10 probe in optionsoverlay.c):"
+D362,CLOSED,F10 GoldenEye dossier-style visual refresh (2026-09-27).,CLOSED (superseded by D363 after user playtest):
+D363,FIXED,F10 return to a dark overlay with the N64 watch's green/Bank Gothic visual language (2026-09-27).,"FIXED (palette/font user-approved, 2026-09-27); D364 follows up on profile tags, click alignment and slider calibration."
+D364,FIXED,Settings UI feedback:,FIXED (user mostly accepted; D365 follows up on annotation size and click targets).
+D365,CLOSED,F10 annotation hierarchy and visible mouse change targets (2026-09-27).,CLOSED (2026-09-28). Shipped in v0.4.0; accepted in the user's final review / campaign playtest.
+D366,CLOSED,Input calibration label:,CLOSED (2026-09-28). Shipped in v0.4.0; accepted in the user's final review / campaign playtest.
+D367,?,F10 full-width control boxes misaligned with their contents (2026-09-27).,SUPERSEDED by D368 after user playtest:
+D368,?,Simplify F10 click targets:,"INPUT RULE KEPT; tinted strips user-rejected, removed in D369."
+D369,FIXED,Remove F10 row boxes/strips entirely (2026-09-27).,FIXED (awaiting live acceptance):
+D370,CLOSED,F10 distance/volume drags hurt frame rate (2026-09-27).,CLOSED (2026-09-28). Shipped in v0.4.0; accepted in the user's final review / campaign playtest.
+D371,FIXED,"GEPD key-layout preset + reload/crouch bindings, v0.4.0 modern options wave M3 (2026-09-27).",FIXED (headless-verified; awaiting user live accept):
+D372,FIXED,"Low-end perf preset (v0.4.0 modern options wave M5, 2026-09-27).",FIXED (headless-verified; awaiting user live accept):
+D373,FIXED,"In-game crosshair on/off + colour (v0.4.0 modern options wave M2, 2026-09-27).",FIXED (user live-tested; rule-2 sign-off recorded 2026-09-27):
+D374,FIXED,"GEPD becomes the default key layout; preset layering fix (v0.4.0 M3 playtest feedback, 2026-09-27).",FIXED (headless + user live-tested):
+D375,CLOSED,Crouch wired to the wrong pad bit:,CLOSED (2026-09-28). Shipped in v0.4.0; accepted in the user's final review / campaign playtest.
+D376,CLOSED,Crouch follow-up + modern-options spot check (2026-09-27).,CLOSED (2026-09-28). Shipped in v0.4.0; accepted in the user's final review / campaign playtest.
+D377,FIXED,GEPD independent crouch (2026-09-27).,FIXED (scripted stance verified; user says live crouch was good; pad/hold-vs-toggle specifics owed):
+D378,FIXED,GEPD use/reload separation (2026-09-27).,FIXED (headless edges tested; user says E/R seemed good; targeted door/partial-mag cases owed):
+D379,FIXED,Crosshair colour: authored texture is red (2026-09-27).,FIXED (cyan frame capture; live yellow/blue recheck owed):
+D380,FIXED,One PC keyboard layout; PD-style rebind design (2026-09-27).,FIXED (follow-up D383):
+D381,CLOSED,"RULE-2-SIGNOFF: opt-in RGB, size and Beta crosshair (2026-09-27).",CLOSED (2026-09-28). Shipped in v0.4.0; accepted in the user's final review / campaign playtest.
+D382,CLOSED,Crosshair menu placement and slider hitches (2026-09-27).,CLOSED (2026-09-28). Shipped in v0.4.0; accepted in the user's final review / campaign playtest.
+D383,PARTIAL,PD-style keyboard rebinding in both options UIs (2026-09-27).,PARTIAL (2026-09-28).
+D384,CLOSED,Front PC Options key-bind navigation feedback + two-slot UI (2026-09-27).,CLOSED (2026-09-28). Shipped in v0.4.0; accepted in the user's final review / campaign playtest.
+D385,CLOSED,Real mouse 1–5 action rebinding in the two-slot editor (2026-09-27).,CLOSED (2026-09-28). Shipped in v0.4.0; accepted in the user's final review / campaign playtest.
+D386,CLOSED,Correct Aim default from SDL middle (Mouse 2) to right (Mouse 3) (2026-09-27).,CLOSED (2026-09-28). Shipped in v0.4.0; accepted in the user's final review / campaign playtest.
+D387,OPEN,M7 AllUnlocked's read-time fake unlocks leak into saved EEPROM (2026-09-27).,OPEN (isolated user-data-copy save/untoggle repro; no fix):
+D388,CLOSED,Nest functional binding/HUD menus and remove redundant M5 toggle (2026-09-27).,CLOSED (2026-09-28). Shipped in v0.4.0; accepted in the user's final review / campaign playtest.
+D389,CLOSED,F10 footer clipping and ambiguous submenu links (2026-09-27).,CLOSED (2026-09-28). Shipped in v0.4.0; accepted in the user's final review / campaign playtest.
+D390,CLOSED,F10 pinned Back selection did not visibly change (2026-09-27).,CLOSED (2026-09-28). Shipped in v0.4.0; accepted in the user's final review / campaign playtest.
+D391,CLOSED,F10 click at glyph bottom could select next row (2026-09-27).,CLOSED (2026-09-28). Shipped in v0.4.0; accepted in the user's final review / campaign playtest.
+D392,FIXED,Train intro soldier hunched; hand intersects torso vs 1964 —,FIXED (#ifdef PORT ABI/layout in chraction.c); Train aim-angle probe confirms correction; user visually verified the soldier looks fixed against 1964.
+D393,FIXED,Controller B/Y both crouch; no in-game use button —,FIXED + user live-tested A use / X reload / Y weapon cycle.
+D394,PARTIAL,Xbox 1.1 Jinx controller parity —,PARTIAL (2026-09-28).
+D395,RESOLVED,Controller trapped in keyboard/mouse-only binding capture —,"RESOLVED (v0.4.0 campaign sign-off 2026-09-28, user:"
+D396,RESOLVED,"F10 pad B/A used as slider decrement/increment, not back/select —","RESOLVED (v0.4.0 campaign sign-off 2026-09-28, user:"
+D397,RESOLVED,SELECT FILE background + intro gun-barrel show an interlaced/comb look —,RESOLVED (2026-09-28):
+D398,FIXED,PC Options label: D348's procedural slider glyph replaced with an existing mainfolderimages texture (2026-09-27).,"SUPERSEDED by D400 (icon removed, text-only label; purple-hover bug moot; the D400 textMeasure arg-swap bug was found and fixed there)."
+D399,PARTIAL,File-select bottom row (SELECT FILE text + Copy/Erase + PC Options dot/label) shifted 15px left as a block (user sign-off 2026-09-27).,"LANDED (port-only, D343 class):"
+D400,PARTIAL,"File-select ""PC Options"" label:","LANDED (port-only, frontoptions.c):"
+D401,CLOSED,Rumble Pak → real gamepad haptics (re-promotes D224/M-87):,CLOSED (2026-09-28). Rumble shipped in v0.4.0 (D401); Steam Deck real-pad validation:
+D402,RESOLVED,First clean Linux (glibc / SteamOS) build of release/v0.4.0 broke in two port-layer include-resolution spots MinGW tolerated (bondconstants.h first-include cyc…,RESOLVED (Linux/Ubuntu-24.04 build → glibc-2.41-compatible binary for the Steam Deck):
+D403,RESOLVED,Deck v0.4.0-pre test:,"RESOLVED (2026-09-27, user-confirmed on the Deck:"
+D404,RESOLVED,"""Aim style: Centred (PC)"" (Input.AimMode=1, D333/D337) has no effect when aiming with a gamepad; the row's own doc comment over-promises (""applies to every aim…","RESOLVED (2026-09-28, port-layer only):"
+D405,CLOSED,"fast3d/OpenGL: MSAA above the driver max black-screens; two glBindFramebuffer calls pass an index where a GL name is required (PR #106, italoarruda, 2026-09-25…","CLOSED (2026-09-28). Merged from PR #106 (code file only; security-reviewed clean - port-layer GL state fixes, no external input, no allocations)."
+D406,RESOLVED,front-end PC Options screen:,"RESOLVED (2026-09-28, port-layer only, Steam Deck-validated):"
+D407,PARTIAL,Tanks cannot be boarded/exited on PC —,"PARTIAL (fix landed in v0.4.0, port-only):"
+D408,OPEN,Game.SkipIntro skips the post-mission failure dossier:,"OPEN (cosmetic, not root-caused past the handoff site)."
diff --git a/docs/dev/findings.md b/docs/dev/findings.md
index d70589a9..f23ee5ee 100644
--- a/docs/dev/findings.md
+++ b/docs/dev/findings.md
@@ -391,36 +391,37 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
| D59–D68 | intro render: blood-RLE clobber, DMA validate, OSMesgQueue, HEADS/BODIES sentinels, romCopy width, image_entry, Globalimagetable BE→LE | resolved |
| D69 · D78–D84 | stage load (`load_bg_file`): bg/stan offline sidecar (`d69_emit.py`) + StandTile/bg_room_data ABI | resolved |
| D70–D74 | intro-logo pixels: C-array bswap, UV path, sinf/cosf `DVAL()`, texture-import truncation | resolved |
-| D75 | front-end 3D model transforms (D77 audio split out below — resolved, unrelated cause) | OPEN (parked). **M-32 triage (see §F "D75 ADDENDUM"): (a) D73-scope-gap RULED OUT (gu tree fully endian-clean). Splits in two: Bug 1 = logo/photo transform = the parked D114/D116 fast3d viewport mirror (not game code). Bug 2 = absent animated models = category (b). M-32b runtime probe (`GE_D75=1`): `render_pos`/`dynAllocate`-arena hypothesis RULED OUT (render_pos valid + fresh each frame), model instances valid (nMtx 21/1), zero fast3d DL warnings — failure is downstream in `drawjointlist`/`dotube` vtx/node-DL resolution or an off-screen `basemtx`. Needs a drawjointlist-level probe. M-33 (upright captures, D168 fixed): "Bug 1 = D114/D116 mirror" retracted; **gun-barrel Bond RENDERS fine** (walk + fire, upright) — the "absent" reports were the flipped capture; **Nintendo logo genuinely broken** (renders as 2 white blobs, shifted left — a real `logoinst` transform/geometry bug, not a flip); cast roll not re-captured. See §F "D75 Bug 2 — M-33 UPDATE". **v0.2.0-pre playtest (user):** Nintendo logo still broken; Rareware logo nearly right but its texture filtering looks off; MISSION COMPLETE / mode-select screens have no 3D models at all, so the earlier "absent mode-select model" framing was inaccurate.** |
+| D75 | front-end 3D model transforms (D77 audio split out below — resolved, unrelated cause) | **PARTIAL (M-197 fixed the logo texture blobs; v0.4.0 campaign 2026-09-28: Nintendo + legal pages confirmed clean, but the Rareware logo still shows a minor texture-filtering artifact -- re-opened as a cosmetic known issue, README-disclosed): the D50 sidecar converter dropped the models' embedded texture blobs (`PnintendologoZ`/`PgoldeneyelogoZ`/`PlegalpageZ`) — `d43_emit.py` now raw-copies + round-trip-validates them; NTSC sidecars regenerated. Remaining: pal/jpn regen on ROM-holding machines.** Historical trail — M-32 triage (see §F "D75 ADDENDUM"): (a) D73-scope-gap RULED OUT (gu tree fully endian-clean). Splits in two: Bug 1 = logo/photo transform = the parked D114/D116 fast3d viewport mirror (not game code). Bug 2 = absent animated models = category (b). M-32b runtime probe (`GE_D75=1`): `render_pos`/`dynAllocate`-arena hypothesis RULED OUT (render_pos valid + fresh each frame), model instances valid (nMtx 21/1), zero fast3d DL warnings — failure is downstream in `drawjointlist`/`dotube` vtx/node-DL resolution or an off-screen `basemtx`. Needs a drawjointlist-level probe. M-33 (upright captures, D168 fixed): "Bug 1 = D114/D116 mirror" retracted; **gun-barrel Bond RENDERS fine** (walk + fire, upright) — the "absent" reports were the flipped capture; **Nintendo logo genuinely broken** (renders as 2 white blobs, shifted left — a real `logoinst` transform/geometry bug, not a flip); cast roll not re-captured. See §F "D75 Bug 2 — M-33 UPDATE". **v0.2.0-pre playtest (user):** Nintendo logo still broken; Rareware logo nearly right but its texture filtering looks off; MISSION COMPLETE / mode-select screens have no 3D models at all, so the earlier "absent mode-select model" framing was inaccurate.** |
| D76 · D164 | **Legal screen only draws line 1 — root-caused (M-31)**: linker-adjacency assumption broken by mingw. — full `## D164` entry at file tail | **FIXED** — this row and the `## D164` entry below were stale; the proposed one-line `#ifdef PORT` fix was actually committed `8dfc578f4` (2026-08-31), well before this correction (2026-09-18). Docs simply never caught up to the merge. |
| D159 | front-end wallet-Bond photo "interlaced"/combed (RC1 / D149) — `texSwapAltRowBytes` odd-row 8-byte pre-swap (N64 RDP odd-line TMEM XOR compensation) not reversed by fast3d | FIXED (`#ifdef PORT` no-op the swap in `image.c`) |
| D161 | Depot (`-level_30`) ceiling = bright-blue speckle + radial rays (B2). A CI8 tile drawn with `gsDPSetTextureLUT(G_TT_NONE)` was decoded against the stale `rdp.palette` → garbage. Fix: `#ifdef`-free narrow route CI→I when `palette_fmt == G_TT_NONE` in `gfx_pc.cpp import_texture()`. | FIXED (`port/fast3d/gfx_pc.cpp`) |
| D165 · D166 | **Input-layer polish (M-31, port-only)**: cursor P-controller + hipfire C-pulse. — full `## D165` / `## D166` entries at file tail | FIXED |
-| D180 · D181 | **Native-PC input pass (QoL run)**: click-to-lock capture + ScreenShakeIntensity hook. — full `## D180` / `## D181` entries at file tail | LANDED, config-gated, feel-checks owed |
-| D186 | **`Video.FpsCap` throttles the sim, not just presentation (M-39 diag, M-42 clamp).** The frame-pacing `sysSleep`+busy-wait in `sync_framerate_with_timer` (`port/fast3d/gfx_sdl2.cpp`) runs inline on the scheduler thread (`osSpTaskStartGo`→`gfx_run`→`swap_buffers_begin`), so a low cap blocks VI-retrace delivery and drags every game thread down to the cap rate (no N64 analogue — RSP/RDP are separate silicon). **Landed:** `0 < FpsCap < 30` → treated as `0` (uncapped) + warning, at both `videoInit` (normalises a bad `ge007.ini` for the next `configSave`) and `gfx_sdl_set_target_fps` (covers the F10-overlay live path); caps ≥ 30 unchanged. Verified: default `FpsCap=0` framediff 3/3 golden-identical; `FpsCap=10` now runs at the uncapped rate; `FpsCap=60` still paces. **Owed:** move pacing off the scheduler thread so any cap only drops presented frames. See §F "D186" + porting-notes.md §E. | CLAMP LANDED (`port/fast3d/gfx_sdl2.cpp`, `port/src/video.c`); real fix owed |
+| D180 · D181 | **Native-PC input pass (QoL run)**: click-to-lock capture + ScreenShakeIntensity hook. — full `## D180` / `## D181` entries at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: LANDED, config-gated, feel-checks owed |
+| D186 | **`Video.FpsCap` throttles the sim, not just presentation (M-39 diag, M-42 clamp).** The frame-pacing `sysSleep`+busy-wait in `sync_framerate_with_timer` (`port/fast3d/gfx_sdl2.cpp`) runs inline on the scheduler thread (`osSpTaskStartGo`→`gfx_run`→`swap_buffers_begin`), so a low cap blocks VI-retrace delivery and drags every game thread down to the cap rate (no N64 analogue — RSP/RDP are separate silicon). **Landed:** `0 < FpsCap < 30` → treated as `0` (uncapped) + warning, at both `videoInit` (normalises a bad `ge007.ini` for the next `configSave`) and `gfx_sdl_set_target_fps` (covers the F10-overlay live path); caps ≥ 30 unchanged. Verified: default `FpsCap=0` framediff 3/3 golden-identical; `FpsCap=10` now runs at the uncapped rate; `FpsCap=60` still paces. **Owed:** move pacing off the scheduler thread so any cap only drops presented frames. See §F "D186" + porting-notes.md §E. | OPEN (2026-09-26, user direction: target an "original" framerate/timing mode, with 60 fps + options on top; pacing must stop throttling the sim). Earlier: CLAMP LANDED (`port/fast3d/gfx_sdl2.cpp`, `port/src/video.c`); real fix owed |
| D187 | **HUD ammo-type/weapon icon vanished at `-O2` (M-46 root-cause, M-47 fix + verify).** — full `## D187` entry at file tail | FIXED (`src/game/gunfire.c`, `#ifdef AVOID_UB`); PR #19 |
| D188 | **Linux `-O2` boot crash: `_Printf` passes `&args` of a by-value `va_list` param (M-48).** — full `## D188` entry at file tail | FIXED (`src/libultrare/libc/xprintf.c`, `src/sprintf.c`, `#ifdef PORT`); PR #22 |
| D189 | **Linux `-fstack-protector` aborts on the decomp's latent stack-buffer overruns (M-48) — FIXED.** — full `## D189` entry at file tail | FIXED (`CMakeLists.txt`, `src/game/stan.c` `#ifdef PORT`); PR #23 |
| D190 | **Linux `-level_45` SIGSEGV at load in `add_ptr_to_objective` (`objective.c:64`) (M-48, open).** — full `## D190` entry at file tail | **CLOSED — not a port bug; level 45 is MP-only and has no solo setup file in the ROM (root-caused M-105, reproduced on Windows).** Full chain… |
| D191 | **Windows `-O2` release-bundle crash: SIGSEGV in `modelGetNodeRwData` (`src/game/model.c:478`) — the `root->Parent` node-tree walk (M-49, user playtests of the…** — full `## D191` entry at file tail | FIXED + PLAYTEST-VERIFIED (M-79).… |
| D193 | **NPC AI locomotion is too slow — characters don't move to their destinations at the original game's rate (M-49, user playtest; clarified after the FULL 21-level…** — full `## D193` entry at file tail | **FIXED + user-playtest-verified (M-81) —… |
-| D194 | **Mouse input needs another tuning pass — RMB aim over-sensitive + sensitivity couples to frame/sim rate (M-49, user playtest).** — full `## D194` entry at file tail | **PARTIAL — M-120 human playtest… |
+| D194 | **Mouse input needs another tuning pass — RMB aim over-sensitive + sensitivity couples to frame/sim rate (M-49, user playtest).** — full `## D194` entry at file tail | FIXED (2026-09-26: user reports the v0.3.0→v0.4.0 mouse changes feel solid and glitch-free; code-side validation owed). Earlier: **PARTIAL — M-120 human playtest… |
| D195 | **Transparency / alpha surfaces broken on Control (M-49, user playtest)** — full `## D195` entry at file tail | **FIXED (`port/fast3d/gfx_pc.cpp`, `gfx_sp_vertex`) —… |
| D197 | **Character face/head textures wrap around the head, seen on Silo (M-49, user playtest).** — full `## D197` entry at file tail | CLOSED — user playtest v0.2.0-pre |
| D198 | **`alAdpcmPull`/`_decodeChunk` used `K0_TO_PHYS` (an unconditional `& 0x1FFFFFFF` mask) on 64-bit heap pointers (`src/libultra/audio/load.c`).** — full `## D198` entry at file tail | FIXED (`src/libultra/audio/load.c`) — part of the Phase-3 mixer landing, not yet build/runtime-verified in isolation |
-| D199 | **Phase-3 software audio mixer: macro-swap `aXxx` opcode execution + reverse-engineered `aSetBuffer` persistent-context semantics (`port/src/mixer.c`…** — full `## D199` entry at file tail | LANDED (`port/src/mixer.c`, `port/include/mixer.h`, `include/PR/abi.h`) — functional on `-level_09`, context-inference risk noted, needs a wider level sweep |
+| D199 | **Phase-3 software audio mixer: macro-swap `aXxx` opcode execution + reverse-engineered `aSetBuffer` persistent-context semantics (`port/src/mixer.c`…** — full `## D199` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: LANDED (`port/src/mixer.c`, `port/include/mixer.h`, `include/PR/abi.h`) — functional on `-level_09`, context-inference risk noted, needs a wider level sweep |
| D200 | **`-level_09` segfault: reverb's `ALDelay.input/output` are u32 — `-d->output` zero-extends to a +4GB pointer offset on 64-bit (D3x class, FIXED).** `src/libultrare/audio/reverb.c` back-references the delay ring as `&r->input[-d->output]`; on N64 (s32 `ptrdiff_t`) the u32 negation wraps to a small negative index, on PC x86-64 it zero-extends to +4294967136 samples → `_loadBuffer`/`_saveBuffer` write 13–52KB wild, trampling the delay array / `r->base` / adjacent heap in a self-propagating loop until unmapped memory. Fixed with `(s32)` negation casts at 5 sites (PD ground truth `n_reverb.c` does exactly this); ramalign line gets `(s64)`. Verified: `-level_09` runs 120 s+ crash-free, DRAM guard window over the FX region sees zero OOB writes. | FIXED — 5-line cast in reverb.c (this session) |
| D196 | **OS mouse cursor stays visible after closing the F10 options overlay in click-to-lock mode (M-49, user playtest).** — full `## D196` entry at file tail | **FIXED (`6e758f1d`, merged to `main`) —… |
| D192 | **Front-end mission/level-select grid pointer still can't reach the outer cells — but only under `Input.MouseCaptureMode=0` (legacy always-grab) (M-49, user…** — full `## D192` entry at file tail | CLOSED — the buggy mode 0 was removed entirely in the v0.2.0 prep pass (see D239); click-to-lock is now the only capture mode, so the unreachable-cells path no longer exists |
| D221 | **SELECT FILE mouse hit-box offset — compiler stack-layout dependency, not coordinate mismatch (M-87 QA / M-91 fix).** — full `## D221` entry at file tail | **FIXED (M-91)** — explicit `{min,max}` `coord2d` pairs under `#ifdef PORT`; human mouse-playtest confirmed. Distinct from D192 (mode-0 clamp range, still open). |
| D222 | **High `Video.FovScale` breaks culling — NPCs and some world objects stop rendering near the screen edges (M-87, user QA).** — full `## D222` entry at file tail | **FIXED, awaiting visual verification (M-121).** Root cause traced one step further than the original write-up… |
| D223 | **QoL ask: mouse-wheel weapon cycling should be directional like a normal PC shooter — wheel up = next weapon, wheel down = previous — instead of both directions…** — full `## D223` entry at file tail | **FIXED (M-105, `port/src/input.c`) —… |
-| D224 | **QoL gap (M-87, from a PD-PC-port survey): the N64 Rumble Pak is fully dropped instead of modernized.** — full `## D224` entry at file tail | **DEPRIORITIZED (M-87, user call).** Gamepad-only —… |
+| D224 | **QoL gap (M-87, from a PD-PC-port survey): the N64 Rumble Pak is fully dropped instead of modernized.** — full `## D224` entry at file tail | **LANDED via D401 (2026-09-27, re-promoted from DEPRIORITIZED).** Rumble Pak now drives real SDL gamepad haptics (`SDL_GameControllerRumble`) via the port-layer `inputRumble*` API + `CONT_CARD_ON`/`PFS_ERR_DEVICE` gating; single global `Input.RumbleScale` (0..1) config + one `Rumble strength` slider row. Real-pad playtest **done 2026-09-28 (Steam Deck: rumble works and is validated; user: include in v0.4.0)**. Gamepad-only —… |
| D225 | **Dev-tooling QoL (M-87, user ask): a debug-build/env toggle that unlocks all missions + all cheats, for testing.** — full `## D225` entry at file tail | **FIXED (2026-09-18)** — `GE_DEBUG_UNLOCKALL=1` short-circuits `fileGetIsCheatUnlocked()` (`src/game/file2.c`, `#ifdef PORT`, cached getenv) to always return TRUE; default off, no save mutation. Build-verified; headless boot with `GE_STARTMENU=7` (mission select) crash-free. |
-| D226 | **QoL ask: player-adjustable HUD scale for the ammo counter, bottom-left pickup/item status text, and top-of-screen dialogue/subtitle text.** — full `## D226` entry at file tail | **OPEN — root cause / call sites identified, not designed or implemented.** Two viable directions… |
+| D226 | **QoL ask: player-adjustable HUD scale for the ammo counter, bottom-left pickup/item status text, and top-of-screen dialogue/subtitle text.** — full `## D226` entry at file tail | **FIXED (2026-09-24)**: Game.HudScale 75–150% (user-verified, capped at 150) via fast3d G_HUDSCALE_EXT rect scaling about per-element anchors; dialogue/bottom messages width-capped. |
| D227 | **Sky renders as two independently-moving halves — "two sky processes" (M-93 QA; root cause M-100b: unit error, `unk20`/`unk24` are S10.5 not texels).** — full `## D227` entries (M-94→M-100b) at file tail | **FIXED (M-100b, PR #43)** — also fixed "scrolls too fast" (M-96) + over-tiling; human play-test confirmed. Full history in the sections. |
| D229 | **IsWater-level water renders green, pulses blue<->green (M-101; Frigate `-level_26`).** — full `## D229` entry at file tail | **FIXED (port-layer)** — ZLIB+CI8 water image now imports via the CI8 palette path (`gfx_pc.cpp`); Frigate verified live ("looks better"); sweep + Surface 2 by-eye still owed. Full trail in the section. |
| D230 | **In-level music quality: a bass instrument sounds corrupted on PC, correct on N64 reference (M-105, user reports; repro refined M-106).** — full `## D230` entry at file tail | **FIXED (M-121, v0.2.0) — RAW16 wavetable byte-order bug, fixed via in-place bswap16 in `romdataFinishCartMap()`; owes a by-ear pass** (Control elevator+track, Cavern elevator, Runway ~5-7s in). |
+| D231 | **QoL batch: mute-on-focus-loss + screenshot-hotkey productization (M-105, roadmap Tier 3, port-only).** `Audio.MuteOnFocusLoss` (drop mixed blocks while the window is unfocused; pause-on-focus-loss deferred because of the D204-family AI feedback loop) plus `Video.ScreenshotKey`/`Video.ScreenshotDir` (scancode-based key, timestamped `screenshots/` PPMs). Implemented M-105 as PR #56 (`port/src/audio.c`, `port/src/video.c`, `port/include/audio.h`), never merged. | **CLOSED — PARKED/DEPRIORITIZED (user, 2026-09-25).** PR #56 closed and the `feat/qol-mute-screenshot` branch was deleted from GitHub. The code is in no release branch; it's kept locally only (local branch `feat/qol-mute-screenshot`, commit `3f6e8d98`, plus a patch in the gitignored `docs/dev/notes/parked/`). If it's revived: rebase onto the current release branch and playtest alt-tab mid-level plus the custom screenshot key/dir. |
| 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…** — full `## D233` entry at file tail | **FIXED (port/fast3d/gfx_pc.cpp, `gfx_sp_tri1`) —… |
| D234 | **Dam: guard towers along the dam structure invisible except the first one (M-106, user QA report).** — full `## D234` entry at file tail | **VISUALLY VERIFIED FIXED (M-108) —… |
| D244 | **Self-hosted CI runner (`corpyvt6agent01`) link failure: `undefined reference to 'objectiveGetStatus_WEAK'` (2026-09-12, first-ever build attempt on the…** — full `## D244` entry at file tail | **OPEN, not yet fixed.** Next… |
@@ -428,9 +429,9 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
| D204 | **PC audio ran ~2 % below real time permanently (AI feedback loop's 3 ms setpoint vs PC jitter).** — full `## D204` entry at file tail | FIXED (`port/src/audio.c`, `port/src/libultra.c`, `port/include/audio.h`) — measured; a by-ear pass on a real playthrough still owed |
| D203 | **Steam Deck (SteamOS, x86_64 Linux) v0.1.0 release bundle: Facility (`-level_34`) crashes "after loading in as James Bond" (user bug report).** — full `## D203` entry at file tail | CLOSED — root cause + fix in D253. |
| D201 | **`src/libultra/audio/bnkf.c` bank/instrument/sound/wavetable relocation offsets were `s32`, truncating the real 64-bit base pointer.** — full `## D201` entry at file tail | FIXED (`src/libultra/audio/bnkf.c`) — part of the Phase-3 mixer landing, not yet build/runtime-verified in isolation |
-| D202 | **Silenced PPK "slap" + broader real-time mixer corruption (M-52→M-67).** — full `## D202` entry at file tail | ROOT CAUSE ESTABLISHED (M-66); Disposition C implemented + measured (M-66b) — stuck loop is sound 203, a faithful N64 quirk; PC-side expiration fades it. M-67 static analysis exhausted. Full trail in the section. |
+| D202 | **Silenced PPK "slap" + broader real-time mixer corruption (M-52→M-67).** — full `## D202` entry at file tail | FIXED (2026-09-26, user by-ear: silenced PPK sounds good, no slap or lingering loop; M-66b expiry + later audio fixes). Earlier: OPEN — by-ear check owed (2026-09-26): fire the silenced PPK a lot, listen for a slap/crackle or a lingering loop. Earlier: ROOT CAUSE ESTABLISHED (M-66); Disposition C implemented + measured (M-66b) — stuck loop is sound 203, a faithful N64 quirk; PC-side expiration fades it. M-67 static analysis exhausted. Full trail in the section. |
| D206 | **PC played every SFX one bank slot too high — pointer-width layout shift in `struct ALInstrumentAlt_s` (M-76).** Root cause of D205 (3)/(4) + D202's "slap". — full `## D206` entry at file tail | **FIXED (M-76, `src/snd.c:1001`, `#ifdef PORT`)** — ABI/layout-only (D3x class); user by-ear A/B vs N64 = PASS (M-77c) → D202 closed. |
-| D207 | **Alarm klaxon starves combat SFX and doesn't recover after it stops (surfaced by D206, M-77).** — full `## D207` entry at file tail | MONITORING (M-77c: user reports the alarm is acceptable in play after D206; no fix shipped) — voice-starvation hypothesis did NOT hold up (M-77b); designed pass reverted. |
+| D207 | **Alarm klaxon starves combat SFX and doesn't recover after it stops (surfaced by D206, M-77).** — full `## D207` entry at file tail | MONITORING — user 2026-09-26: "fixed possibly"; M-201 live captures showed no drops (see D241). Earlier: MONITORING (M-77c: user reports the alarm is acceptable in play after D206; no fix shipped) — voice-starvation hypothesis did NOT hold up (M-77b); designed pass reverted. |
| D208 | **Player weapon-fire sound goes permanently silent mid-firefight (all other SFX unaffected) — `struct` field `bondview.h:200 s32 field_A48` is used as a second…** — full `## D208` entry at file tail | FIXED + user playtest-verified (M-78d) — Bunker firefight: player gunshots persist through the whole engagement and survive the klaxon stopping. Committed 7601b9db (PR #29, merged). |
| D209 | **Every AI character in the game is locked to a *walk* animation regardless of the speed the ailist commands — `act_ubytes.padding[45]`, a raw-byte alias into…** — full `## D209` entry at file tail | **FIXED + user-playtest-verified (M-81).** `chraction.c:3665`, `#ifdef PORT` → `get_sound_at_range(self, self->act_gopos.unk59, ...)`; the N64 line kept verbatim under `#else`.… |
| D210 | **`chrToPatrol()` leaves `act_patrol.lastvisible60` uninitialised on PC — a raw-byte union alias (`act_init.padding[0x13]`) that shifts when `act_patrol` widens…** — full `## D210` entry at file tail | **FIXED (`chraction.c:3872`, `#ifdef PORT` → `self->act_patrol.lastvisible60 = -1;`, N64 line under `#else`).… |
@@ -440,25 +441,25 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
| D168 | **`GE_PCDUMP` / F12 PPM captures were vertically flipped — the entire source of the bogus D114/D116 "HUD/text X-mirror" (M-33, developer-confirmed).** — full `## D168` entry at file tail | FIXED (`port/fast3d/gfx_opengl.cpp`); D114 / D116 reclassified as capture-orientation artifacts (below). |
| D169 | **Front-end mouse pointer can't reach the outer cells of the mission/level-select grid — only an inner ~3×3 selectable (M-33, developer bug report).** — full `## D169` entry at file tail | **FIXED (M-33, `port/src/input.c`, port-only, no `#ifdef PORT`).** The `menuMode` pointer branch now derives its clamp bounds from the live virtual screen —… |
| D178 | **Pre-mission briefing screen: objectives blank / missing, briefing pages blank (also the D143 "briefing text blank" side effect) — FIXED (M-36).** — full `## D178` entry at file tail | **FIXED (M-36)** — high confidence |
-| D175 | **In-game stutter / brief hang during normal play** — full `## D175` entry at file tail | OPEN — observed, not investigated |
+| D175 | **In-game stutter / brief hang during normal play** — full `## D175` entry at file tail | CLOSED (v0.4.0 campaign sign-off 2026-09-28, user: no stutter over the full campaign -- fixed). Earlier: OPEN (user, 2026-09-26: still happens occasionally; keep). Earlier: OPEN — observed, not investigated. **Steam Deck (Linux, 2026-09-27/28): occasional frame-timing hitches at low CPU/GPU (~15–30 %) — a frame-pacing/present profile, not a throughput bottleneck (see entry).** |
| D176 | **Surface exterior renders wrong (`-level_36`), two independent defects.** (a) black sky = RDPHALF no-op, root-caused M-37; scroll-speed FIXED (perspective-correct tc in `skyPortRenderPoly`). (b) cliff/rock walls = grey diagonal static — NOT a decode bug (D183: 0/166 loads strided); re-scope from ROM ground truth of the wall texnum (M-37 inconclusive, leaning tiling-density). — full `## D176(a)` entries at file tail; see also GRAPHICS-BACKLOG.md D176 | **(a) scroll-speed FIXED** (fidelity pass; HIGH confidence on root cause, MODERATE on N64 parity); M-93 playtest + M-98 Defect-2 starburst screenshot in the sections. **(b) open.** |
| D177 | **Ladders non-functional — progression blocker (user QA) — FIXED (M-36): `count`/`rooms` land in the high half of a widened pointer.** — full `## D177` entry at file tail | **FIXED (M-36)** — high confidence; interactive climb test owed |
| D172 | **Bullet-impact / blood / spark particles render magenta or cyan instead of dark red (M-82, ROOT CAUSE FOUND + FIXED).** — full `## D172` entry at file tail | **FIXED (`port/fast3d/gfx_pc.cpp` `gfx_lod_tile_offset`)… |
-| D174 | **"No blood effect" (user QA report).** — full `## D174` entry at file tail | OPEN — observed; first step = BUNKER1 firefight with regenerated sidecars |
-| D183 | **M-36 Family A ("texture line/pitch shear") is DISPROVEN for the Surface repro (`-level_36`).** The importers' pitch assumption is real but never fires there; the Surface cliff "grey static" is not a shear. Full evidence + what the walls actually are: §F "D183" below. A defensive, provably-no-op de-stride landed in `import_texture()` anyway (covers the `gfx_dp_load_tile` case the `SUPPORT_CHECK`s assert against), plus `GE_DTEX` STRIDED marker + `GE_TEXRAW` raw-source dump. | Hypothesis DISPROVEN + diagnostics shipped; D176(b)/D182(2) still OPEN |
+| D174 | **"No blood effect" (user QA report).** — full `## D174` entry at file tail | CLOSED — faithful (2026-09-26, user side-by-side on 1964: blood matches N64). Earlier: Blood renders (user, 2026-09-26); OPEN only for an N64-accuracy comparison on 1964. Earlier: OPEN — observed; first step = BUNKER1 firefight with regenerated sidecars |
+| D183 | **M-36 Family A ("texture line/pitch shear") is DISPROVEN for the Surface repro (`-level_36`).** The importers' pitch assumption is real but never fires there; the Surface cliff "grey static" is not a shear. Full evidence + what the walls actually are: §F "D183" below. A defensive, provably-no-op de-stride landed in `import_texture()` anyway (covers the `gfx_dp_load_tile` case the `SUPPORT_CHECK`s assert against), plus `GE_DTEX` STRIDED marker + `GE_TEXRAW` raw-source dump. | CLOSED (2026-09-26, user-approved: disproven hypothesis; the Surface sky issue it served was fixed under D176). Earlier: Hypothesis DISPROVEN + diagnostics shipped; D176(b)/D182(2) still OPEN |
| D173 | **Third-person Bond model spawns too high** — full `## D173` entry at file tail | **MOSTLY FIXED as a side effect of D243 (2026-09-18, user-confirmed).** User: "9/10 times he's in the right place now, if he's off it's only by a small amount, no more floating in midair or glitching out spinning like before." The M-160/M-165 shared-mechanism hypothesis (D243's animation-state/position desync class) holds for this bug too, confirmed by outcome even though the exact code path was never separately traced for D173 specifically. Small positional-offset residual remains, not investigated further — no more full floating/spin. |
| 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).** — full `## D179` entry at file tail | 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).** — full `## D180` entry at file tail | LANDED, port-only, no `#ifdef PORT`.… |
-| D181 | **`Game.ScreenShakeIntensity` — first route-(b) `src/` gameplay-cosmetic hook (M-XX QoL run).** — full `## D181` entry at file tail | **Menu row pulled (v0.2.1, user testing).** The hook is correct on its own narrow terms —… |
-| D184 | **F10 port-layer options overlay — the approach-(C) surface from `OPTIONS-MENU-PLAN.md` (M-37).** — full `## D184` entry at file tail | **LANDED, port-only, no `#ifdef PORT` in `src/`.** Build links 241/241.… |
-| D211 | **PC FOV slider — `Video.FovScale` (M-83 QoL wave, port-only, `WIDESCREEN-FOV-PLAN.md` Phase 4).** — full `## D211` entry at file tail | **PARTIAL — M-84 refix… |
-| D212 | **Anisotropic-filtering config wiring — `Video.Anisotropy` (M-83 QoL wave, port-only).** — full `## D212` entry at file tail | **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).** — full `## D213` entry at file tail | **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).** — full `## D214` entry at file tail | **PARTIAL — M-84: code PASS (alt/ctrl); one doc-only fix.** LANDED, port-only, no `#ifdef PORT`; build clean.… |
+| D180 | **Native-PC input pass (M-XX QoL run, `qol/native-pc-input-menu`, port-only).** — full `## D180` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: LANDED, port-only, no `#ifdef PORT`.… |
+| D181 | **`Game.ScreenShakeIntensity` — first route-(b) `src/` gameplay-cosmetic hook (M-XX QoL run).** — full `## D181` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: **Menu row pulled (v0.2.1, user testing).** The hook is correct on its own narrow terms —… |
+| D184 | **F10 port-layer options overlay — the approach-(C) surface from `OPTIONS-MENU-PLAN.md` (M-37).** — full `## D184` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: **LANDED, port-only, no `#ifdef PORT` in `src/`.** Build links 241/241.… |
+| D211 | **PC FOV slider — `Video.FovScale` (M-83 QoL wave, port-only, `WIDESCREEN-FOV-PLAN.md` Phase 4).** — full `## D211` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: **PARTIAL — M-84 refix… |
+| D212 | **Anisotropic-filtering config wiring — `Video.Anisotropy` (M-83 QoL wave, port-only).** — full `## D212` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: **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).** — full `## D213` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: **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).** — full `## D214` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: **PARTIAL — M-84: code PASS (alt/ctrl); one doc-only fix.** LANDED, port-only, no `#ifdef PORT`; build clean.… |
| D215 | **1P weapon viewmodel never drew in-level — `gunRenderFirstPersonGunModels` builds its `ModelRenderData` from a reinterpret across separately-declared adjacent…** — full `## D215` entry at file tail | **FIXED (`src/game/gunfire.c`, `#ifdef PORT`).** Build clean.… |
-| 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).** — full `## D216` entry at file tail | **LANDED + VERIFIED this session.** Build clean.… |
+| 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).** — full `## D216` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: **LANDED + VERIFIED this session.** Build clean.… |
| D217 | **1P weapon viewmodel + 3P Bond model textures garbled — wrong/stale palette on model-DL CI textures (M-84).** Two defects: ①texpool arena misalignment (the green), ② CI texture-cache collision. — full `## D217` entries (M-86→M-97) at file tail | **FIXED + merged** (PR #47 + PR #49); white residual split out as D228. |
-| 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).** — full `## D218` entry at file tail | **Logged (M-84), fix proposed + parked.** Plan… |
+| 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).** — full `## D218` entry at file tail | FIXED (2026-09-26 reclassified: the proposed fix shipped as Video.DrawDistance + DrawDistanceAutoFov, default 150). Earlier: **Logged (M-84), fix proposed + parked.** Plan… |
| D219 | **PPK explosions render purple on Dam (M-87, user QA, "not 3D but noting").** — full `## D219` entry at file tail | **RESOLVED (M-115, 2026-09-12)** for the original PPK-purple symptom. **The adjacent bullet-spark-rainbow investigation logged under this label (M-157/M-158) is also FIXED (2026-09-18 session)** — `glass2.c`'s `bullet_spark_render` read the spark's RGBA colour via a hardcoded raw byte offset instead of the named `s_bullet_spark` struct fields, landing in the wrong memory on 64-bit PC; fixed by reading the named fields. User live-confirmed. D252's duplicate "lingering particle residue" report closes as the same bug (M-188). |
**M-111 (2026-09-12, live session, 6 build/relaunch cycles): G_LIGHTING lead RULED OUT; texture-cache-collision lead RULED OUT; ROOT CAUSE FOUND — R/B channel swap in the decoded fire particle texture data itself.** Live-captured 4 rounds against a real user playtest (not scripted) with the user confirming explosions were still blue/purple in every capture:
@@ -472,14 +473,14 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
2. **Confirmed empirically, not just by inspection:** dumped raw decoded RGBA5551 words at the exact point `texChannelsToPixels`/`texInflateLookup` write them (`GE_D219RAW=1`, Dam `-level_33`, scripted PPK fire via `GE_INPUTSCRIPT`) — e.g. pre-fix texnum 2111 decoded word `0xf1c2` (R=30,G=3,B=1 under standard RGBA5551 — briefly looks *plausible* red until you realize this is the value BEFORE the byte-swap fast3d applies; the two-stage mis-cancellation is what produced the reported blue/purple, not a one-step swap — see the fix verification below for the real signal). The conclusive test is the rendered/uploaded texture, not the intermediate value in isolation.
3. **Fix (`src/game/image.c`, `#ifdef PORT`/`#else` macro, no `src/` logic changed):** added `PORT_PIXEL16`/`PORT_PIXEL32` — `__builtin_bswap16`/`32` under `#ifdef PORT`, identity under `#else` (so the N64 build is byte-for-byte unaffected, verified by inspection: the macro is a no-op expansion, not a code path change) — and wrapped every wide-pixel (`RGBA32`/`RGB24`/`RGBA16`/`IA16`/`RGB15`) store in the five functions listed above. CI/I4/I8/IA4/IA8 branches (single-byte, no byte order) are untouched. This is the D3x/AGENTS.md#2 narrow ABI/byte-layout exception class: mechanical, semantics-preserving (same numeric color, corrected byte order for the LE host), scoped to exactly the format cases that need it.
4. **Verified, numerically and visually:** `GE_TEXDUMP=1` PPM capture of the 15 `IMAGE_FIRE_N` textures post-fix — average RGB flipped from B-dominant (M-111: `fire000 (102,67,138)`) to R-dominant (post-fix `fire000 (141,127,53)`, `fire003 (147,81,24)`, `fire005 (47,10,2)`); a 10x-upscaled render of one frame and a 6-frame animation strip (frames 000-006, session scratchpad) show a clean, unambiguous fire-sprite animation — bright yellow-white core cooling through orange to red/black ash, exactly the expected in-game fire look, no purple/blue anywhere. Sanity-checked a montage of 12 ordinary (non-fire) world textures from the same Dam capture (clouds, hazard stripes, pipes, grating, concrete) — all render correctly, no regression from the format-gated change. `-level_09` (BUNKER1) and `-level_33` (Dam) both boot and render crash-free post-fix (10s+ each, no `ge007.crash.log`). **Not yet done:** a full 21-level `verify.sh sweep` and a genuine live human playtest confirming the fix in real (non-scripted) gameplay — the scripted-fire + texdump evidence here is strong but the row should stay open until a human eyeballs a real firefight on this build. `docs/porting-notes.md` gets a new entry for this bug class (runtime-decoded multi-byte pixel formats need explicit byte-order handling; static C-array textures already get this via D71/`gfx_tex_normalize_source`, runtime-decompressed ones did not). **M-114 — REGRESSION, reverted.** User playtest after M-113 landed reported 1P muzzle flash rendering blue and the ammo HUD counter rendering pink — colors inverted on textures that were fine before. Root cause: M-113's `PORT_PIXEL16`/`PORT_PIXEL32` swap is applied unconditionally to every call site in `texReadUncompressed`, `texChannelsToPixels`, `texInflateLookup`, and `texInflateLookupFromBuffer` — i.e. to every RGBA16/RGB15/IA16/RGBA32/RGB24 texture decoded through these shared functions, not just `IMAGE_FIRE_N`. This is the exact failure mode already logged above against the sibling function ("do NOT attempt a blanket R/B swap in `import_texture_rgba16` — it's shared by the whole game and would break every correctly-rendering RGBA16 texture"), just repeated one file over. Muzzle flash and the ammo HUD icon evidently decode through this same shared path without needing the swap. **Fix applied:** `PORT_PIXEL16`/`PORT_PIXEL32` reverted to identity (no-op) in `src/game/image.c`; the macro/call-site wrapping is left in place as scaffolding. **D219 is OPEN again** — the fire-particle color bug is unfixed. **Next, concrete, not yet done:** find the actual property that distinguishes `IMAGE_FIRE_N`'s decode from a working same-format sibling (M-112 already narrowed one candidate: FIRE is baked directly into a DL rather than going through the `sImageTableEntry` table that flare/ammo/impact sprites use — confirm whether that DL-embedded path is what needs the swap, or whether it's specifically `compmethod`, per M-112's still-open next step) and scope the macro to that property, not to a hardcoded texnum range. Not runtime-verified this session (static revert only, done to stop an active regression fast) — a human should confirm muzzle flash/ammo colors are back to normal on this build. **M-115 (2026-09-12, delegated session, 3 build/run cycles): fix RE-APPLIED, scoped by GBI pixel format — the M-114 regression resolved by construction, fire fixed again.** Per M-114's brief, found the property that distinguishes `IMAGE_FIRE_N`'s decode from working same-format siblings and scoped the macros to it. (1) **The discriminator is the decoded pixel FORMAT, not compmethod or texnum.** Full offline census of all 2698 ROM image-table entries (`scratch/d219_headers.py`, parsing real bitstream headers with the same MSB-first `texReadBits` contract as `src/game/image_bank.c`; zlib images skipped after the 2-byte `rz` preamble per `decompressdata`): only 94 non-zlib wide-pixel images exist — RGBA16=19, RGB15=14, IA16=1, RGBA32=60. The fire frames span compmethods RLE(4)/LOOKUP(5)/RLELOOKUP(7)/HUFFMANBLUR(8) (confirmed offline AND at runtime), so compmethod cannot scope the fix; a texnum range is a hardcoded landmine. Format IS structural: every 16-bit GBI-size format (RGBA16/RGB15/IA16) is consumed by importers that read the pool as **manually big-endian bytes** (`import_texture_rgba16`: `col16=(addr[0]<<8)|addr[1]`; `import_texture_ia16`: `intensity=addr[0], alpha=addr[1]`), while every 32-bit format (RGBA32/RGB24) is consumed by `import_texture_rgba32`, which does `PD_BE32()` on a **native** u32 load — it expects host-order words. So bswap-at-store is required for exactly the 16-bit family and must be identity for the 32-bit family; M-113's blanket swap hit both (M-114), M-114's full revert broke the 16-bit family again. (2) **Change (`src/game/image.c`, `#ifdef PORT` only, no call-site edits):** `PORT_PIXEL16(x)` = `__builtin_bswap16((unsigned short)(x))`; `PORT_PIXEL32(x)` = `(x)`. Every existing call site already routes by `TEXFORMAT_*` to the right macro, so M-114's scaffolding was already format-scoped per site — only the macro definitions were wrong (both bswapped). 30-line comment at the definition records the importer contract + census. (3) **Runtime verification (Dam `-level_33`, scripted Z-pulse fire via `GE_INPUTSCRIPT`, `GE_D219RAW=1` + `GE_TEXDUMP`/`GE_TEXRAW`):** all 15 fire frames decode at runtime as format=1 (RGBA16) with compmethods 4/5/7/8 exactly matching the offline census (texnum 2085 is genuinely an nc=1 LOOKUP transparent first frame — A=0 everywhere; the visible frames are 2087–2113); the raw pool dumps decode as warm RGBA5551 sprites — mean RGB over opaque texels **(255,238,139)**, R-dominant, correct vertical-blob shape (rows 3–10 of the 16×14 tile); e.g. texnum 2111's pool word is now `0xc2f1`, which the importer reads back as `0xf1c2` = R30,G3,B1 — the true decoded value M-113 recorded pre-fix, i.e. exactly the pool state whose rendered PPMs M-113 proved correct (mean 141,127,53, clean animation). (4) **Regression safety is structural, not empirical:** RGBA32/RGB24 pool bytes are bit-identical to the M-114-revert build (`PORT_PIXEL32` = identity in both), so muzzle flash / ammo HUD / flares / crosshairs — all RGBA32 — render exactly as in the state the user last confirmed fine; no 32-bit path was touched at all. The other non-zlib 16-bit images (texnum 1198–1201 RGBA16, 2430 IA16, 2510–2523 RGB15) were equally byte-reversed before this change and are now corrected by the same importer contract; they do not load during a Dam run, so their on-screen state is owed to the next human playtest in whatever level uses them. (5) **Probes left in tree** (env-gated, inert): `GE_D219RAW` generalized in `texInflateNonZlib` — header line for every non-zlib wide-pixel decode (`format <= TEXFORMAT_IA16`), first-row pixel dump for RGBA16/RGB15/IA16, no texnum filter; plus the pre-existing D172/D219 and `GE_TEXDUMP` fire-dumper probes. **Status: RESOLVED (2026-09-12)** — user playtested this build and confirmed it works (fire correct, no muzzle-flash/ammo-HUD regression); D219 closed. |
-| D220 | **1P muzzle flash position shifts upward at higher `Video.FovScale` (M-87, user QA, Jungle/M16).** — full `## D220` entry at file tail | **ANALYZED (M-91) — the suspected unscaled-viewmodel root does NOT hold; this is correct perspective behavior, likely cosmetic/QoL.** The 1P hand + flash are model nodes in ONE gun DL (`gunfire.c` builds `rwmtx[1..4]` for hammer/flash/billboard nodes) drawn through the single per-frame projection from `viSetupCurrentPlayerView` —… |
-| D85 | **Room primary/secondary DL → `texLoadFromGdl` garbage.** — see §H (D85 entries: root cause + widen/texture-pool fixes) | OPEN (safety-netted; widen/pool fixes landed, geometry now renders) |
+| D220 | **1P muzzle flash position shifts upward at higher `Video.FovScale` (M-87, user QA, Jungle/M16).** — full `## D220` entry at file tail | CLOSED — merged into D303 (2026-09-26, user: the M16/AR15 muzzle flash rises regardless of FOV, so the FOV framing was wrong; tracked as D303 with a 1964 comparison). Earlier: **ANALYZED (M-91) — the suspected unscaled-viewmodel root does NOT hold; this is correct perspective behavior, likely cosmetic/QoL.** The 1P hand + flash are model nodes in ONE gun DL (`gunfire.c` builds `rwmtx[1..4]` for hammer/flash/billboard nodes) drawn through the single per-frame projection from `viSetupCurrentPlayerView` —… |
+| D85 | **Room primary/secondary DL → `texLoadFromGdl` garbage.** — see §H (D85 entries: root cause + widen/texture-pool fixes) | CLOSED (2026-09-26, user-approved: widen + texture-pool fixes landed long ago, safety net in place, no current symptoms). Earlier: OPEN (safety-netted; widen/pool fixes landed, geometry now renders) |
| D86 · D87 | **modelInitRwData truncated ptr · attract-demo BE `ramromfilestructure`.** — see §H (D86, D87) | resolved |
| D88.1–D88.3 · D88.5–D88.6 | `Usetup*Z` header + sub-table width/endian conversion (`d88_emit.py`) | resolved |
| D88.4 | **`propDefs` polymorphic record stream not byteswapped → `setupDoor` crash.** — see §H (D88.4) + `## D132` | resolved (`d88_propdefs.py`); layout audit M-20 → **D132** |
| D132 | D88 propDefs layout audit (M-20): converter cursor confirmed vs real PC struct layout for all types the 21 levels emit. DOOR/OBJECT-prefix/VEHICHLE/AIRCRAFT/TANK/AUTOGUN/AMMO/TINTED_GLASS/objective sub-records all MATCH. **Divergence found:** types 14 LINK / 19 SWITCH / 38 LOCK_DOOR / 44 SAFE_ITEM — each has `s32 IndexN` fields sharing a union with a pointer (`LinkRecord.first`/`Index1`), so on PC the field sits in an 8-byte, 8-aligned union slot, but the converter emits it at N64 tight-4-byte-word offsets → `pdef->Index1` reads `Index2`'s value, `Index2` reads 0 → switch-doors / dual-weapons / locked-doors / safes silently fail their validity guard (non-crash; guard failure also prevents the under-sized-record `->next` overflow). | proposed fix (not applied) — see below |
| D157 | **Campaign never unlocks the next level (M-30, user bug — "complete Dam on Agent, Facility stays locked, no save").** — full `## D157` entry at file tail | FIXED (`src/bondtypes.h`, `#ifdef PORT` —… |
-| D160 | **Dam level-end cutscene (Bond off the dam / bungee) never plays — cuts straight to the MISSION COMPLETE report (M-30/M-31, user bug D148).** — full `## D160` entry at file tail | DIAGNOSTIC SHIPPED, not root-caused (`c95713f5`, `#ifdef PORT` + `GE_D160=1`)… |
+| D160 | **Dam level-end cutscene (Bond off the dam / bungee) never plays — cuts straight to the MISSION COMPLETE report (M-30/M-31, user bug D148).** — full `## D160` entry at file tail | FIXED (user, 2026-09-26: the Dam ending cutscene plays since v0.3.0). Earlier: DIAGNOSTIC SHIPPED, not root-caused (`c95713f5`, `#ifdef PORT` + `GE_D160=1`)… |
| D161 | **Depot (`-level_30`) ceiling renders as bright-blue speckle + radial "light-ray" streaks converging to a point instead of a dark corrugated roof (M-30/M-31…** — full `## D161` entry at file tail | FIXED (`port/fast3d/gfx_pc.cpp` `import_texture`, `port/fast3d/gfx_opengl.cpp` diag). porting-notes.md §D. |
| D89–D92 | stage-load→frame: stan zero-fill overrun, portal address trunc, chr/AI spawn ptrs | resolved |
| D93–D102 | struct-pun / hardcoded-size pass: player alloc, player.model inline Model, weapon Model pun, master-DL buffer | resolved (D95 pool bump partial) |
@@ -489,20 +490,20 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
| D117 | **Frame nondeterminism root-caused; `GE_DETERM` attempted, not achieved.** — see §H (D117 M-8 root cause + M-52 addenda) | OPEN (mode attempted, not achieved) |
| D118 | **SDL input layer; M-24 mouse-look rework (mode-aware map, real INI); D118b/c FIXED.** — see §H (D118 + M-24 entries) | resolved (D118a residual) |
| D150 | watch OBJECTIVES / BRIEF page crash: `strcat(buf, langGet(id))` with `langGet` → NULL (unloaded bank) → NULL deref; `str.c` str* builtins elide a plain param NULL check, so guard via asm-laundered `GE_IS_NULL` | FIXED (`src/str.c` `#ifdef PORT`); interactive re-verify pending |
-| 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 (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 (the wall itself) — probes live in tree (`GE_D266`/`GE_D266W`/`GE_D267`/`GE_D268_FORCEA`); the D266 decode + D271 portal-culling fixes **landed** (`532092c7`, M-139 R2 extraction) |
-| D270 | **Backfilled (M-132) — this label was never given its own row; only code comments and the HANDOFF archive documented it.** — full `## D270` entry at file tail | dead-ended into D271/D272; infrastructure kept in tree |
+| 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 | CLOSED (2026-09-26 bookkeeping: v0.2.1 hotfix batch shipped; D265 is a D236 sub-pass, closed M-198). Earlier: 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 (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 | CLOSED (2026-09-26 bookkeeping: sub-pass of D236, which closed in M-198). Earlier: OPEN (the wall itself) — probes live in tree (`GE_D266`/`GE_D266W`/`GE_D267`/`GE_D268_FORCEA`); the D266 decode + D271 portal-culling fixes **landed** (`532092c7`, M-139 R2 extraction) |
+| D270 | **Backfilled (M-132) — this label was never given its own row; only code comments and the HANDOFF archive documented it.** — full `## D270` entry at file tail | CLOSED (2026-09-26 bookkeeping: sub-pass of D236, which closed in M-198). Earlier: 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; the D106 guard was the cause (M-129).** — full `## D271` entry at file tail | FIXED-but-insufficient (`src/game/bg.c` `#ifdef PORT`) — **landed** `532092c7` (M-139 R2 extraction, probes stripped); wall still open, see D236 |
-| D276 | **D236 wall, eighth pass — the true painter of the visible weave is a fully-opaque IA8 blocky-noise texture, not either of D269's named CI8 classes (M-134).** — full `## D276` entry at file tail | 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`…** — full `## D279` entry at file tail | 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…** — full `## D281` entry at file tail | 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). |
+| D276 | **D236 wall, eighth pass — the true painter of the visible weave is a fully-opaque IA8 blocky-noise texture, not either of D269's named CI8 classes (M-134).** — full `## D276` entry at file tail | CLOSED (2026-09-26 bookkeeping: sub-pass of D236, which closed in M-198). Earlier: 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`…** — full `## D279` entry at file tail | CLOSED (2026-09-26 bookkeeping: sub-pass of D236, which closed in M-198). Earlier: 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…** — full `## D281` entry at file tail | FIXED (2026-09-23, port/src/libultra.c): the AllUnlocked patch now reproduces the game's fresh-slot result (folder saves with DEFAULT_OPTIONS + one free slot) instead of five folder-1 slots with options 0 (no aim sight). Verified by fresh-eep SAVELOG; audio half **confirmed 2026-09-28 (campaign, user: good)**. v0.4.0 decision (user-delegated, 2026-09-28): keep the shipped mitigation + README disclosure; the zero-saves root cause (sndHandleEvent/aim interaction) is v0.5.0. |
| 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 —…** — full `## D282` entry at file tail | 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…** — full `## D283` entry at file tail | 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; the IA8 noise class VISUALLY confirmed as the weave's painter (M-135).** No quick win for trees. — full `## D280` entry at file tail | REFRAMED again — no fix; next steps in detail section, see D236 |
-| D275 | **D236 wall, eighth pass — probe upgrades (`GE_D270PIX` list, `GE_D270KEEPOML` counter) (M-133).** — full `## D275` entry at file tail | done — probes live, uncommitted; see D276 for what they found |
-| D274 | **D236 wall, seventh pass — probe identity (`seq`) unreliable; per-slot address stamp. Content-vs-decode question was a bug in our own offline analysis (M-132).** — full `## D274` entry at file tail | `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 — N64 ground-truth screenshot obtained; `GE_D273` palette-decode A/B RULED OUT (M-131).** — full `## D273` entry at file tail | 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 RULES OUT "identical UV window" + converter bug; cards sample a wide authored UV range (M-130).** — full `## D272` entry at file tail | OPEN — probe `GE_D272` added to `port/fast3d/gfx_pc.cpp` (env-gated, uncommitted); wall still open, see D236 |
+| D280 | **D236 wall, ninth pass — fog math VERIFIED N64-faithful; the IA8 noise class VISUALLY confirmed as the weave's painter (M-135).** No quick win for trees. — full `## D280` entry at file tail | CLOSED (2026-09-26 bookkeeping: sub-pass of D236, which closed in M-198). Earlier: REFRAMED again — no fix; next steps in detail section, see D236 |
+| D275 | **D236 wall, eighth pass — probe upgrades (`GE_D270PIX` list, `GE_D270KEEPOML` counter) (M-133).** — full `## D275` entry at file tail | CLOSED (2026-09-26 bookkeeping: sub-pass of D236, which closed in M-198). Earlier: done — probes live, uncommitted; see D276 for what they found |
+| D274 | **D236 wall, seventh pass — probe identity (`seq`) unreliable; per-slot address stamp. Content-vs-decode question was a bug in our own offline analysis (M-132).** — full `## D274` entry at file tail | CLOSED (2026-09-26 bookkeeping: sub-pass of D236, which closed in M-198). Earlier: `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 — N64 ground-truth screenshot obtained; `GE_D273` palette-decode A/B RULED OUT (M-131).** — full `## D273` entry at file tail | CLOSED (2026-09-26 bookkeeping: sub-pass of D236, which closed in M-198). Earlier: 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 RULES OUT "identical UV window" + converter bug; cards sample a wide authored UV range (M-130).** — full `## D272` entry at file tail | CLOSED (2026-09-26 bookkeeping: sub-pass of D236, which closed in M-198). Earlier: 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:** — full `## D137` entry at file tail | FIXED (`src/game/gunfire.c` `#ifdef PORT` — `sp54` is `Gfx *`) |
| D119 · D120 | guard-attack `weapons_held[]->chr` pun crash · blood-stain `PointUsage[]` chain hang | D119 fixed · **D120 FIXED (M-30)** |
| D120 | **Blood-decal `PointUsage[]` chain — converter now emits it (M-30).** — full `## D120` entry at file tail | FIXED (`tools_pc/d43_emit.py`). Interactive verify pending — blood decals on a BUNKER1 firefight should now appear + no hang. |
@@ -522,10 +523,10 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
| D118d | **Watch-menu list over-scrolls with keyboard (M-27, user bug report).** `game_options_inventory_navigation` / `sub_GAME_7F0A611C` (`options.c`) have a "slam the stick" fast-scroll: a raw *level* check `joyGetStickY(PLAYER_1) < -0x46` / `>= 0x47` that steps `watch_inventory_cursor_pos` by 1 **every frame** it's held. An N64 stick rarely sustains past ~0x46 on-axis so a human taps it; keyboard W/S (and SDL pads pegged at 0x80) sit there every frame → one keypress skips several items (breaks item selection needed to finish the game). Fix: `#ifdef PORT` macro `GE_WATCH_STICK_FAST{UP,DOWN}` → `0` for the port — stick list-nav then goes only through the latched single-step path (`watch_stick_y_pressed_*`, one step per press, existing game mechanism) and the -0x1e..-0x45 smooth-scroll band; D-pad/C-buttons keep edge-repeat. N64 `#else` = verbatim. | FIXED (`src/game/options.c`, `#ifdef PORT`). Build green, `-level_09` unregressed. **Feel to be confirmed in playtest** (probe couldn't reach the INVENTORY watch page headlessly). D118 input family. Latent sibling: `front.c` menu nav uses `joyGetStick*InRange` level checks the same way. |
| D141 | **Watch-menu item-model crash (M-27), exposed by D140.** — full `## D141` entry at file tail | FIXED (`src/game/gunfire.c`, `#ifdef PORT`). Watch page now renders (weapon page verified — mirrored text D114/D116, dark weapon model D75, both parked cosmetic). `-level_09`/`-level_20` unregressed. |
| D150 | **Watch objective/briefing page crash (M-28).** — full `## D150` entry at file tail | FIXED (`src/str.c`, `#ifdef PORT`). Build green (`ntsc-final`); `-level_09` 600+ frames @ 91.67% unregressed. Watch-page repro is interactive — user to re-verify. porting-notes.md §C. |
-| D152 | **Mission-failed / objective-failed → permanent black screen (M-28, user bug report — LIVE process inspected, not yet fixed).** — full `## D152` entry at file tail | MITIGATED (`port/src/libultra.c`, M-28, `#ifdef PORT`/port-only).… |
+| D152 | **Mission-failed / objective-failed → permanent black screen (M-28, user bug report — LIVE process inspected, not yet fixed).** — full `## D152` entry at file tail | FIXED (user, 2026-09-26: no black screen after mission/objective failure). Earlier: MITIGATED (`port/src/libultra.c`, M-28, `#ifdef PORT`/port-only).… |
| D152+ | **D152 addendum — static audit + narrow fix (M-31).** — full `## D152+` entry at file tail | FIXED (`src/snd.c`, `port/src/libultra.c`, `#ifdef PORT`). porting-notes.md §D4. |
| D154 | **`bg.c` room-geometry ray/hit-test GBI parser port (M-28, WRITTEN / UNVERIFIED / UNCOMMITTED).** — full `## D154` entry at file tail | **M-30 re-audit + fixes (still playtest-gated for final verify).** Cross-checked the ported G_TRI1 + all 12 G_TRI4 nibble recoveries twice against the N64 `#else` and the raw BE-word bit derivation —… |
-| D156 | **Facility outro-cutscene "hang" — the actual fix (M-29, user bug report, 2nd occurrence after D155).** — full `## D156` entry at file tail | GUARDED (`src/game/model.c`, `#ifdef PORT`)… |
+| D156 | **Facility outro-cutscene "hang" — the actual fix (M-29, user bug report, 2nd occurrence after D155).** — full `## D156` entry at file tail | FIXED (user, 2026-09-26: no outro hang seen). Earlier: GUARDED (`src/game/model.c`, `#ifdef PORT`)… |
| D155 | **Facility outro-cutscene end → "hang" = unclamped `deltaFrames` spiral (M-29, user bug report).** — full `## D155` entry at file tail | FIXED (`src/game/frametiming.c`, `#ifdef PORT` —… |
| D153 | **`-level_09` frame ~900–1400 `0xc0000005` = D117/D134 load flakiness, NOT a bug (M-28).** — full `## D153` entry at file tail | NOT A BUG — closed. Run anchors on a quiet machine. |
| D151 | **Watch data all blank — objectives / mission background / M / Q / Moneypenny (M-28, user bug report).** — full `## D151` entry at file tail | FIXED (`src/bondtypes.h`, `#ifdef PORT` —… |
@@ -538,23 +539,23 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
| D166 | **Hipfire mouse pitch is digital while yaw is analog — inconsistent feel (user bug B7 / D118a, "cheaper alternative").** — full `## D166` entry at file tail | FIXED (`port/src/input.c`).… |
| 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…** — full `## D233` entry at file tail | **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).** — full `## D234` entry at file tail | **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).** — full `## D235` entry at file tail | 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 | **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).** — full `## D236` entry at file tail | OPEN, sixteenth pass (2026-09-18) — new `GE_D236RAW` headless dump PROVES every room-background static vertex (all ~40 rooms sampled) is alpha=255 on load, ruling out "static ROM-authored low alpha" outright; a `vtxseg` extension to `GE_D236ALPHA` shows the tree class's vertices come from a raw/unsegmented pointer (not `SPSEGMENT_MODEL_VTX`/`BG_VTX`), consistent with a dynamically-built quad — but all 4 known `model.c` dynamic-quad builders (gunfire/shadow/rottex/tube) and propobj.c's screen-sprite builder were read in full and ruled out by content/addressing mismatch. The actual write site remains genuinely unidentified after a real search; next step is a stack-backtrace-style probe, not more signature-matching. Not v0.3.0-blocking, user is continuing anyway. |
-| 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…** — full `## D237` entry at file tail | OPEN — not designed; port-only |
-| D238 | **QoL ask (M-106, user QA): mouse sensitivity rework toward normal PC-shooter feel.** — full `## D238` entry at file tail | OPEN — not designed; port-only (`port/src/input.c` + `config.c`) |
+| D235 | **Wrong textures load after dying/restarting a level — not Facility-specific, seen on multiple levels (M-106, user QA report; clarified same session).** — full `## D235` entry at file tail | CLOSED (user, 2026-09-26: not seen since the v0.3.0–v0.4.0 work; fix wired; reopen if it recurs). Earlier: OPEN — fix wired, headless reload clean (2026-09-23); NOT reproduced with the fix off (headless) nor in the 2026-09-25 user playtest (die → mission select → restart). Keep the fix; close if it stays unreproduced through the release playtests. |
+| 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).** — full `## D236` entry at file tail | **CLOSED (M-198, pass 26, `425237f9`; probes stripped `0503d335`): root cause = `TEXTURETYPE_DETAIL` tile binding — fast3d drew the detail tile (opaque IA8 noise) instead of the base image; `gfx_lod_tile_offset` now samples tile 1 when the pair differs (`Video.DetailBaseTile`, default on). Live-verified Surface 1/2 + Depot; awaiting user play-test sign-off.** Historical trail — pass 24 (2026-09-21, static) DECODED THE TREE TEXTURE** (new `tools_pc/texdecode.py` ports image.c's LOOKUP/HUFFLOOKUP/RLELOOKUP codecs). **`tex1198` LOD 0 = 64×17 RGBA5551, 44.2% opaque, and its 1-bit alpha renders as an unmistakable treeline silhouette** — pass 23's identification confirmed by decode, not inference. **Rare authored a six-level LOD pyramid that PRESERVES the silhouette and its alpha coverage (44.2% → 43.1% → 38.8%), each level a separately-authored image with its own compmethod.** Since `XLU_SURF` has no alpha test (pass 22), averaging that 0/1 alpha into fractional mip alpha would smear the minified treeline into exactly the reported band — so 'authored LOD chain replaced by driver-generated mips' is now the leading mechanism, with hard ground truth behind it. Corrects pass 23: only `tex1198` is a treeline (`tex1199` 89.4% opaque denser foliage; `tex1200`/`tex1201` 100% opaque detail textures sharing the class). Two decoder bugs found while porting (missing per-image byte-align, `image.c:1096`; a GUESSED rather than ported RLE fudge) — both produced confident wrong pictures. **Pass 25 (live) answered all three questions: (1) YES 64×17 RGBA16 intact, pool ≡ offline decode; (2) YES alpha survives (44.2% opaque); (3) NO — no code path uploads authored chains as GL mips.** New: `tex1198`–`1201` are **never drawn from an idle spawn** (zero RGBA16 imports in 4800 frames) — the spawn wall is painted by other classes (cf D276/D280); the drawn tree-class card is **`tex149` IA8 32×64 hard cutout, byte-exact import, no authored chain**. Mip mechanism located: `texGetDepthAndSize` sizes LOADBLOCKs from the header `lods` field even for single-image textures (live A/B: 2808 B vs 2048 B real data), and the port's RC2 clip + `glGenerateMipmap`/trilinear (observed live) box-filters hard 0/F alpha into fractional mips → XLU smear. Pass 26 fix directions in entry (cheapest: no mip gen for no-chain textures). |
+| 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…** — full `## D237` entry at file tail | FIXED (2026-09-23): category headers + 5 more settings (SkipIntro, Show FPS, pad invert/deadzone/trigger); wheel scroll already existed. Hands-on pass owed. |
+| D238 | **QoL ask (M-106, user QA): mouse sensitivity rework toward normal PC-shooter feel.** — full `## D238` entry at file tail | FIXED (2026-09-26: covered by D337/D338 aim work; user reports mouse feel good; code-side validation owed). Earlier: 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.** — full `## D239` entry at file tail | CLOSED — executed in the v0.2.0 prep pass (port-only)… |
-| D240 | **Player gunshot SFX timing/cadence reportedly does not match the N64 original's rate (M-106, user QA report).** — full `## D240` entry at file tail | **OPEN — not root-caused; static call-site tracing only, no runtime verification possible in this environment.** Confidence LOW-MEDIUM that this is a *new* bug distinct from the D155/D186/D193/D204/D209 timing-keystone family (all of which gate the identical `deltaFrames→speedgraphframes→g_ClockTimer→g_GlobalTimer` chain this SFX trigger consumes).… |
-| D241 | **Player gunshot SFX goes silent under some conditions — reported on Bunker with the alarm sounding, "or some other factor" — but only for PP7 and AK47, Klobb…** — full `## D241` entry at file tail | **OPEN — not newly root-caused; high confidence this is D207 reproduced, not an independent bug.** Recommend folding this repro into D207 (or closing D241 as a duplicate once D207 is reopened) rather than tracking separately.… |
-| D242 | **Player-death sequence UX regression vs N64 original: PC requires an LMB click to proceed to the mission-failed/file-select screen; user recalls N64 auto-plays…** — full `## D242` entry at file tail | **NOT A PORT-LOGIC BUG as far as static analysis can tell.** Confidence HIGH that the LMB-click skip path is faithful original behavior (Z_TRIG-mapped, exists unmodified in `bondview2.c`), not a defect to remove.… |
+| D240 | **Player gunshot SFX timing/cadence reportedly does not match the N64 original's rate (M-106, user QA report).** — full `## D240` entry at file tail | **CLOSED (v0.4.0 campaign sign-off 2026-09-28, user: cadence drift not heard over the full campaign -- fixed). Never root-caused -- if re-reported, the D155/D186/D193/D204/D209 timing-keystone family is the prime suspect.** Earlier: OPEN — not root-caused; static call-site tracing only, no runtime verification possible in this environment. Confidence LOW-MEDIUM that this is a *new* bug distinct from the D155/D186/D193/D204/D209 timing-keystone family (all of which gate the identical `deltaFrames→speedgraphframes→g_ClockTimer→g_GlobalTimer` chain this SFX trigger consumes).… |
+| D241 | **Player gunshot SFX goes silent under some conditions — reported on Bunker with the alarm sounding, "or some other factor" — but only for PP7 and AK47, Klobb…** — full `## D241` entry at file tail | **CLOSED as the D207 family (v0.4.0 campaign sign-off 2026-09-28, user: no silence under heavy fire + alarms -- fixed).** Earlier: OPEN — not newly root-caused; high confidence this is D207 reproduced, not an independent bug. Recommend folding this repro into D207 (or closing D241 as a duplicate once D207 is reopened) rather than tracking separately.… |
+| D242 | **Player-death sequence UX regression vs N64 original: PC requires an LMB click to proceed to the mission-failed/file-select screen; user recalls N64 auto-plays…** — full `## D242` entry at file tail | FIXED/CLOSED (2026-09-26, user: death -> mission failed flow works without input, matches N64). Earlier: OPEN — N64 reference obtained (2026-09-26, user on 1964: after death the game returns to mission select and shows the "killed in action" screen); PC behaviour still to validate. Earlier: OPEN — 1964 comparison owed (2026-09-26, user). Earlier: **NOT A PORT-LOGIC BUG as far as static analysis can tell.** Confidence HIGH that the LMB-click skip path is faithful original behavior (Z_TRIG-mapped, exists unmodified in `bondview2.c`), not a defect to remove.… |
| D243 | **Dam end-of-level cutscene (the abseil/bungee sequence, D148/D160's subject): the camera violently shakes/"spazzes" during each of the scripted camera-angle…** — full `## D243` entry at file tail | **FIXED, live-confirmed by the user (M-189/M-190, 2026-09-18).** Root cause found: `bondview2.c:598-599`'s manual buffer-layout math for Bond's third-person cutscene body model reserved space using a hardcoded 32-bit `sizeof(Model)` literal (`0xfb`) that never accounted for pointer widening — real 64-bit `sizeof(Model)` is 232 bytes, 44 more than reserved, so the animation-data buffer placed right after it overlapped the model's own `playspeed`/`animrate`/`unkac`/`unkb0`/`unkb4` fields, corrupting them (this is what M-183/M-185's clamps were band-aiding). Fixed by computing the real `sizeof(Model)` instead of the stale literal, matching the identical idiom already used one line above for `ModelFileHeader`. **M-190 follow-up:** M-170's camera-shake freeze was replaced with a filter re-seed-on-teleport (camera now tracks Bond continuously within each shot instead of holding a frozen angle), and an unrelated bug in the same review pass — mouse-look never gated during scripted/no-control camera states (D300's own documented-but-unimplemented safety gate) — was found and fixed in `port/src/input.c`. User confirmed both fixes live, full intro-flyover + Dam abseil playthrough: "ok yeah looks good." |
-| D245 | **IsWater water: two competing patterns with a visible seam that moves as the player looks around (mouse/aim) — "looks almost exactly like skybox" (M-116, user…** — full `## D245` entry at file tail | OPEN — REOPENED, M-142 headless probe found the tc-shift `k` measurably flips during ordinary view rotation (new root-cause evidence); an opt-in `GE_D245_FIXEDSHIFT` candidate is build-verified but needs a live A/B, not shipped as default.… |
+| D245 | **IsWater water: two competing patterns with a visible seam that moves as the player looks around (mouse/aim) — "looks almost exactly like skybox" (M-116, user…** — full `## D245` entry at file tail | FIXED (M-201, 2026-09-26, user-verified vs 1964): water texture imported/normalised at 32x43/16-wide instead of the 32x32 CI8 mask period (resets, 2x speed) + s16 sky/water vertices replaced by G_FLOATVTX_EXT floats (jitter). Earlier: OPEN — CONFIRMED PORT BUG (2026-09-26, user vs 1964: PC water displays glitched with a weird yellow line; view-dependent shift is subtle; animation otherwise matches). Earlier: OPEN — REOPENED, M-142 headless probe found the tc-shift `k` measurably flips during ordinary view rotation (new root-cause evidence); an opt-in `GE_D245_FIXEDSHIFT` candidate is build-verified but needs a live A/B, not shipped as default.… |
| D246 | **Tiny pixel strips at the left and right edges of the screen, "like overscan" — look like raw pixels, not 3D graphics (M-116, user QA report).** — full `## D246` entry at file tail | **FIXED (2026-09-18 session, port-only, `Video.SafeAreaCrop`).** Measured precisely (exactly 1.0 logical unit missing on both edges, every resolution); underlying clip-precision root cause not isolated, but `gfx_adjust_viewport_or_scissor` now trims a fixed 1-unit margin from both edges — same toggle/mechanism as D247. Live user playtest confirmed clean edges. |
| D247 | **Black bars top and bottom of screen — possible aspect-ratio defect (M-116, user QA report).** — full `## D247` entry at file tail | **FIXED (2026-09-18 session, port-only, `Video.SafeAreaCrop`).** Root cause: faithful, unmodified N64 TV-safe-area letterboxing that real CRT overscan used to hide; this port showed the full VI frame. Per user request, given a real fix (not left as "correct original behaviour"): `gfx_adjust_viewport_or_scissor` now remaps against the active gameplay viewport bounds so it fills the window edge-to-edge. New `Video.SafeAreaCrop` toggle (default on). Live user playtest confirmed clean edges. |
| D248 | **Port runs a stable 30 FPS instead of 60 FPS game-wide (M-117, user asked to verify FPS after the D209 timing-keystone fixes).** — full `## D248` entry at file tail | **FIXED, root-caused to a genuine pointer-width ABI bug in `src/sched.c` (compiled for the PC build, not excluded).** `osScAddClient()` (`src/sched.c:187-199`) stashes a per-client "present every retrace vs.… |
-| D249 | **LOD/culling breaks down on large open levels (Streets, Egyptian named specifically) at *default* FOV — far-away geometry has visible culling issues (M-119…** — full `## D249` entry at file tail | OPEN — M-142 static review ruled out the unscaled-far-clip and LOD-multiplier suspects (both already correct); narrowed to the portal/room-visibility BFS, needs a live screenshot+location, not more code reading.… |
+| D249 | **LOD/culling breaks down on large open levels (Streets, Egyptian named specifically) at *default* FOV — far-away geometry has visible culling issues (M-119…** — full `## D249` entry at file tail | OPEN — M-142 static review ruled out the unscaled-far-clip and LOD-multiplier suspects (both already correct); CLOSED (v0.4.0 campaign sign-off 2026-09-28, user: no distant-geometry drop-out seen on Streets/Egyptian over the full campaign -- fixed); narrowed to the portal/room-visibility BFS, needs a live screenshot+location, not more code reading.… |
| D250 | **FPS is inconsistent/low on many levels after 60 Hz shipped (M-119, user QA; D248 follow-on).** — full `## D250` entry at file tail | FIXED — per-call `getenv()` cost was the bottleneck; rock-steady 60-61 fps after, user re-tested 1440p/4K clean. |
| D251 | ****F10 options overlay QoL pass:** — full `## D251` entry at file tail | **FIXED (port-only, `port/src/optionsoverlay.c`).** (a) The panel now computes how many rows fit the *actual* current 2D height (`maxVisibleRows()` from `viGetY()`) and scrolls… The last open symptom — the bottom row deterministically duplicating the selected item (v0.2.0-pre playtest) — is resolved by D304's `overlayUpdateScroll()` minimal-scroll rewrite; user-confirmed no longer occurring (2026-09-18). |
-| D252 | **Particle effects cycle through a rainbow palette (M-124, user playtest of the v0.2.0-pre candidate).** — full `## D252` entry at file tail | **PARTIALLY FIXED — a real, distinct manifestation was found and fixed, but the bug family is not fully closed, and is intermittent on BOTH platforms, not Deck-specific (corrected 2026-09-18).** `explosionRenderPart()`'s 4-vs-5-vertex overrun (`src/game/explosion.c`) is fixed and stays in. Initial testing suggested "fixed on Windows, recurs on Deck," but further user testing found it also recurs intermittently on Windows (same Jungle repro, not always reproducing) and is level-dependent (same repro method doesn't trigger it on every level) — not a clean platform split. Repro method (`Game.AllUnlocked` + cheats for an immediate grenade launcher) is flagged as a possible, unconfirmed factor. The unconfirmed secondary lead from this session (stale TMEM-slot `src_fmt` CI8 reroute, `port/fast3d/gfx_pc.cpp` ~line 1250) remains the best-named candidate for the residual. Not re-investigated further this session. |
+| D252 | **Particle effects cycle through a rainbow palette (M-124, user playtest of the v0.2.0-pre candidate).** — full `## D252` entry at file tail | **FIXED (M-201, 2026-09-26, user-verified): port-side D68 shim `gimgSyncCompiledGlobalDLs` only synced the compiled explosion DLs on the first stage; every later stage kept the title stage's freed texture pointers. Earlier history: PARTIALLY FIXED — a real, distinct manifestation was found and fixed, but the bug family is not fully closed, and is intermittent on BOTH platforms, not Deck-specific (corrected 2026-09-18).** `explosionRenderPart()`'s 4-vs-5-vertex overrun (`src/game/explosion.c`) is fixed and stays in. Initial testing suggested "fixed on Windows, recurs on Deck," but further user testing found it also recurs intermittently on Windows (same Jungle repro, not always reproducing) and is level-dependent (same repro method doesn't trigger it on every level) — not a clean platform split. Repro method (`Game.AllUnlocked` + cheats for an immediate grenade launcher) is flagged as a possible, unconfirmed factor. The unconfirmed secondary lead from this session (stale TMEM-slot `src_fmt` CI8 reroute, `port/fast3d/gfx_pc.cpp` ~line 1250) remains the best-named candidate for the residual. Not re-investigated further this session. |
| D253 | **Facility (and any level with a FORCECROUCH/LADDER linked special tile) SIGSEGV on Linux/Steam Deck — `stanCheckLinkedSpecialTile` stores to a garbage…** — full `## D253` entry at file tail | 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.** — full `## D254` entry at file tail | FIXED — field-verified. |
| D255 | **Facility firefight SIGSEGV on Steam Deck (v0.2.0-pre CI bundle; D253 fix confirmed working).** — full writeup directly below this table (row status was stale; see M-140 update) | **FIXED (M-140)** — D3x-class 32-bit pointer truncation (`vtxstore_fix_refs` read `Model*` through the mistyped `ChrRecord.chrflags` 4-byte-enum alias instead of `ObjectRecord.model`), not the destroy-path race originally suspected. Verified live on real Steam Deck hardware (Facility + Caverns, zero crashes); Windows 21-level sweep clean. |
@@ -567,47 +568,132 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
| 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), refined (v0.2.1 further user testing, 2026-09-15). The D318 re-report (2026-09-18) turned out to be a **different** failure mode (derailment race → softlock, see D318 RESOLVED) — D263's proximity-trigger mechanism map stands. |
-| 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). |
+| 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 | CLOSED (2026-09-26 bookkeeping: sub-pass of D236, which closed in M-198). Earlier: 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 | DEFERRED (user call 2026-09-28: v0.4.0 ships NTSC-only, README-disclosed; the verified repair path is post-v0.4 backlog). Earlier: OPEN — repair path verified, not applied (out of v0.2.0 scope). |
| D284 | **`port/src/random.c`'s MIPS64 `dsll32`/`dsrl32` shift helpers were backwards — PC PRNG desynced from the N64's from draw 1, in every release to date (M-138 review; fix M-139b).** — full `## D284` entry at file tail | FIXED, 21-level sweep clean, no crashes — RNG stream changes from every prior build; existing replays will diverge. **"Savegames unaffected" was FALSE (M-148): `fileGenerateCRC` runs off a local constant seed via `randomGetNextFrom`, so the fix invalidates every stored slot CRC — v0.3.0 silently wipes all pre-fix saves on first launch (release-blocker candidate, see D295 M-148)** |
-| 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 | **PARTIALLY FIXED, RECURRED PAST THE FIX (see D305, 2026-09-18).** `osSetIntMask` lock added around the preempt-scan (M-141), live-verified crash-free on real Deck hardware (2026-09-16) — but the exact same fault (same FAULT ADDR, same site) recurred on the v0.3.0 pre-release build, disproving the lock as a *complete* fix: it stops concurrent mutation but not a scan that starts on an already-empty list. D305 adds the actually-missing NULL guard. Do not conflate with D255 — different thread, different subsystem. |
-| D286 | **User-confirmed real ~8s frame freeze on Caverns, Steam Deck — self-recovered, not a crash (M-140).** — full `## D286` entry at file tail | OPEN — one confirmed occurrence, no repro trigger identified, not root-caused; distinct from D255/D285. Do not chase a repro without a better lead. |
-| D287 | **User-confirmed real, non-self-recovering freeze on Windows (M-141, 2026-09-15/16) after ~15-20 minutes with no input, triggered by clicking back into the…** — full `## D287` entry at file tail | OPEN — one confirmed occurrence, real (not self-recovering), not root-caused; `gfxFrameMsgQ` full is a lead, not a confirmed cause. |
-| D288 | **Silo intro: a huge, flat-shaded, untextured brown/tan triangle sweeps across the frame during the camera pan down the missile shaft (M-141, user report —…** — full `## D288` entry at file tail | OPEN — visually confirmed and precisely bounded (frames ~562-741 of a bare `-level_20` boot), root cause not yet identified; promoted from the long-parked `GRAP… |
-| D289 | **Dam: a crash in the computer room, not reproducible on a second attempt (M-141, Steam Deck user playtest).** — full `## D289` entry at file tail | OPEN — needs a human re-test specifically on a build with the D255 fix (this session's `release/v0.3.0`, not any earlier build) to see if it still happens.… |
-| D290 | **Watch's in-game "controller" section shows no control-pad graphic (M-141, Steam Deck user playtest).** — full `## D290` entry at file tail | OPEN — observed, not investigated. Needs a live repro (open the watch, navigate to the controller-config page) plus a screenshot for comparison against N64 reference. |
-| D291 | **Dam: texture glitches when the truck drives past (M-141, Steam Deck user playtest).** — full `## D291` entry at file tail | OPEN — observed, not investigated. Needs a live repro with the truck's exact position/timing and, ideally, a screenshot. |
+| 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 (user, 2026-09-26: fixed around v0.2.1/v0.2.2; D305 guard also landed; not seen since). Earlier: **PARTIALLY FIXED, RECURRED PAST THE FIX (see D305, 2026-09-18).** `osSetIntMask` lock added around the preempt-scan (M-141), live-verified crash-free on real Deck hardware (2026-09-16) — but the exact same fault (same FAULT ADDR, same site) recurred on the v0.3.0 pre-release build, disproving the lock as a *complete* fix: it stops concurrent mutation but not a scan that starts on an already-empty list. D305 adds the actually-missing NULL guard. Do not conflate with D255 — different thread, different subsystem. |
+| D286 | **User-confirmed real ~8s frame freeze on Caverns, Steam Deck — self-recovered, not a crash (M-140).** — full `## D286` entry at file tail | CLOSED (2026-09-26, user-approved: single occurrence on a v0.2.x-era Deck build, before the D255/D285/D305/D329/D336 fixes; not seen since; reopen on recurrence). Earlier: OPEN — one confirmed occurrence, no repro trigger identified, not root-caused; distinct from D255/D285. Do not chase a repro without a better lead. |
+| D287 | **User-confirmed real, non-self-recovering freeze on Windows (M-141, 2026-09-15/16) after ~15-20 minutes with no input, triggered by clicking back into the…** — full `## D287` entry at file tail | FIXED (user playtest 2026-09-26: idle-unfocused → click-in resumes, mouse checks fine). Earlier: FIX APPLIED, pending user re-test (2026-09-25) — likely mechanism found by code review: SDL mouse-mode/cursor calls (`SDL_SetRelativeMouseMode`, `SDL_ShowCursor`) were made from the scheduler thread (every controller poll) concurrently with the host (window) thread's focus/click handling; the idle+refocus trigger is exactly when both flip grab state. Now queued and applied only on the host thread (`inputApplyMouseRequests`). Not reproduced; confirm with an idle 20 min + refocus test. **Steam Deck (Linux/Wayland, 2026-09-27/28): idle + Deck settings and resume-from-sleep both fine — idle/refocus confirmed on the Deck.** |
+| D288 | **Silo intro: a huge, flat-shaded, untextured brown/tan triangle sweeps across the frame during the camera pan down the missile shaft (M-141, user report —…** — full `## D288` entry at file tail | CLOSED — not reproduced on the current build (2026-09-23 capture) and the 2026-09-25 user Silo playtest confirms it gone ("silo fixed"). Fixer not isolated (D271 A/B ruled out). |
+| D289 | **Dam: a crash in the computer room, not reproducible on a second attempt (M-141, Steam Deck user playtest).** — full `## D289` entry at file tail | FIXED (user, 2026-09-26: no recurrence on post-D255 builds). Earlier: OPEN — needs a human re-test specifically on a build with the D255 fix (this session's `release/v0.3.0`, not any earlier build) to see if it still happens.… |
+| D290 | **Watch's in-game "controller" section shows no control-pad graphic (M-141, Steam Deck user playtest).** — full `## D290` entry at file tail | FIXED (2026-09-23): D264-pattern template read in `watchRenderController` (flags read 0), plus 3 pointer-width/raw-Gfx sites on the same path; controller model verified rendering on Windows. |
+| D291 | **Dam: texture glitches when the truck drives past (M-141, Steam Deck user playtest).** — full `## D291` entry at file tail | CLOSED — duplicate of D306, faithful (M-201, 2026-09-26: user-captured, Dam intro camera 5 only). Earlier: OPEN — observed, not investigated. Needs a live repro with the truck's exact position/timing and, ideally, a screenshot. |
| D292 | **Death animations are glitched — Bond is often sunk halfway into the floor (M-141, Steam Deck user playtest).** — full `## D292` entry at file tail | **MOSTLY FIXED as a side effect of D243 (2026-09-18, user-confirmed alongside D173) — same fix, same outcome: right 9/10 times, small residual offset only when off, no more full sinking/glitching.** See D173 for the user's exact confirmation wording; not separately re-tested, reported together. |
| 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.… |
-| D294 | **Statue: ground geometry near the large CCCP/hammer-and-sickle monument briefly renders solid black over a wide area, then self-recovers 10-30 seconds later…** — full `## D294` entry at file tail | OPEN — not investigated.… |
+| D294 | **Statue: ground geometry near the large CCCP/hammer-and-sickle monument briefly renders solid black over a wide area, then self-recovers 10-30 seconds later…** — full `## D294` entry at file tail | **FIXED, user playtest confirmed (2026-09-25: not reproduced)** — root cause: room-model POOL exhaustion (authored `-ma220`=220 KB < ~250 KB monument-plaza demand at FovScale 130 + max DD/Lod); `memaAlloc` NULL → silent skip → undrawn room shows the night-sky clear colour. Stochastic (wall-clock seed → load order). Fix: `portRoomPoolScale()` (video.c, identity at N64-faithful settings) applied in `bossMainloop`; verified headless (faithful→exact authored size; user settings→0 failures; `GE_ROOMPOOL=0.25`→exhaustion on demand). Playtest: main patch gone; residual corner flicker traced to full-level residency (487 KB = 2.2× authored) starving the 2× pool; the 3×/linear fix REGRESSED the renderer on the menu-driven path (D156 NaN anim state, not a bank overflow) → reverted to sqrt + 2.0 cap (ma=450560). Residual open; probes in place for next capture. High-DD horizon edge on night levels = setting-inherent, documented only.… |
| D295 | **Campaign-halting crash at the end of Control Center on 00 Agent difficulty, then a crash on every subsequent relaunch/resume (GitHub #87, user campaign playtest).** — full `## D295` entry at file tail | FIXED (M-149) — M-148 root-caused + reproduced on HEAD; fix landed: port-side JPN-cache seed (`port/src/main.c`) + `#ifdef PORT` buffer widen in `front.c` (D8 class). Verified: previously-crashing input now clean, 21/21 sweep PASS. Companion save-wipe regression → D297. History — M-148 fully root-caused + **reproduced on current HEAD**: `difficultytext[4]` (`front.c:2616`) overflow — `strcpy("00 Agent")`/`strcat("\n")` spills onto `textpos.p[0]` (y, ≥0x80 in normal geometries) → high-bit byte → `langGetJpnCharPixels` NULL deref (`language.c:320`, `g_JpnCacheCacheItems` NULL outside JPN mode — likewise unallocated on N64 non-JP builds, crash potential is decomp-native). The user's save is a *valid pre-D284* save (all 5 slot CRCs bit-exact under the old PRNG); post-fix builds wipe it at validation, which is why the unmodified artifact no longer triggers it — NOT a fix. No OOB exists (M-147 guess retracted: 007 difficulty has no stored times). Fix options flagged for sign-off (port-side JPN-cache init preferred; `difficultytext` enlarge = D8 class). Side finding: v0.3.0 silently wipes all pre-fix saves on first launch (D284 side effect — own D entry owed). Separate still-open issue found: unconditional `[EVTQ-DROP]` audio-pool-exhaustion logging, `src/libultra/audio/event.c:92`.… |
-| D296 | **Control Center, 00 Agent: noticeable stutter, user's own read is "maybe from the drone guns" (M-146, same D295 investigation session).** — full `## D296` entry at file tail | OPEN — single live report, un-triaged; observed only under a gdb-attached debug-harness launch (`tools_pc/debug.ps1`), not the packaged release artifact — user flagged that harness overhead has caused false stutter before. Needs a plain-exe / real-release repro check before any perf tracing.… |
+| D296 | **Control Center, 00 Agent: noticeable stutter, user's own read is "maybe from the drone guns" (M-146, same D295 investigation session).** — full `## D296` entry at file tail | CLOSED (2026-09-26, user-approved: stutter seen only under gdb (debug.ps1); D302, fixed two days later, removed a per-triangle uncached getenv costing ~15 fps; reopen if seen on a normal build). Earlier: OPEN — single live report, un-triaged; observed only under a gdb-attached debug-harness launch (`tools_pc/debug.ps1`), not the packaged release artifact — user flagged that harness overhead has caused false stutter before. Needs a plain-exe / real-release repro check before any perf tracing.… |
| D297 | **v0.3.0 silently wipes ALL pre-fix player saves on first launch (D284 side effect; found during D295/M-148).** — full `## D297` entry at file tail | FIXED (M-150) — migration shim landed: `port/src/legacycrc.c` (isolated, deliberately-wrong pre-D284 PRNG, reviewer-verified bit-exact against `0de99b4d^`) + eeprom-shim read-path hook in `libultra.c` (block 4 only; persists pristine+migrated bytes before the read-time-only AllUnlocked patch). Verified: user's #87 artifact → `D297-migrated 5`, all slots OK, idempotent on relaunch; native post-fix save untouched; corrupt slot left to the game's own wipe path; fresh eep untouched; 21/21 sweep PASS. Block-0 header needed no migration (factory seal, verified). |
-| D298 | **Full HUD (not just the aim-mode crosshair) missing in a v0.3.0 dev build — user suspects a debug-launch artifact and asked NOT to raise it until seen in a regular release-targeted build; parked** — full `## D298` entry at file tail |s — regression reported 2026-09-16 during v0.3.0 work (build ce8427cf); user says "don't fix, just for context"; not yet investigated** — full `## D298` entry at file tail |
+| D298 | **Full HUD (not just the aim-mode crosshair) missing in a v0.3.0 dev build — user suspects a debug-launch artifact and asked NOT to raise it until seen in a regular release-targeted build; parked** — full `## D298` entry at file tail | CLOSED (2026-09-26, user: HUD is fine now). Earlier: s — regression reported 2026-09-16 during v0.3.0 work (build ce8427cf); user says "don't fix, just for context"; not yet investigated** — full `## D298` entry at file tail |
| D299 | **Game.SkipIntro crashed selecting any save slot except one — reproduced on a fresh save and on a clean Release rebuild, ruling out stale save data (found + fixed 2026-09-17).** — full `## D299` entry at file tail | FIXED — `fileValidateSaves()` (only called from the skipped legal screen) never ran under `SkipIntro`, leaving `saves[]` all-zero; added the missing call to the `SkipIntro` shortcut (`src/game/lv.c`, `#ifdef PORT`, zero game-logic change). Build-verified both `RelWithDebInfo`/`Release`; live user re-test confirms all 4 slots now selectable with `SkipIntro=1`. Not yet run through a full 21-level sweep. |
| D300 | **Hipfire look feels non-uniform (issue #89): mouse px routed through the N64 analog stick's quadratic natural-turn curve, so slow motion is "almost unrecognised" and fast motion saturates at full turn speed by ~13 px/poll.** — full `## D300` entry at file tail | FIXED, user-confirmed live — WI-1 per `docs/dev/GEPD-INPUT-PLAN.md`: new `hipDirectCompute()` (`port/src/input.c`) writes `vv_theta`/`vv_verta` directly, mirroring GEPD's hipfire formula and D194's existing aim-mode direct-write pattern — linear px→degree, FOV-scaled, dt-normalized, bypassing the stick curve entirely. Safety-gated on `bonddead`/`outside_watch_menu`/`pause_state` plus (M-190, 2026-09-18) the scripted-camera-mode check the original safety-gates writeup named but never implemented — mouse could freely move the camera during POSEND/INTRO cutscenes until this session. `Input.MouseDirectLook` (default 1) is the escape hatch back to the legacy stick path. Build/link-verified; user playtested live: "input already feels better," then again post-M-190: "ok yeah looks good." |
| D301 | **`fileGetIsCheatUnlocked()` NULL-deref, unguarded on every call site, not just the D299/SkipIntro one (GitHub #87, two reports with an identical crash PC/fault address).** — full `## D301` entry at file tail | FIXED locally on `release/v0.3.0` (not pushed) — NULL-guarded `fileGetIsCheatUnlocked()` itself (`src/game/file2.c`, `#ifdef PORT`) rather than only its one known trigger route; build-verified, crash-free smoke test. Live confirmation against the actual #87 repro still owed — a synthetic `GE_STARTMENU=6` repro attempt did not reproduce the crash even pre-fix, so the exact #87 trigger condition (why the save lookup fails) remains unconfirmed. |
| D302 | **Render FPS regression to ~40-45fps on release/v0.3.0 (user QA on the local v0.3.0 pre-release build, 2026-09-18) — a reintroduction of D250's own uncached-`getenv()`-in-the-hot-path bug class.** — full `## D302` entry at file tail | FIXED — 4 uncached `getenv()` calls added by D236 passes 10/12/14/16 directly inside `gfx_sp_tri1` (`port/fast3d/gfx_pc.cpp`, per-triangle) cached with the same `static int x = -1` pattern D250 established elsewhere in the file. Verified via `GE_D193=1 -level_33` (Dam): rock-steady `render=59-61` post-fix, matching D250's own post-fix baseline exactly (pre-fix D250 pinned this level at 45-50fps). Not yet re-confirmed by the user's own hands-on playtest. |
-| D303 | **Some muzzle flashes (M16 named specifically, "and some others") draw wrong — the flash renders very long, extending straight up from the gun (user QA on the v0.3.0 pre-release build, 2026-09-18).** — full `## D303` entry at file tail | OPEN — reported only, not yet investigated. |
+| D303 | **Some muzzle flashes (M16 named specifically, "and some others") draw wrong — the flash renders very long, extending straight up from the gun (user QA on the v0.3.0 pre-release build, 2026-09-18).** — full `## D303` entry at file tail | FIXED (M-201, 2026-09-26): d43_emit sized op-22 star-flash vertex arrays as count, not count*4 arms; regenerated sidecars render a clean star. Earlier: OPEN — CONFIRMED PORT BUG (2026-09-26, user vs 1964: M16/AR33 muzzle flash shoots up and draws much higher on PC; N64 shows a normal star/burst at the muzzle). Earlier: OPEN — 1964 comparison owed; absorbs D220 (user 2026-09-26: M16/AR15 flash rises regardless of FOV). Earlier: OPEN — investigated: pipeline + weapon stats + model conversion ruled out; GE_D303 capture probe in-tree (`port/fast3d/gfx_pc.cpp`), blocked on a live firing repro. |
| D304 | **Mouse sensitivity menu overhaul (user request, 2026-09-18): aim speed/turn speed sliders weren't "1:1", had too little range, and (follow-up) risked decoupling into a bad state; F10 menu also had a click-jump bug.** — full `## D304` entry at file tail | FIXED — see full entry for both the range/parity pass and the same-day follow-up (collapsed to a single `Input.MouseSensitivity` menu row + fixed `overlayUpdateScroll()`'s unconditional bottom-anchor, the actual cause of the reported "menu jumps to the setting below on click"). Build-verified (Windows) both passes; headless overlay smoke test clean after each. **Live F10 playtest done (2026-09-18): user confirmed the collapsed single-sensitivity menu works, the click-jump is gone, and the D251 bottom-row duplication no longer occurs.** |
| D305 | **Steam Deck SIGSEGV on the v0.3.0 pre-release build (Cavern, early in the level, `Game.AllUnlocked` previously enabled on the session/save) — same exact FAULT ADDR 0x62 / `sndHandleEvent` signature as D285, reproduced AFTER D285's own lock fix was already live-verified crash-free on real hardware.** — full `## D305` entry at file tail | FIXED (defensive guard) — `sndHandleEvent`'s preempt-scan (`src/snd.c`) dereferenced its list-walk pointer before ever checking it for NULL (a do-while always runs its body once); D285's `osSetIntMask` lock only prevents another thread from emptying the list mid-scan, not a scan that starts with an already-empty list, so the crash could still happen even with that fix in place. Added the missing `iterState != NULL` guard before the loop, matching D255's established skip-instead-of-dereference precedent. Root desync mechanism (why pool-exhaustion accounting and the list can disagree) still not identified; `Game.AllUnlocked` correlation not proven, only reported. **Live-verified on the Deck (2026-09-18):** user ran two test sessions on Cavern with the updated build, plus toggled `Game.AllUnlocked` off then back on and relaunched — no crash in any of them. |
-| D306 | **Dam intro/truck wheels render with odd transparent-looking areas, possibly z-fighting (user QA, 2026-09-18).** — full `## D306` entry at file tail | OPEN — reported only, not investigated. Explicitly not for v0.3.0. |
-| D307 | **In-level security camera entities can be flipped facing the wrong direction (backwards) on some levels, e.g. Bunker (user QA, 2026-09-18) — NOT the player's view camera.** — full `## D307` entry at file tail | OPEN — reported only, not investigated. Explicitly not for v0.3.0. |
-| D308 | **Occasional z-fighting confirmed on some levels' geometry, general/broader than the D306 truck-wheel instance (user QA, 2026-09-18).** — full `## D308` entry at file tail | OPEN — reported/confirmed by the user, not investigated. Explicitly not for v0.3.0; D306 may be one instance of this. |
+| D306 | **Dam intro/truck wheels render with odd transparent-looking areas, possibly z-fighting (user QA, 2026-09-18).** — full `## D306` entry at file tail | CLOSED — faithful, confirmed on 1964 (M-201): all PC-specific leads refuted (FOV, depth precision, depth state, near plane, wheel transforms); it is the authored truck geometry seen from intro camera 5 under the truck. Absorbs D291. Earlier GE_ZF "real fighting" was a probe-quantisation artefact. |
+| D307 | **In-level security camera entities can be flipped facing the wrong direction (backwards) on some levels, e.g. Bunker (user QA, 2026-09-18) — NOT the player's view camera.** — full `## D307` entry at file tail | FIXED (M-201, 2026-09-26, user-verified): renamed duplicate member `CCTVRecord.pad`→`lookpad` made setupCctv read the MOUNT pad as the look-at pad; prop.c reads lookpad on PC. Earlier: OPEN — CONFIRMED PORT BUG (2026-09-26, user vs 1964: ALL Bunker 1 security cameras face backwards on PC, correct on N64). Earlier: OPEN (investigated 2026-09-21) — no port-side bug found: orientation path traced, every input verified N64-faithful vs ROM (`PcctvZ` switch data, room-scaled boundpads, config words), all math byte-identical `src/game`. Blocked on user repro detail; `GE_D307=1` diagnostic in-tree. |
+| D308 | **Occasional z-fighting confirmed on some levels' geometry, general/broader than the D306 truck-wheel instance (user QA, 2026-09-18).** — full `## D308` entry at file tail | CLOSED — faithful (2026-09-26, user: Statue ground z-fighting reproduces on 1964 GEPD bundle). Earlier: OPEN (user, 2026-09-26: possibly faithful; validate Statue ground z-fighting on 1964 vs port). Earlier: OPEN — investigated: PC depth precision == N64 (24-bit both sides; RSP writes full 16.16), so level-authored coincident geometry is the prime suspect; GE_ZF flicker probe in-tree. |
| D309 | **Complete non-self-recovering freeze (Windows) returning from Caverns to the intro screen — boss thread spins forever in `ai()` on `m_RunToBondPersistent` via a phantom `GotoNext(lblZero)` created by `chraiitemsize`'s `AI_PRINT` NUL-scan mis-sizing a stringless PRINT record (user report + live GDB diagnosis, 2026-09-18).** — full `## D309` entry at file tail | **FIXED via D310 — user-playtest-confirmed 2026-09-18** (Caverns→intro clean; the spin record itself observed resuming correctly in the post-fix probe log). The teardown-ordering open question (why PC presents the unreachable-Bond state) remains documented but is now harmless. |
| D310 | **RULE-2-SIGNOFF: `chraiitemsize()` mis-sizes 1-byte `AI_PRINT` records (NUL-scans forward), corrupting AI execution for any list containing an active PRINT — fixes D309's freeze mechanism and the Caverns standing-still guards (`Stop`+`PRINT("wait")` mis-lands on EndList, re-executed every tick; user report 2026-09-18).** — full `## D310` entry at file tail | **FIXED — user-playtest-confirmed 2026-09-18** (v2: global-list-only sizing via `d310ItemSize`; guards resume, Trevelyan pathing intact, Caverns→intro clean; probe log verified both list classes). PD precedent: PRINT removed, slot 0xAD reassigned. |
-| D311 | **Complete freeze in `modelSetAnimFrame2WithChrStuff` root-motion loop — D156 guard hole: poisoned `framea` (`model->animframe1`) passes the frameb-only clamp and saturates to a ~3.1e9-iteration spin (Caverns, live GDB diagnosis, 2026-09-18).** — full `## D311` entry at file tail | GUARDED (`#ifdef PORT`, D156-guard extension in the same file) — freeze class neutralized; upstream speed/frame poison source OPEN (level reload clears displaced model state). No AI involvement — distinct from D309/D310. |
+| D311 | **Complete freeze in `modelSetAnimFrame2WithChrStuff` root-motion loop — D156 guard hole: poisoned `framea` (`model->animframe1`) passes the frameb-only clamp and saturates to a ~3.1e9-iteration spin (Caverns, live GDB diagnosis, 2026-09-18).** — full `## D311` entry at file tail | FIXED — root cause D336 (`bheadAdjustAnimation` read never-written placeholder fields); the `#ifdef PORT` guard is retained as a safety net. 2026-09-25 user playtest incl. ~2 min of Caverns combat: 0 D311 ANOMALY / D156 lines in a ~46-min session. |
| D312 | **Throwable items (mines, grenades, etc.) can clip through walls in certain spots (user report, v0.3.0 pre-release playtest, 2026-09-18).** — full `## D312` entry at file tail | FIXED — `bgBuildRoomVtxBounds` mis-reads the G_VTX vertex count on PC (`Gfx_le.dma.par` bit-layout shift): n=16 batches collapse to single-point bounds and fail the per-batch AABB pre-test, so their wall triangles are never ray-tested. One-line `#ifdef PORT` fix; verified by repro. |
| D313 | **Bullet impacts do not appear on some walls/surfaces — the projectile appears to pass through them; impacts DO work on some surfaces such as doors/windows (user report, v0.3.0 pre-release playtest, 2026-09-18).** — full `## D313` entry at file tail | FIXED — same root cause as D312, merged diagnosis; same one-line fix. Doors/windows use the separate working `bgTestHitOnObj` path, hence the "surface-dependent" appearance. |
-| D314 | **F10 menu items flicker randomly/intermittently (reported on the main-menu context, user report, v0.3.0 playtest build, 2026-09-18).** — full `## D314` entry at file tail | OPEN — confirmed still present post-D316 fix (2026-09-18); user deprioritized chasing it further for now. Diagnostic probe (`GE_D314=1`) landed in `optionsOverlayHandleInput()` if revisited. |
-| D315 | **Menu scaling differs between main menu and in-game — main menu scales OK, in-game menu "seems kinda big sometimes" (user report, v0.3.0 playtest build, 2026-09-18).** — full `## D315` entry at file tail | OPEN — disclosed as a known issue, deferred to v0.3.1 (bigger design question, not a mechanical fix). |
+| D314 | **F10 menu items flicker randomly/intermittently (reported on the main-menu context, user report, v0.3.0 playtest build, 2026-09-18).** — full `## D314` entry at file tail | FIXED (user playtest 2026-09-26) — M-200 wheel-queue race fix + D287 window-thread cursor calls. Earlier: confirmed still present post-D316 fix (2026-09-18). Diagnostic probe (`GE_D314=1`) landed in `optionsOverlayHandleInput()` if revisited. |
+| D315 | **Menu scaling differs between main menu and in-game — main menu scales OK, in-game menu "seems kinda big sometimes" (user report, v0.3.0 playtest build, 2026-09-18).** — full `## D315` entry at file tail | CLOSED (2026-09-26, user: menu scaling looks fine; likely resolved by the v0.4.0 widescreen/HUD-scale work D334/D335/D226). Earlier: OPEN — disclosed as a known issue, deferred to v0.3.1 (bigger design question, not a mechanical fix). |
| D316 | **Mouse-clicking some menu items glitches and lands on the item below instead (user report, v0.3.0 playtest build, 2026-09-18).** — full `## D316` entry at file tail | FIXED — root-caused to two compounding bugs (naive mouse->logical mapping ignoring the safe-area crop; asymmetric row hit-test band) in `port/src/optionsoverlay.c` + `port/fast3d/gfx_pc.cpp`. Build-verified (golden diff PASS, worst_cell=0); interactive click verification owed. |
| D317 | **Crash when toggling Game.AllUnlocked back and forth in-game (user report, v0.3.0 playtest build, 2026-09-18) — no dump left behind.** — full `## D317` entry at file tail | CLOSED — symptom of D316 (confirmed instant window close, no WER report; the D316 hit-band bias let a click on "All unlocked" fall through to the adjacent "Quit to desktop" row). Same fix resolves it. |
-| D318 | **Facility (regular play path): after Objective C (destroying the bottling tanks) completes, Ourumov freezes and does not shoot Trevelyan as he should (user report, 2026-09-18).** — full `## D318` entry at file tail | RESOLVED (2026-09-19): as-authored latent race (gas bit mid-monologue derails c78's pre-aim hold into a permanent ACT_ATTACK anim pin; N64-identical), NOT a port bug; port-side watchdog `port/src/d318watchdog.c` re-inits the attack on confirm. D263 mechanism map stands (different failure mode). |
+| D318 | **Facility (regular play path): after Objective C (destroying the bottling tanks) completes, Ourumov freezes and does not shoot Trevelyan as he should (user report, 2026-09-18).** — full `## D318` entry at file tail | FIXED via D329 (2026-09-26 bookkeeping: tick-granularity root cause; Rule-2 fix in model.c, user-verified 2026-09-23). Earlier: OPEN — re-classified 2026-09-23 by D329 (tick-granularity divergence at 1 tick/frame, not a platform-invariant lock); fix decision pending, watchdog stays shipped. Earlier resolution text kept in the full entry. |
| D319 | **Frame cap slider was mostly a dead zone; replaced with a plain 30/60 toggle (user ask, 2026-09-18).** — full `## D319` entry at file tail | FIXED — new `ROW_FPSCAP` cycle (30/60 only) in `port/src/optionsoverlay.c`; default changed to 60 in `port/src/video.c`. Root cause was D186 (sim tick ceiling + low-cap guard); this just fixes the menu's exposure of it. Build-verified; interactive confirmation owed. |
-| D320 | **D318-class sweep: aim-hold → `_update` AI lists in 8 levels carry the same as-authored softlock race (static analysis, 2026-09-20).** — full `## D320` entry at file tail | OPEN — static sweep complete (Facility ai_19, Control ai_9, Depot ai_12 flagged high/med-high; Bond-combat loops likely safe); no live confirmation beyond D318 itself; probe-verify path + generalized-watchdog recommendation documented. |
-| D321 | **D318 trigger chain fully mapped: tanks → combat bit is an authored ~3.5 s gas-cascade delay (chr 254 script); derail lands same-tick; PC behavior confirmed faithful to N64 (probe captures, 2026-09-20).** — full `## D321` entry at file tail | CLOSED — trigger chain mapped and faithful; D318 watchdog validated in live play. Optional deferred tuning: `D318_DEADLOCK_TICKS` 600→300 (user's call). |
-| D322 | **Long-session audio degradation: full campaign on v0.3.0 — audio progressively worsens from Silo, by Caverns/Cradle the OST is inaudible and SFX "come and go"; restarting the game restores it (issue #87, user report, 2026-09-21).** — full `## D322` entry at file tail | OPEN — static triage done (teardown audit, mixer statelessness, D202-coverage check); ranked hypotheses: voice-pool exhaustion/counter drift > queue starvation > evtq saturation. `GE_D322` pool-telemetry probe shipped; needs a campaign capture with `GE_D322=1 GE_D204=1`. |
+| D320 | **D318-class sweep: aim-hold → `_update` AI lists in 8 levels carry the same as-authored softlock race (static analysis, 2026-09-20).** — full `## D320` entry at file tail | FIXED via D329 (2026-09-26 bookkeeping: tick-granularity root cause; Rule-2 fix in model.c, user-verified 2026-09-23). **v0.4.0 campaign (2026-09-28): no D320-beat freeze seen (user: good).** Earlier: OPEN — re-classified 2026-09-23 by D329 (tick-granularity divergence at 1 tick/frame, not a platform-invariant lock); fix decision pending, watchdog stays shipped. Earlier resolution text kept in the full entry. |
+| D321 | **D318 trigger chain fully mapped: tanks → combat bit is an authored ~3.5 s gas-cascade delay (chr 254 script); derail lands same-tick; PC behavior confirmed faithful to N64 (probe captures, 2026-09-20).** — full `## D321` entry at file tail | FIXED via D329 (2026-09-26 bookkeeping: tick-granularity root cause; Rule-2 fix in model.c, user-verified 2026-09-23). Earlier: OPEN — re-classified 2026-09-23 by D329 (tick-granularity divergence at 1 tick/frame, not a platform-invariant lock); fix decision pending, watchdog stays shipped. Earlier resolution text kept in the full entry. |
+| D322 | **Long-session audio degradation: full campaign on v0.3.0 — audio progressively worsens from Silo, by Caverns/Cradle the OST is inaudible and SFX "come and go"; restarting the game restores it (issue #87, user report, 2026-09-20).** — full `## D322` entry at file tail | FIXED (Windows) for the periodic "garbled" bursts — root cause SDL2's WASAPI backend, not the device: side-by-side monitor runs on the same USB interface show WASAPI draining 81–88 % for ~30–50 s every ~317 s (1.4 % playback lost overall) while DirectSound stays at exact real time. `port/src/audio.c` now prefers `directsound,wasapi` (SDL_AUDIODRIVER env still overrides). Remaining: SFX drop-outs in firefights, if still heard after this fix (8-voice cap, D207/D241); #87 progressive decay: **v0.4.0 campaign (2026-09-28) -- user has not seen it again, "probably good"; closed on user trend, `GE_D322=1` capture recipe remains in the entry for any recurrence.** |
+| D323 | **G_TL_LOD bindings of explicit-LOD textures: PC samples driver mips from LOD0, N64 samples the authored mip chain (static census, 2026-09-21).** — full `## D323` entry at file tail | CLOSED — parked as a fidelity enhancement, not a bug (2026-09-26, user-approved: no reported artifact). Earlier: OPEN / parked — mechanism confirmed in code; census (`tools_pc/bg_lod_audit.py`) shows 15 levels with explicit-LOD textures bound under TEXTLOD-on classes (stat 378, azt 806 binds heaviest); overwhelmingly CI8/IA8 (subtle), the only RGBA5551 cut-outs are the D236 set (already fixed). No reported artifact; fix path documented (upload GL mip chains for TEXTLOD-on bindings) but not worth doing speculatively. Also documents GE's OMH field-write encoding (op 0xBA, value at absolute bit position in w1). |
+| D324 | **GE's stub declares nothing, so TUs that include it get NO CRT prototypes — implicit `int f()` declarations silently corrupt non-int returns: `atof` garbage in every float ini parse (D38 class).** — full `## D324` entry at file tail | FIXED (2026-09-22) — proven impact: `Game.ScreenShakeIntensity` (the only float ini option) silently parsed to garbage whenever a user set it; caught via the D294 `GE_ROOMPOOL` hook. Fix: `port/shim/stdlib.h` now declares atof/strtod/strtol/atexit/getenv unconditionally (K&R form); pc_protos.h gains atof/strtod for game TUs; glass2.c PORT block uses `__builtin_memset`. Residual harmless implicit decls (atoi/floorf/sqrtf/memset builtins, lround) documented. |
+| D325 | **ROM-mod (xdelta) compatibility: relocating/total-conversion mods cannot work while the file table is compiled in; in-place data-only mods are one sidecar-staleness fix away (Goldfinger 64 probe, 2026-09-22).** — full `## D325` entry at file tail | CLOSED as a question — mechanism measured (GF64 relocates 797/803 vanilla file slices; 12→24 MB expanded ROM; `d43_emit` dies on a vanilla-offset zlib slice). ASM mods never work; relocating mods need a runtime-parsed file table (rule-2 collision for added levels); in-place data-only mods need only a ROM-identity stamp in `manifest.csv` (`port/src/romconvert.c`). |
+| D326 | **Random Eye randomizer repacks the ROM too — the hypothesised "in-place data-only" mod tier is empty in practice; the in-ROM file table is the single keystone for any mod support (2026-09-22).** — full `## D326` entry at file tail | CLOSED as a question — identical ROM size but 96.5% of bytes and 803/803 file slices differ (content preserved, shifted +7216/+7392/−229888); same `d43_emit` zlib failure. D325's cheap tier disproven. Two bounded scans failed to find the in-ROM `fileentry` table (likely inside the compressed code image); that recovery question gates everything. Backlogged (local notes §5, extends T3.6). |
+| D327 | **Mod support, resolved plan: reading the file table out of the ROM is ruled out by measurement; content-addressed offset remapping (gap inference over contiguously-packed files) is the viable route (1964GEPD fork review, 2026-09-22).** — full `## D327` entry at file tail | RESOLVED — superseded by D328; approach (a) is NOT ruled out (these scans missed the 1172-compressed data segment). `hw_address` absent from the ROM in all 4 encodings tested (0 hits for the segment-relative form); `file_resource_table.inc.c` is a decomp reconstruction. Viable route: locate unchanged files by content, bound changed ones by neighbour gaps (`obInit` differencing proves contiguity). Verified primitive: GE's `0x7F000000` segment maps uncompressed from ROM `0x34b30`, uniquely signature-findable. 1964 fork is GPL-2.0 — facts only, no code reuse. |
+| D328 | **Mod support is viable and D327's "ruled out" was wrong: GE's file table IS in the ROM, inside the 1172-compressed data segment, and locating it needs no hardcoded offsets (PD dabs fork review + verification on vanilla + 2 mods, 2026-09-22).** — full `## D328` entry at file tail | CLOSED as a question — table at `0x252c4` inside the segment inflated from ROM `0x21990` (727 rows; 681 exact offset+size matches vs filelist, 0 mismatches). Hardcode-free scan reads Random Eye's own table -> **704/726 files byte-identical** (vs 0/803 at vanilla offsets in D326) and GF64's 24 MB table correctly. PD's architecture is an offline importer + mod-dir override, not a runtime ROM loader; its MIT `rompatch.c` removes the GPL xdelta3 constraint. Remaining work is the mod-dir override layer. |
+| D329 | **The D318/D320 guard anim pin is a tick-granularity divergence: it exists only at 1 sim tick per rendered frame (port at 60 fps); at 2+ ticks/frame (GE's normal N64 regime) it cannot persist (lockstep A/B, 2026-09-23).** — full `## D329` entry at file tail | FIXED — Rule-2 high-FPS fix in model.c; clk=1 no longer pins (WSL + native Windows), 0 triggers at clk=2; bunker1 golden failure predates it (on/off A/B); user playtest confirms N64-matching Ourumov timing; watchdog stays shipped for now. |
+| D330 | **#103 (v0.3.0, Win 11): Auto-Aim / Look Ahead "won't stay on" in the in-game watch options: the mouse drove the analog stick inside the watch, and a small leftward flick selected OFF.** — full `## D330` entry at file tail | FIXED (port/src/input.c) — user-confirmed 2026-09-25: Auto-Aim set ON in the watch stays ON while moving the mouse. |
+| D331 | **Ejected shell casings never rendered on PC: `g_DefaultCasingModelRenderData` (u32[15] template) read as the wider PC `ModelRenderData` gave flags 0 (third instance of the D264 pattern; found by the D290 sibling sweep, never user-reported).** — full `## D331` entry at file tail | FIXED (gunfire.c `#ifdef PORT`, explicit template); PP7 casing verified on native Windows (before: none; after: brass casing ejects and tumbles); golden 3/3; Win + Linux builds clean. |
+| D332 | **Reticule jitter in aim mode (RMB) — ROOT-CAUSED from live captures; fixed by adopting the Perfect Dark port's mouse-aim model behind `Input.PdMouseAim`.** The game's damped crosshair update runs once per SIM TICK while the port's GEPD overwrite runs once per input POLL, so the value displayed is `write * damp^k` with k varying (k=1 66%, k=2 18%, k=0 17% of frames). At damp 0.8 that stepped the drawn reticule 20% whenever k changed | **FIXED (`fix/mouse-pd-model`)** — `Input.PdMouseAim` (default off, live via `GE_PDMOUSEAim`): the port accumulates only and the game's integrator is driven by the port-supplied turn with PD's near-zero damp (0.01), via a `#ifdef PORT` hook in `sub_GAME_7F067FBC`. Two earlier fixes were tried and reverted (see the `## D332` entry). **Awaiting feel-test.** |
+| D333 | **#104 (v0.3.0, Linux): FPS-style centred aim on RMB, delivered as `Input.AimStyle` (F10 "RMB aim style": N64 / PD DAMP / CENTRED), folding in D332's toggle.** — full `## D333` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: IMPLEMENTED (port/src/input.c + optionsoverlay.c, port-only); default N64 = unchanged; build + golden clean; feel check owed. |
+| D334 | **Native widescreen, world side (WIDESCREEN-FOV-PLAN Option B Phase 2): the world is projected at the real window aspect (Hor+) by generalising GE's own N64 16:9 mode; `Video.NativeWidescreen` (default on).** — full `## D334` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: IMPLEMENTED (Rule-2 approved 2026-09-23); 4:3 identity (golden 3/3); native 16:9 centre matches the 4:3 frame (mean diff 4.3 vs 12.4 stretched); 21:9 clean on Bunker/Facility/Dam. HUD still stretched → Phase 3. |
+| D335 | **Native widescreen, HUD + front end (WIDESCREEN-FOV-PLAN Phase 3): HUD elements anchored left/right/centre and the front end pillarboxed via the fast3d EXT aspect modes (PD pattern), `port/include/hudaspect.h`.** — full `## D335` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped, user-verified at 16:9/21:9; F10-overlay pillarbox remains as D335b in GRAPHICS-BACKLOG). Earlier: IMPLEMENTED (Rule-2 approved 2026-09-23); 4:3 byte-identical (no emission at ≤4:3); verified at 16:9/21:9 on HUD, watch, title card, legal/file-select/gun-barrel. Menu pointer mapped into the pillarbox. One cosmetic leak → GRAPHICS-BACKLOG D335a. |
+| D336 | **Frigate (and any level) "all polygons break except the gun" after turning: Bond's head-bob anim read two never-written placeholder fields (`field_5C0`, `animFlipFlag`) that on N64 are raw-offset aliases into the inline `player.model` (D100 follow-up; A1 class). Likely the D311 upstream anim-poison source.** — full `## D336` entry at file tail | FIXED (src/game/bondhead.c `#ifdef PORT`, ABI/layout); headless repro (Runway → exit → Frigate → turn, D294 2× room pool) clean, D156 0 fires; golden 3/3; Win + Linux builds. **User-confirmed 2026-09-24.** |
+| D337 | **Aim modes folded (user decision): N64 (default) = the N64 aim model with the mouse fed through the game's integrator at PD's damp (D332 made default); CENTRED (PC) opt-in; Q/L and Toggle-aim now route mouse aim like RMB; pad/keyboard aim no longer swallowed by the mouse integration.** — full `## D337` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: IMPLEMENTED (port/src/input.c + optionsoverlay.c); headless-measured with new scripted-mouse harness; golden 3/3; Win + Linux builds. Open: N64-range calibration decision. |
+| D338 | **`Input.AimRange` (F10 "Aim range"): PC (default, GEPD/mouse-injector full-screen crosshair) or N64 (original stick limits: 65% of half-width, camera turn from ~49%).** — full `## D338` entry at file tail | FIXED (2026-09-26 bookkeeping: shipped; user reports it feels good in v0.4.0 play). Earlier: IMPLEMENTED + measured (PC 98%/72%, N64 65%/49%); golden 3/3; Win + Linux builds. |
+| D339 | **#92 (low-end ~30 fps, Celeron N3060 / HD 400) — measured CPU budget: game thread ~0.2–0.35 ms/frame, render-thread display-list pass 0.4–1.8 ms/frame on this box; CPU very likely not the bottleneck. New `GE_PERFSTAT=1` probe (render phases, tris/batches, game-thread busy, GPU timer query).** — full `## D339` entry at file tail | OPEN (2026-09-26, user: review toward the end of v0.4.0; needs a test machine with similar HD 400-class hardware). Earlier: INVESTIGATING — probe landed; target-hardware numbers needed before any fix. |
+| D341 | **Intro gun-barrel blood drip draws as static blocks/lines instead of dripping (user, v0.4.0, 2026-09-26; death blood fine).** — full `## D341` entry at file tail | FIXED (M-201, user-verified 2026-09-27): two fast3d defects — the 1-cycle blood texrect sampled a stale tile-1 declaration via the D236 LOD rule, and the in-place-regenerated dyn-pool texture hit a stale address-keyed cache entry. |
+| D342 | **File select: folder bodies and Bond photos vanish after selecting a file and backing out (user, v0.4.0, 2026-09-26).** — full `## D342` entry at file tail | FIXED (M-201, user-verified): scratch placed at the N64 offset +0xA000 of `ptr_logo_and_walletbond_DL` landed inside the larger PC wallet model; moved past its 0x17000 reservation. |
+| D343 | **File select: PC "Options" label opens the settings overlay (v0.4.0 feature, M1 of the route-B options screen, 2026-09-27); also fixes F10 on file select jumping to the legal screen after 30 s.** — full `## D343` entry at file tail | FIXED (M1, closed): one Rule-2 `#ifdef PORT` draw hook (user sign-off 2026-09-27); headless + user-verified (mouse, F10, >30 s, widescreen, physical controller on the bottom-bar label and options screen). |
+| D344 | **Host PC bugchecks (BSOD 0x119 VIDEO_SCHEDULER_INTERNAL_ERROR) during game test runs; probable cause: quit called exit() while the render thread could be inside the GL driver (2026-09-27).** — full `## D344` entry at file tail | FIXED (probable cause; orderly quit + GE_QUITFRAME), headless + user-verified; no dump, watch for recurrence. |
+| D345 | **Front-end crosshair snaps away from the pointer on screen/page changes (file select → mode select `setCursorPOSforMode(0)`, front.c:2516; every `MENU_PC_OPTIONS` page hop), and the options screen's keyboard navigation was missing/mis-modelled (2026-09-27).** — full `## D345` entry at file tail | Port-only, user-verified (parts a–g): (a) `input.c` re-assert window on menu change; (b) `cursorToItem` skips its snap while the pointer is live; (d) W/S emit stick in menus under NaturalPitch; (e/f) F10-style discrete row stepping + arrow left/right value adjust replace raw drift (first pass shipped broken, fixed); (g) screen-placed cursor parks in the left gutter, off the row text. Game-side one-liner deferred: physical-controller pass was good, no Rule-2 sign-off sought. |
+| D346 | **PC-settings wording pass: option labels/values aligned to Nightdive/Turok + PD-port conventions (title-case On/Off, "Frame rate cap", "Anti-aliasing"/"None", units in values not labels, self-explanatory auto-FOV toggles); VIEW section merged into VIDEO (2026-09-27).** — full `## D346` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
+| D347 | **F10 overlay controller: left/right did nothing (only A/Y adjusted values) and no input had hold-to-repeat (2026-09-27).** — full `## D347` entry at file tail | FIXED (port-only, `optionsoverlay.c`): D-pad left/right + stick-X wired into value adjust (A/X/B/Y kept); 18/4-frame hold-to-repeat on adjust and up/down nav, matching the options screen's D345(e)/(f) cadence. User validated physical-controller pass. |
+| D348 | **File-select "PC Options" label: no settings icon exists in the game art (mainfolderimages = copy/del/select-arrow/X/check/dot only) -- a mini slider glyph is drawn instead (2026-09-27).** — full `## D348` entry at file tail | SUPERSEDED by D398 (glyph replaced with the IMG_DOT texture; no new art). PCDUMP pixel-verified when shipped (135 knob px vs 9 paper). |
+| D349 | **GE watch settings are not normally saved to the Bond file: erase is the only caller of the settings serializer, and stage start reloads the file's old values.** — full `## D349` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
+| D350 | **Watch-backed PC options: shared front/F10 Bond-file rows, explicit chooser, game-thread F10 bridge and field-scoped PC-only persistence.** — full `## D350` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
+| D351 | **Existing D346 value/unit rows had positional initializers shifted into `hidePtr`: %/x rows hidden or formatted without units.** — full `## D351` entry at file tail | FIXED (port-only): designated row initializers restore unit, deadzone divisor and hide-if-auto fields; build clean, headless smoke clean. Manual settings visual check owed. |
+| D352 | **D350 review of 1303c0eb: stage transitions silently drop queued F10 setting changes (queue drained before the `stageActive` check); the UI thread's chooser state is unsynchronized; rapid slider drags push one RSP command per detent and re-persist the same value each frame; persist failures are silent (2026-09-27).** — full `## D352` entry at file tail | FIXED (port-only, `port/src/watchsettings.c`): hold-not-drop at stage transitions, spinlock on chooser state + front-screen guard, per-field coalesced queue commits, warning logs on all no-op paths, `PersistField` made static. NTSC build clean, headless menu smoke clean. |
+| D353 | **Turok-style settings regrouping: the D346 VIDEO mega-section (14–15 rows, at the MAX_PROWS limit) re-split into DISPLAY + GRAPHICS, the "AUDIO (BOND FILE)" hack header dropped (music/FX sliders fold into BOND FILE), all remaining rows converted to designated initializers, `buildPages` overflow now logs, and the Bond-file page title annotates the selected file (2026-09-27).** — full `## D353` entry at file tail | FIXED (port-only, `optionsoverlay.c`/`frontoptions.c`): 6 sections of 3–11 rows, all rows designated-initialized, `optionsRowIsBondChooser` accessor, build clean + headless smoke clean. Master-volume row, per-section reset (Gate E) and key rebinding remain in the M3 backlog. |
+| D354 | **Front-end F10 Bond rows silently disabled: `watchSettingsAvailable()` mis-routed F10-on-file-select through the stage path (returned 0), so with the hidden chooser stuck at "none" the volume sliders read "Select file" and did nothing; maintainer-reported (2026-09-27).** — full `## D354` entry at file tail | FIXED (port-only, `port/src/watchsettings.c`): all non-stage contexts route through the front path with a lazily-resolved default folder (explicit pick > `selected_folder_num` > first valid), one-shot game-thread `fileBuildWriteNewSave(FOLDER1)` fallback, GE_WSPROBE/GE_WSPROBE_FRONT env probes headless-verified (stage: live+save changed; front: default file 1 + save changed). |
+| D355 | **F10/options Music slider inaudible in-stage: the slider drives only the X-track (track 2) via `set_mTrack2Vol`, but in-stage BGM is track 1, which the game pins to `VOLUME_MAX` at level start (lv.c:364) and never rescales from `mTrack2Vol` in solo -- so neither the N64 watch nor the F10 could change the music the player was hearing; maintainer-reported (2026-09-27).** — full `## D355` entry at file tail | FIXED (port-only, `port/src/watchsettings.c`): the Music commit now also calls `musicTrack1ApplySeqpVol(v)` (the exact resync the MP path already does, mpmenu.c:354) and a once-per-activation resync runs on stage entry; by-ear confirmed by the maintainer. N64 watch path untouched. |
+| D356 | **Settings regroup: Turok-style pages renamed to functional sections (INPUT/GAMEPLAY/GRAPHICS/AUDIO/VIDEO) with PD-style "(File N)" scoping annotations, the redundant N64-watch rows (`Bond.Look`, `Bond.AimControl`) off the menu surface, and a real per-section Reset-to-defaults (two-step arm->confirm, watch rows to BLANKSAVEDATA via the D352 commit path, ini rows to the port's C initializers; `__Resolution` a documented exclusion) replacing the Turok reset rows the PC build never honored (2026-09-27).** — full `## D356` entry at file tail | FIXED (port-only, `port/src/optionsoverlay.{h,c}` + `port/src/watchsettings.{h,c}` + `port/src/frontoptions.c`; plan `docs/dev/D356-SETTINGS-REGROUP-PLAN.md`): 5 sections all below MAX_PROWS, hidden rows D181/D216-style (keys + watch paths stay live), edge-triggered reset with 3 s arm window + maintain/clear disarm, GE_WSPROBE_RESET front + in-stage probes headless-verified (all sections failures=0, scope isolation OK, saved bytes 0xFF), GE_WSPROBE/GE_WSPROBE_FRONT regressions clean. |
+| D358 | **D356 level-1 overlap: the front options save-file row (item 0) painted under the first content row, and `s_hl` (item-numbered) was indexed straight into `s_rowIdx` (content-numbered) -- wrong-row adjusts, a dead last row, a one-past read, wrong highlight (2026-09-27).** — full `## D358` entry at file tail | FIXED (port-only, `port/src/frontoptions.c`): content rows draw at `rowY(k+1)`; all `s_hl` -> `s_rowIdx` uses take `s_hl-1`; A-press guards `s_hl >= 1 && s_hl <= s_rowN`; hit-test/cursor untouched (already item-numbered). Companion: "(save)" tag -> "(profile)". |
+| D359 | **Settings UI file terminology settled on the end-user term "Profile": the screen mixed five terms ("Save file"/"File N"/"(profile)"/"Select file"/"(no file)"), and the manual's "game file" reads as the program's own files in modern PC usage (2026-09-27).** — full `## D359` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
+| D360 | **F10 INPUT heading scrolled away; F10 navigation and distance defaults (2026-09-27).** — full `## D360` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
+| D361 | **Main-menu F10 Audio freezes the game: EEPROM save from joyPoll deadlocks waiting for its own poll ack (2026-09-27).** — full `## D361` entry at file tail | FIXED (port-only, `port/src/watchsettings.c` + F10 probe in `optionsoverlay.c`): queue front F10 save edits to game thread; actual poll-thread Music+FX commits tested with existing and fresh EEPROM, orderly quit, user's ini/save restored. |
+| D362 | **F10 GoldenEye dossier-style visual refresh (2026-09-27).** — full `## D362` entry at file tail | CLOSED (superseded by D363 after user playtest): paper/Zurich/gold look rejected. The pinned section heading and shared layout/hit geometry remain. |
+| D363 | **F10 return to a dark overlay with the N64 watch's green/Bank Gothic visual language (2026-09-27).** — full `## D363` entry at file tail | FIXED (palette/font user-approved, 2026-09-27); D364 follows up on profile tags, click alignment and slider calibration. |
+| D364 | **Settings UI feedback: replace P legend; F10 slider click band misses drawn bar; input defaults visually off-centre (2026-09-27).** — full `## D364` entry at file tail | FIXED (user mostly accepted; D365 follows up on annotation size and click targets). |
+| D365 | **F10 annotation hierarchy and visible mouse change targets (2026-09-27).** — full `## D365` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
+| D366 | **Input calibration label: call 50 the default, not original (2026-09-27).** — full `## D366` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
+| D367 | **F10 full-width control boxes misaligned with their contents (2026-09-27).** — full `## D367` entry at file tail | SUPERSEDED by D368 after user playtest: per-value boxes remained confusing. |
+| D368 | **Simplify F10 click targets: setting name selects, the rest changes (2026-09-27).** — full `## D368` entry at file tail | INPUT RULE KEPT; tinted strips user-rejected, removed in D369. |
+| D369 | **Remove F10 row boxes/strips entirely (2026-09-27).** — full `## D369` entry at file tail | FIXED (awaiting live acceptance): no row backgrounds, control rectangles or hover paint; selected setting distinguished by brighter text, slider tracks remain. Input rule from D368 unchanged. |
+| D370 | **F10 distance/volume drags hurt frame rate (2026-09-27).** — full `## D370` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. (user: fixed.) |
+| D371 | **GEPD key-layout preset + reload/crouch bindings, v0.4.0 modern options wave M3 (2026-09-27).** full `## D371` entry at file tail | FIXED (headless-verified; awaiting user live accept): `Input.Layout` (0=FPS, 1=GEPD) + `Input.CrouchMode` (hold/toggle) in F10 INPUT; GEPD preset acts as per-key effective default under `[Input.Bind]` overrides; new IA_RELOAD/IA_CROUCH emit B (unbound by default). MODERN section dropped per D356 Turok-standard rule (rows land in functional INPUT). Boot/ini-round-trip/F10-page verified; in-game feel-check owed. |
+| D372 | **Low-end perf preset (v0.4.0 modern options wave M5, 2026-09-27).** full `## D372` entry at file tail | FIXED (headless-verified; awaiting user live accept): one-row `Video.LowEndMode` in F10 VIDEO; ON writes FpsCap 30 + MSAA x1 (restart), OFF restores compiled defaults (60/2). Registered key persists through orderly exit. |
+| D373 | **In-game crosshair on/off + colour (v0.4.0 modern options wave M2, 2026-09-27).** full `## D373` entry at file tail | FIXED (user live-tested; rule-2 sign-off recorded 2026-09-27): `Video.CrosshairHide` (0 = on, N64) + `Video.CrosshairColor` (7 tints, 0 = white) in F10 GRAPHICS; #ifdef PORT hook in gunfire.c gunDrawSight (the wave's only rule-2 item). |
+| D374 | **GEPD becomes the default key layout; preset layering fix (v0.4.0 M3 playtest feedback, 2026-09-27).** full `## D374` entry at file tail | FIXED (headless + user live-tested): GEPD playtest found Ctrl fired the gun (stale persisted [Input.Bind] Fire=Left Ctrl masked the preset) and the FPS default made no sense for a mouse FPS. D374: GEPD is now Input.Layout=0 (the default; N64 layout = 1); only EXPLICIT per-key overrides beat the preset. One-off ini flip: pre-D374 `Layout = 1` (ex-GEPD) is now 0. |
+| D375 | **Crouch wired to the wrong pad bit: B is not the crouch input (v0.4.0 M3 playtest, 2026-09-27).** full `## D375` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
+| D376 | **Crouch follow-up + modern-options spot check (2026-09-27).** full `## D376` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
+| D377 | **GEPD independent crouch (2026-09-27).** full `## D377` entry at file tail | FIXED (scripted stance verified; user says live crouch was good; pad/hold-vs-toggle specifics owed): Ctrl/B/Y now drive game stance from the port with no aim requirement. |
+| D378 | **GEPD use/reload separation (2026-09-27).** full `## D378` entry at file tail | FIXED (headless edges tested; user says E/R seemed good; targeted door/partial-mag cases owed): R reload only, E interaction only in gameplay, menu/watch/tank retains B. |
+| D379 | **Crosshair colour: authored texture is red (2026-09-27).** full `## D379` entry at file tail | FIXED (cyan frame capture; live yellow/blue recheck owed): nonwhite tints use alpha silhouette instead of multiplying red RGB; default unchanged. |
+| D380 | **One PC keyboard layout; PD-style rebind design (2026-09-27).** full `## D380` entry at file tail | FIXED (follow-up D383): one PC layout and migration remain; keyboard capture UI is implemented separately, with live acceptance owed. |
+| D381 | **RULE-2-SIGNOFF: opt-in RGB, size and Beta crosshair (2026-09-27).** full `## D381` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
+| D382 | **Crosshair menu placement and slider hitches (2026-09-27).** full `## D382` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
+| D383 | **PD-style keyboard rebinding in both options UIs (2026-09-27).** full `## D383` entry at file tail | **PARTIAL (2026-09-28).** Keyboard/mouse rebinding shipped + accepted in v0.4.0; pad-rebinding UI is the v0.5.0 controller-options wave (CONTROLLER-INPUT-PLAN). |
+| D384 | **Front PC Options key-bind navigation feedback + two-slot UI (2026-09-27).** full `## D384` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
+| D385 | **Real mouse 1–5 action rebinding in the two-slot editor (2026-09-27).** full `## D385` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
+| D386 | **Correct Aim default from SDL middle (Mouse 2) to right (Mouse 3) (2026-09-27).** full `## D386` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
+| D387 | **M7 AllUnlocked's read-time fake unlocks leak into saved EEPROM (2026-09-27).** full `## D387` entry at file tail | OPEN (isolated user-data-copy save/untoggle repro; no fix): a profile-settings save persists fake cheats/0x3FF times; OFF does not undo them. Rotating writes and cheat-award suppression make simple write masking unsafe. **v0.4.0 (2026-09-28): shipped as a loudly-documented EXPERIMENTAL / irreversible option** (row = "All unlocked (EXPERIMENTAL)", ON shows "ON - UNSAFE", README backs it up); safe architecture deferred to v0.5.0. **User-confirmed 2026-09-28: the warning is sufficient -- ship.** |
+| D388 | **Nest functional binding/HUD menus and remove redundant M5 toggle (2026-09-27).** full `## D388` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
+| D389 | **F10 footer clipping and ambiguous submenu links (2026-09-27).** full `## D389` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
+| D390 | **F10 pinned Back selection did not visibly change (2026-09-27).** full `## D390` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
+| D391 | **F10 click at glyph bottom could select next row (2026-09-27).** full `## D391` entry at file tail | **CLOSED (2026-09-28).** Shipped in v0.4.0; accepted in the user's final review / campaign playtest. |
+| D392 | **Train intro soldier hunched; hand intersects torso vs 1964** — firing-table float-offset alias across an 8-byte pointer union. Full `## D392` entry at file tail. | FIXED (`#ifdef PORT` ABI/layout in `chraction.c`); Train aim-angle probe confirms correction; user visually verified the soldier looks fixed against 1964. |
+| D393 | **Controller B/Y both crouch; no in-game use button** — gameplay pad face map conflated modern buttons and native N64 A/B. Full `## D393` entry at file tail. | FIXED + user live-tested A use / X reload / Y weapon cycle. D394 supersedes B crouch and shoulder cycling to match the user-supplied Xbox 1.1 chart. |
+| D394 | **Xbox 1.1 Jinx controller parity** — user supplied the Xbox 1.1–1.4 control table; B is gadgets, stick clicks crouch, LB aim, RB HD toggle (not weapon cycling). | **PARTIAL (2026-09-28).** Xbox 1.1 / GEPD presets shipped and validated in the campaign playtest; presets 1.2-1.4 -> v0.5.0 wave. |
+| D395 | **Controller trapped in keyboard/mouse-only binding capture** — pad could enter the binding modal but could not finish or cancel without keyboard. | RESOLVED (v0.4.0 campaign sign-off 2026-09-28, user: pad capture-block + B-cancel confirmed on the physical pad). Both options UIs block pad-initiated capture; pad B can cancel an active modal; **Steam Deck retest 2026-09-27: a pad enters bindings and is not stuck — the "trapped" symptom is not reproduced on the Deck's Steam Controller**. Distinct from the still-open controller-rebinding *feature* — PR #109 / D383.) |
+| D396 | **F10 pad B/A used as slider decrement/increment, not back/select** — B/Y fed Left and A/X fed Right on every row. | RESOLVED (v0.4.0 campaign sign-off 2026-09-28, user: F10 B/A/slider behaviour confirmed on the physical pad). B backs one F10 page, closes at root; A/X accept/toggle on edge but never shift a slider; D-pad/stick left/right still adjust. |
+| D397 | **SELECT FILE background + intro gun-barrel show an interlaced/comb look** — D182(2) family, but present on FIRST display, not only after re-entry (2026-09-27). Full `## D397` entry at file tail. | **RESOLVED (2026-09-28):** root cause = **H2** (confirmed by a 3-scale PCDUMP test — the comb period is fixed at ≈3 *native* rows across 640/1024/1280, i.e. window-period = 3×scale: the `(<<2)-1` 0.75-native-pixel row-strips rely on RDP floor-based scanline stepping that `gfx_draw_rectangle`'s continuous-float rasterization did not replicate). **Fix (port-layer, surgical):** `gfx_draw_rectangle` now floors the top edge to its scanline and the bottom edge to the last covered scanline (the RDP scanline range `[top,bot+1]`) before rasterizing — a no-op for whole-pixel rects, so only sub-pixel strips change; §D15 in `docs/porting-notes.md` documents the mechanism. H1 (ENV_ALPHA 5-bit CC scaling) REFUTED by a byte-identical PD-port cross-check (the darkening it predicted is faithful, not a bug). `GE_D397SH`/`D397SHM` probes removed. Verified by playtest: gun-barrel + file-select gradient render clean, comb gone. |
+| D398 | **PC Options label: D348's procedural slider glyph replaced with an existing mainfolderimages texture (2026-09-27).** Full `## D398` entry at file tail. | SUPERSEDED by D400 (icon removed, text-only label; purple-hover bug moot; the D400 `textMeasure` arg-swap bug was found and fixed there). |
+| D399 | **File-select bottom row (SELECT FILE text + Copy/Erase + PC Options dot/label) shifted 15px left as a block (user sign-off 2026-09-27).** Full `## D399` entry at file tail. | LANDED (port-only, D343 class): `selectpos.f[0]` 110→95 (new `#ifdef PORT` in front.c), Copy 200/222→185/207, Erase 282/304→267/289 (D343 PORT block), `LABEL_X` 358→343 (`frontoptions.c`); hit bounds recompute from the shifted positions. Clean build, PCDUMP frame 195 no overlaps; by-eye check **done 2026-09-28 (user: good) -- RESOLVED**. |
+| D400 | **File-select "PC Options" label: icon removed, text-only; fixed a swapped textMeasure() arg bug and a 7px vertical misalignment vs Copy/Erase (user sign-off 2026-09-27).** Full `## D400` entry at file tail. | LANDED (port-only, `frontoptions.c`): D398's dot icon dropped on user instruction ("most straightforward design"); label now a plain `textRender` word, GAP 28 after Erase. Two measurement bugs fixed: (1) `textMeasure(textheight, textwidth)` args passed swapped -> label 35px above the bar, hot band transposed; (2) bare-label height measures 0 (vs 14 for newline-terminated Copy/Erase) -> 7px lower tops; fixed by restoring the `kLabelNL` height measure. PCDUMP frame 195: tops aligned 279.8 vs 279.3, 31px clearance from Erase; by-eye check **done 2026-09-28 (user: good) -- RESOLVED**. |
+| D401 | **Rumble Pak → real gamepad haptics (re-promotes D224/M-87): `CONT_CARD_ON` gate + `osPfs*`/`osMotor*` shims rewired to `SDL_GameControllerRumble`, single global `Rumble strength` slider on `Input.RumbleScale` (2026-09-27/28).** Full `## D401` entry at file tail. | **CLOSED (2026-09-28).** Rumble shipped in v0.4.0 (D401); Steam Deck real-pad validation: works, user confirmed for release. |
+| D402 | **First clean Linux (glibc / SteamOS) build of `release/v0.4.0` broke in two port-layer include-resolution spots MinGW tolerated (`bondconstants.h` first-include cycle; `video.c` `_SHIFTL`); fixed in `port/shim/` only, no `src/` edits, no rule-2 (2026-09-27).** Full `## D402` entry at file tail. | RESOLVED (Linux/Ubuntu-24.04 build → glibc-2.41-compatible binary for the Steam Deck): three `port/shim/` fixes — `PR/ucode.h` + `bondconstants.h` defer `pc_protos.h`/`bondtypes.h` during `bondconstants.h`'s own `ultra64.h` pass (breaks the `bondconstants.h`→`ultra64.h`→`pc_protos.h`→`bondtypes.h`→`bondconstants.h` re-entry); `PR/gbi.h` defines `_SHIFTL`/`_SHIFTR` when absent (the `gbi.h` macros in `video.c` lost their `mbi.h` definitions on Linux). Builds + links clean on Ubuntu-24.04; bundled `libSDL2` with `rpath=$ORIGIN`; pushed to the Deck. Shim files uncommitted at log time. |
+| D403 | **Deck v0.4.0-pre test: Nintendo logo + copyright/legal page render broken, broader graphics "not reflecting new updates" — stale 0.3.0-era sidecars seeded into the bundle (2026-09-27).** Full `## D403` entry at file tail. | RESOLVED (2026-09-27, user-confirmed on the Deck: a stale-sidecar **test artifact**, never a code regression): the Deck bundle was seeded with `pcmodels`/`pccg` sidecars from the `0.3.0-local` install (data dated 2026-09-15), which predate the D75/M-197 embedded-blob raw-pixel fix (commit `425237f9`, 2026-09-21) and the D303 muzzle-vertex converter fix (2026-09-26) → the `P*` front-end logo/legal textures come out broken while the in-exe fast3d fixes (D397 gun-barrel/file-select) render correctly. Disambiguation performed (2026-09-27): `data/pcmodels-ntsc-final` + `data/pccg-ntsc-final` regenerated from the Python-source converter (`prepare-assets.py` + the current `d43/d69/d88_emit.py`, `ALL CHECKS PASSED`) on the NTSC ROM and pushed to the Deck (sha256-verified), preserving the user's `ge007.eep`/`ge007.ini`. **RESOLVED (user-confirmed on the Deck, 2026-09-27): the front-end logo / copyright / legal page now render correctly — confirming this was the stale-sidecar test artifact, not a code regression.** |
+| D404 | **"Aim style: Centred (PC)" (`Input.AimMode=1`, D333/D337) has no effect when aiming with a gamepad; the row's own doc comment over-promises ("applies to every aim input … pad trigger") but the centred path is mouse-only (Steam Deck, 2026-09-27).** Full `## D404` entry at file tail. | RESOLVED (2026-09-28, port-layer only): the toggle now genuinely works on a pad. First logged as a communication bug and closed by relabel ("Aim style (mouse)"), then reopened on user challenge the same day: the pad path emits stick *deflection* into the game's crosshair integrator, so the pad was actually always in the **N64** model (a flick travels the crosshair; edge-scroll + spring-back only made a hold *feel* direct), and the "stick has no crosshair to travel" rationale was wrong. Fix: new `padDirectCompute()` in `input.c` (the pad twin of `hipDirectCompute`'s CENTRED branch -- same dead/watch/pause/cutscene gates, minus the mouse-grab gate; revised same day to the N64 path's verbatim quadratic curve -- `sign(v)\u00b7clamp(defl/70,\u00b11)\u00b2 \u00b7 (fov/60) \u00b7 3.5 deg/poll` (bondview2.c canNaturalTurn/canNaturalPitch), full-stick parity with the N64 path, pending retest on the refreshed build); the natural-pitch pad branch routes CENTRED through it and keeps N64's classic deflection behaviour (also the default, so no pad default changes). Labels reverted to device-neutral ("Aim style" / "Aim range"). **Steam Deck (2026-09-27/28): "centered aim is broken on deck too" -- fixed for real; the first-pass linear gain was measured as a dramatic sensitivity loss (2026-09-28) and replaced with the N64 path's verbatim quadratic curve (fov/60 base, 3.5 deg/poll top rate); retest on the refreshed build: user "good". RESOLVED.** |
+| D405 | **fast3d/OpenGL: MSAA above the driver max black-screens; two `glBindFramebuffer` calls pass an index where a GL name is required** (PR #106, italoarruda, 2026-09-25; label rebased from the PR's D323, which collides with our D323 LOD finding) — full `## D405` entry at file tail | **CLOSED (2026-09-28).** Merged from PR #106 (code file only; security-reviewed clean - port-layer GL state fixes, no external input, no allocations). `resolve_msaa_color_buffer` clamps `msaa_level` to `GL_MAX_SAMPLES`; the resolve/copy paths bind `current_framebuffer ? framebuffers[i].fbo : 0` (the default framebuffer is GL name 0). Both Windows (opengl32) and Linux use this backend. Post-merge sweep 21/21 PASS + MSAA=16 boot check render-verified; link ritual clean. |
+| D406 | **front-end PC Options screen: long sections (INPUT, 17 visible rows) run off the bottom of the 440x330 paper, and rows past the silent 14-row page cap (Crouch mode, Reset to defaults, Bindings…) are unreachable from the front screen** (user report 2026-09-28, v0.4.0 pre-push review) — full `## D406` entry at file tail | RESOLVED (2026-09-28, port-layer only, Steam Deck-validated): in-section pagination in `frontoptions.c` (11 rows/page; the vertical stepper crosses page boundaries both ways; dim 'Page p/N' marker + bottom hint on every page; slider bars start past long labels; F10 overlay untouched). D406b/c/d same-cycle refinements (label/track overlap, hint visibility/wording, hint position). |
+| D407 | **Tanks cannot be boarded/exited on PC — the v0.4.0 use/reload split (D378/D393) removed the B-button tap the engine's tank handlers in `bondview2.c` consume (user report 2026-09-28, Runway/Streets)** — full `## D407` entry at file tail | PARTIAL (fix landed in v0.4.0, port-only): present `GE_CONT_B` on the use (E / pad A) rising edge only while `g_PlayerIsInTank == 1` or `g_BondCanEnterTank != 0`; E keeps its D378 no-reload-fallback semantics elsewhere, N64 layout unaffected. D407(b) same cycle: front PC Options page-edge highlight clamp + mouse-wheel / W-S paging (wheel queue consumed on the menu, D223); its wheel mapping shipped inverted and was fixed to match W/S (wheel up = step up). D407(c): board-animation lockout, menu-accept B gate in tank states, in-tank aim routed through the legacy velocity stick + `Input.TankAimScale` knob. RESOLVED 2026-09-28: all port-only (src/game zero-diff), 10 TANKDBG probes stripped, release binary verified clean, user live tank drive signed off (board + exit OK, aim feel good). |
+| D408 | **`Game.SkipIntro` skips the post-mission failure dossier: with it on, entering a level then aborting (watch Z+A) or dying (KIA) returns straight to the menus — no REPORT / "Mission status: KILLED IN ACTION / ABORTED" screen** (user report 2026-09-28; save/AllUnlocked ruled out) — full `## D408` entry at file tail | OPEN (cosmetic, not root-caused past the handoff site). v0.4.0 decision: SkipIntro stays EXPERIMENTAL / not recommended for regular users — F10 row relabelled "Skip intro (EXPERIMENTAL)". |
+
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
@@ -2426,6 +2512,12 @@ Confidence overall: **medium** (triage solid, no runtime confirmation).
> renders as degenerate white blobs" plus the unverified cast roll; the
> gun-barrel half is retired. The M-32b probe text below is left for history.
+> **M-195 UPDATE (v0.4.0 Phase 1, 2026-09-21) — Nintendo logo: vertex transform proven CORRECT; leading hypothesis now = G_VTX slot overwrite.** Three-pass investigation this session: **(1) Emission is fine.** Fresh `GE_D63=1` bare-front-end run (log `/c/tmp/d75_d63.log`, 32k lines): all 23 nintendologo Primary GDLs are emitted **every frame** of the logo screen (~1002 hits each, BaseAddr=0x70157a98), and the log contains **zero** `D146: unknown GBI opcode` lines — the software RSP consumes every DL to completion. PC-side GDLs verified byte-faithful to the N64 source via the d43 toolchain (same custom GE opcode sequences, same vertex counts). **(2) Transform is fine.** New `GE_D75V="lo-hi"` probe in `gfx_sp_vertex` (per-batch transformed-vertex min/max + MP translation row; uses new `videoGetFrameCount()` accessor) shows the logo's vertices land exactly where the wordmark geometry says they should: x∈[−2500,+2500], y∈[±644], w≈3.9k–5k, MP translation (0,0,3878.7) matching `lookat(0,0,4000)` — this eliminates basematrix, the f32→s32 conversion loop, and the matrix-list suspects. **(3) Leading hypothesis: vertex-slot overwrite.** Every `G_VTX` batch in these GDLs encodes **v0=0** (fast3d decodes `n=C0(0,16)/sizeof(Vtx)`, `dest_index=C0(16,4)`; probe confirms di=0 for every batch), so a node whose vertices span multiple 16-vertex batches writes all of them to slots 0..n−1 of the shared buffer — each later batch **overwrites** the earlier ones and only the last batch survives. Triangles referencing indices beyond the last batch's count read stale slots from prior nodes (likely clipped, not visible as junk — consistent with the observed single ~25×6px orbiting fragment matching the smallest node's 4-vert quad at x≈1020 → ~155px orbit radius vs observed ~200px). The N64 RSP must handle multi-batch nodes differently (per-batch ring or relative indexing) — GE's own ucode is not in this repo, so the next step is empirical: dump one full node's GDL stream from the ROM and cross-reference its `G_TRI4`/`G_TRI1` index ranges against per-batch vertex counts. If confirmed, the fix is port-layer only (fast3d vertex-slot management) — no rule-2 sign-off needed. Probe registered in `GE-ENV-PROBES.md` as `GE_D75V`.
+
+> **M-196 UPDATE (v0.4.0 Phase 1, 2026-09-21) — slot-overwrite hypothesis REFUTED; bug narrowed to the color-combiner/texture path.** New `GE_D75D="lo-hi"` probe in `port/fast3d/gfx_pc.cpp`: per-frame G_VTX/G_TRI token stream + per-triangle rejection/emission accounting (trivial-reject, backface-cull, NDC bbox, invisible-flag, vertex-color range, oml, depth z/w, combine mode, prim/env color). Front-end boot (no `-level`), logo window = frames ~244+ where a large model appears (nv=93 G_VTX batches, **nt=1018** tris/frame; the earlier frames 5–243 are the title-screen textured quads, nv=2/nt=12, which render fine at 8.36% coverage). **(1) Slot-overwrite is refuted.** The logo model's DL is **streamed**: `seq` = `VTTTT…VTTT…` (each G_VTX batch immediately followed by its own triangles), and `maxtri=15` — no triangle ever references a vertex index beyond 15, i.e. within a single 16-slot batch. M-195's "all batches write to slot 0, only the last survives" mechanism **cannot apply** to this model; there is no cross-batch clobbering. **(2) Rejection gates are not the cause.** Of the 1018 tris/frame: `rej_triv=0`, `rej_cull≈520` (a normal ~half for a closed mesh), **~500 reach OpenGL**; and all ~500 are on-screen (`emit_on≈500, emit_off=0`, NDC bbox ⊂ [−1,1]²). Not the invisible flag (`invis=0`). Vertex colors span `[0,255]` (not black). Depth `z/w ∈ [−1, 0.97]`, no NaN. **(3) Symptom quantified.** PPM pixel analysis of the logo window: **0–33 content px/frame** (a ~19×2px fragment right of centre) vs the title quads' 8.36% — this is M-195's "tiny orbiting fragment", now measured. **(4) Narrowed cause = texture-modulate material rendering black.** The model's combine mode is `0x009ffe4f19ffe4f1` (`is_2cyc=0`). A one-shot decode in `gfx_generate_cc` (new `D75DCC:` log, gated to this exact word) shows it parses to **rgb a=`G_CCMUX_TEXEL0`(1), c=`G_CCMUX_SHADE`(4)** — a texture-modulate material — and `gfx_generate_cc` sets `used_textures[0]=1`. `env=(0,0,0,0)`, `prim=(255,255,255,255)`. Intended output = TEXEL0 × SHADE; if TEXEL0 samples black at draw time (no/empty texture bound), the whole model is black-on-black → invisible except AA-edge fragments. **Anomaly to close next:** at emit time BOTH `gfx_rapi->shader_get_info(prg)` and `comb->used_textures[0]` read **0**, even though `gfx_generate_cc` set it to 1 for this exact combine_mode — so the TEXEL0 input is being lost between combiner build and draw (suspects: `shader_id0`→`gfx_lookup_or_create_shader_program`, the `prg[tm]` variant selection at gfx_pc.cpp:~2503, or the GL backend's sampler binding / texture upload for this model's tile). If the TEXEL0 sampler is dropped, `SHADER_TEXEL0` defaults to 0 → MODULATE → black. Port-layer fast3d only; no rule-2 sign-off needed. Probes (`GE_D75D`, `D75DCC`) registered in `GE-ENV-PROBES.md`; both env-gated/inert without the var.
+
+> **M-197 UPDATE (v0.4.0 Phase 1, 2026-09-21) — ROOT CAUSE FOUND + FIXED: the D50 sidecar converter reserved and remapped the model's embedded texture-blob span but never copied the pixel bytes into the emitted image, so TEXEL0 sampled a zeroed buffer.** The M-196 "utex=0 anomaly" was first closed as a probe artifact: the frame-level `utex`/`cutex` fields were sampled on the *first* emitted triangle of the frame (a title quad, utex=0) while `comb` held the *last* triangle's mode; new logo-mode-specific fields (`lg_utex`/`lg_tex`, gated to combine word `0x009ffe4f19ffe4f1`) show both = 1 for every logo triangle — the combiner and GL shader correctly claim a texture. The real defect is upstream in the asset pipeline: the NINTENDO logo model (`PnintendologoZ`, loaded via `front.c` `init_menu00_nintendoscreen` → `load_object_fill_header(dst=ptr_logo_and_walletbond_DL, 0x3c000)`) carries its texture pixels **embedded in the model file** — GDL `G_SETTIMG` (GE opcode `0xFD`) slots with seg-5 (file-base-relative) targets. The N64 original has a 1024-byte white-wordmark IA8 blob at file offset `0x408`. The D50 offline converter (`tools_pc/d43_emit.py`) detected the blob span (`[0x408, 0x808)`), reserved it in the PC layout (new offset `0x6428`), and remapped all 23 SETTIMG slots to `w1=0x05006428` — **but the emit pass never copied `src[0x408:0x808]` into the output buffer**, so the sidecar's RZ image carried a zero-filled blob region. At runtime `seg_addr(0x05006428)` = file base `0x70157a98` (M-195's BaseAddr) + `0x6428` = **`0x7015dec0`** — exactly the all-zero texture buffer the `GE_TEXDUMP_ADDR` probe measured (both imports, dls=242 and dls=4444: `nonzero=0/1024`). TEXEL0 × SHADE over black pixels = the whole 1018-tri model renders black-on-black. **Three files are affected** (the only model files with seg-5 SETTIMG targets, found by scanning all 512): `PnintendologoZ` (`[0x408,0x808)`), `PgoldeneyelogoZ` (`[0x68,0xb30)`, 2 images), `PlegalpageZ` (`[0x90,0x2070)`, 5 images). **Fix (tooling-only, no `src/game` change, no rule-2 sign-off):** `d43_emit.py` now tracks `blob_regions` through layout, raw-copies each span in the emit pass (`buf[n:n+osz] = src[o:o+osz]` — the RDP reads these bytes as-is, no transform), and round-trip-validates the blob bytes so this cannot silently regress. Sidecars regenerated (`ALL CHECKS PASSED`, incl. the new blob check); offline re-decode confirms all 8 blob targets now carry real pixels (nintendologo: `ffffffff…` white wordmark, 713/1024 nonzero). **Live-verified:** bare front-end boot, `GE_PCDUMP="240-330:10"` — logo window now renders the animated 3D logo: frame 320 = **23,302 non-black px (7.59%)**, wide wordmark bbox `(94,190)-(542,290)`, vs M-196's 0–33 px; frames 340–560 show the logo rotating through its full animation at 3–10% coverage. No placed object offset falls inside any blob span (checked for all three files), so the old-space overlap between a PC-footprint node region and the nintendologo blob start is a bookkeeping artifact with no pointer ambiguity. **User-confirmed live (2026-09-21):** "the images and legal page and Nintendo logo are good now." **Remaining:** `pal-final`/`jpn-final` sidecars must be regenerated on machines holding those ROMs (`python tools_pc/d43_emit.py `). Probes added this pass: `GE_TEXDUMP_ADDR=` (raw source-byte dump + stats for every import from one address, bypassing the 400-cap) and the D75D `lg_*` logo-mode fields; both env-gated.
+
**D75 Bug 2 — RUNTIME PROBE (session M-32b, `GE_D75=1` in `title.c`
`sub_GAME_7F007F30`, kept env-gated).** Booted the bare front end
(`GE_D75=1 GE_PCDUMP="1-1200:120"`, no `-level`) and captured through the
@@ -10448,6 +10540,8 @@ crash class C2 (Facility `-level_34`, Runway `-level_35`): `import_texture_i8` A
**Status (from §F table):** OPEN — observed, not investigated
+**Steam Deck (Linux / SteamOS, v0.4.0-pre, 2026-09-27/28 — user playtest):** the same intermittent frame-timing hitches reproduce on the Deck, but with a *different* utilisation profile than #92/D339: both CPU and GPU sit at only ~15–30 % (nowhere near a throughput bottleneck) yet the user perceives occasional "weird frame timing." Low-utilisation hitches like this are a **frame-pacing / present** problem, not a throughput problem. Candidates: the D186 `Video.FpsCap` throttle (the D186 row notes it throttles the *sim*, not just presentation), the D248/D250 pacing path (fixed for the 30-vs-60 case), or the Deck's Wayland/GBM + AMD present/vsync behaviour. Next (if pursued): a `GE_PERFSTAT=1` run plus a per-frame `dl`/`swap-pace` time histogram on the Deck, to see whether the hitches are a pacing-wait spike or a present stall. **Log-only this pass — not investigated yet.**
+
## D172 — Bullet-impact / blood / spark particles render magenta or cyan instead of dark red (M-82, ROOT CAUSE FOUND + FIXED).
**Bullet-impact / blood / spark particles render magenta or cyan instead of dark red (M-82, ROOT CAUSE FOUND + FIXED).** Particle billboards (`explosion.c` `explosionRenderPart`, `gSPVertex`+`gSP2Triangles`) take their CC/tile state from ROM state records replayed just before them (`g_ExplosionDisplayLists[]` = `assets/oddtextures.c` `globalDL_0x078..`). The dominant record sets `gsDPSetCycleType(G_CYC_2CYCLE)` + `gsDPSetTextureLOD(G_TL_TILE)` + `gsDPSetCombineMode(G_CC_INTERFERENCE, G_CC_MODULATEIA2)` and binds **two** tiles: tile 0 = IA8 "smoke" @ TMEM 0, tile 1 = RGBA16 "fire" @ TMEM 0x188. `G_CC_INTERFERENCE` cycle-0 = `TEXEL0 * TEXEL1`. **fast3d's `gfx_lod_tile_offset()` returns `0` unconditionally** on the `!gfx_detail_textures_enabled` branch — a D107 fix for GE's mip textures (whole chain loaded in one LOADBLOCK at TMEM 0; `port/src/video.c:225` sets the flag `false`). So texunit 1's sampled tile = `first_tile_index + 0` = tile 0 → **TEXEL1 samples the smoke texture, not fire** → `smoke * smoke` → wrong colour. Two earlier M-82 candidates were false trails: (1) "0xB9 unhandled → D146 abort" — 0xB9 is `G_SETOTHERMODE_L` in this non-F3DEX2 build, handled; zero D146 aborts in a sustained-fire run. (2) "records never set 2-cycle" — they do (`0xBA`), and fast3d applies it (probe-confirmed `cycletype=2CYC`). **Probe evidence (`GE_D172=1`, user Silo firefight, `d172_silo.log`):** 40/40 particle tris → `cfg1[tile=1 tmem=392 fmt=RGBA siz=16b]` but `SAMPLED1 = tile0 (tmem=0 fmt=IA)`, `tex_lod=0`.
@@ -10688,6 +10782,13 @@ D243 (cutscene shake/duplication, its own CONSOLIDATED NEXT STEPS block appears
## D224 — QoL gap (M-87, from a PD-PC-port survey): the N64 Rumble Pak is fully dropped instead of modernized.
+**DONE via D401 (2026-09-27).** Re-promoted from DEPRIORITIZED; implemented per
+`docs/dev/RUMBLE-PLAN.md` exactly as the M-87 addendum prescribed — both
+upstream blockers (`CONT_CARD_ON` gate + `osPfsInit` → `PFS_ERR_DEVICE`) landed
+with the `SDL_GameControllerRumble` wiring, plus `Input.PlayerN.RumbleScale`
+config (options-menu slider deferred). Real-pad playtest + `RumbleScale=0`
+silence check owed. See `## D401` (file tail) for the full record.
+
**QoL gap (M-87, from a PD-PC-port survey): the N64 Rumble Pak is fully dropped instead of modernized.** GE genuinely has a rumble subsystem (`src/motor.c`, `src/joy.c`) but the port's `osMotorInit/Start/Stop` (`port/src/libultra.c:1220-1223`) are all hard stubs — "no accessories on the PC," `osMotorStart`/`osMotorStop` return `-1` unconditionally, `osPfsFindFile`-family returns `PFS_ERR_NOPACK`. So every in-game rumble event (weapon recoil, explosions, etc.) computes and calls into a dead path. **PD's PC port wires the equivalent straight through to real gamepad rumble:** `inputRumbleSupported(idx)` checks `SDL_GameControllerHasRumble`/`SDL_JoystickIsHaptic` (with a Windows fallback: some pads report no haptics but rumble anyway), `inputRumble(idx, strength, time)` calls `SDL_GameControllerRumble()` scaled by a per-pad `RumbleScale` config value (options-menu slider, `Input.PadN.RumbleScale`), and `osPfsFindFile` reports a "Rumble Pak" present exactly when a real pad supports rumble (`libultra.c` — same "pretend the N64 accessory is there" pattern GE's port already uses for `osMemSize`/Expansion Pak, just applied to a real capability instead of faked). Cross-ref `QOL-INVENTORY.md` (folded into the existing "per-pad tuning" row).
**Status (from §F table):** **DEPRIORITIZED (M-87, user call).** Gamepad-only — irrelevant to mouse/keyboard, which is the primary target for this next release; no keyboard/mouse play is affected by NOT doing this. Root cause + fix still valid whenever gamepad support becomes a priority (no `src/` game-logic touch: GE's `motor.c`/`joy.c` call sites already exist and already call the `osMotor*` shims; the fix is entirely in `port/src/libultra.c` + `input.c` — add `inputRumbleSupported`/`inputRumble` per the PD shape, wire `osMotorStart`/`osMotorStop` to them, add `Input.PadN.RumbleScale` config). **M-87 PD-legacy-survey addendum (`docs/dev/notes/PD-LEGACY-SURVEY.md` candidate 1, high confidence) — the D224 fix as originally stated is NOT sufficient by itself.** Two more upstream blockers sit before the stubbed `osMotorInit/Start/Stop` ever gets reached: (1) `port/src/libultra.c:1096-1098` sets `g_contStatus[i].status = 0` for every pad — `CONT_CARD_ON` is never set, and `src/joy.c:184` gates the *entire* rumble-pak init path on that bit, so `joyRumblePakInit()` never even calls `osPfsInit`. (2) `src/joy.c:186-190` only attempts `osMotorInit` if `osPfsInit`'s return is `PFS_ERR_ID_FATAL` or `PFS_ERR_DEVICE` — but the port's stub (`libultra.c:1214`) returns `PFS_ERR_NOPACK`, so motor init is never attempted even once (1) is fixed. PD's exact trick (`libultra.c:213-227,349-351`): set `CONT_CARD_ON` for connected pads, and make `osPfsInitPak()` return `PFS_ERR_DEVICE` specifically when the pad supports rumble. **Whenever D224 gets implemented, both of these must land alongside the `SDL_GameControllerRumble` wiring** or the game's state machine parks at `RUMBLEPAKINITSTATE_NOT_READY` forever and nothing audibly changes despite looking wired up correctly.
@@ -10704,6 +10805,10 @@ D243 (cutscene shake/duplication, its own CONSOLIDATED NEXT STEPS block appears
**Status (from §F table):** **OPEN — root cause / call sites identified, not designed or implemented.** Two viable directions: (a) a `#ifdef PORT` wrapper at just these 2-3 `bondview2.c` call sites that scales the emitted `gSPTextureRectangle` geometry (`xl/yl/xh/yh`) by a new `f32 portHudScale` (default 1.0 ⇒ byte-identical) while inversely scaling `dsdx/dsdy` to keep the same source glyph texels mapped onto the larger/smaller rect, re-anchored so the opposite edge (bottom for the lower-left block, top for dialogue) doesn't move; or (b) thread a scale parameter into `textRenderOutlined`/`textDrawGlyphQuad` itself, defaulted to 1.0 at every other call site. (a) is more surgical (matches the "don't touch shared menu text" constraint) but duplicates a little geometry math; (b) is more central but touches a much more widely shared function. No PD precedent found (PD's port has no equivalent HUD-scale option). F10 overlay slider candidate once implemented, alongside `Video.FovScale`.
+**Design refinement (2026-09-23, not implemented).** Per-glyph scaling in `textDrawGlyphQuad` (option a/b above) is insufficient. Both HUD text sites first draw a background box sized to the measured *unscaled* text (lower-left: `draw_blackbox_to_screen`; top dialogue: `microcode_constructor_related_to_menus` over `msg.y..msg.bottom`), NTSC's dialogue path is `textRender`, not `textRenderOutlined`, and the ammo counter pairs text with an icon image (`set_rgba_redirect_generate_microcode`). Scaling glyphs alone would overflow the boxes and split the ammo readout. **Recommended:** a port-side span transform in fast3d, analogous to D335's aspect modes: a new EXT command carrying (scale, anchor x/y) that scales *all* rect and vertex positions (x and y, plus texrect dsdx/dsdy) about the anchor until cleared. The game-side edit is then just a wrap around each HUD call (box + text + icon together), anchored bottom-left for status text, top-centre for dialogue and bottom-right for ammo. The D335 LEFT/RIGHT/CENTER anchoring composes with it. Slider `Game.HudScale` 75–200%, default 100 (no emission, byte-identical).
+
+**IMPLEMENTED (2026-09-24, v0.4.0) as designed above: `Game.HudScale` (F10 "HUD scale %", 75–150, default 100; capped at 150 after the user playtest: "past that not super effective").** fast3d: new extension command `G_HUDSCALE_EXT` (opcode 0x46, free in GE's command space; w0 low 16 = scale×256, w1 = anchor x/y in 10.2): while active, `gfx_dp_texture_rectangle` / `gfx_dp_fill_rectangle` scale rect coordinates about the anchor and divide texrect dsdx/dtdy by the scale (glyphs stay crisp, same texels). Rects clip at the canvas origin (negative rects aren't drawn). The state resets every frame. It composes with the D335 aspect anchors (scaling happens in logical space before the aspect mapping). Game (`#ifdef PORT`, Rule-2 approved presentation, `port/include/hudaspect.h` macros): bottom messages / title card anchored at the view's bottom-left; top dialogue anchored at its text origin (`msg.x` = view left + 0x1e); ammo per hand at the bottom-right / bottom-left. **Width cap:** dialogue and bottom-message line breaks are authored into the text (no runtime wrap), so those two scale only as far as their widest line still fits (`textMeasure` with the renderer's fonts; long lines stay at ~100% instead of clipping). At 100% nothing is emitted (byte-identical; golden 3/3). **Verified (1280×720 captures):** title card and ammo at 150%/200% correctly enlarged about their corners; Archives dialogue no longer clips (an earlier centre-anchor attempt cut off "Guard:", and an origin anchor without the cap overflowed the right edge). Windows + Linux builds clean. Commit `30720354`.
+
## D230 — In-level music quality: a bass instrument sounds corrupted on PC, correct on N64 reference (M-105, user reports; repro refined M-106).
**In-level music quality: a bass instrument sounds corrupted on PC, correct on N64 reference (M-105, user reports; repro refined M-106).** Filed to give the post-D77 residual its own label — D77 (in-level music *absence*) is RESOLVED (PR #42, missing `return` in `sub_GAME_7F0C0BF0` zeroed the seqp volume on the only in-level trigger path), so "music doesn't play" claims in tracked docs are stale; what actually remains is this quality gap. **M-106 repro, deterministic (not intermittent):** Control's elevator track and its regular in-level track both carry the bad instrument throughout; Cavern's elevator track has it too; Runway's track is otherwise fine but the same instrument enters ~5–7s after the track starts and sounds bad from then on. User confirmed against original N64 reference audio that a bass instrument belongs there on Runway — this is **not** a wrong-instrument/wrong-bank selection (rules out the D77 trigger-path family and the seqp-volume fix's neighborhood); the instrument choice is correct, only its *sound* is wrong on PC. Determinism (same track, same spot, every time) points at a static decode/synthesis bug for this instrument's wavetable/ADPCM data — most likely low-frequency-specific (pitch-shift/downsample artifacting, a wrap/loop-point error, or a clipping/gain issue that only bites bass-range samples) — rather than a live mixing/voice-stealing issue (D147/D152/D202 family), which would be intermittent. The mixer layer and the trigger layer are both proven working by every track that sounds right, so the suspect neighborhood is the seq/instrument-bank decode path (`csplayer.c`/`synthesizer.c`, bank loading), not `port/` audio plumbing.
@@ -10728,11 +10833,22 @@ D243 (cutscene shake/duplication, its own CONSOLIDATED NEXT STEPS block appears
**Status (from §F table):** 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.
+**M-193 (v0.4.0 Phase 1, 2026-09-21): port-side GL texture cache found — the M-108 refutation stands for the *game-side* CI cache, but the port has its own cache with the exact same structural hazard, and it was never invalidated on stage transitions.** The PC renderer keeps a `TextureCacheMap` (`port/fast3d/gfx_pc.cpp`) mapping `(source address, palette addrs, fmt, siz, palette_index, size_bytes, palette_hash)` → uploaded GL texture, with **no content check** — a hit trusts the key. Stage textures' source addresses live in the `MEMPOOL_STAGE` arena, which the per-stage `while (!done)` block in `boss.c` resets via `mempResetBank`/`memaReset` on *every* transition (death/restart included — M-108's call-graph trace already established both paths re-run that block). After the reset, any cache entry keyed into that arena is stale; if the texpool places a different image at an address that held another image in the previous run of the level, the lookup hits and returns the old pixels = "wrong textures after dying/restarting", never on a cold boot (empty cache). The N64 has no such cache (the RSP re-reads DRAM every frame), so this is a pure port-side defect — fixable in `port/`-adjacent code with no rule-2 sign-off. **Fix applied:** the orphaned `videoResetTextureCache()` (`port/src/video.c`, zero callers) is now wired into that per-stage block right after `memaReset`/`reset_play_data_ptrs` (`#ifdef PORT`) — a full clear on every stage load. Re-uploads are pixel-identical (same content → same GL upload), cost is one-time during the loading screen; ROM-segment textures (menu/HUD) just re-upload too, harmlessly. `GE_D235=1` logs the purged entry count per transition (cold boot = 0, death/restart > 0) as direct mechanism evidence. **Remaining:** live death/restart confirmation at playtest (wrong textures gone?) + a `GE_D235` capture showing >0 on a restart; if symptoms persist after this, the next suspect is intra-level texpool eviction reusing addresses mid-play (nothing emits `G_INVALTEXCACHE_EXT`, so per-address staleness is possible there too — the handler exists at `gfx_pc.cpp:3624` but has no emitter).
+
+**v0.4.0 verification pass (2026-09-23, native Windows, headless): fix wired and clean on reload; the bug itself did NOT reproduce, even with the fix disabled. Needs the user's original trigger to close.** Harness: `-level_09`, scripted watch Abort → title stage → new TEMP `GE_D235_RELOAD=` re-enters Bunker 1 via `bossSetLoadedStage()` (the front end's mission-start call) after a front-end dwell; new TEMP `GE_D235_OFF=1` skips the reset for an A/B. Results:
+- Fix ON: cold boot purges 0, the abort into the title purges 527–833, and re-entry purges 63 (front-end entries). The reloaded level renders correctly; settled gameplay matches the fix-OFF run 7/9 frames, and the 2 fails are intro-fade/camera timing (D117), not textures.
+- Fix OFF, identical load order (intro both times, 300-frame legal-screen dwell): 588–788 stale entries survive into the reload; **all textures correct**.
+- Fix OFF, divergent load order (first load skips the intro, so only the shaft is seen; the reload plays the full control-room flyby): 589 stale entries survive; **all textures correct** at full resolution (`scratch/d235_1750.png`).
+
+So on this path, stale keys either miss or hit the same image. Either GE's placement of Bunker 1's bg textures is order-independent here, or the key (`palette_hash`, `size_bytes`, fmt) disambiguates. The fix stays correct by construction: an address-keyed cache with no content check outlives an arena reset, and clearing it at every stage load costs only re-uploads during the loading screen. What's untested is the reported trigger: a real **death → mission failed → mission select → restart** (the front end's image-heavy briefing/folder screens and 3D models go through the stage arena in between) on the level where it was seen (Facility first). **Human test:** reproduce with `GE_D235_OFF=1` (expect wrong textures) and without (expect clean); note the level and surfaces. Confidence that the fix addresses the report: medium, since the mechanism is real but the trigger is not captured.
+
+**2026-09-25 playtest (user, Windows, one ~46-min session Silo → Caverns, `GE_D322=1 GE_D204=1`, build `7baf4900`):** die → mission select → restart did not reproduce wrong textures (fix on). Still unreproduced in every configuration; stays OPEN only because the original trigger was never observed locally.
+
## 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).
**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.** **Tenth pass (M-155): that CPU-side-instantiation framing itself is REFUTED — three independent deterministic re-runs of the same `-level_36` boot (one with sustained camera rotation) proved the class's per-frame count is byte-identical, not run-to-run dependent; the earlier "~4 vs 720" comparison was an outdoor-vs-indoor room mismatch (the boot's scripted intro walks the camera out of the tree-bearing room at a fixed frame), not nondeterminism. No fix; the original wall-vs-discrete-sprites symptom is unresolved and untouched by this pass.** Distinct from D176(b)'s painted tree-line backdrop. Logged in `GRAPHICS-BACKLOG.md`.
-**Status (from §F table):** OPEN — tenth pass refuted the "instantiation is nondeterministic" theory (ordinary room-based occlusion, not a bug); original wall-vs-sprites symptom still unresolved; see D276/D280/M-155
+**Status (from §F table):** OPEN — **pass 24 (2026-09-21, static) DECODED THE TREE TEXTURE** (new `tools_pc/texdecode.py` ports image.c's LOOKUP/HUFFLOOKUP/RLELOOKUP codecs). **`tex1198` LOD 0 = 64×17 RGBA5551, 44.2% opaque, and its 1-bit alpha renders as an unmistakable treeline silhouette** — pass 23's identification confirmed by decode, not inference. **Rare authored a six-level LOD pyramid that PRESERVES the silhouette and its alpha coverage (44.2% → 43.1% → 38.8%), each level a separately-authored image with its own compmethod.** Since `XLU_SURF` has no alpha test (pass 22), averaging that 0/1 alpha into fractional mip alpha would smear the minified treeline into exactly the reported band — so 'authored LOD chain replaced by driver-generated mips' is now the leading mechanism, with hard ground truth behind it. Corrects pass 23: only `tex1198` is a treeline (`tex1199` 89.4% opaque denser foliage; `tex1200`/`tex1201` 100% opaque detail textures sharing the class). Two decoder bugs found while porting (missing per-image byte-align, `image.c:1096`; a GUESSED rather than ported RLE fudge) — both produced confident wrong pictures. Next: one `-level_36` run filtered on **`fmt=0 siz=2`**, diffing the live import against this offline reference on (1) 64×17 RGBA5551 dims/format, (2) ~44% alpha transparency, (3) whether all six authored LODs reach GL or only LOD 0.
**Next pass framing (post rule-2-clarification):**
@@ -10977,12 +11093,336 @@ User played live with `GE_D236ORDER=1` set, walked toward Surface1's tree line,
**Not v0.3.0-blocking, user is continuing anyway.** `GE_D236RAW` and the `vtxseg` extension to `GE_D236ALPHA` are both in tree, env-gated, zero cost unset. Raw headless dump preserved at `scratch/d236_raw_headless.log`.
+**Pass 17 (2026-09-21, fresh session) — WRITER IDENTIFIED: `doshadow`. Plus a false alarm resolved.**
+
+1. **Vertex struct is 16 bytes, not 32**: GE's RSP uses a custom vertex format `{s16 ob[3]; u16 flag; s16 tc[2]; cn[4]}` (alpha at byte 0xF) — no screen-space sx/sy/st/ct. `vtxallocator(n)` = `dynAllocate(n<<4)` = n×16B; the observed +0x40 arena increments are doshadow/dogfnegx's 4-vertex (64B) allocations, not 2×32B Vtx.
+2. **Decisive probe**: env-gated backtrace at allocation time (`GE_D236BT`, `get_ptr_allocated_block_for_vertices` in `chr.c`, calling a new C-linkage `port_d236bt_dump()` extracted from the RSP-side unwind logic in `gfx_pc.cpp`). All allocator hits resolve to **`doshadow`** (`model.c:5397`) via `drawjointlist → chrRenderProp → chrpropRender → chrpropsRenderPass → lvlRender`. Zero dotube activity. First allocation's physical address exactly matches the RSP-side rawvtx capture. Pass 16's exclusion of doshadow was based on a wrong claim ("dotube/doshadow use segment-tagged addressing") — only `dorottex` tags G_VTX; both use raw pointers.
+3. **doshadow alpha mechanism**: `cn[3] = (envcolour.a * D_800363F0)/255` for CHR/SMOKE props, else `= D_800363F0` — per-frame global shadow intensity, explains the varying low alphas (observed 1→2→12→13→14; probe saw 13 and 80 alternating). All 4 vertices are copies of a constant template (`D_800363F8`). Textured quad via `shadow->image` → `texSelect`.
+4. **FALSE ALARM (pass 17c) — the d43_emit.py op-13 "ShadowRecord bug" does not exist.** A ROM scan of all 42 op-13 records (all in C*Z character files, none in P*Z props) initially suggested the converter writes HeaderNode to r+24 / Scale to r+32 / omits BaseAddr — misaligned vs the C struct's *commented* offsets (image@0x10, HeaderNode@0x14, Scale@0x18, BaseAddr@0x1C). But a runtime dump of the live record (`GE_D236BT` probe in `doshadow`) shows **HeaderNode@0x14 = 0 and the valid pointer at 0x18** — because on PC (64-bit) `image` widens to 8 bytes, shifting HeaderNode→0x18, Scale→0x20, BaseAddr→0x28. The converter's r+16/r+24/r+32 layout **matches the PC struct exactly**; the C comments describe the N64 32-bit layout (the D52/A1 pointer-widening class). Live values all sane: image→valid tconfig (32×32, fmt=0), HeaderNode valid, height≈110, size≈(389,413), BaseAddr set by the loader's promotion pass (`modelPromoteNodeOffsetsToPointers` SHADOW case overwrites it with fileramaddr anyway). **Do not "fix" d43_emit.py op-13.**
+5. **Conclusion so far**: the faint textured sheets ARE `doshadow`'s drop-shadow quads — large (≈390×413 world units) flat ground quads sampling a tiny 32×32 texture at low per-frame alpha. N64 draws the identical quads (same code, same data). **The user-visible bug is therefore almost certainly that the trees' actual sprite geometry is missing/broken on PC**, leaving only the shadow sheets visible — not a shadow bug.
+
+**Next steps (fresh session):** (a) look at the dumped frames (`ppm/frame_0003xx.ppm`, converted PNGs in /tmp) to characterize what's actually on screen; (b) identify which prop/character model owns the shadow node (node 0x7020ef44, file image base 0x7020eda0 — all 42 op-13 records are in C*Z character files, so check which characters are present on Surface 1 and whether *their* main geometry renders); (c) if the tree sprites are a separate prop class, verify that file's GDL/vertex emission through `d43_emit.py` (D75 blob-bug class is the standing suspect for missing geometry). **TEMP probes in tree (env-gated, zero cost unset):** `GE_D236BT` backtrace probe in `chr.c` + live-record dump in `model.c:doshadow` (writes `d236sh.bin` to CWD on first hit — delete both when D236 concludes), `port_d236bt_dump()` helper in `gfx_pc.cpp`. Untracked scan script: `tools_pc/scan_op13.py`.
+
+**Pass 18 (2026-09-21, static ROM analysis session) — the tree-class RM word is a runtime LUT rewrite; exact source pair identified.**
+
+1. **All tree/plant prop files carry zero renderable geometry — confirmed with the real node parser, not just an opcode histogram.** `PpalmtreeZ`, `PpalmZ`, `Pjungle3_treeZ`, `Pjungle5_treeZ`, `Pplant1`–`Pplant11Z`, `Pgas_plant_*Z`: node set is only {Group(2), BBox(10), DLCollision(24)} — **no op-4 DisplayListRecord anywhere**. Same on N64. The visible tree sprites are therefore drawn by the room background (`bg_sevx_all_p.seg`, ROM 0x66F700, 116176 B; Surface1=level_36, Surface2=level_43), not by prop models.
+2. **The measured tree-class RM word `0x0c184b50` exists in no ROM data.** Scanned all 25 non-empty bg files (decompressed RZ GDLs) and all 512 model GDLs: zero hits. The only `0x0c18xxxx` word in ROM is `0x0c182048` (menu logo bg). It is generated at runtime.
+3. **Exact source found — `DL_LUT_CLOUD`.** Compiled GE's LUT pairs (`scratch/d236/lutdump.c`, output in `scratch/d236/lutvals.txt`): CLOUD = from `G_RM_PASS|G_RM_AA_ZB_XLU_SURF2` = **`0x0c1849d8`** → to `G_RM_PASS|G_RM_ZB_CLD_SURF2` = **`0x0c184b50`** — exact match with the measured tree-class word. The "from" word is one of the six bg RM words present in ROM (×24 in sevx). `G_RM_PASS = 0x0c080000` explains the high bits: `0x0c08 | 0x0010 (ZB_CLD_SURF2 hi) = 0x0c18`. So: room GDLs contain `SETOTHERMODE_L 0x0c1849d8`, and a runtime `bgApplyDynamicCCRMLUT` pass rewrites them to `0x0c184b50` before the RSP sees them. Note `DL_LUT_BILLBOARD` shares the same "from" word (`0x0c1849d8`) but maps to `0x0c193078` (TEX_EDGE2) — which LUT wins depends on the applied index.
+4. **Pass 17's doshadow finding stands but is a different sheet.** doshadow emits `G_RM_ZB_CLD_SURF|G_RM_ZB_CLD_SURF2 = 0x00504b50` (no PASS bits) — a *different* word from the measured tree class. The faint character drop-shadow quads (doshadow, 32×32 shadow texture) and the tree-texture billboards (bg CLOUD-rewritten quads) are two separate draw sources; the user-visible "wall of tree texture" is the latter.
+5. **N64 cutout mechanism (hypothesis to verify):** `0x0c184b50` = ZMODE_XLU + FORCE_BL + CVG_WRAP/FULL + IM_RD, alphacompare bits 0–2 = 000 (NONE), no CVG_X_ALPHA. No alpha *test* — the silhouette must come from plain alpha **blending** of the IA texture (`out = src*A + dst*(1-A)`; transparent texels have A=0). The port's `use_alpha` gate in `gfx_pc.cpp` (~line 2386: requires `(oml & (3<<20)) == G_BL_CLR_MEM<<20 && (oml & (3<<16)) == G_BL_1MA<<16`) must be checked against this word's blender bits (bits 16–17 = 00, bits 20–21 = 01) — if the gate rejects it, fast3d draws the quad opaque → exactly the reported "wall/sheet" symptom.
+
+**Next steps (fresh session):** (a) read `bgApplyDynamicCCRMLUT`'s implementation + the `enum CCRMLUT` in `src/game/bg.h`; map the literal indices 1/5/6/7 used at `bg.c:2797–2819` and `CCRMLUT_WALLETBOND` (`front.c:2177`) to tables, and find which index applies `DL_LUT_CLOUD` plus **all** call sites of that index (the room-load path uses 1/5/6/7 only — the CLOUD application may live in a second call path); (b) verify the port's `use_alpha`/blend handling in `port/fast3d/gfx_pc.cpp` for RM `0x0c184b50` (it is already special-cased at ~lines 2166/2217/2259/2279/2356 — D236 probes + zfix; review what they do); (c) if the gate rejects this RM, fix in `port/fast3d` (port layer — rule 2 allows it); (d) when concluded, remove TEMP probes listed above. Scratch tools: `scratch/d236/{lutdump.c,lutbuild.bat,lutvals.txt,scan_bg_rm.py,rmvals.txt}`, `tools_pc/scan_op13.py`.
+
+**Pass 19 (2026-09-22, static session) — both pass-18 leads REFUTED; complete runtime-emitter map for `0x0c184b50` established; the discrete-sprite mechanism identified as `dogfnegx` (op-12 GUNFIRE node).**
+
+1. **`CCRMLUT_CLOUD` (index 4) has ZERO call sites — pass 18's "runtime LUT rewrite" mechanism is dead.** Complete call-site map for `bgApplyDynamicCCRMLUT` (`src/game/bgapply.c:77`): `bg.c:2797/2804/2812/2819` (room load; literal indices 1=`CCRMLUT_PRIMARY_ADDFOG`, 5=`CCRMLUT_SECONDARY_ADDFOG` when `g_FogSkyIsEnabled`, else 6=`CCRMLUT_PRIMARY`, 7=`CCRMLUT_SECONDARY`) and `front.c:2177` (`CCRMLUT_WALLETBOND`). `CCRMLUT_BILLBOARD` (2), `CCRMLUT_WATER` (3) and `CCRMLUT_CLOUD` (4) are defined (`bondconstants.h:2931-2942`) but never applied anywhere in the compiled set — the `DL_LUT_CLOUD` table itself exists (`bg.c:2174`, comment: "Transparent to Cloud (Saves AA - Stops Jaggies from appearing behind BillBoard)") mapping exactly `0x0c1849d8 → 0x0c184b50`, but its enum index is never passed to `bgApplyDynamicCCRMLUT`. Corroborating: the ADDFOG LUTs that ARE applied map the same "from" word `0x0c1849d8` → `G_RM_FOG_SHADE_A|G_RM_AA_ZB_XLU_SURF2` = **`0xc81049d8`** (`bg.c:2039-2040`, `2061-2062`) — exactly D276's confirmed noise-weave class. So the sevx room-bg quads that carry `0x0c1849d8` in ROM end up as `0xc81049d8` at runtime (fog enabled), NOT as tree cards.
+2. **The only code that emits `0x0c184b50` is `texSetRenderMode` mode 4 ("cloud"), 2-cycle, zbuffer-on** (`othermodemicrocode.c:300`: `gDPSetRenderMode(G_RM_PASS, G_RM_ZB_CLD_SURF2)` = `0x0c184b50` exactly), reached from its callers via `sub_GAME_7F073038` (`model.c:4940-4942`) → `texSelect(..., arg2=4, ...)` → `texSetRenderMode`. Mode-4 `texSelect` call sites: **`model.c:5369` `dogfnegx`** (op-12 `MODELNODE_OPCODE_GUNFIRE`), **`model.c:5545` `doshadow`** (op-13 `MODELNODE_OPCODE_SHADOW`), `glass2.c:732` bullet sparks, `gunfire.c:1512/1518/1524` flares, `explosion.c:1890` smoke (zbuf=1), `front.c` menu/crosshair (zbuf=0 → emits `0x0c184340` instead). Note the 1-cycle variant (emitted when `tconfig==NULL` or `tconfig->level==0`, `othermodemicrocode.c:415`) emits `G_RM_ZB_CLD_SURF|G_RM_ZB_CLD_SURF2` = `0x00504b50` with zbuffer on (`0x00504340` without) — so pass 18's "doshadow emits `0x00504b50`" holds only for the 1-cycle case; with a mipmapped texture + zbuffer on, **both doshadow and dogfnegx emit exactly `0x0c184b50`** (2-cycle combine: `G_CC_TRILERP`/`G_CC_MODULATEIA2` for RGBA/IA → A = texel.a × shade.a). Pass 17's backtrace attributed the tree class's vertex allocations to `doshadow`, but `dogfnegx` is a co-emitter through the identical `drawjointlist → chrRenderProp` path and the same `vtxallocator(4)` + raw-pointer pattern — the attribution may have resolved only the more frequent of the two.
+3. **`dogfnegx` IS the discrete camera-facing sprite mechanism** (`model.c:5225-5380`): it computes the view direction from `rodata->Offset` through the node's model matrix, builds a rotation aligning the quad to face the camera, applies per-frame random scale (0.75–1.25) and random UV rotation around the texture centre, and draws a `rodata->Size`-sized textured quad with mode-4 cloud RM. Gated on `(renderdata->flags & 2) && rwdata->Gunfire.visible`. `ModelRoData_GunfireRecord` (`bondtypes.h:1151`): Offset@0x0, Size@0xC, Image@0x18, Scale@0x1C, RwDataIndex@0x20. This is almost certainly what N64 shows as the "discrete tree sprites".
+4. **The port's `use_alpha` gate ACCEPTS `0x0c184b50` — pass 18's prime suspect is refuted by static decode.** Against this repo's gbi.h blender layout (`include/PR/gbi.h:720-723`: `GBL_c2` puts c2-modifier at bits 16-17, c2-secondary-colour at bits 20-21 of other_mode_l): the gate (`gfx_pc.cpp` ~line 2386) requires bits 16-17 == `G_BL_1MA`(0) and bits 20-21 == `G_BL_CLR_MEM`(1); the word has byte2=`0x18` → bits 16-17=0 ✓, bits 20-21=1 ✓ → `use_alpha=true`. Full decode: ALPHACOMPARE=NONE, no CVG_X_ALPHA/ALPHA_CVG_SEL, FORCE_BL set, c1=(IN, A=0), c2=(IN, A_IN over MEM, 1MA) = standard src-over alpha blend with A from the combiner (`texSelect` mode-4 2-cycle sets `G_CC_TRILERP`/`G_CC_MODULATEIA2` for IA → A = texel.a × shade.a). No port-side opaque-draw gate explains the wall.
+5. **Implication / reframed picture:** with the tree class alpha-blended correctly in the port, the consistent story across D276 (visible weave painted by the `0xc81049d8` room-bg noise class), D280 (tree cards in front yet not visibly winning) and pass 15 (tree-class vertex alpha capped ≤36/255 ≈ 14%, bimodal 25/255) is that the tree cards blend at ~10% opacity over the opaque noise wall — nearly invisible, exactly "the wall shows through". The remaining open questions are (a) whether that low alpha is ROM-faithful (doshadow's `cn[3] = envcolour.a × D_800363F0/255`, `D_800363F0` set per-chr from shade alpha at `chr.c:3319`, init 0x50; dogfnegx uses template `D_800363E0`) or a port-fed wrong input, and (b) whether the discrete billboards (`dogfnegx`) actually instantiate on PC at all — its gate inputs (`renderdata->flags & 2`, `rwdata->Gunfire.visible`) are unverified.
+
+**Next steps (fresh session):** (a) ROM-holding session: run new `tools_pc/scan_op12.py` (added this pass; counts op-12/op-13 nodes per model file, dumps GunfireRecord Offset/Size/Image) to identify which models own Surface 1's tree billboards/shadows and cross-ref against pass 17's D236SH live-record data (32×32 fmt=0 shadow texture); (b) runtime: `GE_D236ALPHA` + `GE_D236RM` on `-level_36`, correlate the tree class's per-vertex alpha with `D_800363F0`/envcolour.a, and compare against N64 reference footage (faint vs solid sprites decides faithful-faint vs port bug); (c) verify `dogfnegx`'s gate inputs on PC (`renderdata->flags & 2`, `rwdata->Gunfire.visible`) — if billboards never fire, the discrete sprites are missing entirely (rule-2 bucket (a)/(b) question); (d) on conclusion remove TEMP probes: `GE_D236RM/ORDER/ALPHA/ZFIX/BT` in `gfx_pc.cpp`, `GE_D236BT` + D236SH dump in `model.c:doshadow`, `port_d236bt_dump()`, `tools_pc/scan_op1{2,3}.py`. Scratch tools: `scratch/d236/{lutdump.c,lutbuild.bat,lutvals.txt,scan_bg_rm.py,rmvals.txt,rmdump.c,rmbuild.bat,rmnames.txt}`.
+
+**Pass 20 (2026-09-22, ROM-holding session) — pass-19 step (a) executed: full 512-file `scan_op12.py` run; the `dogfnegx`=trees identification is REFUTED by the asset data.**
+
+1. **Verified scan result** (re-ran `tools_pc/scan_op12.py ntsc-final` this pass). *Note: the script's final line "total files with op12: 62" is a reporting artifact — that counter accumulates every file with EITHER opcode, so it equals the op13 file count; the real per-opcode totals are below.* Across all 512 converted model files:
+ - **op-12 (`MODELNODE_OPCODE_GUNFIRE` / `dogfnegx`) — 21 nodes total, ALL in weapon/gun/tank models.** 16 one-node weapon files (`PchrautoshotZ`, `Pchrfnp90Z`, `PchrgoldenZ`, `PchrgrenadelaunchZ`, `PchrkalashZ`, `Pchrm16Z`, `Pchrmp5kZ`, `Pchrmp5ksilZ`, `PchrrugerZ`, `PchrshotgunZ`, `PchrskorpionZ`, `PchrspectreZ`, `Pchrtt33Z`, `PchruziZ`, `PchrwppkZ`, `PchrwppksilZ`) + `PgroundgunZ`(2), `Pgun_runway1Z`(1), `ProofgunZ`(1), `PtankZ`(1). The dumped records confirm muzzle-flash semantics: barrel-relative offsets (e.g. shotgun `(-375,0,16)`), modest sizes, small embedded-image indices (`img` 0x18–0x6c, `vma?False`). `PtankZ`'s single node is a large smoke/flash quad (`(0,5,0)`, size 2143×1026).
+ - **op-13 (`MODELNODE_OPCODE_SHADOW` / `doshadow`) — 42 nodes total, ALL in C*Z character files** (one per character; matches pass 17's "42 op-13 records, all in C*Z").
+ - **No tree/plant model carries either node.** The 16 foliage models present (`Pjungle3_treeZ`, `Pjungle5_treeZ`, `PpalmZ`, `PpalmtreeZ`, `Pplant1Z/2Z/2bZ/3Z/11Z`, `Pgas_plant_*Z`, `Pgasplant_clear_doorZ`) contain **only** `{GROUP(2), BBOX(10), DLCOLLISION(24)}` nodes — static geometry + collision, zero billboard/shadow nodes.
+
+2. **Refutation of pass 19's central identification.** Pass 19 concluded "`dogfnegx` (op-12 GUNFIRE) is the discrete camera-facing billboard [that draws the trees]" and that the `0x0c184b50` class is emitted by dogfnegx/doshadow. But `dogfnegx` has **zero tree instances** in the entire asset set — every one of its 21 nodes is a weapon muzzle flash or tank effect. So the `0x0c184b50` class that passes 17/19 chased (pass 17's backtrace attributed its vertex allocations to `doshadow`) is, on this level set, most plausibly **character drop-shadows + weapon muzzle flashes**, *not* the trees. Passes 17–19 appear to have been instrumenting the wrong draw class for the reported symptom.
+
+3. **Redirection: the visible trees are static geometry, not dynamic billboards.** Given (1), the Surface 1 trees must be drawn from static data — either (a) the foliage PROP models (`PpalmZ`/`Pjungle3_treeZ`/etc., which DO load and contain GROUP/BBOX/DLCOLLISION nodes) or (b) the level background file `bg_sevx_all_p.bin`. Both are static; neither uses the dogfnegx/doshadow path. **Honest caveat — not yet disambiguated:** my offline decode of `bg_sevx_all_p`'s room secondary-DL streams found billboard-shaped cards (G_VTX+G_TRI4 with a 0xC0/G_SETTEX texture-select), but their OML words (`0x0c1849d8`/`0x0c184dd8`) do *not* match the runtime `0x0c184b50`, and pass 19 established those exact bg quads become the **noise wall** (`0xc81049d8`) at runtime (fog-enabled ADDFOG LUT). So it is NOT yet confirmed whether the visible tree sprites come from the foliage prop models or the bg file — that needs a runtime attribution.
+
+4. **Next step, concrete (supersedes pass 19's b/c):** a single runtime probe that attributes the on-screen tree-region triangles to their draw source — tag `gfx_sp_tri*` triangles by whether their vertices came from a **model-node GDL** (prop/model path) vs a **bg room DL** (`g_BgRoomInfo[].ptr_*_expanded_mapping_info`, SPSEGMENT_BG_VTX=14 / the bg-widened arena), and for the visible tree pixels log the active RM + texture/tile. That one capture decides (a)-vs-(b) and ends the "which class is actually the trees" question 19 passes have been circling. If it's the foliage prop models, the standing D75 blob-conversion suspect (missing/mis-converted mesh or texture in `d43_emit.py`) becomes the prime fix target; if it's the bg file, the D85/D276 bg-DL + noise-wall thread resumes.
+
+**TEMP probes still in tree (env-gated, zero cost unset) — remove when D236 concludes:** `GE_D236RM/ORDER/ALPHA/ZFIX/BT` (`gfx_pc.cpp`), `GE_D236BT` backtrace + D236SH dump (`model.c:doshadow`, writes `d236sh.bin`), `port_d236bt_dump()` helper, `tools_pc/scan_op1{2,3}.py`.
+
+
+**Pass 21 (2026-09-21, static session) — pass-20's step (a)-vs-(b) ANSWERED STATICALLY, no runtime probe needed: the Surface 1 trees cannot be prop models, because the level places none. Prop/`d43_emit.py` conversion is retired as a D236 fix target; the bg-file branch is the only one left.**
+
+Pass 20 closed by proposing a runtime draw-source attribution probe (tag `gfx_sp_tri*` by model-node GDL vs bg room DL) to decide whether the visible trees are (a) foliage prop models or (b) static `bg_sevx_all_p` geometry. That probe is **not necessary** — the setup data answers it, and two independent static lines of evidence agree.
+
+1. **No Severnaya-type tree model exists in the asset set at all.** All 16 foliage models in `data/pcmodels-ntsc-final/manifest.csv` are jungle-themed (`Pjungle3_treeZ`, `Pjungle5_treeZ`, `PpalmZ`, `PpalmtreeZ`, `Pplant1Z/2Z/2bZ/3Z/11Z`, `Pgas_plant_*Z`), and the `PROP` enum in `src/bondconstants.h` describes each one as "Jungle …" (`PROP_JUNGLE3_TREE` = "Jungle Large Tree", `PROP_PALM` = "Jungle Palm Tree", `PROP_PLANT2B` = "Jungle Plant, Low Shrub", …). There is no pine/fir/snow-tree prop anywhere in the 512-model set.
+
+2. **Surface 1's setup places zero foliage props — and neither does any other setup in the repo.** New tool `tools_pc/scan_setup_props.py` parses the `PROP` enum out of `src/bondconstants.h` and the object records out of `assets/obseg/setup/Usetup*Z.c`, then reports which props each setup instantiates. For `UsetupsevxZ.c` (Surface 1 — `bg.c:201` maps `LEVELID_SURFACE` → `bg/bg_sevx_all_p.seg` + this setup) it matches **219 object records**, of which **all 83 `StandardProp` records are individually accounted for** (`PROP_WOOD_SM_CRATE4`×31, `PROP_BOOK1`×16, `PROP_OIL_DRUM7`×12, `PROP_WOODEN_TABLE1`×4, `PROP_BOOKSHELF1`×4, `PROP_PADLOCK`×4, `PROP_DESK_LAMP2`×2, `PROP_METAL_CHAIR1`×2, `PROP_JERRY_CAN1`×2, plus one each of `PROP_MOTORBIKE`, `PROP_CARBMW`, `PROP_STOOL1`, `PROP_HATPEAKED`, `PROP_KEYBOARD1`, `PROP_CHRMAP`) — **not one foliage prop**. Same result for all 14 setups present in the repo (`arch ark azt cave control cryp dam depo pete run sevbunker sevb sevxb sevx`), including Dam/Runway/Streets, which also have visible outdoor greenery. GE's outdoor trees are never props.
+
+ *Positive control (the zero is meaningful).* A "0 foliage" count is only evidence if the detector can fire. The tool now self-tests and prints it: `PROP enum: 341 entries; foliage props recognised (9): PROP_JUNGLE3_TREE, PROP_JUNGLE5_TREE, PROP_PALM, PROP_PALMTREE, PROP_PLANT1, PROP_PLANT11, PROP_PLANT2, PROP_PLANT2B, PROP_PLANT3`. The matcher resolves every foliage name in the enum and still reports none placed. The record parser is independently sanity-checked by the distinctive props it *does* find in Surface 1 — motorbike, safe + safe door, the map objective item, the Yale and bolt keys, `PROP_HATCHSEVX`, `PROP_SEVDISH` — all of which really are in that level.
+
+ *Tooling bug caught during this pass, worth recording:* the first version of `load_prop_enum()` located the enum with `l.strip().startswith("typedef enum PROP")`, which matches `typedef enum PROPFLAG` (`bondconstants.h:294`) hundreds of lines earlier. That yields an enum containing **no `PROP_*` members at all**, so every `obj` resolved to `?` and every foliage count was a meaningless 0. The run *looked* exactly like a clean negative result. Fixed to an exact `l.strip() == "typedef enum PROP"` match, and the self-test above exists specifically so this failure mode cannot masquerade as a finding again. This is the D219 probe lesson in a different costume (**a diagnostic must be shown capable of producing the positive before its negative is trusted**).
+
+3. **What this settles.** Pass 20's alternative (a) is dead: **the visible Surface 1 trees are static background geometry in `bg_sevx_all_p.seg`**, reached through `bgLoadRoomPrimaryGdl` / `bgLoadRoomSecondaryGdl`. The standing D75 blob-conversion suspect (`tools_pc/d43_emit.py` mis-converting a prop mesh or texture) is therefore **not** a D236 fix target and should not be pursued for this bug. This independently corroborates D280's ninth-pass reframing ("the remaining work is CPU-side instantiation/room-streaming, not a port fix") and explains why 19 passes of model-node instrumentation (op-12/op-13, `dogfnegx`/`doshadow`) found nothing tree-shaped: **there was never a tree model to find.**
+
+4. **Render-mode path re-checked while here (no bug found; recorded so pass 22 does not re-derive it).** The bg-authored foliage/billboard cards carry `G_RM_AA_ZB_XLU_SURF` (`0x…49d8`), and `DL_LUT_BILLBOARD` (`bg.c:2151`, `CCRMLUT_BILLBOARD` = index 2) exists precisely to rewrite `XLU_SURF` → `G_RM_AA_ZB_TEX_EDGE` ("Transparent 1Cycle to BillBoard 1Cycle") — the alpha-cutout mode that would give discrete silhouettes instead of a blended sheet. **But index 2 is never applied**, on N64 or PC: the only call sites are `bg.c:2797/2804/2812/2819` (room load, literals 1/5/6/7 = `PRIMARY_ADDFOG`/`SECONDARY_ADDFOG`/`PRIMARY`/`SECONDARY` by `g_FogSkyIsEnabled`) and `front.c:2177` (`WALLETBOND`). So `BILLBOARD`, `WATER` and `CLOUD` are all dead LUTs in the original game too — this is **not** the PC/N64 divergence, and "apply the BILLBOARD LUT" is not a legitimate fix (it would be a game-logic change, rule 2).
+
+ Also checked and **cleared**: `bgApplyDynamicCCRMLUT`'s scan bound. `Gfx` is 16 bytes on PC (`include/PR/gbi.h`: `Gwords { uintptr_t w0, w1; }`), and `end` is computed as `ptr + usize_primary_DL_binary` in *bytes* — but `usize_*` is assigned from `expanded_size` **after** `bgWidenRoomGdl()` has already rewritten it to the widened PC byte count (`bg.c:2526`/`2559`), so the scan covers the whole room DL, not half of it. No 32→64-bit truncation here. The LUT tables themselves are layout-safe too: their scalar initializers (`DL_LUT_WATER`'s raw `0xB900031D, 0x00552078, …`) brace-elide into `Gfx.words.w0/w1` and zero-extend correctly at either width.
+
+5. **Next step for pass 22 (bg branch only).** The question is now narrow: *within `bg_sevx_all_p`'s room display lists, which draw class carries the tree cards, and why does it not reach the screen?* D280 established that the `0x0c184b50` class sits in front of the noise quad, has CPU-baked bimodal fog (25/255) and "barely draws at all" under a deterministic `-level_36` boot — but pass 20 proved that word is only ever emitted at runtime by `texSetRenderMode` mode 4 (shadows/muzzle flashes/sparks), so it is **not** the bg tree class either. The remaining candidates are the bg-authored classes D269/D272 catalogued (`oml=0xc4112078` OPA and `0xc41049d8` XLU, both CI8) versus D276's `0xc81049d8` IA8 noise painter. Concretely: dump the Surface 1 room GDLs with the existing `GE_D85DUMP` probe (no new code) and census the distinct `oml`/texture pairs per room, then match against a room the user can stand in front of trees in. That is a static-plus-one-run step, not a new instrumentation build.
+
+**Tooling added this pass:** `tools_pc/scan_setup_props.py` (read-only, no ROM needed, runs against the repo's own sources). Unlike the `GE_D236*` env probes it costs nothing at runtime and is generally useful — keep it rather than removing it when D236 concludes.
+
+
+**Pass 22 (2026-09-21, static session) — pass-21's owed step done OFFLINE, no run: complete render-mode census of Surface 1's bg room DLs, straight out of the ROM. D276's wall painter `0xc81049d8` is now tied to its authored source (bg *secondary* DL, `G_RM_AA_ZB_XLU_SURF`, 24 of 42 rooms). No bg file in any level authors the TEX_EDGE "billboard cut-out" class, so the tree cards are plain alpha-blended quads and the remaining suspicion falls on their texture's alpha, not on geometry, culling, fog or billboard orientation.**
+
+Pass 21 proposed dumping the Surface 1 room GDLs with `GE_D85DUMP` and censusing the `oml`/texture classes — a build plus one run. That turned out to be unnecessary too: the bg `.seg` files are stored **uncompressed** in the ROM (they have to be — `obLoadBGFileBytesAtOffset` random-accesses them by byte offset), so the whole structure is readable offline.
+
+**Two new tools, both read-only, no ROM-dependent build, no C compiler:**
+
+* `tools_pc/bg_gdl_census.py` — decodes `bg__all_p` out of the ROM, walks every room's primary and secondary GDL, and censuses the distinct render modes per room. It reproduces `bg.c`'s own structure exactly: file word[1] → the 24-byte `bg_room_data` array (`bg.c:865`); a room's compressed blob size = distance to the next non-zero mapping-bin offset with primary and secondary treated as one interleaved stream (`bg.c:944-990` + `getPri/SecMappingBinCount`); each blob raw-deflate behind its own 2-byte header (`bgDecompress` → `decompressdata`, which is just "skip 2 bytes, inflate").
+ *Validation:* **every** blob in every level tested inflates cleanly with zero failures. Since the sizes are derived, not stored, a single off-by-one in that derivation would corrupt the stream and fail inflate — so a clean sweep is a real check on the derivation, not a vacuous one.
+* `tools_pc/rendermode.py` — evaluates the `G_RM_*`/`RM_*(clk)`/`GBL_c1`/`GBL_c2` macros out of `include/PR/gbi.h` and the `DL_LUT_*` tables out of `src/game/bg.c` in pure Python (93 `G_RM_*` composites resolve). It decodes any `oml` word into flags + blender equation, and produces the (from → to) rewrite maps for the LUTs `bg.c` actually applies. *Cross-check:* it independently reproduces pass 19's `0x0c1849d8 → 0xc81049d8` ADDFOG mapping, which pass 19 derived by hand.
+
+1. **Surface 1 (`bg_sevx_all_p`, 42 rooms) authors exactly six render-mode classes** — the complete set, with their fog-on LUT images:
+
+ | authored | fog-on (LUT 1 pri / 5 sec) | setups | rooms | mode |
+ |---|---|---|---|---|
+ | `0x0c182078` | `0xc8102078` | 39 | 39 | `AA_ZB_OPA_SURF` (primary DLs) |
+ | `0x0c1849d8` | `0xc81049d8` | 24 | 24 | `AA_ZB_XLU_SURF` (secondary DLs) |
+ | `0x0c184dd8` | `0xc8104dd8` | 15 | 15 | `AA_ZB_XLU_DECAL` |
+ | `0x0c192d58` | `0xc8112d58` | 7 | 7 | `AA_ZB_OPA_DECAL` |
+ | `0x00502078` | (unchanged) | 2 | 2 | OPA_SURF, no-fog blender |
+ | `0x005049d8` | (unchanged) | 1 | 1 | XLU_SURF, no-fog blender |
+
+2. **D276's wall painter is now attributed to authored data.** `0xc81049d8` — the class D276 identified as the thing actually painting the visible weave — is **exactly** the LUT image of the bg *secondary* DL class `0x0c1849d8` (`G_RM_AA_ZB_XLU_SURF`), present in 24 of Surface 1's 42 rooms. Nineteen passes have logged that word live; this is the first time it has been traced back to a specific authored source in a specific file. It is ordinary bg transparent-surface geometry, and there is a lot of it.
+
+3. **`LEVELID_SURFACE` == 36**, so the `-level_36` deterministic boot used by D269/D272/D276/D280 really was Surface 1 (`LEVELID_STATUE = 22` + 14 sequential entries; `bg.c:201` then maps it to `bg/bg_sevx_all_p.seg`). Worth stating explicitly — several passes' conclusions rest on it and none of them checked.
+
+4. **No bg file in ANY level authors the TEX_EDGE "billboard cut-out" class `0x0c193078`.** Censused `sevx sevb sev dam jun run depo azt` — the union of their authored render modes is `{0c182078, 0c182048, 0c1849d8, 0c184dd8, 0c184e50, 0c192d58, 00502078, 005049d8, 00504dd8, 00552d58}`, and `0x0c193078` appears in none of them, **including Jungle and Depot**. That matters because `DL_LUT_PRIMARY_ADDFOG` carries a dedicated rewrite pair for it with Rare's own comment: *"Billboard Cut-out to FOG Billboard Cut-out - eg, Mario Tree or Depot lamp"*. So the engine's TEX_EDGE billboard path is real but is **never fed from bg geometry** — it belongs to the prop/model path (which is where the jungle foliage props live, per pass 21). Combined with pass 21, Surface 1 has neither tree props nor TEX_EDGE bg cards.
+
+5. **Consequence — the mechanism is narrowed to texture alpha.** Surface 1's tree cards can only be plain `XLU_SURF` textured quads in the secondary DLs. `XLU_SURF` is `GBL_c2(CLR_IN, A_IN, CLR_MEM, 1MA)` — a straight src-over blend with **no alpha test and no coverage-from-alpha**. Under that mode a card is discrete only if its texture's alpha is transparent outside the silhouette. So "sheet instead of discrete trees" is, mechanically, "the alpha of this class's texture is opaque where it should be transparent" — which is precisely what D276 found and measured (*"fully opaque, alpha nibble mostly 0xf"*, 1023/1024 texels at A=F per D267). **This retires, for this class, the geometry/culling/fog/billboard-orientation branches** that D269→D280 cycled through: none of them can turn a correct-alpha XLU_SURF card into a sheet, and none of them are needed to explain it.
+
+ The open sub-question is no longer *"which draw class"* but *"is this texture's format/alpha being decoded correctly at all"*. D265 tried IA4 `I3:A1`→`I2:A2` (reverted), D267 dumped the IA8 set and dismissed it on a single-instance visual judgement, D273 ruled out the CI palette layout, D276 re-identified the IA8 class but never questioned whether `fmt=3 siz=1` is the *right* interpretation of those bytes. A 32×32 image that decodes as "high-frequency blocky gray static with opaque alpha" is exactly what a tree texture looks like when read at the **wrong format or wrong bit-split** — that specific possibility has never been tested.
+
+6. **Anomaly to resolve before trusting the older logs.** D269 (M-128) recorded the two live Surface 1 classes as `oml=0xc4112078` (OPA) and `0xc41049d8` (XLU). The LUT images derived here are `0xc8112078` and `0xc81049d8`. The difference is exactly one field — cycle-1's fog alpha source, bits 26-27: `G_BL_A_SHADE` (2, what the LUT writes via `G_RM_FOG_SHADE_A`) vs `G_BL_A_FOG` (1, what D269 logged). D276 and D280 logged the `c8` (A_SHADE) form, matching the LUT. So either something rewrites the fog alpha source after `bgApplyDynamicCCRMLUT`, or D269's words were mis-transcribed and three passes of "two draw classes per room stream" rest on them. Decide this by decoding a live log with `python tools_pc/rendermode.py ` rather than by eye — it is a one-bit-field difference that is very easy to read past, and it changes the fog blend if real.
+
+7. **Also recorded (fog-off path).** `DL_LUT_PRIMARY` (6) and `DL_LUT_SECONDARY` (7) contain **zero** render-mode rewrite pairs — their bodies are entirely `gsDPSetCombineMode`/`gsDPSetCombineLERP`. So when `g_FogSkyIsEnabled` is false, room render modes are left exactly as authored and only combiners are patched. (This is a correct reading of the tables, not a parser gap: the tool finds 8 pairs in LUT 1 and 3 in LUT 5 from the same code path.)
+
+**Next step for pass 23 (one run, or possibly none):** re-dump the Surface 1 textures for the `0xc81049d8` class with the existing `GE_TEXDUMP`/`GE_TEXRAW`/`GE_D270KEEPOML=1049d8` probe combination (the current `texdump/` in-tree is from a D75 logo capture, not a Surface run), then decode the dumped bytes offline under *every* plausible format hypothesis — IA8 as currently assumed, IA4 both bit-splits, I8, I4, CI4/CI8 with the room's TLUT, RGBA5551 — and look for the one that yields a tree silhouette with a sensible alpha channel. Cheap, offline, and it directly tests the one mechanism left standing. Resolve item 6's `c4`/`c8` discrepancy from the same run's log.
+
+**Tooling added this pass (keep — read-only, no runtime cost):** `tools_pc/bg_gdl_census.py`, `tools_pc/rendermode.py`.
+
+
+**Pass 23 (2026-09-21, static session) — THE TREE CARDS ARE IDENTIFIED. Surface 1's `0x0c1849d8`/`0xc81049d8` class binds 13 distinct textures, and four of them — `tex1198`–`tex1201`, `RGBA16` (RGBA5551, ONE bit of alpha), 64×17, `HUFFLOOKUP`, six EXPLICIT LODs — are each bound in 21 of the level's 42 rooms, 3.5× the next most widespread. They are the only explicit-LOD textures in the entire Surface 1 bg. They would log as `fmt=0 siz=2`, which is why D265's IA hunt, D269/D272/D273/D274's CI8 hunt and D276's IA8 hunt all missed them: every probe filter in this investigation has been looking at minority members of the right render-mode class.**
+
+Pass 22 left the census with `tex=0` — Surface 1's room DLs contain no `G_SETTIMG` at all. That turned out to be the key, not a dead end.
+
+1. **bg room DLs reference textures by NUMBER, not address.** `texLoadFromGdl` (`tex.c:779`) has `case G_NOOP: texnum = in->words.w1 & 0xfff;` — opcode `0xC0`, 329 occurrences in Surface 1's room DLs — and resolves each into real texture commands at room load. So the texture identity of every bg draw is readable offline, by tracking the active render mode across the command stream and recording the `G_NOOP` texnums bound under it. `bg_gdl_census.py` now does this.
+
+2. **Reading the texture headers offline.** A texture's ROM blob begins with a control byte (`image.c:2568`): bit 7 `hasExplicitLods`, bit 6 `iszlib`, bits 0-5 `lod` (image count); the bitstream starts at **byte 1**. `iszlib=0` → `texInflateNonZlib`'s 24-bit `ffff wwwwwwww hhhhhhhh cccc` header; `iszlib=1` → `texInflateZlib`'s `format u8, numcolours-1 u8, palette[], w/h per image`. New tool `tools_pc/texheader.py`; offsets/sizes come from the existing `imagelist.u.csv`.
+ *Sanity check, and a near-miss worth recording:* the first attempt forgot the control byte. The resulting parse **still looked plausible** — it produced four "formats" and confident dimensions — but 1020 of 2699 entries were implausible and the format nibble only ever took the values 0/4/8/12, the tell that the field was reading across a boundary. With the byte-1 offset, **1** of 2699 is implausible and the dimension histogram is dominated by 64×32, 64×64, 32×64, 32×32. The tool prints that count every run so the offset can't silently drift again ([[porting-notes §E positive-control rule]] — same class as pass 21's enum bug).
+
+3. **The full texture list for the wall class** (`oml=0x0c1849d8` → live `0xc81049d8`), by room coverage:
+
+ | texture | rooms | ROM format | dims | compmethod | explicit LODs | logs live as |
+ |---|---|---|---|---|---|---|
+ | `tex1201` | **21** | `RGBA16` | 64×17 | HUFFLOOKUP | **yes (6)** | `fmt=0 siz=2` |
+ | `tex1200` | **21** | `RGBA16` | 64×17 | HUFFLOOKUP | **yes (6)** | `fmt=0 siz=2` |
+ | `tex1199` | **21** | `RGBA16` | 64×17 | HUFFLOOKUP | **yes (6)** | `fmt=0 siz=2` |
+ | `tex1198` | **21** | `RGBA16` | 64×17 | HUFFLOOKUP | **yes (6)** | `fmt=0 siz=2` |
+ | `tex149` | 6 | `IA8` | 32×64 | RLELOOKUP | no | `fmt=3 siz=1` |
+ | `tex806` | 5 | `IA8` | 64×32 | HUFFLOOKUP | no | `fmt=3 siz=1` |
+ | `tex844` | 4 | `RGBA16_CI8` | (zlib, 180 col) | — | no | `fmt=2 siz=1` |
+ | `tex1373` | 3 | `IA8` | 64×32 | RLE | no | `fmt=3 siz=1` |
+ | `tex239` | 2 | `IA8` | 64×32 | HUFFBLUR | no | `fmt=3 siz=1` |
+ | `tex241`/`tex151` | 1 | `RGBA16_CI8` | (zlib) | — | no | `fmt=2 siz=1` |
+ | `tex619` | 1 | `IA8` | 32×64 | RLELOOKUP | no | `fmt=3 siz=1` |
+ | `tex238` | 1 | `IA8` | 64×32 | RLE | no | `fmt=3 siz=1` |
+
+4. **This resolves the D269-vs-D276 contradiction.** Three passes argued about whether "the wall" is CI8 (D269/D272: `fmt=2 siz=1`) or IA8 (D276: `fmt=3 siz=1`). **Both are members of this one render-mode class, and both are minority members.** D269 sampled a CI8 card and generalised; D276 sampled an IA8 card and generalised and called D269's reframing premature. Neither was wrong about what it sampled; both were wrong to treat their sample as *the* class. The dominant members — four textures covering half the level — are `fmt=0 siz=2` **RGBA16**, a format nobody has filtered for or dumped in 22 passes.
+
+5. **Why these four are the tree cards** (inference, stated as such — see the honesty note below). Four consecutive texture numbers; near-identical compressed sizes (1569/2035/1902/1964 bytes); identical geometry (64×17) and codec; each bound in exactly 21 of 42 rooms — i.e. every outdoor room, four variants apiece; drawn in the *secondary* (transparent) pass. That is precisely the signature of a small set of foliage/treeline billboard variants scattered across an outdoor level. Nothing else in the level looks like this.
+
+6. **Mechanism hypothesis, now concrete for the first time.** `RGBA16` is RGBA**5551** — exactly **one bit** of alpha. A 64×17 strip is a wide, short card: a treeline band whose alpha bit is 0 for sky above the silhouette and 1 for tree. It is drawn under `G_RM_AA_ZB_XLU_SURF`, which pass 22 established is a straight src-over blend with **no alpha test and no coverage-from-alpha**. So the silhouette exists *only* in that one alpha bit, and the card is minified hard at distance — which is why Rare authored **six explicit LOD images** for it, the only explicit-LOD textures in this entire bg. Two ways that becomes a sheet on PC, both untested:
+ * **the 1-bit alpha is decoded as always-opaque** → a solid rectangle of tree-coloured texture tiled across the horizon; or
+ * **the authored LOD chain is dropped and GL generates mips by averaging** → the 0/1 alpha becomes fractional, and with no alpha test a minified treeline smears into a translucent band instead of crisp cutouts.
+
+ The second is the concrete, asset-grounded version of the "TEXTLOD/mip sampling" suspicion D269 raised, D272 reframed to, and D274 parked for lack of a mechanism. It now has one.
+
+7. **Honesty note — what is NOT established.** The pixels have not been decoded: `HUFFLOOKUP` is one of Rare's huffman/RLE/lookup codecs and none of them are implemented offline, so "these are trees" is a strong inference from format, geometry, count and room coverage, **not** a decode. It should be confirmed before any fix is built on it. Equally, no claim is made here that either mechanism in (6) is the actual bug — both are hypotheses that now have a specific, cheap test.
+
+**Next step for pass 24 — one run, tightly specified:** boot `-level_36` and dump the textures for this class with the probe filter set to **`fmt=0 siz=2`** (RGBA16) rather than the IA/CI filters every previous pass used — `GE_TEXDUMP=1 GE_TEXRAW=1 GE_D270KEEPOML=1049d8`, then match `GE_TEXI[N] addr=` against the dump by address as D275 did. Three things to read off that one capture: (a) do `tex1198`–`tex1201` arrive as 64×17 RGBA5551, or has the width/height/format been mangled on the way in (D265 already found one transposed-dimensions bug in this area); (b) is the alpha bit non-trivial, or opaque everywhere; (c) do all six explicit LOD images arrive, or only LOD 0. Also resolve pass 22's item 6 (`c4` vs `c8` fog alpha) from the same log using `python tools_pc/rendermode.py `.
+
+**Tooling added/extended this pass (keep — read-only, no runtime cost):** `tools_pc/texheader.py` (new); `tools_pc/bg_gdl_census.py` now tracks `G_NOOP` texnums and prints the decoded ROM header for every texture each render-mode class binds.
+
+
+**Pass 24 (2026-09-21, static session) — THE TREE TEXTURE IS DECODED. `tex1198` LOD 0 is a 64×17 RGBA5551 treeline strip, 44.2 % opaque, and its alpha channel renders as an unmistakable row of trees. Rare authored a six-level LOD pyramid for it that PRESERVES the silhouette and its alpha coverage all the way down (44.2 % → 43.1 % → 38.8 %). Pass 23's mechanism (b) — authored LOD chain replaced by averaged mips — now has hard ground truth behind it, and the reference image to diff a live capture against exists offline. Still no build, still no run.**
+
+Pass 23 identified the candidate cards but could not decode them (`HUFFLOOKUP` unimplemented). That is now done: `tools_pc/texdecode.py` ports `texReadBits` (`image_bank.c:99`), `texInflateNonZlib`'s image loop, `texBuildLookup`, `texInflateHuffman`, `texInflateRle` and `texInflateLookupFromBuffer` from `src/game/image.c`, covering the `LOOKUP` / `HUFFLOOKUP` / `RLELOOKUP` compmethods.
+
+1. **`tex1198` LOD 0 — 64×17 RGBA16 (RGBA5551), HUFFLOOKUP, 481/1088 texels opaque (44.2 %).** Its 1-bit alpha channel:
+
+ ```
+ ###....###############...############...########....########.###
+ ###.....##############...############....#.#####.....######..###
+ ###.....#############....###########......######.....######...##
+ ##.....#############.....######.####.......#####....######.#..##
+ ###.....#############....###########.......###......#######...##
+ ###.....####.########....###########......######.....######...##
+ #.......############.....###########.......#####.....#####.....#
+ ##.......###########.....#######..##.......####......#####.....#
+ ##......#######.####......#..###..#........####......#####.....#
+ #.......########.###......#..###..#........####......####......#
+ ##.......###########........####...........#.#.......#####.....#
+ ##.......###########.........###...........####......#####.....#
+ ##.......##.###..###.........###...........####.......####.....#
+ ##........#.###..##..........###............#.#.......###......#
+ .#...........#...##..........##.............##........###.......
+ .................##...........#.............##..........#.......
+ .............................................#..................
+ ```
+
+ Vertical trunks tapering to points, transparent sky between them, thinning to isolated tips at the bottom edge. **These are the trees.** Pass 23's identification is confirmed by decode, not inference. 522 of the 1088 texels are literally `0x0000` (black, alpha 0).
+
+2. **The authored LOD pyramid preserves the silhouette** — this is the load-bearing new fact:
+
+ | LOD | dims | compmethod | alpha opaque |
+ |---|---|---|---|
+ | 0 | 64×17 | HUFFLOOKUP | 481/1088 = **44.2 %** |
+ | 1 | 32×9 | RLELOOKUP | 124/288 = **43.1 %** |
+ | 2 | 16×5 | LOOKUP | 31/80 = **38.8 %** |
+ | 3 | 8×3 | RLE | (channel codec, not ported) |
+
+ LOD 1 and LOD 2 still read as recognisable tree silhouettes, and the opaque fraction holds near 40 % instead of collapsing. Each level uses a *different* compression method, i.e. these are six separately authored images, not a generated chain. **That is exactly what you do to keep a 1-bit-alpha cutout crisp under minification, and exactly what naive box-filter mip generation destroys** — averaging a 0/1 alpha yields fractional alpha, and under `XLU_SURF` (no alpha test, no coverage-from-alpha — pass 22) fractional alpha does not cut out, it blends. A minified treeline then smears into a translucent band across the horizon: the reported "wall/sheet of tree texture".
+
+3. **Correction to pass 23.** Pass 23 called `tex1198`–`tex1201` "the tree cards". The decode refines that: only **`tex1198`** is unambiguously a treeline strip (44.2 % alpha). **`tex1199`** is the same geometry and codec but 89.4 % opaque with thin vertical gaps — a denser foliage/branch mass or a treed cliff face. **`tex1200` and `tex1201` are 100 % opaque** dense detail textures (138–162 distinct colours, no alpha structure at all) that merely share the render-mode class. The "four textures in 21 of 42 rooms each" observation stands; "all four are trees" does not.
+
+4. **Two decoder bugs found and fixed while porting — both would have produced confident, wrong pictures.**
+ * **Per-image byte alignment.** `image.c:1096` ends each LOD image with `if (img_bitcount == 0) img_curpos++; else img_bitcount = 0;` — discard the partial byte, and skip a whole byte when already aligned. Omitting it desynchronised every LOD after the first. It surfaced as LOD 1 parsing as `fmt=0 1x32 cm=0`; a scan of neighbouring bit offsets showed a constant **+8-bit** drift, which pointed straight at the align.
+ * **`texInflateRle`'s fudge was guessed, not ported.** The first draft invented `fudge = ((1< 0) { cost -= blocksize + 1; fudge++; }`. The guess still decoded *something* — it gave `tex1198` LOD 1 as 66.7 % opaque and `tex149` as 48.3 % — and only the corrected port gives the self-consistent 43.1 % and 34.4 %. **Every RLELOOKUP figure in this entry is from the corrected port; any earlier session notes quoting 48.3 % for `tex149` are wrong.** (Same lesson as passes 21 and 23, third time in four passes: in this codebase a half-right bitstream parse produces plausible output, never an obvious failure. Port the derivation, don't reconstruct it.)
+
+5. **What this gives pass 25.** There is now an offline, N64-correct reference for the exact texture at the centre of D236: format, dimensions, per-LOD alpha coverage, and the silhouette itself, reproducible with `python tools_pc/texdecode.py 1198`. A live capture can be diffed against it field by field instead of being interpreted on its own.
+
+**Next step (pass 25) — one run, three questions, all now answerable by direct comparison:**
+ 1. Does `tex1198` arrive as **64×17 RGBA5551**, or are the dimensions/format mangled? (D265 already found one transposed-dimensions bug in this area.)
+ 2. Is the imported alpha still **~44 % transparent**, or opaque everywhere? If opaque, the bug is in the 5551 alpha-bit decode and mechanism (a) is confirmed.
+ 3. Do **all six authored LOD images** reach the GL texture, or only LOD 0 with driver-generated mips? If the latter, mechanism (b) is confirmed and the fix is to upload the authored chain (`glTexImage2D` per level with `GL_TEXTURE_MAX_LEVEL`) rather than letting the driver filter a 1-bit alpha.
+ Capture with `GE_TEXDUMP=1 GE_TEXRAW=1 GE_D270KEEPOML=1049d8` on `-level_36`, filtering **`fmt=0 siz=2`**. Note `texSwapAltRowBytes` (`image.c:1088`) runs on explicit-LOD textures and swaps bytes on alternate rows — expect that in the raw dump; `texdecode.py` decodes logically and does not apply it.
+
+**Tooling added this pass (keep — read-only, no runtime cost):** `tools_pc/texdecode.py` (``, `--lod N`, `--ppm out.ppm`; PPM renders transparent texels magenta).
+
+**Pass 25 (2026-09-21, live session) — all three pass-24 questions answered by direct comparison; the mip mechanism is now located to a line. Q1 YES, Q2 YES, Q3 NO — and the "wall" turns out not to be painted by `tex1198` at all from an idle spawn.**
+
+**Setup:** live runs of `-level_36` (idle boot, 4800 frames) with `GE_TEXDUMP=1 GE_TEXRAW=1 GE_D219RAW=1`, plus one A/B run flipping the in-tree config key `Video.FixMipTextures` (default 1 → 0) to observe unclipped LOADBLOCK sizes. `texdecode.py` was first verified function-by-function against `image.c`/`image_bank.c` (all pass-24 numbers reproduced: 44.2/43.1/38.8 %, 89.4 %, 100 %, tex149 34.375 %). One latent decoder bug fixed on the way: a `max(1, …)` clamp on `(ncol-1).bit_length()` in the plain-LOOKUP path that C's `texGetBitSize(1)=0` does not have (unreachable for GE assets; no output change).
+
+**Q1 — YES, dimensions/format intact.** D219RAW shows all six `tex1198` LODs pool-decoded at level load with the exact offline dims/compmethods (64×17 HUFFLOOKUP, 32×9 RLELOOKUP, 16×5 LOOKUP, 8×3/4×2/2×2 RLE), format=1 (RGBA16) throughout. The live C decoder's first row of LOD 0 is **pixel-identical** to the offline decode after the documented `texSwapAltRowBytes` byte-swap — and that swap is a no-op on PORT (D159 early-return), so raw dumps are plain linear pixels.
+
+**Q2 — YES, alpha survives intact.** Pool bytes ≡ ROM-decoded bytes ⇒ 44.2 % opaque at the pool stage. `import_texture_rgba16` (`gfx_pc.cpp:791`) maps bit0→alpha 255/0; `import_texture_ia8` maps upper nibble=intensity, lower=alpha — both preserve alpha by construction. Data check on the one drawn member of the class: `GE_TEXI[101]` (IA8 32×64, `tex149`) is **byte-for-byte identical** to the offline ROM decode (2048/2048).
+
+**Q3 — NO, structurally.** `gfx_opengl_upload_texture` (`gfx_opengl.cpp:713`) does `glTexImage2D(level 0)` + optional `glGenerateMipmap`; there is no code path that uploads an authored chain as GL mip levels. Each LOADBLOCK becomes one GL texture; the driver box-filters level 0.
+
+**New finding N1 — `tex1198`–`tex1201` are never drawn from an idle spawn.** Across all 4800 frames there is not a single `fmt=0 siz=2` (RGBA16) import while CI8/IA8 background draws continue every frame. The tree-like cover visible at Bond's spawn point is therefore painted by the *other* classes — consistent with D276/D280 (the IA8 noise class paints the weave). Pass 24's "leading mechanism" applies to `tex1198`'s cards, which only draw when the player reaches their rooms; the spawn-visible wall is a different painter.
+
+**New finding N2 — the drawn tree-class texture is `tex149`: IA8 32×64 hard cutout, 34.4 % opaque** (alpha nibble F for opaque pixels, 0 for holes — no ramp). Its ROM blob is **329 bytes = exactly one image**; there is no authored mip chain.
+
+**New finding N3 — the mip mechanism, located precisely.** `texGetDepthAndSize` (`tex.c:330`) sizes the LOADBLOCK from `maxlod`, and `texInflateNonZlib` sets `maxlod = forcenumimages` = the header's `lods` field (**7** for `tex149`) **even when `explicit_lods=0` and only one image was decoded**. So GE's LOADBLOCK is deliberately larger than the real pool data: live A/B with `Video.FixMipTextures=0` shows the same 32×64 cards at **size=2808** (base 2048 + 760 B "mip region") vs 2048 clipped. On N64 those extra bytes are whatever adjacent stage-pool memory follows the allocation — deterministic, likely neighbouring level textures. The port's RC2 clip (`g_fix_mip_textures`, `gfx_pc.cpp:1089/1142`, default ON) truncates to base height and `glGenerateMipmap` + trilinear minification replaces that region with driver box-filtered mips — **observed live on the 32×64 upload: `gen_mip=1 filter=LINEAR mipmap_filter_mode=MIPMAP_LINEAR`; 380/400 sampled uploads generate mips.** For a hard 0/F alpha cutout, box filtering turns every mip level into fractional alpha, and the XLU blend then smears minified silhouettes — the reported wall/sheet. (For `tex1198`-class cards with real six-level chains, the same clip+regenerate path replaces the authored pyramid — pass 24's mechanism (b), now confirmed at the line level.)
+
+**Honesty note.** The N64 side of N3 is established for the *load* (block size, pool contents) but not for the *sample*: what the RDP actually read out of that 760 B mip region for minified cards (adjacent-pool bytes vs effectively level-0) cannot be resolved without hardware or an RDP sampler model. Both candidate port fixes below are therefore approximations to be judged by playtest A/B, not by derivation.
+
+**Fix directions for pass 26 (port-only; no game logic):**
+1. **No-chain textures (`explicit_lods=0`, single-image blob — `tex149` class):** do not `glGenerateMipmap`; sample level 0 only (`GL_LINEAR`, non-mip min filter). Matches "RDP with no real mips" and kills the fractional-alpha smear at zero cost. This is the cheapest test of the whole hypothesis: if the spawn wall sharpens into discrete trees, mechanism confirmed end-to-end.
+2. **Chained textures (`tex1198` class):** upload the authored levels as real GL mip levels (needs per-LOD dims from the pool header; GE's authored dims are *not* exact halvings — 17→9→5→3→2 vs GL's required 17→8→4→2 — so levels must be padded/cropped to legal sizes, or uploaded into a custom layout). Bigger job; only after (1) is judged.
+
+**Probe added this pass (TEMPORARY — remove once the mip question is closed):** `GE_D236P25` env-gated block in `gfx_opengl_upload_texture` logging width/height/gen_mipmaps/filter per upload (capped 400 lines). **Process note:** Git-Bash `kill` does not terminate the game here — use `taskkill //F //IM ge007.x86_64.exe` (two idle runs were left alive this session).
+
+**Pass 26 (2026-09-21, live session) — D236 ROOT-CAUSED AND FIXED. The tree cards were never a decode, alpha or mip problem: the port was binding the wrong texture. Surface 1 now renders discrete snow-covered pines; verified live.**
+
+**The pass-25 fix direction was tested first and REFUTED.** Implemented cut-out
+mip suppression (skip `glGenerateMipmap` + `GL_TEXTURE_MAX_LEVEL 0` for
+textures whose alpha is hard 0/255 with real holes), config-gated, and A/B'd it
+live on `-level_36` at the idle spawn. **The wall did not change.** Two things
+came out of that run and both mattered:
+
+- The per-upload alpha census showed the spawn view is dominated by **248
+ uploads of a fully-opaque 32×32 texture** (`clear=0 solid=1024 mid=0`) — the
+ "wall" is opaque, so no alpha/blend mechanism could have produced it. That
+ retires the whole mip / 1-bit-alpha / XLU-smear branch (passes 22–25).
+- Correction to pass 25's **N3**: the 760 B "mip region" beyond a no-chain
+ texture's base image is **not adjacent pool garbage**. `texInflateNonZlib`
+ (`image.c:1123-1153`) and `texInflateZlib` (`image.c:324-360`) *generate* the
+ chain themselves via `texShrinkNonPaletted`/`texShrinkPaletted` when
+ `explicit_lods == 0 && forcenumimages >= 2`. Those shrinkers also re-quantise
+ alpha to the source's bit depth — for RGBA5551, `a = (a0+a1+a2+a3 + 2) >> 2`
+ keeps every level a hard 0/1 cut-out, unlike a GL box filter. Worth knowing
+ for any future mip work; irrelevant to this bug.
+
+**Root cause (`gfx_lod_tile_offset`, `port/fast3d/gfx_pc.cpp`).** GE's
+`texLoadFromGdl` picks a handler per binding from `in->words.w0 & 7`:
+
+- `TEXTURETYPE_LOD`(0) / `TEXTURETYPE_MIPMAP`(2) — `texHandleType0`/`Type2`:
+ ONE load block at TMEM 0, tiles 0..n are LOD levels of that one image.
+- `TEXTURETYPE_DETAIL`(1) — `texHandleType1` (`tex.c`): **TWO** load blocks —
+ the *detail* texture at TMEM 0 (`texWriteLoadToTmemZero(tex2)`) and the
+ **base image** at TMEM `texGetSizeInBytes(tex2,0)` on tiles 1+
+ (`texWriteLoadToTmemAddr(tex1,size)` + `texWriteTileLods(tex1,…,1,size)`).
+
+D107 folded every `G_TL_LOD` draw to tile 0 on the premise "GE loads the whole
+mip chain at TMEM 0". True for types 0/2, **false for type 1** — there tile 0 is
+the detail texture. So the port drew the detail texture instead of the base
+image, and never imported the base image at all.
+
+**Confirmed against the data, offline and live:**
+
+1. `GE_D85TEX` on `-level_36`: `tex1198`–`tex1201` each resolve 28× with a
+ non-NULL pool pointer and **`type=1`** — they were being bound all along.
+ Pass 25's N1 ("zero `fmt=0 siz=2` imports in 4800 frames") was the symptom,
+ not evidence the cards weren't referenced.
+2. Offline scan of every `G_NOOP` in `bg_sevx_all_p`: the only `type=1`
+ bindings in Surface 1 are exactly the four tree cards, **21 rooms each, all
+ sharing `texnum2 = 2465`**. Everything else in the level is `type=2` with
+ `detail=0`.
+3. `tex2465` = **IA8 32×32 HUFFLOOKUP, `lods=0`, fully opaque** — precisely the
+ 248-upload opaque 32×32 class above, and the same texture D267/D276 measured
+ at "1023/1024 texels at A=F" and called the noise painter. D276's painter was
+ never a mystery texture; it is the tree cards' detail tile.
+
+**Fix (port-layer only, `gfx_lod_tile_offset`).** When `rdp.tex_lod` is set and
+tile `fi+1` sits at a different TMEM address *and* declares a different
+`fmt`/`siz` than tile `fi`, return tile offset 1 (the base image) instead of 0.
+Deliberately asymmetric: LOD levels of one image always share a format, so a
+mip chain can never trip it, and a detail pair that happened to share a format
+would simply keep today's behaviour. Config `Video.DetailBaseTile`, default on.
+
+**Two weaker discriminators were tried live and both failed — recorded so they
+are not retried:** (a) "tile fi+1 is at a different TMEM address" alone — mip
+levels are at different TMEM offsets too, so every ordinary surface started
+sampling its first mip (trees correct, all ground/snow textures wrecked; the
+user caught this in the same build); (b) additionally requiring
+`rdp.loaded_texture[tmem].addr` to be set — same failure, because
+`rdp.loaded_texture[]` is persistent state never cleared between binds, so a
+stale entry from an earlier type-1 draw satisfies it.
+
+**Verification.**
+- Live `-level_36` idle spawn, frame 560: opaque tiled noise wall →
+ **discrete snow-covered pines lining the valley**, ground/snow unchanged
+ against the pre-fix capture. Surface 2 (`-level_43`) likewise shows a discrete
+ night treeline; Depot (`-level_30`, the only other level with `type=1`
+ bindings) renders clean.
+- `bunker1` golden framediff is **bit-identical with the fix on and off**
+ (`worst_cell=189.741875` both ways — that delta is pre-existing, from the
+ other uncommitted work in the tree, not from this change).
+- Offline blast-radius scan of every level's bg: `type=1` bindings exist **only**
+ in `sevx` (84), `depo` (39), `sevb` (4), `sev` (4). Every other level has zero,
+ so the change is provably inert on them.
+
+**Generalised to `docs/porting-notes.md` §D12.**
+
+**Housekeeping:** with D236 closed, every D236 diagnostic is out of the tree
+-- the pass-25 `GE_D236P25` probe and the pass-26 cut-out experiment, plus
+`GE_D236RM`/`ORDER`/`ALPHA`/`ZFIX` (`gfx_pc.cpp` `gfx_sp_tri1`), `GE_D236RAW`
+(`bg.c`), `GE_D236BT` (`chr.c` + `model.c`), the `port_d236bt_dump()` host
+backtrace dumper and the `windows.h` include it needed, and the
+`g_d236_last_vtx_seg`/`_raw` G_VTX trackers. Their rows are gone from
+`GE-ENV-PROBES.md`; `gen_env_probes.py --check` is in sync. Build clean and
+the Surface 1 spawn capture is unchanged across the strip.
+
## 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…
**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".
**Status (from §F table):** OPEN — not designed; port-only
+**IMPLEMENTED (2026-09-23, v0.4.0; port-only, `port/src/optionsoverlay.c`).** Wheel scroll already existed (D304 era). Added: (1) **category headers**, a new `ROW_HEADER` kind (not config-backed, never selectable; up/down/wheel step over headers via `overlayStepSel`, clicks on headers ignored, `rowAdjust` no-ops) drawn in the title accent: DISPLAY / VIEW / MOUSE / AIM / CONTROLLER / GAME; (2) **more settings**: `Game.SkipIntro`, `Video.DisplayFPS`, `Input.PadLookInvertY`, `Input.PadDeadzone` (slider, step 500), `Input.PadTriggerPct`. `Game.NoHitFlash` was already exposed. Verified: overlay capture shows the headers and the selection starting on the first real row; no unregistered-key warnings; Windows + Linux builds clean. Owed: a hands-on pass (wheel/arrows skip headers, the new sliders feel right).
+
## D238 — QoL ask (M-106, user QA): mouse sensitivity rework toward normal PC-shooter feel.
**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).
@@ -10999,13 +11439,32 @@ User played live with `GE_D236ORDER=1` set, walked toward Surface1's tree line,
**Player gunshot SFX timing/cadence reportedly does not match the N64 original's rate (M-106, user QA report).** Static trace only (no ROM/build/display in this worktree). Weapon fire re-triggers its SFX through `bondwalkItemGetSoundTriggerRate()`/`bondwalkItemGetSound()`, gated on `g_GlobalTimer` (`src/game/gunfire.c:3246-3286`: `handptr->field_A4C = bondwalkItemGetSoundTriggerRate(var_s1) + g_GlobalTimer`), alternating between two per-hand `ALSoundState` slots (`audioHandle`/`field_A48`, same block) so a fast-firing weapon's report can overlap itself once before being retriggered. `g_GlobalTimer` advances by `g_ClockTimer = speedgraphframes` every tick (`src/game/lv.c:1023,1037`), and `speedgraphframes` is set from `deltaFrames` in `updateFrameCounters()` (`src/game/frametiming.c:64-76`) — the exact tick-delta plumbing already investigated (and partly fixed) under **D155** (catch-up-spiral clamp, same file), **D186** (`Video.FpsCap` sim-coupling clamp), **D193/D209** (AI-locomotion timing keystone — D209 the actual root cause, a 64-bit union-aliasing bug, now fixed and merged), and **D204** (audio pipeline running ~2% below real time forever, fixed by padding silence at the DAC). Since D209 and D204 both merged before this report, the SFX-cadence complaint is either (a) a genuinely distinct, audio-specific residual downstream of those fixes — e.g. a mismatch between the sim-tick-gated *trigger* (above) and the software mixer's own playback clock (`port/src/audio.c:93,155,221-222` — the synth produces samples at a measured ~21600/s against the 22050 Hz output device, the same gap D204 had to compensate for elsewhere) that specifically desyncs how *often* a shot's sample starts rather than the absolute wall-clock rate D204 measured, or (b) a stale pre-D209/pre-D204 build report. Per-weapon rate constants (`AutomaticFiringRate`/`SingleFiringRate`/`SoundTriggerRate`, `src/game/gun.h:60-82`, values in `assets/obseg/gun/*/gunWeaponStat.inc.c`) are plain compiled C literals, not ROM-serialized data, so no pointer-width/ABI reconciliation issue applies here.
-**Status (from §F table):** **OPEN — not root-caused; static call-site tracing only, no runtime verification possible in this environment.** Confidence LOW-MEDIUM that this is a *new* bug distinct from the D155/D186/D193/D204/D209 timing-keystone family (all of which gate the identical `deltaFrames→speedgraphframes→g_ClockTimer→g_GlobalTimer` chain this SFX trigger consumes). Raise confidence by: (1) confirming the user's build/commit postdates the D209 merge; (2) a `GE_AUDIOTRACE`-style capture logging the tick (`g_GlobalTimer`) and/or wall-clock timestamp of consecutive same-weapon fire-SFX requests during sustained auto-fire (AK47/Klobb, `AutomaticFiringRate=0x03`), diffed against the weapon's rate constant and against N64 reference footage's frame-count between shots.
+**Status (from §F table):** **CLOSED (v0.4.0 campaign sign-off 2026-09-28, user: cadence drift not heard over the full campaign -- fixed; never root-caused -- if re-reported, the timing-keystone family above is the prime suspect).** Superseded: OPEN — not root-caused; static call-site tracing only, no runtime verification possible in this environment. Confidence LOW-MEDIUM that this is a *new* bug distinct from the D155/D186/D193/D204/D209 timing-keystone family (all of which gate the identical `deltaFrames→speedgraphframes→g_ClockTimer→g_GlobalTimer` chain this SFX trigger consumes). Raise confidence by: (1) confirming the user's build/commit postdates the D209 merge; (2) a `GE_AUDIOTRACE`-style capture logging the tick (`g_GlobalTimer`) and/or wall-clock timestamp of consecutive same-weapon fire-SFX requests during sustained auto-fire (AK47/Klobb, `AutomaticFiringRate=0x03`), diffed against the weapon's rate constant and against N64 reference footage's frame-count between shots.
## D241 — Player gunshot SFX goes silent under some conditions — reported on Bunker with the alarm sounding, "or some other factor" — but only for PP7 and AK47, Klobb…
**Player gunshot SFX goes silent under some conditions — reported on Bunker with the alarm sounding, "or some other factor" — but only for PP7 and AK47, Klobb unaffected (M-106, user QA report, concrete repro).** This is very likely **D207 finally reproduced.** D207 (M-77) root-caused that GE's SFX player has a hard **8-voice cap** (`MUSIC_SFX_SEQ_MAYBE_MAX_SOUNDS`, `src/music.c:76`) and that D206's fix made the alarm klaxon (`ALARM3_SFX`=163, triggered from `handle_alarm_gas_timer_calldamage()`, `src/game/propobj.c:14457`) a genuine **infinite ADPCM loop** that now legitimately occupies one of those 8 voices for the alarm's whole duration; the manual preemption scan (`snd.c:~347-392`) refuses to steal any *owned* LOOPED voice (the M-65 carve-out only exempts *ownerless* loops), so once 8/8 voices are held, a new one-shot request is silently dropped via `sndDisposeSound` (`snd.c:391`). D207's own M-77b session built a `GE_FORCEALARM=1` BUNKER1 harness and could **not** reproduce audible drops (`[D207-DROP]=0`), and explicitly left as "owed": *"needs a real repro from the user: which level, which weapon/situation."* This report supplies exactly that (Bunker, alarm). **Investigated why PP7/AK47 and not Klobb, inconclusive at the code level:** Klobb (`ITEM_SKORPION` — confirmed GE's internal name for the Klobb via `src/game/player.c:576`, `skorpion_stats` in `assets/obseg/gun/skorpion/gunWeaponStat.inc.c`) and AK47 (`ak47_stats`, `assets/obseg/gun/ak47/gunWeaponStat.inc.c`) share the **same** `AutomaticFiringRate=0x03` (fast auto), while PP7 (`wppk_stats`, `assets/obseg/gun/wppk/gunWeaponStat.inc.c`) is semi-auto only (`AutomaticFiringRate=0xFF`) — so a naive "fires faster ⇒ more self-inflicted voice contention" theory does not cleanly explain grouping PP7 with AK47 against Klobb. All three weapons' fire SFX (`GUN_B1_MGUN3_3_SFX`=Klobb, `GUN_B2_HEAVY_SFX`=PP7, `GUN_B4_BOLTACTION_SFX`=AK47 — `src/bondconstants.h:2277-2280`) go through the identical one-shot, priority-0x40 `sndPlaySfx` path (`gunfire.c:3246-3286`); no per-weapon distinction exists in the trigger or priority *code*. A real distinction, if one exists, most likely lives in the **sample bank data itself** (per-sample envelope/decay length — a longer-tailed sample stays `PLAYING` and stealable/held longer, changing its odds in the preemption scan) — that data is ROM-derived binary content, not inspectable without the extracted ROM (`./data/`, absent in this worktree). Confirmed the voice-allocation/preemption code is unmodified `src/snd.c` game logic, not a `port/` mixer bug: `port/src/mixer.c`/`port/src/audio.c` implement DSP synthesis and device output only (per D199), with no SDL `Mix_*`-style channel-priority layer in the port to have introduced a new bug. Per AGENTS.md non-negotiable #2, any real fix belongs in D207's already-designed (reverted, M-77b) last-resort LOOPED-preemption pass in `snd.c`, not in `port/`.
-**Status (from §F table):** **OPEN — not newly root-caused; high confidence this is D207 reproduced, not an independent bug.** Recommend folding this repro into D207 (or closing D241 as a duplicate once D207 is reopened) rather than tracking separately. Next: re-run M-77's `GE_FORCEALARM=1` harness on `-level_09` with scripted PP7 and AK47 fire (not just generic fire) against Klobb as a control, watching `g_sndAllocatedVoicesCount` / the (currently reverted) `[D207-DROP]` counters to see whether the 8-cap is actually hit for two of the three weapons and not the third — or whether it is really the sample-envelope-length distinction above. No runtime verification possible in this environment (no ROM/display).
+**Status (from §F table):** **CLOSED as the D207 family (v0.4.0 campaign sign-off 2026-09-28, user: no silence under heavy fire + alarms -- fixed).** Superseded: OPEN — not newly root-caused; high confidence this is D207 reproduced, not an independent bug. Recommend folding this repro into D207 (or closing D241 as a duplicate once D207 is reopened) rather than tracking separately. Next: re-run M-77's `GE_FORCEALARM=1` harness on `-level_09` with scripted PP7 and AK47 fire (not just generic fire) against Klobb as a control, watching `g_sndAllocatedVoicesCount` / the (currently reverted) `[D207-DROP]` counters to see whether the 8-cap is actually hit for two of the three weapons and not the third — or whether it is really the sample-envelope-length distinction above. No runtime verification possible in this environment (no ROM/display).
+
+**2026-09-26 (M-201, v0.4.0): user still hears gunshot drop-outs "with rapid fire guns while a lot of other sound effects occur" (D322 garbling itself is gone). Headless measurements, no drop reproduced.**
+
+- **Static:** player shots (`gunfire.c:3296-3340`) and guard shots (`chraction.c:5630`) both stop the previous shot sound and start a new one, using at most 2 handles per hand, rate-limited by `SoundTriggerRate` against `g_GlobalTimer`. At 1 tick/frame a shot sound can start every `rate+1` ticks; at the N64's 2–3 ticks/frame the check rounds up to frame boundaries (rate 3: every 6 ticks at 3 ticks/frame vs 4 on PC), so the port plays up to ~1.5x more shot sounds (a D13-class divergence) but does not hold more simultaneous voices. `sndDeactivate` only posts `AL_SNDP_DEACTIVATE_EVT`; the slot frees in `sndDisposeSound`.
+- **Runtime (Dam, invincibility + all guns, `GE_FORCEALARM=1`, PP7 held in bursts via new `GE_INPUTSCRIPT` `ZHOLD`/`ZREL`, ~100 s each):** `GE_AUDIOTRACE` `[D207-DROP]` = 0 at both FpsCap 60 and 30 (peak SFX slots 8/8 and 7/8); `GE_D322` at 60 fps: worst sample `sfx` 7/8, `pv free` 9/24, `evtq` 9/64, audio `q` 720/2880, `drop=0`. Both drop paths (`preempt-scan` = 8-cap, `no-limit` = physical pool) are covered by `[D207-DROP]`; failing sounds first retry every 33 ms (audible as late, not missing).
+- **Harness limits:** `gunRequestHandWeaponChange` / the level-start equip call did not switch weapons mid-level from the harness, so the automatic-weapon case (AK47/Klobb) is untested headless.
+- **Next:** capture the user's real scenario with `GE_AUDIOTRACE=1 GE_D322=1 GE_D204=1` (`scratch/playtest_audio.bat`), noting level, weapon and time of each drop-out; any `[D207-DROP]` line names the layer. If none appear, the "drop-out" is the faithful stop-then-restart of the per-hand shot sound, not a lost voice.
+
+**2026-09-26 (M-201, cont.): live captures. Not reproducible with lightweight logging; the one captured drop-out ran with the heavy GE_AUDIOTRACE probe.**
+
+- **Run 1 (user, Bunker, AK47, alarm active, `GE_AUDIOTRACE`+D322+D204):** drop-out heard. `[D207-DROP]`=0, pools healthy (sfx <= 6/8, pv free >= 11/24). The per-sound `sndPlaySfx` trace is capped (~630 events) and stopped before the AK47 section, so it could not show the moment. `GE_AUDIOTRACE` does an uncached `getenv` plus a file write from the audio path on nearly every sound event (D250/D302 class), so this drop-out may be probe-induced.
+- **New TEMP `GE_D207S`** (`src/game/gunfire.c`, `#ifdef PORT`, read-only): logs every player shot-sound decision (`H1`/`H2`/`SKIP`, where SKIP = both per-hand handles still held because the stop is async) with `g_GlobalTimer`. Uncapped, cheap.
+- **Run 2 (FpsCap 30):** 315 decisions, 0 SKIP, no drop-outs heard. AK47 gaps 5–7 ticks.
+- **Run 3 (FpsCap 60):** 408 decisions, 0 SKIP, no drop-outs heard. AK47 exactly every 5 ticks (`SoundTriggerRate` 4 in the BUGFIX_R0 build).
+- **Run 4 (FpsCap 60, stress test, 7 weapons incl. AK47/ZMG/D5K/RC-P90/Klobb):** 2073 decisions, **0 SKIP**; SFX cap frequently 8/8 but `drop=0`; evtq max 21/64; D204 `rt` 0.999–1.000 throughout, audio queue never empty. User: "seemed okay".
+- **Refuted:** voice-cap drops, physical-pool exhaustion, per-hand handle starvation (the 60 fps shot-rate divergence is real, 5 vs 6 ticks for the AK47, but never starves the handles), output underrun during these runs.
+- **Open:** the drop-outs the user heard in the untraced 21:9 session remain unexplained and intermittent. Standing capture: `scratch/playtest_audio.bat` (`GE_D207S` + D322 + D204, no audio-thread file I/O); press F12 on a drop-out.
+
+**Status: OPEN (not reproducible) — drop paths and handle starvation refuted by measurement; awaiting a natural occurrence under lightweight logging.**
## D242 — Player-death sequence UX regression vs N64 original: PC requires an LMB click to proceed to the mission-failed/file-select screen; user recalls N64 auto-plays…
@@ -11013,6 +11472,8 @@ User played live with `GE_D236ORDER=1` set, walked toward Surface1's tree line,
**Status (from §F table):** **NOT A PORT-LOGIC BUG as far as static analysis can tell.** Confidence HIGH that the LMB-click skip path is faithful original behavior (Z_TRIG-mapped, exists unmodified in `bondview2.c`), not a defect to remove. Confidence LOW (untested) on whether the no-click automatic path is actually healthy on PC. Needs a human playtest: die, then **deliberately touch no input** during the death sequence and time whether/how long until the mission-failed screen appears on its own. If it never arrives without a click, the real bug is a `g_GlobalTimerDelta`/`camera_transition_timer` stall in the D240 clock family — cross-reference D240 — not a wrongly-added input gate, so there is still no `#ifdef PORT` skip-gate to remove per AGENTS.md #2. No runtime verification possible in this environment (no ROM/display).
+**M-201 (2026-09-26): headless check, no input after death.** Dam, `GE_D320R="1000:explode=13,0"` (grenade at Bond's feet, no invincibility), `GE_INPUTSCRIPT` with no events: the death camera auto-played its angles (frames ~1400–1900) and the stage exited on its own ~15 s later (stage 33 → 90). **The "PC requires an LMB click" report does not hold on the current build.** A direct `-level_XX` launch has no front-end session, so it then fell into the title/attract loop (stage 90 → 25 → 90) — not comparable with the N64 reference (user on 1964: returns to mission select and shows "Killed in Action"). Owed: user check from a normal menu launch that death lands on mission select + KIA.
+
## D243 — Dam end-of-level cutscene (the abseil/bungee sequence, D148/D160's subject): the camera violently shakes/"spazzes" during each of the scripted camera-angle…
**Dam end-of-level cutscene (the abseil/bungee sequence, D148/D160's subject): the camera violently shakes/"spazzes" during each of the scripted camera-angle cuts (M-107, live `GE_D160=1` trace, two interactive `-level_33` runs + one fresh-save normal-progression run).** **First, corrects D160's own premise: the cutscene is NOT skipped on the current build.** Both debug-loaded runs show exactly the AI-script-scripted sequence firing: `bondviewSetCameraMode(7)` (`CAMERAMODE_POSEND`, the cutscene camera-lock state) called 2-3 times, matching `ai_17`'s three `camera_switch` commands in `UsetupdamZ.c`'s abseil ailist (`assets/obseg/setup/UsetupdamZ.c:2656`+), followed by `bossReturnTitleStage`. So D160's original (b)/(c) hypotheses (AI trigger never fires / puppet renders invisibly) are **refuted on this build** — the mechanism runs; what's broken is what it renders. **Root cause candidate, directly observed:** right in the camera-cut window, fast3d's D146 catch-all (`port/fast3d/gfx_pc.cpp:3037`, "unknown GBI opcode … ending DL") fired a tight burst — same 4 memory addresses repeating every frame for ~10 frames, then clean — with payload bytes `0xfafbfbfcfcfbfdfc`/`0xfcfafbfbfafbfafb`: a **freed/uninitialized-memory fill pattern**, not real GBI commands. This reads as a stale-pointer display-list read, not a decode bug — some segment/scene buffer backing the outgoing camera shot's geometry got freed or recycled while a still-in-flight display list (queued ahead in the port's double-buffered render pipeline — see D134's "pendingGfx" note, `port/src/libultra.c:733-746`, on how many frames of latency the sched round-trip can carry) still referenced it. **Independent evidence of a race, not a fixed-order bug:** the two `-level_33` debug-load runs show the camera_switch(7) calls landing in a *different order relative to* `bossReturnTitleStage` between runs (run 1: three switches, then the stage-return call; run 2: two switches, the stage-return call, *then* a third switch arriving after) — and the D146 corruption burst appeared in run 1 but not run 2 at all, non-deterministically. That pattern (symptom present/absent, call order shuffling) is the signature of a genuine timing race between the AI-list's cutscene commands and the level-exit/report-transition code, not a deterministic logic bug. **User-confirmed on real (non-debug-boot) play, not just the debug harness:** a fresh-save normal playthrough into Dam still hit the camera glitch on this same cutscene — ruling out "artifact of the `-level_33` debug boot" for this specific symptom (unlike the secondary "no report screen, straight to file-select" symptom seen only on the debug-loaded runs, which did NOT reproduce on the fresh-save run and is therefore most likely a `-level_N` debug-boot artifact per the D190 precedent, not filed as its own bug here).
@@ -11229,6 +11690,19 @@ D173 (level-start floating), D292 (death sinking), and the Cradle spin (also und
**Untested candidate, explicitly NOT shipped as the default** (per this row's own "don't repeat the same mistake" lesson from the superseded `allowShift=TRUE` fix): `skyPortForceFanShift()` + `GE_D245_FIXEDSHIFT=` env var (`sky.c`, `#ifdef PORT`) lets a live session force a single constant `k` for the whole water draw instead of the per-frame adaptive pick, eliminating the transition this session found — but at the cost of coarser tc quantization *always* (a fixed `k=5` collapses to whole-texel granularity, which could itself look worse on the shimmer cross-fade; that trade-off can only be judged by eyes on the actual water, not another headless capture). Build-verified crash-free with `GE_D245_FIXEDSHIFT=5` on Frigate (300+ frames); `verify.sh frigate`/`verify.sh "surface 2"` both PASS with the env var unset (default path byte-identical to the current reopened behaviour — nothing changes for players until someone opts in). **Next step for whoever has live Frigate/Surface-2 access:** play with `GE_D245_FIXEDSHIFT` unset (current behaviour, confirm the glitch still reproduces) vs. set to 3, 4, and 5 in turn, and report which (if any) looks better — that's the missing data point, not more static tracing.
+**M-201 (2026-09-26, v0.4.0): FIXED — two port-layer causes; user-verified against 1964 ("looks accurate").**
+
+User vs 1964: PC water jittered with player movement, "reset" even standing still, animated too fast (the earlier yellow line was not present on the current build).
+
+1. **Texture geometry (the resets + speed).** The IsWater image is a 32x32 CI8 texture (mask 5 = 32-texel period) loaded with its mip chain (1400 B) and drawn through an RGBA16 tile (the D229 case). fast3d imported it CI8 as **32x43** (mip rows included) while the triangle path normalised UVs with the RGBA16 reading, **width 16** (line bytes / 2) and height 43 (`GE_D229` probe: `texw=16 texh=43`). So S ran at 2x frequency and T repeated every 43 rows, not 32; the port's per-fan S/T fold (multiples of 64 texels) is not a multiple of 43, so every fold step jumped the pattern. Fix (`gfx_pc.cpp`, D229 case only): import only the `line_size * (1<` overrides the computed scale outright (test hook; floor 0.25 for stress-testing the exhaustion path; also a user opt-out).
+
+**Verified headless (`-level_22`, probes `GE_D294`/`GE_D104`; the `GE_D294` stage-load print is left in `boss.c`, env-gated):** all-N64-faithful INI → `ma=225280` = exact authored size (identity ✓); user settings (FovScale 130, DD/Lod 400) → `ma=450560` (2×) with **zero** alloc failures across repeated runs; `GE_ROOMPOOL=0.25` → `ma=56320` and exhaustion reproduces on demand (thousands of `FAIL alloc` lines with movement) — proving both the override and the mechanism.
+
+**Residual, documented only (NOT a port bug): the far-horizon hard edge at high DrawDistance on night levels.** Where fog saturates to the sky colour before the geometry's far clip, the horizon reads as a sharp lit→black boundary. That is inherent to the D218 draw-distance setting on levels whose sky ≈ black (Statue, and similar); the N64 never shows it because its far clip is authored short enough that fog always saturates first. No fix warranted; if it bothers a user, lower DrawDistance.
+
+**Side finding (CLI footgun, no fix — game code is ground truth):** `tokenFind(1, "-m")` **prefix-matches** `-ma`, `-ml`, `-me`, …, so passing a bare `-ma N` (or `-m`) on the PC command line corrupts `g_CurentMMallocValue` (→ 0 → divide-by-zero crash). The in-ROM rows are the only intended source of these tokens; don't pass `-m*` flags on the CLI. (The D121 guard `tokenFind(1, "-m") == 0` correctly means "no -m* flag supplied".)
+
+**Playtest update (evening of 2026-09-22).** First live Statue session at the user's settings: the wide monument-plaza patch is **gone**; residual report — "minor geometry going black/void at the corner of the screen at max FOV when running forward/backwards fast". An instrumented re-test (`scratch/d294_capture.bat`) did not visibly reproduce, but the log caught **75 `FAIL alloc` events anyway** (room 11 ×43 @33 KB, plus 23/7/17) — brief per-frame-retried starvation episodes the eye doesn't register. Root of the residual: GE keeps loaded rooms resident, and Statue's **full 27-room set is 487 KB = 2.2× its authored pool** — running around at max visibility eventually demands the whole set, which starves even the 2× (450 KB) pool. The sqrt rationale measured only *simultaneously visible* rooms; it missed accumulated residency.
+
+**3×/linear attempt REGRESSED — reverted (same evening).** Distance terms linear + cap 3.0 (`ma=675840`) fixed the starvation headroom but broke the renderer on the **menu-driven** Statue path: "world renders as untextured polygons, Bond's hands normal". Log evidence: `[D156] bad anim frame guarded: frame=nan ...` + `subcalcmatrices: frameb out of range!` spam starting ~5 frames after stage load; the corrupted Model has valid fields (speed=0.54, anim ptr into PERMANENT) mixed with NaN/garbage floats (newspeed≈1e24, elapsespeed≈-FLT_MAX). **Not a bank overflow** — `GE_D294BANK` probe shows STAGE [0x70050000..0x706a8000] with pos=0x702ebb30 (2.6 MB used, 3.7 MB free) and `needmemallocation=0`. **Path-dependent**: direct `-level_22` boots were clean in 4/4 headless runs (Statue at vi post #1); the user's menu-driven load (Statue at vi post ~#3541, after ~60 s of front-end) corrupted. Suspects: menu-path-specific init order, or wall-clock-seeded stochasticity that the short headless runs didn't hit. Reverted to sqrt + 2.0 cap (user-verified working state; `ma=450560` re-verified). Probes left in place for the next repro: D156 hexdump of the corrupted struct (`src/game/model.c`, TEMP-marked) + `GE_D294BANK` bank-state dump (`src/game/bg.c`, wired into `scratch/d294_capture.bat`).
+
+**Status:** **FIXED (main symptom, user-confirmed); residual corner flicker OPEN** (2026-09-22). The 2× build is the shipped state. To close the residual: capture-bat run at max FOV with fast movement → if `FAIL alloc` recurs, the fix must cover whole-level residency (487 KB) **without** the 3× regression — options: scale only when the level's room-set total is known to exceed pool (bg data has per-room sizes at load), or a smaller targeted bump (e.g. 2.5×); any change needs BOTH a direct-boot AND a menu-driven verification.
+
+**2026-09-25 playtest (user, Windows, one ~46-min session Silo → Caverns, `GE_D322=1 GE_D204=1`, build `7baf4900`):** walking past the CCCP monument did not reproduce the black ground. Confirmed fixed.
+
## D211 — PC FOV slider — `Video.FovScale` (M-83 QoL wave, port-only, `WIDESCREEN-FOV-PLAN.md` Phase 4).
**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).
@@ -11520,6 +12042,32 @@ This is a **perfect, smooth white-hot → yellow → orange → deep-red → fad
**M-188 (2026-09-18, same session as the D219 fix): the user's actual live-observed symptom for this label — "little lingering particle sprites that show up after an explosion ran" (explicitly NOT the main fireball, which the user separately confirmed was already fixed a while back, presumably the D228/M-115 byte-order fix) — is CONFIRMED RESOLVED by the D219 fix.** Traced the mechanism before asking for a retest, not just guessed: `bullet_spark_create(pos, 1, ..., room)` (`explosion_smokeimages`-typed spark, `glass2.c`) is called from `objHit` (`propobj.c:9633`) on any bullet/explosion-damage impact against a world object, plus `chr.c:3408/3414`, `chraction.c:6865`, `chrprop.c:1200` — i.e. the "lingering embers/residue" particles left behind after something is shot or blown up **are** `s_bullet_spark` instances, rendered through the exact same `bullet_spark_render` function D219's fix corrected. User retested live on Facility (destroying an object/explosion) on the post-D219-fix build: "seems fine now." **This row (the lingering-particle-residue manifestation of D252) closes as a duplicate of D219, not a separate bug.** The *original* M-124 "particle effects cycle through a rainbow palette" report bundled at least two distinct code paths under one label (see the M-157/M-158 history above, which already started separating them) — the surviving open question, if any, is only the **main explosion fireball's own palette** (the `explosion.c`/`import_texture_rgba16`/`G_CC_INTERFERENCE` combiner investigation above), which the user says was already fixed separately and has not been re-reported since. Re-open only if a *fireball* (not residue/embers) rainbow report comes in again.
+**M-200 (2026-09-25, v0.4.0): the M-191 "rainbow confetti" is identified as the explosion *debris* particles; four headless explosion repros are all clean; that code path is statically clean for colour; still OPEN, with a working repro harness.** (M-191's own text was filed under `## D303` by mistake. Read it there.)
+
+- **What the "confetti" is.** The only confetti-like explosion element is the shrapnel/debris quads: `explosionInitFlyingParticles` spawns them (`explosion.c:1541`, called from `explosionCreate` with `shrapnel_*` sizes), they tumble with random rotation, fall under gravity, and are drawn by `explosionRenderFlyingParticles` (`explosion.c:1722`) via `globalDL_0xa50`. Captured frames show exactly that: dozens of small tumbling flecks over the fireball.
+- **Statically clean for colour.** Vertex colour is always grey (`cn[0]=cn[1]=cn[2]`, alpha 0xDC). The texture is `IMAGE_STATIC_NOISE` (IA8 32x32, loaded as IA16 LoadBlock; the texture-cache key includes the render tile's fmt/siz, so it always decodes as greyscale). The combiner is `G_CC_MODULATEIA` 1-cycle. The only way to get hue on this path is a coloured SHADE, i.e. `G_LIGHTING` on at `gSPVertex`, which would make `cn[]` read as a normal and give a colour that changes as the quad rotates. That would fit "rainbow confetti", but the preceding `glassRenderShards` always clears `G_LIGHTING|G_TEXTURE_GEN` and nothing else in the `lv.c` sequence sets it. `struct FlyingParticles` has no pointer fields; the buffer uses `sizeof`; the `(struct FlyingParticles *)(u32)g_FlyingParticlesBuffer` cast (`:1737`) is safe because DRAM is mapped below 4 GiB (`dram.c`). No A1 raw-offset reads exist in `explosion.c`, `glass.c`, `cleanexplosions.c` or `initexplosioncasing.c` (the D219 site in `glass2.c` is fixed in full). Smoke (`g_SmokeTypes[]` rgb), scorch (grey `cn`), and explosion parts (white default vertex, 15 fire/smoke records) are the same: no per-vertex colour source.
+- **Measured.** `GE_D157=1` on the Bunker 1 forced-explosion repro: every debris vertex (`cn` alpha 220) has `shaded == cn` and grey, with `LIGHTING=off`. The only `LIGHTING=ON` draws are `geom=0x60205` (texgen: viewmodel/glass), not particles.
+- **Repro matrix, all clean** (hue scan: saturated cyan/blue/magenta pixels outside the HUD strip; plus contact sheets checked by eye): (1) Bunker 1, forced `EXPLOSION_DEF_STANDARD` at 500 u; (2) Jungle, 4 forced explosions; (3) Jungle, **the user's original trigger**: 2x grenade launcher cheat + invincibility, 12 grenades fired (`scratch/d252gl_sheet.png`); (4) the same at the user's video settings (1280x720 windowed, MSAA 8, aniso 16, filter 2, FOV 150, native widescreen). Debris is grey/white in every frame, and the fireball and smoke palettes are correct. This is the sixth-plus clean dev-box attempt for this family (cf. M-158's 0/6), so the trigger is environmental or state-dependent and not a fixed mechanism in this path.
+- **Ruled-out side lead (worth keeping):** the GL texture cache (`gfx_texture_cache_lookup`) has no content hash for non-CI keys and is cleared only at stage load (D235 M-193). Particle textures load through the main texpool, which is bump-only inside a level (`texLoad`, `image.c:2510`: no eviction; on pool-full it returns `pool->start`). So they can't hit a stale address. Per-weapon pools that reset on weapon switch remain a theoretical stale-hit site (unrelated to explosions).
+- **Harness (TEMP, in the existing `GE_D320R`, `port/src/d320repro.c`):** new actions `explode=,` (calls `explosionCreate` `` units ahead of Bond) and `cheat=gl|rl|inv|` (the game's own `cheatButtonHandleCheatsTurnedOn`). Real-trigger repro: `GE_D320R="850:cheat=gl;850:cheat=inv" GE_INPUTSCRIPT="950:A;1060:Z;1100:Z;…" GE_PCDUMP="1000-2500:2" ./build-pc/ge007.x86_64.exe -level_37` (A once equips the dual GLs; a second A goes to mines). Scan script: `scratch/d252hue.py`. Do **not** combine with `GE_D157` on Jungle: the per-vertex log drops rendering to ~1 frame/1.5 s.
+- **Next (needs the user):** when it is seen live, capture it. Take a screenshot, note the level and what exploded, and ideally run with `GE_D157=1` on an indoor level (logging is too heavy outdoors). The probe logs `LIGHTING`/`shaded` for the debris vertices; a `LIGHTING=ON` line or non-grey `shaded` for an alpha-220 vertex confirms the lighting-leak branch and names the preceding state. If shaded is grey while the screen shows colour, the fault is GL-side (texture binding/sampling), not the vertex stage.
+
+**Status: OPEN (PARTIAL) — unchanged; not reproducible headless across 4 configurations including the user's own trigger; debris-particle path statically clean for colour.**
+
+**M-201 (2026-09-26, v0.4.0): ROOT CAUSE FOUND + FIXED — port-side, `port/src/gimgfixup.c` (the D68 shim). Explosion display lists kept the *first stage's* (title screen's) texture pointers for the rest of the session.**
+
+- **User trigger that cracked it:** Facility explosions are fine at first and go rainbow for *every* weapon once the player reaches the middle (by the lockers), and stay broken. So it is state-dependent, not per-explosion-type (the tank = `EXPLOSION_MEDIUM` vs grenade `EXPLOSION_DEF_STANDARD` split was a red herring).
+- **Refuted on the way:** stage texpool exhaustion. `GE_D252POOL` (TEMP, `image.c` texLoad) logged 0 pool-full events; Facility's 650 KB pool is 85% full at spawn and still had ≥61 KB free at the lockers.
+- **Evidence:** the F12 burst capture (`GE_D157`) showed the debris/smoke/fire textures being read from `0x700ae2e8…0x700bb5d8`, while Facility's texpool was `[0x70102ae0..)`. `0x700ae2e8` is exactly the *title stage's* texpool start + the 8-byte texnum header. The debris quad state itself was clean (grey `cn`, LIGHTING off, MODULATEIA, IA8), so the colour came from the texture bytes.
+- **Mechanism:** `texReset()` (`image_bank.c`, called from `lv.c` on **every** stage load, after `set_mt_tex_alloc()`) re-copies the global image table from ROM and `texLoadFromDisplayList()` resolves the `globalDL_0x078…0xa50` IMAGESEG words into the new stage's pool. On N64 those ROM-copy DLs are what executes. On PC `explosion.c` executes the *compiled* shadows (`g_ExplosionDisplayLists[]`), and `gimgSyncCompiledGlobalDLs()` copied resolved words into them **only for slots still holding the `0xABCDxxxx` marker**, i.e. only on the first call (the title stage). Every later stage kept pointers into the title's freed pool region. That memory is reused by the new stage's own allocations, so explosions look right until the stage overwrites it (Facility: rooms streamed in around the lockers), then every smoke/fire/debris texture samples garbage.
+- **Why ~10 headless repros were clean:** `-level_XX` boots straight into the level (its own first stage, so its pointers are correct), and even a harnessed `level → title → level` reload lands both pools at the same address with the same load order, so stale pointers hit identical bytes. Only a real boot → title → level sequence moves the pool.
+- **Fix:** learn which compiled `G_SETTIMG` slots are IMAGESEG references on the first sync (marker still present), store them in `s_imgslot[][]`, and re-copy those slots on every call. Semantics now match N64 (fresh pointers per stage). Port-only; no game code touched.
+- **Verified:** user playtest, boot → title → Facility → past the lockers, grenades/mines/tanks all correct ("yeah that fixed it"). `D252SYNC` (TEMP, `GE_D252POOL`) logged `32 texture slots re-synced, 32 changed` at the Facility load, i.e. all 32 were stale before the fix. Golden gate 3/3.
+- **Scope:** covers every D252-family report that goes through these 17 global DLs (explosion parts, smoke, debris; `globalDL_0x000` too). The M-124/M-157 bullet-impact spark sightings are likely the same stale-pointer effect but were not separately re-tested.
+- **TEMP probes to remove when the release settles:** `GE_D252POOL` (`image.c`, `initmttex.c`, `D252SYNC` in `gimgfixup.c`); `GE_D157` F12 burst (`gfx_pc.cpp`, `video.c`); `GE_D320R explode=/cheat=`.
+
+**Status: FIXED (M-201, user-verified 2026-09-26).**
+
## D255 — Facility firefight SIGSEGV on Steam Deck (v0.2.0-pre CI bundle; D253 fix confirmed working).
**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, activation-only" theory — both are plausible triggers of the same underlying destroy-path-ordering race (any code path that frees/nulls an active OBJ record's model mid-tick), not necessarily contradictory. **Not yet reconciled or verified by a session with hands on the repo** — next Linux/Deck session should test both triggers (activation AND destruction-by-gunfire) with `GE_D255=1` set before revising the "reliable repro" framing again. **Reconciled, same day, further real-Deck testing (v0.2.1 published release): destruction is the trigger, not activation — confirmed across four levels.** Facility: **every** terminal crashes consistently when destroyed (not merely activated). Caverns: terminals near the radio objective crash when they explode — same mechanism, different level, rules out a Facility-specific asset/state bug. Archives: a **similar** crash when a guard's grenade detonates — same destroy-via-explosion mechanism, no terminal involved at all, which narrows the real trigger to "an object dying via explosion damage" in general, not anything terminal-specific. **Negative control:** shooting an entire room of terminals on Frigate did not reproduce it — so it isn't simply "any destructible OBJ record exploding" either; something about the specific object/scene state still gates it. Release notes updated to match (`.github/release-notes.md`, D255 bullet: "consistently crash the game when destroyed"). **Static lead for next session (untested, from this session's code read):** `objFree`'s destroy path (`propobj.c:1017-1039`, entered via `RUNTIMEBITFLAG_REMOVE` from explosion damage, `sub_GAME_7F04424C`/`sub_GAME_7F0442DC`) never explicitly nulls `ObjectRecord.model` — `clear_model_obj()` only nulls `model->obj`. The NULL therefore can't come from that assignment; it must come from either (a) `chrpropFree`'s freed slot being reused by `chrpropAllocate` for a *different*, not-yet-`objInit`'d record before `vtxstore_fix_refs`'s later same-frame pass reaches it (a stale-pointer/reuse race, not a field write), or (b) the active-list unlink (`chrpropDelist`) happening while some other code is mid-iteration over the same list (a modify-during-iterate bug) — explosion damage plausibly spawns new effect props (sparks/debris/smoke) in the same tick that immediately reuse the just-freed slot, which would explain why it's tied to *explosions specifically* and not simple destruction. **Next, concrete:** with `GE_D255=1` set (probes already in tree from this session, ship in any build off `release/v0.3.0` including the local Linux package built this session), reproduce on Facility from the Deck itself (Desktop Mode, launch from a terminal, redirect stdout to a file) and send the log back — the probe already logs the dying record's identity plus every `objFree` call, which should show whether the crashing record's `prop`/`obj` pointer address was recently freed-and-reused this same session.
@@ -11785,14 +12333,32 @@ Adversarial pre-ship review of the whole v0.3.0 window (147 commits since v0.2.2
**Not yet looked at:** which code path renders muzzle flashes (likely a dynamically-built quad/billboard in `src/game`, similar family to the drop-shadow/flare-effect functions already surveyed during the unrelated D236 investigation — see that entry's pass 16 for the list of quad-building functions already read), whether this is a geometry/orientation bug (billboard not facing camera, or built in the wrong basis — "goes straight up" suggests a billboard axis or transform mixup) versus a render-mode/texture-stretch bug, and whether it's PC-port-specific or was already present pre-port (no N64 comparison done yet).
-**Repro clarified, same day (follow-up playtest):** the normal, correct muzzle flash still draws — this is an **extra**, erroneous long flash overlaid on top of it, not a corruption/replacement of the one correct effect. This significantly narrows the search: two draw calls are happening where one is expected, one correct and one wrong. Points more strongly at an extra/duplicate quad-builder call site or a second render-mode pass firing unconditionally alongside the correct one (e.g. a "flare arms"-style secondary effect, per the pass-16 D236 survey's `dorottex` muzzle-flash-flare-arms function, distinct from the main flash builder) than at a single corrupted transform. Still not investigated — no code read done yet against this sharper framing.
+**Repro clarified, same day (follow-up playtest):** the normal, correct muzzle flash still draws — this is an **extra**, erroneous long flash overlaid on top of it, not a corruption/replacement of the one correct effect. This significantly narrows the search: two draw calls are happening where one is expected, one correct and one wrong. Points more strongly at an extra/duplicate quad-builder call site or a second render-mode pass firing unconditionally alongside the correct one (e.g. a "flare arms"-style secondary effect, per the pass-16 D236 survey's `dorottex` muzzle-flash-flare-arms function, distinct from the main flash builder) than at a single corrupted transform.
-**Status:** OPEN — logged, repro description sharpened, no investigation started.
+**Investigation (2026-07-19):**
+
+- **Flash pipeline mapped.** `gunUpdateAndFire()` (`gunfire.c`): per shot it builds `flashmtx` = random yaw (bitflag 0x1) × scale `flashscale`∈[1.0,1.25] × `MuzzleFlashExtension` on the Z-row (`matrix_column_3_scalar_multiply(flashext, flashmtx.m[0])`), multiplies into `gunmtx`, copies to `rwmtx[1]`; a second matrix `rwmtx[2]` is built from `mdlhdr->Switches[2]` (the flash-anchor node) with the same scale/extension. The model-side flare arms are the `MODELNODE_OPCODE_DLPRIMARY` nodes rendered by `dorottex()` (`model.c`) — 4-vertex shuffling with per-frame random rotation/scale. **All byte-identical game code; the `matrixmath.c` helpers verified index-for-index.**
+- **Data ruled out (two layers).** (a) `MuzzleFlashExtension`: M16 = 6.0, the max in the set (sniperrifle also 6.0; others 1.0–4.0) — a legitimate per-weapon stat from the decompiled `gunWeaponStat.inc.c`, not a misread. All ext≥3 weapons happen to be `CAN_DUAL_WIELD` but so are many short-flash ones, so that's design coincidence, not a lead. (b) Model conversion: Gm16Z's `DisplayListPrimaryRecord` words + vertex blocks are byte-faithful through `d43_emit.py` (numVertices bswap, 16B vertex fields verbatim, GDL pointers remapped; round-trip validated per M-197).
+- **Live capture blocked headless.** Added a TEMP env-gated probe in `port/fast3d/gfx_pc.cpp` (`GE_D303=1`, cached-getenv pattern): logs every emitted triangle whose NDC bbox is very tall + thin (h>45% frame, aspect>3) reaching GL, with blend/oml/comb/prim/env/w. Ran `-level_09/-level_05/-level_17` unattended 75s each: **only static scene geometry** (identical across frames — pillars; Bunker1 is full of tall thin quads). Guards do not fire at an idle player, so the artifact cannot be reproduced without live input.
+- **Next step (playtest-gated):** user runs with `GE_D303=1`, fires the M16 until the artifact appears, captures the log lines around that moment. The probe prints the RDP state of every candidate quad — if the spurious flash is a distinct draw call its comb/oml/prim/w will differ from the correct one and point straight at the source (model node vs rwmtx slot vs fast3d pass). Fallback suspects if the log shows only ONE tall quad: fast3d handling of the flash texture's S/T wrap, or `rwmtx[1]/[2]` being consumed by the wrong model node.
+
+**Status:** OPEN — investigated (pipeline + data ruled out), GE_D303 capture probe in-tree, blocked on a live firing repro.
**M-191 (2026-09-18, user QA on the v0.3.0 pre-release build): REOPENING the "main fireball" thread — the fresh report matches exactly the condition M-188 said to watch for.** User describes smoke/explosion effects as now looking correct overall, EXCEPT a "weird rainbow confetti looking sprite" that still draws overlaid on top of otherwise-correct smoke/explosion particles, sometimes. M-188 explicitly closed only the "lingering particle residue" manifestation (which turned out to be `s_bullet_spark`/D219, unrelated code) and left this exact scenario — a rainbow artifact riding on the actual explosion/smoke effect itself — open, with the instruction "re-open only if a fireball (not residue/embers) rainbow report comes in again." This report fits that description, not the residue/embers one. **Not yet investigated further this session** — the last concrete lead (before M-188's tangent into D219) was: trace forward from `import_texture_rgba16`'s `upload_texture()` call (`gfx_pc.cpp:718`) through the GL texture object and the `G_CC_INTERFERENCE` (TEXEL0=smoke IA × TEXEL1=fire RGBA16) combiner, since the source ROM data itself was already proven correct by hand-decoding all 14 fire-stage textures (M-156, see above) — the bug is downstream of decode, in upload/cache/combiner. A live `GE_PCDUMP` + `GE_D252=1` capture during an actual reproduction (shoot something that smokes/explodes) is still the next concrete step, unchanged from M-156's own "next, concrete" recommendation.
**Status: REOPENED (was incorrectly treated as fully covered by the D219 closure) — OPEN, root cause still unconfirmed for this specific manifestation.**
+**M-201 (2026-09-26, v0.4.0): ROOT CAUSE FOUND + FIXED — the offline model converter copied only a quarter of every star muzzle flash's vertices.**
+
+- **Confirmed port bug:** user side-by-side with 1964: the N64 M16/AR33 shows a normal star/burst at the muzzle; PC draws a long flame shooting up. D220 (flash "rises at high FOV") merged here: FOV-independent.
+- **Headless repro:** new TEMP `GE_STARTWEAPON=13` (M16, `bondview_r.c`, adds 400 ammo) + `GE_INPUTSCRIPT` `ZHOLD` on Dam: every flash frame shows the upward spike (`scratch/d303_sheet.png`).
+- **Ruled out by dump:** gun model bindings (group 0 body mtx 0, group 1 muzzle mtx 1 = `Switches[3]`, group 2 billboard mtx 2 = `Switches[2]`) and both flash matrices (`rw1` Z-stretched ×6 = `MuzzleFlashExtension`, as PD's `muzzlez`) are as designed; every matrix helper matches PD (`mtx00015ea8` = column_3, `mtx00016710` = row_3, `mtx00016e98` = axis-angle).
+- **Cause:** op-22 `DisplayListPrimaryRecord` (PD `modelrodata_stargunfire`) stores the number of flare **arms**; `dorottex()` (`model.c`) and PD's `modelRenderNodeStarGunfire` read `count*4` vertices (`src += 4` per arm). `tools_pc/d43_emit.py` (and its `d43_convert.py` twin) sized the vertex region as `16*nv`, copying only `nv` vertices. The M16 star (6 arms, 24 vertices) kept vertices 0–5; 6–23 came from whatever followed in the PC sidecar (dump: `src[6]=(4,0,0) st=(-28648,28709)`, `src[7].x=-28960`), rendering as long spikes on top of the real arms.
+- **Fix:** size op-22 vertex regions as `16*4*nv` in both tools. `d43_emit.py ntsc-final` re-run: ALL CHECKS PASSED (round-trip field validation, region tiling, buffer bounds); 512 sidecars regenerated. After: `src[6..7]=(289,±20)` real arm vertices; the M16 renders a clean 6-arm star (`scratch/d303_after.png`). Affects every star-flash weapon. Golden gate 3/3.
+- **Note:** `data/pcmodels-*` is generated (gitignored). Existing installs must re-run `python3 tools_pc/d43_emit.py `; bundles run the shipped emitter.
+
+**Status: FIXED (M-201, user-verified 2026-09-27).**
+
## D304 — Mouse sensitivity menu overhaul (user request, 2026-09-18): aim speed/turn speed sliders weren't "1:1" and had too little range.
**User request, immediately after cutting the v0.3.0 Deck pre-release build:** review the mouse sensitivity configs against "the new logic" (D194/D300's GEPD-direct-write input model) and overhaul the F10 menu — specifically, "aim speed/turn speed are not 1:1 in the menu" and players need "more range for mouse sensitivity/settings," referencing the fact that GE's whole aim/hipfire model was ported from the GEPD Mouse Injector (`reference/mouse-injector/`, our D194 mirror), which itself mirrors Perfect Dark's own input scheme.
@@ -11891,19 +12457,67 @@ do {
**Reported only, not investigated, explicitly not for v0.3.0.** User playtest: the truck's wheels on Dam (seen during the level intro) render oddly, showing what look like transparent areas where they shouldn't be — user's own suspicion is z-fighting. No code read done. May be an instance of the broader z-fighting issue logged separately as D308, or a distinct texture/alpha bug specific to this prop — not distinguished yet.
-**Status:** OPEN — logged, no investigation started. Deferred past v0.3.0 by user instruction.
+**Investigation (2026-07-19, with D308):** see §D308's investigation block — the two were worked together. The `GE_ZF` flicker probe confirms **real, persistent depth-fighting in the Dam intro's lower-left screen region** (the truck area) — cells there accrue oscillation counts to ~1000 over a 40 s unattended run, i.e. near-equal depths genuinely alternating frame-to-frame, not tessellation noise. The "transparent-looking areas" are consistent with the wheel geometry losing the depth test against coincident surfaces in alternating frames.
+
+**Status:** OPEN — confirmed as real z-fighting by probe; port-vs-faithful question open (see D308).
+
+**2026-09-26 (v0.4.0, M-201): D291 is this same bug; FOV ruled out.** User captured the D291 "Dam truck" glitch with F12: it happens only on the Dam level intro (also the attract demo), in the shot where the truck drives over the low walkway camera (~frames 280–440 from `-level_33` boot). Wheels and tyre treads draw through the mudguards/body. It reproduces at **FovScale 100** (user re-test, `scratch/d291_shots/fov100_full.png`) as well as at 150 + native widescreen (`shot_000..003.png`), so the port's wider cutscene FOV is not what exposes it. Headless A/B at default vs user settings: `scratch/d291_AB.png` (runs not frame-locked; `GE_ZF` hit its 600-line cap in both). Occlusion-state path checked in passing: fast3d honours `G_ZBUFFER`/`G_ZS_PRIM`/`Z_CMP`/`Z_UPD` per triangle (`gfx_pc.cpp` depth_mode), so no obvious state gap. **Still gated on an N64/emulator reference of this exact shot** (porting-notes D12): if the N64 shows clean wheels there, the fight is PC-specific and the next suspect is the vehicle model's per-part transforms at extreme close range; if it shows the same, close as faithful.
+
+**2026-09-26 (M-201, user: "investigate the PC-specific leads"): every PC-specific lead refuted; the artifact is the authored truck geometry seen from under the truck.**
+
+- **Repro pinned:** Dam has 6 intro cameras and picks the first at random (`bondview_r.c`, `randomGetNext() % g_SetupIntroCameraCount`); the truck-over-walkway shot is **index 5**. New TEMP `GE_INTROCAM=5` forces it (PRNG draw still consumed). `GE_RSEED` does not reliably select it. With it pinned the truck passes at ~frames 280–320 (60 fps), and frame 280 at **default settings** (640x480/1280x720, FovScale 100) shows the same tread-slab look as the user's captures.
+- **FOV:** refuted (user re-test at FovScale 100).
+- **Depth precision / DrawDistance:** refuted. DrawDistance does move the far plane (`bgfog.c` → `viSetZRange`), but perspective depth resolution near the camera is set by the near plane; at the truck's range (w ≈ 280–790) 24-bit depth resolves far below the tyre↔mudguard separation, so these non-coplanar surfaces cannot precision-fight at any setting.
+- **Depth state:** refuted. TEMP `GE_D306C="f0 f1 [wmax]"` census (per emitted triangle: GL depth mode, geometry_mode, other_mode_l, tile-0 texture, NDC, z/w, min w): every truck triangle is `dm=07` (test+compare+update, ZMODE_OPA, `G_ZBUFFER` on). The only no-compare near geometry (`dm=01`, `oml=c8102048`) is the walkway/ground under the camera, and 2D text is `dm=00`.
+- **Near plane:** refuted. Truck triangles have w ≥ 278, no eye-plane crossings; only the ground under the camera crosses w=0, and GL clips it in homogeneous space.
+- **Wheel transforms:** refuted. TEMP wheel-state log (removed): `wheelyrot` = 0 through the straight run, `wheelxrot` advances normally, radius/scale constant; the one ~20° steering blip (t≈530, `turnrot60`=0.0026) is the original formula's response and is outside the overhead pass. The wheel code uses named fields only (no A1 raw-offset reads).
+- **Conclusion:** the rendered image is a depth-correct depiction of the geometry (correct depth state, sane z/w, sane transforms, ROM model data). The camera sits under the truck, where the model's wheel/mudguard construction (tyres passing through the arches, open-backed panels) is visible; it is not seen from normal gameplay angles. **Faithful with high confidence**; an N64/emulator frame of intro camera 5 would make it certain.
+- **Probe-quality correction:** `GE_ZF` quantises NDC z to ±255 (`z24 = ndc_z*255`, not 24-bit as its comment says) with tolerance 2, so it flags unrelated nearby surfaces as "oscillating". The 2026-09-18 "confirmed real depth-fighting" was over-read and should not be cited as evidence of a fight.
+
+**N64 reference (user, 2026-09-26):** the same shot on the 1964 emulator (GEPD bundle) shows the same wheel/body fighting as the port. Confirmed faithful.
+
+**Status: CLOSED — faithful, confirmed against 1964 (authored geometry; no PC-specific cause). D291 closes with it.**
## D307 — In-level security camera entities can be flipped facing the wrong direction (backwards) on some levels, e.g. Bunker (user QA, 2026-09-18).
**Reported only, not investigated, explicitly not for v0.3.0.** User playtest: on some levels (Bunker named specifically), the in-level **security camera props/entities** — the gameplay objects that spot the player and alert guards, not the player's own view camera — can end up facing backwards (the wrong direction) "for some reason." No further repro detail given (trigger condition, whether it self-corrects, whether it's tied to a specific spawn/setup path). No code read done. **Correction (same day): initially misread as the player's view camera — clarified by the user to mean the in-level security camera entities specifically.**
-**Status:** OPEN — logged, no investigation started. Deferred past v0.3.0 by user instruction.
+**Investigation (v0.4.0 prep, 2026-09-21):** the full orientation path was traced and every input verified N64-faithful; **no port-side divergence found**.
+
+- Orientation is computed once at load in `setupCctv()` (`src/game/prop.c`): watchpoint = `obj->mtx · Switches[0]->Data + prop->pos`; look basis = `set_basis_and_position_target(watch − padpos, up=(0,1,0))` with row 0 scaled by model scale (0.1); yaw `unkC4 = atan2f(tx, tz)`; initial turret angle `unkCC` from record config word (N64 w51). Per-frame render: `mtxs[1] = RotY(unkC8) × unk84` (`propobj.c` ~5964). All matrix/atan2 helpers are byte-identical `src/game` code (`matrixmath.c`, `math_atan2f.c`) — none are port forks.
+- Data verified against the ROM for Bunker1 (`UsetupsevbunkerZ`, level_09, 4 CCTV records): model switch offset `(0, 70.3664, 31.9837)` matches `PcctvZ` (NOT `GcameraZ` — that's the gun; PitemZ idx 24 = `assets/obseg/prop/cctv`) node sw[0] bit-for-bit; boundpad positions match the pccg sidecar exactly once the legitimate load-time room-scale is applied (`pad->pos *= get_room_data_float2()`, 1/level-scale ≈ 1.8542 for Bunker1 — initially looked like a mismatch because raw file values are pre-scale); `unkCC` raw angles match record config words; prop placement derives from pad + bbox via the standard `moveToPad` path.
+- An env-gated diagnostic (`GE_D307=1`, TEMP, load-time only) dumps every input and derived value per CCTV record for offline ROM comparison — kept in-tree like D60/D88.
+
+**Conclusion so far:** given byte-identical code + verified-identical inputs, the PC orientation values are what the N64 computes. If the cameras genuinely differ from N64 hardware, the remaining suspect is fast3d's handling of the non-orthonormal lens matrix (`unk84` row 0 scaled 0.1×) in the per-frame render path — but that is unproven. **Blocked on user repro detail:** which camera/level, a screenshot, and ideally an N64 comparison (the symptom may be faithful N64 level-design behavior).
+
+**Status:** OPEN (investigated — no port-side bug found; awaiting repro detail).
+
+**M-201 (2026-09-26, v0.4.0): ROOT CAUSE FOUND + FIXED — a renamed duplicate struct member re-pointed `setupCctv`'s look-at pad to the mount pad. User-verified.**
+
+- **Confirmed port bug first:** user side-by-side with 1964: every Bunker 1 camera faces backwards on PC, correct on N64.
+- **Measured (TEMP render dump, since removed):** the CCTV model is fine (group `mtx 0` = base, group at the switch origin (0,70.4,32.0) = `mtx 1` = lens). The lens matrix's forward axis pointed back into the mount: camera 48's base maps model +Z to world +X, its lens matrix mapped +Z to about (-0.80,-0.60,0).
+- **Cause:** `CCTVRecord` inherits `ObjectRecord` (s16 `pad` @0x08 = mount pad) and declares its own s32 `pad` @0x80 = the **look-at** pad. IDO accepts the duplicate and `arg1->pad` in `setupCctv` means the CCTV's own look-at pad. GCC rejects duplicate members, so the PC struct renamed it `lookpad`, and a comment asserted game code "never" means this member. So on PC `arg1->pad` silently became the mount pad: the look vector was just the lens's offset from its own mount (`target` ≈ (9.4, 7.0, 0.9)) and `matrix_4x4_set_basis_and_position` (Z = -look) aimed every lens back at its wall. The load dump showed it: `pad=10018 bound=1` (mount) while the record's look-at pad is 0x58 = 88.
+- **Cross-check:** Perfect Dark's same function (`pd_port/src/game/setup.c:795`, `setupCreateCctv`) uses `cctv->lookatpadnum`, the CCTV's own field.
+- **Fix (`src/game/prop.c`, `#ifdef PORT`, A1 class: read the correctly named field):** `CCTV_LOOKPAD` = `arg1->lookpad` on PC, `arg1->pad` on N64, for the 4 look-pad reads in `setupCctv`. `bondtypes.h`'s misleading comment corrected. After the fix the look vectors run from the floor look-at pads (y ≈ 185) to the lenses (~700 units, ~240 up), and the lens forward axis points at the watched spot.
+- **Verified:** user playtest ("that fixed it"); golden gate 3/3. Only `CCTVRecord` had this rename (grep for the duplicate-member note).
+
+**Follow-up (2026-09-27): reaction speed.** User: cameras now point correctly, but may be slow to react. By code this is faithful and tick-based: the alarm fires after `CCTV_ALARM_FRAMES` (NTSC 300) x `F_80030B14` ticks of continuous sighting (Agent 2.0 -> ~10 s, Secret/00 1.0 -> ~5 s), accumulated with `g_ClockTimer`; the sweep integrates `g_GlobalTimerDelta`. No frame-rate dependence. Optional check: time spot-to-alarm on the same camera and difficulty on 1964.
+
+**Status: FIXED (M-201, user-verified 2026-09-26).**
## D308 — Occasional z-fighting confirmed on some levels' geometry, general/broader than the D306 truck-wheel instance (user QA, 2026-09-18).
**Reported/confirmed by the user, not investigated, explicitly not for v0.3.0.** User: "Confirmed occasional z-fighting in some levels due to the level geometry and current state of code" — framed as a general, known-class issue (overlapping/coincident geometry at the current renderer's precision) rather than a single specific bug site. D306 (Dam truck wheels) may be one visible instance of this broader class, not confirmed as the same mechanism.
-**Status:** OPEN — logged, no investigation started. Deferred past v0.3.0 by user instruction.
+**Investigation (2026-07-19, with D306):**
+
+- **Depth pipeline precision compared against N64 — equivalent.** GE's RSP (`rsp/graphics/gmain.s` vertex transform) writes post-transform x/y/z/w back to the vertex buffer as **full 32-bit 16.16 fixed-point** (`sdv` stores at +0/+8/+18), NOT s16 8.8 — so the RDP receives full-precision NDC on N64. The port's `gfx_sp_vertex` keeps float (≥ as precise). Both sides write into a **24-bit integer depth buffer** (port: `GL_DEPTH24_STENCIL8`; N64 RDP: 24-bit z-image) with the same near/far from the game's projection matrix. Net: the PC has *no less* depth precision than the N64 — coincident-geometry fighting that exists in the level data fights on both platforms.
+- **Probe.** TEMP env-gated `GE_ZF=1` flicker detector in `port/fast3d/gfx_pc.cpp`: a 96×54 screen-cell grid remembers recent per-cell 24-bit depth values; a hit requires non-monotonic oscillation (a-b-a within `GE_ZFTOL`, default 2 units) that **recurs ≥5 times at the same cell** — filters out tessellation gradients and one-off camera-motion coincidences. Optional region filter `GE_ZFBOX="x0 y0 x1 y1"`.
+- **Result (Dam, `-level_33`, 40 s unattended, intro plays automatically):** sustained hits in the lower-left screen region (ndc ≈ x∈[-0.1,+0.1], y≈-0.57 — the truck) with per-cell oscillation counts reaching ~970: genuine near-equal depths alternating across hundreds of frames.
+- **Open question — port bug or faithful?** Given equivalent precision on both sides, the most likely explanation is **level-authored coincident geometry** (the user's own framing: "due to the level geometry") that fights on N64 too. Proving/disproving requires an N64 reference of the same spot (footage or side-by-side), which is not available in this environment. Secondary suspects if it turns out PC-only: exact `glDepthFunc`/z-range edge cases, or MSAA depth behavior — none evidenced so far.
+- **Fix posture if confirmed PC-only:** no game-code lever exists (depth values are the game's); a port-side mitigation would be a precision change (e.g. 32-bit float depth) which would *reduce* fighting but diverge from N64 behavior — needs a rule-2-style decision, not a silent fix.
+
+**Status:** OPEN — real z-fighting confirmed by probe; port-vs-faithful blocked on an N64 reference of the same spot.
## D309 — Complete non-self-recovering freeze (Windows) returning from Caverns to the intro screen — boss thread spins forever in `ai()` on `m_RunToBondPersistent` via a phantom `GotoNext(lblZero)` (user report + live GDB diagnosis, 2026-09-18).
@@ -11983,7 +12597,13 @@ This confirms the M-160/M-164/M-165 hypothesis (`## D173`'s own consolidated-nex
**Open question (deeper root cause, NOT fixed):** what wrote `-1.75e18` into `oldspeed` / poisoned `animframe1` in the first place? `oldspeed = model->speed` at transition start (`model.c:2975`) means `speed` was already garbage upstream; `timespeed = 0` and `unkb0 = 0` at the guarded tick are suspicious division denominators (D156's original note flagged `/ unkb0` and `/ timespeed` as guarded `> 0` but not against tiny values). Also, the model's persistent root-motion header (`header->unk34/unk24`) is poisoned to ~1e30–1e32 in this instance, so **Bond's rendered model may be displaced even after the freeze is prevented** — a level reload clears it. Needs its own investigation (dump speed-field write sites during the triggering transition) before any further fix.
-**Status:** GUARDED (freeze class neutralized; upstream poison source OPEN). **RULE-2-SIGNOFF (2026-09-19, release review — M-192 item 2):** user approved retention as the stability-maximizing option; classified as a defensive guard against non-finite state in the same class as the accepted D156 guards — it fires only on poison values that indicate an already-broken state and snaps to a valid frame rather than altering any legitimate animation. Previously disclosed in-thread as a D156-guard extension following the already-accepted D156 precedent in this same file; revert is still two small blocks. Cross-refs: D156 (original guard + Facility outro hang), D309/D310 (ruled out for this freeze — no AI involvement).
+**M-194 (v0.4.0 Phase 1, 2026-09-21): upstream poison source — static pass + choke-point instrumentation.** Every write of `model->speed` funnels through exactly two functions: `modelSetAnimSpeed` (startframe≤0 path) and `modelSetAnimation2`. Both now log a `D311: … ANOMALY` line when the incoming value OR the already-resident `model->speed` is non-finite or |v|>1e4 (`#ifdef PORT`, unconditional-on-fire like the D156 guards — silent in healthy play). The line captures in/resident/startframe/oldspeed/newspeed/timespeed + model pointer, so the next occurrence pins the exact injecting call. Static pass over the computed-speed callers: `sub_GAME_7F06FE90` (the `t = speed + 2·φ/arg2` divider) is only ever called with arg2=16.0f — its denominator is safe, though a garbage `animframe1` would poison its numerator; `bheadAdjustAnimation` divides by the *static* `g_BondMoveAnimationSetup[].speedMultiplier` table (bondhead.c:19) — safe constants, but its `speed` parameter is the player's live movement speed, so an upstream velocity-field corruption would flow straight into `model->speed`; the old→new lerp at model.c:3654/3663 only circulates already-poised values (no new garbage). Live repro (Caverns, ~2 min in) remains the trigger; the probe turns that occurrence into a one-line diagnosis.
+
+**Status:** GUARDED (freeze class neutralized; upstream poison source OPEN — M-194 probe armed). **RULE-2-SIGNOFF (2026-09-19, release review — M-192 item 2):** user approved retention as the stability-maximizing option; classified as a defensive guard against non-finite state in the same class as the accepted D156 guards — it fires only on poison values that indicate an already-broken state and snaps to a valid frame rather than altering any legitimate animation. Previously disclosed in-thread as a D156-guard extension following the already-accepted D156 precedent in this same file; revert is still two small blocks. Cross-refs: D156 (original guard + Facility outro hang), D309/D310 (ruled out for this freeze — no AI involvement).
+
+**2026-09-24 — likely root cause found in D336.** D311's own static pass named `bheadAdjustAnimation` as the one path where a corrupted value reaches `model->speed`. D336 showed that path computing Bond's head-bob start frame from a never-written alias field (`field_5C0` = N64 `model.animframe1`), i.e. uninitialised player-struct bytes, whose garbage depends on memory layout (room-pool size, previous level). That matches D311's intermittent `oldspeed=-1.75e18`-at-transition signature. After the D336 fix: headless Caverns (`-level_39`, user settings incl. the 2× room pool `ma=512000`, scripted movement/turning, ~80 s) gave **0 D156 guards, 0 D311 anomaly lines, no crash**. **Close after one live Caverns combat session (~2 min) stays D156/D311-silent.**
+
+**2026-09-25 playtest (user, Windows, one ~46-min session Silo → Caverns, `GE_D322=1 GE_D204=1`, build `7baf4900`):** Caverns combat ~2 min, plus Silo onwards: zero `D311: … ANOMALY` lines (the probe logs via `osSyncPrintf` into the same log, and D318W lines from the same path prove it is live). Combined with D336 (upstream poison source) this closes D311. Guard stays (Rule-2 sign-off 2026-09-19).
## D312 — Throwable items (mines, grenades, etc.) can clip through walls in certain spots (user report, v0.3.0 pre-release playtest, 2026-09-18) — FIXED: `bgBuildRoomVtxBounds` mis-reads the G_VTX vertex count on PC (Gfx_le `dma.par` bit-layout shift), collapsing whole batches of wall geometry out of the collision query (diagnosed + fixed, 2026-09-18).
@@ -12037,6 +12657,20 @@ This confirms the M-160/M-164/M-165 hypothesis (`## D173`'s own consolidated-nex
**Status:** OPEN — deprioritized by user request (2026-09-18); probe landed, repro run not currently planned.
+**2026-09-25 (v0.4.0, M-200 session): threading audit, one race fixed, headless repro clean; plausibly fixed via D287; owed a live check.**
+- **Thread map (verified in source):** `optionsOverlayEmit` runs inside `gfx_run`, called from `osSpTaskStartGo` in `__scMain` (scheduler thread). `optionsOverlayHandleInput` runs from `joyPoll` → `osContGetReadData` → `inputComputePad`, *also* synchronously inside `__scMain` (`sched.c:308/321`; see the `osRecvMesg` note in `libultra.c`). So Emit and HandleInput never race. The host thread (SDL event pump in `videoPumpEvents`) calls `optionsOverlayToggle` (F10) and `optionsOverlayScroll` (wheel).
+- **Race fixed:** `optionsOverlayScroll` ran `overlayUpdateVisible()` (`s_visN = 0`, then refill in place) and wrote `s_sel` on the host thread. That can race Emit's draw loop on the scheduler thread, which could draw a truncated row list for a frame or clamp the selection against a half-built `s_visN`. It now only does `SDL_AtomicAdd` of the notches; `overlayApplyWheel()` in HandleInput applies them on the scheduler thread (the D287 pattern). Behaviour is unchanged (one row per notch, clamped, headers skipped). This only mattered while the wheel was moving.
+- **Pre-D287 cross-thread SDL calls on this exact path:** HandleInput calls `inputSuspendForOverlay()` on every poll while open. Before `d07ed0f2` (D287, 2026-09-25) that called `SDL_SetRelativeMouseMode`/`SDL_ShowCursor` directly from the scheduler thread while the host thread made its own cursor/focus calls. That's the D287 class, and it fits a "flicker" that was seen only with the overlay open. The D314 report (2026-09-18) predates D287, so D287 may already have fixed it.
+- **Headless repro, clean:** `GE_STARTMENU=7` (mission select) and `GE_STARTMENU=5` (file select) with `GE_OPTIONSOVERLAY=1`, `GE_PCDUMP="200-800:1"` at 640x480: 600 consecutive frames each, **0 frames** with a text-level (>120 summed RGB delta, >30 px) change against the previous frame (`scratch/d314diff.py`). The post-fix file-select frame 500 is pixel-identical to pre-fix. So without input the overlay draws deterministically; any real flicker needs input (mouse/cursor) or the in-game→file-select transition.
+- **User check (next playtest):** open F10 on file-select and mission-select and move the mouse, click rows, wheel-scroll; also open it in-game, then abort to file-select with it still open. If there's still flicker, note whether it's the overlay rows or the game's menu behind it, and whether it happens with the mouse still.
+
+**Status:** OPEN, likely fixed (D287 + wheel race), pending the user check above.
+
+
+**2026-09-26 (v0.4.0, M-201): user-confirmed.** F10 on file-select/mission-select with mouse move/click/wheel, and in-level → abort with F10 open: no flicker. Closed by the M-200 wheel-queue fix plus D287 (cursor/relative-mode calls moved to the window thread).
+
+**Status: FIXED (user playtest, 2026-09-26).**
+
## D315 — Menu scaling differs between main menu and in-game — main menu scales OK, in-game menu "seems kinda big sometimes" (user report, v0.3.0 playtest build, 2026-09-18).
**Reported:** "Menu scaling can change between main menu / game — main menu scales OK, game seems kinda big sometimes." The "sometimes" is the key word: it tracks some state (video mode? window size? level?) rather than being constant.
@@ -12081,6 +12715,8 @@ Same fix as D316 resolves this (no separate code change). Verification: toggle `
## D318 — Facility (regular play path): after Objective C (destroying the bottling tanks) completes, Ourumov freezes and does not shoot Trevelyan as he should (user report, 2026-09-18).
+**2026-09-23 correction (D329):** the pin is a tick-granularity divergence, not a platform-invariant lock. It persists only at 1 sim tick per rendered frame (the port at 60 fps); a single frame of 2 or more ticks (GE's normal N64 regime) breaks it. Lockstep A/B (GE_D318B_CLK) on ai_19 and ai_9: clk=1 pins every time, clk=2 never confirms. Earlier WSL wall-clock pin rates are uncalibrated (mixed clk 1-6). See D329.
+
**Reported (user, 2026-09-18):** on Facility (`-level_34`), following the **regular play path**, Ourumov **freezes up and will not shoot Trevelyan when Objective C — destroying the bottling tanks — is completed**. The user states this is **definitely a bug** despite prior agent conclusions to the contrary.
**Contradicts D263's resolution.** D263 (v0.2.1) closed the same surface symptom as "not a port bug — proximity/LOS-triggered exactly as on N64", verified via a *teleport probe* that dropped Bond 65 units from Trevelyan and confirmed the full chain incl. the execution shot firing. But that verification was **synthetic** (teleport, not the authored play path), and D263's own refinement already noted the trigger is "significantly sensitive to Bond's precise location" and "not confirmed byte-identical to N64 for every approach". This report reaches the beat via the **authored objective sequence** (Objective C = bottling tanks destroyed) and sees Ourumov frozen with no shot — a materially different entry point than D263 tested. **Do not re-assert the "not a bug / finicky trigger" conclusion from D263 without fresh evidence from this specific path.**
@@ -12109,7 +12745,9 @@ Ourumov = chr 78 on list 0x0417 (ai_22). Key offsets: off=38 pre-aim at Trevelya
### Classification
-**As-authored latent race, not a port bug.** Every link — ai_22 flow, the update command's missing re-init, the `framea==frameb` pin, the ACT_ATTACK-gated stop loop — is byte-matched ROM logic; the N64 has the identical race and the identical unrecoverable softlock. The trigger (gas bit landing during the monologue while c78 is in pre-aim hold) is organic level state on both platforms.
+**As-authored latent race, not a port bug.** Every link — ai_22 flow, the update command's missing re-init, the `framea==frameb` pin, the ACT_ATTACK-gated stop loop — is byte-matched ROM logic.
+
+**N64 manifestation — CORRECTION (2026-09-22, user challenge).** The earlier claim "the N64 has the identical race and the identical unrecoverable softlock" was *inference* from byte-matched logic, never observation — we cannot instrument an N64. The user's empirical prior (and ~30 years of community play with no such known bug) is that guards do **not** freeze on real hardware. For that to hold, the derail must effectively never occur on N64 — i.e., the gas bit never lands inside c78's pre-aim hold window there. But that window is player-timing-gated (tanks destroyed during the hold) and tick counts are platform-invariant, so if the port were fully tick-faithful upstream of this beat, an organic PC freeze should reproduce on N64 with the same play. Two live hypotheses: **(i) an upstream port divergence** (timing shim / scheduler phase / earlier state misread) opens a derail window on PC that never opens on N64 — in which case the freeze is a *port-exposed* artifact and the real fix is finding that divergence; **(ii)** it can occur on N64 but has simply never been observed (narrow window). **Discriminating test (revised 2026-09-22.)** Primary — **deliberate timing on an unthrottled emulator**; the derail window is *player-controllable*, so no instrumentation is needed. Per D321 the combat bit lands 212 ticks (~3.5 s) after the last tank destruction, and c78's vulnerable span runs from pre-aim (off=38) through the monologue loop (off=75, check re-evaluated continuously; ~22 s). So: on an **unthrottled emulator** (stable 60 Hz tick regime — the same regime as the port; Mupen64Plus-Next/Project64 at full speed) with save states, trigger the Ourumov scene, then destroy the last tank ~1–15 s into the monologue; repeat with fresh loads to sample the aim-variant lottery. **Freezes ≈ the port's forced-repro rate (~2/3)** → the race is reachable under stable ticks; "N64 never freezes" is an organic-timing/frame-drop story (hypothesis ii; watchdog = insurance, no port bug). **Never freezes across ~10 deliberate placements** → the mechanism differs environmentally (hypothesis i) and there is a genuine upstream port divergence to hunt. Control first: confirm the *port* freezes at the expected rate under the same deliberate timing (forced-repro probe can calibrate this headlessly). Secondary — **PRNG-stream A/B** (only if the emulator never freezes and hypothesis (i) needs hunting): the port already ships `GE_RSEED_LV` (`port/src/d318watchdog.c`) + DET seed logging — read `g_randomSeed` from N64 RAM `0x80024460` at a canonical moment (emulator memory viewer; that read is the only console-side step), inject it with `GE_RSEED_LV=@`, replay the same play, and compare DET lines: any divergence pinpoints the tick where the port's stream leaves the original's. The watchdog remains justified either way as a port-side liveness measure: it acts only in a state where the game otherwise hard-locks.
### Fix (port-side, `port/src/d318watchdog.c` — same pattern as D202 M-66b)
@@ -12146,7 +12784,9 @@ Verified: build clean (MSYS2 MINGW64/ninja); `tools_pc/verify.sh bunker1` — PA
## D320 — D318-class sweep: aim-hold → `_update` AI lists in 8 levels carry the same as-authored softlock race (static analysis, 2026-09-20).
-**Context.** D318 (Facility ai_22) root-caused a permanent softlock to a *generic* game-code mechanism, not to anything Facility-specific: `actor_fire_or_aim_at_target_update` (`chraction.c:4882`) succeeds whenever the chr is in ACT_ATTACK with `attacktype & (TARGET_AIM_ONLY | TARGET_DONTTURN)`, retargets, and calls `chrlvAttackActionRelated` — which only changes the model's **endframe** and **never calls `modelSetAnimation`**. If the chr is pinned on a non-looping aim-hold pose whose endframe H sits *before* the variant's shoot window (`shoot_start_frame`), and the new endframe E lands above H, nothing ever re-inits the animation (the `chrlvTickAttackCommon` "endframe <= frame" block's re-init sub-branches are all gated out: `unk31==0`, `unk33 != unk34`) → the model stays pinned below the shoot window → the timer/frame-gated fire events (`shoot_start <= frame < shoot_end`) never open → the chr never fires. If the surrounding AI script's only escape is an `if_guard_has_stopped_moving` gate, it can never pass: `chrHasStoppedOrPatroling` (`chraction.c:4291`) returns FALSE for ACT_ATTACK. **Permanent softlock.** Every link is byte-matched ROM logic; the N64 has the identical race (the random aim-variant pick in `chrlvInitActAttack`, `randomGetNext() % len`, differs per run on both platforms).
+**2026-09-23 correction (D329):** the pin is a tick-granularity divergence, not a platform-invariant lock. It persists only at 1 sim tick per rendered frame (the port at 60 fps); a single frame of 2 or more ticks (GE's normal N64 regime) breaks it. Lockstep A/B (GE_D318B_CLK) on ai_19 and ai_9: clk=1 pins every time, clk=2 never confirms. Earlier WSL wall-clock pin rates are uncalibrated (mixed clk 1-6). See D329.
+
+**Context.** D318 (Facility ai_22) root-caused a permanent softlock to a *generic* game-code mechanism, not to anything Facility-specific: `actor_fire_or_aim_at_target_update` (`chraction.c:4882`) succeeds whenever the chr is in ACT_ATTACK with `attacktype & (TARGET_AIM_ONLY | TARGET_DONTTURN)`, retargets, and calls `chrlvAttackActionRelated` — which only changes the model's **endframe** and **never calls `modelSetAnimation`**. If the chr is pinned on a non-looping aim-hold pose whose endframe H sits *before* the variant's shoot window (`shoot_start_frame`), and the new endframe E lands above H, nothing ever re-inits the animation (the `chrlvTickAttackCommon` "endframe <= frame" block's re-init sub-branches are all gated out: `unk31==0`, `unk33 != unk34`) → the model stays pinned below the shoot window → the timer/frame-gated fire events (`shoot_start <= frame < shoot_end`) never open → the chr never fires. If the surrounding AI script's only escape is an `if_guard_has_stopped_moving` gate, it can never pass: `chrHasStoppedOrPatroling` (`chraction.c:4291`) returns FALSE for ACT_ATTACK. **Permanent softlock.** Every link is byte-matched ROM logic. (N64 manifestation unverified — see D318's 2026-09-22 correction: the user's empirical prior is that no such N64 freeze exists; the same upstream-divergence-vs-unobserved question applies to every list in this sweep.)
**Sweep.** All 21 levels' AI script sources (`assets/obseg/setup/*.c`) scanned for lists containing both an aim command (`guard_try_fire_or_aim_at_target[_kneel]`, BITFIELD byte with `TARGET_AIM_ONLY` 0x20 set — the hold pose) and a `guard_try_fire_or_aim_at_target_update` reachable while the hold is active. 17 lists in 8 levels match; control-flow checked per list:
@@ -12168,18 +12808,27 @@ Verified: build clean (MSYS2 MINGW64/ninja); `tools_pc/verify.sh bunker1` — PA
**Why the Bond-combat loops are probably not affected.** In normal sustained fire the aim-hold endframe sits *at/inside* the shoot window (the hold pose is the recoil pose; shots fire from the `attack_time`/`unk44` timer machinery in `chrlvTickAttackCommon`, not from frame progression past endframe), so a `_update` 0x21→0x01 keeps the model pinned *inside* the firing window and the guard keeps shooting — that is how continuous fire works by design. The D318 danger requires the hold pose to be a distinct pre-shoot pose (H < shoot_start) — which is what "aim at X but don't fire yet" scripts produce. These combat lists are also exercised on every playtest of every level with no observed freezes, which is strong empirical evidence. This distinction is inferred from code reading + playtest history, **not** probe-verified per list.
-**Classification.** As-authored latent race(s), faithfully reproduced — NOT a port bug (same classification as D318). No `src/game` fix is warranted or permitted absent Rule-2 sign-off; the N64 carries the identical risk on the identical random-variant branch.
+**Classification.** As-authored latent race(s) in the byte-matched state machine; PC manifestation now confirmed by forced repro (below). N64 manifestation unverified — inherits D318's 2026-09-22 correction (upstream-divergence-vs-unobserved; A/B against an N64 capture is the discriminating test). No `src/game` fix is warranted or permitted absent Rule-2 sign-off.
-**Verification path (owed).** The pin branch depends on the per-variant animation-table fields (H vs E), which are data, not code — static analysis cannot price the risk per list. Use the D318T-style timeline probe (`port/src/d318watchdog.c` `d318TimelineTick`, generalized to log any chr in ACT_ATTACK with an AIM_ONLY attacktype whose `modelGetAnimFrame` is byte-stable across ticks while `aioffset` sits in a stop-check loop) during playtests of: Facility (the ai_19 beat — the guard who monologues at the captive scientists before shooting), Control (the ai_9 beat), Depot (the ai_12 execution beat). A frozen run shows the exact D318 signature: stable `animframe1`, zero `modelSetAnimation` calls post-`_update`.
+**Forced-repro results (2026-09-22, headless).** Method: probe `port/src/d320repro.c` (env-gated `GE_D320R`, TEMP) hijacks a named guard's AI list + offset at chosen ticks in a bare `-level_XX` boot. **Detector fix first:** the D318T timeline scanned only `g_ActiveChrs`, which holds *background script entities only* (chrnum 0xFE, allocated from AI lists with ID ≥ 0x1000 in `deb_loadallmodels.c`) — it was blind to every real guard. Real guards live in `g_ChrSlots[0..g_NumChrSlots)`; the detector now builds a unified pointer array over both (both are ticked by `chrlvAllChrTick`, chraction.c:9539).
+- **ai_19 (Facility): PIN CONFIRMED.** c5 pinned at off=483 (label 0x34 loop), act=8, atk=0x8, ent=109 (pad 0x6d), frame stable 30.00/23.00 for thousands of ticks.
+- **ai_9 (Control): PIN CONFIRMED, variant-dependent** (~2/3 of seed variants pin; the rest escape — the same random-variant structure as D318). Pinned at off=69 (label 0x0b loop), act=8, atk=0x04, ent=252, frame stable 23.00.
+- **ai_12 (Depot): CLEAN 3/3.** The scripted `chr_hit_body_part_with_item_damage` kill progresses the beat to off=267+ regardless of pin state; no objective-block observed under forced repro → deliberately NOT covered by the watchdog.
-**If confirmed in live play:** generalize the D318 watchdog instead of writing per-list copies — parameterize (AI list ID, offset range, chr number, target entityid, confirmation tick count) as a small table; the detection signature and the `sub_GAME_7F025560` re-init are already generic. The current `d318WatchdogTick` is hardcoded to ai_22/c78/c67 by design (one-shot, zero false-positive surface); keep it that way until a second list actually freezes in play.
+**Watchdog generalized (`port/src/d318watchdog.c`).** Table-driven entries: `{0x0417, [321,333), ent=67, c67-alive}`, `{0x0414, [480,548), any}`, `{0x040a, [60,86), any}`; detection = ACT_ATTACK + live-fire attacktype (no TARGET_AIM_ONLY bit) + offset in range [+ per-entry target-alive condition], held 600 ticks; recovery = `sub_GAME_7F025560(chr, attacktype, entityid)` (the game's own fresh-attack entry). **Repeat rescue:** a successful reseed lets the attack play out → stop-check passes → the list re-aims → a new variant can re-pin (~240 ticks later), so if the signature persists the watchdog re-fires every 360 ticks, uncapped. Verified on both Facility and Control forced runs: continuous rescues at ~600-tick cadence, guard never frozen >~6 s. D318's entry behavior is unchanged by the refactor (code path re-verified in a live playtest is still owed). Fidelity note: detection + one-shot rescue is faithful (the N64 escape path does exactly this re-init); the *uncapped repeat* is an intentional port-side accommodation for the forced/context-dependent re-pin — same class as D318/D202 M-66b; logged (`D318W:`) for playtest audit.
+
+**Observation (single, not reproduced).** Depot bare headless boot: c0 on ai_10 (a TREED guard list) pinned at off=49, act=8, atk=0x1 (live-fire), ent=0, frame stable 33.00 for 1200+ ticks — a combat-loop list the sweep rated "likely safe." Not reproduced on re-run. If seen in playtests, the "combat loops are safe" inference needs revisiting; severity is low either way (a frozen combat guard doesn't block objectives — the player can kill him).
+
+**Status:** RESOLVED (2026-09-22) — pins confirmed on ai_19 + ai_9, clean on ai_12; generalized table-driven watchdog shipped. Open threads: (a) N64-fidelity classification per D318's 2026-09-22 correction (unthrottled-emulator deliberate-timing A/B); (b) the Depot ai_10 observation; (c) ai_13/14/15 pad lists untested (LOW-MED). Cross-ref: D318 (mechanism + watchdog), D263 (ai_22 mechanism map).
**Second-pass verification (2026-09-20, review of this entry).** All mechanism claims re-checked against source and confirmed: `chrHasStoppedOrPatroling` (chraction.c:4291) returns TRUE only for ACT_STAND/ACT_ANIM/ACT_PATROL; `actor_fire_or_aim_at_target_update` (chraction.c:4882) succeeds on ACT_ATTACK + (AIM_ONLY\|DONTTURN) and calls `chrlvAttackActionRelated`, which only ever calls `modelSetAnimEndFrame` (recoil/shoot start or end frame) — never `modelSetAnimation`. The three high/med-high lists were re-read in full: ai_19 (UsetuparkZ.c 2630–2826), ai_9 (UsetupcontrolZ.c 2279–2313), ai_12 (UsetupdepoZ.c 2174+) — structures match the table above, with the two precision corrections applied to the ai_19/ai_12 rows. **AI command-ID mapping settled:** `bondaicommands.h`'s explicit IDs are the cmdbuilder enum values from `aicommands.def` order (cross-checked on four known commands; a naive `#define CMDNAME` count is off by 2). In particular `if_guard_bitfield_is_set_on` = ID 0x96 = `AI_IFMyFlags2Has` — it tests the **executing guard's own** flags2 (`AiIFMyFlags2HasRecord {cmd, BITS, GOTOLABEL}`, handler chrai.c:3146), and `guard_bitfield_set_on` = 0x94 = `AI_SetMyFlags2`. The per-chr-targeting family is separate (0x97–0x99, `chr_bitfield_*`). `AI_TRYFireOrAimAtTarget[Kneel]` jumps to GOTOLABEL on success, falls through on failure (chrai.c:1372+). TARGET_ flag decode for the hold commands: 0x24 = CHR\|AIM_ONLY, 0x28 = PAD\|AIM_ONLY (hold poses); updates set 0x04/0x08 (live fire, no AIM_ONLY).
-**Status:** OPEN — static sweep complete and second-pass verified; no live confirmation yet for any list beyond D318 itself. Cross-ref: D318 (mechanism + watchdog), D263 (ai_22 mechanism map).
+(Superseded 2026-09-22 — see the RESOLVED status line at the end of this entry.)
## D321 — D318 trigger chain fully mapped: tanks → combat bit is an authored ~3.5 s gas-cascade delay (chr 254 script); derail lands same-tick; PC behavior confirmed faithful to N64 (probe captures, 2026-09-20).
+**2026-09-23 correction (D329):** the pin is a tick-granularity divergence, not a platform-invariant lock. It persists only at 1 sim tick per rendered frame (the port at 60 fps); a single frame of 2 or more ticks (GE's normal N64 regime) breaks it. Lockstep A/B (GE_D318B_CLK) on ai_19 and ai_9: clk=1 pins every time, clk=2 never confirms. Earlier WSL wall-clock pin rates are uncalibrated (mixed clk 1-6). See D329.
+
**Question.** User's N64 memory: Ourumov shoots Trevelyan the moment the tanks are blown up. PC playtests looked like he waited for the countdown to finish, then paused ~10 s, then shot. D318 covered the pin + watchdog; this entry closes the upstream question — *when and why does the combat bit `0x04` land, and is the PC trigger chain faithful?*
**Probes (all env-gated, port-only, capped, read-only; post-release removal candidates per M-192 item 7):**
@@ -12197,9 +12846,9 @@ Verified: build clean (MSYS2 MINGW64/ninja); `tools_pc/verify.sh bunker1` — PA
**Optional tuning (deferred, user's call).** `D318_DEADLOCK_TICKS` 600→300 would halve the worst-case pin-branch pause (10 s → 5 s); the margin over the natural escape (<2 s; run B: 0 ticks) stays ≥2.5×. One-line `#define` in `port/src/d318watchdog.c`; not done pre-ship to avoid re-validating the watchdog on the release branch.
-**Status:** CLOSED — trigger chain mapped and faithful; D318 watchdog validated in live play (fired + recovered in run A, correctly inert in run B). Cross-ref: D318 (pin mechanism + watchdog), D320 (sweep of the same race in 8 levels), D263 (ai_22 mechanism map), M-192 (release review).
+**Status:** CLOSED — trigger chain mapped and faithful *at the script-data level* (the ~3.5 s cascade delay and same-tick derail are identical ROM data on both platforms); D318 watchdog validated in live play (fired + recovered in run A, correctly inert in run B). Caveat per D318's 2026-09-22 correction: "faithful end-to-end" does not establish that the derail window ever opens organically on N64 — that remains the open upstream-divergence-vs-unobserved question. Cross-ref: D318 (pin mechanism + watchdog + N64-manifestation correction), D320 (sweep of the same race in 8 levels), D263 (ai_22 mechanism map), M-192 (release review).
-## D322 — Long-session audio degradation: full campaign on v0.3.0, audio progressively worsens (Silo → Caverns/Cradle: OST inaudible, SFX "come and go"); restart restores it (issue #87, user report, 2026-09-21)
+## D322 — Long-session audio degradation: full campaign on v0.3.0, audio progressively worsens (Silo → Caverns/Cradle: OST inaudible, SFX "come and go"); restart restores it (issue #87, user report, 2026-09-20)
### Report (verbatim, issue #87 comment, v0.3.0 build)
@@ -12227,7 +12876,7 @@ Verified: build clean (MSYS2 MINGW64/ninja); `tools_pc/verify.sh bunker1` — PA
`GE_D322=1` — one line per 5 s of wall clock from `port/src/audio.c` (`audioSetNextBuffer`, ~30 Hz cadence, campaign-safe): `sfx=count/max` (8-voice soft cap), `evtq=n/64`, `pv free/lame/alloc` (24 physical voices via new `sndD322PoolSummary()` in `src/snd.c`, lists walked under the D285 lock), plus `q=queued/limit drop=n` (pipeline). Read with `GE_D204=1` for `rt=` (production rate; <1.0 sustained = starvation).
-**Probe smoke-tested 2026-09-21:** 40 s headless `-level_09` run, no crash, pools coherent (`pv free+alloc = 24`, `sfx=0/8` idle, `evtq=0`). (First build faulted: the probe took the address of `ALSndPlayer.drvr`, which is a *pointer* field, not an embedded synth — fixed with a NULL guard for pre-sndInit.)
+**Probe smoke-tested 2026-09-20:** 40 s headless `-level_09` run, no crash, pools coherent (`pv free+alloc = 24`, `sfx=0/8` idle, `evtq=0`). (First build faulted: the probe took the address of `ALSndPlayer.drvr`, which is a *pointer* field, not an embedded synth — fixed with a NULL guard for pre-sndInit.)
### Evidence ask (user, next campaign run)
@@ -12239,3 +12888,2639 @@ Full campaign (or at minimum Silo → Caverns/Cradle) with **`GE_D322=1 GE_D204=
- All pools healthy while audio is bad → back to the mixer/reverb state classes (re-open M-65's ruled-out list with a long-session `GE_AUDIODUMP`).
**Status:** OPEN — static triage complete, probe shipped, awaiting campaign capture. Cross-ref: D202/M-65+M-66b (ownerless-loop leak class + expiration), D207 (8-cap starvation design notes), D305 (pool/list desync observation), D204 (pipeline pacing + `GE_D204` monitor), D248/D250 (frame pacing, fixed), issue #87.
+
+### M-197 (v0.4.0 Phase 1, 2026-09-21): D75 root cause — the D50 model-sidecar converter dropped embedded texture blobs; fix + live verification
+
+**Symptom (inherited from M-33/M-195/M-196):** on the intro's Nintendo-logo screen the 3D `logoinst` model rendered as a tiny orbiting fragment (0–33 content px/frame) — M-195 proved emission + vertex transform correct, M-196 refuted G_VTX slot-overwrite and narrowed to "texture-modulate material rendering black" with an open anomaly (`utex`/`comb->used_textures[0]` reading 0 at emit despite `gfx_generate_cc` setting it).
+
+**Step 1 — the anomaly was a probe artifact.** The D75D frame-level fields are first-write-wins per frame: `utex`/`cutex` captured on the *first* emitted triangle (a title-screen quad with no texture) while `comb` held the *last* triangle's combine mode. Added logo-mode-specific sampling (`lg_*` fields, updated on every emitted tri whose `rdp.combine_mode == 0x009ffe4f19ffe4f1`): `lg_utex=1 lg_tex=1` on all 14k+ logo triangles, tile 0 = tmem 0 / fmt IA8 / 32×32, `lg_texaddr=0x7015dec0`. The combiner/GL-shader texture claim was never the bug.
+
+**Step 2 — the texture buffer is zero because the sidecar never contained the pixels.** New `GE_TEXDUMP_ADDR=` probe (raw source-byte dump + min/max/nonzero stats for every import from one address, bypassing the 400-dump cap) showed the buffer at `0x7015dec0` all-zero on *every* import across a 90 s run. GE's ucode has no RSP `loadtxl` (checked `rsp/graphics/gmain.s`) — all texture loading is DP DMA from DRAM, so the zeroed DRAM is upstream of the RSP. Tracing the load path: `front.c` loads `PnintendologoZ` into `ptr_logo_and_walletbond_DL` via `load_object_fill_header` → `load_resource` → RZ-inflate of the **D50 PC sidecar** (`port/src/pcmodels.c`, produced by `tools_pc/d43_emit.py`). Offline decode of the shipped sidecar: all 23 GDL `G_SETTIMG` slots correctly remapped to `w1=0x05006428`, but the decompressed image at `0x6428` is **zero for ≥1024 bytes** and contains no trace of the source pixels (the N64 original's blob at file offset `0x408` is a 32×32 IA8 white wordmark, `ffffffff…`, 713/1024 nonzero). Address chain closes exactly: runtime file base `0x70157a98` (M-195) + `0x6428` = `0x7015dec0`.
+
+**Root cause:** in `d43_emit.py`, the layout pass detects embedded image blobs (G_SETTIMG seg-5 targets — present only in P* logo/legal files), reserves the span via `add_region` (so pointers remap and tiling passes) — but **no emit-pass code ever copies the span's bytes into `buf`** (`bytearray(D_PC)` stays zero there). Round-trip validation checked every structured field and GDL slot (including `w1 == 0x05|remap(target)`, which *passes* because remap found the reserved region) but never the raw blob pixels, so the gap was invisible to `ALL CHECKS PASSED`.
+
+**Blast radius:** a scan of all 512 model files finds exactly three with seg-5 SETTIMG targets: `PnintendologoZ` (1 image), `PgoldeneyelogoZ` (2), `PlegalpageZ` (5) — the intro's GoldenEye logo and legal page were black-textured too (less visible: dark screens). No placed object offset falls inside any blob span in any of the three files, so old/new-space remapping is unambiguous (the nintendologo node-region overlap at the blob start is a PC-footprint bookkeeping artifact — 12 bytes past the N64 node's true end, no pointer targets it).
+
+**Fix (tooling-only):** `tools_pc/d43_emit.py` — (1) layout pass records `blob_regions = [(old, osz, new)]`; (2) emit pass raw-copies each span (`buf[n:n+osz] = src[o:o+osz]`, identity — the RDP reads blob bytes as-is on both platforms); (3) `validate()` gains a raw-identity round-trip check per blob span. Regenerated `data/pcmodels-ntsc-final/` (`ALL CHECKS PASSED`); offline re-decode confirms all 8 blob targets now carry their N64 pixels. No C code changed — the port's load path (`pcmodels.c` → `load_resource` → inflate) is correct and untouched; no rule-2 sign-off involved (no `src/game` edit).
+
+**Live verification:** bare front-end boot (existing binary, new data only). `GE_PCDUMP="240-330:10"`: logo window goes 45 → 851 → 8,599 → **23,302 non-black px (7.59%)** over frames 260–320 with a wide wordmark bbox `(94,190)-(542,290)`; `GE_PCDUMP="340-560:20"` shows the logo continuing its rotation animation at 3–10% coverage (ASCII-art inspection: elliptical ring + shaded 3D lettering, grey/white palette consistent with the wordmark texture). Pre-fix baseline (M-196): 0–33 px/frame.
+
+**Status:** **FIXED, user-confirmed live (2026-09-21)** — "the images and legal page and Nintendo logo are good now." Tooling fix + ntsc-final sidecars regenerated; headless-verified before the live check. Remaining: `pal-final`/`jpn-final` sidecar regeneration on machines holding those ROMs (`python tools_pc/d43_emit.py ` — the fix is in the tool, so any future regen is correct), and D75's last unverified scope: cast-roll models (status unknown since M-33; recheck with `GE_PCDUMP="1400-2200:25"`).
+
+**2026-09-25 capture (user, Windows, ~46 min, Silo → Caverns, `GE_D322=1 GE_D204=1`, build `7baf4900`; log `scratch/d322_session.log`).** User: "sound gets glitchy… gunshots from your gun and other sounds start to drop out as things stack up"; "audio did get garbled" (times not noted). No crash.
+
+- **Pools: no leak.** SFX voices (`sfx`) fall back to 0–2 in quiet stretches; physical voices never exhausted (min free 4/24, `lame` always 0); event queue max 28/64. The voice-pool-exhaustion/counter-drift hypothesis (ranked #1 above) is **refuted** for this session length.
+- **(a) SFX soft cap saturation:** `sfx=8/8` in 10 of 204 samples in the first 17 min, all during combat. While 8 are playing, `sndPlaySfx` refuses new requests, so the player's own gunshots drop out. This is D207/D241. `maxSounds=8` is the game's own configuration, so the N64 has the same cap; open question = whether N64 voices free sooner (per-sound duration/release) or the port holds slots longer.
+- **(b) NEW: periodic output-queue overflow ("garbled").** 1,244 blocks (~33 ms each) dropped at `audioSetNextBuffer` (`port/src/audio.c`, `q >= queueLimit` 2880) in **10 bursts starting at t = 30, 356, 672, 984, 1295, 1612, 1923, 2229, 2546 s**: period **~314 s** (306–326), each ~20–50 s, drops per 5 s ramping up (4 → 7 → … → 20) then stopping abruptly with `q` collapsing (e.g. 2576 → 624). Across bursts: VI steady at 60/s; produced-audio rate (`rt`, which counts dropped blocks too) ~0.99 throughout. So the queue sits near the limit while production is *not* above nominal, which implies the SDL device drains more slowly during a burst (or the regulator's floor exceeds the drain rate). It happens on every level and **already at t=30 s (title/menus)**, so it is **not gameplay load** and should reproduce headless.
+- **Next (cheap):** headless 6–7 min run at the title with `GE_D204=1` (expect bursts at ~30 s and ~345 s); add per-block timestamps / SDL queued-bytes deltas to measure drain rate inside vs outside a burst; check the regulator floor (`g_MinFrameSize` × audio ticks/s vs 22050) and the `AUDIO_TARGET_FRAMES` setpoint (D204/F5) for a beat against the 60 Hz VI timer. A ~314 s period at a 2000-frame swing corresponds to a ~6 frames/s (~290 ppm) mismatch between the clocks.
+
+**2026-09-25 (later): the periodic overflow bursts are the host audio device, not the port.**
+
+- **Ruled out, cheaply:** the SDL pull pattern (512 frames every 20–30 ms, steady); level loads and combat (bursts hit with 0 SFX playing and no load stall nearby, and are spaced 306–326 s over 50 min of varied play); a pacemaker-vs-vsync beat (the VI pacemaker's integer `1000000/60` = 16,666 µs = 60.0024 Hz predicts ~417 s against the 60/1 Hz monitor; but a 3-min run with the pacemaker nudged to a ~69 s beat, via a TEMP `GE_VI_US` override, showed no periodic bursts). The in-game capture's arithmetic already showed the drain side falling behind: during bursts production fell to 0.97× (the regulator at its 720-frame floor) while 12–15 % of blocks were dropped and the queue stayed near the limit, so SDL consumed only ~85 %.
+- **Decisive:** `tools_pc/sdl_drain_monitor.c` (SDL opened exactly like `port/src/audio.c`, silence, no game, no window, 11 min) on the maintainer's **Focusrite USB Audio** (WASAPI) showed drain dips of **88–95 % for t=76–89 s and 93 % at t=388–390 s: 312 s apart**, the same period as the in-game bursts, with a long-run average that is exact (22,005.7 frames/s; SDL 2.32's 22050→48k path drains at ~22,002). The device/driver/audio engine periodically falls behind, and during those windows our 2880-frame queue overflows and drops 33 ms blocks. A TEMP cadence probe (`GE_D322T`) also caught a queue jump with perfectly regular submissions.
+- **Port impact:** none to fix for this mechanism. A bigger `Audio.QueueLimit` would only trade drops for up to ~2 s of latency during a dip. Suggested user-side checks: Focusrite Control sample rate = the Windows device format; driver update; A/B on onboard audio.
+- **Still open under D322:** (a) the 8-voice SFX cap starving gunshots in firefights (D207/D241, N64-configured cap: is it faithful to the N64's audible behaviour?); (b) #87's own report of *progressive* campaign-long decay, which this machine does not show. The reporter's hardware may have its own periodic device dips; the monitor tool is the first thing to run there.
+
+**2026-09-25 (fix): it is SDL's WASAPI backend, not the device. Correction to the entry above.** Four copies of the monitor ran simultaneously on the same Focusrite for 700 s (WASAPI and DirectSound, 512 and 2048 samples each):
+
+| backend | buffer | dips (5 s mean) | long-run drain |
+|---|---|---|---|
+| WASAPI | 512 | 236–269 s (88 %), 553–605 s (82 %) | 21,730 f/s |
+| WASAPI | 2048 | same windows (81–88 %) | 21,733 f/s |
+| DirectSound | 512 | none | 22,048.5 f/s |
+| DirectSound | 2048 | none | 22,048.5 f/s |
+
+The same device at the same moment drains at exactly real time through DirectSound, so the periodic shortfall is SDL 2.32's WASAPI path with this USB interface. Buffer size makes no difference. The earlier "host device, nothing to fix" conclusion is withdrawn.
+
+**Fix:** `audioInit()` sets `SDL_HINT_AUDIODRIVER = "directsound,wasapi"` on Windows before `SDL_InitSubSystem(SDL_INIT_AUDIO)` (the only audio init in the port); `SDL_AUDIODRIVER` in the environment still overrides it (normal-priority hint). The init log line now names the driver. **Verified:** golden gate 3/3; Bunker 1 60 s with `GE_D204=1`: driver `directsound`, rt 0.997–0.999, q 240–624, one drop during the level load (pre-existing) and none after. **Owed:** a long play session on the maintainer's machine (the bursts came every ~5 min), expect `drop=` flat after the load. Then re-assess the firefight SFX drop-outs; D207's measurements found no sounds actually dropped by the 8-voice cap, so they were most likely this same block loss.
+
+## D323 — G_TL_LOD bindings of explicit-LOD textures: PC samples driver mips from LOD0; N64 samples the authored mip chain (static census, 2026-09-21)
+
+**Context.** D107 established that GE's room DLs emit `G_TL_LOD` and that the port folds every LOD draw to tile 0 (driver-generated GL mips from the finest level). D236 then found + fixed the one case where that folding was *visibly* wrong for a type-1 (DETAIL) binding of an RGBA5551 cut-out. This entry closes the loop on the rest of the surface: how often do LOD-on draw classes bind textures that actually carry **authored** mip levels, and does the N64 hardware behavior differ from the port's?
+
+**N64 mechanism (confirmed in code).** For a texture whose imagelist header declares `hasExplicitLods` with N images (`tex->maxlod = N`, image.c:343), every binding type emits `gDPSetTextureLOD(G_TL_LOD)` + `G_TD_DETAIL` and sets up **N tiles**: tile 0 at the image-table TMEM slot, tiles 1..N-1 at successive TMEM addresses with halved dims/masks (`texWriteTileLods`, tex.c:355; one LOADBLOCK covering the whole chain via `texGetDepthAndSize`). With TEXTLOD on, the RDP ORs the minification level into the tile index, so distant geometry samples the **authored** coarser images.
+
+**Port behavior.** fast3d folds all of tiles 0..N-1 to GL texture unit 0 (D107; D236's type-1 carve-out excepted), with `glGenerateMipmap` mips from LOD0. So on PC, minified geometry samples **driver-filtered** versions of the finest image instead of Rare's hand-authored mips.
+
+**Census (`tools_pc/bg_lod_audit.py`, ntsc-final, bg room DLs only).** GE's modified-fast3d `G_SETOTHERMODE_H` is a partial field write (op 0xBA; w0 bits 8-15 = shift, bits 0-7 = width; the value sits at its *absolute* bit position in w1 — fast3d calls `gfx_sp_set_other_mode(shift+32, nbits, w1<<32)`, an identity map). TEXTLOD = bit 16. The tool walks every room DL tracking OML/OMH state and cross-references each bind's texnum against the imagelist explicit-LOD flag:
+
+- **423** textures carry explicit LODs; **15 levels** bind at least one of them under a TEXTLOD-on class (azt 806, stat 378, arec 88, sevx 84, crad 17, pete 15, jun 25, depo 28, dest 8, run 8, arch 4, ark 4, sev 4, sevb 4, silo 2 LOD-on binds).
+- Formats: overwhelmingly **RGBA16_CI8 / IA8** (7-8 authored levels each) — driver mips of these are usually close to the authored ones; this is a subtle fidelity gap, not an artifact.
+- The only **RGBA5551** (1-bit alpha cut-out) explicit-LOD textures bound under G_TL_LOD anywhere are tex1198-1201 in sevx/sev/sevb — the D236 set, already handled by the type-1 carve-out; user reports Surface 2's treeline reads fine.
+
+**Assessment.** Real N64-vs-PC divergence, pervasive but low-visibility: no reported artifact, and the one high-visibility subset (cut-outs) is already covered by D236. A full fix means uploading GL mip chains from the explicit LOD images for TEXTLOD-on bindings (tex.c + fast3d texture-cache plumbing), which would invalidate golden baselines across all 15 levels for a change nobody has seen to be wrong — not worth doing speculatively.
+
+**Status:** OPEN / parked (GRAPHICS-BACKLOG). If a playtest ever flags mushy distant detail in one of the 15 levels above (Statue and Aztec are the heaviest), the fix path is: in fast3d's texture upload, when the binding class has TEXTLOD on and `tex->maxlod > 1`, feed `glTexImage2D` levels 0..N-1 from the decoded explicit images instead of `glGenerateMipmap`. Audit tool: `tools_pc/bg_lod_audit.py` (also documents the GE OMH field-write encoding, which no other tool captures).
+
+## D324 — GE's stub declares nothing, so TUs that include it get NO CRT prototypes: implicit `int f()` declarations silently corrupt non-int returns (atof garbage in every float ini parse; D38 class)
+
+**Context.** Found while verifying the D294 fix: the new `GE_ROOMPOOL` env hook in `port/src/video.c` called `atof()`, and the parsed value was deterministic-per-call-site garbage (observed 15279232 / 7087232 / 6817888 / 14288992 across runs) instead of the env string's value. The scale override was silently ignored on every run until prototypes were added.
+
+**Mechanism.** `include/stdlib.h` (the N64 decomp's libc stub) wraps its entire body in `#if 0` — only the `lldiv_t`/`ldiv_t` typedefs are live. `port/shim/stdlib.h` (what `` resolves to for C TUs; see docs/internals.md §11 D12) includes that stub and then declared a couple of stragglers — but `getenv`/`realloc` **only on non-Windows**, on the empirically-wrong assumption that MinGW's other CRT headers "leak" prototypes (config.c gets no leaked `getenv`; the implicit-declaration warning proves it). So any port/src or game TU that calls a CRT function nothing else declared compiles with an **implicit `int f()` declaration**. On x86-64 SysV that means: a `double`/`float` return in **xmm0 is read from eax** (garbage), pointer returns are truncated to 32 bits, and `long` returns truncate on LP64 (Linux) builds.
+
+**Why only some functions actually misbehave (the triage rule for this class):**
+- **GCC builtins** (`floorf`, `sqrtf`, `memset`, …): GCC recognises them even when implicitly declared — it warns (`-Wbuiltin-declaration-mismatch`) but generates the *correct* xmm0/inline code. Harmless in practice. (This is why `sky.c`'s implicit `floorf` never visibly broke: correct values, and its fold result is additionally masked by `SKY_TC_WRAP` periodicity — any multiple of the wrap period is visually identical on a wrapping sky texture.)
+- **int-returning** (`atoi`, `atexit`): the 32-bit int fits in eax exactly. Harmless (still UB per the standard; warning only).
+- **Non-builtin float/double/pointer/64-bit-long returns** (`atof`, `strtod`, `getenv`, `strtol` on LP64, …): genuine silent corruption. This is the only bucket that needs fixing.
+
+**Proven impact.** (1) `port/src/config.c` `parseFloat` → **every float ini option parsed to garbage whenever a user set one** — currently exactly one exists, `Game.ScreenShakeIntensity`, and the affected user had it set to 10 (silently became an eax-derived float for months). (2) The D294 `GE_ROOMPOOL` hook (video.c) — caught during verification. (3) `getenv` in 8 port TUs + `src/libultra/audio/csplayer.c`: pointer truncated to 32 bits — harmless *so far* on Windows (CRT env block sits below 4 GB), a landmine otherwise.
+
+**Ruled out during diagnosis** (all tested, all clean): msvcrt corruption, locale, stack alignment, C++ runtime interference — plain MinGW test programs parse identically; only this build is affected, purely via the header shadowing. Garbage values are deterministic per call-site (same eax state each time) but vary between runs.
+
+**Fix (port-layer only):**
+- `port/shim/stdlib.h`: now declares `atof` / `strtod` / `strtol` / `atexit` / `getenv` **unconditionally** (K&R form — compatible with any real or leaked prototype), after including the N64 stub. `realloc` stays non-Windows-only (strict-GCC leak gap).
+- `port/include/pc_protos.h`: gains `double atof(); double strtod();` for game TUs that include `` but never `` (no game TU currently calls them; future-proofing + canonical D38 list).
+- `src/game/glass2.c` (the D219 `#ifdef PORT` block): `memset` → `__builtin_memset` so that block needs no prototype at all.
+
+**Residual implicit declarations (audited, all in the harmless buckets above — warning-only, no action):** `atoi` in `src/game/chr.c`, `src/game/sky.c`, `src/game/bondview2.c`, `port/src/optionsoverlay.c`; `lround` in `optionsoverlay.c` (fine on Windows LLP64 where `long`=32 bits; would truncate on an LP64 Linux build — revisit only if a Linux port is actively maintained); the builtin family (`floorf`/`sqrtf`/`memset`) wherever it appears.
+
+**Status:** FIXED (2026-09-22). Build clean; D294 verification battery re-run post-fix (faithful→authored size, user settings→0 failures, `GE_ROOMPOOL=0.25`→honoured + exhaustion on demand). Cross-ref: D38 (original implicit-declaration finding), D294 (where it was caught).
+## D325 — ROM-mod (xdelta) compatibility: relocating/total-conversion mods cannot work while the file table is compiled in; in-place data-only mods are one sidecar-staleness fix away (Goldfinger 64 probe, 2026-09-22)
+
+### Question
+
+Can the port run classic GoldenEye ROM mods distributed as xdelta patches, and
+is a `mods/` folder mechanism worth building? Probe target: **Goldfinger 64
+v1.0** (12.8 MB xdelta, a total-conversion campaign), with the Random Eye
+randomizer (286 KB xdelta) as the contrasting light-touch case.
+
+### What the port actually does with the ROM
+
+- `port/src/romdata.c` reads the `.z64` and memcpys it to the N64 cart address
+ `0x10000000`; **all game data is read out of it at runtime**. `romSize` comes
+ from `fsSize`, so ROM size is not fixed — an *expanded* ROM maps fine. The
+ only header validation is the country byte at `0x3E` (`romHeaderValid`).
+- ROM **code is never executed** — we run the compiled decomp C. Every ASM hack
+ in a mod is inert by construction.
+- **The binding constraint:** the file table is *compiled into our binary*, not
+ read from the ROM. `src/game/ob.c:29` includes
+ `assets/obseg/file_resource_table.inc.c`, whose `hw_address` fields are
+ linker symbols resolved by `port/src/romassets_.s` to **hardcoded
+ vanilla ROM offsets**. The offline converters do the same at convert time:
+ `tools_pc/d88_emit.py:96` reads that table, and `scripts/filelist.u.csv`
+ carries 812 vanilla offset/size rows.
+- Sidecars (`pccg-*/`, `pcmodels-*/`) are **presence-checked only**
+ (`rcSidecarsPresent`, `port/src/romconvert.c`); `manifest.csv` is
+ `name,offset,size` with **no ROM identity stamp**. Swapping the ROM therefore
+ leaves stale converted data in place silently.
+
+### Probe (isolated worktree off `main`; primary checkout untouched)
+
+1. `xdelta3 -d` of `Goldfinger64_1_0.xdelta` against our vanilla NTSC-U ROM
+ applied **cleanly** (rc=0). Output **25,165,824 bytes** — a 12 MB → 24 MB
+ expanded ROM.
+2. Header: magic `80371240` unchanged, internal name `GOLDENEYE` → `GOLDFINGER`,
+ country byte still `E` (so `rcRegionForCountry` maps it to `ntsc-final` and
+ `romHeaderValid` accepts it), CRC pair changed, boot segment
+ `0x40..0x1000` byte-identical.
+3. **Relocation measured:** slicing all 803 usable `filelist.u.csv`
+ (offset, size) rows out of both ROMs and comparing — **797 of 803 differ**,
+ 6 identical. Total content replacement with a rebuilt file layout.
+4. **Converter failure signature** (first hard stop, exactly as predicted):
+
+ ```
+ File "tools_pc/d43_emit.py", line 273, in process
+ src = zlib.decompress(rom[addr:addr + size][2:], -15)
+ zlib.error: Error -3 while decompressing data: invalid block type
+ ```
+
+ i.e. a hardcoded vanilla offset now lands on unrelated bytes. `d69`/`d88`
+ and the runtime path would fail the same way; not run, and no build was
+ attempted.
+
+### Taxonomy + verdict
+
+| Mod class | Verdict |
+|---|---|
+| **ASM / code hacks** | **Never.** ROM code is not executed here. |
+| **Relocating / expanding / total conversions** (Goldfinger 64) | **Out of reach.** Every compiled `hw_address` and every `filelist.u.csv` row is wrong. Would require the file table (and, for added levels, the stage tables) to be **parsed from the ROM at runtime** instead of compiled from `file_resource_table.inc.c` + `romassets_*.s`. That is an architectural project, and the added-level half collides with AGENTS.md rule 2 (new stage-table entries are `src/game` logic, not an ABI/layout edit). Not a `mods/` folder feature. |
+| **In-place data-only** (offsets and sizes preserved — e.g. a setup randomizer like Random Eye) | **Hypothesis — DISPROVEN by D326**, which found the Random Eye randomizer repacks the ROM as well (identical size, 803/803 slices shifted); no known mod actually inhabits this tier. The fix below is still worth doing on its own merits, but unlocks no mod by itself. Needs one fix: stamp ROM identity (size + hash) into `manifest.csv` and reconvert on mismatch, in `rcSidecarsPresent` / `port/src/romconvert.c`. Without it a swapped ROM keeps stale sidecars and mis-renders in a way that looks like corruption rather than like a stale cache. |
+
+A bounded scan of both ROMs for an in-ROM 12-byte `fileentry` array
+(`{s32 index, char *filename, u8 *hw_address}`, `src/game/ob.h:16`) found no
+convincing ~800-entry candidate (only a 40-entry run at `0x29e24c`, present in
+both ROMs, almost certainly a false positive of the heuristic). So **whether
+the table is even recoverable from an arbitrary modded ROM is unresolved** and
+is its own investigation — it is the gating question for the relocating-mod
+refactor above.
+
+### If a `mods/` loader is ever built
+
+- xdelta3 is **GPLv2** — invoke it as a separate bundled executable (the
+ existing `prepare-assets/ge007-convert` spawn pattern in
+ `port/src/romconvert.c`) or implement VCDIFF (RFC 3284) decode; do not link
+ it into this MIT tree.
+- Never redistribute a patched ROM or a patch; user supplies both, same posture
+ as the base ROM.
+- Sidecar trees must be keyed per ROM hash, which is the same stamp fix as
+ above.
+
+**Status:** CLOSED as a question — mechanism established and measured; no code
+changed. Follow-up (b) was run and **disproved the cheap tier — see D326**, which
+also identifies the real keystone (recovering the file table from the ROM at
+runtime) and carries the backlog pointer. Follow-up (a), the `manifest.csv`
+ROM-stamp fix in `port/src/romconvert.c`, still stands on its own merits (a
+genuine latent bug today for anyone swapping ROMs or regions). Cross-ref: D43/D69/D88 (the converters whose vanilla offsets
+this exposes), D179 (missing-sidecar abort path).
+
+## D326 — Random Eye randomizer repacks the ROM too: the hypothesised "in-place data-only" mod tier is empty in practice; the in-ROM file table is the single keystone for any mod support (2026-09-22)
+
+### Why this was run
+
+D325 closed with a hypothesis: mods that preserve every file's offset and size
+would work almost for free, needing only a ROM-identity stamp on the sidecars.
+The Random Eye randomizer (`Murks-Random-eye-zer-v1.1.xdelta`, 286 KB) was the
+obvious candidate — a *setup* randomizer, no new content, and a patch 45× smaller
+than Goldfinger 64's. This tests that hypothesis. **It fails.**
+
+### Measurements (same isolated worktree method as D325)
+
+- Patch applies cleanly (rc=0) to our vanilla NTSC-U ROM. Output size is
+ **identical** — 12,582,912 bytes. Internal name still `GOLDENEYE`, country
+ byte still `E`, boot segment `0x40..0x1000` byte-identical, CRC pair changed.
+ Every surface signal says "small in-place edit".
+- It is not. **96.5% of all ROM bytes differ** (12,144,703 of 12,582,912), and
+ **803 of 803** usable `filelist.u.csv` slices differ — every single file,
+ including fonts and the Rareware logo, which a randomizer has no reason to
+ touch.
+- The content is preserved and **shifted**: searching the modded ROM for
+ vanilla file content finds it intact at a new offset —
+ `jfont_dl` / `jfont_chardata` / `efont_chardata` / `animationtable_entries`
+ all at **+7216**, `GwppksilZ` / `PICBMZ` at **+7392**, and
+ `GwristdartZ` at **−229888** (genuinely rearranged, not merely displaced).
+- Converter failure is the D325 signature again:
+ `tools_pc/d43_emit.py:273` → `zlib.error: Error -3 while decompressing data:
+ invalid stored block lengths`.
+
+**Conclusion:** the community toolchain (GE Setup Editor lineage) *repacks* the
+ROM. Even a content-neutral randomizer comes out with a rebuilt file layout. So
+the D325 taxonomy's cheap tier — "in-place data-only" — has no known real
+inhabitants, and the `manifest.csv` ROM-stamp fix, while still worth doing on
+its own merits (swapping ROMs/regions today silently serves stale sidecars),
+**does not by itself unlock any actual mod**.
+
+### Revised verdict: one keystone, not a tiering problem
+
+All ROM-mod support now reduces to a single research question: **can the file
+table be recovered from an arbitrary modded ROM at runtime?** Our table is
+compiled in (`src/game/ob.c:29` → `file_resource_table.inc.c` → linker symbols
+in `port/src/romassets_.s`), and the converters re-use the same vanilla
+offsets (`tools_pc/d88_emit.py:96`, `scripts/filelist.u.csv`).
+
+Two bounded scans failed to locate the table in either ROM:
+
+1. 12-byte-stride runs of `{s32 index, char *filename, u8 *hw_address}`
+ (`src/game/ob.h:16`) with ascending index and in-range ascending address —
+ best candidate was a 40-entry run at `0x29e24c` present *identically in both
+ vanilla and Goldfinger 64*, i.e. a heuristic false positive, not an ~800-entry
+ table.
+2. Known file offsets encoded as big-endian u32 in raw / `|0x10000000` /
+ `|0xB0000000` form, over the first 200 `filelist.u.csv` entries: 20 raw hits
+ across the whole ROM, 1 each for the other two encodings, **no dense cluster
+ anywhere**.
+
+**Answered in D327:** the table is provably not in the ROM in any plaintext
+encoding (including the segment-relative `0x7F000000` form, 0 hits), so approach
+(a) is ruled out — `file_resource_table.inc.c` is a decomp reconstruction, and
+the retail table lives in the loaded/compressed code image this port never
+unpacks. D327 replaces this open question with a viable route: content-addressed
+offset remapping with gap inference over contiguously-packed files.
+
+**Not fixable regardless:** ASM/code mods (ROM code is never executed here), and
+mods that add levels (new stage-table entries are `src/game` logic — AGENTS.md
+rule 2, not the ABI/layout exception).
+
+**Status:** CLOSED as a question — D325's cheap tier disproven, keystone
+identified and then **resolved in D327** (table-in-ROM ruled out; content-addressed
+remapping specified). No code changed. Backlogged as §5 of the local
+`docs/dev/notes/MODERN-PORT-FEATURES-BACKLOG.md` (extends T3.6), sized
+large/multi-session and gated on the table-recovery question; correct sequencing
+is after first public release. Cross-ref: D325 (Goldfinger 64 probe + the
+taxonomy this revises), D43/D69/D88 (the converters carrying vanilla offsets),
+D179 (missing-sidecar abort path).
+
+## D327 — Mod support, resolved plan: reading the file table out of the ROM is ruled out by measurement; content-addressed offset remapping is the viable route (1964GEPD fork review, 2026-09-22)
+
+### Why this was run
+
+D326 left one gating unknown — "can the file table be recovered from a modded
+ROM at runtime?" — with a guess that it lives inside the compressed code image.
+A community fork, `1964GEPD-automatic-mod-compatibility`, advertises automatic
+mod compatibility and was reviewed for a technique. This entry settles the
+gating question with measurements and replaces the guess with a plan.
+
+### What the 1964 fork actually does (not what we need)
+
+Its change is in the emulator's TLB setup (`InitTLBOther`). 1964 had a
+GoldenEye **speed hack** that direct-maps the `0x7F000000` game segment with the
+ROM offset hardcoded (`0x90034b30`); mods relocate that segment, so the hack
+broke. The fork replaces the hardcode with a scan for a unique 5-instruction
+signature at the segment's head. Its own code comments note the game *"still
+work[s] without hack"* — this is an optimization guard, not mod-loading
+machinery.
+
+**Why it transfers so poorly:** 1964 *executes the ROM's MIPS code*, so it never
+needs a file table at all. Mod compatibility is nearly free for an emulator and
+structural for us. There is exactly one transferable fact (next paragraph).
+
+### Transferable fact, independently verified
+
+GE maps its `0x7F000000` game segment **directly from ROM, uncompressed**, and
+the segment head is uniquely identifiable. Re-derived here against our own
+vanilla NTSC ROM by scanning for the instruction sequence
+`lui $at,0x3f80` / `mtc1 $at,$f0` / `addiu $v0,$zero,-1` / `lui $at,hi` /
+`sw $v0,lo($at)`: **exactly one hit, ROM offset `0x34b30`** — matching the
+emulator's old hardcoded `0x90034b30`. This locates a *relocated* segment in a
+modded ROM in one pass. Useful, but on its own it does not yield the file table.
+
+### (a) Reading the table out of the ROM — **CORRECTED BY D328: NOT ruled out**
+
+> **This section's conclusion is wrong.** Every scan below was run over
+> *uncompressed* ROM bytes. GE's file table lives at offset `0x252c4` inside
+> the 1172-compressed data segment that starts at ROM `0x21990`, with
+> pointers in a `0x80020d90`-based space — one raw-inflate call away, using
+> the decompressor `port/src/rzdecomp.c` already ships. D328 verifies it
+> against our own ROM (681 exact offset+size matches vs `filelist.u.csv`, 0
+> mismatches) and reads two real mods' own tables with no hardcoded offsets.
+> The measurements below remain accurate as *negative results about raw ROM
+> bytes*; the inference drawn from them does not hold.
+
+`file_resource_table`'s `hw_address` values are not present in the vanilla ROM
+in any plaintext encoding. Measured over the first 100 `filelist.u.csv` offsets:
+
+| Encoding | Hits |
+|---|---|
+| raw u32 offset | 4 (incidental) |
+| `\|0x80000000` | 1 |
+| `\|0xB0000000` | 1 |
+| segment-relative `0x7F000000 + off − 0x34b30` | **0** |
+
+Plus two structural scans for a 12-byte `{s32 index, char *filename,
+u8 *hw_address}` run (`src/game/ob.h:16`) — one requiring ascending index and
+in-range ascending address, one additionally requiring `filename` to point at a
+plausible ASCII string. No candidate in either ROM. (The 40-entry run at
+`0x29e24c` reported in D325 appears identically in vanilla *and* Goldfinger 64,
+confirming it as heuristic noise.)
+
+The retail table does live in the compressed image — that much was right — but
+"which this port never unpacks" was the error: the data segment is a single
+raw-inflate away and `port/src/rzdecomp.c` already does exactly that inflate.
+See **D328**, which reads the table out of it and validates it end to end.
+(`file_resource_table.inc.c` is still a decomp reconstruction; its *ordering*
+matches the retail table, which is what makes index-keyed extraction work.)
+
+### (b) Content-addressed offset remapping — the viable route
+
+`obInit` derives each file's size by differencing adjacent `hw_address` values
+(`src/game/ob.c:126`), which means **files are packed contiguously in the ROM**.
+That is the lever:
+
+1. We know every vanilla file's exact bytes. For a repacked ROM, locate each
+ *unchanged* file by content search → its new offset.
+2. A file the mod actually **changed** cannot be found that way, but because the
+ layout is contiguous it is **bounded by the gap between its located
+ neighbours** — offset and size both fall out.
+3. Output is a per-mod offset/size table that replaces the compiled
+ `hw_address` values at runtime and feeds `d43`/`d69`/`d88` in place of
+ `scripts/filelist.u.csv`.
+
+One-time cost at the convert step, not a runtime cost. Verified plausible
+against Random Eye, whose content is preserved and merely shifted
+(+7216 / +7392, one file −229888; D326).
+
+**Known limits:** a run of *adjacent* changed files cannot be subdivided by gap
+inference; files a mod *adds* get no help; and mods that add levels still need
+new stage-table entries in `src/game` (AGENTS.md rule 2, not the ABI/layout
+exception). ASM/code mods remain impossible — ROM code is never executed here.
+
+### License posture (important)
+
+`1964GEPD-automatic-mod-compatibility` is **GPL-2.0**; this is an MIT tree. Do
+not vendor it, copy code from it, or paste its functions — the same hygiene call
+as the 2026-09-19 removal of the vendored Mouse Injector (`AGENTS.md`, `reference/mouse-injector/README.md`).
+The segment-start offset and instruction signature recorded above are facts
+about *Rare's* code and were re-derived directly from our own ROM and decomp;
+any implementation must be written from our sources, with provenance noted as
+"technique observed in a GPL-2.0 project, reimplemented independently". Better
+still: the segment start is obtainable from our own link map, so the scan can be
+skipped entirely.
+
+**Status:** SUPERSEDED BY D328 — approach (a) is **not** ruled out; this entry's
+scans missed the compressed data segment. Approach (b) (content-addressed
+remapping) remains a valid fallback for a mod that relocates the data segment
+beyond what the scan finds. Mod support is viable; only implementation effort
+remains (large, correct sequencing
+is after first public release). No code changed. Cross-ref: D325 (Goldfinger 64
+probe + mod taxonomy), D326 (Random Eye repack — cheap tier disproven),
+D43/D69/D88 (converters carrying vanilla offsets), D179 (missing-sidecar abort).
+
+## D328 — Mod support is viable and D327's "ruled out" was wrong: GE's file table is in the ROM inside the 1172-compressed data segment, and locating it needs no hardcoded offsets (verified on vanilla + 2 mods, 2026-09-22)
+
+### Source
+
+The Perfect Dark PC port fork at `perfect-dark-dabs-mod-dabs-mod` (**MIT**,
+Copyright 2022 Ryan Dwyer — same license as this tree; `AGENTS.md` already names
+the PD port a standing reference). Two files matter:
+
+- `port/src/modimport.c` — in-game console-mod import. Its header states our own
+ constraint verbatim: *"The port cannot run the patched ROM — it runs decomp
+ code and reads the ROM at fixed offsets — but it can use the mod's content."*
+- `port/src/geconvert.c` — converts a **GoldenEye 007 US ROM** into PD arenas, so
+ it parses *our* ROM and carries GE's constants.
+
+### The architectural flip (why D325–D327 asked the wrong question)
+
+D325–D327 scoped mod support as "make the port load a modded ROM", concluded
+that needed a runtime-parsed file table, and stalled on whether that table was
+recoverable. PD does not do that at all: it **diffs the patched ROM against
+stock, extracts only what differs, and writes it out as a mod directory**
+(`files/`, `segs/`) in the layout its loader already reads. The game keeps
+running its own stock ROM at fixed compiled offsets; mod content arrives as loose
+files. Mod support is an **offline import step plus a file-override layer**, not
+a loader rewrite.
+
+Supporting pieces in that fork, all MIT:
+
+- `port/src/rompatch.c` — its own VCDIFF (xdelta3: RFC 3284 + app header +
+ per-window adler32), BPS and IPS decoder, verified byte-identical against
+ xdelta3 on 40 patches. **This removes the GPLv2 xdelta3 dependency D325 flagged
+ as a licensing constraint.**
+- `findFileTable()` (`modimport.c:321`) scans the decompressed data segment for a
+ run of ≥`MIN_FILES` (500) records; the data segment is found by scanning the
+ first 4 MB. No hardcoded offsets, so it survives relocation.
+- `tblStartBySamples()` locates audio tables by searching for stock waveform
+ prefixes and voting — the content-addressed idea D327 proposed, in production.
+
+### GE's file table: where it actually is
+
+`geconvert.c` gives `DATA_ROM 0x21990`, `DATA_VRAM 0x80020d90`, `FILES_AT
+0x252c4`, and reads the table as 12-byte records with size by differencing
+adjacent addresses — **exactly `obInit` (`src/game/ob.c:126`)**. Verified here
+against our own `data/ge007.ntsc-final.z64`:
+
+- `1172` magic present at `0x21990`; `zlib` raw-inflate (`-15`) after the 2-byte
+ header yields a **247,120-byte** data segment. This is the same inflate
+ `port/src/rzdecomp.c` already wraps.
+- Table at `0x252c4`, **727 rows**, first word counting `0..726`. Names resolve
+ via `vaddr − 0x80020d90` to `bg/bg_sev_all_p.seg`, `bg/bg_silo_all_p.seg`, …
+ matching `assets/obseg/file_resource_table.inc.c` order.
+- **Cross-checked against ground truth:** derived `addr`/`size` pairs vs
+ `scripts/filelist.u.csv` — **681 exact offset+size matches, 0 mismatches**
+ (44 unmatched are artifacts of the basename matcher used, not data errors).
+
+**D327's approach (a) is therefore NOT ruled out.** Its scans were run over
+*uncompressed* ROM bytes, so it was right about the mechanism ("lives inside the
+compressed code image") and wrong about the consequence: the segment inflates in
+one call with a decompressor we already ship.
+
+### Hardcode-free discovery, verified on two real mods
+
+Implemented the PD strategy independently (scan for `1172` in the first 4 MB,
+inflate, scan for a ≥500-row run whose first word counts from 0) and ran it on
+all three ROMs. **No constant from `geconvert.c` was used.** Then validated by
+comparing vanilla file *content* against each mod ROM at the offsets its **own**
+table gives:
+
+| ROM | data seg | table | rows | content identical to vanilla at extracted offsets |
+|---|---|---|---|---|
+| vanilla NTSC-U | `0x21990` | `0x252c4` | 727 | — (reference) |
+| Random Eye v1.1 | **`0x219f0`** | `0x25304` | 727 | **704 / 726 (97.0%)** |
+| Goldfinger 64 v1.0 (24 MB) | `0x21990` | `0x252c4` | 727 | 19 / 726 (2.6%, as expected for a total conversion) |
+
+Random Eye's non-identical entries, fully accounted for:
+
+- **2 real changes** — `UsetupcaveZ` and `UsetuptraZ` (setup files; the
+ randomizer's actual edits).
+- **1 artifact** — index 0, the `NULLFILE` slot, whose "size" is the differencing
+ of `addr 0` against the first real file.
+- **19 zero-size slots** — unused `bg/` entries (`bg_sho_all_p.seg`,
+ `bg_eld_all_p.seg`, `bg_imp_all_p.seg`, …), i.e. cut-level table padding, not
+ failures.
+
+704 + 2 + 1 + 19 = 726. **Complete.**
+
+Contrast with D326, which found **0 of 803** slices matching when read at
+*vanilla* offsets: the same ROM, read through its own table, is 97% recognisable.
+That is the whole mod story in one number, and it also confirms D326's shift
+measurement (`+7392`) was the data segment and file area moving together.
+
+### Revised viability
+
+From "architectural project gated on unresolved research" (D327) to **known-good
+architecture with an MIT reference implementation**. Remaining work is real but
+bounded, and none of it is research:
+
+1. **File-table extraction** — the scan above, in C, reusing
+ `port/src/rzdecomp.c`. Proven.
+2. **Patch decoding** — VCDIFF/BPS/IPS. `rompatch.c` is MIT and adaptable with
+ attribution; no GPL dependency needed.
+3. **Mod-directory override layer** — the genuinely new piece. PD has
+ `romfile.moddir` file slots; **we have no analogue** — `port/src/romdata.c`
+ reads the ROM only. This is where the effort sits.
+4. **Converter integration** — `d43`/`d69`/`d88` taking an extracted table
+ instead of `scripts/filelist.u.csv`.
+
+**Unchanged limits:** mods that *add levels* still need new stage-table entries in
+`src/game` (AGENTS.md rule 2, not the ABI/layout exception); ASM/code mods remain
+impossible since ROM code is never executed here.
+
+**Status:** CLOSED as a question — viability established, mechanism verified end
+to end on two real mods, no research blocker remains. Amends **D327** (approach
+(a) un-ruled-out) and supersedes its keystone framing. No code changed; backlog
+updated in the local notes backlog §5. Cross-ref: D325 (GF64 probe + taxonomy),
+D326 (Random Eye repack measurement this re-reads correctly), D327 (the scans
+whose conclusion this corrects), D43/D69/D88 (converters), D179.
+
+---
+
+### M-199 (2026-09-22): adversarial review of the proposed D318/D320 fix plan (frame-data audit → runtime trace → conformance sweep → watchdog excision). **Verdict: SOUND-WITH-CORRECTIONS** — direction and constraint posture are right, but step 1 mischaracterizes the data path and is ~80% already done, the plan's "unhandled branch" is actually the *expected* outcome of step 1 (with no decision rule for it), and step 2 as written hunts for a mechanism that cannot exist in byte-matched code. No code changed; review only.
+
+**Pre-flight (mechanism re-verified against source this session, all links hold):**
+- `chrlvInitActAttack` (chraction.c ~1258–1376): `next_anim = randomGetNext() % len`, `modelSetAnimation(panim_float->anim, flip, panim_float->start_frame, …)`; `unk36`/`unk32`/`unk34` set from weapon props + PRNG. ✓
+- `chrlvAttackActionRelated` (chraction.c:1126–1163): **only ever calls `modelSetAnimEndFrame`** — never `modelSetAnimation`. E-selection branches verified verbatim: AIM_ONLY → `H = (0 ≤ recoil < shoot_start) ? recoil : shoot_start`; live-fire → `E = recoil (if ≥ 0, both unk36 arms) else shoot_start / end_frame / −1`. ✓
+- Re-init block (chraction.c ~7126+): `modelGetAnimEndFrame <= frame` gates everything; the unk31 path (~7209) is the only `modelSetAnimation` re-init and is unreachable while E > H. ✓
+- `modelSetAnimEndFrame` (model.c:2936): accepts `endframe` only if `endframe < ModelAnimation->unk04 − 1`, else stores −1 (unbounded). Named fields throughout. ✓
+
+**New finding 1 — there is no conversion step for this data; step 1's premise is wrong.** The firing-animation groups are byte-matched decompiled C in `src/game/chr.c` (`pistol_firing_animation_groupN[]`, named-field access → A1-immune; `len = −1` resolved at runtime by `initResolveAnimGroups`/`initResolveAnimGroupTable`, initactorpropstuff.c:82–158, counting to the sentinel row). The `ModelAnimation` records come from **raw ROM via `romCopy`** (D34, port/src/romdata.c; `g_pc_animdata_base`) — the PC reads the *same bytes* the N64 does, and `ModelAnimation` has no pointer members (bondtypes.h:629; D32 already u32-ized `bitDescriptors`/`bitStream`) so the layout is stable. "Decode from raw ROM and compare against the port's converted assets" compares two things that are identical by construction; no ROM-decode tooling is needed or in `tools_pc/`.
+
+**New finding 2 — the audit was effectively pre-run, and its outcome is knowable now.** Applying `chrlvAttackActionRelated`'s exact selection logic to `pistol_firing_animation_group1` (the group matching both probe signatures):
+
+| variant | start | shoot_start | recoil_start | H (pre-aim) | E (post-derail) | outcome |
+|---|---|---|---|---|---|---|
+| fire_hip_…_slow | 12 | 19 | 24 | **19** | **24** | **PIN** — exact match to frozen-run probe (H=19.00/E=24.00) |
+| fire_hip_…_fast | 17 | 25 | 30 | 25 | 30 | PIN |
+| fire_standing_… | 12 | 58 | 60 | 58 | 60 | PIN |
+| fire_hip_forward_… | 4 | 22 | 22 | **22** | 22 | ESCAPE (E==H → `endframe <= frame` re-init) — exact match to escape-run probe (H=22.00) |
+
+Group1 is **3/4 pin-capable** — consistent with the observed ~2/3–3/4 forced-repro rate. Other pistol groups: g2 1/2, g3 3/4, g4 1/1, g5 1/1, g6 3/4 — **no all-escape group exists**. Consequence: byte-matched code + ROM-identical data ⇒ under identical ticks the N64 should pin on ≥½ (typically ¾) of derails. The user's spec (0 pins in ~30 y / all 1964 trials escape) is therefore **in direct conflict with the byte-matched data** — this is the plan's "unhandled branch," and it is the *expected* outcome of step 1, not a corner case. The plan has no decision rule for it.
+
+**New finding 3 — the pin sits exactly ON the fire-window boundary; D318/D320's stated condition is imprecise.** In every E>H variant above, **H == shoot_start exactly** (19/25/58) — not "H < shoot_start" as D318/D320 state. The pinned frame therefore sits at the edge of the fire gate `shoot_start <= frame < shoot_end` (chrlvTickAttackCommon), and whether c78 fires from that state hinges on **sub-frame settling**: `modelTickAnim`'s s32 `framea/frameb` clamp (`modelConstrainOrWrapAnimFrame`) + the `if (vb == va) animframe1 = va` sub-progress discard. The D318 probe printed `%.2f` ("19.00") — 18.99x would display identically. Working hypothesis: on N64 the frame settles at exactly shoot_start → gate opens → every "pin" variant fires (N64 pin rate = 0%, matches spec); on PC it settles just below (or a downstream gate in the fire loop — `unk38[i]` weapon slots, `unk44`/`attack_time` priming — stays closed) → ¾ pin (matches observation). **A single deterministic per-derail divergence explains the full 0%-vs-~66% rate gap with no PRNG argument** — which also confirms the brief's claim that a stream difference cannot explain it. Note this makes "find what re-inits the animation on N64 that the port misses" (plan step 2) misdirected: there is **no** re-init path for E>H in byte-matched code, so if data+code are identical, nothing can re-init on N64 either — the N64 escape must come from the fire gate opening at the boundary (or from the derail never landing), not from a missed re-init.
+
+**Verdict per attack surface:**
+1. **Candidate completeness.** {data, runtime} is exhaustive only if "runtime" is expanded to include (a) fire-gate boundary settling in `modelTickAnim`/`modelConstrainOrWrapAnimFrame` (new finding 3 — the prime suspect) and (b) the upstream derail window (hypothesis i). The §A1 concern (E/H misread at runtime from correct data) is *ruled out by construction* for this chain — compiled named-field access + raw-ROM `ModelAnimation` with no pointer members — but the plan should record that ruling explicitly instead of running a ROM decode.
+2. **The unhandled branch.** Expected outcome, not corner case (finding 2). "The user's spec is wrong" is **not** an acceptable first conclusion: byte-matched code + identical data vs a 30-year community record means one link in the pin chain carries an unverified platform assumption; hunt runtime/reachability first (boundary settling is cheap and port-probeable). If the boundary hypothesis fails, one *bounded* emulator artifact — a single `g_randomSeed` memory read feeding `GE_RSEED_LV`, or one trial with frame-precision observation — is a different cost class from the 10-trial series the user ruled out; needs user sign-off before use.
+3. **Ordering/feasibility.** Data-audit-first is right but ~80% complete after this review (finding 2). Remainder: extend the H/E table to the ai_19/ai_9 groups and confirm which group index c78 actually draws (`anim_index` from `chrlvDistanceToChrRelated`, distance-bucketed over the 32-entry `ptr_pistol_firing_animation_groups[]`). Effort: ~1–2 h, no new tooling.
+4. **ai_19 / ai_9.** Different anim groups (kneel/crouched tables; probes pinned at frames 30.00/23.00 — neither matches a group1 row). Fixing ai_22's root cause implies theirs only if the cause is shared (boundary settling would be; per-group data is not). Step 4's "re-verify first" must be an explicit **per-entry H/E table + boundary check + forced-repro sweep**, and each watchdog entry excised on its own evidence — not on ai_22's result. Depot ai_12 (clean) and the untested ai_13/14/15 (LOW-MED) stay out of scope for excision.
+5. **Emulator A/B.** Cutting the trial series is correct (given identical data it can only re-test "does the derail happen on N64," which the spec's +3.5 s fire signature already answers — the monologue-completion path takes ~22 s, so a +3.5 s shot is itself evidence the derail landed). Preserve `GE_RSEED_LV` + DET logging armed as the fallback oracle for an upstream stream hunt (cost: one memory-viewer read + an env var); do not burn it until the boundary hypothesis fails.
+6. **Constraint compliance.** All steps port-side; no Rule-2 exposure *as written*. Two cautions: (a) watchdog excision changes shipped v0.4.0 behavior mid-release — keep the watchdog on `release/v0.4.0` until step 3 passes and land the excision post-release (M-192 item 7 precedent); (b) pre-decide the fix bucket for the boundary root cause: if it turns out to be FMA contraction / float-rounding in compiled game code, a **build-flag** change (CMakeLists, e.g. `-ffp-contract=off`) is port-side, but any arithmetic edit in `src/game/model.c` requires Rule-2 sign-off.
+
+**Corrected plan sketch:** (1) finish the static H/E table for all three confirmed entries' groups + c78's group index — ~1–2 h; (2) env-gated probe: full-precision (`%f`/bit-exact) `animframe1` + fire-gate operands (`shoot_start`, `phi_f20`, `unk38[i]`, `unk3a[i]`, `unk44`) in a frozen forced-repro run, plus an A1/float pass over `modelTickAnim`/`modelConstrainOrWrapAnimFrame` — ~half a day; (3) decision gate: boundary divergence found → port-side fix + per-entry conformance (static table + existing ai_19/ai_9 forced sweeps; c78 headless materialization is NOT available today — d320repro cannot spawn the proximity-spawned c78, so a "headless forced-derail seed sweep" of the Trevelyan beat as written in plan step 3 is infeasible without a ~1-day harness extension; cost it or drop it) → excision post-release; boundary hypothesis dead → upstream hunt with `GE_RSEED_LV` fallback (user sign-off for the single bounded emulator artifact); (4) D318/D320/D321 rewrite only on pass, per-entry.
+
+**Status:** review recorded; plan amended per above. No code changed. Cross-ref: D318 (mechanism + 2026-09-22 correction + probe evidence), D320 (sweep + forced repros), D321 (trigger chain), D34 (animationtable ROM-copy data path), D32 (ModelAnimation u32 fields), D94 (firing-table pointer arithmetic), porting-notes §A1 (ruled out for this chain by construction).
+
+## D329 — The D318/D320 guard "anim pin" is a tick-granularity divergence, not a platform-invariant lock: it exists only at 1 sim tick per rendered frame (the port at 60 fps); at ≥2 ticks/frame (GE's normal N64 regime) it cannot persist (lockstep A/B, 2026-09-23)
+
+**Question (M-199 step 2 decision gate).** Is the pinned ACT_ATTACK state behind D318 (Facility ai_22), D320 ai_19 (Facility) and ai_9 (Control) a lock that would also happen on N64, or a platform divergence? M-199 expected the former. It is the latter.
+
+**Mechanism (source, model.c).** `modelTickAnim` accumulates `frame += playspeed * speed` once per tick inside its `numticks` loop, then calls `modelSetAnimFrame2WithChrStuff(model, animframe1, frame, …)` **once per rendered frame**. That function only writes `framea`/`frameb` inside its per-frame loop, which runs only when `floor(frame)` crosses an integer boundary; its tail re-emits the stored pair and, when `vb == va`, sets `animframe1 = va` and discards all sub-frame progress. During the aim hold the endframe clamp (`modelConstrainOrWrapAnimFrame`: `frame = ceil(endframe)`) leaves `framea == frameb == H`. When `actor_fire_or_aim_at_target_update` then raises the endframe (`chrlvAttackActionRelated`, endframe only, no `modelSetAnimation`), the model can move again **only if one call advances by ≥ 1.0 frame**. Guard attack speed is 0.5–0.8 (`chrlvGetGuard007SpeedRating(0.5, 0.8)`; the fire gate also re-sets 0.5 via `modelSetAnimSpeed`). So:
+- `numticks` = `g_ClockTimer` = 1: advance 0.5–0.8 < 1, no crossing, stale pair re-emitted, pinned for ever.
+- `numticks` ≥ 2: advance ≥ 1.0, crossing, loop runs, `frameb` becomes `framea+1`, the anim plays on. **A single ≥2-tick frame breaks any such pin.**
+
+`g_ClockTimer = speedgraphframes` = 60 Hz ticks elapsed since the last rendered frame (`lvlManageMpGame`, `waitForNextFrame`). The port renders at 60 fps → 1 (D193 telemetry's "healthy" 60 render / 60 sim). GE on N64 runs gameplay at roughly 20–30 fps → 2–3. For the pin to survive on N64 the console would have to hold a steady 60 fps on every frame from the update onward, with guards on screen. This matches the user's spec (no such freeze on N64) without any PRNG, variant-lottery or reachability argument.
+
+**Evidence (lockstep A/B, WSL Linux build).** Harness: `GE_D318B_CLK=N` in the TEMP probe `port/src/d318bprobe.c` rewrites `g_ClockTimer`/`g_GlobalTimer`/`g_GlobalTimerDelta` in the existing lv.c hook (before any chr tick), so every rendered frame is exactly N ticks regardless of host speed. The probe log's `clk` field confirms it: 100% `clk=N` in every run. Same forced repros as D320 (`GE_D320R`), same PRNG path (the first variant drawn matches across N).
+
+| Run | ticks/frame | Post-update state | Watchdog |
+|---|---|---|---|
+| Facility ai_19 (c5) | 1 | `fr=19` exact, `fa=fb=19`, `ef=-1`, `spd=0.5`, frozen for ever | confirmed t=1561, then re-pin rescue every ~600 ticks (6×) |
+| Facility ai_19 (c5) | 2 | first post-update frame 19→20 (`fa=20 fb=21`), advances each frame | 6 detections, **0 confirms**; clean aim→fire→re-aim cycle |
+| Control ai_9 (c3) | 1 | `fr=23` exact, `fa=fb=23`, `ef=-1`, `spd=0.5`, frozen | confirmed t=1021, rescue (2) t=1679 |
+| Control ai_9 (c3) | 2 | 24→25→26… (`fa≠fb`) | 6 detections, **0 confirms** |
+
+Logs: `scratch/d318b_{fac,ctrl}_clk{1,2}.log`. Summariser: `scratch/d318b_summ.py`.
+
+**Corrections to earlier framing:**
+1. **Not "clamp at endframe".** After the update the endframe is often *unbounded* (`ef=-1`: rifle variants with `recoil_start = end_frame = -1`). The pin is purely the stale `va == vb` pair from the pre-update clamp, which only a ≥1.0-frame step (or a `modelSetAnimation` re-init) clears.
+2. **Weapon class does not decide it.** Both pinned guards carry an **automatic** weapon (`wr=3`). While pinned at frame == `shoot_start` the per-hand fire gate (`shoot_start <= frame < shoot_end`) is open, so the gun **fires every tick** (`unk44` = current tick). The guard "fires for ever and never finishes the animation"; it is not "never fires". The escape conditions all read false (`efle=0`: endframe unbounded; `trm=0`: `recoil_end < 0`; unk31 re-arm path unreachable). "Semi-auto with E>H = deterministic lock on any platform" (M-199 plan) is refuted as the explanation. At ≥2 ticks/frame no escape path is needed, because the model just plays on.
+3. **The M-199 "sub-frame settling at the fire-gate boundary" suspect is also refuted** (as the probe header predicted). Frozen values are exact integers and the gate is open, not closed.
+4. **Earlier WSL wall-clock repros are not calibrated evidence.** WSLg renders this game at ~5 fps, so wall-clock runs mix clk 1–6. Whether they pinned depended on host speed. D320's "PIN CONFIRMED" and ai_9's "~2/3 of seed variants" rates came from such runs. The first run of this session (42 detections, zero pins) is the same artifact in the other direction. `GE_DETERM=1` does **not** hold 1 tick/frame either (clock advances per VI-retrace request; measured clk 1–6), so use `GE_D318B_CLK` for any granularity-sensitive repro.
+5. **D318's variant split still holds, but only at clk=1.** E == H variants escape via the endframe re-init; E > H variants pin. That is where the ~2/3 PC rate comes from. At clk ≥ 2 every variant escapes (e.g. ai_22 hip-slow H=19/E=24 at speed 0.68: 19 + 1.36 crosses, plays to 24, normal re-init fires).
+
+**Float / A1 pass (M-199 task 3).** Nothing in the pin path is precision-sensitive: `floorFloatToInt`/`ceilFloatToInt` are truncation-based on both targets; the stored pair and endframe comparisons are integer-valued floats; the build uses no `-march`/`-ffast-math` (x86-64 baseline SSE2 single precision, no FMA contraction), and the R4300 has no fused multiply-add either. `Model`, `act_attack` and the firing tables are accessed by named fields (A1 ruled out, M-199). The divergence is purely the number of ticks folded into one `modelTickAnim` call.
+
+**Scope (implication, not yet swept).** The trigger is generic: any chr whose non-looping anim is clamped (`framea == frameb`) at an endframe that is then raised without a `modelSetAnimation`, at speed < 1. D320's "combat loops are likely safe" inference and the Depot ai_10 one-off freeze (c0 pinned at 33.00, atk=0x1) should be re-read in this light. The three-entry watchdog covers only the known scripted cases.
+
+**Classification.** A genuine port behavioural divergence caused by frame pacing (the port sim runs finer tick granularity per render than the N64 ever did), acting on byte-matched, non-associative game code. Not a data or ABI issue, not in the game logic's inputs. The N64-side claim "GE runs 2–3 ticks/frame in these scenes" rests on GE's well-known frame rate, not an in-session measurement. It is not needed for the PC-side A/B, but it is what connects the result to the user's spec.
+
+**Fix options (decision owed — user; none applied):**
+- (a) **Keep the table-driven watchdog** (ships in v0.4.0). Correct for the three known entries; blind to other instances.
+- (b) **Generic port-side pin breaker**: detect a chr model in the stale state (`framea == frameb`, non-looping, raised/unbounded endframe above the frame, speed > 0) and nudge it the way a 2-tick frame would. Port-side, but it writes game state from a hook (watchdog class).
+- (c) **Rule-2 edit in `modelSetAnimFrame2WithChrStuff`**: keep sub-frame progress when the per-frame loop does not run. This is the standard "high-frame-rate fix" pattern. It changes behaviour only where N64 would have taken ≥2 ticks. Needs sign-off.
+- Workaround available now (expected, not verified): `Video.FpsCap=30` gives 2 ticks/frame, which by the above makes the pin impossible.
+
+**Fix applied (2026-09-23, RULE-2-SIGNOFF: user approved option (c)).** `src/game/model.c` `modelSetAnimFrame2WithChrStuff`, `#ifdef PORT`: when the per-frame loop did not run, progress was requested, and `vb == va`, re-derive `frameb = constrain(va±1)` against the CURRENT endframe (as `modelSetAnimFrame` does); applied only if that next frame is unclamped. Genuine holds, anim ends and looping wraps are untouched. Env: `GE_D329=0` disables (A/B), `GE_D329LOG=1` logs triggers (cached getenv). **Verified (WSL, lockstep):** clk=1 with watchdog off (`GE_D318W=0`): Facility ai_19 and Control ai_9 no longer pin (frame 19→19.5→20…, 0.5/tick = the N64 clk=2 trajectory; aim→fire→re-aim cycle ≈192 ticks vs ≈195 at clk=2/3); authored holds intact. Trigger census: clk=1 Facility, 9 triggers, all on the repro guard, one per attack update; **clk=2: 0 triggers** over ~5300 ticks (Facility) and ~3600 (Control), so the fix is inert in the N64 regime. **Windows-native validation (2026-09-23):** Facility forced repro, native exe, `GE_D318B_CLK=1`, watchdog off: 48 post-update fire episodes, **0 pinned**, 23 D329 triggers. Bunker 1 golden (640x480 pinned, direct launch): REGRESSION with the fix ON (worst dmean 113.6) and equally with it OFF (`GE_D329=0`, 126.4), so it predates this fix (stale golden; re-baseline already owed). **Interactive playtest (2026-09-23, user, native Windows, shipping config + GE_D329LOG):** Ourumov reacts with N64-matching timing (user-confirmed on screen). Log: 4 D329 triggers (four guards, same tick, stale-pin state, released at 0.5/tick), 3 watchdog detections that cleared on their own, 0 watchdog rescues, no crash (`scratch/d329_playtest.log`).
+
+**Status:** FIXED + playtest-verified (2026-09-23). Next: demote the D318 watchdog to detect-and-log post-release (M-192 item 7), then remove the TEMP probes (d318bprobe, d320repro). Watchdog stays shipped on `release/v0.4.0`; plan to demote it to detect-and-log after playtests. Cross-ref: D318, D320, D321, M-199, D155, D186/D193.
+
+## D330 — #103 (v0.3.0, Win 11): Auto-Aim / Look Ahead "won't stay on" in the in-game watch options (2026-09-23)
+
+**Report:** "Look ahead and Auto-aim both won't remain 'on' in game or in menu options. They simply seem to not work?"
+
+**Root cause (port input layer, not game logic).** The in-level watch runs with `current_menu == RUN_STAGE`, so `inputComputePad`'s `menuMode` is 0 and the mouse took the in-stage look path. `hipDirectCompute` declines while the watch is open (`!outside_watch_menu || pause_state`, the GEPD `!watch && !pause` gate), and control fell through to the **legacy stick fallback**: `sx += dx * hipSens * MOUSE_TURN_GAIN` (defaults 0.5 × 1.0 × 6 = 3 counts/px/poll; SOLITAIRE also routes mouse Y to `sy`). The watch reads that stick exactly like an N64 stick:
+- `options.c game_option_toggle_input`: while an option is selected, stick X < −0x2D selects value 0 (OFF) and > 0x2D selects 1 (ON). `controlstick_lr_enabled` re-arms each time the stick recentres, so every mouse flick of about 15 px per poll re-selects a value.
+- With no item selected, the same stick flips watch pages, and mouse Y scrolls rows.
+
+So a player sets Auto-Aim ON with a key, touches the mouse, and it snaps back OFF. An RMB hold in the watch also ran the GEPD aim writes (`aimGepdCompute` has no watch gate; GEPD itself gates on `!watch`).
+
+**Ruled out.** Save/load round trip: `fileSaveSettingsForFolder`/`fileLoadSettingsForFolder` pack/unpack symmetrically (`OPTION_AUTOAIM 0x2`, `OPTION_LOOKAHEAD 0x10`). The save runs through the misnamed `deleteCurrentSelectedFolder` → `fileClearSavefileForFolder`, which really means "write the current watch options into the selected folder" and is called on watch close and on abort. The load runs at each stage start (`options.c:415`). This machine's `ge007.eep` has the bits set in every populated folder (`opts=003a`). No port code writes `game_options_entries`, `autoxaimenabled`, `autoyaimenabled` or `automovecentreenabled`.
+
+**Fix (`port/src/input.c`, port-only, no Rule-2).** A new branch in the mouse block, ahead of the aim/hipfire paths: while in-stage with the watch open or paused, the mouse contributes nothing. Keyboard (A/D = C-left/right, turn keys = stick) and gamepad still drive the watch exactly as on N64.
+
+**Verification.** Build clean. Bunker 1 golden 3/3 (no-input path unchanged). A runtime check of the mouse path needs real mouse motion (scripted input replaces the whole controller-0 source), so **a human must run it**: in a level, START → Control Options → select Auto-Aim → ON → move the mouse around → still ON; close and reopen the watch → still ON; next level → still ON.
+
+**Related but not changed (faithful behaviour worth knowing if the "doesn't seem to work" part persists):** look-ahead (`bondview2.c` ~6060) only arms on `analogWalk > 60` (analog stick walking) and cancels on any manual pitch. Under the port's default SOLITAIRE scheme, W/S are digital C-up/C-down and pitch is continuous mouse/right stick, so look-ahead rarely engages. That is N64 1.2/SOLITAIRE behaviour, not a port bug. Auto-aim's crosshair pull is overwritten by the GEPD aim-mode writes while RMB is held, which is the D194 design, but it still applies in hipfire.
+
+**Status:** FIXED, feel-check owed. Cross-ref: D194, D261, WI-1 (`hipDirectCompute`).
+
+**2026-09-25 playtest (user, Windows, one ~46-min session Silo → Caverns, `GE_D322=1 GE_D204=1`, build `7baf4900`):** watch → Control Options → Auto-Aim ON → mouse moved → stays ON. Feel-check done.
+
+## D331 — Ejected shell casings never rendered on PC (D264 pattern, third site) (2026-09-23)
+
+Found by the sibling-site sweep that D290 prompted (porting-notes §B "one layout bug often has sibling call sites"); never user-reported, likely because a missing casing is easy to overlook. `sub_GAME_7F068EC4` (`gunfire.c`, casing draw) initialises its render data with `*(ModelRenderData *)g_DefaultCasingModelRenderData`, where the global is `u32[15] = {0, 1, 3, 0…}` (`gun.c`): the N64 template basemtx NULL, zbufferenabled TRUE, flags 3. On PC `basemtx` is 8 bytes, so the words shift: zbufferenabled = 3, **flags = 0**, and `subdraw()` gates off every node. The copy also reads past the 60-byte array into whatever the linker put next. **Fix (`#ifdef PORT`, ABI/layout class, no Rule-2):** the explicit template, as in D264/D290. **Verified (native Windows, headless):** `-level_09`, `GE_INPUTSCRIPT="1000:Z;1020:Z"`, `GE_PCDUMP=850-1000:3`, shots at frames ~970/991. Before: impact sparks only, no casing. After: a brass casing ejects right of the PP7 and tumbles across frames 973–979. Bunker 1 golden 3/3; Windows + Linux builds clean. Sweep status: every `*(ModelRenderData *)` read in `src/game` is now covered (D215 :1592, D264, D290, D331). Cross-ref: D264, D290, porting-notes §A1/§B.
+
+## D332 — Reticule jitter in aim mode (RMB): the drawn crosshair steps because the game damps once per tick and we write once per poll
+**Symptom.** Holding RMB (precision aim), the reticule jumps around. Reported in issue #89 as "mouse movement is not uniform", and independently as a jitter that is worst under uneven frame pacing.
+**Root cause, measured from live captures.** `caclulate_gun_crosshair_position_rotation` (`src/game/gunfire.c`) runs once per **sim tick** and does `crosshair_x_pos = crosshair_x_pos * guncrossdamp + turn_x`. The GEPD aim path (`port/src/input.c`, `aimGepdCompute`) *overwrites* `crosshair_x_pos` once per **input poll**. So the value the game displays is `write * damp^k`, where `k` is the number of ticks between two of our writes — and `k` varies, because the poll cadence (per VI retrace) and the tick cadence drift in phase.
+
+Measured over 1266 aim frames from two captures, with the mouse still and the accumulator constant, `game/cross` took exactly three values (`damp^0`, `damp^1`, `damp^2`); `game = cross * damp^k` fits within 0.02 on 814/834 frames:
+```
+k=0: 72 k=1: 664 k=2: 89 k=3: 5 k=5: 3 k=6: 1
+ct=1 -> k=1 (226), k=0 (7) ct=2 -> k=2 (77), k=0 (68)
+```
+The drawn offset is that times `(1 - damp)`, so at `damp = 0.8` the reticule steps **20%** whenever `k` changes — on 16.5% of frames. (Diagnosed with `GE_INPUTLOG=1`, which logs the port's accumulator, the game's residue, `ct`, and the damp.)
+**Ruled out by the same capture:** auto-aim (`aa=0` throughout), stick turn (`st/sv=0`), camera edge-scroll (`cam` constant), and damp changes (only 5 KISSY/HONEY flips in 839 frames — real, each a ~43% rescale jump, but far too rare to be the main effect).
+**Two fixes tried and reverted.**
+- *Removing the D194(b) dt-normalisation* from the GEPD accumulator. Theoretically right — a position integral wants raw displacement, not a rate — but it did not stop the jitter.
+- *Pre-compensating `damp^k` with a predicted `k`.* Made it **worse**. `k` is not predictable at write time, and using the observed previous `k` is worse than doing nothing: simulating properly against the captures gives no compensation `mean|step| 0.0066`, lagged compensation `0.0170`, ideal `0.0005`. The first validation of this attempt was **circular** — it used `ct` as the number of damps, the very assumption the data contradicts.
+**Fix — adopt the Perfect Dark port's model.** `fgsfdsfgs/perfect_dark`'s aim code is structurally identical to GE's (`crosspossum` == `crosshair_x_pos`, `screenx` == `turn_x`, `crossdamp` == `guncrossdamp`, the same rescale block and display formula), but PD chooses the damp **per input device**: its mouse path calls `bgunSwivelWithDamp(x, y, 0.01f)` while its stick path uses `~0.945`. GE has one damp for both, and the weapon's `CrosshairSpeed` (~0.8) is a stick value — the integrator is what smooths a stick. At 0.01 the input dominates and `k` stops mattering.
+
+Implemented as `Input.PdMouseAim` (**default off**, because it changes aim feel): the port only accumulates (`aimGepdAccumulate`, shared with GEPD's model) and a `#ifdef PORT` block in `sub_GAME_7F067FBC` takes the port-supplied turn and sets `guncrossdamp = 0.01f`. It is an F10 overlay row ("PD mouse aim"), which is live — toggling it in game takes effect on the next poll, no restart — plus a `GE_PDMOUSEAIM` launch-time override for A/B runs without editing the ini. PD does the same thing from its own game files (`bondmove.c`/`bondgun.c`/`player.c`, behind `#ifndef PLATFORM_N64`) calling port functions, so a platform-guarded game hook is the reference's own approach.
+**Also fixed here:** the first cut of PD mode skipped `aimGepdCompute` entirely, which is where the camera **edge-scroll** lived, so dragging the crosshair to the screen edge stopped moving the camera. The edge-scroll belongs to the aim position, not to whichever model owns the crosshair — it is now `aimGepdEdgeScroll()`, called from both paths.
+**Owed:** sensitivity tuning (PD's 0.01 against GEPD's accumulator scale may not match PD's own calibration; `Input.AimModeSens` is the knob).
+**Integrated locally on `release/v0.4.0` (2026-09-23), from PR #96 (danturn; authorship kept).** Changes on integration: renumbered D337 → D332 (next free label); `GE_PDMOUSEAIM` lookup cached via `GE_ENVSTR` (it ran every input poll and every aim tick; see the getenv sweep `d9d7cd35`), and the "live env A/B without restart" comments corrected (a running process's env is fixed; the live toggle is the F10 row); the `gunfire.c` hook is a behavioural `#ifdef PORT` hook and got the maintainer's **Rule-2 sign-off 2026-09-23**. Scope checked: the hooked `sub_GAME_7F067FBC` runs only on the aim-mode (KISSY) branch of `bondview2.c`, so hipfire and auto-aim are unaffected. Windows build clean; golden 3/3 (default off). **Owed:** hands-on feel A/B (F10 → "PD mouse aim" on/off while holding RMB).
+
+## D333 — #104: FPS-style centred aim on RMB → `Input.AimStyle` selector (2026-09-23)
+
+**Request (#104):** with mouse and keyboard, RMB gives N64-style aim (crosshair travels, camera edge-scrolls). The reporter found that holding Q (bound to the game's L button) gives "PC FPS-style" aim, with the crosshair centred and the mouse turning the camera, and wanted that on RMB. **Why Q behaves that way:** Q sends `GE_CONT_L`, so the game enters its sight mode, but the port's `aimHeld` is RMB/`IA_AIM` only, so `inputComputePad` stays on the hipfire direct-look path (`hipDirectCompute`, WI-1). No stick is emitted, and the aim integrator (`pos = pos*damp + turn`, gunfire.c via the KISSY branch) settles the crosshair at 0 (centre).
+
+**Implementation (port-only, no game-code change):** one setting, `Input.AimStyle` 0–2, replaces D332's boolean in the F10 overlay ("RMB aim style"). 0 = **N64** (GEPD mirror, D194; default, unchanged). 1 = **PD DAMP** (D332). 2 = **CENTRED**: inside the RMB `aimHeld` branch, the mouse goes to `hipDirectCompute` (FOV-scaled, so zoomed weapons turn slower; the same watch/pause/cutscene gates) and no aim stick is emitted, which is exactly the Q behaviour on RMB. `Input.PdMouseAim=1` from PR-#96 builds migrates to style 1 on first use. `GE_PDMOUSEAIM` still forces PD on or off at launch.
+
+**Verified:** Windows + Linux builds clean; bunker1 golden 3/3 (default style 0). Not verifiable headlessly: the scripted input source has no mouse, and the overlay row sits below the fold at every window size (it uses the same `ROW_ENUM` machinery as Texture filter). **Owed (user):** F10 → RMB aim style → CENTRED, hold RMB and move the mouse: the camera turns, the crosshair stays centred, and zoomed weapons turn slower. Also compare PD DAMP against N64 for D332's jitter fix.
+
+## D334 — Native widescreen, world side (WIDESCREEN-FOV-PLAN Option B, Phase 2) (2026-09-23)
+
+**Approach:** GE already ships an N64 16:9 mode (`bondviewMovePlayerUpdateViewport`, bondview2.c): `aspect = (vp_w/vp_h) × 0.75 × 16/9`, an anamorphic pre-squash that a 16:9 TV undid. The port's canvas-to-window mapping (fast3d `RATIO_X = win_w/320`) is exactly that TV, so native widescreen is the same formula with 16/9 replaced by the real window aspect, via `portNativeAspect()` (`port/src/video.c`, clamp [0.5, 4.0]). Vertical FOV stays the game's; horizontal widens (Hor+, the PD standard). Because the game's canvas, portal scissors, culling (`currentPlayerSetPerspective` gets the same aspect through `viSetAspect`) and AI off-screen tests all stay in the 320×240 logical space with one consistent aspect, the GEVR "two notions of view width" trap (plan §6) doesn't arise. That's the same reason the N64 16:9 mode worked.
+
+**Game edits (`#ifdef PORT`, Rule-2 approved by the maintainer 2026-09-23), the complete set of GE's own 16:9 special cases, generalised:** (1) the camera aspect, NTSC and EU branches; the in-game "Ratio 16:9" option is ignored while native is on, since the window is the ratio; (2) the crosshair sprite half-width, where ×0.75 = (4/3)/(16/9) becomes (4/3)/aspect; (3) the rocket launcher's lowered viewmodel (`gunofs.y -= 3`) for aspect ≥ 1.55. **Port:** `Video.NativeWidescreen` (default 1, F10 "Native widescreen"); `portScaleFovY` skips the Phase-4 `sqrt(aspect/4:3)` vertical boost while native is on (it would double-count); `portRoomPoolScale` keeps its aspect term (the horizontal frustum still widens, D294).
+
+**Verified (native Windows, headless):** 640×480 golden 3/3, so 4:3 is identity by construction (aspect 4/3 → ×1). Quantitative check: the centre 960 columns of a native 1280×720 frame vs a 960×720 (4:3) frame of the same moment differ by mean 4.3 (run-to-run noise), against 12.4 for the old stretched+boosted mode squeezed back. 21:9 (1680×720): Bunker intro + gameplay, Facility intro room and Dam gorge all render edge to edge with no scissor slabs, missing rooms or sky gaps. Windows + Linux builds clean. **Not yet native:** HUD, text and 2D overlays (level title card, ammo counter, watch) are still stretched with the canvas. That's Phase 3 (EXT-aspect wraps). The front-end menus' fixed 3D (`viSetAspect(ASPECT_RATIO_SD)` in front.c) is still stretched too. **Owed (user):** feel check at 16:9/21:9 (aim zoom, rocket launcher, cutscenes).
+
+## D335 — Native widescreen, HUD + front end (WIDESCREEN-FOV-PLAN Option B, Phase 3) (2026-09-23)
+
+**Mechanism:** fast3d already decodes PD's `G_EXTRAGEOMETRYMODE_EXT` aspect modes (`gfx_update_aspect_mode`). Under a mode, clip-space vertex x (`gfx_pc.cpp` vertex path, so 3D-projected HUD too) and rectangle x are scaled by `(4/3)/window_aspect` and anchored LEFT, RIGHT or CENTER; scissors are adjusted to match. `port/include/hudaspect.h` (game-includable) adds `gSPHudAspectEXT` (via `gDma0p`; the decoder clears `~(w0 & 0xFFFFFF)`, so passing `~MASK` swaps only the aspect bits) and `PORT_HUD_ASPECT(gdl, mode)`. That emits only while native widescreen is on **and** the window is wider than 4:3. At ≤ 4:3 nothing is emitted (byte-identical); narrower windows keep the squeezed canvas mapping rather than cropping.
+
+**Wraps (`#ifdef PORT`, Rule-2 approved):** gameplay HUD in `maybe_mp_interface` (bondview2.c): watch (`bondviewRenderWatch`), gauge bars (both sites), bottom messages / level title card (`hudmsgBottomRender`, both camera modes), upper text window (`sub_GAME_7F08AAE8`) and countdown timer, all CENTER. Ammo (`generate_ammo_total_microcode`, gunfire.c): right-hand block RIGHT, left-hand (dual-wield) block LEFT. Front end: all of `menu_jump_constructor_handler` (front.c) CENTER, i.e. pillarboxed 4:3, and `input.c` maps the absolute menu pointer across the centred region instead of the window. **Deliberately unwrapped:** the crosshair (its position lives in the world-projection space; only its sprite width is corrected, D334), full-screen fades, blood and damage overlays (should cover the window), and the world.
+
+**Verified (native Windows, headless captures):** 21:9 gameplay: ammo counter right-anchored and undistorted; title card undistorted; watch round, inventory and control-options pages (incl. the D290 pad) undistorted. 16:9 front end: legal screen and file select pillarboxed and undistorted; gun-barrel spiral clips at the 4:3 edge. 640×480 golden 3/3; Windows + Linux builds clean. **Known cosmetic:** gun-barrel circle leaks into the bars while sliding in (GRAPHICS-BACKLOG D335a). **Not yet wrapped (next pass, needs live checks):** end credits (`bondviewRenderCredits`), sniper/zoom overlays inside `gunDrawSight`, MP radar (MP out of scope). **Owed (user):** full HUD pass at 16:9 and 21:9 in play (pickup messages, objectives, health bars, dual-wield ammo, mission complete/failed screens, menu pointer accuracy).
+**D335 follow-up (2026-09-23):** end credits (`bondviewRenderCredits`, camera-mode-1 path) wrapped CENTER. Verified at 16:9 via `-level_54`: ending cutscene subtitles undistorted, cast roll inside the 4:3 region. `gunDrawSight` checked: it draws only the crosshair sprite (GE has no scope mask), which D334 already corrects, so no wrap is needed. **D335a resolved:** a full-canvas scissor after the front-end CENTER wrap clips the gun-barrel backdrop at the pillarbox edge (capture-verified).
+**D334/D335 16:9 smoke sweep (2026-09-23, native Windows, 1280×720, defaults):** 16 further levels (Runway, Surface 1, Silo, Frigate, Bunker 2, Statue, Archives, Streets, Depot, Train, Jungle, Control, Caverns, Cradle, Aztec, Egypt; plus Bunker 1, Facility and Dam earlier), `GE_PCDUMP` at an intro frame (400) and a gameplay frame (1000). 16/16 render both frames, 0 crash lines. Contact sheets eyeballed: world edge to edge with no scissor slabs or missing rooms, skies full width, intro captions and Archives' dialogue box undistorted inside the 4:3 region, ammo counter right-anchored, Streets' countdown timer undistorted.
+
+## D336 — "All polygons break except the gun" on Frigate after another level: uninitialised head-anim alias fields (A1 / D100 follow-up) (2026-09-24)
+
+**Report (user playtest):** load Runway (or Dam/Surface/Silo…), exit, load Frigate, look around, and everything but Bond's gun breaks for the rest of the session. The `D156` bad-anim guard fired in those sessions.
+
+**Repro (headless, native Windows):** `GE_STARTMENU=10 GE_STARTMENU_PAGE=3` (Runway briefing) → scripted watch Abort → TEMP `GE_D235_NEXT=26` re-entry into Frigate → sustained `SLEFT`/`SRIGHT` turn. The user's ini (DrawDistance 400, LodDistance 400, FOV 120) gives D294's room pool its 2.0× cap. After the turn the world renders black except the gun, and D156 fires. `GE_ROOMPOOL=1.0` (authored pool) is clean. The pool size was the trigger, not the cause.
+
+**Elimination:** stage bank not exhausted (3.8 MB free, `GE_D294BANK`); texture pool full-size (768 KB) with 0 pool-full events (new `D85TEX` pool-size line); no mema alloc/free outside the heap and no room-GDL/room-total overflow (TEMP probes, removed). The D156 victim was always **heap end + 0x838**, i.e. `&player->model` (the player struct is allocated right after the room heap). Hardware watchpoints (gdb Python, `scratch/watch2.py`) on the model's anim fields caught the poisoned value arriving through legitimate code: `MoveBond → bondviewMoveAnimationTick → bheadAdjustAnimation (bondhead.c) → modelSetAnimation → modelSetAnimFrame(frame=2.07e32)`. The startframe comes from `g_CurrentPlayer->field_5C0`, and a watchpoint on that field saw **no writes at all**.
+
+**Root cause (ABI/layout, A1 raw-offset alias, D100 follow-up):** on N64 the player's `model` at 0x598 is an inline `struct Model`. The decomp split it into `Model *model;` plus placeholder fields, so `animFlipFlag` (0x5BC) is `model+0x24` = **`model.gunhand`** and `field_5C0` is `model+0x28` = **`model.animframe1`** (4-byte pointers, no holes; the neighbouring `unk25`/`unk27`/`unk2c` names confirm). D100 made `model` a real inline struct on PC, so those placeholders became separate fields that nothing ever initialises. `bheadAdjustAnimation` computed Bond's head-bob start frame from uninitialised player-struct bytes. Where the player struct lands depends on the room-pool size (D294 2× moves it) and on what previously occupied that memory (hence the level-order dependence). Garbage like 2e32 poisoned Bond's model anim state and wrecked the scene. A sweep of all `field_59C..field_650` placeholders found these two as the only reads.
+
+**Fix (`src/game/bondhead.c`, `#ifdef PORT`):** `BHEAD_CURFRAME` = `model.animframe1`, `BHEAD_FLIPFLAG` = `model.gunhand`, the exact bytes the N64 code touches. No logic change.
+
+**Verified:** the same Runway → Frigate → turn repro at the 2× pool renders correctly throughout, D156 0 fires; bunker1 golden 3/3; Windows + Linux builds clean. **D311 link:** D311 (upstream anim-poison source, open) named `bheadAdjustAnimation` as the one path where a corrupted value reaches `model->speed`. This is very likely that source; re-check D311's Caverns signature in play before closing it. **D294 note:** its "linear 3.0× variant corrupted model anim state on the menu-driven path" is this same bug (a bigger pool moved the player struct onto dirtier memory), not a pool-size problem. The 2× cap can be revisited. **User-confirmed (2026-09-24, native Windows, shipping settings incl. DD/LOD 400):** "Looks good when doing Facility > Frigate and in same session Runway > Frigate." Session log: 14 level loads (Bunker 1, Facility, Silo, Dam, Runway, Frigate…), **0 D156 fires** (every prior session had ≥1), 0 texture pool-full.
+
+## D337 — Aim modes folded; Q/L + Toggle routing; per-device mouse integration; headless aim measurements (2026-09-24)
+
+**User decision:** one N64 aim mode (the PD-integration of D332, made default) plus the opt-in "Centred (PC)" mode from #104. The old GEPD overwrite survives only as the hidden `Input.AimLegacyGepd=1` for one release. **Setting:** `Input.AimMode` 0 = N64, 1 = Centred (PC). Legacy `Input.AimStyle` 2 migrates to 1; `AimStyle` 0/1 and `Input.PdMouseAim` map to N64. F10 row "Aim style". `GE_PDMOUSEAIM=0/1` still forces the path at launch.
+
+**#104 origin, corrected:** the reporter was describing the port's existing Q behaviour, not requesting a new design. In GE's 1.1/1.2 control schemes the aim buttons are `L_TRIG | R_TRIG` (bondview2.c `aimButtons`), so Q ("LeanLeft", which sends L; the bind name is a misnomer) is a second aim button. The port's mouse routing only recognised RMB/Shift, so Q put the game in aim mode while the mouse kept turning the camera: an accidental "FPS aim". **Fix:** mouse aim routing is now "RMB/Aim bind **or** Q/L **or** the game's `insightaimmode`". That also covers the Aim Control "Toggle" option and gamepad aim, which the held-button check never did. The R emit stays tied to the physical button, so Toggle still sees edges. Verified: `LHOLD` + scripted mouse moves the drawn crosshair N64-style (160 → 280 px) with the camera turning only via edge-scroll.
+
+**Device-aware integration (would have been a regression when made default):** the D332 game hook replaced the stick's turn whenever the mode was on and the mouse was grabbed, freezing a gamepad or keyboard user's crosshair. PD chooses its damp per input device; the port now tracks the last-used aim device (mouse motion sets it; pad stick deflection or keyboard turn keys clear it), and the hook feeds the mouse integration only while the mouse is active.
+
+**Headless measurement harness (new):** `GE_INPUTSCRIPT` tokens `MDX`/`MDY` (sustained scripted mouse px per poll, treated as a grabbed mouse), `RHOLD`/`LHOLD`/`RREL` (sustained aim hold), and `GE_AIMLOG=1` (per-poll: aim state, active device, the game's drawn crosshair x `crosshair_angle.f[0]`, `crosshair_x/y_pos`, camera angles). **Results (Bunker 1, 640×480, 3 px/poll sweep):**
+- *Screen range and edge-scroll, old GEPD vs new:* the drawn offset is `crosshair_x_pos × (1 − damp) × W/2` (gunfire.c), so GEPD's raw ±5.16 at damp 0.8 and PD's ±1.0 at damp 0.01 are the same screen edge. Sweep speed on screen 1.00 vs 1.21 px/poll; edge-scroll starts at 0.745 vs 0.719 of half-width; edge-scroll rate identical (1.478°/poll, sd 0). Equivalent.
+- *Jitter:* drawn-crosshair cv 0.18 (GEPD) vs 0.16 (new), one stall and one jump each in 75 polls. The GEPD step jitter needs irregular ticks per poll (real vsync pacing), which this headless run rarely produces, so the jitter benefit rests on D332's analysis plus hands-on feel, not this test.
+- **Accuracy finding (open, user decision):** N64 stick aim turns the crosshair by `stick × 0.65/80` per tick, so at full deflection the drawn crosshair settles at **65% of the half-width regardless of weapon damp**, and the camera starts turning past stick 60/80 (≈49% of half-width). Both mouse paths let the crosshair travel to about 100% of the half-width, with edge-scroll from about 72%. A faithful calibration would map the mouse range to ±65% and start the camera turn at ≈49%.
+
+**Verified:** golden 3/3; Windows + Linux builds clean. **Owed:** user feel pass (N64 default vs Centred; Q now aims); decision on N64-range calibration.
+
+## D338 — Aim range option: PC (default) vs N64 original limits (2026-09-24)
+
+**User decision (D337 follow-up):** keep the GEPD / mouse-injector feel (the crosshair reaches the screen edge, edge-scroll from 72%) as the PC default even though it isn't N64-exact, and offer the original behaviour as an option. **`Input.AimRange`** 0 = PC, 1 = N64 (F10 "Aim range", hidden while the aim style is Centred (PC)). N64 derivation: aim turn is `stick × 0.65/80` per tick, so full deflection settles at a drawn offset of 0.65 × half-width regardless of weapon damp (`screen = pos × (1−damp) × W/2`, gunfire.c), and the camera turns past stick 60/80 = 75% of that range. Implementation: the mouse accumulator is unchanged; its screen mapping is scaled by 0.65 (PD turn and legacy GEPD write alike) and the edge-scroll threshold moves to 0.75. The non-aim "adopt" of the game crosshair into the accumulator now converts through the game's screen mapping and the range scale, so aim entry never jumps (it copied raw units before, a small pre-existing mismatch on the PD path). **Measured (GE_AIMLOG harness, 3 px/poll sweep):** PC max drawn x 316/320 (98%, the game's 4 px edge clamp), camera turn from 72%; N64 max 264 (65%), camera turn from 237.9 px (49%); aim entry at 160 (centre) in both. Golden 3/3; Windows + Linux builds clean.
+**D337/D338 user feel check (2026-09-24):** "Seems good." The session exercised the legacy-key migration live: the user's ini had `AimStyle = 2` (Centred) from D333 testing and loaded as `AimMode = 1` as designed. User's ini then set back to `AimMode = 0` (N64, their stated preference), `AimRange = 0` (PC).
+
+## D339 — #92 low-end performance: CPU budget measured, GPU the likely bottleneck; GE_PERFSTAT probe (2026-09-24)
+
+**Report (#92, filed by the maintainer for a user):** Celeron N3060 (2C, 1.6 GHz) + Intel HD 400 (Bay Trail), Windows 10: the port runs ~30 fps, "a bit higher in enclosed areas", while the PD PC port holds 60 on the same machine.
+
+**Probe (`GE_PERFSTAT=1`, env-gated, zero cost when unset):** `port/fast3d/gfx_pc.cpp` averages over 300 frames: `pre` (frame setup), `dl` (display-list pass = software RSP + GL draw submission), `post` (MSAA resolve / end_frame), `swap/pace` (swap_buffers_begin, which contains the D186 frame-pacing wait), triangles and draw batches per frame. `src/boss.c` reports game-thread busy time per scheduler message (~2 messages/frame). `port/fast3d/gfx_opengl.cpp` adds a double-buffered `GL_TIME_ELAPSED` GPU timer (logged with `GL_RENDERER`/`GL_VERSION`).
+
+**Measured (this machine: RTX 5090, fast desktop CPU; 1280×720, defaults):**
+
+| Level | dl (ms) | tris/frame | batches/frame | game thread (ms/frame) |
+|---|---|---|---|---|
+| Bunker 1 | 0.32–0.39 | ~560 | ~99 | ~0.2 |
+| Dam | 1.24 | ~2,700 | ~233 | — |
+| Streets | 1.69–1.71 | ~3,080 | ~411 | ~0.35 |
+| Egypt | 1.77–1.79 | ~3,560 | ~322 | ~0.3 |
+
+`pre`/`post` ≈ 0; `swap/pace` ≈ 2 ms is the pacing wait, not work. **CPU cost scales with scene complexity** (matching the reporter's "higher in enclosed areas"), but totals only ~2.1 ms/frame at the heaviest. Scaled for an N3060 (~5× slower per core, logic and render on separate threads), that's roughly 2 + 9 ms on two cores, which fits in 16.7 ms. CPU is therefore unlikely to be what pins the reporter at 30. **GPU is the prime suspect:** an HD 400 at native resolution with the port's defaults (MSAA 4×, 16× anisotropic, the 3-point filter shader) against the PD port's lighter defaults. **GPU timer caveat:** on this RTX 5090 the query read 1.7 ms at defaults and 4.1 ms at MSAA 1 / aniso 1 / bilinear, an inversion that shows the timer also captures swap-chain back-buffer waits on the direct (non-FBO) path. It is only meaningful on a GPU-bound machine (it would then read close to the frame time).
+
+**Next (needs HD 400-class hardware, not reporter logs):** run `GE_PERFSTAT=1` on a comparable machine at defaults vs MSAA 1 / aniso 1 / bilinear; if GPU-bound, consider a first-run low-end preset (the Steam Deck preset, `da31edaa`, is the precedent) keyed on the GL renderer. CPU-side headroom if ever needed: batches per frame (100–411) is the obvious lever (state-change batching in `gfx_flush`).
+
+## D341 — Intro gun-barrel blood drip draws as static blocks/lines instead of dripping (M-201, 2026-09-26)
+
+**Report:** the 2D red blood "drip" at the end of the gun-barrel intro glitches and doesn't draw right; the death blood looks normal.
+
+**Measured:** `die_blood_image_routine` builds a correct 96x80 I4 drip image each step (dumped and rendered: a growing top band with drips); `gunbarrelBloodOverlayDL` draws it as a full-screen 1-cycle texture rectangle at the title's 440x330 VI (dsdx 223, dtdy 248, tile 0 = I4 96x80, `GE_D116` confirmed). The screen showed a constant pattern of red blocks and horizontal lines that never changed.
+
+**Cause 1 (the wrong image):** texture-unit 0 imported from **tile 1** (`unit=0 tile=1 cyc=1CYCLE lod=1`): the title leaves `G_TL_LOD` set from earlier draws, and `gfx_lod_tile_offset`'s D236 detail-base rule (tile 1 in different TMEM with a different format → sample tile 1) redirected the rectangle to a stale CI8 tile-1 declaration sharing TMEM 0 — 128 bytes of the blood buffer decoded as CI8 and stretched. On the RDP, LOD tile selection only exists in 2-cycle mode; a 1-cycle texture rectangle samples exactly its command's tile. Fix: `gfx_lod_tile_offset` returns `i` while a texture rectangle is drawing outside 2-cycle mode (`s_in_texrect`, set around `gfx_draw_rectangle` in the texrect + wide-texrect paths). 3D triangle paths (D107/D236) are unchanged.
+
+**Cause 2 (frozen animation):** the image is regenerated in place in the per-frame dynamic pool, at the same address every step, and reached via `OS_K0_TO_PHYSICAL` → the KSEG0 mirror (`0x800a8f20`). The GL texture cache keys non-CI textures on address only, so after the first import every later step was a stale hit. Fix: textures in the dyn pool (`[g_VtxBuffers[0], g_VtxBuffers[2])`, with the 0x80000000 mirror normalised to V1) get an FNV-1a content hash in the key. A/B with `GE_DYNTEXHASH_OFF=1`: blood coverage stays at 1% (frozen); with it, the drip grows.
+
+**Verified:** headless boot capture — the blood now runs down from the top over the gun barrel as on N64 (`scratch/blood_after.png`). Golden gate 3/3. User visual check owed.
+
+**Status: FIXED (M-201, user-verified 2026-09-27).**
+
+## D342 — File select: folder bodies and Bond photos vanish after selecting a file and backing out (M-201, 2026-09-26)
+
+**Report:** select a file, back out to file select: the folders draw only their outlines; bodies and Bond photos are gone. Reproduced headless (`SkipIntro=1`, `GE_INPUTSCRIPT="400:A;700:B"`).
+
+**Cause (layout-only, A1-adjacent):** the file-select folders are `PROP_WALLETBOND` model instances loaded at the start of `ptr_logo_and_walletbond_DL`. On PC that model expands to 0x1664C bytes and was given a 0x17000 reservation (D45) instead of N64's 0xA000, but two users of the space AFTER it kept the N64 offset `4096*10` (+0xA000): `init_menu05_fileselect`'s scratch DL (budget 0x6e000) and `load_briefing_text_for_stage` (briefing data). On PC both write into the middle of the wallet model. `load_walletbond` only reloads the model when the instances were freed (coming from the legal screen), so after main menu → back, the overwritten model was drawn: outlines intact, body/photo geometry corrupt.
+
+**Ruled out first:** GL texture-cache staleness on pool reuse (a queued texLoad-address invalidation made no difference; reverted).
+
+**Fix (`src/game/front.c`, `#ifdef PORT`):** both sites use `ptr_logo_and_walletbond_DL + 0x17000`. The budget is unchanged: 0x85000 (PC buffer) - 0x17000 == 0x78000 - 0xA000 == 0x6e000.
+
+**Verified:** user playtest (select/back out repeatedly, folders and photos intact); golden gate 3/3.
+
+**Status: FIXED (M-201, user-verified 2026-09-27).**
+
+## D343 — File select: PC "Options" label + F10 idle-timer fix (M1, 2026-09-27)
+
+**Feature (v0.4.0 intake):** PD PC reaches its extended settings through its own menus (Options > Extended). GE has no generic menu system (each `front.c` screen is hand-written) and no front-end options screen, so this is "route B" from `docs/dev/OPTIONS-MENU-PLAN.md` §4: a native-looking entry point on the file select screen. M1 adds the entry point and reuses the existing F10 overlay; M2 (a GE-styled screen) is next.
+
+**Game-code edit (Rule 2, user sign-off 2026-09-27, a feature not an ABI fix):** one additive `#ifdef PORT` block in `constructor_menu05_fileselect` (`src/game/front.c`), right after the Erase label, calling `optionsFileSelectLabel(DL)`. Nothing else in `src/game` changed. Drawing inside GE's own frame means the label fades with screen transitions and gets the D335 pillarbox for free.
+
+**Port side (`port/src/optionsoverlay.c`, `port/src/input.c`):**
+- The label is ASCII "Options" (issue #87 / D295) in Zurich Bold, right-aligned with the Erase text (x 392) at y 24, in the dark strip above the right-hand folder. The bottom row is full; the cursor can reach this spot (clamp 20..420 x 20..310). White normally, gold (`0xEBD879FF`, the folder text colour) when hot.
+- Hot = cursor over the text (+4 px), overlay closed, no pending menu change (`menu_update == -1`), and no erase confirmation open (`folder_selected_for_deletion < 0`). The label is clear of every folder and Copy/Erase hit box, so a press on it only triggers front.c's "clear the Copy/Erase mode" path.
+- The click is A/Z/Start via `joyGetButtonsPressedThisFrame` (the game's interface tick runs before the constructor each frame, so the latched press is valid). A left click is A in menus. It plays `DOOR_LOCK_SFX` (222, Copy/Erase's sound).
+- **Threads:** the hook runs on the game's main thread, but the overlay's open flag is read by the scheduler thread (input poll + `gfx_run`). So the hook only posts an `SDL_atomic_t` request, which `optionsOverlayPollRequests()` applies from `inputComputePad(0)` (the same shape as the D314 wheel queue).
+- `textMeasure` returns a height of 0 for text without a trailing newline, which collapsed the first hit box to one row. The height is now measured from `"Options\n"`.
+
+**Bug fixed along the way (existing since F10 landed):** while the overlay is open it gives front.c a neutral pad, so `interface_menu05_fileselect`'s idle timer (`g_MenuTimer`, `front.c:~2304`) never resets. After 30 s the screen went to the legal screen and the intro attract loop. The hook now sets `g_MenuTimer = 0` while the overlay is open, which is the same write front.c makes when a button is pressed. This is scoped by construction: the hook only runs on MENU_FILE_SELECT, the one screen where `g_MenuTimer` is an idle timer. Elsewhere it is the intro/cast-roll animation clock and must keep running, so a blanket "front-end menu" gate would have frozen the intro logos under F10.
+
+**Verified (Windows native, 640x480 defaults-only ini + `MouseEnabled=0`):**
+- Click: `GE_STARTMENU=5 GE_INPUTSCRIPT="300:SUP,SRIGHT;360:SNONE;370:SLEFT;376:SNONE;420:A"` puts the cursor at (386,20), hot=1, press, and logs `optionsoverlay: opened`. The frame shows the overlay over file select.
+- Idle A/B (`GE_OPTIONSOVERLAY=1`, 45 s, TEMP switch since removed): with the fix, `current_menu` stays 5 for the whole run. Without it, 5 → 23 → 0 (legal) → 1 → 2 (logos).
+- With the overlay closed, the file-select frame differs from the pre-change frame only in the label box (788 px, x 500-571 / y 35-55 at 640x480). There is no file-select golden; the golden gate (Bunker 1) is 3/3.
+- Captures at 854x480 (16:9) and 1120x480 (21:9) show the label and highlight correctly pillarboxed.
+
+**Harness note:** in headless menu runs the menu pointer follows the real OS mouse (WI-2 absolute tracking), so the maintainer's desktop mouse moves the in-game cursor mid-script. Set `[Input] MouseEnabled = 0` in the test ini (porting-notes §E).
+
+**Owed:** a user playtest: mouse hover/click and pad on the label, F10 and label opening the same overlay, leaving it open for more than 30 s, and 16:9 / 21:9. Then M2 (a GE-styled screen) per the local plan.
+
+**User playtest (2026-09-27):** mouse hover and click, F10 and label opening the same overlay, open for more than 30 s, and widescreen all confirmed. Controller not yet confirmed. (Headless scripted stick + A drives the same game-side path; only the SDL pad read, which is existing code, is uncovered.)
+
+**Status: LANDED (M1), user-verified except a physical controller.**
+
+**M2 (2026-09-27): GE-styled options panel.** The label now opens a panel drawn in the same hook (`port/src/frontoptions.c`, new; the label code moved there from `optionsoverlay.c`):
+- A dark sheet over the folders with gold rules, "PC OPTIONS" in Bank Gothic, and one Zurich tab per section of the F10 overlay's row table (Display / View / Mouse/Aim / Controller / Game), spread to fit the panel. Rows show label + value, slider bars, and "(restart)" for MSAA; a footer has "L / R: section" and a clickable Back. The bottom bar (SELECT FILE / Copy / Erase) and the game's own crosshair stay visible.
+- Rows, values and live-apply come from a small exported row API in `optionsoverlay.c` (`optionsRow*`), so F10 and the panel edit the same settings. The panel keeps its own page and selection state.
+- **Input:** while the panel is open on file select, `input.c` passes the stick through (so the mouse or stick moves front.c's crosshair, the panel's pointer, exactly as in GE's menus) and hands the buttons to `frontOptionsFeedPad()`; front.c gets 0. Edges are published with atomics; all UI state lives on the game thread, in the hook. Controls: the crosshair highlights rows and tabs; A/Z/Start (click, Space) acts on the highlighted row (toggle/cycle/step, or set a slider from the crosshair's x and drag); D-pad or C up/down moves the highlight; D-pad or C left/right (keyboard A/D) adjusts with hold-repeat; L/R switch section; B (right-click, Esc) or Back closes and saves the config.
+- Two bugs found and fixed by headless scripts: (1) A acted on the row under an idle crosshair instead of the D-pad's highlighted row; (2) the press that opened the panel leaked in as a first press and toggled Fullscreen. The panel now ignores buttons until they are released once.
+- The hook calls `microcode_constructor()` after the panel, so the Copy/Erase icons and the cursor drawn after it keep their render state.
+- **Verified headless (self-quitting runs, D344):** panel render at 640x480; tab/section switching, D-pad select + A toggle (Mouse invert Y OFF to ON, nothing else changed), B close; label click opens it; open for 2450 frames with no legal-screen drop; clean exit every run. User playtest 2026-09-27: works on PC (mouse/keyboard, window close, Alt+F4). Owed: physical controller; widescreen captures of the panel.
+
+**Route B, final (2026-09-27, user sign-off for both game-code edits): the panel above is RETIRED.** Two reviews flagged the panel's weak points (a sub-state of file select, a button hand-off in input.c, a per-frame g_MenuTimer write). PC options are now a real GE front-end screen, `MENU_PC_OPTIONS`, built like the Cheat Options screen:
+- **Game code (all `#ifdef PORT`):** one enum value before `MENU_MAX` (`src/bondconstants.h`; nothing is sized by `MENU_MAX`); five one-line dispatch cases in `front.c` (debug name, update, init, interface, constructor); `front.c` includes `frontoptions.h`.
+- **Screen (`port/src/frontoptions.c`):** `frontOptionsMenuInit` copies `init_menu15_cheat` (tab state reset, `load_walletbond()`) and sets `selected_folder_num` to folder 1, as a return from mode select does, because `frontSetupMenuBackground` frames `folderpositions[selected_folder_num]`. The interface sets the view like the cheat screen, highlights rows from the crosshair's y (not while A is held), and uses A/Z/Start to open a section, toggle/cycle/step a row, or set and drag a slider from the crosshair's x. D-pad/C left-right (keyboard A/D) adjusts with hold-repeat; B or the PREVIOUS tab goes back (section to list, list to file select, which saves the config). The dossier is shown with tabs + blank CLASSIFIED page, the cheat screen's switches. The constructor draws the black fill, `frontSetupMenuBackground`, black Zurich ink via `frontPrintText`, GE's 0x32 highlight boxes, ON in the cheat red (0xA00000FF), slider bars as two menu boxes, then `frontAddPreviousTabText` and `frontDrawCursor`.
+- Page 0 is a numbered section list ("1. Display" ... "5. Game", title case from the row headers); page 1 lists one section's rows under a title.
+- `input.c`'s M2 button hand-off is removed. F10 and Select don't open the overlay while `MENU_PC_OPTIONS` is up. The file-select hook keeps only the label and the F10 idle-timer fix.
+- **Bottom bar (`constructor_menu05_fileselect`, `#ifdef PORT`):** Copy text/icon 247/225 moved to 222/200 and Erase 357/335 to 304/282, making room for "PC Options" at the right end of the same bar (y centred on 285). The click boxes follow the draw positions. The label is drawn by the port hook at `max(352, Erase bound.right + 14)`, so a wider localised Erase (JP) pushes it right instead of overlapping. The old top-right label is gone.
+- **Verified headless (self-quitting runs):** `GE_STARTMENU=26` boots the screen; a script opened section 2 (View), went back to the list, then back to file select, confirmed by log lines. A scripted crosshair click on the bottom label opened the screen once, and the click did not carry into it. Captures at 640x480, 854x480 and 1120x480 are pillarboxed correctly. The Erase click box (to ~338) clears the label's (from 348). Golden gate 3/3.
+- **Not verifiable here:** PAL/JP captures, because only the NTSC ROM is present. PAL shares the canvas, strings and constants; JP relies on the dynamic label x.
+- **Owed:** user playtest (mouse, keyboard, pad; music/ambience on entering and leaving; "PC Options" sits close to the right edge, ending about x 428 of 440); a physical controller.
+
+**Closed 2026-09-27.** User visual pass done: label placement accepted, mini slider glyph added beside it (D348), screen navigation fixed and verified (D345), wording aligned to Turok/Nightdive (D346/D346b); music on enter/leave unremarked. NTSC reference frames captured + pixel-verified headless (PCDUMP, incl. the D348 glyph: 135 knob px vs 9 paper). PAL/JP visual verification not capturable — only an NTSC ROM is present locally; layout is font-width-driven and region-independent except label width, so risk is low. Subsequent user physical-controller pass on the bottom-bar label and options-screen navigation: all good.
+
+
+
+## D344 — Host PC bugchecks (0x119) during game runs: unclean multi-threaded quit (2026-09-27)
+
+**Report:** the maintainer's PC blue-screened twice while headless test runs were going (10:17 and 10:22), and once before (2026-09-14 14:38, during M-123 captures). All three are bugcheck **0x119 VIDEO_SCHEDULER_INTERNAL_ERROR, parameter 1 = 0x3000**, raised by the Windows GPU scheduler (dxgkrnl) on an invalid driver state. No minidump was written (volmgr 161, "Dump file creation failed"). Host: RTX 5090 (driver 2026-09-03) + AMD iGPU + Parsec virtual display + Microsoft Remote Display adapter; hardware-accelerated GPU scheduling on (`HwSchMode = 2`). The first crash hit while `data/ge007.ini` was being restored, leaving it as 1465 zero bytes (restored from backup).
+
+**Evidence (event logs only; runs were stopped):**
+- Both of today's crashes fell within seconds of the harness ending a run with `timeout`: the ini restore that runs right after the kill never reached disk, and the second reboot came about 30 s after its kill was due.
+- A killed run is not an abrupt kill: SDL turns the signal into `SDL_QUIT`, the logs end with `config: wrote data\ge007.ini`, and the process then ends in `abort()` (WER holds thousands of ge007 `0x40000015` reports at shifting in-binary offsets, plus older `0xC0000374` heap-corruption reports; Windows has applied the Fault Tolerant Heap to ge007). The teardown is not clean even when nothing crashes.
+
+**Probable cause (medium confidence, no dump):** the GL context is created on the host thread but bound to the scheduler thread, which does all rendering and `SDL_GL_SwapWindow` (`gfx_sdl2.cpp`). Quit was `exit(0)` straight from whichever SDL pump dequeued the event: the host thread (`video.c`, three sites) or fast3d's render-thread pump (`gfx_sdl2.cpp`, three sites), plus the overlay's "Quit to desktop". From the host thread, process teardown could run while the render thread was mid-frame inside the NVIDIA driver. A user-mode program shouldn't be able to bugcheck the machine, so the driver/HAGS stack is at fault too, but the port was feeding it a hazard on every exit.
+
+**Fix (port-only, `port/src/video.c`, `port/fast3d/gfx_sdl2.cpp`, `port/src/optionsoverlay.c`):**
+- Every quit site calls `videoRequestQuit(why)` instead of `exit(0)`.
+- The render thread brackets each frame with an `s_inFrame` atomic. At a frame boundary after a quit request (`videoStartFrame` before binding, or `videoEndFrame` after the swap) it runs `gfx_sdl_park_for_exit()` (`glFinish` + unbind the context) and parks for good.
+- The host thread (`videoHostExitIfRequested`, each pump) waits until the render thread is parked or outside a frame (at most 2 s), logs `video: exiting (render parked|idle|STILL IN FRAME (timeout) after N ms)`, then `exit(0)`, so atexit still saves the config.
+- Ordering: the render thread sets `s_inFrame` before reading `s_quitReq`; the host sets `s_quitReq` before reading `s_inFrame` (SDL atomics are sequentially consistent), so no frame can start after the host decides to exit.
+- **Harness:** `GE_QUITFRAME=` requests the same orderly quit after frame n. `tools_pc/golden_gate.sh` now quits 30 frames after its last capture; `timeout` stays as a backstop only. `verify.sh`'s sweep still uses `timeout` (follow-up).
+
+**Not changed (noted):** fast3d's render-thread SDL pump still drains the shared event queue alongside the host pump. On Windows only the window's creating thread should pump; worth revisiting. The audio device is still open at exit.
+
+**Verification owed (runs paused at the user's request):** with the user's OK (ideally HAGS off first), run `tools_pc/golden_gate.sh` and a few `GE_QUITFRAME` runs. Check each log ends with `video: exiting (render parked ...)`, check no new ge007 `0x40000015` WER reports appear, and check a window-close / Alt+F4 by hand. A crash dump (fix the host's dump creation) would confirm or rule out the cause.
+
+**Verified (2026-09-27):** golden gate 3/3 with self-quit; five self-quitting runs all logged `render parked after 0-17 ms`; the user closed the game by window close and Alt+F4 with no crash. Not proven as the bugcheck's root cause (no dump): watch for recurrence.
+
+**Status: FIXED (probable cause), user-verified.**
+
+
+## D345 — File select → mode select: crosshair snaps to the Agent row and stays there until the mouse moves (2026-09-27)
+
+**Report:** with mouse input, entering a folder page (file select → mode select) resets the crosshair away from the pointer; it stays at the snapped position until the next mouse movement.
+
+**Evidence:** `front.c:2516` — the file-select→mode-select entry path calls `setCursorPOSforMode(0)` unconditionally, teleporting `cursor_h/v_pos` to (126, 226), the first row. Its two sibling re-entry paths (`front.c:4502`, `:4646`) already use `setCursorPOSforMode(gamemode)`. The port's 1:1 menu pointer (`port/src/input.c`, D165/D169) writes `cursor_h/v_pos` from the absolute mouse position **only on frames where the OS mouse moved** (idle-mouse cede so WASD/arrow navigation can drive the cursor through the game's integrator). A click-to-enter leaves the mouse idle, so the game's snap persists until the next movement. `menuPrevActive` (`input.c:1359`) tracks menu-vs-not only, so the "just entered a menu" baseline-adopt branch does not fire on menu-to-menu transitions. Poll order is input → interface (D343), so the same-frame snap wins over any same-frame re-assert.
+
+**Fix (port-only, `port/src/input.c`):** carry pointer ownership across front-end screen changes.
+- New statics: `menuPointerLastMenuId`, `menuPointerTransitionFrames`, `menuPointerLastAbsWrite` (perf-counter stamp of the last absolute write).
+- Every pad-0 poll: if `current_menu` changed, open a 3-poll re-assert window (the transition poll's write loses to the game's same-frame snap; poll+1 sticks).
+- In the 1:1 pointer block, force the absolute write when `(mouse moved) OR (window open AND mouse used within MENU_POINTER_REASSERT_MS = 1500 ms)`. Click-to-enter → cursor lands back under the pointer on the new page. Stale idle mouse (WASD navigation) → no yank; the cede-to-keyboard behaviour is preserved.
+- The F10 overlay does not change `current_menu`, so opening/closing it opens no window (correct: same screen, pointer already under the mouse). Entering `MENU_PC_OPTIONS` does change it and re-asserts — desirable, flagged for playtest.
+
+**D345(b) — the new PC options screen (user report: "doesn't work on our new PC settings menu").** `MENU_PC_OPTIONS` has its own cursor teleporter: `cursorToItem()` (`port/src/frontoptions.c:178`) snaps the crosshair to a page's first item, called from screen-entry init, section entry, and back. The section/back hops happen *inside* frontoptions.c (no `current_menu` change), so the D345(a) window never sees them; every page hop re-snapped the crosshair away from the pointer. Fix (port-only): input.c now exports `inputMenuPointerLive()` (in a menu + abs pointer available + mouse used within `MENU_POINTER_REASSERT_MS`; refreshed every pad-0 poll, forced 0 while the F10 overlay owns the mouse), and `cursorToItem()` skips its snap when it is set — the cursor is already under the OS pointer. Keyboard/D-pad users (stale mouse) keep the snap. Also added `#include ` to `port/include/input.h` (its API uses SDL types; previously only includers that had SDL already compiled — cf. video.h).
+
+**D345(c) — options screen: W/S did not move through the lists (user playtest).** Cheat-screen-style menus integrate stick input into `cursor_h/v_pos` by calling `frontUpdateControlStickPosition()` from their interface (`interface_menu15_cheat`, front.c:7700); `frontOptionsMenuInterface()` never did, so keyboard/D-pad vertical input had nothing to drive the cursor (A/D worked because frontoptions.c reads those as buttons directly). Fix (port-only): `frontOptionsMenuInterface()` now calls `frontUpdateControlStickPosition()` after `buildPages()`, before the highlight reads the cursor — mirroring the cheat screen. No-op while the 1:1 pointer owns the cursor (it emits a zero stick).
+
+**D345(d) — W/S still dead after (c): NaturalPitch mode starves the stick (user playtest via playtest_v040.bat, confirmed running the fixed binary by exe/log timestamps).** With `NaturalPitch = 1` (default; in the user's ini), the keyboard forward/back mapping emits **C-up/C-down buttons** instead of stick (`input.c:1037-1042`, the D194/D238 in-stage aim scheme) -- and no front-end screen navigates on C-buttons, so W/S (and Up/Down arrows) produced zero stick in every menu. Fix (port-only): the natural-pitch branch now requires `!menuMode`, so menus always get the stick mapping (N64 front end is stick-driven). Watch/pause is unaffected: it runs at `current_menu == RUN_STAGE` (menuMode 0) and keeps the C-button mapping, N64-style. This also restores W/S vertical navigation on file select / cheat screen for NaturalPitch users (mouse users never noticed).
+
+**D345(e) — user preference: F10-style discrete keyboard navigation on the options screen ("behave like the F10 menu but keep the mouse option").** Replaced the D345(c) raw stick drift with one-row-at-a-time stepping in `frontOptionsMenuInterface()`: W/S / Up-Down arrows (stick, via D345(d)) or pad stick/D-pad (`U_JPAD`/`D_JPAD`) move the highlight ±1 row, clamped at the ends, hold-to-repeat on the screen's existing 18/4-frame cadence (separate `s_vrepeatDir/Timer` state so it can run alongside left/right value adjust). Each step snaps the crosshair via new ungated `cursorToItemRaw()` (explicit keyboard nav owns the cursor even right after mouse use); `cursorToItem()` keeps the D345(b) pointer-live gate for page transitions. Mouse is untouched: any movement re-owns the cursor and the highlight hit-test follows it next frame. Stepper is skipped while a slider drag holds A. The D345(c) `frontUpdateControlStickPosition()` call is removed (it fought the pointer over `cursor_v_pos`).
+
+**D345(f) — D345(e) shipped broken (user: "W/S still just move the crosshair").** The D345(e) edit added the stepper but its two *removals* never landed: the D345(c) `frontUpdateControlStickPosition()` call (after `buildPages()`) and its extern stayed in `frontoptions.c`, so W/S ran raw cursor drift on top of the row stepper. The build didn't catch it: with the extern gone from my intended edit but present in the file, compilation only produced an implicit-declaration warning, which this project compiles through. Fix: removed the call + extern (verified by grep, not just the edit tool's success message), and added stick-X to the left/right value block so Left/Right arrows adjust values like F10 (A/D letters already worked via C-buttons). Lesson recorded: after any edit that both adds and removes, grep-verify the removals against the file.
+
+**Not changed:** the game-side snap itself (`front.c:2516`) is untouched — no Rule-2 sign-off needed for this fix. The optional one-liner `setCursorPOSforMode(gamemode >= 0 ? gamemode : 0)` (matching its siblings) was considered for pad users, who have no re-assert at all. Following a successful physical-controller pass, the maintainer chose to defer it; do not change game code without fresh Rule-2 sign-off.
+
+**User-verified (2026-09-27):** folder-page snap gone (mouse); options screen entry/section hops keep the pointer under the mouse; sliders/toggles fine; F10-style W/S + arrow navigation with hold-to-repeat works; left/right arrows adjust values; crosshair parks in the gutter off the row text. Resolved for time being.
+
+**Verification:** builds clean (`build-pc.sh ntsc-final`; only pre-existing warnings). Headless smoke: `GE_STARTMENU=5 GE_INPUTLOG=1 GE_QUITFRAME=400` — 412 `menuptr` lines, cursor stable, orderly self-quit (`render parked after 3 ms`). The playtest items listed here were subsequently covered by the user verification above.
+
+## D346 — PC-settings wording pass: Nightdive/Turok + PD-port conventions (2026-09-27)
+
+User request: make all PC setting labels/values consistent with the PD port and
+Nightdive remasters (Turok) where possible. Both settings UIs (the F10 overlay
+and `MENU_PC_OPTIONS`) share one table — `rows[]` in `port/src/optionsoverlay.c`
+— so a single pass covers both. Display-only: config keys, stored values, and
+adjustment behavior are untouched (existing inis read/write identically).
+
+**Label changes** (`rows[]`, optionsoverlay.c):
+
+| Before | After | Rationale |
+|---|---|---|
+| `DISPLAY` (section) | `VIDEO` | Nightdive section naming ("Video"); pairs with the existing `VIEW` section |
+| `Frame cap` | `Frame rate cap` | Turok 3's exact wording; values already Nightdive-style (`Uncapped` / `120 FPS`) |
+| `MSAA` | `Anti-aliasing` | Nightdive standard; jargon-free |
+| `Anisotropic` | `Anisotropic filtering` | Nightdive standard |
+| `FOV scale %` / `Draw distance %` / `LOD distance %` / `HUD scale %` / `Trigger threshold %` | `%` dropped from labels, moved to the value (`100%`, `150%`, …) | Nightdive shows units in the value column, not the label |
+| `Crop overscan bars` | `Crop overscan` | trimmed; "bars" was internal phrasing |
+| `Draw dist. auto` / `LOD dist. auto` | `Draw dist. follows FOV` / `LOD dist. follows FOV` | the old labels didn't say what follows what (they couple the distance to `Video.FovScale`, video.c:203-231) |
+| `Mouse invert Y` / `Pad invert look Y` | `Invert look (mouse)` / `Invert look (controller)` | Nightdive per-device convention; now a consistent pair |
+
+**Value changes:** `OFF/ON` → `Off/On` (kOnOff); `NEAREST/BILINEAR/3-POINT` →
+`Nearest/Bilinear/3-Point`; `CENTRED (PC)` → `Centred (PC)`; MSAA `OFF` →
+`None` (Nightdive AA vocabulary). The options screen's ON-colour match in
+`frontoptions.c:454` updated `"ON"` → `"On"` to follow.
+
+**Mechanism:** two display-only fields appended to `struct Row` (`unit`,
+`dispDiv`; C zero-fill keeps every existing positional initializer valid) and a
+small branch at the integer-slider tail of `valueText()`: optional raw→display
+divide, then unit suffix. Used by: FovScale/DrawDistance/LodDistance/HudScale/
+PadTriggerPct (`%`), Anisotropy (`x`), PadDeadzone (`%`, dispDiv 300 — raw
+0..30000 stick units → % of full travel; default 7000 now reads "23%" instead
+of "7000").
+
+**Kept deliberately:** `VSync`, `Fullscreen`, `Resolution`, `Texture filter`
+(universal); `Aim style`/`Aim range` + their `N64`/`PC` values (GE-specific,
+already clear); `No hit flash` (community-mod name; inverting the polarity to
+"Hit flash: Off" would change what On means — not a wording fix); `Skip intro`,
+`Show FPS`, `All unlocked`, `Quit to desktop` (plain English, no reference
+counterpart worth matching).
+
+**Known cosmetic follow-up (not done):** the deadzone slider's raw step is 500
+stick units, so its % display advances ~1.7% per press (23→24→25→27…). A clean
+1%-per-press grid would mean changing `step` to 300 — a behavior change to the
+adjustment feel, out of scope for a wording pass; flagged for a future input-QoL
+item.
+
+**D346b — VIEW section merged into VIDEO (user request, same day).** The old
+`VIEW` header row was deleted from `rows[]`; its rows (FOV scale, Native
+widescreen, Widescreen auto FOV, Crop overscan, draw/LOD distance pairs) now
+follow the display/quality rows in one Nightdive-style "Video" section. The
+options screen's page model is one-header-one-page with a row cap, so the cap
+moved 12→15 (`MAX_PROWS`) and the row pitch 20→18 px (`ROW_DY`, frontoptions.c)
+to keep the worst case (15 rows: "Draw dist. follows FOV" off reveals both its
+toggle and the slider) on one page — last row text at y=317, inside the 330
+paper; its 22px highlight box overflows by 1px in that state only (clipped,
+invisible). Hit bands stay contiguous at the tighter pitch (band = [y-3, y+15),
+next starts y+15). F10 overlay unaffected (own pitch, scrolls). Section list is
+now 4 entries: Video / Mouse / Aim / Controller / Game.
+
+**Verification:** `build-pc.sh ntsc-final` clean (no new warnings); headless
+boot into the options screen (`GE_STARTMENU=26 GE_QUITFRAME=300`) orderly
+self-quit, zero crash lines. All strings plain ASCII (JP-region safe, #87).
+Port-only: no `src/game` edits, N64 build untouched.
+
+## D347 — F10 overlay controller value-adjust + hold-to-repeat (2026-09-27)
+
+User report (controller, Steam Deck class): in the F10 options overlay,
+left/right did nothing — only A (increment) / Y (decrement) adjusted values,
+"feels bad". Root cause: `optionsOverlayHandleInput()` wired only DPAD_UP/DOWN
++ stick-Y to navigation and A/X/B/Y to adjust; **DPAD_LEFT/RIGHT and stick-X
+were never read**, and every action was edge-triggered — no hold-to-repeat
+anywhere (holding a key or the stick stepped exactly once).
+
+Fix (port-only, `optionsoverlay.c`):
+- D-pad left/right + left-stick X (±12000, matching the existing Y-axis style)
+ OR-ed into the lf/rt adjust inputs. A/X/B/Y keep working as before.
+- Hold-to-repeat on value adjust: first press fires immediately, then an
+ 18-frame delay and a 4-frame repeat — the same cadence as the options
+ screen's D345(e)/(f) blocks, so both settings UIs now feel identical.
+- Same repeat added to up/down navigation (previously one row per press even
+ on keyboard — traversing the list held-down was impossible). `overlayStepSel`
+ clamps at the ends, so holding at an end is a harmless no-op.
+- Repeat state (`navDir/navTimer/adjDir/adjTimer`) resets when the overlay
+ closes, alongside the existing prev*/dragRow reset.
+
+Toggles flip on either adjust direction (existing `rowAdjust` behaviour);
+sliders step by their registered step; MSAA/enum wrap as before.
+
+**Verification:** `build-pc.sh ntsc-final` clean; headless boot smoke clean.
+Subsequent user physical-controller pass validated the F10 overlay, options
+screen and bottom-bar label; all good. No new automated controller test was run.
+
+## D348 — File-select "PC Options" label gets a mini slider glyph (2026-09-27)
+
+User request: an icon next to the "PC Options" bottom-bar label, like Copy/Erase
+have theirs. There is no settings-style sprite anywhere in the game art — the
+`mainfolderimages` bank (`assets/oddtextures.h:57-64`) holds exactly six:
+COPY, DEL, SEL (select arrow), X, CHECK, DOT. Per user choice, a glyph is drawn
+instead of borrowing an off-meaning sprite.
+
+Implementation (port-only, `optionsFileSelectLabel()` in frontoptions.c):
+three 2px menu-box tracks with offset 3×4 knobs (a miniature slider control),
+emitted with the same `microcode_constructor_related_to_menus` primitive the
+options screen uses for its slider bars. Tracks are `INK_DIM`; knobs match the
+label ink (white, gold while hot). The glyph is 10px wide (`GLYPH_W`) and sits
+INSIDE the existing 14px `LABEL_GAP` after Erase (2px margins each side), so it
+tracks the dynamic label x — JP's wider Erase pushes glyph + label together —
+and the text position / right-edge clearance are unchanged. The hot region
+extends left to include the glyph. No new strings (issue #87).
+
+**Verification:** `build-pc.sh ntsc-final` clean; headless file-select capture
+(`GE_STARTMENU=5 GE_PCDUMP=200-201`, 1088×816 = 440×330 canvas × 2.473): the
+glyph box (logical x 339–351, y 278–292) contains 135 pure-white pixels — the
+three knobs — vs 9 on an equivalent empty-paper strip. Visual feel-check owed
+to the user (proportions/knob offsets are one-constant tweaks if it looks off).
+
+## D349 — Watch options are session-only in normal GE play (static audit)
+
+**Ground truth:** `fileSaveSettingsForFolder` (`src/game/file2.c:1246`) serializes live Music/FX and the eight watch options into a supplied `save_data`; its only call site is `fileClearSavefileForFolder` (`:1379`), reached by `deleteCurrentSelectedFolder` (`src/game/file.c:84`, the Erase path). The normal watch controls (`options.c`) update live state, not the save. At stage start `init_watch_at_start_of_stage` (`options.c:415`) calls `fileLoadSaveSettingsForSelectedFolder`, which reloads the file values (`file2.c:1308-1350`) and discards unsaved watch adjustments. A repository search found no other assignments to `save->music_vol`, `save->sfx_vol` or `save->options` from live watch state. This is a decomp/N64 quirk, not a PC regression; recheck any indirect write path before implementing persistence.
+
+**Consequences for PC options:** editable watch-backed rows on file select would revert on stage entry without an extra save path. The maintainer approved a deliberate PC-only QoL divergence: **edits in the PC settings/F10 UIs persist to the explicitly chosen/active Bond file; edits made directly in GE's watch stay session-only (faithful to N64)**. Preserve the N64 game/save format and verify per-file isolation. The agreed design is `docs/dev/WATCH-SETTINGS-PLAN.md`.
+
+**Save hazards to cover in implementation:** `fileSaveSettingsForFolder` serializes *all* live options, including `OPTION_CONTROLTYPE`. Since the port forces Honey/Solitaire in `input.c`, keep the original stored control-type bits. Serialize into a temporary copy, merge **only the PC-UI-edited field/bit(s)** into the actual slot pointer returned by `fileGetSaveForFoldernum` (folder number is not a `saves[]` slot index), then call `fileWriteSave`. This also keeps unrelated watch-only edits session-only when a PC-UI value is later changed. Toggle/enum edits commit on change; slider drags commit on release. GE's folder loader would change front-end Music/FX volume live; D350's chooser reads the saved fields directly instead, avoiding this side effect. Erase (`fileClearSavefileForFolder`) serializes *current live* settings into the wiped save: editing B, then erasing A may put B's live watch settings into wiped A. The same edge exists on N64 after changing watch settings; acknowledge it in tests, do not change game logic. D350's front-end chooser avoids loading B into live state, so editing B there does not itself trigger this edge.
+
+**Defaults:** `BLANKSAVEDATA` (`src/bondconstants.h:2151`) starts `music_vol`/`sfx_vol` at 0xFF and `options` at `DEFAULT_OPTIONS` (`src/game/file2.h:23`: auto-aim, sight, look-ahead, ammo on). No code change or runtime verification in this finding.
+
+## D350 — Watch-backed PC settings: shared rows, explicit chooser and per-file save (2026-09-27)
+
+**Port-only, no `src/game` changes.** `port/src/optionsoverlay.c` adds an Audio (Bond File) section (Music/FX sliders) and a Bond File page (explicit file chooser + watch look direction, auto-aim, aim control, sight, look-ahead, ammo). These rows are shared by F10 and `MENU_PC_OPTIONS`; control style, GE Screen/Ratio and unrelated M3 items remain deferred. `watchSettingsFieldForKey` routes them to `port/src/watchsettings.c` rather than `config.c`/ge007.ini. Empty/unvalidated files cannot be edited; the chooser is independent of `selected_folder_num`, skips absent saves, and reads save fields directly rather than loading a folder and changing front-end audio. In a running level, F10 targets the active file and reads a game-thread-published snapshot so direct watch changes appear next frame. The watch's own edits remain session-only.
+
+**Thread handoff:** F10's scheduler-thread input queues bounded `(folder, field, value, commit)` requests under an SDL spinlock (not a GE state mutation). `port/src/libultra.c` drains these on successful `gfxFrameMsgQ` receives, *after* unlocking the OS queue mutex, on `boss.c`'s game thread; the only consumer of that queue is the boss main loop. The game thread checks stage and target file identity before using GE's setters or EEPROM, updates the snapshot, and discards stale cross-file requests. It also supports direct `-level_XX` boots, where `current_menu` stays `MENU_INVALID` despite an active level. F10's file chooser is hidden; the front screen has a separate explicit chooser.
+
+**Save safety:** `fileGetSaveForFoldernum` alone may return a zero-filled phantom slot if `GE_STARTMENU` skipped `fileValidateSaves` (D299). `validSave` verifies GE's CRC before showing/editing a file. For F10, `fileSaveSettingsForFolder` serializes into a temporary copy and only the PC-edited Music/FX byte or watch bit is merged into the real physical save slot, then `fileWriteSave` updates CRC/EEPROM. In the front screen (no active player), the same single field is edited directly on the explicitly chosen save without invoking the folder loader; front-end audio levels do not preview the selected file's new Music/FX value until the stage loads it. The stored controller-type bits and unrelated watch-only changes stay untouched. Toggles and discrete value steps persist on change; mouse-drag sliders update their displayed value (and apply GE audio live in-stage through F10) but persist once on release (or F10 close/back). F10 preview and commit use the game-thread queue; no save per drag detent.
+
+**Verification (native Windows, backed-up ini and EEPROM restored after each probe; temporary test hooks removed):**
+- Build/link clean; `bash tools_pc/golden_gate.sh` 3/3, orderly self-quit. `GE_STARTMENU=26 GE_QUITFRAME=320` shows the new screen and quits cleanly; `-level_09 GE_OPTIONSOVERLAY=1 GE_QUITFRAME=500` opens F10 cleanly.
+- A `GE_STARTMENU=26` scripted chooser attempted to edit file 1 without prior save validation: CRC guard refused, **EEPROM byte-identical**. This prevents D299-class writes to phantom slot 0.
+- With test-only save validation injected at options init, `GE_INPUTSCRIPT` navigated to Bond File, selected file 1, changed Watch look: EEPROM slot 0 options `0x013a→0x013b`, only option byte + CRC changed. Separately selected file 1, entered Audio, decremented Music once: `music_vol 255→254`, FX unchanged, only music byte + CRC changed. Neither temporary hook ships.
+- A second front-end script chose Bond file 1 then switched to Bond file 2 and changed Look. Only physical EEPROM **slot 4** changed (the file-2 save lives there; file number is not slot index), while slot 0/file 1's options stayed `0x013a` untouched. This confirms basic two-file targeting; copy/erase and rapid transitions remain manual checks.
+- Game-thread `-level_09` temporary probe toggled Auto-aim and persisted: options `0x013a→0x0138`, original `0x0100` control-style bits unchanged, only option byte + eight CRC bytes changed. A second process loaded Auto-aim=0 from the same test EEPROM. A temporary scheduler-thread/F10 queue probe produced the same targeted EEPROM change using the game-thread handoff. All test EEPROM copies were restored; neither probe ships.
+
+**User feedback:** maintainer reports "it tests good" and requested a commit before a new-session review. No per-case playtest notes were supplied, so do not infer that every edge case below was exercised.
+
+**For the review:** confirm mouse/pad chooser and both settings surfaces, watch↔F10 live changes, slider drag/release and rapid close/stage transitions; front-end audio slider preview feel (edits persist but do not apply to current menu audio); two-file switch/copy/erase. PAL/JP build/visual checks remain uncapturable locally (only NTSC ROM).
+
+## D351 — D346 unit-row initializers shifted into `hidePtr` (2026-09-27)
+
+The existing positional `struct Row` initializers for FOV, anisotropy, draw/LOD distance, HUD scale, deadzone and trigger threshold omitted the `hidePtr` placeholder. GCC warned about assigning a `char *` unit ("%"/"x") to `int *hidePtr` and assigning deadzone's numeric display divisor to `unit`. The visible-list builder dereferences non-null hidePtr, so these rows were hidden or misformatted despite D346's intended unit labels. Corrected *only* these rows to designated initializers, preserving their key, label, clamp, step and intended `hiddenIfOn`/unit/divisor. New watch rows also use designated initializers to prevent repeats. Build has no new optionsoverlay warnings; headless menu smoke/golden gate clean. Maintainer reports the implementation tests good, but did not separately report a unit-label/auto-hide visual check; carry that into the new-session review.
+
+## D352 — D350 watch-settings bridge review: transition drops, chooser sync, queue coalescing, silent failures (2026-09-27)
+
+Design review of `1303c0eb` (D350) of the game-thread bridge in `port/src/watchsettings.c`. Four robustness fixes, all port-side, behavior-neutral:
+
+1. **M1 — hold-not-drop at stage transitions.** `watchSettingsGameTick` drained the queue *before* checking `stageActive()`, so any setting change committed while a stage transition was in progress was silently discarded. Now the stage-active check runs first: while unavailable the queue is held (with coalescing, so only the latest value per field is ever pending) and applies as soon as the stage reactivates. The drain still happens before the `gfxFrameMsgQ` spin, so the bridge can never deadlock on its own lock ordering.
+2. **M2 — chooser state synchronized.** `chosen`/`stagedVal`/`stagedValid` are mutated by `watchSettingsChooseFile` (UI thread, both surfaces) and read by the bridge (game thread). A single `SDL_SpinLock uiLock` now guards the whole read/modify/write on both sides, and the chooser is a no-op unless the UI is at the front end (`frontScreen()`), where the chooser row is the only caller. The lock is short-critical-section-only (no I/O held), taken in one order (never with `qLock` held).
+3. **L2 — per-field coalescing.** A fast slider drag queued one `WsCmd` (and one `wsPersist`, i.e. one `fileWriteSave`) per detent per frame. The queue now keeps at most one pending command per field: a new commit for an already-pending field replaces its value, ORs its `commits` flag, and reuses the slot. `fileWriteSave` still happens at most once per tick (the drain loop is unchanged), but the per-detent spam is gone and a stage transition can never build up an unbounded queue.
+4. **L3/L6 — visible failures, smaller API.** Every silent no-op path now logs a one-line `LOG_WARNING` (`frontoptions`/`watchSettings`-tagged): stage transition holding the queue (rate-limited by the coalescing), front-path commit with nothing staged, F10 commit with no valid save, no loaded player record, save absent from the EEPROM range. `watchSettingsPersistField` was only ever called inside `watchsettings.c` — made `static`, removed from the public header.
+
+Verification: NTSC build clean (no new warnings in `watchsettings.c`), headless menu smoke boots to the front end and renders (frames advancing, EEPROM load). Edge cases (setting F10 during a level transition; rapid slider drag; committing with an invalid file) are log-visible but need a manual pass to confirm by eye.
+
+## D353 — Turok-style settings regrouping: DISPLAY/GRAPHICS split, AUDIO(BOND FILE) header dropped, designated rows, overflow guard (2026-09-27)
+
+The D346 "single Video section" compromise (14–15 rows, exactly at `MAX_PROWS=15`) is re-grouped along the Turok/Nightdive menu axes (per `docs/dev/notes/OPTIONS-MENU-PLAN.md` §6), all port-side, no config-key renames:
+
+- **VIDEO → DISPLAY + GRAPHICS.** DISPLAY: fullscreen, resolution, vsync, frame-rate cap (the window-management block Turok's "Video" panel covers). GRAPHICS: MSAA, texture filter, anisotropy, FOV scale, native widescreen, auto-FOV pair, overscan crop, the two draw/LOD-distance sliders and their auto-FOV hide-ifs. 4 + 11 rows, both well under the page limit.
+- **"AUDIO (BOND FILE)" hack header dropped.** The music/FX sliders now sit under the BOND FILE section, next to the other persisted-per-file watch rows — the section is exactly the per-file save, so the parenthetical header is redundant. No standalone master-volume row yet (that is the M3 addition, which will land in a future AUDIO section alongside the config-side volume keys).
+- **Scope annotation.** The Bond-file page title carries the chooser's value ("Game\n(File 2)" / "(no file)" in dim ink), so a player navigating from the front end sees which save the section writes without scrolling to the "Edit file" row. `frontoptions.c` level-1 draw only; F10 shows the chooser row inline (its value already there) so no annotation is needed.
+- **Full designated-initializer conversion.** The remaining positional `struct Row` entries (all of them) are now designated, per the D351 policy: new fields (like `found`) can never silently shift a value into the wrong member again. Keys, labels, clamps, steps, names, hide-ifs, units are all carried over unchanged — this is a display-only regroup, no value or config-key change.
+- **Overflow guard + accessor.** `buildPages` logs a `LOG_WARNING` naming the section and the last visible row if a section ever outgrows `MAX_PROWS` (the D346 VIDEO section truncated silently at exactly 15 rows before this); `optionsRowIsBondChooser()` is exported so the front-end title annotation and future F10 code can find the chooser row by role instead of index.
+
+Section order: DISPLAY, GRAPHICS, BOND FILE, MOUSE / AIM, CONTROLLER, GAME (6 sections, under `MAX_PAGES=8`). Per-section "reset to defaults" (Gate E) and the §6-missing master-volume row remain outstanding. Verification: NTSC build clean, headless menu smoke boots to the front end; the regrouped page flow (arrows through 6 pages, annotation visible, no overflow log) needs a manual visual pass.
+
+## D354 — Front-end F10 Bond rows disabled; default Bond-file selection (2026-09-27)
+
+Maintainer report: the F10 volume settings "don't apply", while the watch and the main-menu options work. Diagnosis (port-only):
+
+- **The in-stage F10 path is sound.** A new env-gated probe (`GE_WSPROBE=`, same pattern as `GE_D314`) queues an F10-style commit of Music+FX on the Nth active stage tick and logs live + saved before/after. Headless `-level_33` run: live music 15163→4096, save.music 118→32, `watchsettings: persisted field 0/1 in Bond file 1` — the queue→apply→persist bridge works end-to-end in a stage.
+- **The real gap was the front end.** `watchSettingsAvailable()` special-cased only `MENU_PC_OPTIONS` (front path) and stages (snapshot path); every other context — notably **F10 over the file-select screen**, the primary F10 surface — fell through to the stage path and returned "unavailable", disabling every Bond row. With the chooser row hidden in F10 and `chosen` starting at -1 ("none"), the rows read "Select file" and could never be enabled: the user's "volume settings in F10 don't apply".
+- **Fix (maintainer-suggested UX):**
+ - All **non-stage** contexts now route through the front path (chooser + `validSave`), so F10-on-file-select behaves like the options screen.
+ - New `frontFolder()`: lazy default target, resolving `chosen` on first use to **explicit pick > `selected_folder_num` (the folder the player is viewing) > first valid folder**, under the existing `uiLock`. A fresh PC eeprom is booted with a BLANKSAVEDATA slot per folder (libultra.c D259/D281 patch), so the default almost always lands on file 1 — the Bond section is usable transparently, no "Edit file" ceremony. The D353 title annotation now shows "(File 1)" by default instead of "(no file)".
+ - **Auto-init fallback:** one-shot, game-thread, in `watchSettingsGameTick`: if *no* folder holds a valid save (wiped/corrupt eeprom), call the game's own `fileBuildWriteNewSave(FOLDER1)` (the routine boot validation uses, `file2.c:509`) and default to it; logs the outcome. Never modifies game code.
+ - `watchSettingsChooseFile` guard flipped to "refuse in stage context" (front-end control only); "none" remains reachable by explicit cycling.
+ - `GE_WSPROBE_FRONT=` probe verifies the same front commit headlessly: default boot (menu) resolves folder 0 (file 1) with no interaction, commit 4096 → save.music 255→32.
+
+Verification: NTSC build clean; both probes pass headless; in-stage behavior unchanged (probe-identical). The D350 threading gate is unchanged in effect: the front path's `fileWriteSave` (EEPROM) now also runs from the F10 input thread in front contexts, but only one settings surface is ever active at a time and the game thread performs no save writes on menus, so writes stay serialized; reads (`saves[]`/CRC) are stable menu state. Manual by-ear check owed: F10 music/FX on file select, and default-file behavior after erase. (By-ear confirmed by the maintainer in the D355 session: FX applies live; the music follow-on gap became D355.)
+
+## D355 — F10/options Music slider inaudible on in-stage BGM (2026-09-27)
+
+Maintainer report (after D354): F10 sound-FX changes apply in real time, but the Music slider does not; opening the in-game watch menu seemed to be what "applied" it. Diagnosis (port-only):
+
+- **Root cause: the slider and the audible music are on different tracks.** The Music row drives `mTrack2Vol` → `set_mTrack2Vol` → the **X-track (track 2)** CSP player (cutscene/stinger music). In-stage BGM, however, is **track 1**: solo level start plays `lvlPlayMusicTrack1(getmusictrack_or_randomtrack(...))` (lv.c:304) and the level-start reset pins **all three** players to `VOLUME_MAX` (lv.c:364–366) — it never rescales track 1 from `mTrack2Vol` in solo. The MP path is the exception: `mpmenu.c:354` explicitly feeds `get_mTrack2Vol()` into `musicTrack1ApplySeqpVol` at level entry, and `mp_music.c` mission-state transitions do the same before each `musicTrack1Play`.
+- **Why the watch "fixed" it:** the in-game watch re-applies `set_mTrack2Vol` every frame while its volume screen is drawn (`draw_music_volume_slider`), so the watch-screen/stinger music (X track) audibly tracked the slider — the in-stage BGM never did. A one-shot F10 commit has the same effect as the N64 watch: correct value stored, inaudible on BGM.
+- **Fix (`port/src/watchsettings.c`, port-side only):**
+ - `applyValue(WATCH_SETTING_MUSIC)` now calls `musicTrack1ApplySeqpVol(value)` after `set_mTrack2Vol(value)` — the *same* resync the game's own MP path performs (mpmenu.c:354), so F10/options Music edits are audible on the BGM player in real time (mirroring how FX always was via the global SFX scaler).
+ - **Once-per-activation resync** in `watchSettingsGameTick`: level start pins track 1 to `VOLUME_MAX` before `init_watch_at_start_of_stage` (lv.c:474, level load) re-applies the saved music value to the X track; the hook resyncs track 1 to `get_mTrack2Vol()` on the first active tick of each activation (`wsStageWasActive` reset whenever the stage is inactive), so the player's setting survives level starts, not just live edits. The `!=` guard keeps it a no-op when the game already set an equal value.
+ - Track 3 (MP BGM) is intentionally untouched — the game drives it from its own volume, and the PC port is solo.
+ - The N64 watch-menu path is **unchanged** (ground truth: the watch scales the X track only); this is a PC-settings-surface extension, in the same category as D350's existing `set_mTrack2Vol` call from the port.
+- GE_WSPROBE logs extended with the track-1 volume (`t1=`) so the resync is visible headless.
+
+Verification: NTSC build clean; **by-ear confirmed by the maintainer** — F10 Music now changes in-stage music in real time. The front-end commit path (input thread) now also calls `musicTrack1ApplySeqpVol`, which wraps `alCSPSetVol` on the track-1 player — same thread pattern as the pre-existing track-2 call, so the D350 threading gate is unchanged in effect.
+
+## D356 — Settings regroup: functional sections, per-section reset-to-defaults, N64-watch rows off the menu (2026-09-27)
+
+**Problem.** The D353 regroup left the F10 overlay with a Turok-style layout
+and Turok-style per-page "Reset to defaults" rows (options.c:121/140/159/173/190,
+all N64-watch defaults, none of which the PC build honors -- a D330-era known
+gap). The N64-watch `Bond.Look` (stacks double-negation with the PC invert
+rows) and `Bond.AimControl` (collides with `Input.AimMode`) rows were
+menu-surface hazards. The plan
+(docs/dev/D356-SETTINGS-REGROUP-PLAN.md, signed off 2026-09-27): rename the
+page model to functional sections, scope the per-file rows under a file
+annotation, drop the N64-watch rows from the menu (keep the code paths), and
+replace the Turok reset row with a real per-section reset-to-defaults.
+
+**Fix (port-only; `optionsoverlay.{h,c}`, `watchsettings.{h,c}`,
+`frontoptions.c`, + plan doc; zero game-code changes).**
+1. **Sections (plan §4).** `INPUT` (7 rows: the old MOUSE + PAD headers
+ merged, `__HdrMouse`/`__HdrPad` dropped) / `GAMEPLAY` (File N; 8 rows:
+ surviving watch toggles `Bond.AutoAim`/`LookAhead`/`Sight`/`Ammo` + the
+ `Game.*` ini rows + Quit) / `GRAPHICS` (11 rows, D353's DISPLAY+GRAPHICS
+ merged) / `AUDIO` (File N; `Bond.Music` + `Bond.FX` watch sliders, out of
+ the Bond-file page) / `VIDEO` (5 rows: the D353 DISPLAY rename,
+ `Video.DisplayFPS` moved from GAME). All pages below the MAX_PROWS cap.
+ `Bond.Look`/`Bond.AimControl` are commented out of `rows[]` with a D356
+ note (D181/D216 style: keys + watch paths stay live; ini power users can
+ still set them). The `__HdrBond` header + `__BondFile` chooser row are
+ retired, replaced by the front-only top `Save file: File N [change]` row
+ (frontoptions.c; hidden in F10 via the generalized
+ `optionsRowIsBondChooser` mechanism).
+2. **Save-file scoping.** Sections that carry per-file rows (GAMEPLAY,
+ AUDIO) get a PD-style header annotation `"(File N)"` / `"(no file)"`
+ driven by `Row.saveScoped` + `watchSettingsActiveFolder()` (in-stage: the
+ active file; front: the resolved front target); per-file rows inside the
+ mixed-scope GAMEPLAY section carry a dim `"(save)"` tag (the section
+ title already says which file). The D353 `optionsRowIsBondChooser`
+ special-case is generalized to the `saveScoped` flag (both UIs: F10
+ overlay + front options screen).
+3. **Per-section reset-to-defaults (Gate E).** The Turok rows are deleted and
+ replaced by a trailing `Reset to defaults` ROW_ACTION on every section
+ (`__ResetInput`/`__ResetGameplay`/`__ResetGraphics`/`__ResetAudio`/
+ `__ResetVideo`). Activation is edge-triggered, two-step arm -> confirm
+ within 3 s (`RESET_ARM_US`), one commit per confirmed activation, disarm
+ on timeout or navigating away (`optionsResetMaintain`), disarm on
+ close (`optionsResetClear`); the arm state is shared by the F10 overlay
+ and the front screen (never open at once; both clear it). The front
+ screen suppresses held-key repeat for reset rows; F10's held-repeat
+ branches skip them -- callers guarantee edges, so hold never repeats
+ into confirm. Confirm clears the pending arm first, then
+ `rowResetSection(i)` walks the section's DECLARED row range (header to
+ next header, so conditionally hidden rows are reset too):
+ watch rows -> `watchSettingsSet(field, watchSettingsBlankValue(field),
+ 1)` (BLANKSAVEDATA, through the D352 commit path: front direct write,
+ stage queue drained + persisted by the game thread); ini rows -> the
+ `kResetDefaults[]` table (the port's own C initializers, each entry
+ citing its source variable) applied via `rowSetCommit` and persisted like
+ any ini edit. `__Resolution` is a documented exclusion (action-backed,
+ not a registered config row -- a numeric default cannot express it; the
+ player's resolution choice survives a VIDEO reset). No new write path.
+
+**Verification (headless, this session).**
+- `GE_WSPROBE_RESET=1` front boot: all 5 sections dirty -> arm -> confirm ->
+ verify, `failures=0` each; `__Resolution` untouched (exclusion holds);
+ `file 2 unchanged (scope isolation)` -- the second-file negative test.
+- `GE_WSPROBE_RESET=1 -level_33`: `stage dispatch: 6 command(s) queued`
+ (4 GAMEPLAY + 2 AUDIO watch rows), all 5 sections `failures=0` after the
+ game-thread drain, `saved bytes OK (music/sfx = 0xFF)` -- the persisted
+ save holds BLANKSAVEDATA raw.
+- Probe phase machine (`watchSettingsGameTick`, same env gate): front tick
+ 120 = prepare (front-only, direct commits), stage tick 120 = dispatch
+ (queue), stage tick 121 = verify (post-drain). A direct `-level_XX` boot
+ enters the stage long before the front counter would reach 120, so a
+ stage run starts its timer from phase 0 (the stage dispatch builds its
+ section list lazily).
+- Regressions: `GE_WSPROBE=120 -level_33` live music/fx/t1 32767->4096 +
+ save 255->32 (D355 resync still fires); `GE_WSPROBE_FRONT=120` default
+ folder 0 commit 4096 -> save.music=32. All runs end with the orderly
+ `video: exiting (render parked ...)` quit.
+- Build: full reconfigure + 248/248 targets + link clean
+ (`build-pc-cmd.bat`; the pi agent shell drops MSYS env at the
+ msys-2.0.dll boundary, so the build goes through a `cmd.exe` wrapper with
+ the MSYS2 toolchain on PATH).
+
+**Residuals.** By-ear confirmation of the in-stage Music slider is owed
+(D355 territory, unchanged by this entry). The hidden N64-watch rows
+(`Bond.Look`, `Bond.AimControl`) are still reachable by hand-editing the
+save file, exactly as on N64 -- only the menu surface is gone
+(D181/D216/D304 pattern).
+
+## D358 — D356 level-1 overlap: the front options save-file row painted under the first content row; s_hl -> s_rowIdx off-by-one (2026-09-27)
+
+**Problem.** D356's top save-file row (item 0 of the level-1 page) shared
+the page's item numbering (0 = save row, k = content row k-1) but the
+level-1 draw loop painted content row k at `rowY(k)` -- the save row's own
+line -- so "Save file: File N" and the section's first row (e.g.
+"Auto-aim") printed on top of each other (maintainer-reported overlap,
+2026-09-27). The same item/content confusion ran through the input paths:
+`s_hl` (item-numbered) was indexed straight into `s_rowIdx`
+(content-numbered), so A-press / L-R adjusts hit the row *below* the
+highlight, the last content row (`s_hl == s_rowN`) fell through both
+handlers entirely, `s_rowIdx[s_hl]` could read one past the built rows,
+and the highlight test (`k == s_hl`) lit the wrong row.
+
+**Fix (port-only, `port/src/frontoptions.c`).** Content rows now draw at
+`rowY(k + 1)` (one line below the save row, as `rowY`'s level-1 shift
+intended) and every `s_hl` -> `s_rowIdx` use takes `s_hl - 1` (A-press,
+L/R repeat, `optionsResetMaintain`); the A-press branch now guards
+`s_hl >= 1 && s_hl <= s_rowN` so the last row is live. The hit-test and
+cursor paths were already item-numbered (they use `rowY(k)` for item k)
+and are untouched. Verified by build + headless boot smoke; visual check
+of the screen owed (D357 plan gate 4).
+
+**Companion change (same commit):** the per-file row tag now reads
+"(profile)" (was "(save)", maintainer request), both UIs. (Full term
+history: "(save)" -> "(profile)" -> "(game file)" -> the final
+"(per profile)" set anchored on the end-user "Profile" term -- D357
+plan section 4.)
+
+## D359 — Settings UI file terminology settled on the end-user term "Profile" (2026-09-27)
+
+**Reported:** maintainer English/terminology review of the settings UI:
+the screen mixed five terms for the same thing ("Save file" label,
+"File N" values, "(profile)" tag, "Select file" fallback, "(no file)"
+state), and the instruction manual's "game file" was questioned for
+clarity.
+
+**Decision:** the end-user term is **"Profile"** — a per-player bundle
+of progress + per-file settings. "Game file" rejected: in modern PC
+usage it reads as the program's own files (Steam's "Verify integrity of
+game files"), not the user's save. "Save" was the fallback but reads as
+a verb in the row tag and implies progress-only. Rationale + the full
+string table: `docs/dev/D357-SETTINGS-VALUES-PLAN.md` §4.
+
+**Landed (port-only, `frontoptions.c` + `optionsoverlay.c`, both draw
+paths + comments):** top row `Profile` / value `N` (was `Save file` /
+`File N`); section-title annotation `(Profile N)` / `(none)` (was
+`(File N)` / `(no file)`); per-file row tag `(per profile)` (was
+`(save)` -> `(profile)` -> `(game file)`); F10 unavailable value
+`Select a profile` (was `Select file`). Code identifiers and ini keys
+(`Bond.*` rows, `watchSettings*`, ini) are unchanged — terminology is a
+display-layer concern only (principle 3).
+
+**TODO:** update the instruction manual's "game file" to "profile"
+(port-owned doc, not in this repo).
+
+## D360 — F10 category navigation and modern-display distance defaults (2026-09-27)
+
+**Reported:** F10's INPUT header was offscreen above Mouse sensitivity when
+scrolling; the long flat list was hard to navigate. Draw/LOD distance auto
+switches obscured the controls and N64-scale pop-in looked like a port bug.
+4x default MSAA was too expensive for low-end hardware.
+
+**Fix (port-only):** F10 opens on a five-category list, and entering a
+category shows its header (with Back) and settings together. Keyboard,
+controller, mouse and wheel all use the same per-page list. ESC backs out of
+a section, then closes from the category list; F10/Start/X still close.
+Front-end PC Options already had category pages and shares the row table.
+Removed AutoFov controls from both menus (ini keys remain for legacy files),
+and exposed both distance sliders at all times. Stored values remain percent
+of authored distance; the UI displays 0–100, where 50/100 maps to 250%.
+Fresh and SteamOS defaults are 250% draw and LOD distance, 2x MSAA. Reset
+uses the same new defaults. Existing ini settings are preserved; old LOD
+values below the menu minimum (100%) can still be edited via ini. Note:
+these higher distances increase visible geometry/pool demand; test on
+open levels and low-end GPUs before claiming performance parity.
+
+**Verification:** `git diff --check` clean. `build-pc.sh ntsc-final`
+initially failed because GCC could not create temporary files in
+`C:\Windows\`; configuring C/C++ with `-pipe` built all 248 objects.
+The existing executable was locked by a running `ge007.x86_64` process,
+so the same linker response file was linked to
+`scratch/cc-tmp/ge007-check.exe` successfully. No live game was interrupted.
+Manual mouse/pad/ESC, reset, and 50/100 rendering/perf checks are owed.
+
+## D361 — F10 Audio on the main menu locks up: self-deadlock in EEPROM poll gate (2026-09-27)
+
+**User repro:** fresh v0.4.0 playtest, change an AUDIO slider from F10
+on the main menu → game locks (not merely an inaudible volume change).
+
+**Exact chain:** F10 input is processed inside `inputComputePad()` called
+by `osContStartReadData()` in `joyPoll()` (`src/joy.c:500`). D354 routed
+front-context watch edits through `watchSettingsSet()` → `saveFrontField()`
+on that same poll thread. Both `fileGamePakProbe()` and `fileWriteSave()` call
+`joyGamePakProbe/LongWrite()` → `joyDisablePoll()` → blocking receive on
+`g_ContDisablePollReceiveMessageQueue`. Only `joyPoll()` can send the
+acknowledgement (`src/joy.c:434`), but it is itself blocked inside the
+F10 edit: deterministic self-deadlock. D354's claim that one settings
+surface meant this thread's writes were safe was incorrect; serialization
+between editors does not make the controller-poll handshake re-entrant.
+
+**Fix (port-only):** front F10 writes now coalesce in a small locked
+folder/field queue. `watchSettingsGameTick()` drains commits on the game
+thread before its non-stage early return and runs `saveFrontField()` there,
+with no lock held across EEPROM I/O. The on-screen value is staged before
+enqueue for immediate feedback, then cleared only after the matching commit.
+The separate stage queue and the direct front options screen path retain
+their prior behavior. `GE_WSPROBE_F10FRONT=1` is an opt-in regression probe
+called from F10's actual controller-poll input path at file select (once,
+after a valid save is available), committing Music+FX to 4096.
+
+**Verified:** NTSC incremental build/link succeeded. Native Windows test
+with `GE_OPTIONSOVERLAY=1`, `Game.SkipIntro=1` and the poll-thread probe
+logged the queue plus both Music/FX field persist events and exited via
+`GE_QUITFRAME=700` (with existing EEPROM **and** absent EEPROM on boot).
+The pre-existing game-thread `GE_WSPROBE_FRONT=120` front-path regression
+also persisted Music 255→32 and quit normally. The user's ini and EEPROM
+were copied aside and restored byte-identically after each run. Manual
+mouse slider/section-reset and stage transition checks remain owed.
+
+## D362 — F10 GoldenEye dossier-style visual refresh (2026-09-27)
+
+**Request:** restyle F10 to match GoldenEye, following
+`docs/dev/F10-GE-STYLE-PLAN.md`. Existing front Options already uses the
+in-game dossier; in-stage F10 needs a lighter port-owned 2D equivalent.
+
+**Implementation (port-only):** replace the blue/black panel with a dark
+scene scrim and warm paper card/shadow/rules, Zurich Bold menu ink (Bank
+Gothic fallback during early font loading), MI6 gold masthead, dark-red
+active values and slider fills/knobs, numbered GE-style category labels,
+faint black selected-row highlights. The active section's Back/title/Profile
+band stays pinned while content scrolls, so INPUT never disappears above
+Mouse sensitivity. A short `P` gutter marker and footer legend retain
+per-profile scoping without the old `(per profile)` label/value collision
+at 320px. One `OvLayout` drives render, max rows, slider/drag coordinates,
+mouse row-hit bands and close box. No config, input binding, save format,
+N64 game-code, or F10 audio queue changes. `GE_OPTIONSOVERLAY=2..6`
+optionally auto-opens a particular section for deterministic screenshots
+(`=1` remains the category root).
+
+**Verification:** NTSC compile/link clean; native-Windows `GE_PCDUMP`
+front-end root and GRAPHICS capture plus 320x240 stage GRAPHICS capture
+(no crash/overflow; orderly `GE_QUITFRAME` exit). Captures:
+`scratch/d362-test/` (local, not distributed). User ini and EEPROM restored
+byte-identically after each run. A final tiny title/footer polish rebuild
+compiled, but the running playtest held `build-pc/ge007.x86_64.exe` open;
+linked identical objects to `scratch/d362-test/ge007-d362.exe`, and the
+local `scratch/playtest_v040.bat` installed that binary on the next
+closed-game launch (guards against a concurrent game process; installed exe
+size/timestamp match the staged build). Live human
+review of palette, label fit, mouse clicks/drag/wheel, controller focus,
+back/close, longest GRAPHICS labels and the F10 Audio regression is owed.
+**User follow-up:** dossier/paper styling rejected; see D363 for the dark,
+watch-green successor. D362 is retained as the design/iteration record.
+
+## D363 — F10 returns to a dark overlay, restyled after GE's green watch (2026-09-27)
+
+**User feedback on D362:** "make it more like how it was but use the watch
+style as a reference — green, that other font etc." The watch's actual
+options pages (`src/game/options.c:3524+`, `:3825+`) use Bank Gothic and
+mint/bright green ink (`0xA0FFA0F0` selected, muted green unselected).
+The paper card, Zurich Bold, gold, red, numbered categories and dossier
+branding from D362 were not the desired direction.
+
+**Fix (port-only):** restore the original F10's simple dark translucent
+panel and full-screen dim; use the existing Bank Gothic font for all rows,
+subdued green for ordinary text and bright watch green for selection,
+active values and slider knobs/fill. The `PC OPTIONS` title and close `X`
+replace MI6/CLOSE; category names return to their uppercase row labels.
+Keep D360's two-level navigation and D362's shared layout/hit geometry,
+scrolling and pinned section heading; no setting behavior or save changes.
+The mixed-scope profile marker stays short to avoid 320px overlap.
+
+**Verification:** NTSC build/link clean. `GE_OPTIONSOVERLAY=4`,
+`GE_PCDUMP=160-160` with native Windows `GE_QUITFRAME=170` passed in both
+front/menu and `-level_09` stage contexts at 640x480: dark panel and green
+pixels present (front/stage captures in `scratch/d363-test/`), no errors,
+orderly exit. User ini and EEPROM restored byte-identically after both.
+Live review of colour, text, click/drag/wheel, controller, ESC/close and
+F10 Audio remains owed.
+**User follow-up:** palette/font approved, but P shorthand, low-contrast
+legend, slider mouse hit position and off-centre input defaults need work;
+see D364.
+
+## D364 — Profile scope, F10 slider hitbox and calibrated input displays (2026-09-27)
+
+**Reported:** user likes D363's palette but not `P` / `P = PROFILE`;
+controls legend reads like setting text; clicking the visible F10 slider
+requires aiming above the bar; Input defaults such as deadzone and trigger
+show ~23% rather than a visually centred 50/100 or 5/10.
+
+**Diagnosis:** the F10 bar was at `rowY + 10..12` (knob `+8..14`), but
+`overlayRowAtY` assigns the row only within `rowY - 8..+8`: the bar/knob
+landed in the NEXT row's hit band. The input values were physically correct
+(raw `MouseSensitivity=100` of 1..500; `PadDeadzone=7000` of 0..30000,
+~21% full-stick travel; `PadTriggerPct=23` of 1..99), but the UI treated
+them as linear full-range sliders rather than calibrated defaults. AUDIO's
+`Bond.Music`/`Bond.FX` rows were watch-backed but lacked `.saveScoped`, so
+the promised `(Profile N)` title was missing despite every Audio row being
+per-profile.
+
+**Fix (port-only):** move F10 slider bar to `rowY+3..5` and knob to
+`rowY+1..7`, both entirely inside the row's `-8..+8` click band. The one
+shared slider-bar geometry still drives dragging. Give the control legend
+its own dark inset strip with muted-grey Zurich text (Bank Gothic remains
+for SETTINGS); likewise use a separate font/colour for scope notes. Remove
+`P`/legend. Tag each saved Gameplay toggle `(profile)` beside its label
+when measured space permits; Audio is entirely per-profile, so its title
+now carries `(Profile N)` (both UIs) and no per-row tags. The front dossier
+also suppresses redundant inline Audio tags. Input's three calibrated
+sliders now display 0–100, mapping each *unchanged* raw default to 50/100
+via a two-segment monotonic map preserving raw minimum and maximum; bars,
+mouse drags and the shared front options screen use that same map. A
+`(50 = original)` note explains the scale. Other sliders (audio %, texture
+filter x, FOV/HUD %) keep their physical units and authored defaults.
+Reset still writes the original raw defaults, config/ini values are unchanged,
+and input sensitivity/deadzone/trigger behavior is untouched.
+
+**Verification:** NTSC build/link clean. Native-Windows `GE_OPTIONSOVERLAY=2`
++ `-level_09` stage screenshot at 640x480 and orderly `GE_QUITFRAME=180`
+pass, with the user ini/EEPROM restored byte-identically after the run.
+Mouse click/drag on real 4:3/16:9 front/stage, reset and visible tooltip
+fit still need an interactive user pass. User accepted most of D364 but
+requested smaller profile annotation, a `Controls:` heading with smaller
+instructions, and a visible mouse-change target; see D365.
+
+## D365 — F10 annotations and bounded mouse-change controls (2026-09-27)
+
+**Reported:** `(profile)` is visibly larger than its setting label; the
+Controls legend should read `Controls:` with smaller following instructions;
+it isn't visually clear that a label click *selects* but the right column
+*changes* a setting.
+
+**Fix:** Bank Gothic (the smaller watch font already used for row labels)
+now renders dim profile tags and section scope notes instead of the larger
+Zurich Bold. The footer's `CONTROLS:` heading keeps Zurich Bold, but its
+instructions use smaller, dim Bank Gothic, including `BOX: CHANGE` within
+sections. Draw subtle green-bordered dark control boxes around every section
+row's right-hand interactive area, brightening the hovered box without
+moving keyboard selection. Restrict left/right mouse changes to the same
+bounded x region; label clicks still select only. Coordinates are mapped
+through `gfx_get_ui_screen_rect` for hover as for input, preserving the
+safe-area crop behavior (D316). No game logic or saved data touched.
+
+**Verification:** NTSC compile/link and 640x480 `GE_OPTIONSOVERLAY=2`
+`-level_09` frame-160 capture with clean frame-180 quit; ini and EEPROM
+restored byte-identically after testing. During compilation the user's
+existing game process held the exe, so it was first linked to a staged
+scratch path rather than overwritten; final build linked directly after
+that process exited. Mouse hover/click in a live window is still owed.
+
+## D366 — Input slider calibration: “default,� not “original� (2026-09-27)
+
+**Request:** user prefers the GEPD bundle as the standard for input and
+wants the `(50 = original)` note to read `(50 = default)`.
+
+**Fix:** changed both settings UIs' Input-header text to `(50 = default)`.
+`calibratedDefault()` still maps the port's current *real* defaults
+(`MouseSensitivity=100`, `PadDeadzone=7000`, `PadTriggerPct=23`) to 50/100,
+and `kResetDefaults` agrees with `input.c`. No live config or save edited.
+**Caveat:** GEPD's emulator bundle is an external reference; this checkout
+has a stub in `reference/mouse-injector/` and its `GEPD-INPUT-PLAN.md` records
+its camera aim formulas, but neither has an authoritative bundle controller
+config for all three numbers. The port's 38/50 per-mode mouse speeds were
+user-calibrated for its own FOV. Don't claim numeric parity with the GEPD
+bundle or re-tune default input feel without the user's bundle configuration.
+
+**Verification:** NTSC compile and staged link succeed. The active user's
+playtest held `build-pc/ge007.x86_64.exe`, so the new binary was linked to
+`scratch/d366-test/` and the local v040 launcher installs it only after the
+running game exits. No stage smoke test while the user's save is active. The user later exited;
+D367 includes the subsequent native stage smoke test of both changes.
+
+## D367 — F10 control frames aligned with their actual clickable contents (2026-09-27)
+
+**Reported:** D365's full-width rectangles do not align with their text
+contents. Root cause: every row used a fixed `[barX0, right-7)` box despite
+right-aligned toggles/enum text near `right-12`, slider bars ending much
+earlier at `barX1`, and reset actions having their button in the *label*
+column (empty value until armed). This advertised empty space as clickable
+and left the reset's real target unframed.
+
+**Fix:** `overlayControlSpan()` now calculates the actual button region per
+row: tight around slider tracks, around measured right-aligned values, or
+around the reset label; non-interactive/unavailable slider rows get no box.
+Both mouse buttons and hover use that same geometry, so moving the mouse
+only highlights a genuinely clickable area. The narrower border fits the
+Bank Gothic text's vertical footprint. Label clicks on ordinary settings
+still only select; clicking the framed reset label invokes its existing
+two-step confirmation. No game logic or config/default value changes.
+
+**Verification:** NTSC compile/link passed, native 640x480
+`GE_OPTIONSOVERLAY=2 -level_09` stage capture and orderly frame-180 exit;
+ini/EEPROM restored byte-for-byte. Mouse click/hover and confirmation remain
+to be feel-checked live. User's immediate playtest found remaining boxes
+wrong; replaced by the simpler D368 rule.
+
+## D368 — F10: name selects, right-of-name changes (2026-09-27)
+
+**User decision:** stop trying to frame each differently sized value; all
+of the row except the setting name should change it. D367's boxed controls
+were still misleading in a live test.
+
+**Fix (port-only):** `overlayControlSpan` now starts after the measured
+Bank Gothic setting name (capped at its label-column clipping edge) and
+extends to the panel's right edge. It is used by mouse hover, left/right
+click and a single subtly tinted strip with a thin left divider; the
+per-value borders are gone. Clicking a name still focuses without changing.
+A slider click on its track positions/drags; clicking elsewhere to the right
+of its name changes it by one step instead of snapping to the minimum or
+maximum. Reset is an action, so its whole row activates the existing
+confirmation. The footer now says `RIGHT: CHANGE`. No config/save or game
+logic changes. NTSC build/link passes; live mouse check still owed.
+
+## D369 — Remove F10 per-row paint (2026-09-27)
+
+User still saw boxes in D368 because the tinted per-row change strips and
+selected-row rectangles were still painted. Removed **all** of those from
+`optionsOverlayEmit()` (including hover paint). Selected text remains
+brighter, sliders retain only their track and knob, and the existing dark
+panel/Controls footer remain. Mouse hit behavior from D368 is unchanged.
+NTSC compile and staged link pass; a running playtest still holds the old
+exe, so the local launcher will install `scratch/d369-test/ge007-d369.exe`
+only after the game exits. No save/ini touched.
+
+## D370 — Avoid redundant work during F10 slider drags (2026-09-27)
+
+**Reported:** dragging Draw/LOD distance, Music or FX volume drops FPS.
+**Diagnosis:** `sliderSetFromX` runs on every controller poll while LMB is
+held, including repeated polls over the *same snapped value*. `rowSetCommit`
+then re-enqueues watch audio updates and, for *any* `Video.*` key, requests a
+`videoStartFrame` live-config pass: `set_swap_interval`, FPS cap, texture
+filter, image state and a log line. Draw/LOD multipliers are already read
+straight from their config pointers by the render path, so no such pass is
+needed. The active playtest log has hundreds of `video: live config applied`
+lines and multiple watch-setting persists; log alone does not isolate all
+of those calls to one knob. Physically increasing Draw/LOD can independently
+increase rendering load, which this fix will not hide.
+
+**Fix (port-only `optionsoverlay.c`):** early-return before live-apply/queue
+when a snapped value has not changed (including queued watch settings); held
+mouse polls no longer repeat the same audio setter or GL state work. Retain
+slider-release `watchSettingsCommit` so a drag persists exactly once. Exempt
+only `Video.DrawDistance` and `Video.LodDistance` from
+`videoRequestLiveConfig`; all other Video keys retain their old live-apply
+semantics. Audio updates still preview live for genuinely changed detents.
+No user config/save or game code edited. NTSC compile + staged link passed;
+active Windows game initially held the executable; the local launcher
+installed the staged binary on the user's next safe launch (now running).
+Live FPS retest (ideally compare dragging
+Music/FX while paused vs simply raising Draw/LOD values) owed.
+**User playtest:** "it can still drop it down if you spam it but mostly good."
+Residual not isolated: the latest playtest log shows repeated full video
+config passes while changing FOV (not Draw/LOD) and several watch EEPROM
+persists; sustained high Draw/LOD also increases actual GPU work. Parked as
+polish until a focused repro identifies which slider/spam pattern dominates.
+
+## D371 — GEPD key-layout preset + reload/crouch bindings (v0.4.0 modern options wave, M3) (2026-09-27)
+
+**Spec:** `docs/dev/notes/MODERN-OPTIONS-PLAN.md` M3. **Deviation from plan
+(user decision, 2026-09-27):** the planned "MODERN" F10 section is DROPPED —
+it is a provenance bucket, not a functional category, which conflicts with
+the D356 Turok-standard "every section is functional, baseline names" rule
+(the same rule that retired the D353 BOND FILE section). The rows land in
+the functional section (INPUT); no new category, no `__ResetModern` — the
+existing per-section reset rows cover the new keys via `kResetDefaults`.
+
+**Implementation (port-only; `port/src/input.c`, `port/include/input.h`,
+`port/src/optionsoverlay.c`):**
+- New actions `IA_RELOAD` / `IA_CROUCH` (emits `GE_CONT_B` both; on N64 the
+ single B button does reload-tap vs crouch-hold, and the game's own
+ `bond_interact_object` context logic makes that choice — so the port
+ emits B and leaves the decision to the game, as on the N64). Defaults are
+ empty strings = unbound (byte-identical N64 layout out of the box).
+- `Input.Layout` (0 = FPS defaults, 1 = GEPD) + `Input.CrouchMode`
+ (0 = hold, 1 = toggle/latched). The GEPD preset
+ (GEPORT-REFERENCE-DEEPDIVE.md section 7.3: Action=Q, Cancel=E, Reload=R,
+ Crouch=Left Ctrl, Fire="" — LMB fires/RMB aims, LeanLeft="" — lean is
+ GEPD-removed) acts as the EFFECTIVE DEFAULT only for actions the user has
+ not individually overridden in `[Input.Bind]`; per-key ini values layer
+ on top, so switching layouts never clobbers explicit binds. Revert =
+ switching back.
+- Toggle crouch latches B; the latch is dropped on entering a menu (a
+ latched B would read as back/cancel) and on `inputLayoutApply()`.
+- F10 rows: "Key layout" (kKeyLayout) + "Crouch mode" (reuses kHold) under
+ INPUT, before its reset row.
+- `inputRebuildBinds`: an intentionally-empty string no longer logs the
+ "action unbound" warning; only a NON-empty string that yields no valid
+ keys warns (D214's warning was written when no default was ever empty).
+- `optionsoverlay.c` `rowSetCommit` hook: `Input.Layout` /
+ `Input.CrouchMode` -> `inputLayoutApply()` (scheduler thread; a mid-poll
+ rebuild can at worst drop/add one scancode for one frame — the D214
+ thread-pairing, just at F10 commit time instead of boot).
+
+**Verification:** NTSC build clean. GEPD boot (`Layout=1`, `CrouchMode=1`,
+`Bind.Reload=R`, `Bind.Crouch=Left Ctrl`): no crash, no spurious warnings.
+ini round-trip via orderly `GE_QUITFRAME` exit: `Layout`/`CrouchMode`
+persist under `[Input]`; `Reload`/`Crouch` under `[Input.Bind]` (config.c
+`splitKey` splits on the LAST dot — pre-existing D214 behaviour, binds were
+always saved there with bare leaf names). F10 INPUT page
+(`GE_OPTIONSOVERLAY=2`) boots clean, both rows resolve. **Owed:** live F10
+layout-switch feel-check (GEPD vs FPS), in-game crouch toggle/reload B
+semantics + menu-latch-reset, PAL/JP.
+
+## D372 — Low-end performance preset row (v0.4.0 modern options wave, M5) (2026-09-27)
+
+**Spec:** `docs/dev/notes/MODERN-OPTIONS-PLAN.md` M5. One row in the
+functional VIDEO section (no MODERN bucket — same D356 Turok-standard
+decision as D371).
+
+**Implementation (port-only; `port/src/video.c`, `port/src/optionsoverlay.c`):**
+- `Video.LowEndMode` (0/1, default 0) registered in `videoConfigInit`. The
+ key only records which preset the user last selected; the effect flows
+ through the two real knobs.
+- F10 row "Low-end mode (restart)" (VIDEO section, after Frame rate cap):
+ ON → `rowSet(Video.FpsCap, 30)` + `rowSet(Video.MSAA, 1)`; OFF → 60 / 2
+ (the compiled defaults). We do NOT remember per-user pre-preset values —
+ the defaults are the N64-original setting, and a single well-known restore
+ point is simpler and predictable (deviation from plan §M5's
+ "pre-toggle values" wording; plan's parenthetical already notes the
+ defaults are the expected pre-toggle state).
+- FpsCap applies live (`videoRequestLiveConfig` via the row's own commit);
+ MSAA is a restart row (applied at next GL context rebuild), hence the
+ "(restart)" label.
+- `kResetDefaults`: `Video.LowEndMode = 0`.
+
+**Verification:** NTSC build clean. F10 VIDEO page (`GE_OPTIONSOVERLAY=6`)
+boots clean — row resolves, no "not registered" warning. `LowEndMode=1`
+survives an orderly `GE_QUITFRAME` exit. **Owed:** live F10 preset
+toggle (ON→FpsCap/MSAA written, FPS readout drops to 30), restart-for-MSAA
+check, PAL/JP.
+
+## D373 — In-game crosshair on/off + colour (v0.4.0 modern options wave, M2) (2026-09-27)
+
+**Spec:** `docs/dev/notes/MODERN-OPTIONS-PLAN.md` M2 — the wave's ONLY
+rule-2 item (the port intentionally differs from N64 rendering when the
+option is on; sign-off recorded 2026-09-27 via user direction to
+implement + live test).
+
+**Implementation:**
+- `port/src/gunfire.c` `gunDrawSight`: `#ifdef PORT` guard only.
+ `Video.CrosshairHide` → early return (no texSelect, no draw);
+ otherwise `portCrosshairTint()` supplies the RGB args that the N64
+ hardcodes to 0xFF,0xFF,0xFF (alpha 0x6E unchanged). Non-PORT builds
+ keep the original literals byte-identical (local `extern`s inside the
+ function, the bondview2 `portNoHitFlash` pattern; single consumer, no
+ header declaration).
+- `port/src/video.c`: `s32 portCrosshairHide` (non-static, D232
+ pattern) + `portCrosshairTint()` — 7 named tints (index 0 = white =
+ the N64 identity), `Game`-section registration `Video.CrosshairHide`
+ (0/1) + `Video.CrosshairColor` (0..6).
+- `port/src/optionsoverlay.c`: GRAPHICS rows "Crosshair" (kOnOffRev,
+ 0 = On) + "Crosshair colour" (kCrosshairColor) + kResetDefaults.
+
+**Verification:** NTSC build clean. `GE_OPTIONSOVERLAY=4` (GRAPHICS
+page) boots clean — both rows resolve. Headless level captures (dam,
+`-level_33`) with hide/red-tint configs: no crash; the crosshair is
+suppressed in cutscene frames (gunDrawSight only draws in live
+gameplay), so the visual A/B was done by the user: **crosshair shows in
+normal gameplay (user-confirmed 2026-09-27, "crosshair showed up")**.
+Defaults (hide=0, colour=0) leave the render byte-identical to the
+pre-wave tree. `betacrosshairimage` confirmed dead (plan §M2) — one
+hook covers in-level rendering.
+
+## D374 — GEPD becomes the default key layout; preset layering fix (v0.4.0 M3 playtest feedback, 2026-09-27)
+
+**Trigger:** first GEPD playtest (user, 2026-09-27): "Crouch is broken,
+in GEPD mode ctrl shoots the gun" + "why is FPS different than GEPD? GE
+is a first person shooter ... make [GEPD] the default and call it
+default."
+
+**Diagnosis (two distinct bugs, one user-visible symptom):**
+1. The M3 "preset as effective default" only applied to EMPTY
+ `g_bindStr`. But the config migration writes EVERY bind key into the
+ ini on clean exit, so any pre-existing ini carried the full set of
+ persisted N64-layout defaults (`Fire = Left Ctrl`, `Action =
+ Space,Z,E`, ...) — all non-empty — and the preset was silently dead
+ for real users. Switching to GEPD left `Fire = Left Ctrl` in force
+ while the preset added `Crouch = Left Ctrl` → **Ctrl fired and
+ crouched at once**.
+2. Naming/expectation: in the GEPD scheme (DEEPDIVE §7.1) Q = A button
+ (accept/next weapon), E = B button (use/cancel/crouch/reload). The
+ stale ini's `Action = Space,Z,E` kept E on the A button (weapon
+ cycle), not B (use) — the user correctly expected E = USE.
+
+**Fix (user decision 2026-09-27; port-layer only, zero game-logic
+change):**
+- **GEPD is now the DEFAULT layout** (`Input.Layout`: 0 = GEPD — the
+ natural mouse/FPS default; the N64 never had a keyboard, so the old
+ "FPS" default was always a port artifact — 1 = the N64-era keyboard
+ binds, byte-identical to the pre-wave set, one click away). F10 row
+ now reads "Default" / "N64".
+- **True effective-default semantics** (`inputRebuildBinds`, D374
+ precedence): an EXPLICIT per-key `[Input.Bind]` value (non-empty and
+ ≠the N64 default, or an explicit unbind of a default-bound action)
+ wins; a persisted value that merely equals the N64 default counts as
+ UNSET, so the layout preset fills it. With this, the user's existing
+ ini (full of persisted defaults) gets the clean GEPD preset:
+ Fire="" (LMB/RMB fire/aim), Crouch=Left Ctrl, Reload=R, Action=Q
+ (A), Cancel=E (B=use) — no Ctrl conflict.
+- Boot log line: `input: layout GEPD (default)/N64, crouch mode ...`
+ (verifiable in `ge007.log` without an F10 screenshot).
+- **One-off semantics flip:** an ini with the pre-D374 `Layout = 1`
+ (which meant GEPD) now means N64. No in-code migration marker exists
+ (the value is ambiguous); the dev ini was flipped to 0 by hand —
+ note it in the v0.4.0 release notes for users who had selected GEPD.
+
+**Verification:** user ini (stale binds, `Layout = 0`): boot log
+"layout GEPD (default), crouch mode hold", no "no valid keys" warnings,
+clean exit persists `Layout = 0`. `Layout = 1`: "layout N64". Ctrl no
+longers fires (Fire unset under the preset); crouch/reload/E-use per
+preset. **Owed:** user re-playtest (Ctrl crouch, E use, Q cycle) before
+the wave closes.
+
+## D375 -- Crouch wired to the wrong pad bit (v0.4.0 M3 playtest, 2026-09-27)
+
+**User report:** "crouch still does not work" after D374. The ini was checked
+first: clean (`Crouch =` empty, preset applies) -- **not the cause**.
+
+**Diagnosis:** the engine never reads B for crouch. bondview2.c:5318/5579:
+`moveData.crouchDown = insightaimmode && (buttons & (D_JPAD | D_CBUTTONS))`
+(keyboard/stick variant: `insightaimmode && stickY < -30`). N64 crouch was
+**Z (aim) + C-down**. M3's `IA_CROUCH -> GE_CONT_B` (and the toggle latch)
+was inert -- the bit the game reads was never set.
+
+**Fix (port-layer only, `port/src/input.c`):** IA_CROUCH hold + the
+`CrouchMode=1` latch now emit `GE_CONT_D` (0x0004, C-down). Side effect,
+N64-faithful: S (IA_BACK, already C-down for "back") also crouches while
+aiming, exactly as C-down did on N64. Crouch therefore only works **while
+in aim (insight) mode** -- holding RMB/Left Shift + Left Ctrl. On release
+(or leaving aim) the player stands (bondview2's `crouchUp` branch).
+
+**Verification:** build clean; clean boot + exit. **Owed:** user live
+re-test (aim + Ctrl, both hold and F10 Toggle mode). Cross-tag: the D373
+entry's "emits B" wording was superseded by D375 (reload still emits B;
+crouch no longer does).
+
+## D376 — Crouch follow-up: control-style axis and pad mapping (2026-09-27)
+
+**Diagnosis:** The 1-controller branch in `bondview2.c:5579` reads C-down
+for BOTH 1.1/HONEY and 1.2/SOLITARE. The stick-Y crouch at 5318 is the
+**two-controller** branch; it does not apply to the port's single-controller
+layout. The existing gamepad B/Y mapping in `port/src/input.c` still sent B
+in stages (never crouch). Also D375's keyboard C-down was emitted in menus
+and outside aim, where it can navigate or move backwards instead.
+
+**Fix (port only):** keyboard Ctrl and pad B/Y feed one hold/toggle latch;
+in-stage, while aiming, send C-down for both single-controller schemes.
+Do not emit crouch/navigation input in menus or outside aim; B/Y retain
+B/cancel in menus. Drop the latch at menu/overlay transitions. No game-code
+edits. The engine still *requires aim* for crouching; releasing aim before
+releasing crouch may leave the posture down until aim resumes (N64
+constraint, not a promise that Ctrl alone crouches). Some weapons disable
+crouch and use down for zoom instead.
+
+**Spot check of M2/M3/M5:** M2 hide/tint hook defaults to the original white
+render and keeps the non-PORT branch unchanged (`gunfire.c:gunDrawSight`);
+M3 E and R both send context-sensitive B, **not** a guaranteed
+interaction-free reload (deferred v2 reload gate in the plan); D374
+preset layering treats N64-default strings as unset, which also means an
+explicit custom binding *equal to* the N64 default cannot override GEPD;
+for the default-empty Reload/Crouch keys, an empty string likewise cannot
+express an intentional unbind of the GEPD preset (the ini writer stores
+empty for both "unset" and "explicitly unbound"). This needs a separate
+config migration/presence design before promising full rebind semantics.
+M5 low-end row writes 30fps/MSAA 1 on selection and restores 60/2 on
+deselection (does not remember custom values); the individual settings
+are stored separately, so `LowEndMode` describes the last preset selection,
+not an enforced invariant after subsequent ini edits. No other code fix
+made in this spot check.
+
+**Verification:** touched object compiles (two pre-existing warnings);
+linked alternate `build-pc/ge007.review.exe` because the normal exe was
+locked by a running process. Scripted `-level_33` boot via
+`GE_INPUTSCRIPT='60:RHOLD;90:CDOWN'` with `GE_INPUTLOG=1` observed
+`btn=0014` (R+C-down) during pulses, normal self-quit at frame 140.
+This tests native pad delivery, **not** live SDL keyboard or physical
+pad input. **Superseded by D377:** user confirmed GEPD freely crouches
+without aim; the aim gate here was wrong for the GEPD preset and prevented
+the requested feature from working as advertised.
+
+
+## D377 — GEPD independent crouch via native player stance (2026-09-27)
+
+**User correction:** GEPD Ctrl crouches freely without aim. D375/D376
+mistook N64's controller chord (aim + C-down) for the GEPD *input injector's*
+dedicated crouch behavior. `docs/dev/GEPD-INPUT-PLAN.md` WI-6 had already
+identified the GEPD mechanism: port-side stance flag write, not a pad bit.
+The source is no longer vendored here (GPL stub in `reference/mouse-injector/`),
+so no injector code is copied into the port.
+
+**Diagnosis:** `bondview2.c:5579` gates native C-down crouch on
+`insightaimmode`; `5969–5975` updates `g_CurrentPlayer->crouchpos` via
+`currentPlayerAdjustCrouchPos`; `bondview.c:1543–1560` clamps it to
+`CROUCH_SQUAT..CROUCH_STAND`; `bondview2.c:7038–7074` interpolates
+`ducking_height_offset` from `currentPlayerGetCrouchPos()` (minimum of
+`crouchpos` and `autocrouchpos`). No pad bit can request independent crouch
+without the game's aim gate. This is a PC control feature, not an N64
+game-code divergence: implement in the port, no rule-2 game edit.
+
+**Fix (`port/src/input.c`):** the Ctrl and gamepad B/Y hold/toggle latch
+now writes `g_CurrentPlayer->crouchpos = CROUCH_SQUAT` while active and
+restores `CROUCH_STAND` on release; the game continues to animate the
+height offset itself and automatic crouch still works via its separate
+`autocrouchpos`. While already aiming, the port also feeds native C-down
+so the game's per-tick `crouchUp` branch cannot undo the held stance; without
+aim, no C-down is needed. The adapter does not force stand after a native
+C-down crouch, and the native game path remains unchanged. No effect in
+menus, watch, pause, death, tank, scripted camera,
+or on weapons whose flags disable crouching; no B action is emitted in a
+stage from pad B/Y. `GE_INPUTSCRIPT` gained CHOLD/CREL for headless stance
+checks. Holding a bind through a menu/overlay drops the latch.
+
+**Verification:** compiled and linked `build-pc/ge007.review.exe` (normal
+exe still locked). On `-level_09`, `GE_INPUTSCRIPT='30:START;300:CHOLD;600:CREL'`
+with `GE_INPUTLOG=1 GE_QUITFRAME=780` logged **free crouch applied
+(aim=0)** and **free crouch released**, with normal self-quit. The same
+run with simultaneous RHOLD/CHOLD also applied and released the stance.
+A Dam `-level_33` control run kept the scripted-camera gate closed during
+its intro, as intended. User later said crouch was good in live testing; which of hold/toggle and
+pad B/Y were tried was not specified, so those specific checks remain open.
+
+
+## D378 — GEPD E/R were both B (use/reload separation, 2026-09-27)
+
+**User report:** R opens doors; question whether E is use-only. **Confirmed:**
+D371 mapped `IA_RELOAD` *and* `IA_CANCEL` (GEPD E) to `GE_CONT_B` in
+`port/src/input.c`. `bondview2.c:5642/5712` sets `field_D0` on a B tap;
+`lv.c:796` calls `bond_interact_object()` and reloads both hands via
+`attempt_reload_item_in_hand()` only if the interaction found no target.
+Therefore **R opens doors and E reloads with no nearby target**. The M3
+plan's "we get it for free" premise was incorrect: the B context alone
+cannot implement two separate actions. Native single-button B stays intact
+for N64 controls; this is a PC input feature, not an N64 game-code bug.
+
+**Fix (`port/src/input.c` only):** with GEPD layout in a playable stage,
+E's rising edge calls the existing `bond_interact_object()` (its return
+value is ignored: no fallback reload), while R's rising edge calls the
+existing `attempt_reload_item_in_hand(GUNRIGHT/GUNLEFT)` without B/door
+interaction. During front menus, watch, tank, pause/cutscenes, E retains
+native B/cancel; R does not fire while controls are unavailable. Gamepad
+B/Y still drives crouch in stages and B/cancel in menus (D377).
+Use/reload edges are tracked across polls and the F10 overlay to avoid
+held-key repeat or action leaks. **Caveat:** input sampling runs before
+`lv.c`'s original after-`propsTick` interaction site, so the port call can
+use the previous frame's on-screen prop list. A real door/terminal playtest
+is needed to check for a one-frame targeting discrepancy; no `src/game`
+behavior edits were made. Special N64 B combos (e.g. remote-mine A+B)
+are not promised on GEPD's dedicated E key.
+
+**Verification:** touched object compiled (only pre-existing warnings),
+`build-pc/ge007.review.exe` linked (normal exe can be locked by a running
+playtest). Headless Bunker1 `-level_09` with
+`GE_INPUTSCRIPT='30:START;300:UHOLD;315:UREL;450:RELOADHOLD;465:RELOADREL'`
+logged exactly one `dedicated use (no target=1)` and one `dedicated reload
+(no use)`; exit clean at GE_QUITFRAME=800. The scripted scene had no door
+target and does not prove interaction targeting or a visible magazine refill;
+live R-near-door, E-near-door, E-no-target, R-with-partial-mag verification
+remains owed. Cross-ref D371/D376/D377.
+
+
+## D379 — Crosshair colour is multiplied against a red texture (2026-09-27)
+
+**User report:** Yellow/Cyan/Blue are visibly the wrong colours.
+`gunDrawSight` (`src/game/gunfire.c:6550`) passes the RGB from
+`portCrosshairTint` to `display_image_at_position`, whose
+`G_CC_FADEA` combine mode multiplies TEXEL0.rgb by ENVIRONMENT.rgb.
+The authored `IMAGE_CROSSHAIR1` is not a neutral white mask: captured
+`-level_09` with GE_TEXDUMP, fmt=RGBA32, size=4096, tile=32x32;
+`texdump/r331_f0_s3_32x32.bin` had 787 fully transparent black pixels,
+232 nonzero RED pixels and essentially zero green/blue (examples
+`(235,0,0,203)`, `(245,0,0,255)`). A blue tint multiplied by this image
+remains dark/black/red; the user observation follows exactly. The texture
+alpha retains the antialiased reticle silhouette (237 nonzero pixels).
+
+**Fix:** preserve byte-identical default (White/0) and the N64 builds.
+Only for selected nonwhite tints, `src/game/gunfire.c`'s existing
+`#ifdef PORT` opt-in crosshair hook calls
+`portCrosshairApplyTintCombine()` in `port/src/video.c`, replacing only
+that sprite's generated combine command. Colour now uses
+`ENVIRONMENT.rgb * TEXEL0_ALPHA`, transparency stays
+`TEXEL0_ALPHA * ENVIRONMENT_ALPHA` (the original alpha expression).
+The hook points at the command immediately after
+`display_image_at_position`'s `gDPSetEnvColor`; the latter emits the
+combine command next (`bondwalk2.c:136–155`). This is the same opt-in
+crosshair-colour behavior previously authorized in D373, corrected to
+actually show the named colours; not a new default-game behavior change.
+No generic texture/combiner path is affected.
+
+**Verification:** touched NTSC objects compile and alternate
+`build-pc/ge007.review.exe` links. With user's Cyan (5) ini and a scripted
+Bunker1 aim, GE_PCDUMP at frames 1050–1090 shows ~2130 cyan-dominant
+pixels in the reticle region (x=664–774,y=385–505), vs zero in the same
+region outside aim; screenshot-independent pixel count, no unsupported
+combiner errors. The temporary GE_TEXDUMP 800-cap probes in gfx_pc/
+gfx_opengl were reverted, and the user's ini was not edited for the
+capture. Owed: human Blue/Yellow/Cyan and White default visuals in the
+actual playtest window. Cross-ref D373.
+
+
+## D380 — Remove the N64 keyboard preset; migrate to one honest PC layout (2026-09-27)
+
+**User decision:** no N64 equivalent on PC; remove the N64-vs-GEPD key
+layout selector and consolidate. Rebindable keys like the PD port should
+be investigated next. The menu row used to hide the effective GEPD strings
+behind persisted N64-era defaults (`Input.Bind.Fire = Left Ctrl` even though
+Ctrl was actually crouch); it also made explicit unbinding of empty-default
+Reload/Crouch impossible (D374/D376).
+
+**Implemented (`port/src/input.c`, `port/src/optionsoverlay.c`):** remove
+`Input.Layout` registration and F10 INPUT row. There is one PC layout.
+`Input.Bind.*` now represents effective bindings directly: a fresh ini
+contains Fire empty, Action Q, Cancel E, Reload R, Crouch Left Ctrl,
+LeanLeft empty; `NONE` explicitly unbinds any action (PD convention).
+`Input.BindingsVersion=0→1` performs a one-time migration of legacy ini
+values equal to the old N64 defaults to the real PC values, preserving
+non-default custom bind strings; then writes the updated ini and drops
+the obsolete Layout key. There is no perfect way to distinguish a user's
+explicit N64-default-valued custom bind from a stale auto-written default
+in a pre-versioned ini: the one-time migration treats both as defaults,
+matching D374's previous behavior. Once migrated, bindings with those
+same strings can be intentionally set and retained. The crouch mode row
+still invokes `inputLayoutApply` to drop the latch/rebuild parsed keys;
+the exported function can also serve the later capture UI.
+
+**Verification:** touched input/options objects compile and alternate exe
+links. Backed up the user's ignored `data/ge007.ini` before a 90-frame
+headless boot, observed the one-time migration: Layout removed;
+BindingsVersion=1; Fire empty, Action=Q, Cancel=E, LeanLeft empty,
+Reload=R, Crouch=Left Ctrl; normal quit. Then restored the original
+user ini byte-for-byte (live game may still use it until next boot).
+
+**PD-port analogue / next work:** `../pd_port/port/src/optionsmenu.c:1694–1885`
+has a binding capture dialog (next key/button; ESC cancels, DEL clears)
+and multiple slots per action; `port/src/input.c` there collects
+SDL_KEYDOWN/MOUSEBUTTONDOWN/controller events into a last-key queue and
+serializes `NONE`. GE already has multi-key ini strings but no UI; add
+`ROW_BIND` to the shared F10/front-options row data with primary +
+secondary slots, action labels and values, and a modal capture state.
+`port/src/video.c:videoPumpEvents` owns SDL events on the host thread,
+whereas `optionsOverlayHandleInput` runs on the scheduler thread, so
+handoff must be synchronized (one-shot scancode/button event, ignore the
+opening click/press). Escape cancels, Delete clears to `NONE`, and F10/
+mouse-wheel/overlay navigation must be swallowed during capture. Use
+`inputLayoutApply` to rebuild + persist on commit, expose only actions
+that can actually be remapped (mouse LMB/RMB are currently hardwired).
+No binding-capture UI is implemented yet; first verify the D379 colours
+and D378 doors/reload with the user.
+
+
+## D381 — RULE-2-SIGNOFF: opt-in RGB, size and Beta crosshair (2026-09-27)
+
+**Status: IMPLEMENTED; live visual acceptance and golden sweep owed.** The
+user asked for an RGB slider in the menus, then crosshair size and alternate
+in-game art. After the concrete design was explained (Original/default,
+Custom RGB with a swatch, 50–200% size, Original/Beta using the existing
+`IMAGE_CROSSHAIR2`), the user explicitly replied **“OK proceed with all of
+these.�** This is an opt-in customization, not a claim that the N64 drawing is
+wrong: with the default Original/100%/Original, the same game code draws the
+same source image at the same dimensions with the same N64 combiner. There
+is therefore no alleged default N64 divergence to prove. D379 already
+established the actual red source image/combiner contract (not a port bug in
+the default path). The user-approved opt-in behaviour deliberately differs
+from the fixed N64 appearance; it is not an ABI/layout fix.
+
+**Narrow src/game scope:** `src/game/gunfire.c:gunDrawSight`, inside the
+existing `#ifdef PORT` crosshair hook (D373/D379) only. The exact
+`texSelect`/`display_image_at_position` calls determine which of the two
+32×32 asset-table entries is uploaded and the half-size of the sprite. These
+cannot be changed safely from `port/` alone without intercepting and
+rewriting another game's texture/draw display list after the fact. Only the
+opt-in image pointer and half-size are selected; the aim coordinates, alpha,
+other sprites, and unguarded N64 `#else` code are unchanged. PD's analogous
+port uses configurable sight colour/size in `../pd_port/src/game/sight.c`
+and sliders in `../pd_port/port/src/optionsmenu.c`; its image formats are
+not assumed interchangeable with GE's. The previously unused
+`betacrosshairimage` is loaded in `src/game/image_bank.c` and backed by
+`IMAGE_CROSSHAIR2` in `assets/oddtextures.c`; it is **not** automatically
+selected by 007 mode.
+
+**Implementation:** `port/src/video.c` registers `Video.CrosshairRed/Green/
+Blue` (0–255, defaults 255), `Video.CrosshairSize` (50–200, default 100),
+and `Video.CrosshairStyle` (0 Original, 1 Beta). Existing colour values
+0–7 preserve their meanings; 8 = Custom. Colour 0 keeps the sprite RGB
+and original combine mode; all other colours (including Custom) use the
+D379 alpha-silhouette combiner. The three RGB sliders appear only while
+Custom is selected; changing presets does not discard their saved values.
+`port/src/optionsoverlay.c` adds a functional CROSSHAIR page because the
+GRAPHICS front page has a fixed 14-row cap; F10 and front options share its
+rows, live values, and section reset (including hidden RGB rows). Both UIs
+show a representative colour swatch; Original shows red, not the white
+identity multiplier. The game menu cursor and every other image remain
+unaffected. Ini saved under `$S/ge007.ini` as before; user ini was untouched
+for testing.
+
+**Verification:** MSYS2 MINGW64 `./build-pc.sh ntsc-final` compiled/linked.
+In an isolated temporary data directory, `-level_09` with scripted aim
+(`GE_INPUTSCRIPT='30:START;60:RHOLD'`, `GE_PCDUMP=580-580`) rendered and
+quit cleanly twice. Beta/Custom (10,200,240)/150% had 159 cyan-dominant and
+0 red-dominant pixels in the 100×80 reticle region; Original/100% had 384
+red-dominant and 0 cyan-dominant pixels there. With `GE_TEXDUMP`/`GE_TEXRAW`,
+the Beta texture was actually loaded as 32×32 RGBA32 (4096 bytes): 112
+nonzero-alpha pixels in a thin plus-shaped sight, confirming that the
+D379 colour-mask approach applies. Section row census: CROSSHAIR has at
+most 8 content rows, GRAPHICS 10, both under the front menu's 14-row cap.
+`git diff --check` passes. These scripted checks do **not** replace a human
+size/style/colour swatch inspection or the post-landing 21-level golden gate.
+
+
+## D382 — Functional crosshair page and full GPU-state reset on every slider detent (2026-09-27)
+
+**Status: PARTIAL (built; live FPS/menus and 21-level golden gate owed).** User
+asked to keep Turok-style functional categories/setting names and reported
+hitches while dragging sliders, possibly when launched with the v0.4.0 batch
+file. Evidence from the actual `scratch/playtest_v040.log`: **349**
+`video: live config applied` passes. `optionsoverlay.c:rowSetCommit` called
+`videoRequestLiveConfig()` after *every changed `Video.*` value* except
+Draw/LOD and restart rows, including all RGB sliders, size, style, colour and
+FOV. Each pass in `videoStartFrame` called `videoApplyTexFilter()` and
+`videoApplyImageOptions()` regardless of the key. In
+`port/fast3d/gfx_pc.cpp`, both texture-filter setters and the anisotropy
+setter call `reset_texture_state()` (clears texture and shader caches). These
+three resets and the log line per detent plausibly explain the crosshair/FOV
+drag hitches; a high Draw/LOD setting independently increases real render
+cost, and changing anisotropy/texture-filter themselves necessarily still
+rebuilds texture state. The log does not tie each pass to a specific row, so
+this is a proven redundant-work path, not a measured per-slider frame-time
+attribution.
+
+**Fix (port-only):** `videoRequestLiveConfigForKey()` uses atomic bitmask
+coalescing across the options and render threads. VSync, FPS cap, texture
+filter, FOV, anisotropy and safe-area crop each update *only themselves* on
+render-thread frame start. Crosshair knobs and the other direct-read
+world/HUD settings request no GL pass at all. No default, ini key or
+rendered-image behavior changes; fullscreen still uses its host-thread
+request. With the existing early-return on unchanged snapped values (D370),
+mouse-held duplicate polls do no GL work. User's local
+`scratch/playtest_v040.bat` still runs `build-pc/ge007.latest.exe` with the
+same ini and logs output, but no longer forces `GE_D322`/`GE_D204` audio
+telemetry on each run (these were cheap 5-s probes, not proven to cause
+hitches); setting them in the calling environment restores diagnostics.
+The outdated pre-D377 binary warning and launcher label were corrected.
+No running game was interrupted, and no live ini was changed.
+
+**UI decision:** Keep the Turok five functional headings and add only a
+sixth *functional* CROSSHAIR page beside GAMEPLAY (not a "MODERN" provenance
+bucket, and not a graphics-quality control). The front menu has a fixed
+14-content-row cap; merging RGB/style/size into GRAPHICS or GAMEPLAY would
+silently lose settings. It has its own reset and no change to the existing
+per-profile `Bond.Sight` option in GAMEPLAY. Rename the ambiguous duplicate
+"Crosshair" row to **Show crosshair** (`On`/`Off`, original On); colour 0 to
+**Original (red)** (keeps the authored sprite), Beta style to the visible
+shape **Thin cross**, custom channels to **Red / Green / Blue** with `N/255`
+values only while Custom is selected. **Crosshair size** displays 50–200%,
+100% default. Keys and existing numeric ini values are unchanged; selecting
+Beta does not affect the front-end cursor or automatically select 007 mode.
+The show-crosshair machine-level gate is separate from the original save-
+scoped Sight-on-screen option; both may hide the aim sight.
+
+**Verification:** MSYS2 MINGW64 NTSC rebuild linked after the selective
+update; a first compile caught a MinGW SDL without `SDL_AtomicOr`, replaced
+with `SDL_AtomicCAS` loop and then rebuilt successfully. Isolated data-dir
+headless run `GE_OPTIONSOVERLAY=4 GE_PCDUMP=90-90 GE_QUITFRAME=125` booted
+the sixth-section layout into CROSSHAIR and captured a frame with no
+missing option or crash. GRAPHICS has 10 rows and CROSSHAIR has 8, both
+within the 14-row front menu cap. `GE_OPTIONSOVERLAY=2..7` now supports
+all six sections. **Not yet measured:** live FPS during drag with the new
+binary; the 349-pass log is from the previous build. Human verification of
+both menus' labels and sizing is still owed.
+
+## D383 — PD-style keyboard rebind capture in both options UIs (2026-09-27)
+
+**Status: PARTIAL (built and isolated automated checks; live keyboard-menu
+acceptance and the release golden gate owed).** User explicitly requested
+PD-style keyboard rebinding after the D380 single-PC-layout migration.
+PD analogue: `../pd_port/port/src/optionsmenu.c:1694–1885` offers a per-action
+slot dropdown, next-key capture, ESC cancel, DEL clear, and reset. GE's
+`Input.Bind.*` string parser already supported four keys per action (D214)
+but neither the F10 overlay nor the front options screen exposed them.
+No `src/game` code changes or N64 build changes were needed.
+
+**UI:** shared `ROW_BIND` entries in `port/src/optionsoverlay.c`, with
+**MOVEMENT KEYS** (six actions) and **ACTION KEYS** (eight actions) as two
+functional pages adjacent to INPUT; each fits the front menu's 14-row cap
+and has a two-step **Reset to defaults** row. `port/src/frontoptions.c`
+uses the same row/slot/capture API; F10 has a modal footer, the front
+options screen has a modal prompt. Left/Right chooses slot **1–4** on an
+action; Enter/A/click begins capture; Escape cancels, Delete clears the
+selected slot; moving/clicking other controls is suppressed until the key
+is released. `NONE` is displayed for empty slots. After user feedback the
+ambiguous `1/4` display became **Primary / Secondary / Extra 1 / Extra 2**:
+all four keys remain accessible, including older hand-edited extras. The
+user's Turok hierarchy reference puts Bindings beneath INPUT; the two
+standalone binding categories here are an interim layout, not final menu
+acceptance. Do not add fake Weapons/Automap/Menu rows without actual separate
+GE actions/input sources. The front-end `Profile`
+chooser remains on every page by its pre-existing design; key bindings
+are machine-wide, not attached to a save file. Mouse LMB/RMB fire/aim,
+mouse wheel and gamepad controls remain hardwired and are NOT advertised
+as rebindable; the on-screen instruction explicitly says mouse/pad fixed.
+
+**Implementation:** `port/src/video.c:videoPumpEvents` consumes the first
+SDL_KEYDOWN while capture is active *before* F10/ESC shortcuts; F10/F12 and
+Alt+F4 are reserved, and repeats ignored. An SDL atomic flag and spinlock
+pass one scancode to the scheduler/front UI thread, which alone serializes
+the bind, calls `inputBindingsApply()`, and commits with `configSave()`.
+Capture ignores navigation, wheel and click-to-lock; the actual captured
+key is suppressed through key release. `inputBindingSetSlot()` preserves
+all four existing slots (rather than discarding older hand-edited third/
+fourth keys), prevents duplicate keys within an action, and never truncates
+the 64-byte ini value. An explicit `NONE` token is a positional empty slot
+for sparse lists (e.g. `NONE,NONE,F1`); the sole `NONE` string still unbinds
+the entire action. SDL key names containing a comma or lacking a name use
+`SC:` in the ini, so the delimiter cannot corrupt a key. Section
+reset restores the GEPD one-layout defaults.
+An opt-in, restoring `GE_BINDPROBE` tests the runtime string round-trip;
+`GE_BINDCAPTUREPROBE=F2|ESC|DEL` exercises the actual host-event/modal
+handoff only against an isolated test ini.
+
+**Verification:** MSYS2 MINGW64 NTSC build linked. With an isolated
+`/Temp/ge-d381/data/ge007.ini`, `GE_BINDPROBE=1` logged PASS for sparse
+slots 3/4 (including keypad comma), deleting slot 1 while preserving 3,
+clearing all, then restoring the original strings in memory. `GE_OPTIONSOVERLAY=4` action-keys page
+booted with no missing options. With `GE_BINDCAPTUREPROBE=F2` the host SDL
+event was consumed and the menu committed `Input.Bind.Fire` slot 1 = F2;
+`Fire = F2` appeared under `[Input.Bind]`. Repeating with ESC left F2
+unchanged; DEL committed `Fire = NONE`. The real user's ini and running
+process were not modified. User physical-key capture feel-check, front
+options page traversal/reset, mouse/pad input after capture, other regions,
+and 21-level default-render golden comparison are still owed.
+
+
+## D384 — Front PC Options used editable gameplay keys as its own controls (2026-09-27)
+
+**Status: PARTIAL (NTSC built, isolated probes pass, user retest owed).** User
+reported A/D could not be rebound and C caused menu glitches. Live log
+`scratch/playtest_v040.log` shows captures succeeding but selection cycling
+uncontrollably across slots 1–4 (e.g. `Input.Bind.StrafeRight` A → D → C → X,
+then C across slots). The user's live ini now has `Forward=NONE,D` and
+`StrafeRight=NONE,V,C,X` from that session: DO NOT modify or overwrite it
+while testing. Root cause is precise and port-side: `inputComputePad()`
+turns *newly assigned* A/D/C into live GE `GE_CONT_C/GE_CONT_F` strafe bits
+or stick X/Y; `frontOptionsMenuInterface()` reads those same C buttons and
+stick values as Left/Right slot selection and Up/Down row selection. The
+editor therefore feeds its own rebind results back into its navigation on
+the very next poll. F10 already swallows the virtual pad and did not have
+this particular feedback path. In parallel, four visible slots and `1/4`
+were confusing and produced unnecessary ghost mappings; user wants two.
+
+**Fix, `port/` only:** while `current_menu==MENU_PC_OPTIONS`, the port
+builds keyboard menu input from PHYSICAL arrows and Enter/Escape, not from
+editable gameplay keys. Mouse LMB/RMB and gamepad still reach the menu; the
+scripted-input test harness retains its exclusive source. While capture
+is active or the captured key remains down, keyboard/mouse menu buttons
+are neutral (`optionsBindingInputBlocked()` checks an atomic held-scancode
+across host, scheduler and front threads); the front menu's capture modal
+continues to suppress interface navigation. The same `Input.Bind.*` keys
+still drive gameplay outside PC Options, unchanged. `GE_BINDPROBE` exercises
+the menu conversion with A+D+C all pressed (zero menu input), arrows+
+Enter (only their fixed signals), and a blocked modal (zero input).
+
+`INPUT_BIND_SLOTS` is **2**: only Primary and Secondary are displayed and
+active. Old third/fourth ini tokens are **preserved verbatim and ignored**
+on boot (one warning per affected action); editing that action writes its
+two displayed keys, removing only that action's extras. No automatic ini
+rewrite/migration, no hidden active mappings. Reset to defaults still
+restores the one-PC-layout binds. This also eliminates the 3rd/4th slot
+selection loop; physical arrows and mouse/pad are the only editor controls.
+
+**Verification:** MSYS2 MINGW64 NTSC link succeeded without new diagnostics.
+Isolated `GE_BINDPROBE=1` reports PASS for two slots, sparse and comma-name
+serialization, A/D/C no-nav, arrow/Enter and modal block, restoring
+original bind state. In an isolated ini, `StrafeRight=NONE,V,C,X` produced
+one `legacy extra keys (ignored)` warning and survived a no-op quit
+unchanged; capturing F2 into Primary using the host-event/modal test
+changed *only that action* to `StrafeRight=F2,V` (C/X discarded).
+`GE_BINDCAPTUREPROBE` synthetic SDL_KEYDOWN must set `ev.key.repeat=0` and
+`ev.key.keysym.mod=KMOD_NONE` explicitly; without initialization its
+random repeat value caused the **test event** to be ignored. Real user
+SDL key events were not implicated. Live physical-key feel check and
+Turok Input→Bindings nesting remain open. No 21-level visual gate yet.
+
+
+## D385 — Mouse 1–5 need to be real action bindings (2026-09-27)
+
+**Status: PARTIAL (NTSC build and isolated probe pass; live acceptance owed).**
+User correctly noted that Mouse 1/2/other buttons should be rebindable, not
+merely described as fixed UI decorations. Previously `inputComputePad()`
+unconditionally emitted Z on LMB and R on RMB even after Fire/Aim keyboard
+slots were edited or cleared. The binding editor captured only keyboard
+`SDL_KEYDOWN`; a click while capturing could instead lock the gameplay cursor.
+
+**Port-only fix:** `Input.Bind.*` Primary/Secondary accept `Mouse 1` through
+`Mouse 5` alongside SDL scancodes; held gameplay actions now read those
+actual tokens rather than special-casing LMB/RMB for Fire/Aim. Fire defaults
+to `Mouse 1`; Aim defaults to `Mouse 2,Left Shift`. Captured mouse-down events
+are consumed before click-to-lock. The modal blocks through physical button
+release so a binding click cannot also activate the editor or stage. Mouse
+clicks stay fixed as *menu* accept/back, irrespective of their gameplay
+assignment; wheel up/down still cycle weapons and are not remappable here.
+Gamepad mappings remain untouched.
+
+`Input.BindingsVersion=2` upgrades pre-mouse ini files on the next launch:
+formerly-hardwired Mouse 1/2 are placed in Fire/Aim slots without losing an
+existing single keyboard bind. For an action already using two keyboard
+slots, the two user keys take priority: no hidden mouse bind is kept, a
+warning says how to reassign a slot if the old mouse default is wanted.
+Old third/fourth tokens follow D384's existing ignore-until-edit rule.
+The change does **not** erase game saves or migrate other actions.
+
+**Verification:** MINGW64 NTSC build/link passed. `GE_BINDPROBE=1` checks
+Mouse 5 serialization, duplicate-slot clearing and keyboard/modal isolation,
+restoring original strings in memory. In a private copy of the test data
+(`AppData/Local/Temp/ge-d385`, not the user's ini), a version-1 config with
+`Fire=F2` and `Aim=Left Shift` migrated to `Mouse 1,F2` and
+`Mouse 2,Left Shift`; `GE_BINDCAPTUREPROBE=MOUSE5` reached the host event
+pump, committed Fire Primary and persisted `Mouse 5,F2`. Quit after 120
+frames was clean. **Owed:** physical Mouse 1/2/side-button capture and
+stage-level behavior, and the 21-level visual gate.
+
+
+## D386 — Aim default must use SDL Mouse 3, not Mouse 2 (2026-09-27)
+
+**Status: PARTIAL (NTSC build and isolated migration verified; physical retest
+owed).** User caught that SDL numbers mouse buttons as left=1, middle=2,
+right=3. D385 used `Mouse 2` for Aim, so RMB stopped aiming and MMB took its
+place. This was a port binding-default error, not a gameplay change.
+
+**Fix:** default Aim is `Mouse 3,Left Shift`; pre-D385 migrations now put
+SDL_BUTTON_RIGHT (3) into Aim. Increment `Input.BindingsVersion` to 3 so a
+version-2 ini with the **exact** erroneous default `Mouse 2,Left Shift` is
+corrected on launch. Any other version-2 Aim string is left alone, preserving
+explicit middle-button assignments and other user changes. Mouse-button
+capture/serialization and fixed front-menu clicks still use SDL's 1–5 values.
+
+**Verification:** NTSC MINGW64 build; isolated version-2 ini (Fire custom
+`Mouse 5,F2`, Aim old default `Mouse 2,Left Shift`) migrates just Aim to
+`Mouse 3,Left Shift` and version 3; version-3 restart is stable. User ini was
+not edited for the probe. Live RMB/MMB test and release gate remain open.
+
+
+## D387 — M7 AllUnlocked read-time patches leak into persistent saves (2026-09-27)
+
+**Status: OPEN; M7 cannot be signed off as a preservation-only playtest.**
+User offered `data/` for investigation. Original `data/ge007.ini` and
+`data/ge007.eep` were **read only**, checksums unchanged after all runs.
+Private isolated test folders live under
+`AppData/Local/Temp/ge-m7-20260927/`: current ini + ROM/sidecars,
+with the older `data/ge007 copy.eep` as the reproducible EEPROM seed.
+The original seed has two genuinely completed times in folder 0, no
+0x3FF fake completions, and only its earned cheat bits (`04 00 00`).
+No edits to game logic, port code, or user saves in this investigation.
+
+**Mechanism:** `port/src/libultra.c:geEepromRW` patches a *copy* of all five
+save slots on the block-4 read when `portAllUnlocked` is on. The raw EEPROM
+on disk stays untouched until a later save. `fileValidateSaves` accepts the
+patched copies (CRC recomputed by `geEepromPatchAllCheats`) into the live
+`src/game/file2.c:saves[]` array. An unrelated call to `fileWriteSave`
+serializes **all 96 bytes of the live slot** back to EEPROM, including its
+synthetic unlocked-cheat bits and 0x3FF time fields. D257's original finding
+explicitly warned these unlocks can persist after a save; the M7 plan's
+"read-time patch should guarantee untoggle preservation" premise is false.
+The two genuine times in the test slot were retained, but fake completions
+and cheats now survive an OFF restart. `main.c` also seeds the RAM debug
+unlock flags only when the process starts: switching the setting OFF during
+the same process does not clear those flags. A valid OFF test needs restart.
+
+**Isolated evidence:** with `GE_SAVELOG=1 GE_QUITFRAME=105`, both ON and OFF
+booted from the same older seed; no-gameplay-write EEPROM hashes were equal
+(legacy CRC migration occurred in both). Starting from the migrated raw
+EEPROM, `Game.AllUnlocked=1 GE_WSPROBE_FRONT=600 GE_QUITFRAME=780` called the
+real front-end `watchSettingsSet(Music, 4096, commit=1)` on folder 0.
+`SAVELOG` recorded `fileWriteSave slot=4 block=52`; only that EEPROM slot
+changed. Cheat bits went `04 00 00` → `ff ff 0f`; 58 empty completion
+times became 0x3FF, and the 2 earned times remained. Starting from that
+result, `AllUnlocked=0` followed by a clean restart/quit validated the
+slot's stored CRC and left the 58 fakes intact. Logs and private snapshots
+in `on-save/` and `after-off/`. This reproduces on a **settings save alone**,
+without a mission completion. Current user `data/ge007.eep` already has two
+nonfree slots with 60/60 0x3FF times and all cheat bits set, consistent with
+past persistence; this test neither created nor changed those bytes. Do not
+silently overwrite it with an older backup (that would lose later progress).
+
+**Scope before fixing:** decide whether OFF must restore *earned* progress
+while stripping only artificial unlocks (recommended). A port-only write
+adapter needs to retain unpatched per-slot state and merge legitimate
+changes while ON, including new real completion times and cheat awards;
+simply masking every 0x3FF on writes cannot distinguish a genuinely earned
+capped time or a previously persisted fake. There is no safe automatic
+repair for already-persisted fake bits without prior raw-state provenance.
+Test fresh/real/mixed saves in isolated directories, ON and OFF restarts,
+settings writes and actual mission completion, CRC validity and all folders;
+only then mark M7 done. The 21-level visual gate is independent and open.
+
+## D388 — Nest functional binding/HUD menus and remove redundant M5 toggle (2026-09-27).
+
+Reconstructed stub — the original full entry was lost when `findings.md` was truncated to its section table (recovery, 2026-09-28); this entry is rebuilt from the section-table row and the `findings-index.csv` one-liner. Original root-cause detail is not recoverable.
+
+**Status: PARTIAL (NTSC build, isolated nested navigation/reset/capture probes; physical UI acceptance and visual release gate owed)**: five root sections, Input → Bindings → Movement/Actions; Gameplay → HUD; independent FPS/MSAA remain exposed and unchanged by removing Low-end mode.
+
+## D389 — F10 footer clipping and ambiguous submenu links (2026-09-27).
+
+Reconstructed stub — the original full entry was lost when `findings.md` was truncated to its section table (recovery, 2026-09-28); this entry is rebuilt from the section-table row and the `findings-index.csv` one-liner. Original root-cause detail is not recoverable.
+
+**Status: PARTIAL (NTSC build + measured 320-wide footer variants; visual/user review owed)**: two-line help strip with reserved height, no default-scale title note, PD-style ellipses in place of "Open >".
+
+## D390 — F10 pinned Back selection did not visibly change (2026-09-27).
+
+Reconstructed stub — the original full entry was lost when `findings.md` was truncated to its section table (recovery, 2026-09-28); this entry is rebuilt from the section-table row and the `findings-index.csv` one-liner. Original root-cause detail is not recoverable.
+
+**Status: PARTIAL (NTSC build + isolated page/tree probes; human visual check owed)**: Back now uses row-selected colour only when selected, title uses neutral ink; keep mixed-scope HUD under Gameplay, not Video.
+
+## D391 — F10 click at glyph bottom could select next row (2026-09-27).
+
+Reconstructed stub — the original full entry was lost when `findings.md` was truncated to its section table (recovery, 2026-09-28); this entry is rebuilt from the section-table row and the `findings-index.csv` one-liner. Original root-cause detail is not recoverable.
+
+**Status: PARTIAL (pixel-bound measurement, NTSC build, isolated hit probes; real pointer UX owed)**: root header hint removed; hit bands follow rendered glyph baselines, leave a dead zone and reject clicks outside card.
+
+## D392 — Train intro soldier hunched; hand intersects torso vs 1964 (2026-09-27).
+
+Reconstructed stub — the original full entry was lost when `findings.md` was truncated to its section table (recovery, 2026-09-28); this entry is rebuilt from the section-table row and the `findings-index.csv` one-liner. Original root-cause detail is not recoverable.
+
+**Status: FIXED (`#ifdef PORT` ABI/layout in `chraction.c`)**: firing-table float-offset alias across an 8-byte pointer union; Train aim-angle probe confirms correction; user visually verified the soldier looks fixed against 1964.
+
+## D393 — Controller B/Y both crouch; no in-game use button (2026-09-27).
+
+Reconstructed stub — the original full entry was lost when `findings.md` was truncated to its section table (recovery, 2026-09-28); this entry is rebuilt from the section-table row and the `findings-index.csv` one-liner. Original root-cause detail is not recoverable.
+
+**Status: FIXED + user live-tested A use / X reload / Y weapon cycle**: gameplay pad face map conflated modern buttons and native N64 A/B. D394 supersedes B crouch and shoulder cycling to match the user-supplied Xbox 1.1 chart.
+
+## D394 — Xbox 1.1 Jinx controller parity (2026-09-27).
+
+Reconstructed stub — the original full entry was lost when `findings.md` was truncated to its section table (recovery, 2026-09-28); this entry is rebuilt from the section-table row and the `findings-index.csv` one-liner. Original root-cause detail is not recoverable.
+
+**Status: PARTIAL (port 1.1 gameplay button roles user-tested; exact gadget-category parity and other selectable presets outstanding; RB HD toggle unavailable)**: user supplied the Xbox 1.1–1.4 control table; B is gadgets, stick clicks crouch, LB aim, RB HD toggle (not weapon cycling).
+
+## D395 — Controller trapped in keyboard/mouse-only binding capture (2026-09-27).
+
+Reconstructed stub — the original full entry was lost when `findings.md` was truncated to its section table (recovery, 2026-09-28); this entry is rebuilt from the section-table row and the `findings-index.csv` one-liner. Original root-cause detail is not recoverable.
+
+**Status: PARTIAL (both options UIs block pad-initiated capture; pad B can cancel an active modal; NTSC build linked, controller-only retest owed)**: pad could enter the binding modal but could not finish or cancel without keyboard.
+
+**Steam Deck retest (2026-09-27/28, Steam Controller):** a pad can go into the binding UI and is not left stuck — the original "trapped in capture" symptom is not reproduced. (This is distinct from the broader controller-rebinding *feature*, which still doesn't work — PR #109 / D383.)
+
+
+## D396 (F10 pad controls) — done
+Already completed and you confirmed it ("looks good"). It's linked in NTSC, documented, and awaiting the physical‑pad retest only. Nothing to do there.
+
+## The interlaced / glitchy background — recorded as D397, but I deliberately did **not** ship a blind fix
+
+I traced both the file‑select background and the intro gun‑barrel through the render path, and ruled out the obvious suspects rather than guess at a change:
+
+- **D159 odd‑row pre‑swap** (`texSwapAltRowBytes`, the original "venetian‑blind" cause) — it's a full `#ifdef PORT` no‑op (D159, FIXED). Not the cause.
+- **D183 pitch/stride shear (Family A)** — already disproven for `-level_36` (0/166 loads strided), and it's a *no‑op for this screen anyway*: `titleRenderFolderMenuBackgroundLines` issues 299 separate 440×1 `gDPLoadTextureBlock`s, each with `line == full == 440`, so the de‑stride never engages.
+- **RLE header** — the gun‑barrel image is `rle_expand_8bit` of the `unknown2` blob; D55 already bswaps the BE w/h header. The 440×299 byte count is correct.
+- **Row‑strip texrect math** — verified a 1‑texel‑tall quad samples v 0→1 across one screen row. Correct.
+
+**The key new data point:** the intro gun‑barrel is a *first‑time* render, so the comb is present on first display, not only after a back‑out/re‑entry. That **refutes D182's "stale descriptor on re‑entry" hypothesis** — it's a *persistent* defect on these large 1:1 grayscale front‑end images.
+
+I stopped at diagnosis (no code touched this session — the tree is still in the known‑good D396‑linked state) because the two remaining candidate causes can't be told apart without a live capture, and a blind fix here would be the kind of speculation the project rules are meant to prevent:
+
+1. **Ground‑truth dithered content** — the gun‑barrel is a dithered 8‑bit halftone; at the port's 440×330→window nearest‑neighbour upscale the dots alias into a comb. *If the N64 CRT showed the same, this isn't a bug* and "fixing" it would diverge from ground truth.
+2. **GL sampling / texture‑cache artifact** on the 299 distinct 440×1 I8 uploads (cache eviction or 1‑row mip handling).
+
+## What a real fix needs (one focused session)
+- One `GE_PCDUMP` capture of the **fresh** file‑select screen + the **intro** gun‑barrel (not a round‑trip), plus an N64/emulator screenshot of the same screens to settle cause 1 vs 2.
+- A ROM ground‑truth decode of the `unknown2` gun‑barrel RLE image (the `ge007.u.2A4D50.usedby7F008DE4.bin` isn't in the repo — it's generated from the ROM).
+- If cause 2, a `GE_DTEX` sweep of the 299 row imports (it already logs `STRIDED`/`OVER‑TALL`).
+
+**One question that narrows it fast:** does the file‑select background look interlaced on the *very first* time you reach it (fresh boot / skip‑intro), or only after you've left and come back? The intro gun‑barrel already says "first display," but confirming it for file‑select too will tell us whether to keep it merged with D182 or split it as its own defect.
+
+I logged all of this as **D397** (`docs/dev/findings.md`, index CSV, and the D182 cross‑ref in `GRAPHICS‑BACKLOG.md`) so the investigation doesn't get lost.
+
+
+## D397 — file-select background + intro gun-barrel "interlaced / comb" look (2026-09-27)
+
+**Status:** OPEN, re-scoped. New user observation; no code change this session (diagnosis below; a blind fix is not safe without the ground-truth capture that is blocked here).
+
+**User report:** the SELECT FILE background image (the 007 gun-barrel / Bond-silhouette backdrop behind the file boxes) and the intro gun-barrel both have a "weird interlaced glitchy look" — a horizontal comb / line-scramble. This extends the long-standing D182(2) "comb / interlacing" symptom and adds a **second, independent instance** (the intro gun-barrel) that D182 never covered. Critically, the intro gun-barrel is a **first-time render** (not a back-out / re-entry), so the interlace is present on first display, not only after a round-trip. That refutes D182's "only after re-entry / stale tile descriptor" hypothesis for this symptom: it is a *persistent* defect on these large 1:1 grayscale front-end images.
+
+**What was ruled out this session (static read, no live run):**
+- **D159 odd-row pre-swap** — `texSwapAltRowBytes` (`src/game/image.c:2271`) is a full `#ifdef PORT` no-op (D159, FIXED). It is not the cause.
+- **M-36 Family A line/pitch shear** — D183 DISPROVED it for the `-level_36` wall repro (0/166 texture loads strided). The D183 de-stride (`gfx_tex_pitch_fix`, on by default) is a **no-op for the row-strip** anyway: `titleRenderFolderMenuBackgroundLines` (`src/game/title2.c:24`) issues 299 *separate* 440×1 `gDPLoadTextureBlock`s (each advances `image += 440`), so every individual load has `line == full == 440` and the de-stride never engages.
+- **RLE header byte-swap** — the gun-barrel background is `rle_expand_8bit` of the `unknown2` blob (`src/game/title.c:465`); D55 already bswaps the BE w/h header in place (`port/src/romdata.c:244`). `rle_expand_8bit` only consumes `w*h` (the product is swap-invariant), so the 440×299 byte count is correct.
+- **Row-strip texture-rectangle math** — `gfx_dp_texture_rectangle` for a 1-texel-tall quad (ult=0, dtdy=1024) samples v 0→1 texel across one screen row; verified correct for `titleRenderFolderMenuBackgroundLines`.
+
+**Remaining candidate causes (need a live capture to pick between):**
+1. **Ground-truth content** — the gun-barrel image is a dithered 8-bit halftone; at the port's 440×330→window nearest-neighbour upscale the dither dots alias into a visible comb. If the N64 (CRT low-pass) showed the same, this is *not a bug* and "fixing" it would diverge from ground truth. **Must be confirmed against an N64/emulator screenshot of the same screen before any code change.**
+2. **GL sampling / texture-cache artifact** on the 299 distinct 440×1 I8 uploads (cache key/eviction or 1-row mip handling). The `GE_DTEX` probe already logs `STRIDED` / `OVER-TALL` for exactly this path; a `GE_PCDUMP` screenshot across the file-select + intro gun-barrel frames is the next step.
+3. **RC3 / shift-LOD density** (D167 family) or a wrong-texture bind, per the M-36 re-scope note.
+
+**Owed (concrete next steps, single session):** (a) one `GE_PCDUMP` capture of the fresh file-select screen + the intro gun-barrel (not a round-trip); (b) ROM ground-truth decode of the `unknown2` gun-barrel RLE image (`tools_pc` extract of `assets/ge007.u.2A4D50.usedby7F008DE4.bin`) to settle cause 1 vs 2; (c) if cause 2, an `GE_DTEX` sweep of the 299 row imports to confirm cache/STRIDED behaviour. Cross-refs: D182(2) (same symptom, round-trip trigger), D176(b) (Family A, disproven), D159 (no-op'd), D183 (pitch de-stride, latent no-op here).
+
+**Status (2026-09-28, per `scratch/d397_fs/HANDOFF-D397.md`) — OPEN, re-scoped; no fix landed.** An in-tree CI→I import-routing block in `gfx_pc.cpp` `import_texture` was tried and is **ineffective** (the D116 stale-tile-metadata hypothesis is REFUTED: D161 already routes `fmt==CI && palette==G_TT_NONE` to `import_texture_i8`; the GE_TEXI logs show `palfmt=0` on all 299 rows) — the block was removed 2026-09-28. Established: the data path is 100% clean (299 row strips upload byte-exact vs `scratch/d397_fs/gunbarrel.pgm`); the artifact is static; MSAA, mips, RAND_NOISE, 5-bit quant and texture-cache aliasing are ruled out. Leading hypotheses: **H1** — the file-select custom-CC LERP (`gDPSetCombineLERP(TEXEL0, PRIMITIVE, ENV_ALPHA, ...)`) uses ENV_ALPHA 0x14 as the mix factor; the port feeds it as `20/255 ≈ 0.078` (`G_CCMUX_ENV_ALPHA` input, `gfx_pc.cpp`), so a black texel renders ≈0.92×prim ≈ the measured 27/28/17 black-region census — the N64 RDP 5-bit coefficient scaling (check `rsp/graphics/gmain.s` and the PD port's CC engine for the same combine id) is the root-cause question; **H2** — the 6-of-9-row comb is a second, separate row-strip-geometry defect (non-integer quad y + NEAREST, or the 256-tri vertex-ring wrap between the two tris of a row); **H3** — part of the darkened look is authentic N64 (G_TC_FILT 4-bit intensity dither, unimplemented here). A `GE_D397SH` env-gated shader-formula probe (in `gfx_opengl.cpp`, ~30 lines) stays in-tree until the root cause lands, then is removed. Next steps per the handoff: Q1 correlate which of the 20 compiled shaders is the gun-barrel row-strip one, Q4 settle the LERP-factor scaling against gmain.s/PD, Q5 native 640×480 FBO capture to re-measure the comb period.
+
+**Status (2026-09-27b, static read against the local `pd_port` checkout) — H1 REFUTED, H2 promoted to leading cause.** Cross-checked `G_CCMUX_ENV_ALPHA` handling against `C:\Users\james\Source\Repos\pd_port\port\fast3d\gfx_pc.cpp`: byte-identical formula (`tmp.r=tmp.g=tmp.b=rdp.env_color.a`, then `/255.0f` at the vertex-color pack site) — no special-cased coarser scaling in either port. There is no "5-bit RDP coefficient" quirk in the real color combiner; PRIM/ENV alpha are genuine 8-bit `gDPSetEnvColor` fields and the combiner's A/B/C/D math is full 8-bit on real hardware (the 5-bit-precision idea conflated the CC with an unrelated hardware unit). **H1 is very likely not the (or not the only) cause; the uniform darkening it predicts may simply be faithful/authored** — do not correct it without an N64/1964-GEPD reference frame.
+
+Found a concrete geometric mechanism for **H2**, promoted to leading cause: `titleRenderFolderMenuBackgroundLines` (`src/game/title2.c:24`) emits `lry - uly == 3` (U10.2 units) per row — a **0.75-native-pixel-tall** rect, not 1.0 — via the universal N64 `((y+1)<<2)-1` idiom. That idiom is only exact on real hardware because the RDP's scan converter *floors* the fixed-point Y to an integer scanline before testing coverage. `gfx_draw_rectangle` (`gfx_pc.cpp:3443`) does not replicate that: it maps the U10.2 corners straight to continuous NDC floats and rasterizes with the GPU's ordinary continuous coverage rules. A sub-1-native-pixel-tall rect stacked 299 times (each offset exactly 1.0 native px from the last) does not reliably rasterize as "the one scanline the idiom intended" under continuous rasterization — which of the two neighbouring device rows a given source row's fractional edges land in drifts as the stack accumulates, worsened by any non-integer native→window scale (`gfx_adjust_viewport_or_scissor`'s `ratioY`). This is a rasterization property of the geometry, not stale state, so it naturally explains the first-render (non-re-entry) trigger that refuted D182's hypothesis for this instance. Full mechanism + tell for future occurrences: `docs/porting-notes.md` §D15. **Not yet done: a live capture to confirm the comb period tracks `ratioY`** (predicted: comb period should change if the window/render resolution changes, at fixed native resolution) — this is the concrete falsifiable test before attempting a fix. No code changed this pass (diagnosis only).
+
+
+**2026-09-28 progress (this session — D397SH/D397SHM probes, env-gated in `gfx_opengl.cpp`/`gfx_pc.cpp`):**
+- **Q1 answered:** the gun-barrel row-strip shader is `id0=0000000010001218`, formula `texel = mix(vInput1, texVal0.rgb, vInput2)`, with (D397SHM) **vInput1 = G_CCMUX_PRIMITIVE, vInput2 = G_CCMUX_ENV_ALPHA**, so the LERP (front.c:2596 `gDPSetCombineLERP(TEXEL0, PRIMITIVE, ENV_ALPHA, PRIMITIVE, …)`) renders `out = PRIM + (TEXEL − PRIM)·(envA/255)`; black texels → `prim·(1 − envA/255)`. The 5-bit LERP coef-C in the packed combine word is 0x14 (= 20, i.e. the env-alpha value travels in the coef slot).
+- **H1's "1/255 vs N64 1/32 coefficient" variant is effectively ruled out:** the PD ground-truth port feeds the *same* `env_color.a / 255.0` factor for the identical ENV_ALPHA LERP input (`pd_port/port/fast3d/gfx_pc.cpp` ~1548→1580; same `append_formula`/`mix` generation, same 5-bit-coef packing at :1949). The GE port matches the reference implementation, so the LERP factor is **not** a port-side scaling bug. (If the *N64 hardware* itself scaled the 5-bit coefficient differently, that would be a shared PD/GE port interpretation, not a GE regression — and it cannot explain a *periodic* pattern regardless.)
+- **The visible defect is periodic (the 6-of-9-row comb), which a uniform LERP factor cannot produce** → the comb is **H2 (row-strip geometry / vertex-ring)**, not H1. The gray census (27/28/17) is the LERP factor applied on top; the *lines* are H2.
+- **Next steps (next session):** (1) extend the probe / add an env-gated counter in `gfx_flush()` to test Q3 (does a 256-tri ring flush land between the two tris of a row strip, splitting them across draw calls with attribute-state drops?); (2) Q5 native 640×480 FBO capture to re-measure the comb period in native rows (expect ≈3 if it's the 40px-window period scaled); (3) H3 control — an N64/PD screenshot of the equivalent screen to bound how much of the darkened look is authentic.
+- **Cleanup owed before merge:** remove the `GE_D397SH` probes in `gfx_opengl.cpp` (formula dump) and `gfx_pc.cpp` (D397SHM mapping dump) — **done 2026-09-28** (both blocks removed; see below).
+
+**RESOLVED (2026-09-28) — H2 confirmed; fix landed in `gfx_draw_rectangle`.**
+- **The falsifiable 3-scale test CONFIRMS H2.** Captured the file-select background at 640×480 (1.0×), 1024×768 (1.6×) and 1280×960 (2.0×) via `GE_PCDUMP` and measured the comb period in *native* rows (window-period ÷ scale, on a clean background band, detrended diff-autocorrelation): **s2 (1.6×) P_win=5 → P_nat≈3.1; s3 (2.0×) P_win=6 → P_nat=3.0 (two independent bands agree); s1 (1.0×) too fine to resolve (near-Nyquist, consistent).** A *fixed native period* (~3 rows) is exactly the beat of 0.75-px strips stacked at a 1.0-px pitch (0.75 = 3/4 → four strips span three native rows → a 3:4 beat). A period that scales with the window while staying fixed in native rows is the signature of a **fixed-point scan-converter flooring** artifact — not a LERP/CC factor (which would be scale-invariant and non-periodic). H2 confirmed.
+- **Fix (port-layer, surgical, `gfx_pc.cpp` `gfx_draw_rectangle`):** after the sub-pixel offset is applied, floor the U10.2 top edge to its scanline and the bottom edge to the *last covered* scanline — the RDP scanline range `[top_scan, bot_scan+1]` — before rasterizing: `top_scan = uly>>2`, `bot_scan = (lry-1)>>2`, then `uly = top_scan<<2`, `lry = (bot_scan+1)<<2`. **No-op for whole-pixel rects** (their edges are already `4k`/`4k−1`, and the N64 `(<<2)−1` bottom idiom already yields `bot_scan` = the intended last row), so only sub-pixel-tall strips change — exactly the gun-barrel / file-select row strips. Mechanism + future-occurrence tell: `docs/porting-notes.md` §D15.
+- **Verified by playtest:** the gun-barrel intro + file-select gradient render clean — the interlaced/comb look is gone (user-confirmed on the v0.4.0 playtest). `GE_D397SH`/`D397SHM` probes removed.
+- **H3 note:** the *overall* darkened look (gray census 27/28/17) is the faithful LERP (H1, byte-identical to the PD port) and/or authored — **not** a defect. Only the periodic comb was the bug, and it is now fixed; do not "correct" the darkening.
+
+## D398 — PC Options label: IMG_DOT texture replaces the D348 procedural slider glyph (2026-09-27)
+
+User request: drop the D348 mini-slider glyph in favour of a real sprite from
+the existing `mainfolderimages` table — the option-1 path of the D348
+discussion, zero new art. Chose `IMG_DOT` (the table's sixth entry;
+`assets/oddtextures.h:57-64`, entry `{IMAGE_DOT, 0x10, 0x10, 0, G_IM_FMT_I,
+G_IM_SIZ_8b, ...}` at `assets/oddtextures.c:657`).
+
+Implementation (port-only, `optionsFileSelectLabel()` in `frontoptions.c`):
+the D348 `microcode_constructor_related_to_menus` block is replaced with the
+exact Copy/Erase pattern (`src/game/front.c:2816-2848`):
+`texSelect(&gdl, mainfolderimages + IMG_DOT, 4, 0, 0)` +
+`display_image_at_position(&gdl, &dotpos, &dothalf, 16, 16, 0, 0, 1, r, g, b,
+0xFF, 0, 0)`. Two signature gotchas: `texSelect` (`game/othermodemicrocode.h`)
+*and* `display_image_at_position` (`game/bondwalk2.h:6`) both return `void`
+and advance the DL in place through `Gfx **` — front.c never assigns their
+return values either, so neither is assigned here. IMG_DOT is already in
+ROM's Globalimagetable (D39 table rebase covers the whole `s_mainfolderimages`
+array), so no asset-pipeline work. Layout: dot centred on `LABEL_CY`, right
+edge keeps the old glyph's 2px margin before the label (`DOT_MARGIN`), so it
+tracks the dynamic `x` (JP's wider Erase pushes it along) and text position is
+unchanged; `GLYPH_W` is superseded by `DOT_W` (16) / `DOT_MARGIN` (2) and the
+hot region's left edge extends to the dot's left edge. I8 texels are
+multiplied by vertex RGB, so the hot state passes the label's gold per channel
+(0xEB/0xD8/0x79, the same tinting trick front.c:3829 uses for its red I8
+tint), white otherwise. No texture-state leak: front.c re-binds its own
+Copy/Erase/Select icons immediately after this hook returns. No new strings
+(issue #87).
+
+**Verification:** `./build-pc.sh ntsc-final` clean (first pass failed on the
+`gdl = display_image_at_position(...)` assignment — void return; fixed).
+Visual check owed: the dot's actual appearance is the ROM's `IMAGE_DOT`
+texture (D348's audit noted nothing in the game art reads as "settings"; the
+dot is the user-chosen compromise, not a settings icon).
+
+
+## D399 — File-select bottom row shifted 15px left as a block (user sign-off 2026-09-27)
+
+User: the D398 layout "is okay but a bit cramped" — asked whether the whole
+row, **including the "SELECT FILE" text**, could move left. Answer: yes —
+every x on that bar is a plain float constant, so it's the same mechanical
+D343 class of port-guarded position tweak (no logic, no new assets).
+
+Shift applied (all −15 logical px, NTSC; JP/PAL share the same PORT
+constants):
+- `selectpos.f[0]` 110 → 95 — the `IMAGE_SELECTFILE` 122×18 title, which is
+ NOT a 3D/asset element: it's `texSelect` + `display_image_at_position`
+ at `selectpos` in `constructor_menu05_fileselect` (src/game/front.c, ~2850).
+ Wrapped in `#ifdef PORT` (new; N64 keeps 110).
+- Copy icon 200 → 185, Copy text 222 → 207 (D343 PORT block, values updated).
+- Erase icon 282 → 267, Erase text 304 → 289 (same block).
+- `LABEL_X` 358 → 343 in `port/src/frontoptions.c`; the dot's dynamic
+ `x = folder_option_ERASE_bound.right + LABEL_GAP` tracks the new Erase
+ position automatically (323 + 20 = 343 = LABEL_X, so the clamp is a no-op
+ on NTSC).
+
+Hit bounds (`folder_option_COPY/ERASE_bound`) recompute from the shifted
+positions, so the clickable regions move with the art. Nothing else on the
+screen references these coordinates.
+
+**Verification:** clean build; PCDUMP frame 195 column profile: SELECT FILE
+text now 34..156, Copy icon 169..201, Erase text ends ~323, dot ~341..357
+(no overlap, 18px clearance), label ends before the right edge. **Owed:
+by-eye check via `scratch\playtest_v040.bat`.**
+
+## D400 — File-select "PC Options" label: icon removed, text-only; `textMeasure` arg-swap and 7px vertical misalignment fixed (user sign-off 2026-09-27).
+
+D398's ROM-native dot texture next to "PC Options" was removed on user sign-off
+("most straightforward, no nonsense" design). The label is now text-only, same
+size/look and highlight colour as the Copy/Erase labels: `optionsFileSelectLabel()`
+(`port/src/frontoptions.c`) is one `textMeasure` + one `textRender` with the
+game's own packed gold `0xEBD879FF` constant — no per-channel unpack, so the
+D398 purple-hover bug class (`gDPSetEnvColor` AARRGGBB vs RRGGBBAA channel
+unpack) is gone by construction. Removed includes: `image_bank.h`,
+`othermodemicrocode.h`, `bondwalk2.h`, `assets/oddtextures.h`; `DOT_W` /
+`DOT_MARGIN` gone.
+
+Two measurement bugs found and fixed while landing D400:
+
+1. **`textMeasure` argument swap.** The signature is
+ `textMeasure(s32 *textheight, s32 *textwidth, ...)` — height FIRST
+ (`textrelated.h:21`, impl `textrelated.c:766-843`). The first D400 rewrite
+ passed `(&w, &h)`, drawing the label ~35px above the bar and transposing the
+ hot/click band. Fixed to `(&h, &w)`.
+2. **7px vertical misalignment vs Copy/Erase.** `textMeasure` only adds to
+ `*textheight` on a `\n` character; Copy/Erase's localized strings end in a
+ newline → h=14 → y = 285 - 7 = 278 (front.c:2778 does exactly this). The
+ bare "PC Options" measures h=0 → y=285 → tops 7px low. Fixed by measuring
+ height with `kLabelNL` ("PC Options\n") while rendering plain `kLabel`.
+
+Constants: `LABEL_GAP` 20→28, `LABEL_X` 343→351 (plain-word gap after Erase; no
+glyph to clear). Hitbox: `cursor_h [x-4, x+w+4]`, `cursor_v [y-4, y+h+4]` =
+[274, 296] at h=14. PCDUMP frame 195 (NTSC): "PC Options" logical x 353..426,
+top 279.8 vs Copy/Erase 279.3 (aligned); Erase ends 323 → 31px clearance; 14px
+right margin (canvas 440). **Owed: by-eye check via `scratch\playtest_v040.bat`
+(idle legibility, gold hover not purple, click opens the F10 overlay, vertical
+alignment with Copy/Erase).** Cross-ref D348 (glyph lineage), D398
+(superseded icon approach), D399 (bottom-row 15px shift).
+
+## D401 — Rumble Pak → real gamepad haptics: `CONT_CARD_ON` gate + `osPfs*`/`osMotor*` shims rewired to `SDL_GameControllerRumble`, global `Input.RumbleScale` slider (2026-09-27/28).
+
+Implements **D224 (M-87)** per `docs/dev/RUMBLE-PLAN.md` — the N64 Rumble
+Pak state machine (`src/joy.c` `joyRumblePakInit`/`joyRumblePakTick`/Start/Stop,
+call sites `gunfire.c:3284/3288`, `bondview2.c:9722/9726`, `lv.c:1688`,
+`sched.c:272`) now drives real SDL gamepad haptics. **Port layer only: zero
+`src/` edits**, so rule 2 / the ABI exception are not implicated.
+
+**The two M-87 addendum blockers** (both `port/src/libultra.c`):
+
+1. `contSnapshotFromKeyboard` left `g_contStatus[i].status = 0`, so
+ `joyRumblePakInit` (`src/joy.c:178` — requires `status & CONT_CARD_ON`) never
+ ran and `joyRumblePak` was never populated. Now
+ `status = (connected && inputRumbleSupported(i)) ? CONT_CARD_ON : 0` —
+ gated on real capability so non-rumble pads never enter the rumble path
+ (cleaner than PD's unconditional bit; `joyRumblePakInit` is the real gate).
+ `type` stays `CONT_TYPE_NORMAL` (includes `CONT_JOYPORT`; PD's
+ `CONT_ABSOLUTE` would have broken the init gate).
+2. `osPfsInit` stubbed `PFS_ERR_NOPACK`, so `osMotorInit` was never
+ attempted (`src/joy.c:186-190` proceeds only on `PFS_ERR_ID_FATAL` or
+ `PFS_ERR_DEVICE`). Now returns `PFS_ERR_DEVICE` ("Rumble Pak present, not a
+ Memory Pak") for rumble-capable channels, `PFS_ERR_NOPACK` otherwise.
+
+**`port/src/input.c`** (flat statics, GE style — not PD's `padsCfg[]`):
+`padRumbleOn[MAX_PADS]` + a single global `gRumbleScale` (default 0.5) —
+the per-pad `padRumbleScale[]` array was superseded 2026-09-28 by the
+global slider (see "Menu row" below). Detection in
+`inputOpenPads`: `SDL_GameControllerHasRumble` on SDL ≥ 2.0.18, else
+`SDL_JoystickIsHaptic` with the known-type Windows no-haptic fallback; zeroed
+on close in `inputDestroy`/`inputRescanPads`. PS4/PS5 HIDAPI rumble hints in
+`inputInit`. New API: `inputRumbleSupported(idx)` (bounds-checked against
+`MAX_PADS` — JPN `_HW_VERSION_1` has `MAXCONTROLLERS == 6`, so out-of-range
+channels report unsupported and stay silent), `inputRumble(idx, strength,
+time)` (no-op on `gRumbleScale <= 0`; scales strength, `*65535` / `*1000`
+units, `SDL_GameControllerRumble`; `(0,0)` stops), global
+`inputRumbleGetScale(void)`/`inputRumbleSetScale(f32)`. Config: a single
+`Input.RumbleScale` (range 0..1, default 0.5) registered in
+`inputConfigInit`; the four per-player `Input.Player%d.RumbleScale` keys
+were dropped in the same-day menu-shape decision (stale keys in an
+existing ini log four `unknown key` `LOG_NOTE` lines and self-clean on the
+next `configSave`). `keyboard`/mouse-only play stays silent: no pad open
+⇒ `pads[idx]` NULL ⇒ `inputRumble` no-ops.
+
+**`port/src/libultra.c`** shims: `osPfsInit` as above; `osPfsIsPlug` sets its
+pattern bits for the capable channels over `MAXCONTROLLERS` (completeness
+only — **no GE game call sites**); `osMotorInit` stores `pfs->queue`,
+`pfs->channel`, `pfs->activebank = 0xff` (PD `osMotorProbe` shape) and returns
+0 for capable channels, `PFS_ERR_NOPACK` otherwise (no `PFS_MOTOR_INITIALIZED`
+store — not defined in GE headers and `joy.c` never reads `pfs->status`);
+`osMotorStart` → `inputRumble(channel, 1.0f, 5.0f)` (one generous window;
+the game's `joyRumblePakTimer60` normally stops it, PD-style), `osMotorStop` →
+`inputRumble(channel, 0.0f, 0.0f)`; both return 0 for non-NULL `pfs`.
+
+**Verification.** `./build-pc.sh ntsc-final` clean (no new undefined/duplicate
+symbols; `inputRumble*` defined once in input.c, declared in input.h).
+**Real-pad haptics confirmed working by the user (2026-09-28)** — the gun-
+impact rumble path is live on a real controller. The `RumbleScale=0`
+silence check remains unverified (it is the trivial `gRumbleScale <= 0`
+early-out in `inputRumble`). Same-session environment fix: the
+recurring `Cannot create temporary file in C:\Windows\: Permission denied`
+link failure is now self-healed by `build-pc.sh` (native-TMP probe →
+cmake+ninja re-run under PowerShell with `TMP`/`TEMP` set natively; see the
+AGENTS.md "Build" note), and `cannot open output file ... Permission denied`
+is documented as the running-`.exe`-lock case (close the game before
+rebuilding).
+
+**Menu row (plan item 3, 2026-09-28).** The deferred options-menu slider
+landed the day after the user confirmed the haptics, as a **single global
+row** (user decision — "making the rumble menu just 1 player"):
+`port/src/optionsoverlay.c` gains one `ROW_SLIDER` row — `Rumble strength`
+on `Input.RumbleScale` (0.05 detents; the generic `CONFIG_OPT_FLOAT`
+value/display path, shown `%.2f`) — placed in the INPUT section after the
+other controller rows, with a `kResetDefaults` entry (0.5, the
+`gRumbleScale` C initializer) so INPUT's "Reset to defaults" covers it.
+The originally-coded four per-player rows (`Rumble (Player 1..4)` on
+`Input.Player%d.RumbleScale`) were replaced before first compile; the
+four ini keys are dropped rather than kept as hidden overrides (stale ones
+self-clean on the next save, and a per-pad override would be a no-op since
+`configSave` rewrites all registered keys — see `RUMBLE-PLAN.md` item 3).
+Both the F10 overlay and the front options screen walk the shared `rows[]`
+(`frontoptions.c` via the `optionsRow*` accessors), so the row appears in
+both. A "row disabled when no pad is rumble-capable" state is NOT
+implemented — the overlay has no generic row-disabled mechanism; the row is
+inert (no-op) on unsupported/absent pads, and the disabled state is deferred
+with the per-pad tuning row (`QOL-INVENTORY.md`), which will need the same
+mechanism for deadzone/swap rows.
+
+**Steam Deck confirmation (2026-09-27/28):** rumble works on the Deck's Steam Controller — `SDL_GameControllerRumble` fires on SteamOS/Steam Input with `Input.RumbleScale > 0` (user: "rumble works").
+
+
+## D402 — Linux/Steam-Deck build broken: `bondconstants.h`-first TUs re-enter `bondtypes.h` from a half-parsed `bondconstants.h`, and `video.c` loses the `_SHIFTL` gbi macro (found 2026-09-28, building `release/v0.4.0` on Ubuntu 24.04 for the v0.4.0 Deck pre-release).
+
+**Context.** Every v0.4.0 wave item since the modern-options work was validated on Windows/MinGW only. The first clean Linux (glibc GCC) compile of `release/v0.4.0` surfaced two port-layer build breaks, both include-resolution issues that MinGW happened to tolerate.
+
+**Break 1 — half-parsed `bondconstants.h` (header include cycle).**
+`src/bondconstants.h` includes `` at its **top** (line 16), before it defines `ITEM_IDS`/`ACT_TYPE`/`DIFFICULTY`/`TICKOP`/… (those land from ~line 4100 on). On PC, `ultra64.h` → `port/shim/PR/ucode.h` → `pc_protos.h` → `bondtypes.h` (whose own line-28 `#include ` normally pulls the *complete* constants header). A TU that includes `` **first** — `port/src/optionsoverlay.c` (and any sibling port TU with the same shape) — sets `src/bondconstants.h`'s include-guard, then that header's top `#include ` re-enters the `pc_protos.h`→`bondtypes.h`→`bondconstants.h` chain while `src/bondconstants.h` is still mid-parse (guard already set → body skipped). `src/bondtypes.h` then needs `ITEM_IDS`/`ACT_TYPE`/`CHRFLAG`/`DROPTYPE`/`AMMOTYPE`/`DIFFICULTY`/`MPSCENARIOS`/… which are not yet defined:
+```
+src/bondtypes.h:1722:9: error: unknown type name ‘ITEM_IDS’
+… :2445 ACT_TYPE, :2457 CHRFLAG, :2621 DROPTYPE, :3230 AMMOTYPE, :3485 DIFFICULTY
+port/include/pc_protos.h:219 ITEM_IDS, :260 MPSCENARIOS, :359 TICKOP
+```
+`-H` on `optionsoverlay.c` shows the exact cycle: `. shim/bondconstants.h → .. src/bondconstants.h → … ultra64.h → pc_protos.h → shim/bondtypes.h → chrobjdata.h → shim/bondconstants.h (guard set, body skipped) → src/bondtypes.h:1722` error. The natural `ultra64.h`-first path (the common N64 TU shape) is unaffected: `pc_protos.h`→`bondtypes.h`→`bondconstants.h` reaches `src/bondconstants.h` *fresh* and fully parses it before any type is needed.
+
+**Break 2 — `video.c` `_SHIFTL` undefined (gbi macro without mbi.h).**
+`port/src/video.c` includes `` (whose `gDP*` DL macros expand to `_SHIFTL(...)`/`_SHIFTR(...)`) but never reaches `PR/mbi.h` (where `_SHIFTL` is `#define`d) on Linux — no header in its include chain pulls it in, so the `gDPSetCombineLERP` in `portCrosshairApplyTintCombine` (crosshair tint, M2 wave D373/D379) compiles as an *implicit `_SHIFTL()` function call* and fails at **link**: `undefined reference to '_SHIFTL'` from `video.c:131`. `port/src/optionsoverlay.c` already worked around this locally (it defines `_SHIFTL`/`_SHIFTR` before its `gbi.h` include); `video.c` did not. MinGW reaches `mbi.h` through a different include path for these TUs, so it linked; glibc does not.
+
+**Fix (port-layer only, zero `src/` edits; D402):**
+1. `port/shim/bondconstants.h` (PC branch): `#define _PORT_DEFER_PC_PROTOS`, `#include `, `#undef` — i.e. pre-parse the PR base headers *first* while telling `PR/ucode.h` to **defer** `pc_protos.h` for that pass; then `#include "src/bondconstants.h"` (completes all the constants; its own top `ultra64.h` is now a no-op); then `#include "pc_protos.h"` so the D38 prototypes are still pulled in exactly once, *after* every type is defined.
+2. `port/shim/PR/ucode.h`: wrap `#include "pc_protos.h"` in `#if !defined(_PORT_DEFER_PC_PROTOS)`, so it is skipped only during the bondconstants-defer pass and runs normally for every other `ultra64.h` consumer.
+3. `port/shim/PR/gbi.h` (PC branch): after `#define Gfx Gfx_le`, `#ifndef _SHIFTL / #define _SHIFTL(v,s,w)…`, `#ifndef _SHIFTR / #define _SHIFTR(v,s,w)…` (verbatim from `mbi.h`), guarded so a TU that also reaches `mbi.h` keeps `mbi.h`'s definitions. Fixes `video.c` and protects any future ``-only TU.
+
+**Verified (Ubuntu 24.04 / GCC, WSL):** full `cmake -S . -B build-pc -DROMID=ntsc-final && cmake --build build-pc -j16` of the freshly cloned branch compiles and **links** `build-pc/ge007.x86_64` (ELF x86-64, links `libSDL2-2.0`) with zero errors after the fix; the pre-fix build fails exactly on the two errors above. Windows/MinGW build unaffected (the `#ifndef`/`#if !defined` guards make both fixes no-ops where the macros are already provided).
+
+**Landed** (see commit). **Lever:** this is the first clean Linux compile of the v0.4.0 branch; CI's `linux-build` job should now pass. The Deck pre-release test bundle (exe + bundled `libSDL2`, `rpath=$ORIGIN`) is produced from this build — note `tools_pc/bundle-linux.sh` still requires the CI-frozen `prepare-assets/ge007-convert` for the *drop-in-ROM* first-run path; a Deck running from an existing `data/` (ROM + pre-generated sidecars) never invokes it.
+## D403 — Deck v0.4.0-pre test: Nintendo logo + copyright/legal page render broken, broader graphics "not reflecting new updates" — **test artifact (stale sidecars seeded into the Deck bundle), not a code regression** (Linux/SteamOS; log only per user, 2026-09-27)
+
+**Reported (user, Steam Deck):** on the freshly built `release/v0.4.0` Linux/Deck pre-release, the **Nintendo logo is broken** and the **copyright/legal page (front_end `PnintendologoZ`/`PgoldeneyelogoZ`/`PlegalpageZ` front-end models) doesn't look right**; more broadly "this doesn't seem to reflect a lot of the new updates for some graphics." Meanwhile the **D397 gun-barrel / file-select fix clearly works**, and "other than that game seems alright on Steam Deck." Log-only requested (no fix this pass).
+
+**Root cause (confirmed by commit dates, not code):** the Deck test bundle was **seeded with `pcmodels-ntsc-final` / `pccg-ntsc-final` sidecars copied from the `0.3.0-local.20260915c` install, whose `data/` is dated 2026-09-15** (the workflow's "reuse prepared assets" step). Those sidecars predate two converter-baked fixes:
+- **D75 / M-197 — the embedded-image-blob raw-pixel copy in `tools_pc/d43_emit.py`** (the fix that made the Nintendo/GoldenEye logo + legal/copyright page stop drawing black), which landed in **commit `425237f9` on 2026-09-21** (21-line d43_emit change). Sidecars generated before that carry the **old zeroed-blob layout** → the front-end logo/legal textures come out broken.
+- **D303 — model-converter muzzle-flash vertex copy** (`d061ca3d`, 2026-09-26) and any other converter-side fixes after 09-15, likewise baked into the sidecar image.
+
+So the broken logo + copyright page + "a lot of graphics not updated" are **stale-sidecar artifacts**, exactly the front-end/`P*` items the D75/M-197 + D303 converter fixes address. The in-exe **fast3d** fixes (D397 gun-barrel/file-select comb, D341 blood drip, D226 HUD scale, …) are compiled into the current binary and are *not* sidecar-dependent, which is precisely why the user saw D397 working on the Deck while the front-end graphics looked wrong.
+
+**Not a code regression.** The same `release/v0.4.0` code renders the logo correctly on Windows with freshly-regenerated sidecars (D75 user-confirmed 2026-09-21). The Deck discrepancy is a **test-harness artifact of seeding the bundle with 0.3.0-era sidecars**.
+
+**Disambiguation (performed 2026-09-27) → RESOLVED:** `data/pcmodels-ntsc-final` + `data/pccg-ntsc-final` were regenerated for the Deck from the **current** Python-source toolchain — `tools_pc/dist/prepare-assets/prepare-assets.py` driving `d43_emit.py` (models, carries the M-197 blob copy) + `d69_emit.py` (bg/stan) + `d88_emit.py --regen` (stage setup), all `ALL CHECKS PASSED` on the NTSC ROM — and the two sidecar dirs were pushed to the Deck's bundle (sha256-verified), replacing the stale 0.3.0-era ones while keeping the user's `ge007.eep` save + `ge007.ini`. **User re-checked the front end on the Deck and confirmed the Nintendo/GoldenEye logo + copyright/legal page now render correctly** — D403 is closed. It was the stale-sidecar test artifact all along; no code was ever at fault.
+
+**Cross-refs:** D75 (M-197 logo/legal blob fix), D303 (converter muzzle-vertex), D397 (fast3d in-exe, works on Deck), D402 (Linux build that produced this bundle), `docs/dev/notes/REMOTE-DECK-DEBUG-WORKFLOW.md` (the asset-reuse step that seeded the stale sidecars).
+## D404 — "Aim style: Centred (PC)" has no effect when aiming with a gamepad; the row's own doc comment over-promises (Steam Deck, 2026-09-27; log-only per user)
+
+**Reported (user, Steam Deck):** setting **Aim style → "Centred (PC)"** (`Input.AimMode = 1`, D333/D337) does nothing while aiming with a controller — the pad keeps the N64-style stick look. Log-only requested (no fix this pass).
+
+**Root cause (code, no `src/` change involved):** the `AIMMODE_CENTRED` handling exists **only** in the mouse RMB/Aim-button-held branch of the aim poll — `input.c:1783`, `hipDirectCompute(edx * lookDtScale, dyLook * lookDtScale)`. That path is fed **only while the mouse is the active aim device** (D337 device-aware integration: "the hook feeds the mouse integration only while the mouse is active"). A pad's aim is the **native right-stick look** path, which never consults `aimMode` (grep: `aimModeGet()` is used only at `input.c:1783` and the `pdMouseAim`/`aimLegacyGepd` helper at `input.c:480`). There is no stick-input "centred" variant — Centred (PC) is defined by feeding *mouse deltas* straight to the camera with a fixed centre crosshair, so it is structurally a **mouse-only** model. On a pad, `AimMode = 1` is therefore inert.
+
+**Contradiction with the row's own documentation:** the `Input.AimMode` row comment in `port/src/optionsoverlay.c` (line ~205) states *"CENTRED (PC) = opt-in FPS-style aim (#104), not N64. Applies to **every aim input (RMB, Shift, Q/L, pad trigger, Toggle mode)**."* The "pad trigger" applicability is not honored by the implementation. Related: D338's `Input.AimRange` is `hiddenIfOn=Input.AimMode` (Centred hides it, "the crosshair doesn't travel"), which is correct for the mouse path but misleading on a pad, where the inert Centred mode still leaves the N64 crosshair travelling.
+
+**Not a code regression on the port's stated model** — Centred (PC) is a mouse-aim style and a pad always uses its native look stick. The defect is the **gap between the F10 row's documented scope ("every aim input … pad trigger") and its actual mouse-only behavior**, which reads as a dead/no-op option to a pad user.
+
+**Resolution (2026-09-28, same day -- superseded from clarification to implementation):** morning pass closed this as a communication bug (relabelling the row "(mouse)", on the reasoning "a gamepad's right stick has no crosshair to travel, so the pad's continuous natural-pitch look already *is* the centred model"). User challenge that afternoon: *why not just let the toggle work on a controller?* On re-reading the code that reasoning was wrong: the pad's natural-pitch path sets `sx/sy` to stick **deflection** -- the same unit the N64-mouse path and the keyboard turn keys feed the game's crosshair integrator. A flick *does* travel the crosshair on a pad; edge-scroll + spring-back merely make a hold feel like direct rotation. The pad was therefore always in the **N64** aim model, and a real CENTRED pad mode was both well-defined and missing. **Implemented (port-layer only, no `src/` edits):** `padDirectCompute()` in `input.c` -- the pad twin of `hipDirectCompute`'s CENTRED branch, same safety gates (dead/watch/pause/`gameScriptedCameraActive`), minus the `mouseDirectLook`/`mouseGrabbed` gate (meaningless for a pad); full deflection (`STICK_MAX=80`, the established per-poll look unit) maps to `PAD_DIRECT_FULL_DEG` (2.0 deg/poll, fov-scaled like the mouse path) -- a first-pass feel estimate. **Revision (2026-09-28, user Deck A/B): "sensitivity goes down dramatically" in Centred mode** -- the first pass was a *linear* 2.0 deg/poll on a `fov/GEPD_BASE_FOV` (90) base, while the N64 path is the game's *quadratic* natural-turn curve `v=sign(v)\u00b7clamp(defl/70,\u00b11)\u00b2 \u00b7 (fov/60) \u00b7 3.5 deg/poll` (bondview2.c canNaturalTurn/canNaturalPitch) -- ~2.6\u00d7 slower at full stick. `padDirectCompute` now reproduces that curve verbatim (yaw and pitch, incl. pitch's `-v\u00b7(fov/60)` sign), so full-stick Centred turns at exactly the N64 path's top rate; the existing per-axis look-sensitivity rows still shape the deflection beforehand. The natural-pitch pad branch now routes `AIMMODE_CENTRED` through it (crosshair pinned at centre, no look stick emitted; declines fall back to the legacy stick emission, as the mouse branch does), and `AIMMODE_N64` keeps today's deflection behaviour -- which is also the default, so **no pad default changes**. Labels reverted to device-neutral ("Aim style" / "Aim range"), keeping the "(controller)" suffixes on the pad look-sens/smoothing rows from the same-day sweep. **Open item: Steam-Deck A/B of the new pad CENTRED feel vs N64 edge-scroll rate -- done 2026-09-28: centred confirmed good, sensitivity dramatically low; fixed by adopting the N64 path's verbatim quadratic curve + `fov/60` base (above); retest owed on the refreshed build.**
+
+**Cross-refs:** D333 (#104 centred aim origin), D337 (device-aware aim integration; the "mouse-active only" gate), D338 (`Input.AimRange` hidden-in-Centred), D403 (same Deck session's other observations), `input.c:1783` / `optionsoverlay.c` Aim-style row.
+
+
+## D405 — fast3d/OpenGL MSAA clamp + default-framebuffer bind (PR #106, italoarruda, security-reviewed and merged 2026-09-28; label rebased from the PR's D323)
+
+**PR #106 (merged into v0.4.0).** Two `port/fast3d/gfx_opengl.cpp` fixes, both latent in the shipped renderer (Windows *and* Linux use the OpenGL backend):
+
+1. `resolve_msaa_color_buffer`: `glRenderbufferStorageMultisample` with a `msaa_level` above the driver's `GL_MAX_SAMPLES` silently leaves the renderbuffers unallocated -> black screen when the user sets MSAA beyond what the GPU/driver offers (common on integrated GPUs and old IGP drivers). Fix: clamp `msaa_level` to `GL_MAX_SAMPLES` (queried once) before allocating.
+2. Two `glBindFramebuffer(GL_FRAMEBUFFER, ...)` calls passed the *index* `current_framebuffer` (or `framebuffers[current_framebuffer]` while `current_framebuffer == 0`) where a GL *name* is required: when `current_framebuffer == 0` the call must bind `0` (the default framebuffer), not `framebuffers[0].fbo`. Binding index 1-3 as a raw name hits whatever unrelated GL object (or an invalid name -> GL error) that number holds.
+
+**Security review (2026-09-28, per the unvetted-contributor policy):** port-layer only, three hunks, no external input, no allocations, no control-flow changes beyond the clamp; N64 build untouched; no game-code edits. Clean. Integrated by cherry-pick of the PR head's code file (`fc092d99`); the PR's docs hunks were superseded by our canonical findings log, and its D323 label rebased to D405 (our D323 is the G_TL_LOD finding).
+
+**Verification (2026-09-28):** post-merge 21-level sweep PASS (build-pc Windows); MSAA=16 boot check: `-level_09` renders 900+ frames in a 20s window with no black screen and no crash (the clamp is silent by design; on drivers whose `GL_MAX_SAMPLES` < 16 the pre-fix build black-screened here). Link ritual: no undefined port symbols; the only duplicate `nm` hits are pre-existing per-TU internal statics (e.g. `_ZL12framebuffers` in each gfx backend TU) — legal under C++ unique linkage, unchanged by this PR.
+
+**Status:** CLOSED (merged to v0.4.0 as 50e3426a, 2026-09-28; PR #106 closed as landed).
+## v0.4.0 campaign playtest sign-off (2026-09-28, user -- full campaign, Steam Deck + physical pad)
+
+The user walked the v0.4.0 release checklist end-to-end and reported (item = user verdict):
+D322 not seen again ("probably good" -- closed on user trend, `GE_D322` recipe kept for recurrence);
+D320 beats clean; D404 pad Centred good (after the parity-curve revision); D397 clean;
+D395/D396 pad capture-cancel + F10 B/A/slider confirmed; D399/D400 by-eye confirmed;
+D289, D294, D291, D249, D236, D311, D235, D303, D307, D245, D240, D241, D287, D286, D175 all
+"fixed"/not seen; D306/D308 "same as N64" (faithful -- not a defect). **One residual: the Rareware
+front-end logo shows a minor texture-filtering artifact (D75) -- kept open as a cosmetic known
+issue, README-disclosed; Nintendo logo + legal page are clean.** Release decisions recorded:
+M7/D387 ships as the loudly-documented EXPERIMENTAL option (user: the warning is sufficient);
+D258 ships NTSC-only (PAL/JP repair deferred post-v0.4); D224 rumble validated on the Deck and
+included; D281 keeps the shipped mitigation + disclosure, zero-saves root cause moves to v0.5.0.
+
+## D406 — front-end PC Options: long sections (INPUT) run off the bottom of the paper; rows past the silent page cap are unreachable (user report 2026-09-28, during the v0.4.0 pre-push review)
+
+**Report:** on the main-menu PC Options screen (MENU_PC_OPTIONS, `port/src/frontoptions.c`), the INPUT section "has too many settings and they go off the bottom of the screen."
+
+**Root cause (geometry, two stacked miscalculations):** the 440x330 paper ends at y=330. `rowY()` puts content row k at `ROW_Y0 + (k+2)*ROW_DY` (the D356 top profile row takes slot 1). D346b sized the 18px pitch for 15 rows *without* a profile row; D356 then added the profile row (cap became `MAX_PROWS-1 = 14`), which pushed the 14th content row to y = 65+15*18 = **335 — 5px below the paper's bottom edge** (text + highlight box mostly off-screen). Meanwhile the v0.4.0 Wave-A additions grew INPUT to **17 visible rows** (mouse H/V, invert-mouse, aim style, aim range, invert-pad, deadzone L/R, pad look-sens X/Y, look smoothing, southpaw, trigger threshold, vibration, crouch mode, reset, Bindings…), so rows 15–17 (Crouch mode, Reset to defaults, **Bindings…**) were dropped by `buildPages()`' silent cap — logged as a warning, but **unreachable from the front screen** (key rebinding was only reachable via the F10 overlay).
+
+**Fix (port-layer only, `frontoptions.c`; no `src/` edits, F10 overlay untouched — it scrolls its own window, D345–D347):** in-section pagination. `buildPages()` now gathers all visible rows of the active section and pages them at `ROWS_PER_PAGE = 13` (the largest page that fits: last row at y = 65+14*18 = 317, +~10px text stays inside 330). The discrete vertical stepper crosses page boundaries — stepping past the last visible row turns the page (cursor lands on the next page's first row; the reverse at the top), single-page sections still clamp as before — and the section title carries a `(p/N)` marker when it spans pages (e.g. "Input (1/2)"). `s_pageno` resets on every section/nested-page entry and back-navigation, and is re-clamped on each `buildPages()` so rows that auto-hide (aim range, auto-FOV) cannot strand the cursor past the end.
+
+**Verification:** rebuild + user re-check of the front screen owed on the refreshed build (INPUT shows 13 rows + "Input (1/2)", stepping down turns to page 2 with Crouch mode / Reset / Bindings…; `GE_FRONTNAVLOG` logs the page number per step; `GE_FRONTOPTIONS_SECTION=INPUT` logs the gathered row count).
+
+
+**D406b (2026-09-28, same pre-push cycle, user reports from the rebuilt screen):** (1) "X axis look sensitivity (controller)" ran into the fixed 232px slider track (label/track overlap) -- slider bars now start 10px past each row's measured label (rows too long for any track show the value only); drag hit-test and A-hold fraction mapping use the same per-row origin. (2) the subtle "(1/2)" title suffix was not a clear enough page-2 indicator -- replaced with a dim "Page p/N" marker right of the title plus a bottom-row hint ("Down: more rows >" / "Up: previous page <") on pages with room below (full 13-row pages carry the top-right marker only). `GE_FRONTOPTS_WIDTHS` one-shot label-metrics log added. Committed a973922d; redeployed to the Deck over the same directory (fresh-save state preserved).
+**D406c (2026-09-28, Deck reports):** (1) page 1 of a multi-page section showed no hint (a full 13-row page left no bottom line) -- now 11 rows per page so the paper's last line always carries the hint: page 1 shows 'Down: next page', a short last page 'Up: previous page'. (2) the chevron glyphs ('more rows >' / 'previous page <') read as confusing direction arrows -- dropped; the Up/Down words carry the direction.
+
+**Status:** RESOLVED (implemented 2026-09-28 pre-push; validated on the Steam Deck through the D406b/c/d refinements -- bars clear the long controller labels, 'Page p/N' + bottom hint on every page, hint pitch-aligned under the last entry; headless log runs confirmed both page-turn directions with all 17 INPUT rows reachable).
+
+## D407 — Tanks cannot be boarded/exited on PC: the v0.4.0 use/reload button split removed the B-button tap the engine's tank handlers consume (user report 2026-09-28, Runway + Streets, during the v0.4.0 pre-push review)
+
+**Report:** on the tank levels (Runway `-level_35`, Streets), the tank blocks Bond's movement but can never be entered — on the N64 original the same spot boards and drives.
+
+**Root cause (port-layer input regression from the v0.4.0 wave, not game logic):** on the N64 a B tap drives both object use and the tank handlers in `bondview2.c` (`bondviewProcessInput`): B tap while `g_PlayerIsInTank == 1` exits the tank; B tap while `g_BondCanEnterTank != 0` (set by the `PROPDEF_TANK` proximity test in `bondviewCalcUpdatePlayerCollision`) boards it. The v0.4.0 input rework (D378: E = `bond_interact_object()` use with no reload fallback; D393: pad A = use / X = reload / Y = weapon cycle) routed the PC use action to that dedicated interact call and stopped presenting `GE_CONT_B`, so both tank branches lost their trigger: the tank stayed solid (collision) but its state machine was unreachable.
+
+**Fix (port-layer only, `port/src/input.c` `inputComputePad`; `src/game` zero-diff — the ten `TANKDBG` probes used to diagnose this (7 in `bondview2.c`, 3 in `input.c`, all `#ifdef PORT`) were stripped for the release):** present `GE_CONT_B` on the use (E / pad A) rising edge only while `g_PlayerIsInTank == 1 || g_BondCanEnterTank != 0`. E keeps its D378 no-reload-fallback semantics everywhere else; the N64 layout presents B natively and is unaffected.
+
+**D407(c) (same cycle, tank follow-ups from live playtest):** (1) **Board-animation lockout** — the game's B handler is a toggle: while `g_PlayerIsInTank` is set, a B tap is the EXIT path. Boarding commits a ~45-frame sit blend during which `g_EnterTankAudioState` stays `TANK_RUN_STATE_NOT_RUNNING`; a key bounce or impatient re-press of E in that window hit the exit a few frames later and silently cancelled the whole animation (playtest 2026-09-28: every attempt was a board→exit microcycle — "tries to do something, cancels out"). The port now suppresses B from E while `g_PlayerIsInTank == 1 && g_EnterTankAudioState == TANK_RUN_STATE_NOT_RUNNING` (`s_tankBoardLock`); once seated, E = exit again. (2) **Menu-accept B gate** — the level-triggered "pad A / E presents B outside playable stages" mapping is disabled in tank states, so a held E while seated cannot re-edge the game's own exit toggle. (3) **Tank aim routing + `Input.TankAimScale`** — in-tank the game aims the turret from raw stick deflection only (`bondview2.c`: |stick| > 60 → `aimTurn`/`speedVerta`), so the direct-write aim models (centred / GEPD) are bypassed while `g_PlayerIsInTank == 1` and the legacy velocity stick is emitted instead; new `Input.TankAimScale` (default 100, range 10–300) scales only the mouse→stick gain on that in-tank path — on-foot aim, hipfire and keyboard/pad input are untouched.
+
+**D407(b) (same cycle, front-end follow-ups from the D406 Deck validation; `frontoptions.c` + `input.c`):** (1) on the front PC Options screen, past the top/bottom edge of the visible rows the mouse highlight was dropped entirely — it now clamps to the page-edge row, so a mouse-only user keeps a row selected at the page edge (wheel / S then turns the page). (2) the mouse wheel pages the row list on that screen (wheel down = step down); the wheel queue is consumed on the menu so a scroll cannot leak into the next stage's weapon cycle (D223). (3) W/S also drive pad up/down on that screen. **(2026-09-28 fix, user report):** the (2) mapping shipped inverted — `wheelBack` (SDL wheel-up) drove `-STICK_MAX` and `wheelFwd` (wheel-down) `+STICK_MAX`, but this screen's convention is `+STICK_MAX` = up (the W/Up keys in `pcOptionsKeyboardPad`). Swapped so wheel up = step up, wheel down = step down; scoped to `MENU_PC_OPTIONS` only — in-stage weapon cycling (D223: wheel down = next weapon) and the F10 overlay's own scroll are untouched.
+
+**Verification:** NTSC build + link clean; zero `TANKDBG` strings in the release binary (re-verified after probe strip, 2026-09-28); 40 s front-end boot steady ~60 fps with zero errors. **Live sign-off (user, 2026-09-28):** first tank drive — board + exit work, no double-trigger against the tank prop, and the in-tank aim feel is good at `TankAimScale = 100` ("felt fine, glad to have the setting").
+
+**Status:** RESOLVED (2026-09-28) — board-ordering fix + board-animation lockout + menu-accept B gate + tank aim routing + `Input.TankAimScale`, all port-only (`src/game` zero-diff), user-signed off on a live tank drive; probes stripped, release binary verified clean.
+
+## D408 — `Game.SkipIntro` skips the post-mission failure dossier: failed/aborted missions return straight to the menus with no REPORT screen (user report 2026-09-28, during v0.4.0 wrap-up playtest)
+
+**Symptom:** with `Game.SkipIntro = 1`, entering a solo mission and then either aborting (watch menu Z+A) or dying (KIA) returns the player straight to the front-end menus — the post-mission failure dossier is never shown. On a normal boot that screen is `MENU_MISSION_FAILED` (`front.c` `constructor_menu0C_missionfailed`: "REPORT / Mission status: KILLED IN ACTION" or "ABORTED", with the objective list and next/prev tabs to the mission-complete report). Initial suspicion was the user's save or `Game.AllUnlocked` (D257/D387); **user confirmed 2026-09-28 that it is a SkipIntro quirk** — save state and AllUnlocked are not involved.
+
+**Traced so far (handoff site, mechanism not fully pinned):** the stage-end handoff lives in `menu_init()`'s `MENU_RUN_STAGE` case (`front.c:~8909`, called from `lv.c:1322` only once `g_CurrentStageToLoad == LEVELID_TITLE`): non-replay solo stages fall through to `frontChangeMenu(MENU_MISSION_FAILED, 1)` (Cuba → cast display, multi → MP options, ramrom replay → legal screen). Under SkipIntro that handoff evidently never runs on the fail/abort return path — the exact point where the boot shortcut's state diverges (which flag/menu value the skipped intro stages would normally have left behind) is not yet identified. The abort path itself (`options.c:798`: `set_missionstate(MISSION_STATE_0); bossRunTitleStage(); mission_failed_or_aborted = TRUE; deleteCurrentSelectedFolder()`) and the KIA path (`bondview2.c bondviewKillCurrentPlayer`, `g_isBondKIA = 1`) are game logic and behave per the decomp; nothing in `port/` intercepts `MENU_MISSION_FAILED`.
+
+**v0.4.0 decision (user, 09-28):** `Game.SkipIntro` stays **EXPERIMENTAL — not recommended for regular users yet**, alongside its older unrooted audio-break report (D216 status note). The F10 row is relabelled "Skip intro (EXPERIMENTAL)" (`port/src/optionsoverlay.c`, mirroring the D387 AllUnlocked labelling precedent); the config var, default (off), and `lv.c` hook are unchanged.
+
+**Status:** OPEN — cosmetic (no crash, no data loss; the mission still records as failed/aborted and the menus are fully functional). Root-causing needs a side-by-side of front-end state (`current_menu`, `g_CurrentStageToLoad`, `mission_failed_or_aborted`, `g_isBondKIA`) on the fail return with SkipIntro off vs on — candidate for v0.5.0 alongside the D216 audio half.
diff --git a/docs/dev/release-dropin-rom-plan.md b/docs/dev/release-dropin-rom-plan.md
index d28b8911..9f4b9c67 100644
--- a/docs/dev/release-dropin-rom-plan.md
+++ b/docs/dev/release-dropin-rom-plan.md
@@ -303,3 +303,32 @@ offer to create it).
2. Interim frozen `ge007-convert.exe` wired to first-run — days, optional.
3. Part B C port of `d43`/`d69` + embedded metadata + output diffing — weeks.
4. Drop the interim binary; single-binary drop-in experience complete.
+
+## Part B follow-up backlog (was GitHub issue #85, folded here 2026-09-25)
+
+Follow-up to #6, which shipped in v0.2.0 as an NTSC-U-only drop-in (PR #84). The issue was closed on GitHub and its scope is tracked here now. It has three independent workstreams.
+
+### 1. PAL/JP asset pipeline repair (data + build)
+
+`scripts/filelist.e.csv` and `filelist.j.csv` contain naming defects that make PAL/JP conversion impossible from this tree (D258: no PAL/JP PC build has ever been producible). The repair path has been verified but not applied:
+
+- **PAL:** rename 31 model rows to the exact `file_resource_table.inc.c` symbols. Examples: `Pbridge_console` ×6 → `Pbridge_console1aZ…3bZ`, and `Pchrgrenadelauncher` → `PchrgrenadelaunchZ`. Greedy sequence alignment proves the mapping: 512×512 exact 1:1, offsets non-decreasing, and zero size anomalies against US.
+- **JP:** rename the mislabeled font row @3044276 from `fontZurichBold_fontchartable.bin` to `fontBankGothic_fontchartable.bin`. The contiguity invariant confirms it: the Gothic kerning table @3043600+676 ends exactly at this row, and the size matches the US Gothic chartable.
+- **Then:**
+ - `gen_romassets.py e/j` must run clean without a ROM.
+ - Commit `romassets_e.s` and `romassets_j.s`.
+ - Replace the hardcoded `filelist.u.csv` in the d43/d69/d88 emitters with a region→suffix map.
+ - Remove the NTSC-U-only gate in `prepare-assets.py`.
+- **Needs a PAL and/or JP ROM** for final byte-level verification of at least one region. Only NTSC is available locally.
+
+### 2. Multi-region release layout
+
+- Give each region its own OUTPUT_NAME (`ge007-ntsc-final` / `-pal-final` / `-jpn-final`) plus a default alias.
+- Add a region re-exec guard in `romdata.c` (~50 lines): if the country byte at 0x3E doesn't match the build, re-exec the sibling exe.
+- CI builds all 3 regions sequentially, and the bundle scripts package all 3 exes.
+
+### 3. C port of the emitters (optional, removes PyInstaller)
+
+Port the d43/d69/d88 emit logic to C. That removes the frozen-converter dependency entirely, because the converter becomes part of the engine binary. It's a 2–4 week task, and PD's `filemodel.c` is the closest analogue. It isn't required for PAL/JP: the frozen Python path works for all three regions once #1 lands.
+
+**Acceptance:** a user with a PAL or JP ROM gets the same one-step drop-in experience as NTSC does today.
diff --git a/docs/img/animation-glitch-1964gepd.png b/docs/img/animation-glitch-1964gepd.png
new file mode 100644
index 00000000..f744f666
Binary files /dev/null and b/docs/img/animation-glitch-1964gepd.png differ
diff --git a/docs/img/animation-glitch-pc-port.png b/docs/img/animation-glitch-pc-port.png
new file mode 100644
index 00000000..b156aecc
Binary files /dev/null and b/docs/img/animation-glitch-pc-port.png differ
diff --git a/docs/img/shots/manifest.csv b/docs/img/shots/manifest.csv
index 3334d020..bba1d5f6 100644
--- a/docs/img/shots/manifest.csv
+++ b/docs/img/shots/manifest.csv
@@ -1,27 +1,13 @@
new_name,original,video_session,snapshot_time
-shot-01.jpg,Build-pc 2026.09.13 - 16.05.44.10.mp4_snapshot_02.11.149.jpg,2026.09.13 - 16.05.44.10,02:11:149
-shot-02.jpg,Build-pc 2026.09.13 - 16.05.44.10.mp4_snapshot_06.00.446.jpg,2026.09.13 - 16.05.44.10,06:00:446
-shot-03.jpg,Build-pc 2026.09.13 - 16.05.44.10.mp4_snapshot_07.56.664.jpg,2026.09.13 - 16.05.44.10,07:56:664
-shot-04.jpg,Build-pc 2026.09.13 - 16.05.44.10.mp4_snapshot_08.33.264.jpg,2026.09.13 - 16.05.44.10,08:33:264
-shot-05.jpg,Build-pc 2026.09.13 - 16.05.44.10.mp4_snapshot_10.16.041.jpg,2026.09.13 - 16.05.44.10,10:16:041
-shot-06.jpg,Build-pc 2026.09.13 - 16.05.44.10.mp4_snapshot_11.03.430.jpg,2026.09.13 - 16.05.44.10,11:03:430
-shot-07.jpg,Build-pc 2026.09.13 - 16.54.07.12.mp4_snapshot_02.25.190.jpg,2026.09.13 - 16.54.07.12,02:25:190
-shot-08.jpg,Build-pc 2026.09.13 - 16.54.07.12.mp4_snapshot_03.05.000.jpg,2026.09.13 - 16.54.07.12,03:05:000
-shot-09.jpg,Build-pc 2026.09.13 - 16.54.07.12.mp4_snapshot_05.39.999.jpg,2026.09.13 - 16.54.07.12,05:39:999
-shot-10.jpg,Build-pc 2026.09.13 - 16.54.07.12.mp4_snapshot_05.55.651.jpg,2026.09.13 - 16.54.07.12,05:55:651
-shot-11.jpg,Build-pc 2026.09.13 - 16.54.07.12.mp4_snapshot_06.25.650.jpg,2026.09.13 - 16.54.07.12,06:25:650
-shot-12.jpg,Build-pc 2026.09.13 - 16.54.07.12.mp4_snapshot_09.33.997.jpg,2026.09.13 - 16.54.07.12,09:33:997
-shot-13.jpg,Build-pc 2026.09.13 - 16.54.07.12.mp4_snapshot_12.09.456.jpg,2026.09.13 - 16.54.07.12,12:09:456
-shot-14.jpg,Build-pc 2026.09.13 - 16.54.07.12.mp4_snapshot_12.59.497.jpg,2026.09.13 - 16.54.07.12,12:59:497
-shot-15.jpg,Build-pc 2026.09.13 - 16.54.07.12.mp4_snapshot_13.09.496.jpg,2026.09.13 - 16.54.07.12,13:09:496
-shot-16.jpg,Build-pc 2026.09.13 - 16.54.07.12.mp4_snapshot_15.22.656.jpg,2026.09.13 - 16.54.07.12,15:22:656
-shot-17.jpg,Build-pc 2026.09.13 - 16.54.07.12.mp4_snapshot_15.39.495.jpg,2026.09.13 - 16.54.07.12,15:39:495
-shot-18.jpg,Build-pc 2026.09.13 - 16.54.07.12.mp4_snapshot_16.54.496.jpg,2026.09.13 - 16.54.07.12,16:54:496
-shot-19.jpg,Build-pc 2026.09.13 - 16.54.07.12.mp4_snapshot_17.29.495.jpg,2026.09.13 - 16.54.07.12,17:29:495
-shot-20.jpg,Build-pc 2026.09.13 - 16.54.07.12.mp4_snapshot_17.39.495.jpg,2026.09.13 - 16.54.07.12,17:39:495
-shot-21.jpg,Build-pc 2026.09.13 - 17.49.35.18.mp4_snapshot_00.21.484.jpg,2026.09.13 - 17.49.35.18,00:21:484
-shot-22.jpg,Build-pc 2026.09.13 - 17.49.35.18.mp4_snapshot_00.31.328.jpg,2026.09.13 - 17.49.35.18,00:31:328
-shot-23.jpg,Build-pc 2026.09.13 - 17.49.35.18.mp4_snapshot_02.03.469.jpg,2026.09.13 - 17.49.35.18,02:03:469
-shot-24.jpg,Build-pc 2026.09.13 - 17.54.08.19.mp4_snapshot_00.11.972.jpg,2026.09.13 - 17.54.08.19,00:11:972
-shot-25.jpg,Build-pc 2026.09.13 - 17.54.08.19.mp4_snapshot_02.36.532.jpg,2026.09.13 - 17.54.08.19,02:36:532
-shot-26.jpg,settings menu.jpg,settings-menu,
+shot-01.jpg,v040/dam0.png,v0.4.0 attract pass 2026.09.28,
+shot-03.jpg,v040/runway.png,v0.4.0 attract pass 2026.09.28,
+shot-04.jpg,v040/surface 1.png,v0.4.0 attract pass 2026.09.28,
+shot-09.jpg,v040/frigate 0.png,v0.4.0 attract pass 2026.09.28,
+shot-11.jpg,v040/surface 2.png,v0.4.0 attract pass 2026.09.28,
+shot-16.jpg,v040/statue 0.png,v0.4.0 attract pass 2026.09.28,
+shot-20.jpg,v040/archives 0.png,v0.4.0 attract pass 2026.09.28,
+shot-21.jpg,v040/cradle 0.png,v0.4.0 attract pass 2026.09.28,
+shot-24.jpg,v040/aztec 0.png,v0.4.0 attract pass 2026.09.28,
+shot-27.jpg,v040/control 0.png,v0.4.0 attract pass 2026.09.28,
+shot-28.jpg,v040/streets 0.png,v0.4.0 attract pass 2026.09.28,
+shot-29.jpg,v040/jungle.png,v0.4.0 attract pass 2026.09.28,
diff --git a/docs/img/shots/shot-01.jpg b/docs/img/shots/shot-01.jpg
index a1ac9588..97329a5c 100644
Binary files a/docs/img/shots/shot-01.jpg and b/docs/img/shots/shot-01.jpg differ
diff --git a/docs/img/shots/shot-02.jpg b/docs/img/shots/shot-02.jpg
deleted file mode 100644
index a734aa26..00000000
Binary files a/docs/img/shots/shot-02.jpg and /dev/null differ
diff --git a/docs/img/shots/shot-03.jpg b/docs/img/shots/shot-03.jpg
index 1ecb51f2..276d9f43 100644
Binary files a/docs/img/shots/shot-03.jpg and b/docs/img/shots/shot-03.jpg differ
diff --git a/docs/img/shots/shot-04.jpg b/docs/img/shots/shot-04.jpg
index f43c8958..85a91505 100644
Binary files a/docs/img/shots/shot-04.jpg and b/docs/img/shots/shot-04.jpg differ
diff --git a/docs/img/shots/shot-05.jpg b/docs/img/shots/shot-05.jpg
deleted file mode 100644
index 7ec10482..00000000
Binary files a/docs/img/shots/shot-05.jpg and /dev/null differ
diff --git a/docs/img/shots/shot-06.jpg b/docs/img/shots/shot-06.jpg
deleted file mode 100644
index 35aae062..00000000
Binary files a/docs/img/shots/shot-06.jpg and /dev/null differ
diff --git a/docs/img/shots/shot-07.jpg b/docs/img/shots/shot-07.jpg
deleted file mode 100644
index eb436341..00000000
Binary files a/docs/img/shots/shot-07.jpg and /dev/null differ
diff --git a/docs/img/shots/shot-09.jpg b/docs/img/shots/shot-09.jpg
index 5797c5dd..c63b3fd0 100644
Binary files a/docs/img/shots/shot-09.jpg and b/docs/img/shots/shot-09.jpg differ
diff --git a/docs/img/shots/shot-10.jpg b/docs/img/shots/shot-10.jpg
deleted file mode 100644
index cb4cc6bf..00000000
Binary files a/docs/img/shots/shot-10.jpg and /dev/null differ
diff --git a/docs/img/shots/shot-11.jpg b/docs/img/shots/shot-11.jpg
index bf473aa7..bffef81f 100644
Binary files a/docs/img/shots/shot-11.jpg and b/docs/img/shots/shot-11.jpg differ
diff --git a/docs/img/shots/shot-13.jpg b/docs/img/shots/shot-13.jpg
deleted file mode 100644
index 9af74619..00000000
Binary files a/docs/img/shots/shot-13.jpg and /dev/null differ
diff --git a/docs/img/shots/shot-16.jpg b/docs/img/shots/shot-16.jpg
index c8df9dbc..279d999e 100644
Binary files a/docs/img/shots/shot-16.jpg and b/docs/img/shots/shot-16.jpg differ
diff --git a/docs/img/shots/shot-19.jpg b/docs/img/shots/shot-19.jpg
deleted file mode 100644
index 96b3f189..00000000
Binary files a/docs/img/shots/shot-19.jpg and /dev/null differ
diff --git a/docs/img/shots/shot-20.jpg b/docs/img/shots/shot-20.jpg
index 2a78a7aa..d78b9baa 100644
Binary files a/docs/img/shots/shot-20.jpg and b/docs/img/shots/shot-20.jpg differ
diff --git a/docs/img/shots/shot-21.jpg b/docs/img/shots/shot-21.jpg
index 8dc9c05f..36f45b90 100644
Binary files a/docs/img/shots/shot-21.jpg and b/docs/img/shots/shot-21.jpg differ
diff --git a/docs/img/shots/shot-23.jpg b/docs/img/shots/shot-23.jpg
deleted file mode 100644
index 446e22a3..00000000
Binary files a/docs/img/shots/shot-23.jpg and /dev/null differ
diff --git a/docs/img/shots/shot-24.jpg b/docs/img/shots/shot-24.jpg
index 621dbadf..c2a5b983 100644
Binary files a/docs/img/shots/shot-24.jpg and b/docs/img/shots/shot-24.jpg differ
diff --git a/docs/img/shots/shot-25.jpg b/docs/img/shots/shot-25.jpg
deleted file mode 100644
index 3974633d..00000000
Binary files a/docs/img/shots/shot-25.jpg and /dev/null differ
diff --git a/docs/img/shots/shot-26.jpg b/docs/img/shots/shot-26.jpg
deleted file mode 100644
index bb1d7ad8..00000000
Binary files a/docs/img/shots/shot-26.jpg and /dev/null differ
diff --git a/docs/img/shots/shot-27.jpg b/docs/img/shots/shot-27.jpg
new file mode 100644
index 00000000..3eaffb79
Binary files /dev/null and b/docs/img/shots/shot-27.jpg differ
diff --git a/docs/img/shots/shot-28.jpg b/docs/img/shots/shot-28.jpg
new file mode 100644
index 00000000..79564d5f
Binary files /dev/null and b/docs/img/shots/shot-28.jpg differ
diff --git a/docs/img/shots/shot-29.jpg b/docs/img/shots/shot-29.jpg
new file mode 100644
index 00000000..508ea2ac
Binary files /dev/null and b/docs/img/shots/shot-29.jpg differ
diff --git a/docs/index.md b/docs/index.md
index 1fbb8b20..621d467f 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,43 +1,70 @@
---
title: GoldenEye 007 PC Port
-# v0.3.0: status bumped from v0.2.1; Honest-status bullets synced with the
-# README's v0.3.0 known issues (D255/D282/F10-dup/overscan items removed as
-# fixed). Description kept at 133 chars (Bing's 160 limit).
+# v0.4.0: status bumped from v0.3.0; Honest status replaced by the v0.4.0
+# release-notes known issues (rainbow/water/z-fight/cameras/trees/muzzle/
+# stretched-widescreen/gunshot items all fixed; D335b F10-overlay stretch is
+# the new widescreen residual). Description kept short (Bing's 160 limit).
description: >-
- A native PC port of the 1997 N64 classic, built from decompiled source with
- a software RSP. v0.3.0 for Windows, Linux and Steam Deck.
+ A native PC port of GoldenEye 007 (Nintendo 64, 1997), built from
+ decompiled source with a software RSP. v0.4.0 for Windows, Linux and
+ Steam Deck.
---
-
+A native PC port of _GoldenEye 007_ (Rare, 1997, Nintendo 64), compiled from
+the [GoldenEye 007 decompilation](https://github.com/n64decomp/007): the
+original N64 game running from reconstructed source, not the Xbox 360
+remaster. The N64's graphics coprocessor (RSP) is emulated in software; every
+other hardware surface (video, audio, input, timers, save storage) is shimmed
+in a dedicated `port/` layer, following the architecture of the
+[Perfect Dark PC port](https://github.com/fgsfdsfgs/perfect_dark), the same
+Rare "Indy" engine family, one hardware generation apart.
-A native PC port of the original 1997 Nintendo 64 _GoldenEye 007_, compiled
-from the game's [decompiled source](https://github.com/n64decomp/007) with the
-N64's graphics coprocessor (RSP) running in software, the same architecture
-as the [Perfect Dark PC port](https://github.com/fgsfdsfgs/perfect_dark), the
-same Rare "Indy" engine family, one hardware generation apart.
+[Download](#download) · [News](#news) · [See it running](#see-it-running) · [Honest status](#honest-status) · [Documentation](#documentation)
-**Status: v0.3.0.** The full single-player campaign runs at a steady 60 fps
-and is completable end to end (all 20 missions playtested); all 20 missions
-load and run clean on Windows, Linux and Steam Deck, and audio (music + SFX)
-plays throughout. The known rough edges — mostly cosmetic rendering defects,
-stretched-not-native widescreen, and missing features — are listed plainly
+**Status: v0.4.0 (2026-09-28) — fully playable, with a small set of known
+caveats.** The full single-player campaign runs at a steady 60 fps and is
+completable end to end (all 20 missions, plus the ending-credits sequence,
+playtested on Windows, Linux and Steam Deck). It is the most complete release
+to date: **native widescreen** (on by default — undistorted world at your
+display's aspect, 4:3 menus pillarboxed), a **complete aim system** (N64 or
+centred PC style, for mouse *and* controller), **in-game key rebinding** with
+a GEPD-style default layout, **crosshair customization**, **rumble-pak
+haptics** on supported gamepads, and a **rebuilt options overlay** with
+fine-tuning rows (frame cap, MSAA, draw/LOD distance, HUD scale). The most
+common defect classes from earlier releases — particle colour drift, water
+seams, z-fighting, billboard trees, muzzle flashes, the front-end Nintendo
+logo, gunshot SFX — are fixed in this version; what remains is a short list,
under [Honest status](#honest-status).
**This is a pre-1.0 release, not a finished product** — v1.0 is the target
-for a polished, feature-complete build; expect rough edges and missing
-features until then. See the
+for a polished, feature-complete build; expect missing features and the
+occasional breaking change until then. See the
[README's Roadmap section](https://github.com/jkdansereau/goldeneye-pc-port#roadmap)
-for direction (PAL/JP support, real widescreen, LAN multiplayer under
-consideration, and more).
+for direction (PAL/JP support, controller-button rebinding, LAN multiplayer
+under consideration, and more).
- ~12 s gameplay montage from live v0.3.0 play sessions, running in the port.
+ alt="~24 s gameplay montage from live play sessions">
+ ~24 s gameplay montage from live v0.4.0 play sessions (Runway tank,
+ Dam, Caverns, Aztec, Bunker 2, Surface 2), running in the port.
+## News
+
+- **2026-09-28** — **v0.4.0**: native widescreen, a complete aim system for
+ mouse and controller, in-game key rebinding, crosshair customization,
+ rumble-pak haptics, a rebuilt options overlay, and a broad fidelity-fix
+ pass (water, particles, billboard trees, front-end logo, gunshot SFX, a
+ true stable 60 fps). [Release notes](https://github.com/jkdansereau/goldeneye-pc-port/releases/tag/v0.4.0) ·
+ [downloads](#download).
+- **2026-09-20** — **v0.3.0**: the first release with the complete campaign
+ playable end to end at 60 fps on Windows, Linux, and Steam Deck.
+ [Release notes](https://github.com/jkdansereau/goldeneye-pc-port/releases/tag/v0.3.0).
+- **2026-09-04 → 2026-09-16** — **v0.1.0 – v0.2.2**: the alpha and beta
+ cycle — build chain, software RSP, first rendered frames, front end, and
+ per-level stabilization across the campaign.
+
## Download
| Platform | Bundle | Notes |
@@ -59,89 +86,61 @@ without a ROM you already own.
## See it running
-
-
+
+
-Full playtest gallery : 19 in-engine captures from the v0.2.0 Windows playtest, across the campaign
+Full gallery : 12 in-engine captures from a v0.4.0 intro-attract pass (3072×1728 widescreen, Sep 2026)
-
+
Dam
-
-
Facility
-
-
-
+
Runway
-
+
Surface
-
-
Surface
-
-
-
-
Bunker 1
-
-
-
-
Silo
-
-
-
+
Frigate
-
-
Frigate
-
-
-
+
Surface 2
-
-
Bunker 2
-
-
-
+
Statue
-
-
Statue
-
-
-
+
Archives
-
+
Cradle
-
-
Cradle
-
-
-
+
Aztec
-
-
Aztec
+
+
Control
-
-
Frigate
+
+
Streets
+
+
+
+
Jungle
@@ -190,34 +189,25 @@ catalogue: [Porting notes](porting-notes.md).
## Honest status
-- **Particle colours** can still drift through a rainbow palette
- intermittently: one genuine cause is fixed this release, a second hasn't
- been found yet (D252).
-- **Some muzzle flashes draw an extra erroneous long flash** straight up from
- the gun, on top of the correct one. Cosmetic only (D303).
-- Water levels show a moving seam between two water patterns (D245);
- occasional z-fighting on some geometry (D308); in-level security-camera
- props can occasionally face backwards (D307). Minor and cosmetic.
-- **The front-end Nintendo logo renders as two white blobs** and the Rareware
- logo's texture filtering looks off (D75).
-- Surface 1's 2D billboard trees render as a solid wall of tree texture
- instead of discrete sprites; under active investigation (D236).
-- **Widescreen is stretched, not native**: 16:9 stretches the 4:3 frame to
- fill the display; automatic FOV scaling keeps the framing comfortable and
- gameplay is unaffected, but a distortion-free native widescreen render is
- still on the roadmap. Distant geometry can also drop out on the biggest
- open levels (D249).
-- **Gunshot SFX can sound off during sustained/rapid fire** (D240/D241).
-- **`All unlocked` is highly experimental** — don't enable it until you have
- at least one save written, or it can break audio and mouse aim
- (D257/D259/D281).
-- Bond's cutscene positioning is fixed and right the large majority of the
- time now; on the rare occasion he's off it's a small drift — no more
- floating or spin-glitching (D173/D292/D243).
-- No macOS/ARM support; no controller rebinding UI yet.
+- On Facility, if gas leaks during Ourumov's monologue he can pause for up to
+ ~10 s before resuming the scripted shootout — a latent race that exists in
+ the N64 original too (where it softlocks permanently); the port detects and
+ auto-recovers it. (D318)
+- With native widescreen on, the F10 options overlay stretches with the
+ window instead of pillarboxing like the front-end menus (legible; cosmetic;
+ the F10 *Native widescreen* toggle restores the old stretched frame
+ throughout). The world/HUD widescreen rendering itself is correct. (D335b)
+- The front-end Rareware logo shows a subtle texture-filtering artifact
+ (the Nintendo logo and legal page are clean). Cosmetic only. (D75)
+- **`All unlocked` is experimental**: the fresh-install corruption case is
+ fixed, but saves made while it is on are not guaranteed recoverable by
+ switching it off — complete a level normally first, and back up
+ `data/ge007.eep` before enabling it. (D387)
+- No macOS or ARM support. Keyboard/mouse rebinding shipped in v0.4.0;
+ controller-button rebinding is not supported yet.
The full list, with root causes and fix status: the
-[README's Status section](https://github.com/jkdansereau/goldeneye-pc-port#status)
+[release notes](https://github.com/jkdansereau/goldeneye-pc-port/releases)
and the [finding log](https://github.com/jkdansereau/goldeneye-pc-port/tree/main/docs/dev).
## Documentation
@@ -227,9 +217,3 @@ and the [finding log](https://github.com/jkdansereau/goldeneye-pc-port/tree/main
- [Internals](internals.md): architecture, the software RSP-emulation approach, GoldenEye-vs-Perfect-Dark engine differences, the phased plan.
- [Porting notes](porting-notes.md): the recurring Nintendo 64 → PC bug classes hit during the port, with fixes.
- [Building](building.md): full build and asset-extraction guide.
-
----
-
-Non-commercial fan preservation/research project. No ROM or game assets
-are distributed; you supply a ROM you already own. Not affiliated with or
-endorsed by Nintendo, Rare, Microsoft, MGM, Danjaq, or EON Productions.
diff --git a/docs/media/goldeneye-gh-preview.gif b/docs/media/goldeneye-gh-preview.gif
index 346f848a..e05e42b1 100644
Binary files a/docs/media/goldeneye-gh-preview.gif and b/docs/media/goldeneye-gh-preview.gif differ
diff --git a/docs/porting-notes.md b/docs/porting-notes.md
index f8df7af7..05598fdd 100644
--- a/docs/porting-notes.md
+++ b/docs/porting-notes.md
@@ -28,6 +28,8 @@ good that you are looking at one of these.
- [D3. GCC/mingw makes an all-non-negative `enum` UNSIGNED](#d3-gccmingw-makes-an-all-non-negative-enum-unsigned)
- [D4. N64 "interrupts off" must be a real lock on PC](#d4-n64-interrupts-off-is-not-free-on-pc--it-must-be-a-real-lock)
- [D5. Loop bounds that assume linker adjacency of two globals](#d5-loop-bounds-that-assume-linker-adjacency-of-two-file-scope-globals)
+- [D12. Collapsing an LOD binding to tile 0 breaks a DETAIL binding](#d12-collapse-an-lod-binding-to-tile-0-is-wrong-for-a-two-texture-detail-binding-d236)
+- [D13. Per-render accumulation is not associative (1 vs 2–3 ticks/frame)](#d13-per-render-accumulation-is-not-associative-the-ports-1-tickframe-can-reach-states-the-n64s-23-ticksframe-never-did-d329)
- [E. Process / method notes](#e-process--method-notes)
## A. Pointer-width struct growth (32→64): the dominant class
@@ -105,6 +107,15 @@ for hardcoded indices into any `padding[]`/`u8[]` union arm.
through the correctly-typed sibling field (`ObjectRecord.model`)
instead, `#ifdef PORT`, N64 line/cast kept verbatim under `#else`.
§F **D255** (M-140).
+- **D392** (the firing-guard pose variant): `chrlvUpdateAimendbackShoulders`
+ reads `((f32*)arg1)[12/13/16/17]` into a
+ `weapon_firing_animation_table` whose *first* member is a union of a
+ `s32` and a `ModelAnimation *`. N64's 4-byte union places those slots at
+ `max_up/max_down/free_arm_frac_up/free_arm_frac_down`; on PC the 8-byte
+ union shifts the floats, so the raw indices read `aim_end_frame/max_up/
+ max_right/free_arm_frac_up`. The wrong aim clamp bends the torso ~49° and
+ pushes the hands through it. Under `#ifdef PORT`, use the four named
+ fields; retain the raw indices for N64. §F D392.
**Lesson.** When a value reads as a clean constant (0, 1, NULL) rather
than garbage, a pointer byte is a prime suspect; the high bytes of a
@@ -249,6 +260,36 @@ framing — it never looked like game logic to begin with.
decompilation of what the write side already does, `#ifdef PORT` with the
N64 hand-rolled offset kept verbatim under `#else`. §F **D248**.
+- **One layout bug often has sibling call sites.** D264 fixed a
+ cross-global `ModelRenderData` template read in the watch item preview;
+ the identical read in `watchRenderController` (`D_80035D04 + 0x3c`) stayed
+ broken until D290. When fixing a raw-offset global read, grep for every
+ other read of the same globals (`D_80035D00`/`D_80035D04` here) before closing.
+
+- **When a split N64 struct is made inline on PC, its placeholder fields
+ become dead, and any code still reading them reads uninitialised memory.**
+ D100 turned `player.model` into a real inline `struct Model`, but the
+ decomp's `field_59C..field_650` placeholders (raw N64 offsets into that
+ model) stayed as separate fields. `bheadAdjustAnimation` kept reading
+ `field_5C0` (= N64 `model.animframe1`) and `animFlipFlag` (= `model.gunhand`),
+ which nothing ever wrote (D336: garbage anim frames, Frigate scene wrecked,
+ sensitive to memory layout). After inlining a split struct, grep every
+ placeholder in the old range and remap each read to the real field.
+ Diagnostic tell: a hardware watchpoint on the "poisoned" field sees *no
+ writes at all*.
+
+### A-dup. Renaming an IDO duplicate member re-points every read of it (D307)
+
+IDO accepts a struct that redeclares an inherited member name (`inherits ObjectRecord;` then its own `pad`); GCC rejects it, so the port renames one. **The rename changes which field existing code reads**: on N64 the later declaration shadows the inherited one, so `rec->pad` meant the derived record's own field. After renaming it (`lookpad`), unchanged game code silently binds to the inherited field instead. D307: `setupCctv` read the mount pad as the look-at pad and every security camera faced its wall. When a port rename resolves a duplicate, grep every `->name` on that struct type and decide per site which field N64 meant (PD's named-field sources are the tie-breaker).
+
+### A-cnt. A record's "count" field may not count what the converter copies (D303)
+
+The offline model converter sizes each record's trailing arrays from its count field. Check what the *runtime consumer* does with that count before trusting its name: GE's op-22 `DisplayListPrimaryRecord.numVertices` is really the number of star-flash ARMS, and `dorottex()` reads `count*4` vertices (PD names it `stargunfire.unk00` and reads `unk00*4`). Sizing by the field name dropped 3/4 of every muzzle star; the missing vertices came from neighbouring sidecar bytes and drew as spikes. For every record type, derive the copied extent from the reading loop, and cross-check PD's typed struct.
+
+### A-buf. Growing one region of a shared buffer must move every hard-coded offset after it (D342)
+
+When a PC-larger asset gets a bigger reservation inside a shared scratch buffer (D45: the wallet model 0xA000 -> 0x17000 in `ptr_logo_and_walletbond_DL`), grep for every other user of that buffer that computes a fixed offset (`base + 4096*10`). Those still point into the old boundary and silently overwrite the enlarged asset; the corruption only shows when the asset is not reloaded.
+
## B. 16-byte PC `Gfx` / `Vtx` vs 8-byte N64
Any buffer reservation, `memcpy` size, slot stride, or pool budget
@@ -556,12 +597,42 @@ through a converter or a runtime bswap fixup reads scrambled.
(exact for hold-to-aim; a toggle scheme needs `g_CurrentPlayer->
insightaimmode`). GE's native pitch is **inverted** (C-up → look down);
hide it so mouse-down looks down; `MouseInvertY` flips (D118, M-24).
+- **N64 A/B bit names are not modern pad A/B semantics (D393).** GE's
+ gameplay `GE_CONT_A` cycles weapons and `GE_CONT_B` contextually uses or
+ reloads. Assigning Xbox A/X both to native A and B/Y both to independent
+ crouch leaves *no* use button. For modern controls, map A to a dedicated
+ use edge, X to a dedicated reload edge, B to crouch, Y to a weapon-cycle
+ edge; preserve native accept/cancel bits in front menus/watch/tank. Track
+ held buttons across UI transitions so closing F10 does not trigger an
+ unexpected use/reload/cycle.
- `osContGetReadData(pad)` must fill **one OSContPad per channel**
(`MAXCONTROLLERS`-long array), not just controller 0; joy.c passes the
whole `samples[i].pads` array (D118).
- Controller state has one source: `port/src/input.c`. `libultra.c`'s SI
section marshals `inputComputePad()` into `g_contPad[]`; it is driven by
`osContStartReadData` (per logic tick), no separate `video.c` frame hook.
+- **The in-level watch/pause is not a "menu" to `input.c`** (`current_menu`
+ stays `RUN_STAGE`), but it reads the stick as **discrete** input: ±0x2E
+ on X picks an option value or flips a page, and Y scrolls rows. Any path
+ that turns a continuous device (the mouse) into stick deflection must be
+ gated off there (`!outside_watch_menu || pause_state`), or small hand
+ motion becomes phantom menu choices. D330/#103: mouse → legacy stick
+ fallback silently reset Auto-Aim/Look-Ahead to OFF. The direct-write
+ look paths already gate on this; the fallbacks must too.
+
+- **A port-side shadow of game data must be re-synced every time the game
+ re-derives the original, not just the first time.** D252: `texReset()`
+ re-resolves the global image DLs' texture pointers on *every* stage load;
+ the compiled shadow copies (`gimgSyncCompiledGlobalDLs`, D68) only accepted
+ a write while a slot still held its unresolved marker, so every stage after
+ the title kept pointers into freed memory. It looked fine until the new
+ stage overwrote that region (rainbow explosions mid-level). Key a sync on
+ *which slots* are synced (learned once), never on "still unresolved".
+ Headless `-level_XX` boots hide this class (the first stage is the level
+ itself): repro through a real boot → title → level sequence.
+
+- **A texture re-declared in another format must be normalised with the geometry it was IMPORTED with (D245).** GE's sky water loads a CI8 image and draws it through an RGBA16 tile; fast3d imports it as CI8 (D229) but its triangle path computed the UV divisor from the RGBA16 tile (half the width) and the whole mip-chain height (43 rows, not the 32-row mask period). Anything that changes the importer's format/extent must change the tri-path `tex_width/tex_height` with it, and wrap must equal the N64 mask period.
+- **CPU-built RDP triangles need float vertices, not `Vtx` (D245).** Where GE builds RDP edge/texture coefficients on the CPU (sky/water), 32-bit S/T and positions are normal; squeezing them through s16 `tc`/`ob` costs visible precision on horizon-scale geometry. Use `G_FLOATVTX_EXT` (`port/include/floatvtx.h`).
## D. N64 hardware idioms fast3d does not emulate
@@ -865,6 +936,52 @@ through a converter or a runtime bswap fixup reads scrambled.
(ginit.s is missing from this repo; the expansion lives there), not UV,
shade, or the combiner.
+## D14. LOD tile selection is a 2-cycle-only RDP feature; stale `G_TL_LOD` must not affect 1-cycle texture rectangles (D341)
+
+`G_TL_LOD` is sticky other-mode state. Game code that draws a 1-cycle texture rectangle after an LOD-textured draw never clears it, because on the RDP it is irrelevant outside 2-cycle mode. fast3d's LOD/detail tile folding (D107/D236) must therefore not run for 1-cycle texrects, or they sample a neighbouring tile's stale declaration. Related: textures the CPU rewrites in place at recurring addresses (dyn pool) need a content-hashed cache key, and remember game code reaches them through the 0x80000000 KSEG0 mirror.
+
+## D12. "Collapse an LOD binding to tile 0" is wrong for a two-texture DETAIL binding (D236)
+
+**M-198 (finding D236 pass 26).** GE's `texLoadFromGdl` resolves a room DL's
+`G_NOOP` texture reference through one of three handlers, chosen by
+`in->words.w0 & 7` (`src/game/tex.c`):
+
+| type | handler | TMEM layout |
+|---|---|---|
+| 0 `TEXTURETYPE_LOD` | `texHandleType0` | ONE load block at TMEM 0; tiles 0..n are LOD levels **of that one image** |
+| 1 `TEXTURETYPE_DETAIL` | `texHandleType1` | TWO load blocks: the **detail** texture at TMEM 0, the **base** image at TMEM `texGetSizeInBytes(detail,0)` on tiles 1.. |
+| 2 `TEXTURETYPE_MIPMAP` | `texHandleType2` | as type 0 |
+
+fast3d's `gfx_lod_tile_offset` (`port/fast3d/gfx_pc.cpp`) folded **every**
+`G_TL_LOD` draw to tile 0, on the reasoning (D107) that "GE loads the whole mip
+chain at TMEM 0". That is true for types 0 and 2 and **false for type 1**,
+where TMEM 0 holds the detail texture and the image you actually want is on
+tile 1. The port therefore drew the detail texture in place of the base image —
+and never even *imported* the base image, so every probe looking for it came
+back empty.
+
+On Surface 1 this was the whole of D236: all four treeline cards
+(`tex1198`–`tex1201`, RGBA5551 64×17 cut-outs) are type-1 bindings sharing one
+detail texture, `tex2465` — an opaque 32×32 IA8 noise tile. Result: an opaque
+tiled noise wall where the treeline belongs, for 20+ investigation passes.
+
+**Tells.** (a) A texture the game demonstrably decodes into the pool is never
+imported by the renderer — that is a *binding* bug, not a decode, alpha, mip or
+geometry bug, and no amount of work on the texture's contents will move it.
+(b) The wrong thing drawn is itself a real, correctly-decoded texture from the
+same draw — look for a second texture bound by the same command.
+
+**Discriminating the two layouts at draw time.** Prefer an invariant that a mip
+chain can never satisfy. Tiles 1.. of a type-0/2 binding are LOD levels of tile
+0's image, so they necessarily carry the **same `fmt`/`siz`**; a type-1 pair is
+two unrelated textures and generally does not. Deliberately choose the
+asymmetric test — a mip chain can never trip it, and a detail pair that happens
+to share a format merely keeps the old behaviour. Two weaker discriminators
+were tried live and both misfire: "tile 1 sits at a different TMEM address"
+matches ordinary mip chains (their levels are at different TMEM offsets too),
+and "tile 1's TMEM slot has a registered load" matches them as well, because
+`rdp.loaded_texture[]` is persistent state that is never cleared between binds.
+
## D2. The HUD/model "X-mirror" (D114/D116): RESOLVED, it was an upside-down capture
**M-33 (finding D168).** There was no mirror. `gfx_opengl_dump_bound_fbo`
@@ -1019,6 +1136,12 @@ silently disappears.
upstream. This class isn't limited to display-list cursors; audit any
`-O2`-only "quietly broken but not crashing" symptom for the same shape.
Fix: `return` under `#ifdef AVOID_UB` (`#else` keeps the N64 body).
+- Instances (latent, 2026-09-25 Q7 warning audit): `constructor_menu07_missionsel()` and
+ `constructor_menu0D_missioncomplete()` (`src/game/front.c`), both `DL = f(DL)` at the
+ caller. Currently harmless: old/new objects disassemble identically at `-O2` (GCC
+ keeps `frontDrawCursor()`'s result in `rax`), but a clang/arm64 build is not bound
+ by that. `return DL;` under `#ifdef AVOID_UB`. A `-Wreturn-type` sweep lists the
+ remaining candidates; the other 7 current hits have callers that ignore the value.
- The one flagged in-source with an explicit comment is
`grep -rn "missing a \"return\"" src/` (`gunfire.c`). Others (like D77's)
exist without the banner; suspect this class whenever an `-O2` build
@@ -1158,8 +1281,98 @@ produce the same visual symptom but need opposite fixes.
**Update 2 (same investigation, immediately after): freezing a *value* and freezing a *pointer target* are different operations — treating them the same crashed twice.** Extending the same freeze technique to `render_pos` (a `Model*` field, unlike `field_488.pos` a plain value) by skipping the call that reassigns it (`subcalcmatrices`) seemed equivalent to M-167's "skip the write" but wasn't: `render_pos` is a pointer into a per-tick dynamically-reallocated arena, and skipping its reassignment only works if the pointer it's left holding is still valid — which depends on model lifecycle, not just on this tick's logic. Two fixes in a row tried to detect "is it safe to skip" by inspecting either the pointer's *value* (a sentinel check, `!= NULL && != -1`) or a *cached identity* (comparing the pointer against a remembered "already validated" pointer) — both crashed, because **the allocator reuses model-pointer addresses across genuinely different logical model instances**: a freshly-`CREATE`d model can land at the exact address a previously-removed model occupied, so both the sentinel check and the identity check wrongly conclude "this is already valid" for an instance whose `render_pos` is still an uninitialized tombstone. This is the same "don't guess validity from a value that can coincidentally look right" trap as the paragraph above, encountered a second time from a different angle (a *pointer's* target identity, not a *field's* content). **The robust fix:** never skip the call that establishes the pointer's validity — let it always run — and apply the freeze as a *contents* copy afterward, on top of a buffer that's now unconditionally guaranteed valid. If you must "hold something steady" and that something is reached through a pointer that some other code reassigns, freeze the *pointee's bytes*, not the *act of assignment* — the assignment is usually also the thing keeping the pointer sane.
+## D12. Depth precision: the port matches N64 exactly — z-fighting seen on PC is probably faithful (D306/D308)
+
+When a user reports z-fighting, do NOT assume the software renderer has less depth precision than the RDP. Verified for GE: the RSP's vertex transform (`rsp/graphics/gmain.s`) writes post-transform x/y/z/w back to the vertex buffer as **full 32-bit 16.16 fixed-point** (`sdv` stores), not s16 8.8 — the RDP interpolates from full-precision NDC and quantizes once, into its 24-bit z-image. The port keeps floats through `gfx_sp_vertex` and writes a `GL_DEPTH24_STENCIL8` buffer: same or better precision at every stage, same near/far. So coincident geometry authored into the level fights on both platforms; a PC-only explanation needs positive evidence (an N64 reference of the same spot), not just the existence of the symptom. A mitigation like switching to 32-bit float depth would *reduce* fighting but diverge from N64 behavior — that's a rule-2-style decision, not a silent fix.
+
+**Probe technique that worked (vs. three variants that didn't):** detecting z-fighting from emitted triangles requires filtering out (a) tessellation gradients — adjacent tris of one surface have near-equal depths by construction — and (b) camera-motion coincidences — as the camera moves, unrelated world points pass through the same screen cell and their depths happen to sit close. What survives both: a coarse screen-cell grid keeping a short per-cell depth history, flagging only **non-monotonic** (a-b-a) oscillation within a small tolerance that **recurs many times at the same cell**. Monotonic drift = gradient; one-off close pairs = motion artifact; sustained recurrence = a real fight. `GE_ZF` in `port/fast3d/gfx_pc.cpp` implements this (`GE_ZFTOL` tolerance, `GE_ZFBOX` region filter). **Caveat (M-201):** `GE_ZF` actually quantises NDC z to ±255, not 24 bits, so at its default tolerance it flags unrelated nearby surfaces; treat a hit as a lead, not proof. Before calling anything "z-fighting", check the separation against the near plane: at close range 24-bit depth resolves far finer than any non-coplanar model parts, so "whole surfaces in the wrong order" there is depth *state* or geometry, not precision. Use `GE_D306C` (per-triangle depth-state census) for that; D306 turned out to be authored geometry seen from an unusual camera.
+
+## D13. Per-render accumulation is not associative: the port's 1 tick/frame can reach states the N64's 2–3 ticks/frame never did (D329)
+
+GE on N64 renders gameplay at ~20–30 fps, so `g_ClockTimer` (= `speedgraphframes`, 60 Hz ticks per rendered frame) is normally 2–3. The port at 60 fps gives 1. Code that folds `numticks` ticks into **one** downstream call is not equivalent at different granularities. `modelTickAnim` sums `playspeed*speed` over the ticks, then makes one `modelSetAnimFrame2WithChrStuff` call, which only moves `framea`/`frameb` when `floor(frame)` crosses an integer. At speed < 1 and 1 tick/frame a clamped (`framea == frameb`) non-looping anim never crosses, so it stays pinned even after its endframe is raised. At 2+ ticks it crosses on the next frame. Result: permanent guard freezes on PC (D318/D320) that the console could not sustain.
+
+- **Tell:** a state that holds forever at 60 fps, is frame-exact (integer frame, `fa == fb`), and has a per-tick step < 1.0 unit.
+- **Repro correctly:** force the granularity with `GE_D318B_CLK=N` (TEMP probe, lockstep N ticks/frame) and A/B N=1 vs N=2. Wall-clock runs on a slow host (WSLg renders ~5 fps → mixed clk 1–6) and `GE_DETERM=1` (advances per VI-retrace request, also mixed clk) both give uncalibrated results.
+- **Where else to look:** any `for/while (numticks)` accumulator followed by a single threshold/floor consumer; D193 (AI locomotion rate) and D243 (cutscene `numticks` bursts) touched the same axis from the other side.
+
+## D15. A texture-rectangle idiom that is sub-1-native-pixel tall relies on the RDP's floor-based fixed-point scanline stepping — now replicated in `gfx_draw_rectangle` (D397, RESOLVED 2026-09-28)
+
+GE's front-end vertical-gradient trick (`titleRenderFolderMenuBackgroundLines`,
+`src/game/title2.c`) draws a tall image as 299 separate 1-scanline-tall
+`gSPTextureRectangle`s, one per source row, each tinted by a per-row
+interpolated `gDPSetPrimColor`. The lower-right Y coordinate uses the
+universal N64 idiom `((y+1)<<2)-1`, which in U10.2 units is **3, not 4** — a
+0.75-native-pixel-tall rect, not 1.0. This is correct *on real hardware*
+specifically because the RDP's scan converter steps by truncating
+(flooring) the fixed-point Y to an integer scanline before testing coverage,
+so `y*4+3` still falls in scanline `y` and nothing else — the "-1" is a
+hardware-truncation idiom, not a literal float height.
+
+`gfx_pc.cpp`'s `gfx_draw_rectangle` does not replicate that truncation: it
+converts the U10.2 corners straight to continuous NDC floats
+(`ulyf/lryf = ±(y / (4·HALF_SCREEN_HEIGHT)) + 1`) and hands them to the GPU's
+own continuous rasterizer, which uses pixel-center/coverage rules, not
+floor-stepped scanlines. A rect that is 0.75 (native) pixels tall, stacked
+299 times with each row offset by exactly 1.0 native pixel from the last,
+does not reliably rasterize as "exactly the one scanline the N64 idiom
+intended" under a continuous rasterizer — whether a given row rounds to 0 or
+1 device pixel depends on where its fractional edges land relative to the
+device pixel grid, and that phase drifts row-to-row (worse under any
+non-integer native→window viewport scale, `gfx_adjust_viewport_or_scissor`'s
+`ratioX`/`ratioY`). Stacked over ~300 rows filling the screen, sporadic
+per-row rounding reads as a periodic horizontal comb/interlace — this is the
+leading mechanism for D397's "interlaced" file-select background and intro
+gun-barrel, and is a **first-render, not re-entry-triggered** artifact
+(unlike D182), because it is a rasterization property of the geometry itself,
+not stale state.
+
+**Tell:** any GE front-end/menu draw that fakes a large image or gradient by
+stacking many independent 1-scanline (or otherwise sub-1-native-pixel-tall)
+`gSPTextureRectangle`s rather than one multi-row texture — the `(<<2)-1`
+lower-right idiom is the signature to grep for. A single such rect is
+invisible (sub-pixel rounding on one edge); many stacked ones make the
+rounding pattern visible as banding.
+
+**Fix (landed 2026-09-28, RESOLVED):** `gfx_draw_rectangle` (`gfx_pc.cpp`) now replicates the RDP truncation. After the sub-pixel offset is applied, it floors the U10.2 top edge to its scanline and the bottom edge to the *last covered* scanline — the RDP scanline range `[top_scan, bot_scan+1]` — before converting to NDC: `top_scan = uly>>2`, `bot_scan = (lry-1)>>2`, then `uly = top_scan<<2`, `lry = (bot_scan+1)<<2`. This is a **no-op for whole-pixel rects** (their edges are already `4k`/`4k−1`, and the `(<<2)−1` bottom idiom already yields `bot_scan` = the intended last row), so only sub-pixel-tall strips change. **Confirmed by a falsifiable 3-scale PCDUMP test:** the comb period is fixed at ≈3 *native* rows across 640/1024/1280 (window-period = 3×scale) — the beat of 0.75-px strips at a 1.0-px pitch — exactly the fixed-point-flooring signature, ruling out the scale-invariant LERP/CC factor. Verified by playtest: gun-barrel + file-select gradient render clean, comb gone.
+
+**Not the cause (checked and ruled out for D397):** the CC/LERP scaling of
+`ENV_ALPHA` as `value/255.0f` (`gfx_pc.cpp` around the `G_CCMUX_ENV_ALPHA`
+case) is byte-identical to the Perfect Dark PC port's (`gfx_pc.cpp` in the
+local `pd_port` checkout) — same formula, no special-cased "5-bit RDP
+coefficient" scaling in either port. The real RDP color combiner's A/B/C/D
+inputs are 8-bit values (PRIM/ENV alpha come from an 8-bit `gDPSetEnvColor`
+field); there is no coarser-precision quirk to correct here. A uniform
+darkening from a nonzero `ENV_ALPHA` LERP factor is very likely faithful
+(possibly authored/original-dev-intentional tinting), not a bug — do not
+"fix" it without an N64/1964-GEPD reference screenshot proving otherwise.
+
## E. Process / method notes
+- **Never `exit()` while another thread may be inside the GL driver (D344).**
+ The GL context lives on the render (scheduler) thread; quit events arrive on
+ other threads. Request the quit (`videoRequestQuit`), let the render thread
+ `glFinish` + unbind at a frame boundary and park, then exit from the host.
+ An unclean GL teardown bugchecked the maintainer's PC (0x119, HAGS on).
+ Harness runs should end with `GE_QUITFRAME=`, not a `timeout` kill.
+- **Headless front-end scripts: turn the mouse off (D343).** In menus the
+ pointer follows the real OS mouse (WI-2 absolute tracking), so the desktop
+ mouse moves the in-game cursor mid-`GE_INPUTSCRIPT`. Put
+ `[Input] MouseEnabled = 0` in the pinned test ini. Script "frames" were
+ about one per rendered frame on the file select screen; calibrate with a
+ log, not by assumption.
+- **`textMeasure` height is 0 without a trailing newline (D343).** It only
+ counts completed lines. Measure `"text\n"` when you need a height (front.c's
+ folder text does this); the width is unaffected.
+- **Periodic audio glitches: measure the host audio device before touching
+ the pipeline (D322).** A clock-regular overflow/garble pattern (~5 min period,
+ ~45 s bursts, independent of level and load) looked like a port timing beat
+ and cost several game runs to chase. `tools_pc/sdl_drain_monitor.c` opens SDL
+ exactly like `port/src/audio.c` with no game and no window; on the
+ maintainer's Focusrite USB interface it showed the same ~312 s drain dips
+ (to 88 %) outright, and running it on several SDL backends at once showed the
+ culprit was SDL2's WASAPI path, not the device (DirectSound was clean; the
+ port now prefers it on Windows). Run it first on any "audio gets garbled"
+ report, and compare backends with `SDL_AUDIODRIVER` before blaming the hardware.
- Investigation loop is: reproduce → env-gated capped probe → root-cause
→ narrow `#ifdef PORT` fix → visual verify (`GE_PCDUMP` +
`tools_pc/pixcount.py` vs `docs/reference/n64-footage-*`).
@@ -1169,6 +1382,43 @@ produce the same visual symptom but need opposite fixes.
writing anything new; same Rare engine family.
- Don't re-investigate a closed §F finding or re-derive a format spec
that already has a converter.
+- **When re-implementing a ROM bitstream/codec offline, PORT the derivation
+ from the decomp; never reconstruct it from the comment.** A half-right
+ bitstream parser in this codebase does not fail loudly — it returns
+ confident, plausible, wrong output. Three hits in four D236 passes:
+ (1) a texture-header parse that omitted the leading control byte
+ (`image.c:2568`) still produced sensible-looking formats and dimensions,
+ and was only caught by counting implausible entries (1020/2699 vs 1/2699
+ when correct) and noticing the format nibble only ever took values
+ 0/4/8/12 — the tell that a field was straddling a boundary;
+ (2) omitting the per-image byte-align at the end of each LOD
+ (`image.c:1096`: discard the partial byte, and skip a whole byte when
+ already aligned) silently desynchronised every LOD after the first;
+ (3) `texInflateRle`'s run-length `fudge` was *guessed* from the doc comment
+ instead of ported from the loop at `image.c:1605`, and still decoded
+ images — just with the wrong alpha coverage (66.7% vs the correct 43.1%).
+ Practical rules: copy the arithmetic statement-for-statement even when it
+ looks like it could be closed-formed; build a cheap global plausibility
+ counter into the tool and print it every run; and cross-check one decoded
+ artifact against an independent expectation (here, that a LOD chain's
+ dimensions halve exactly and its alpha coverage stays roughly constant)
+ before trusting any number the tool emits.
+
+- **A negative result from a diagnostic is worthless until the diagnostic has
+ been shown capable of producing the positive.** Two independent hits: D219's
+ "verify diagnostic caps exempt the real target signature before trusting
+ 'clean'", and D236 pass 21, where a static setup scanner located GE's `PROP`
+ enum with `startswith("typedef enum PROP")` — which matches
+ `typedef enum PROPFLAG` hundreds of lines earlier, yielding an enum with **no
+ `PROP_*` members at all**. Every prop id then resolved to `?` and every
+ "foliage props placed" count came back 0, which is exactly what a clean,
+ meaningful negative looks like. Build the positive control **into** the probe:
+ have it print what it is capable of matching (`foliage props recognised (9):
+ …`) and fail loudly when that set is empty, so the failure mode cannot be
+ read as a finding. Applies equally to grep-based audits over generated C,
+ where near-miss identifier prefixes (`PROP` / `PROPFLAG` / `PROPDEF_TYPE`) are
+ the norm.
+
- **`docs/dev/findings-index.csv` is generator-derived** (`tools_pc/gen_findings_index.py`): the status cell is normalised from the `## Dxx` entry's §F summary-table row in `findings.md`, so hand-editing the CSV is silently reverted on the next regen (hit live in the D219/D252 M-158 session). Update the table row, then run the generator.
- **Every game launch rewrites `data/ge007.eep`** (the eeprom shim stores on
every write; validation resets, cheat patches and normal saves all write).
diff --git a/docs/security-and-fidelity-status.md b/docs/security-and-fidelity-status.md
index 47e61657..8f0b0c00 100644
--- a/docs/security-and-fidelity-status.md
+++ b/docs/security-and-fidelity-status.md
@@ -1,3 +1,9 @@
+---
+title: Security & fidelity status
+description: What a release actually installs (no networking, no telemetry, no ROM or assets)
+and how faithfully the port tracks the original N64 game's logic.
+---
+
# Security & fidelity status
This page answers two questions plainly: *what does installing this put on
diff --git a/port/fast3d/gfx_api.h b/port/fast3d/gfx_api.h
index cf333996..ab93f739 100644
--- a/port/fast3d/gfx_api.h
+++ b/port/fast3d/gfx_api.h
@@ -47,6 +47,7 @@ void gfx_set_target_fps(int);
void gfx_set_texture_filter(enum FilteringMode mode);
void gfx_set_mipmap_filter(enum MipmapFilteringMode mode);
void gfx_set_fix_mip_textures(int on);
+void gfx_set_detail_base_tile(int on); /* D236: sample the base image of a DETAIL binding */
void gfx_set_wrap_fix(int on);
void gfx_set_anisotropy_level(int level); /* 1 = off; clamped to GL max */
void gfx_set_safe_area_crop(int on); /* crop the N64 TV-overscan safe-area margin instead of showing it as black bars */
@@ -56,6 +57,7 @@ void gfx_set_safe_area_crop(int on); /* crop the N64 TV-overscan safe-area
* (see port/src/optionsoverlay.c, D316). */
void gfx_get_ui_screen_rect(int32_t *outX, int32_t *outY, int32_t *outW, int32_t *outH);
void gfx_texture_cache_clear(void);
+int gfx_texture_cache_count(void); /* D235 */
void gfx_texture_cache_delete(const uint8_t *orig_addr);
void gfx_texture_cache_delete_range(const uint8_t *start, const uint8_t *end);
int gfx_create_framebuffer(uint32_t width, uint32_t height, int upscale, int autoresize);
diff --git a/port/fast3d/gfx_opengl.cpp b/port/fast3d/gfx_opengl.cpp
index 4177a203..c3881e12 100644
--- a/port/fast3d/gfx_opengl.cpp
+++ b/port/fast3d/gfx_opengl.cpp
@@ -1095,12 +1095,54 @@ static void gfx_opengl_init(void) {
static void gfx_opengl_on_resize(void) {
}
+/* #92 perf probe (GE_PERFSTAT=1): GPU time per frame via GL_TIME_ELAPSED,
+ * double-buffered so reading never stalls the pipeline. Skipped when the
+ * driver lacks timer queries. Tells a low-end reporter's log whether frames
+ * are GPU-bound (render/MSAA/filtering cost) or CPU-bound. */
+static int s_gpuq_on = -1;
+static GLuint s_gpuq[2];
+static int s_gpuq_idx = 0, s_gpuq_valid[2] = { 0, 0 };
+static double s_gpuq_ms = 0.0;
+static int s_gpuq_n = 0, s_gpuq_frames = 0;
+
static void gfx_opengl_start_frame(void) {
frame_count++;
+ if (s_gpuq_on < 0) {
+ s_gpuq_on = getenv("GE_PERFSTAT") != NULL && glGenQueries != NULL &&
+ glGetQueryObjectui64v != NULL && glBeginQuery != NULL;
+ if (s_gpuq_on) {
+ glGenQueries(2, s_gpuq);
+ fprintf(stderr, "PERFSTAT gpu timer on: %s | %s\n",
+ (const char *)glGetString(GL_RENDERER), (const char *)glGetString(GL_VERSION));
+ }
+ }
+ if (s_gpuq_on == 1) {
+ if (s_gpuq_valid[s_gpuq_idx]) {
+ GLuint avail = 0;
+ glGetQueryObjectuiv(s_gpuq[s_gpuq_idx], GL_QUERY_RESULT_AVAILABLE, &avail);
+ if (avail) {
+ GLuint64 ns = 0;
+ glGetQueryObjectui64v(s_gpuq[s_gpuq_idx], GL_QUERY_RESULT, &ns);
+ s_gpuq_ms += (double)ns / 1.0e6;
+ s_gpuq_n++;
+ }
+ }
+ glBeginQuery(GL_TIME_ELAPSED, s_gpuq[s_gpuq_idx]);
+ }
}
static void gfx_opengl_end_frame(void) {
glFlush();
+ if (s_gpuq_on == 1) {
+ glEndQuery(GL_TIME_ELAPSED);
+ s_gpuq_valid[s_gpuq_idx] = 1;
+ s_gpuq_idx ^= 1;
+ if (++s_gpuq_frames % 300 == 0 && s_gpuq_n > 0) {
+ fprintf(stderr, "PERFSTAT gpu=%.2fms/frame (avg over %d frames)\n", s_gpuq_ms / s_gpuq_n, s_gpuq_n);
+ s_gpuq_ms = 0.0;
+ s_gpuq_n = 0;
+ }
+ }
}
/* Frame capture (dev tool, env GE_PCDUMP) — see gfx_opengl.h. TEMP D70: read
@@ -1188,6 +1230,20 @@ static void gfx_opengl_update_framebuffer_parameters(int fb_id, uint32_t width,
width = max(width, 1U);
height = max(height, 1U);
+ /* macOS GL 4.1 core: asking for more samples than GL_MAX_SAMPLES (e.g. 8x)
+ * makes the multisample renderbuffers incomplete and the screen goes dark
+ * (glReadPixels 0x506). Clamp to what the driver reports. */
+ if (msaa_level > 1) {
+ static GLint maxSamples = -1;
+ if (maxSamples < 0) {
+ glGetIntegerv(GL_MAX_SAMPLES, &maxSamples);
+ if (maxSamples < 1) maxSamples = 1;
+ }
+ if ((GLint)msaa_level > maxSamples) {
+ msaa_level = (uint32_t)maxSamples;
+ }
+ }
+
if (gfx_framebuffers_enabled) {
glBindFramebuffer(GL_FRAMEBUFFER, fb.fbo);
@@ -1284,7 +1340,8 @@ void gfx_opengl_resolve_msaa_color_buffer(int fb_id_target, int fb_id_source) {
glBindFramebuffer(GL_READ_FRAMEBUFFER, fb_src.fbo);
glBlitFramebuffer(0, 0, fb_src.width, fb_src.height, 0, 0, fb_dst.width, fb_dst.height, GL_COLOR_BUFFER_BIT,
GL_NEAREST);
- glBindFramebuffer(GL_FRAMEBUFFER, current_framebuffer);
+ /* current_framebuffer is an INDEX into framebuffers[], not a GL name; fb 0 is the window's default framebuffer (name 0). */
+ glBindFramebuffer(GL_FRAMEBUFFER, current_framebuffer == 0 ? 0 : framebuffers[current_framebuffer].fbo);
glEnable(GL_SCISSOR_TEST);
}
@@ -1348,7 +1405,7 @@ void gfx_opengl_copy_framebuffer(int fb_dst, int fb_src, int left, int top, bool
glBlitFramebuffer(srcX0, srcY0, srcX1, srcY1, dstX0, dstY0, dstX1, dstY1, GL_COLOR_BUFFER_BIT, GL_NEAREST);
- glBindFramebuffer(GL_FRAMEBUFFER, framebuffers[current_framebuffer].fbo);
+ glBindFramebuffer(GL_FRAMEBUFFER, current_framebuffer == 0 ? 0 : framebuffers[current_framebuffer].fbo);
glReadBuffer(GL_BACK);
diff --git a/port/fast3d/gfx_pc.cpp b/port/fast3d/gfx_pc.cpp
index 31938c6a..f0e09f6a 100644
--- a/port/fast3d/gfx_pc.cpp
+++ b/port/fast3d/gfx_pc.cpp
@@ -1,5 +1,6 @@
#define NOMINMAX
+#include "floatvtx.h" /* D245 */
#include
#include
#include
@@ -16,6 +17,7 @@
#include
#include
#include
+#include
#include
#ifndef _LANGUAGE_C
@@ -139,12 +141,6 @@ static struct RSP {
const struct NormalColor *vertex_colors; //[MAX_VERTEX_COLORS];
} rsp;
-/* D236 pass 16 (TEMP): segment byte (top byte of the raw segmented address)
- * of the most recent G_VTX load, so a later triangle-time probe can report
- * which segment the tree class's vertices actually came from. See the
- * G_VTX case comment below for why. Remove once D236 pass 16 concludes. */
-static uint8_t g_d236_last_vtx_seg = 0xFF;
-
struct RawTexMetadata {
uint16_t width, height;
float h_byte_scale = 1, v_pixel_scale = 1;
@@ -294,8 +290,13 @@ static constexpr float clampf(const float x, const float min, const float max) {
return (x < min) ? min : (x > max) ? max : x;
}
+/* #92 perf probe (GE_PERFSTAT=1): per-frame counters, see gfx_run. */
+static uint64_t s_perf_batches = 0, s_perf_tris = 0;
+
static void gfx_flush(void) {
if (buf_vbo_len > 0) {
+ s_perf_batches++;
+ s_perf_tris += buf_vbo_num_tris;
gfx_rapi->draw_triangles(buf_vbo, buf_vbo_len, buf_vbo_num_tris);
buf_vbo_len = 0;
buf_vbo_num_tris = 0;
@@ -353,6 +354,9 @@ static const char* acmux_to_string(uint32_t acmux) {
return tbl[acmux];
}
+/* forward decl for the env-gated D75D probe (defined further down) */
+static bool d75d_env_active(void);
+
static void gfx_generate_cc(struct ColorCombiner* comb, const ColorCombinerKey& key) {
bool is_2cyc = (key.options & (uint64_t)SHADER_OPT_2CYC) != 0;
@@ -482,6 +486,17 @@ static void gfx_generate_cc(struct ColorCombiner* comb, const ColorCombinerKey&
}
}
}
+ /* TEMP D75D: one-shot decode of the logo model's combine mode (env-gated via d75d_lo) */
+ {
+ static int d75d_cc_once = 0;
+ if (!d75d_cc_once && d75d_env_active() && key.combine_mode == 0x009ffe4f19ffe4f1ULL) {
+ d75d_cc_once = 1;
+ fprintf(stderr, "D75DCC: comb=0x%016llx is_2cyc=%d c0=(rgb a=%u b=%u c=%u d=%u)(al a=%u b=%u c=%u d=%u) utex0=%d\n",
+ (unsigned long long)key.combine_mode, (int)is_2cyc,
+ c[0][0][0], c[0][0][1], c[0][0][2], c[0][0][3],
+ c[0][1][0], c[0][1][1], c[0][1][2], c[0][1][3], (int)used_textures[0]);
+ }
+ }
{
uint8_t input_number[16] = { 0 };
int next_input_number = SHADER_INPUT_1;
@@ -563,6 +578,13 @@ void gfx_texture_cache_clear() {
memset(rendering_state.textures, 0, sizeof(rendering_state.textures));
}
+/* D235: entry count for the stage-transition probe (boss.c). */
+extern "C" int gfx_texture_cache_count(void) {
+ return (int)gfx_texture_cache.map.size();
+}
+
+extern "C" u8 *g_VtxBuffers[3]; /* dyn.c per-frame pool (dynamic-texture cache key) */
+
static bool gfx_texture_cache_lookup(int i, const TextureCacheKey& key) {
TextureCacheMap::iterator it = gfx_texture_cache.map.find(key);
TextureCacheNode** n = &rendering_state.textures[i];
@@ -1001,6 +1023,11 @@ bool g_fix_mip_textures = true;
* off. Video.WrapFix = 1. */
bool g_wrap_fix = false;
+/* D236 pass 26: for a GE TEXTURETYPE_DETAIL binding, sample the BASE image
+ * (tile 1) rather than the detail texture sitting at TMEM 0. See
+ * gfx_lod_tile_offset. Video.DetailBaseTile. */
+bool g_detail_base_tile = false;
+
/* D183 source-pitch de-stride (see import_texture). Default on;
* GE_TEXPITCH=0 restores the old flat read for A/B. */
static bool gfx_tex_pitch_fix(void) {
@@ -1078,6 +1105,34 @@ static void import_texture(int i, int tile, bool importReplacement) {
key = { orig_addr, {}, fmt, siz, palette_index, loaded_texture.size_bytes, 0u };
}
+ /* Intro blood (M-201): textures the game regenerates IN PLACE in the
+ * per-frame dynamic pool (dynAllocate, [g_VtxBuffers[0], g_VtxBuffers[2]))
+ * -- e.g. the gun-barrel / death blood-drip image rebuilt by
+ * die_blood_image_routine -- reuse the same addresses frame after frame,
+ * so an address-only key returns a stale GL texture from an earlier frame.
+ * Key those on a content hash (FNV-1a) instead; static textures keep the
+ * free address key. GE_DYNTEXHASH_OFF=1 disables for A/B. */
+ {
+ static int dynhash_off = -1;
+ if (dynhash_off < 0) dynhash_off = getenv("GE_DYNTEXHASH_OFF") != NULL;
+ /* Game code often reaches this memory through OS_K0_TO_PHYSICAL,
+ * which fast3d resolves into the byte-identical KSEG0 mirror at
+ * 0x80000000 (port/src/dram.c V2) -- normalise to the V1 view the
+ * dyn pool pointers use before the range test. */
+ const uint8_t* v1addr = orig_addr;
+ if ((uintptr_t)v1addr >= 0x80000000UL && (uintptr_t)v1addr < 0x80800000UL) {
+ v1addr -= 0x10000000UL;
+ }
+ if (!dynhash_off && g_VtxBuffers[0] && v1addr >= g_VtxBuffers[0] && v1addr < g_VtxBuffers[2]) {
+ uint32_t h = 2166136261u;
+ const uint32_t n = loaded_texture.size_bytes;
+ for (uint32_t b = 0; b < n; b++) {
+ h = (h ^ orig_addr[b]) * 16777619u;
+ }
+ key.palette_hash = h ? h : 1u;
+ }
+ }
+
if (gfx_texture_cache_lookup(i, key)) {
return;
}
@@ -1222,6 +1277,33 @@ static void import_texture(int i, int tile, bool importReplacement) {
FILE* bf = fopen(nm, "wb");
if (bf) { fwrite(loaded_texture.addr, 1, loaded_texture.size_bytes, bf); fclose(bf); }
}
+ /* D75 round 3: GE_TEXDUMP_ADDR= -- dump the raw source bytes of
+ * EVERY import from that address (bypassing the 400-cap) with byte
+ * stats, so a late re-import into a scratch-arena alias can be told
+ * apart from a never-loaded buffer. */
+ static uint64_t ge_texdump_addr = 0;
+ static int ge_texdump_addr_init = 0;
+ if (!ge_texdump_addr_init) {
+ ge_texdump_addr_init = 1;
+ const char* ea = getenv("GE_TEXDUMP_ADDR");
+ if (ea && *ea) ge_texdump_addr = strtoull(ea, NULL, 16);
+ }
+ if (ge_texdump_addr && (uint64_t)(uintptr_t)orig_addr == ge_texdump_addr) {
+ unsigned mn = 255, mx = 0, nz = 0;
+ for (uint32_t i = 0; i < loaded_texture.size_bytes; i++) {
+ uint8_t b = loaded_texture.addr[i];
+ if (b < mn) mn = b; if (b > mx) mx = b; if (b) nz++;
+ }
+ char nm2[160];
+ snprintf(nm2, sizeof nm2, "texdump/a%05d_%ux%u.bin", tdc - 1, tw, th);
+ FILE* bf2 = fopen(nm2, "wb");
+ if (bf2) { fwrite(loaded_texture.addr, 1, loaded_texture.size_bytes, bf2); fclose(bf2); }
+ extern uint32_t num_dls; // same counter the D157I probe uses
+ sysLogPrintf(LOG_NOTE,
+ "GE_TEXA[%d] dls=%u addr=%p fmt=%u siz=%u size=%u min=%u max=%u nonzero=%u/%u file=%s",
+ tdc - 1, num_dls, (void*)orig_addr, fmt, siz,
+ loaded_texture.size_bytes, mn, mx, nz, loaded_texture.size_bytes, nm2);
+ }
}
/* D161: a CI-format tile drawn with the TLUT disabled (G_TT_NONE) must NOT
@@ -1285,7 +1367,27 @@ static void import_texture(int i, int tile, bool importReplacement) {
if (siz_eff == G_IM_SIZ_4b) {
import_texture_ci4(tile, loaded_texture, rdp.tex_lod);
} else if (siz_eff == G_IM_SIZ_8b) {
- import_texture_ci8(tile, loaded_texture, rdp.tex_lod);
+ /* D245 (M-201): for the D229 case (RGBA16 tile over a CI8 load --
+ * the IsWater sky water), upload only the base level: the load
+ * carries the whole mip chain (32x32 base + mips = 1400 B), which
+ * made a 32x43 image whose GL REPEAT period (43 rows) differs from
+ * the N64's mask period (32). See the tri-path counterpart. */
+ static int d245_oldtex = -1;
+ if (d245_oldtex < 0) d245_oldtex = getenv("GE_D245_OLDTEX") != NULL;
+ const uint8_t maskt = rdp.texture_tile[tile].maskt;
+ /* Crop by the TILE line size (the importer's row width): for a
+ * LoadBlock, loaded_texture.line_size_bytes is the whole block, so
+ * testing against it never cropped (M-201 follow-up: the mip rows
+ * then showed as coloured dashes on Frigate's water). */
+ const uint32_t d245_row = rdp.texture_tile[tile].line_size_bytes;
+ if (!d245_oldtex && fmt == G_IM_FMT_RGBA && siz == G_IM_SIZ_16b && maskt > 0 && maskt < 12 &&
+ d245_row > 0 && loaded_texture.size_bytes > d245_row * (1u << maskt)) {
+ LoadedTexture lt = loaded_texture;
+ lt.size_bytes = d245_row * (1u << maskt);
+ import_texture_ci8(tile, lt, rdp.tex_lod);
+ } else {
+ import_texture_ci8(tile, loaded_texture, rdp.tex_lod);
+ }
} else {
sysFatalError("Bad size for CI texture in tile %d: %02x", tile, siz);
}
@@ -1437,7 +1539,297 @@ static inline bool fast3d_ptr_ok(const void *p) {
return v >= 0x10000 && v < 0x0000800000000000ULL;
}
+extern "C" u32 videoGetFrameCount(void); /* port/src/video.c (D75 probe) */
+
+/* D75 DL-sequence dump (M-196, GE_D75D="lo-hi"): the M-195 slot-overwrite
+ * hypothesis says every G_VTX batch encodes dest_index=0, so a multi-batch
+ * node's later batches clobber earlier ones and only the last survives. That
+ * is ONLY harmful if a node's triangles are drawn AFTER a later node's G_VTX
+ * has overwritten their slots -- i.e. only if the DL is structured batched
+ * (all vertices for all nodes, then all triangles) rather than streamed
+ * (each node: its vertices then its triangles). gfx_sp_tri1 copies vertex data
+ * into buf_vbo eagerly at triangle time, so streaming order makes cross-node
+ * overwrites harmless exactly like the N64 RDP. This probe records, per frame,
+ * a compact V/T token stream (V=G_VTX batch, T=triangle) plus max-triangle-
+ *index vs cumulative-vertices-loaded, to decide which structure GE's front-end
+ * model DLs actually use. Zero cost unless GE_D75D is set. Remove once D75 is
+ * root-caused. */
+static int d75d_lo = -1, d75d_hi = 0x7FFFFFFF;
+static int d75d_init_done = 0;
+static uint32_t d75d_frame = 0xFFFFFFFFu;
+static char d75d_seq[4096];
+static int d75d_seqlen = 0;
+static uint32_t d75d_maxtri = 0, d75d_vtxloaded = 0, d75d_batchhi = 0, d75d_nv = 0, d75d_nt = 0;
+static uint32_t d75d_rej_triv = 0, d75d_rej_cull = 0, d75d_emitted = 0;
+static uint32_t d75d_emit_on = 0, d75d_emit_off = 0; // emitted tris: NDC bbox intersects [-1,1]^2 vs not
+static float d75d_ndc_minx=1e9f,d75d_ndc_maxx=-1e9f,d75d_ndc_miny=1e9f,d75d_ndc_maxy=-1e9f;
+static uint32_t d75d_emit_invis = 0; // emitted tris with SHADER_OPT_INVISIBLE (G_BL_0+G_BL_CLR_MEM)
+static int d75d_cmin=999, d75d_cmax=-1; // min/max over all vertex color channels of emitted tris
+static uint32_t d75d_oml = 0xFFFFFFFFu; static int d75d_oml_set = 0;
+static float d75d_zmin=1e9f,d75d_zmax=-1e9f; static uint32_t d75d_znan=0; static int d75d_use_tex=-1;
+static uint64_t d75d_comb=0; static int d75d_prim[4]={-1,-1,-1,-1}; static int d75d_env[4]={-1,-1,-1,-1};
+static int d75d_comb_utex=-1; // comb->used_textures[0] (combiner's own claim)
+// D75D round 2: logo-mode-only sampling (the frame-level fields above mix the
+// first emitted tri -- often a title quad -- with the LAST tri's combine mode,
+// which is what made 'comb claims 0' look like an anomaly). These are updated
+// on EVERY emitted tri whose rdp.combine_mode is the logo word, so comb and
+// utex always describe the same triangle.
+static int d75d_lg_utex=-1, d75d_lg_tex=-1; static uint32_t d75d_lg_cnt=0;
+static int d75d_lg_set=0; static uint32_t d75d_lg_tmem,d75d_lg_fmt,d75d_lg_siz;
+static int d75d_lg_shifts,d75d_lg_shifft,d75d_lg_uls,d75d_lg_ult,d75d_lg_lrs,d75d_lg_lrt;
+static const uint8_t* d75d_lg_texaddr=nullptr; static uint32_t d75d_lg_texid,d75d_lg_texbytes;
+static int d75d_lg_u0,d75d_lg_v0;
+
+static void d75d_init(void) {
+ if (d75d_init_done) return;
+ d75d_init_done = 1;
+ const char* e = getenv("GE_D75D");
+ if (e && *e) {
+ d75d_lo = atoi(e);
+ const char* dash = strchr(e, '-');
+ if (dash) d75d_hi = atoi(dash + 1);
+ }
+}
+static bool d75d_env_active(void) { d75d_init(); return d75d_lo >= 0; }
+static void d75d_flush(uint32_t f) {
+ if (f == 0xFFFFFFFFu || d75d_seqlen == 0) return;
+ fprintf(stderr,
+ "D75D: f=%u nv=%u nt=%u maxtri=%u vtxloaded=%u batchhi=%u rej_triv=%u rej_cull=%u emitted=%u emit_on=%u emit_off=%u ndc_x=[%.3f,%.3f] ndc_y=[%.3f,%.3f] invis=%u col=[%d,%d] oml=0x%08x omset=%d z=[%.4f,%.4f] znan=%u utex=%d cutex=%d comb=0x%016llx prim=(%d,%d,%d,%d) env=(%d,%d,%d,%d) lg_cnt=%u lg_utex=%d lg_tex=%d lg_tile(tmem=%u fmt=%u siz=%u sh=%d st=%d uls=%d ult=%d lrs=%d lrt=%d) lg_texaddr=%p lg_texid=%u lg_texbytes=%u lg_uv0=(%d,%d) seq=%.8s%s\n",
+ f, d75d_nv, d75d_nt, d75d_maxtri, d75d_vtxloaded, d75d_batchhi,
+ d75d_rej_triv, d75d_rej_cull, d75d_emitted, d75d_emit_on, d75d_emit_off,
+ d75d_ndc_minx, d75d_ndc_maxx, d75d_ndc_miny, d75d_ndc_maxy,
+ d75d_emit_invis, d75d_cmin, d75d_cmax, (unsigned)d75d_oml, d75d_oml_set,
+ d75d_zmin, d75d_zmax, d75d_znan, d75d_use_tex, d75d_comb_utex,
+ (unsigned long long)d75d_comb, d75d_prim[0],d75d_prim[1],d75d_prim[2],d75d_prim[3],
+ d75d_env[0],d75d_env[1],d75d_env[2],d75d_env[3],
+ d75d_lg_cnt, d75d_lg_utex, d75d_lg_tex,
+ (unsigned)d75d_lg_tmem,(unsigned)d75d_lg_fmt,(unsigned)d75d_lg_siz,
+ d75d_lg_shifts,d75d_lg_shifft,d75d_lg_uls,d75d_lg_ult,d75d_lg_lrs,d75d_lg_lrt,
+ (const void*)d75d_lg_texaddr,(unsigned)d75d_lg_texid,(unsigned)d75d_lg_texbytes,
+ d75d_lg_u0,d75d_lg_v0,
+ d75d_seq, (d75d_seqlen >= 4096) ? "...[trunc]" : "");
+ d75d_seqlen = 0; d75d_maxtri = 0; d75d_vtxloaded = 0; d75d_batchhi = 0; d75d_nv = 0; d75d_nt = 0;
+ d75d_rej_triv = 0; d75d_rej_cull = 0; d75d_emitted = 0; d75d_emit_on = 0; d75d_emit_off = 0;
+ d75d_ndc_minx=1e9f;d75d_ndc_maxx=-1e9f;d75d_ndc_miny=1e9f;d75d_ndc_maxy=-1e9f;
+ d75d_emit_invis = 0; d75d_cmin=999; d75d_cmax=-1; d75d_oml=0xFFFFFFFFu; d75d_oml_set=0;
+ d75d_zmin=1e9f;d75d_zmax=-1e9f;d75d_znan=0;d75d_use_tex=-1;
+ d75d_comb=0; for(int i=0;i<4;i++){d75d_prim[i]=-1;d75d_env[i]=-1;} d75d_comb_utex=-1;
+ d75d_lg_utex=-1; d75d_lg_tex=-1;
+}
+static void d75d_frame_check(uint32_t f) {
+ if (f != d75d_frame) { d75d_flush(f); d75d_frame = f; }
+}
+static void d75d_note_vtx(uint32_t f, uint32_t count, uint32_t dest) {
+ d75d_init();
+ if (d75d_lo < 0 || f < (uint32_t)d75d_lo || f > (uint32_t)d75d_hi) return;
+ d75d_frame_check(f);
+ d75d_nv++;
+ d75d_vtxloaded += count;
+ if (dest + count > d75d_batchhi) d75d_batchhi = dest + count;
+ if (d75d_seqlen < 4096) d75d_seq[d75d_seqlen++] = 'V';
+}
+/* TEMP D306/D308: env-gated z-fighting FLICKER detector (docs/dev/findings.md
+ * §D306/§D308). A coarse grid of screen cells remembers the depth values seen
+ * recently at each cell; when a new value arrives that is close to a
+ * recently-seen-but-different one (within GE_ZFTOL, default 2 units of 24-bit
+ * quantized NDC z), that is the screen-space signature of genuine z-fighting
+ * (the winner alternates between two near-equal depths as the camera moves).
+ * Tessellation noise (adjacent tris of one surface) does not alternate. Gated
+ * on GE_ZF; optional NDC box filter GE_ZFBOX="x0 y0 x1 y1"; caps output per
+ * run. Remove once D306/D308 are resolved. */
+static int zf_on = -1;
+static int zf_tol = 2;
+static float zfbox[4] = { -1.05f, -1.05f, 1.05f, 1.05f };
+static int zfbox_on = 0;
+#define ZF_GRIDX 96
+#define ZF_GRIDY 54
+#define ZF_HIST 6
+struct ZfCell { uint32_t hist[ZF_HIST]; uint8_t n; uint16_t osc; };
+static ZfCell zf_grid[ZF_GRIDX * ZF_GRIDY];
+static uint32_t zf_hits = 0;
+#define ZF_MIN_OSC 5 // a fight must recur this many times at the same cell
+static void zf_note_emit(uint32_t f, struct LoadedVertex* const* v_arr) {
+ if (zf_on < 0) {
+ const char* e = getenv("GE_ZF");
+ zf_on = (e && e[0]) ? 1 : 0;
+ const char* t = getenv("GE_ZFTOL");
+ if (t) zf_tol = atoi(t);
+ const char* b = getenv("GE_ZFBOX");
+ if (b && sscanf(b, "%f %f %f %f", &zfbox[0], &zfbox[1], &zfbox[2], &zfbox[3]) == 4)
+ zfbox_on = 1;
+ }
+ if (!zf_on || zf_hits >= 600) return;
+ float cx=0.f, cy=0.f, czw=0.f, wsum=0.f; int ok=0;
+ for (int i = 0; i < 3; i++) {
+ float w = v_arr[i]->w;
+ if (w <= 0.f) continue;
+ cx += (v_arr[i]->x / w) * w; cy += (v_arr[i]->y / w) * w; czw += (v_arr[i]->z / w) * w; wsum += w;
+ ok = 1;
+ }
+ if (!ok || wsum <= 0.f) return;
+ cx /= wsum; cy /= wsum; czw /= wsum;
+ if (cx < -1.0f || cx > 1.0f || cy < -1.0f || cy > 1.0f) return; // on-screen only
+ if (zfbox_on && (cx < zfbox[0] || cx > zfbox[2] || cy < zfbox[1] || cy > zfbox[3])) return;
+ uint32_t z24 = (uint32_t)(czw * 255.f); // NDC z in [-1,1] -> 24-bit [0,65535]
+ int gx = (int)((cx + 1.0f) * 0.5f * ZF_GRIDX); if (gx >= ZF_GRIDX) gx = ZF_GRIDX - 1;
+ int gy = (int)((cy + 1.0f) * 0.5f * ZF_GRIDY); if (gy >= ZF_GRIDY) gy = ZF_GRIDY - 1;
+ ZfCell* c = &zf_grid[gy * ZF_GRIDX + gx];
+ int dup = 0;
+ for (uint8_t k = 0; k < c->n; k++) if (c->hist[k] == z24) { dup = 1; break; }
+ if (!dup) {
+ // TRUE oscillation: new value close to an OLDER history entry while a
+ // different value sat between them (a b a), AND the cell's history is
+ // not a monotonic drift (tessellation gradient of one surface).
+ int anyup = 0, anydn = 0;
+ for (uint8_t k = 1; k < c->n; k++) {
+ if (c->hist[k] > c->hist[k - 1]) anyup = 1;
+ else if (c->hist[k] < c->hist[k - 1]) anydn = 1;
+ }
+ if (!(anyup && anydn)) goto zf_push; // monotonic drift = gradient, not a fight
+ for (uint8_t k = 0; k + 1 < c->n; k++) {
+ long dz = (long)z24 - (long)c->hist[k];
+ if (dz < 0) dz = -dz;
+ if (dz > 0 && dz <= (long)zf_tol) {
+ uint16_t o = ++c->osc;
+ if (o < ZF_MIN_OSC || (o % 10) != 0 && o != ZF_MIN_OSC) goto zf_push;
+ zf_hits++;
+ fprintf(stderr,
+ "ZF: f=%u z24=%u dprev=%ld osc=%u ndc=(%.3f,%.3f) oml=0x%08x comb=0x%016llx wavg=%.1f\n",
+ f, z24, dz, (unsigned)o, cx, cy, rdp.other_mode_l,
+ (unsigned long long)rdp.combine_mode, wsum / 3.0f);
+ if (zf_hits >= 600) return;
+ }
+ }
+zf_push:
+ if (c->n < ZF_HIST) c->hist[c->n++] = z24;
+ else { memmove(c->hist, c->hist + 1, sizeof(uint32_t) * (ZF_HIST - 1)); c->hist[ZF_HIST - 1] = z24; }
+ }
+}
+
+/* TEMP D303: env-gated detector for the "extra long flash straight up" quad
+ * (M-?? / docs/dev/findings.md §D303). Logs any emitted triangle whose NDC bbox
+ * is tall + thin (aspect > 3, height > 25% of frame) and mostly on-screen — the
+ * screen-space signature of the reported artifact, whatever its source. Gated
+ * on GE_D303; caps output. Remove once D303 is resolved. */
+static int d303_on = -1;
+static uint32_t d303_hits = 0;
+static void d303_note_emit(uint32_t f, struct LoadedVertex* const* v_arr) {
+ if (d303_on < 0) { const char* e = getenv("GE_D303"); d303_on = (e && e[0]) ? 1 : 0; }
+ if (!d303_on || d303_hits >= 200) return;
+ float mnx=1e9f,mxx=-1e9f,mny=1e9f,mxy=-1e9f, wsum = 0.f;
+ int ok = 0;
+ for (int i = 0; i < 3; i++) {
+ float w = v_arr[i]->w;
+ if (w <= 0.f) continue;
+ wsum += w;
+ float nx = v_arr[i]->x / w, ny = v_arr[i]->y / w;
+ if (nxmxx)mxx=nx; if(nymxy)mxy=ny;
+ ok = 1;
+ }
+ if (!ok) return;
+ float h = mxy - mny, wd = mxx - mnx;
+ if (h < 0.45f || wd <= 0.f || h / wd < 3.0f) return; // VERY tall + thin
+ if (mxy < 0.1f || mny > 0.9f) return; // must reach upper screen region
+ d303_hits++;
+ int bl = (rdp.other_mode_l >> 24) & 3u, blc = (rdp.other_mode_l >> 20) & 3u;
+ fprintf(stderr,
+ "D303: f=%u ndc=(%.3f,%.3f)-(%.3f,%.3f) h=%.3f w=%.3f asp=%.1f bl=%d blc=%d oml=0x%08x comb=0x%016llx prim=(%u,%u,%u) env=(%u,%u,%u) wavg=%.1f c0=(%d,%d,%d)\n",
+ f, mnx, mny, mxx, mxy, h, wd, (wd > 0.f ? h / wd : 99.0f), bl, blc,
+ rdp.other_mode_l, (unsigned long long)rdp.combine_mode,
+ rdp.prim_color.r, rdp.prim_color.g, rdp.prim_color.b,
+ rdp.env_color.r, rdp.env_color.g, rdp.env_color.b, wsum / 3.0f,
+ v_arr[0]->color.r, v_arr[0]->color.g, v_arr[0]->color.b);
+}
+
+static void d75d_note_tri(uint32_t f, uint32_t maxidx) {
+ d75d_init();
+ if (d75d_lo < 0 || f < (uint32_t)d75d_lo || f > (uint32_t)d75d_hi) return;
+ d75d_frame_check(f);
+ d75d_nt++;
+ if (maxidx > d75d_maxtri) d75d_maxtri = maxidx;
+ if (d75d_seqlen < 4096) d75d_seq[d75d_seqlen++] = 'T';
+}
+static void d75d_note_rej(uint32_t f, int which) {
+ d75d_init();
+ if (d75d_lo < 0 || f < (uint32_t)d75d_lo || f > (uint32_t)d75d_hi) return;
+ d75d_frame_check(f);
+ if (which == 0) d75d_rej_triv++; else d75d_rej_cull++;
+}
+static inline int gfx_lod_tile_offset(const int i); // fwd (defined below; D75D round 2)
+
+static void d75d_note_emit(uint32_t f, struct LoadedVertex* const* v_arr) {
+ d75d_init();
+ if (d75d_lo < 0 || f < (uint32_t)d75d_lo || f > (uint32_t)d75d_hi) return;
+ d75d_frame_check(f);
+ d75d_emitted++;
+ // NDC bbox of this triangle (x/w, y/w); count on-screen vs off
+ float mnx=1e9f,mxx=-1e9f,mny=1e9f,mxy=-1e9f;
+ for (int i = 0; i < 3; i++) {
+ float w = v_arr[i]->w;
+ if (w == 0.f) continue;
+ float nx = v_arr[i]->x / w, ny = v_arr[i]->y / w;
+ if (nxmxx)mxx=nx; if(nymxy)mxy=ny;
+ }
+ if (mxx < -1e8f) return; // all w==0, degenerate
+ if (mxx >= -1.05f && mnx <= 1.05f && mxy >= -1.05f && mny <= 1.05f) d75d_emit_on++; else d75d_emit_off++;
+ if (mnxd75d_ndc_maxx)d75d_ndc_maxx=mxx;
+ if (mnyd75d_ndc_maxy)d75d_ndc_maxy=mxy;
+ // invisible flag + oml + vertex color range
+ if ((rdp.other_mode_l & (3u << 24)) == ((uint32_t)G_BL_0 << 24) &&
+ (rdp.other_mode_l & (3u << 20)) == ((uint32_t)G_BL_CLR_MEM << 20)) d75d_emit_invis++;
+ if (!d75d_oml_set) { d75d_oml = rdp.other_mode_l; d75d_oml_set = 1; }
+ for (int i = 0; i < 3; i++) {
+ int cr=v_arr[i]->color.r, cg=v_arr[i]->color.g, cb=v_arr[i]->color.b;
+ if (crd75d_cmax)d75d_cmax=cr;
+ if (cgd75d_cmax)d75d_cmax=cg;
+ if (cbd75d_cmax)d75d_cmax=cb;
+ }
+}
+// called at emit with the texunit-0 bound flag
+static void d75d_note_emit_z(uint32_t f, struct LoadedVertex* const* v_arr, int has_tex, int comb_utex) {
+ d75d_init();
+ if (d75d_lo < 0 || f < (uint32_t)d75d_lo || f > (uint32_t)d75d_hi) return;
+ d75d_frame_check(f);
+ for (int i = 0; i < 3; i++) {
+ float w = v_arr[i]->w;
+ if (w == 0.f) { d75d_znan++; continue; }
+ float nz = v_arr[i]->z / w;
+ if (!std::isfinite(nz)) { d75d_znan++; continue; }
+ if (nzd75d_zmax)d75d_zmax=nz;
+ }
+ if (d75d_use_tex < 0) d75d_use_tex = has_tex;
+ if (d75d_comb_utex < 0) d75d_comb_utex = comb_utex;
+ if (rdp.combine_mode == 0x009ffe4f19ffe4f1ULL) {
+ d75d_lg_utex = comb_utex; d75d_lg_tex = has_tex; d75d_lg_cnt++;
+ if (!d75d_lg_set && v_arr[0] && v_arr[0]->w != 0.f) {
+ d75d_lg_set = 1;
+ const uint32_t t0 = rdp.first_tile_index + gfx_lod_tile_offset(0);
+ d75d_lg_tmem = rdp.texture_tile[t0].tmem; d75d_lg_fmt = rdp.texture_tile[t0].fmt;
+ d75d_lg_siz = rdp.texture_tile[t0].siz;
+ d75d_lg_shifts = rdp.texture_tile[t0].shifts; d75d_lg_shifft = rdp.texture_tile[t0].shiftt;
+ d75d_lg_uls = rdp.texture_tile[t0].uls; d75d_lg_ult = rdp.texture_tile[t0].ult;
+ d75d_lg_lrs = rdp.texture_tile[t0].lrs; d75d_lg_lrt = rdp.texture_tile[t0].lrt;
+ if (rendering_state.textures[0]) {
+ d75d_lg_texaddr = rendering_state.textures[0]->first.texture_addr;
+ d75d_lg_texid = rendering_state.textures[0]->second.texture_id;
+ d75d_lg_texbytes = rendering_state.textures[0]->first.size_bytes;
+ }
+ d75d_lg_u0 = (int)v_arr[0]->u; d75d_lg_v0 = (int)v_arr[0]->v;
+ }
+ }
+ d75d_comb = rdp.combine_mode;
+ d75d_prim[0]=rdp.prim_color.r; d75d_prim[1]=rdp.prim_color.g; d75d_prim[2]=rdp.prim_color.b; d75d_prim[3]=rdp.prim_color.a;
+ d75d_env[0]=rdp.env_color.r; d75d_env[1]=rdp.env_color.g; d75d_env[2]=rdp.env_color.b; d75d_env[3]=rdp.env_color.a;
+}
+
+/* D252 TEMP (2026-09-26): F12 with GE_D157 set opens a full-detail D157T
+ * burst (every small-texture vertex, no sampling) for ~90 frames, so a live
+ * sighting is captured even after the continuous cap has dropped to 1-in-50.
+ * Armed from videoEndFrame's screenshot path via gfxD157Burst(). */
+static volatile uint32_t s_d157BurstUntil = 0;
+
static void gfx_sp_vertex(size_t n_vertices, size_t dest_index, const Vtx* vertices) {
+ const size_t d75_di0 = dest_index; /* D75 probe: loop below mutates dest_index */
SUPPORT_CHECK(n_vertices <= MAX_VERTICES);
if (!fast3d_ptr_ok(vertices) || dest_index + n_vertices > MAX_VERTICES) {
@@ -1582,8 +1974,15 @@ static void gfx_sp_vertex(size_t n_vertices, size_t dest_index, const Vtx* verti
static const uint8_t known[][4] = {
{0xFF,0xFF,0xFF,0xFF}, {0xFF,0xFF,0xC8,0xFF}, {0xFF,0x00,0x00,0xFF},
};
- for (const auto& k : known) {
+ /* 2026-09-26: this match was uncapped and white (255,255,255,255)
+ * hits every HUD/text/effect vertex -> ~200k lines in 18 s, 4-6 fps
+ * in live play. Now: first 500 hits, then only inside an F12 burst. */
+ extern uint32_t num_dls;
+ static int d157m_n = 0;
+ const bool d157m_on = d157m_n < 500 || num_dls <= s_d157BurstUntil;
+ if (d157m_on) for (const auto& k : known) {
if (v->v.cn[0] == k[0] && v->v.cn[1] == k[1] && v->v.cn[2] == k[2] && v->v.cn[3] == k[3]) {
+ d157m_n++;
sysLogPrintf(LOG_NOTE,
"D157: bulletspark-color-match cn=(%d,%d,%d,%d) geometry_mode=%08x LIGHTING=%s -> shaded=(%d,%d,%d)",
v->v.cn[0], v->v.cn[1], v->v.cn[2], v->v.cn[3], rsp.geometry_mode,
@@ -1628,11 +2027,17 @@ static void gfx_sp_vertex(size_t n_vertices, size_t dest_index, const Vtx* verti
* so a real, minutes-long play session doesn't drop
* to sparse 1-in-200 sampling before the player
* actually triggers the bug. */
- if (d157b_n <= 20000 || (d157b_n % 50) == 0) {
+ static int d157burst_n = 0;
+ const bool burst = num_dls <= s_d157BurstUntil && d157burst_n < 60000;
+ if (burst) d157burst_n++;
+ /* 2026-09-26: continuous logging cut to a 2000-line baseline;
+ * the 20000 + 1-in-50 tail was a big share of the
+ * live-play slowdown. Full detail now comes from F12. */
+ if (burst || d157b_n <= 2000) {
sysLogPrintf(LOG_NOTE,
- "D157T: frame=%u cn=(%d,%d,%d,%d) shaded=(%d,%d,%d) LIGHTING=%s geom=%08x combine=%llx | "
+ "D157T%s: frame=%u cn=(%d,%d,%d,%d) shaded=(%d,%d,%d) LIGHTING=%s geom=%08x combine=%llx | "
"tile0=%u tmem=%u fmt=%u siz=%u addr=%p size=%u line=%u | uv=(%d,%d)",
- num_dls,
+ burst ? "B" : "", num_dls,
v->v.cn[0], v->v.cn[1], v->v.cn[2], v->v.cn[3],
(int)d->color.r, (int)d->color.g, (int)d->color.b,
(rsp.geometry_mode & G_LIGHTING) ? "ON" : "off", rsp.geometry_mode,
@@ -1694,6 +2099,47 @@ static void gfx_sp_vertex(size_t n_vertices, size_t dest_index, const Vtx* verti
d->color.a = vcn->a; // can be required for SHADE_ALPHA even if fog is enabled
}
+
+#ifdef PORT
+ /* TEMP D75 (M-19x): per-batch transformed-vertex range probe. Static
+ * analysis of the nintendologo path exhausted itself clean (all 23 op=4
+ * nodes visited every frame, GDLs byte-faithful to N64, every opcode
+ * handled, G_VTX counts sum exactly to nv, G_TRI4 decode matches the
+ * gSP4Triangles macro) -- so log what the pipeline actually computes:
+ * for frames in [lo,hi] (env GE_D75V="lo-hi"), one line per G_VTX batch
+ * with the resolved vertex ptr, raw first-vertex ob[], transformed
+ * x/y/z/w ranges and the MP translation row. The logo's batches are
+ * identifiable offline by vtx ptr == BaseAddr(0x70157a98)+file offset.
+ * Remove once D75 is root-caused. */
+ {
+ static int d75v_lo = -1, d75v_hi = 0;
+ if (d75v_lo < 0) {
+ const char* v = getenv("GE_D75V");
+ d75v_lo = 1; d75v_hi = 0x7fffffff;
+ if (!v || sscanf(v, "%d-%d", &d75v_lo, &d75v_hi) != 2)
+ d75v_lo = -1;
+ }
+ if (d75v_lo >= 0 && (int)videoGetFrameCount() >= d75v_lo &&
+ (int)videoGetFrameCount() <= d75v_hi) {
+ float mnx = 1e30f, mxx = -1e30f, mny = 1e30f, mxy = -1e30f,
+ mnz = 1e30f, mxz = -1e30f, mnw = 1e30f, mxw = -1e30f;
+ for (size_t i = 0; i < n_vertices; i++) {
+ const struct LoadedVertex* d2 = &rsp.loaded_vertices[d75_di0 + i];
+ if (d2->x < mnx) mnx = d2->x; if (d2->x > mxx) mxx = d2->x;
+ if (d2->y < mny) mny = d2->y; if (d2->y > mxy) mxy = d2->y;
+ if (d2->z < mnz) mnz = d2->z; if (d2->z > mxz) mxz = d2->z;
+ if (d2->w < mnw) mnw = d2->w; if (d2->w > mxw) mxw = d2->w;
+ }
+ const Vtx* v0 = &vertices[0];
+ sysLogPrintf(LOG_NOTE,
+ "D75V: f=%u vtx=%p n=%zu di=%zu raw0=(%d,%d,%d) x[%.0f,%.0f] y[%.0f,%.0f] z[%.0f,%.0f] w[%.0f,%.0f] MPt=(%.1f,%.1f,%.1f)",
+ videoGetFrameCount(), (const void*)vertices, n_vertices, d75_di0,
+ (int)v0->v.ob[0], (int)v0->v.ob[1], (int)v0->v.ob[2],
+ mnx, mxx, mny, mxy, mnz, mxz, mnw, mxw,
+ rsp.MP_matrix[3][0], rsp.MP_matrix[3][1], rsp.MP_matrix[3][2]);
+ }
+ }
+#endif
}
static void gfx_sp_modify_vertex(uint16_t vtx_idx, uint8_t where, uint32_t val) {
@@ -1707,7 +2153,18 @@ static void gfx_sp_modify_vertex(uint16_t vtx_idx, uint8_t where, uint32_t val)
v->v = t;
}
+/* Intro blood (M-201): set while a texture rectangle is being drawn. */
+static bool s_in_texrect = false;
+
static inline int gfx_lod_tile_offset(const int i) {
+ /* Intro blood (M-201): a texture rectangle outside 2-cycle mode samples
+ * exactly the tile its command names -- the RDP only selects LOD tiles in
+ * 2-cycle mode. The title leaves G_TL_LOD set from earlier draws, and the
+ * D236 detail-base rule below then sent the 1-cycle gun-barrel blood
+ * rectangle to a stale tile-1 CI8 declaration (static garbage instead of
+ * the drip). Scoped to texrects so 3D LOD/detail paths are untouched. */
+ if (s_in_texrect && (rdp.other_mode_h & (3U << G_MDSFT_CYCLETYPE)) != G_CYC_2CYCLE)
+ return i;
if (gfx_detail_textures_enabled)
return ((rdp.tex_lod && !rdp.tex_detail) ? 0 : i);
// D107: GE has no true detail textures (gfx_detail_textures_enabled is
@@ -1727,10 +2184,48 @@ static inline int gfx_lod_tile_offset(const int i) {
// fire @ TMEM 0x188. Returning 0 here fed TEXEL1 the smoke texture too
// (smoke * smoke) -> the magenta/cyan particle colour. Only fold to the
// base tile when LOD is actually active.
- return rdp.tex_lod ? 0 : i;
+ //
+ // D236 pass 26: "GE loads the whole mip chain at TMEM 0" holds for the
+ // TEXTURETYPE_LOD / TEXTURETYPE_MIPMAP bindings (texHandleType0 /
+ // texHandleType2 both do texWriteLoadToTmemAddr(tex, 0)), but NOT for
+ // TEXTURETYPE_DETAIL. texHandleType1 (tex.c) loads the DETAIL texture at
+ // TMEM 0 and the real base image at TMEM offset texGetSizeInBytes(tex2,0)
+ // on tiles 1+. Folding to tile 0 there samples the detail texture instead
+ // of the base image. On Surface 1 that is literally the D236 bug: all four
+ // treeline cards (tex1198-1201, RGBA5551 64x17 cut-outs) are type-1
+ // bindings whose detail texture is tex2465, an opaque 32x32 IA8 noise
+ // tile -- so the treeline draws as an opaque tiled noise wall and the
+ // cards themselves are never even imported (pass 25's "zero fmt=0 siz=2
+ // imports in 4800 frames").
+ //
+ // Discriminator: tile fi+1's declared FORMAT. Tiles 1.. of a type-0/type-2
+ // binding are LOD levels of tile 0's image, so they necessarily carry the
+ // same fmt/siz; a type-1 pair is two unrelated textures and generally does
+ // not (Surface 1's cards: tile 0 = tex2465 IA8, tile 1 = tex1198 RGBA16).
+ // Deliberately asymmetric -- a mip chain can never trip this, and a detail
+ // pair that happens to share a format just keeps today's behaviour -- so
+ // the D107 mip case is safe by construction.
+ if (rdp.tex_lod) {
+ const uint32_t fi = rdp.first_tile_index;
+ if (g_detail_base_tile && fi + 1 < 8 &&
+ rdp.texture_tile[fi + 1].tmem != rdp.texture_tile[fi].tmem &&
+ (rdp.texture_tile[fi + 1].fmt != rdp.texture_tile[fi].fmt ||
+ rdp.texture_tile[fi + 1].siz != rdp.texture_tile[fi].siz)) {
+ return 1;
+ }
+ return 0;
+ }
+ return i;
}
static void gfx_sp_tri1(uint8_t vtx1_idx, uint8_t vtx2_idx, uint8_t vtx3_idx, bool is_rect) {
+ /* D75D: only real model triangles (all indices < MAX_VERTICES); the
+ * fullscreen-quad helpers use indices MAX_VERTICES+0..3 and would skew maxtri. */
+ if (vtx1_idx < MAX_VERTICES && vtx2_idx < MAX_VERTICES && vtx3_idx < MAX_VERTICES) {
+ uint32_t d75d_mx = vtx1_idx > vtx2_idx ? (vtx1_idx > vtx3_idx ? vtx1_idx : vtx3_idx)
+ : (vtx2_idx > vtx3_idx ? vtx2_idx : vtx3_idx);
+ d75d_note_tri(videoGetFrameCount(), d75d_mx);
+ }
struct LoadedVertex* v1 = &rsp.loaded_vertices[vtx1_idx];
struct LoadedVertex* v2 = &rsp.loaded_vertices[vtx2_idx];
struct LoadedVertex* v3 = &rsp.loaded_vertices[vtx3_idx];
@@ -1758,26 +2253,37 @@ static void gfx_sp_tri1(uint8_t vtx1_idx, uint8_t vtx2_idx, uint8_t vtx3_idx, bo
bool any_behind_camera = (v1->w < 0) || (v2->w < 0) || (v3->w < 0);
if (!any_behind_camera && (v1->clip_rej & v2->clip_rej & v3->clip_rej)) {
// The whole triangle lies outside the visible area
+ d75d_note_rej(videoGetFrameCount(), 0);
return;
}
- /* D288 diag (M-152, frame-gated M-155): the silo intro's
- * screen-filling stray triangle (bounded to level_20 frames
- * ~562-741) isn't explained by this function's existing D233/D106
- * guards on static review -- log any triangle whose clip-space NDC
- * bbox covers an unreasonable chunk of the screen, or whose bbox
- * math goes non-finite, together with each vertex's w and
- * any_behind_camera, to catch it at draw time. M-155: the first
- * ungated run fired 116k times in ~1500-2000 frames -- bbox_frac
- * >0.15 alone is a common occurrence for ordinary near-camera
- * geometry, not a distinguishing signature. Gated to the reported
- * frame window (num_dls, same pattern as GE_D236RM below) and the
+ /* D288 diag (M-152, frame-gated M-155, any_behind-restricted
+ * M-155-follow-up): the silo intro's screen-filling stray triangle
+ * (bounded to level_20 frames ~562-741) isn't explained by this
+ * function's existing D233/D106 guards on static review -- log any
+ * triangle whose clip-space NDC bbox covers an unreasonable chunk
+ * of the screen, together with each vertex's w and the render-mode
+ * word (oml), to catch it at draw time. M-155: the first ungated
+ * run fired 116k times in ~1500-2000 frames -- bbox_frac >0.15
+ * alone is a common occurrence for ordinary near-camera geometry,
+ * not a distinguishing signature. Gated to the reported frame
+ * window (num_dls, the pattern the since-removed GE_D236RM
+ * probe also used) and the
* threshold raised well above the ~0.15 "normal" baseline M-155
- * established, so a hit here is actually rare. Remove once D288 is
- * root-caused. */
+ * established, so a hit here is actually rare. M-155 follow-up:
+ * for any_behind_camera==1 triangles the x/w perspective divide is
+ * meaningless (w near zero or negative -> arbitrarily large
+ * "bbox_frac" for perfectly ordinary geometry), which is exactly
+ * why the code above defers those to GL's own clipper -- so the
+ * probe now skips them entirely; a hit here has comfortably
+ * positive w's and is a genuine large-NDC-bbox triangle. oml is
+ * logged so the culprit's material/model can be identified against
+ * the level's asset tables (the flat RGB(83,62,44) color grep
+ * found nothing in the .c sources -- binary model blobs). Remove
+ * once D288 is root-caused. */
static int ge_d288 = -1;
if (ge_d288 < 0) ge_d288 = getenv("GE_D288") != NULL;
- if (ge_d288) {
+ if (ge_d288 && !any_behind_camera) {
extern uint32_t num_dls;
if (num_dls >= 550 && num_dls <= 755) {
float minx = std::fmin(std::fmin(v1->x / v1->w, v2->x / v2->w), v3->x / v3->w);
@@ -1786,134 +2292,15 @@ static void gfx_sp_tri1(uint8_t vtx1_idx, uint8_t vtx2_idx, uint8_t vtx3_idx, bo
float maxy = std::fmax(std::fmax(v1->y / v1->w, v2->y / v2->w), v3->y / v3->w);
float bbox_frac = (maxx - minx) * (maxy - miny) / 4.0f; // NDC quad is [-1,1]^2
if (!std::isfinite(bbox_frac) || bbox_frac > 0.5f) {
- fprintf(stderr, "D288: dl=%u tri w=(%.6f,%.6f,%.6f) bbox_frac=%.3f any_behind=%d color=(%d,%d,%d)\n",
- num_dls, v1->w, v2->w, v3->w, bbox_frac, (int)any_behind_camera,
+ fprintf(stderr, "D288: dl=%u tri w=(%.6f,%.6f,%.6f) bbox_frac=%.3f oml=0x%08x color=(%d,%d,%d)\n",
+ num_dls, v1->w, v2->w, v3->w, bbox_frac,
+ (unsigned)rdp.other_mode_l,
v1->color.r, v1->color.g, v1->color.b);
}
}
}
}
- /* D236 pass 10 (M-155, TEMP): does the 0x0c184b50 render-mode class's
- * per-frame triangle count actually vary frame-to-frame WITHIN one
- * deterministic -level_36 boot, or is D280's "run-to-run dependent"
- * observation only visible ACROSS separate process launches (which
- * would point at room-streaming state that isn't reset/seeded the
- * same way twice, not at true per-frame nondeterminism)? Log the
- * count once per display list (frame) while it's nonzero. Remove
- * once D236 pass 10 concludes. */
- static int s_d236rm = -1;
- if (s_d236rm < 0) s_d236rm = getenv("GE_D236RM") != NULL;
- if (s_d236rm) {
- static uint32_t d236rm_last_dl = 0xFFFFFFFFu;
- static uint32_t d236rm_count = 0;
- extern uint32_t num_dls;
- if (num_dls != d236rm_last_dl) {
- if (d236rm_count != 0 || (num_dls % 300) == 0) {
- fprintf(stderr, "D236RM dl=%u count=%u\n", d236rm_last_dl, d236rm_count);
- }
- d236rm_last_dl = num_dls;
- d236rm_count = 0;
- }
- if (rdp.other_mode_l == 0x0c184b50u) {
- d236rm_count++;
- }
- }
-
- /* D236 pass 12 (M-191, TEMP): new angle, not tried by passes 1-11 (all of
- * which focused on the tree-card class's z/w and instantiation counts).
- * Both the noise "wall" (oml=0xc81049d8) and the discrete tree-card class
- * (oml=0x0c184b50) decode to Z_CMP=1/Z_UPD=0/ZMODE=DEC (checked offline
- * against both full oml words) -- NEITHER writes the depth buffer. Two
- * no-z-write decals drawn against the same static (z-writing) background
- * never actually depth-test against EACH OTHER: each is tested only
- * against the background's z, so on-screen precedence between the two
- * decals is decided purely by which one is submitted LAST in the frame's
- * draw list, not by which one's own z/w is smaller (the z/w comparisons
- * all 11 prior passes made are therefore not conclusive either way about
- * visual winner). This probe tests that directly: track a per-frame
- * submission-order counter (reset whenever num_dls changes, i.e. once
- * per frame) and log the min/max order index seen for each of the two
- * classes whenever num_dls changes and at least one of them appeared
- * that frame -- if the noise class's order index is consistently HIGHER
- * (drawn later) than the tree class's in frames where both appear, that
- * directly explains "wall painted over trees" regardless of geometric
- * depth, and points at a draw-order (not depth) bug. Remove once D236
- * pass 12 concludes. */
- static int s_d236order = -1;
- if (s_d236order < 0) s_d236order = getenv("GE_D236ORDER") != NULL;
- if (s_d236order) {
- static uint32_t d236o_last_dl = 0xFFFFFFFFu;
- static uint32_t d236o_seq = 0;
- static uint32_t d236o_noise_min = 0, d236o_noise_max = 0, d236o_noise_n = 0;
- static uint32_t d236o_tree_min = 0, d236o_tree_max = 0, d236o_tree_n = 0;
- extern uint32_t num_dls;
- if (num_dls != d236o_last_dl) {
- if (d236o_noise_n != 0 || d236o_tree_n != 0) {
- fprintf(stderr,
- "D236ORDER dl=%u noise_n=%u noise_order=[%u,%u] tree_n=%u tree_order=[%u,%u] "
- "tree_last_after_noise_last=%d\n",
- d236o_last_dl, d236o_noise_n, d236o_noise_min, d236o_noise_max,
- d236o_tree_n, d236o_tree_min, d236o_tree_max,
- (d236o_tree_n != 0 && d236o_noise_n != 0) ? (int) (d236o_tree_max > d236o_noise_max) : -1);
- }
- d236o_last_dl = num_dls;
- d236o_seq = 0;
- d236o_noise_min = d236o_noise_max = d236o_noise_n = 0;
- d236o_tree_min = d236o_tree_max = d236o_tree_n = 0;
- }
- if (rdp.other_mode_l == 0xc81049d8u) {
- if (d236o_noise_n == 0) d236o_noise_min = d236o_seq;
- d236o_noise_max = d236o_seq;
- d236o_noise_n++;
- } else if (rdp.other_mode_l == 0x0c184b50u) {
- if (d236o_tree_n == 0) d236o_tree_min = d236o_seq;
- d236o_tree_max = d236o_seq;
- d236o_tree_n++;
- }
- d236o_seq++;
- }
-
- /* D236 pass 14 (M-19x, TEMP): pass 13's live capture ruled out draw
- * order as the dominant cause even in the favorable (tree-after-noise,
- * high tri count) zone -- redirecting to whether the tree class's own
- * per-vertex alpha (D280's "gfog=0, CPU-baked bimodal 25/255" census)
- * is actually LOW (near-transparent) for the close/high-count trees the
- * live capture walked through. This render mode's blend equation is a
- * standard alpha-blend decal (FORCE_BL, GBL c1/c2 = CLR_IN,A_IN ->
- * CLR_MEM,1-A_IN -- see include/PR/gbi.h RM_AA_ZB_XLU_DECAL), so if the
- * combiner's alpha output tracks vertex/SHADE alpha directly, a card
- * baked near 25/255 (~10%) would blend almost invisibly over whatever
- * was drawn under it -- looking exactly like "the noise wall shows
- * through" even with correct geometry and correct draw order. Logs,
- * once per triangle of this class: each vertex's raw color.a, the
- * combine_mode word (to see which alpha slot actually feeds the
- * blend), and 1/w (a cheap camera-distance proxy) so alpha can be
- * correlated against "close" vs "far" the same way pass 13's live
- * capture was read. Zero cost unset. Remove once D236 pass 14
- * concludes. */
- static int s_d236alpha = -1;
- if (s_d236alpha < 0) s_d236alpha = getenv("GE_D236ALPHA") != NULL;
- if (s_d236alpha && rdp.other_mode_l == 0x0c184b50u) {
- static uint32_t d236a_hits = 0;
- static uint64_t d236a_last_combine = 0xFFFFFFFFFFFFFFFFull;
- if (d236a_hits < 4000) {
- if (rdp.combine_mode != d236a_last_combine) {
- fprintf(stderr, "D236ALPHA combine_mode=0x%016llx\n",
- (unsigned long long)rdp.combine_mode);
- d236a_last_combine = rdp.combine_mode;
- }
- fprintf(stderr,
- "D236ALPHA a=(%u,%u,%u) invw=(%.4f,%.4f,%.4f) vtxseg=0x%02x\n",
- v1->color.a, v2->color.a, v3->color.a,
- (v1->w != 0.f) ? 1.f / v1->w : 0.f,
- (v2->w != 0.f) ? 1.f / v2->w : 0.f,
- (v3->w != 0.f) ? 1.f / v3->w : 0.f,
- g_d236_last_vtx_seg);
- d236a_hits++;
- }
- }
-
if ((rsp.geometry_mode & G_CULL_BOTH) != 0) {
float dx1 = v1->x / (v1->w) - v2->x / (v2->w);
float dy1 = v1->y / (v1->w) - v2->y / (v2->w);
@@ -1935,16 +2322,19 @@ static void gfx_sp_tri1(uint8_t vtx1_idx, uint8_t vtx2_idx, uint8_t vtx3_idx, bo
switch (rsp.geometry_mode & G_CULL_BOTH) {
case G_CULL_FRONT:
if (cross <= 0) {
+ d75d_note_rej(videoGetFrameCount(), 1);
return;
}
break;
case G_CULL_BACK:
if (cross >= 0) {
+ d75d_note_rej(videoGetFrameCount(), 1);
return;
}
break;
case G_CULL_BOTH:
// Why is this even an option?
+ d75d_note_rej(videoGetFrameCount(), 1);
return;
}
}
@@ -1952,25 +2342,6 @@ static void gfx_sp_tri1(uint8_t vtx1_idx, uint8_t vtx2_idx, uint8_t vtx3_idx, bo
bool depth_test = ((rsp.geometry_mode & G_ZBUFFER) == G_ZBUFFER || (rdp.other_mode_l & G_ZS_PRIM) == G_ZS_PRIM) &&
((rdp.other_mode_h & G_CYC_1CYCLE) == G_CYC_1CYCLE || (rdp.other_mode_h & G_CYC_2CYCLE) == G_CYC_2CYCLE);
bool depth_update = (rdp.other_mode_l & Z_UPD) == Z_UPD;
- /* D236 pass 12 (M-191, TEMP, diagnostic-only): both the noise "wall"
- * (oml=0xc81049d8) and the discrete tree-card class (oml=0x0c184b50) are
- * Z_CMP=1/Z_UPD=0 decals, and GE_D236ORDER above shows the noise class is
- * ALWAYS submitted after the tree class in every sampled frame -- with
- * neither writing depth, later-submitted always wins the color buffer
- * regardless of which is geometrically closer. This experiment forces
- * depth_update=true for the tree-card class ONLY, so it writes real
- * depth; if the noise class (submitted later, same depth_compare=LEQUAL)
- * now correctly fails its depth test against the tree cards' nearer z
- * instead of overwriting them, that confirms draw-order+no-z-write is
- * the actual visual-precedence mechanism. NOT a proposed fix (forcing
- * z-write for a decal-mode class is not decomp-faithful either) -- purely
- * to test the mechanism cheaply before deciding what a real fix looks
- * like. Remove once D236 pass 12 concludes. */
- static int s_d236zfix = -1;
- if (s_d236zfix < 0) s_d236zfix = getenv("GE_D236ZFIX") != NULL;
- if (s_d236zfix && rdp.other_mode_l == 0x0c184b50u) {
- depth_update = true;
- }
bool depth_compare = (rdp.other_mode_l & Z_CMP) == Z_CMP;
bool depth_source_prim = (rdp.other_mode_l & G_ZS_PRIM) == G_ZS_PRIM /* && gDP.primDepth.z == 1.0f */;
uint16_t zmode = rdp.other_mode_l & ZMODE_DEC;
@@ -2106,6 +2477,22 @@ static void gfx_sp_tri1(uint8_t vtx1_idx, uint8_t vtx2_idx, uint8_t vtx3_idx, bo
break;
}
tex_width[i] = line_size;
+ /* D245 (M-201): D229 water (RGBA16 tile over a CI8 load) is
+ * imported as CI8 -- one byte per texel, base level only -- so the
+ * UV normalisation must use that geometry: width = line bytes
+ * (32, not 32/2 = 16, which doubled the S frequency) and height =
+ * the mask period (32, not 1400/32 = 43 rows of mip chain). */
+ {
+ static int d245_oldtex2 = -1;
+ if (d245_oldtex2 < 0) d245_oldtex2 = getenv("GE_D245_OLDTEX") != NULL;
+ const auto& ltx = rdp.loaded_texture[rdp.texture_tile[tile].tmem];
+ if (!d245_oldtex2 && rdp.texture_tile[tile].fmt == G_IM_FMT_RGBA &&
+ rdp.texture_tile[tile].siz == G_IM_SIZ_16b && ltx.src_fmt == G_IM_FMT_CI) {
+ const uint8_t mkt = rdp.texture_tile[tile].maskt;
+ tex_width[i] = rdp.texture_tile[tile].line_size_bytes;
+ if (mkt > 0 && mkt < 12 && tex_height[i] > (1u << mkt)) tex_height[i] = 1u << mkt;
+ }
+ }
tex_width2[i] = (rdp.texture_tile[tile].lrs - rdp.texture_tile[tile].uls + 4) / 4;
tex_height2[i] = (rdp.texture_tile[tile].lrt - rdp.texture_tile[tile].ult + 4) / 4;
@@ -2325,6 +2712,44 @@ static void gfx_sp_tri1(uint8_t vtx1_idx, uint8_t vtx2_idx, uint8_t vtx3_idx, bo
struct GfxClipParameters clip_parameters = gfx_rapi->get_clip_parameters();
+ d75d_note_emit(videoGetFrameCount(), v_arr); // survived all rejection gates -> reaches GL
+ d303_note_emit(videoGetFrameCount(), v_arr); // TEMP D303
+ zf_note_emit(videoGetFrameCount(), v_arr); // TEMP D306/D308
+ /* TEMP D306 (M-201): depth-state census of emitted triangles in a frame
+ * window, GE_D306C="f0 f1". One line per tri: depth flags as sent to GL,
+ * geometry_mode, other_mode_l, tile-0 texture, NDC centroid, z/w range and
+ * min w (<=0 = crosses the eye plane). Cap 60000. */
+ {
+ static int d306c = -1; static uint32_t d306f0 = 0, d306f1 = 0, d306n = 0; static float d306wmax = 1e30f;
+ if (d306c < 0) {
+ const char* e = getenv("GE_D306C");
+ d306c = (e && sscanf(e, "%u %u %f", &d306f0, &d306f1, &d306wmax) >= 2) ? 1 : 0;
+ }
+ const uint32_t fc = videoGetFrameCount();
+ if (d306c && fc >= d306f0 && fc <= d306f1 && d306n < 60000) {
+ float cx = 0, cy = 0, zmin = 1e9f, zmax = -1e9f, wmin = 1e9f;
+ for (int i = 0; i < 3; i++) {
+ const float w = v_arr[i]->w;
+ if (w < wmin) wmin = w;
+ if (w > 0.f) {
+ const float zn = v_arr[i]->z / w;
+ cx += v_arr[i]->x / w; cy += v_arr[i]->y / w;
+ if (zn < zmin) zmin = zn;
+ if (zn > zmax) zmax = zn;
+ }
+ }
+ cx /= 3.f; cy /= 3.f;
+ if (wmin > d306wmax) goto d306c_skip; /* optional 3rd arg: near geometry only */
+ {
+ const void* t0 = rendering_state.textures[0] ? (const void*)rendering_state.textures[0]->first.texture_addr : nullptr;
+ d306n++;
+ fprintf(stderr, "D306C f=%u dm=%02x geom=%08x oml=%08x tex0=%p c=(%.3f,%.3f) z=[%.5f,%.5f] wmin=%.2f\n",
+ fc, rendering_state.depth_mode, rsp.geometry_mode, rdp.other_mode_l, t0, cx, cy, zmin, zmax, wmin);
+ }
+ }
+ d306c_skip:;
+ }
+ d75d_note_emit_z(videoGetFrameCount(), v_arr, (used_textures[0] || used_textures[1]) ? 1 : 0, comb->used_textures[0] ? 1 : 0);
for (int i = 0; i < 3; i++) {
float z = v_arr[i]->z, w = v_arr[i]->w;
if (clip_parameters.z_is_from_0_to_1) {
@@ -3028,6 +3453,28 @@ static void gfx_draw_rectangle(int32_t ulx, int32_t uly, int32_t lrx, int32_t lr
uly += rdp.subpixel_ofs_y;
lry += rdp.subpixel_ofs_y;
+ // D397: the N64 RDP floors the U10.2 top/bottom edges of a texture rect to
+ // whole scanlines before its per-scanline coverage test, so a rect covers
+ // scanlines [floor(uly/4), floor((lry-1)/4]]. A rect whose height is not a
+ // whole number of native pixels therefore rasterizes to a whole number of
+ // scanlines on hardware at every window scale. The gun-barrel / file-select
+ // gradient is built from 0.75px-tall row strips (title2.c: uly=(i+12)<<2,
+ // lry=((i+13)<<2)-1 -> 0.75px tall at 1px pitch); on the RDP each strip is
+ // exactly one scanline. The continuous-NDC conversion below instead passes
+ // the fractional bottom edge straight to the GPU, whose coverage test
+ // aliases the 0.75px pitch into a visible "comb" (H2: period fixed in
+ // native rows, ~3 = the 0.75px-at-1px-pitch beat, window period = 3*scale).
+ // Snap the top/bottom edges to the RDP scanline range so a sub-pixel strip
+ // rasterizes to exactly the scanline it targets. No-op for whole-pixel
+ // rects (edges already multiples of 4) and for the copy-mode +1<<2 edge
+ // (already a whole scanline), so normal fills are unchanged.
+ if (lry > uly) {
+ const int32_t top_scan = uly >> 2;
+ const int32_t bot_scan = (lry - 1) >> 2;
+ uly = top_scan << 2;
+ lry = (bot_scan + 1) << 2;
+ }
+
// U10.2 coordinates
float ulxf = ulx;
float ulyf = uly;
@@ -3090,8 +3537,36 @@ static void gfx_draw_rectangle(int32_t ulx, int32_t uly, int32_t lrx, int32_t lr
}
}
+/* D226: HUD span scale (G_HUDSCALE_EXT, emitted by port/include/hudaspect.h
+ * PORT_HUD_SCALE around specific HUD draws). While active, every rectangle
+ * (text glyphs, message boxes, ammo icons are all rects) is scaled about the
+ * anchor in logical 10.2 screen space, and texrect steps are divided by the
+ * scale so the same texels cover the larger/smaller rect. 1.0 = inactive. */
+static float s_hud_scale = 1.0f;
+static int32_t s_hud_ax = 0, s_hud_ay = 0; /* anchor, 10.2 fixed (px*4) */
+
+static inline void gfx_hud_scale_rect(int32_t& ulx, int32_t& uly, int32_t& lrx, int32_t& lry) {
+ if (s_hud_scale == 1.0f) {
+ return;
+ }
+ ulx = s_hud_ax + (int32_t)lroundf((float)(ulx - s_hud_ax) * s_hud_scale);
+ lrx = s_hud_ax + (int32_t)lroundf((float)(lrx - s_hud_ax) * s_hud_scale);
+ uly = s_hud_ay + (int32_t)lroundf((float)(uly - s_hud_ay) * s_hud_scale);
+ lry = s_hud_ay + (int32_t)lroundf((float)(lry - s_hud_ay) * s_hud_scale);
+ /* A full-width box (e.g. the dialogue backdrop, 0..W) scaled about a
+ * centre anchor goes negative; negative rect coords are not drawn, so
+ * clip at the canvas origin (the right/bottom overhang is harmless). */
+ if (ulx < 0) ulx = 0;
+ if (uly < 0) uly = 0;
+}
+
static void gfx_dp_texture_rectangle(int32_t ulx, int32_t uly, int32_t lrx, int32_t lry, uint8_t tile, int16_t uls,
int16_t ult, int16_t dsdx, int16_t dtdy, bool flip) {
+ if (s_hud_scale != 1.0f) { /* D226 */
+ gfx_hud_scale_rect(ulx, uly, lrx, lry);
+ dsdx = (int16_t)lroundf((float)dsdx / s_hud_scale);
+ dtdy = (int16_t)lroundf((float)dtdy / s_hud_scale);
+ }
uint64_t saved_combine_mode = rdp.combine_mode;
if ((rdp.other_mode_h & (3U << G_MDSFT_CYCLETYPE)) == G_CYC_COPY) {
// Per RDP Command Summary Set Tile's shift s and this dsdx should be set to 4 texels
@@ -3158,7 +3633,9 @@ static void gfx_dp_texture_rectangle(int32_t ulx, int32_t uly, int32_t lrx, int3
}
rdp.first_tile_index = tile;
+ s_in_texrect = true;
gfx_draw_rectangle(ulx, uly, lrx, lry);
+ s_in_texrect = false;
if (saved_tile != tile) {
rdp.textures_changed[0] = true;
rdp.textures_changed[1] = true;
@@ -3204,7 +3681,9 @@ static void gfx_dp_image_rectangle(int32_t tile, int32_t w, int32_t h,
}
rdp.first_tile_index = tile;
+ s_in_texrect = true;
gfx_draw_rectangle(ulx, uly, lrx, lry);
+ s_in_texrect = false;
if (saved_tile != tile) {
rdp.textures_changed[0] = true;
rdp.textures_changed[1] = true;
@@ -3215,6 +3694,7 @@ static void gfx_dp_image_rectangle(int32_t tile, int32_t w, int32_t h,
}
static void gfx_dp_fill_rectangle(int32_t ulx, int32_t uly, int32_t lrx, int32_t lry) {
+ gfx_hud_scale_rect(ulx, uly, lrx, lry); /* D226 */
if (rdp.color_image_address == rdp.z_buf_address) {
// Don't clear Z buffer here since we already did it with glClear
return;
@@ -3286,6 +3766,38 @@ static void gfx_dp_set_other_mode(uint32_t h, uint32_t l) {
rdp.other_mode_l = l;
}
+/* D245 (M-201): G_FLOATVTX_EXT loader -- gfx_sp_vertex for pre-transformed
+ * clip-space float vertices with float S/T (see port/include/floatvtx.h).
+ * Only the unlit, unfogged path the sky/water fans use is supported. */
+static void gfx_sp_vertex_float(size_t n_vertices, size_t dest_index, const PortFloatVtx* vertices) {
+ if (!fast3d_ptr_ok(vertices) || dest_index + n_vertices > MAX_VERTICES) {
+ return;
+ }
+ for (size_t i = 0; i < n_vertices; i++, dest_index++) {
+ const PortFloatVtx* v = &vertices[i];
+ struct LoadedVertex* d = &rsp.loaded_vertices[dest_index];
+ float x = gfx_adjust_x_for_aspect_ratio(v->x, v->w);
+ float y = v->y, z = v->z, w = v->w;
+ d->u = v->s * (float)rsp.texture_scaling_factor.s / 65536.0f;
+ d->v = v->t * (float)rsp.texture_scaling_factor.t / 65536.0f;
+ d->color.r = v->r;
+ d->color.g = v->g;
+ d->color.b = v->b;
+ d->color.a = v->a;
+ d->clip_rej = 0;
+ if (x < -w) d->clip_rej |= 1;
+ if (x > w) d->clip_rej |= 2;
+ if (y < -w) d->clip_rej |= 4;
+ if (y > w) d->clip_rej |= 8;
+ if (z > w) d->clip_rej |= 32;
+ d->x = x;
+ d->y = y;
+ d->z = z;
+ d->w = w;
+ d->fog = rdp.fog_color.a;
+ }
+}
+
static inline void *seg_addr(uintptr_t w1) {
// GE model files reference GDLs (gSPDisplayList) and vertex arrays by raw
// VMA 0x05xxxxxx WITHOUT the LSB set; segment 5 is set per-render to the
@@ -3379,19 +3891,8 @@ static void gfx_run_dl(Gfx* cmd) {
gfx_sp_texture(C1(16, 16), C1(0, 16), C0(11, 3), C0(8, 3), C0(0, 8));
break;
case G_VTX:
- /* D236 pass 16 (TEMP): pass 15's GE_D236RAW dump proved every
- * room-background Vtx (g_BgRoomInfo[].vertices) is alpha=255
- * on load -- so the tree class's <=36/255 alpha measured at
- * draw time (GE_D236ALPHA) can't be coming from that static
- * table. Record which segment (top byte of the raw segmented
- * address) the most recent G_VTX load came from, so the next
- * triangle-time probe can report it -- distinguishes "still
- * room background, something else touches it after load" from
- * "not room background at all" (a different segment, e.g. a
- * model/CPU-built-quad source) without guessing from source
- * review alone. Remove once D236 pass 16 concludes. */
- g_d236_last_vtx_seg = (uint8_t)(cmd->words.w1 >> 24);
gfx_sp_vertex(C0(0, 16) / sizeof(Vtx), C0(16, 4), (const Vtx*)seg_addr(cmd->words.w1));
+ d75d_note_vtx(videoGetFrameCount(), C0(0, 16) / sizeof(Vtx), C0(16, 4));
break;
case G_DL: {
if (C0(16, 1) == 0) {
@@ -3415,6 +3916,17 @@ static void gfx_run_dl(Gfx* cmd) {
case (uint8_t)G_CLEARGEOMETRYMODE:
gfx_sp_geometry_mode(cmd->words.w1, 0);
break;
+ case G_FLOATVTX_EXT: /* D245 */
+ gfx_sp_vertex_float(C0(0, 16), C0(16, 8), (const PortFloatVtx*)seg_addr(cmd->words.w1));
+ break;
+ case 0x46: /* G_HUDSCALE_EXT (D226): w0 low16 = scale*256 (0/256 = off), w1 = ax4<<16 | ay4 */
+ {
+ const uint32_t sc = C0(0, 16);
+ s_hud_scale = (sc == 0 || sc == 256) ? 1.0f : (float)sc / 256.0f;
+ s_hud_ax = (int32_t)C1(16, 16);
+ s_hud_ay = (int32_t)C1(0, 16);
+ break;
+ }
case G_EXTRAGEOMETRYMODE_EXT:
gfx_sp_extra_geometry_mode(~C0(0, 24), cmd->words.w1);
break;
@@ -3788,13 +4300,38 @@ extern "C" void gfx_start_frame(void) {
uint32_t num_dls = 0;
+/* D252 TEMP: see s_d157BurstUntil. No-op unless GE_D157 is set. */
+extern "C" void gfxD157Burst(void) {
+ static int on = -1;
+ if (on < 0) on = getenv("GE_D157") != NULL;
+ if (!on) return;
+ s_d157BurstUntil = num_dls + 90;
+ sysLogPrintf(LOG_NOTE, "D157 burst: F12 at frame=%u, full logging to frame=%u",
+ num_dls, (uint32_t)s_d157BurstUntil);
+}
+
/* F10 port-layer options overlay (port/src/optionsoverlay.c). Returns a
* self-contained 2D display list to draw on top of the game's frame, or NULL
* when the overlay is closed -- in which case nothing is appended and the
* frame is byte-identical to before (golden dumps unaffected). */
extern "C" Gfx* optionsOverlayEmit(void);
+static uint64_t gfx_perf_now_ns(void) {
+ return (uint64_t)std::chrono::duration_cast(
+ std::chrono::steady_clock::now().time_since_epoch()).count();
+}
+static int gfx_perfstat_on(void) {
+ static int on = -1;
+ if (on < 0) on = getenv("GE_PERFSTAT") != NULL;
+ return on;
+}
+static double s_perf_dl = 0, s_perf_run = 0, s_perf_present = 0, s_perf_interval = 0;
+static double s_perf_pre = 0, s_perf_post = 0, s_perf_swap = 0;
+static uint64_t s_perf_frames = 0, s_perf_last_start = 0;
+
extern "C" void gfx_run(Gfx* commands) {
+ s_hud_scale = 1.0f; /* D226: never carry a HUD scale across frames */
+ const uint64_t perf_t0 = gfx_perfstat_on() ? gfx_perf_now_ns() : 0;
++num_dls;
gfx_sp_reset();
@@ -3816,6 +4353,7 @@ extern "C" void gfx_run(Gfx* commands) {
rdp.viewport_or_scissor_changed = true;
rendering_state.viewport = {};
rendering_state.scissor = {};
+ const uint64_t perf_tpre = perf_t0 ? gfx_perf_now_ns() : 0;
gfx_run_dl(commands);
{
Gfx* overlay = optionsOverlayEmit();
@@ -3824,6 +4362,7 @@ extern "C" void gfx_run(Gfx* commands) {
}
}
gfx_flush();
+ const uint64_t perf_t1 = perf_t0 ? gfx_perf_now_ns() : 0;
gfxFramebuffer = 0;
if (game_renders_to_framebuffer) {
@@ -3846,14 +4385,42 @@ extern "C" void gfx_run(Gfx* commands) {
}
gfx_rapi->end_frame();
+ const uint64_t perf_tpost = perf_t0 ? gfx_perf_now_ns() : 0;
gfx_wapi->swap_buffers_begin();
+ if (perf_t0) {
+ s_perf_pre += (double)(perf_tpre - perf_t0) / 1.0e6;
+ s_perf_post += (double)(perf_tpost - perf_t1) / 1.0e6;
+ s_perf_swap += (double)(gfx_perf_now_ns() - perf_tpost) / 1.0e6;
+ const double f = 1.0e6;
+ const uint64_t t2 = gfx_perf_now_ns();
+ s_perf_dl += (double)(perf_t1 - perf_tpre) / f;
+ s_perf_run += (double)(t2 - perf_t0) / f;
+ if (s_perf_last_start) s_perf_interval += (double)(perf_t0 - s_perf_last_start) / f;
+ s_perf_last_start = perf_t0;
+ }
}
extern "C" void gfx_end_frame(void) {
+ const uint64_t perf_t0 = gfx_perfstat_on() ? gfx_perf_now_ns() : 0;
if (!dropped_frame) {
gfx_rapi->finish_render();
gfx_wapi->swap_buffers_end();
}
+ if (perf_t0) {
+ const double f = 1.0e6;
+ s_perf_present += (double)(gfx_perf_now_ns() - perf_t0) / f;
+ if (++s_perf_frames % 300 == 0) {
+ const double n = 300.0;
+ fprintf(stderr, "PERFSTAT pre=%.2f post=%.2f swap/pace=%.2f | ", s_perf_pre / n, s_perf_post / n, s_perf_swap / n);
+ s_perf_pre = s_perf_post = s_perf_swap = 0;
+ fprintf(stderr, "frames=%llu dl=%.2fms run=%.2fms present=%.2fms interval=%.2fms (%.1f fps) tris=%.0f batches=%.0f\n",
+ (unsigned long long)s_perf_frames, s_perf_dl / n, s_perf_run / n, s_perf_present / n,
+ s_perf_interval / n, s_perf_interval > 0 ? 1000.0 * n / s_perf_interval : 0.0,
+ (double)s_perf_tris / n, (double)s_perf_batches / n);
+ s_perf_dl = s_perf_run = s_perf_present = s_perf_interval = 0;
+ s_perf_tris = s_perf_batches = 0;
+ }
+ }
}
extern "C" void gfx_set_target_fps(int fps) {
@@ -3883,6 +4450,8 @@ extern "C" void gfx_set_mipmap_filter(enum MipmapFilteringMode mode) {
extern "C" void gfx_set_fix_mip_textures(int on) { g_fix_mip_textures = !!on; }
+extern "C" void gfx_set_detail_base_tile(int on) { g_detail_base_tile = !!on; }
+
/* D212: expose the (already-implemented) rendering-API anisotropy hook to the
* port layer. Clamp to [1, GL max] so a stale ini value can't feed an invalid
* GL_TEXTURE_MAX_ANISOTROPY. 1 = isotropic (driver default). */
diff --git a/port/fast3d/gfx_sdl.h b/port/fast3d/gfx_sdl.h
index 3611dcfa..3590f42e 100644
--- a/port/fast3d/gfx_sdl.h
+++ b/port/fast3d/gfx_sdl.h
@@ -16,6 +16,8 @@ void gfx_sdl_make_context_current(void);
/* Release the GL context from the calling (host main) thread after init, so
* the game's scheduler thread can bind it per frame (see gfx_sdl2.cpp). */
void gfx_sdl_release_context(void);
+/* D344: finish queued GL work and unbind the context (render thread, quit). */
+void gfx_sdl_park_for_exit(void);
/* Refresh the cached drawable size after a window resize (called by the
* host thread's event pump). */
diff --git a/port/fast3d/gfx_sdl2.cpp b/port/fast3d/gfx_sdl2.cpp
index a43552a7..e32611e5 100644
--- a/port/fast3d/gfx_sdl2.cpp
+++ b/port/fast3d/gfx_sdl2.cpp
@@ -9,6 +9,8 @@
#include "gfx_screen_config.h"
#include "input.h"
+extern "C" void videoRequestQuit(const char *why); // port/src/video.c (D344)
+
static SDL_Window* wnd;
static SDL_GLContext ctx;
static SDL_Renderer* renderer;
@@ -319,7 +321,9 @@ static void gfx_sdl_handle_events(void) {
} else if (event.key.keysym.sym == SDLK_F4 && (event.key.keysym.mod & KMOD_ALT)) {
// D145: Alt+F4 quits; bare ESC no longer does (it is the
// menu "back" key -- see port/src/video.c / input.c).
- exit(0);
+ // D344: request, don't exit(): this is the render thread,
+ // mid-frame. It parks at the frame's end; the host exits.
+ videoRequestQuit("Alt+F4 (render pump)");
} else if (event.key.keysym.sym == SDLK_ESCAPE && !event.key.repeat) {
// WI-1: this render-thread pump and port/src/video.c's
// host-thread pump both drain the same SDL queue, so either
@@ -345,7 +349,7 @@ static void gfx_sdl_handle_events(void) {
event.window.windowID == SDL_GetWindowID(wnd)) {
// We listen specifically for main window close because closing main window
// on macOS does not trigger SDL_Quit.
- exit(0);
+ videoRequestQuit("window closed (render pump)"); // D344
} else if (event.window.event == SDL_WINDOWEVENT_FOCUS_LOST) {
inputSetMouseGrab(0); // free + show cursor on alt-tab (also handled by video.c's pump; whichever dequeues it)
} else if (event.window.event == SDL_WINDOWEVENT_FOCUS_GAINED) {
@@ -353,7 +357,7 @@ static void gfx_sdl_handle_events(void) {
}
break;
case SDL_QUIT:
- exit(0);
+ videoRequestQuit("quit event (render pump)"); // D344
break;
}
}
@@ -372,6 +376,20 @@ extern "C" void gfx_sdl_make_context_current(void) {
}
}
+/* D344: called on the render thread when a quit was requested, at a frame
+ * boundary: wait for the GPU to drain everything this process queued, then
+ * unbind the context, so process teardown never lands inside the driver. */
+extern "C" void gfx_sdl_park_for_exit(void) {
+ if (ctx && SDL_GL_GetCurrentContext() == ctx) {
+ typedef void (SDLCALL *FinishFn)(void); // = APIENTRY on Windows
+ FinishFn finish = (FinishFn)SDL_GL_GetProcAddress("glFinish");
+ if (finish) {
+ finish();
+ }
+ SDL_GL_MakeCurrent(NULL, NULL);
+ }
+}
+
/* Release the GL context from the calling (host main) thread. WGL only allows
* a context to be current on one thread at a time, and while another thread
* still holds it, wglMakeContextCurrent fails with "The requested resource is
diff --git a/port/include/d318bprobe.h b/port/include/d318bprobe.h
new file mode 100644
index 00000000..d2f1c2a7
--- /dev/null
+++ b/port/include/d318bprobe.h
@@ -0,0 +1,13 @@
+/* d318bprobe.h -- D318/D320 boundary probe (M-199 step 2).
+ *
+ * Env-gated per-tick ground-truth logger for the aim-hold / derail /
+ * anim-pin state machine of chrlvTickAttack + chrlvTickAttackCommon.
+ * Read-only; inert without GE_D318B. TEMP: remove when D318/D320
+ * conclude (same class as d320repro.c).
+ */
+#ifndef D318BPROBE_H
+#define D318BPROBE_H
+
+void d318bProbeTick(void);
+
+#endif /* D318BPROBE_H */
diff --git a/port/include/envflag.h b/port/include/envflag.h
new file mode 100644
index 00000000..b8f929fa
--- /dev/null
+++ b/port/include/envflag.h
@@ -0,0 +1,20 @@
+#ifndef _ENVFLAG_H_
+#define _ENVFLAG_H_
+
+/* Cached env-var probes for hot paths (D250/D302: an uncached getenv() per
+ * node/poly/audio event is a measurable per-frame cost on Windows). Each
+ * expansion site gets its own static, read once on first use; the env is
+ * fixed for the process lifetime, so this is exact. The TU must already have
+ * a real getenv() prototype (see D324). GCC/Clang statement expressions. */
+#define GE_ENVFLAG(name) __extension__({ \
+ static int _ge_envflag = -1; \
+ if (_ge_envflag < 0) _ge_envflag = getenv(name) != NULL; \
+ _ge_envflag; })
+
+#define GE_ENVSTR(name) __extension__({ \
+ static int _ge_envstr_init = 0; \
+ static const char *_ge_envstr; \
+ if (!_ge_envstr_init) { _ge_envstr = getenv(name); _ge_envstr_init = 1; } \
+ _ge_envstr; })
+
+#endif
diff --git a/port/include/floatvtx.h b/port/include/floatvtx.h
new file mode 100644
index 00000000..ffff7658
--- /dev/null
+++ b/port/include/floatvtx.h
@@ -0,0 +1,33 @@
+#ifndef PORT_FLOATVTX_H
+#define PORT_FLOATVTX_H
+
+/* D245 (M-201): port-only full-precision vertex load for the sky/water fans.
+ *
+ * On N64, sky.c builds these triangles directly as RDP edge/texture
+ * coefficients with 32-bit S/T, so a horizon-scale quad (the IsWater ocean
+ * spans ~300,000 S10.5 units, ~9,400 texels) is exact. Routed through an
+ * ordinary gSPVertex, both its texture coords (s16 tc: +-1,024 texels) and its
+ * positions (s16 ob, divided by a fan-wide wScale to fit camera-space w up to
+ * ~2e5) had to be quantised -- visible as water/sky jitter that slides with
+ * the view. G_FLOATVTX_EXT hands fast3d the clip-space position and S/T as
+ * floats instead; fast3d already stores every loaded vertex as floats.
+ *
+ * w0 = G_FLOATVTX_EXT << 24 | dest_index << 16 | count, w1 = PortFloatVtx *.
+ * s/t are in Vtx.tc units (S10.5, before gSPTexture scaling). */
+#define G_FLOATVTX_EXT 0x47
+
+typedef struct PortFloatVtx {
+ float x, y, z, w; /* clip space */
+ float s, t; /* tc units */
+ unsigned char r, g, b, a;
+} PortFloatVtx;
+
+#define gSPFloatVertexExt(pkt, ptr, n, v0) \
+ do { \
+ Gfx *_g = (Gfx *)(pkt); \
+ _g->words.w0 = ((uintptr_t)G_FLOATVTX_EXT << 24) | \
+ (((uintptr_t)(v0) & 0xff) << 16) | ((uintptr_t)(n) & 0xffff); \
+ _g->words.w1 = (uintptr_t)(ptr); \
+ } while (0)
+
+#endif
diff --git a/port/include/frontoptions.h b/port/include/frontoptions.h
new file mode 100644
index 00000000..8e42808a
--- /dev/null
+++ b/port/include/frontoptions.h
@@ -0,0 +1,35 @@
+#ifndef PORT_FRONTOPTIONS_H
+#define PORT_FRONTOPTIONS_H
+
+/*
+ * D343: PC options as a GE front-end screen, MENU_PC_OPTIONS
+ * (port/src/frontoptions.c).
+ *
+ * Hooks:
+ * front.c the five MENU_* dispatch sites : frontOptionsMenu{Init,Update,
+ * Interface,Draw} and the debug name (all #ifdef PORT)
+ * front.c constructor_menu05_fileselect : optionsFileSelectLabel() draws the
+ * file-select entry label and enters MENU_PC_OPTIONS on a click
+ * video.c / input.c : F10 / Select don't open the overlay on this screen
+ */
+
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+void frontOptionsMenuInit(void);
+void frontOptionsMenuUpdate(void);
+void frontOptionsMenuInterface(void);
+Gfx *frontOptionsMenuDraw(Gfx *DL);
+
+Gfx *optionsFileSelectLabel(Gfx *gdl); /* file-select hook */
+int frontOptionsBlocksOverlay(void); /* 1 while MENU_PC_OPTIONS is up */
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* PORT_FRONTOPTIONS_H */
diff --git a/port/include/hudaspect.h b/port/include/hudaspect.h
new file mode 100644
index 00000000..7dc84e48
--- /dev/null
+++ b/port/include/hudaspect.h
@@ -0,0 +1,49 @@
+#ifndef _HUDASPECT_H_
+#define _HUDASPECT_H_
+
+/* D335 (WIDESCREEN-FOV-PLAN Option B, Phase 3): HUD alignment under native
+ * widescreen (D334). The world is projected at the window aspect, but 2D/HUD
+ * elements are laid out in the game's 4:3 logical canvas, which fast3d
+ * stretches to the window. Wrapping a HUD draw in an aspect mode makes fast3d
+ * map its x undistorted (x scaled by (4/3)/window_aspect) and anchor it to
+ * the left edge, the right edge, or the centre -- the PD port's
+ * G_ASPECT_{LEFT,RIGHT,CENTER}_EXT extra-geometry-mode, which GE's fast3d
+ * already decodes (gfx_pc.cpp gfx_sp_extra_geometry_mode /
+ * gfx_update_aspect_mode). Game-includable: no SDL, no fast3d headers.
+ *
+ * PORT_HUD_ASPECT emits only while native widescreen is on and the window is
+ * wider than 4:3; otherwise the display list is byte-identical to before. */
+
+#define GE_HUD_ASPECT_NONE 0x00u
+#define GE_HUD_ASPECT_LEFT 0x10u /* G_ASPECT_LEFT_EXT (port/fast3d/gbiex.h) */
+#define GE_HUD_ASPECT_RIGHT 0x20u /* G_ASPECT_RIGHT_EXT */
+#define GE_HUD_ASPECT_CENTER 0x30u /* G_ASPECT_CENTER_EXT */
+#define GE_HUD_ASPECT_MASK 0x70u /* G_ASPECT_MODE_EXT */
+#define GE_EXTRAGEOMETRYMODE_EXT 0x3a /* G_EXTRAGEOMETRYMODE_EXT */
+
+/* The decoder does extra_mode = (extra_mode & ~clear) | set with
+ * clear = ~(w0 & 0xFFFFFF): passing ~MASK as the low 24 bits clears exactly
+ * the aspect bits and leaves the other extra-geometry flags alone. */
+#define gSPHudAspectEXT(pkt, mode) \
+ gDma0p((pkt), GE_EXTRAGEOMETRYMODE_EXT, (mode), ((~GE_HUD_ASPECT_MASK) & 0xFFFFFFu))
+
+extern float portNativeAspect(void); /* port/src/video.c */
+
+/* Only for windows wider than 4:3: in a narrower window a centred 4:3 HUD
+ * would be wider than the window and crop, so there the HUD keeps the canvas
+ * mapping (squeezed, never cropped). 1.3334 so exact 4:3 emits nothing. */
+#define PORT_HUD_ASPECT(gdl, mode) \
+ do { if (portNativeAspect() > 1.3334f) { gSPHudAspectEXT((gdl)++, (mode)); } } while (0)
+
+/* D226: HUD span scale. PORT_HUD_SCALE(gdl, ax, ay) scales every rectangle
+ * drawn after it (glyphs, message boxes, ammo icons) about the anchor
+ * (logical screen px) by Game.HudScale %, until PORT_HUD_SCALE_END. At 100%
+ * nothing is emitted (byte-identical). Decoded as G_HUDSCALE_EXT in fast3d. */
+#define GE_HUDSCALE_EXT 0x46
+extern s32 portHudScalePercent(void); /* port/src/video.c */
+#define gSPHudScaleEXT(pkt, scale256, ax4, ay4) gDma0p((pkt), GE_HUDSCALE_EXT, ((u32)(ax4) << 16) | ((u32)(ay4) & 0xFFFFu), ((u32)(scale256) & 0xFFFFu))
+#define PORT_HUD_SCALE(gdl, ax, ay) do { s32 _hp = portHudScalePercent(); if (_hp != 100) { gSPHudScaleEXT((gdl)++, _hp * 256 / 100, (ax) * 4, (ay) * 4); } } while (0)
+#define PORT_HUD_SCALE_PCT(gdl, pct, ax, ay) do { s32 _hp = (pct); if (_hp != 100) { gSPHudScaleEXT((gdl)++, _hp * 256 / 100, (ax) * 4, (ay) * 4); } } while (0)
+#define PORT_HUD_SCALE_END(gdl) do { if (portHudScalePercent() != 100) { gSPHudScaleEXT((gdl)++, 256, 0, 0); } } while (0)
+
+#endif
diff --git a/port/include/input.h b/port/include/input.h
index 27fa4ab2..8d454224 100644
--- a/port/include/input.h
+++ b/port/include/input.h
@@ -12,6 +12,7 @@
*/
#include
+#include /* API types (SDL_GameControllerButton/Axis); cf. video.h */
#ifdef __cplusplus
extern "C" {
@@ -43,6 +44,8 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y);
* also suspends mouse-aim reads until re-grabbed. No-op if the mouse is
* disabled in config. */
void inputSetMouseGrab(int on);
+/* D287: apply queued mouse-mode/cursor changes; host (window) thread only. */
+void inputApplyMouseRequests(void);
/* WI-1 click-to-lock cursor capture. The host event pump calls
* inputNotifyClick() when a mouse button goes down inside the game window
@@ -65,9 +68,37 @@ short inputPadAxis(int idx, SDL_GameControllerAxis a);
* ignored -- GE only cycles forward on a bare A edge. */
void inputPostWheel(int notches);
+/* v0.4.0 M3: re-derive the keyboard binds after an F10 change to
+ * Input.CrouchMode or a future binding capture (optionsoverlay hook).
+ * Also drops the latched-crouch state. Scheduler thread only. */
+void inputBindingsApply(void);
+/* D384/D385: Primary/Secondary each accept a key or Mouse 1..5.
+ * Legacy extra ini tokens remain on disk but are ignored until action edit. */
+#define INPUT_BIND_SLOTS 2
+#define INPUT_BIND_MOUSE(button) (SDL_NUM_SCANCODES + (button))
+const char *inputBindingSlot(const char *key, int slot);
+int inputBindingSetSlot(const char *key, int slot, int code);
+int inputBindingResetKey(const char *key);
+
+/* D345(b): 1 while the 1:1 menu pointer owns cursor_h/v_pos (in a menu,
+ * abs pointer available, mouse used within the re-assert window). Port
+ * screens that teleport the crosshair should skip their snap when set. */
+int inputMenuPointerLive(void);
+
/* Re-enumerate gamepads after a hotplug (SDL_CONTROLLERDEVICEADDED/REMOVED). */
void inputRescanPads(void);
+/* D401: Rumble Pak -> real gamepad haptics. The osMotor* shims in
+ * libultra.c (game's src/joy.c rumble state machine) route here.
+ * idx is the N64 controller channel (0..3; out-of-range is a no-op).
+ * strength is 0..1, time seconds; 0/0 stops an active rumble. The scale
+ * is the single global Input.RumbleScale config value (0 = silent),
+ * applied to every pad. */
+int inputRumbleSupported(int idx);
+void inputRumble(int idx, f32 strength, f32 time);
+f32 inputRumbleGetScale(void);
+void inputRumbleSetScale(f32 v);
+
#ifdef __cplusplus
}
#endif
diff --git a/port/include/optionsoverlay.h b/port/include/optionsoverlay.h
index df4add48..65eb77cd 100644
--- a/port/include/optionsoverlay.h
+++ b/port/include/optionsoverlay.h
@@ -17,10 +17,17 @@
* input.c inputComputePad : controller 0 swallowed while open;
* nav routed to optionsOverlayHandleInput()
* gfx_pc.cpp gfx_run : optionsOverlayEmit() appended after the game DL
+ *
+ * D346 (M2): the row API below is the shared row table the front MENU_PC_
+ * OPTIONS screen (frontoptions.c) renders from, so one option set in one
+ * place.
*/
#include
#include
+#include
+#include
+#include
#ifdef __cplusplus
extern "C" {
@@ -28,6 +35,8 @@ extern "C" {
/* Toggle open/closed. On the closing edge the config is saved. */
void optionsOverlayToggle(void);
+/* ESC: back to categories, or close from the category list (host-thread request). */
+void optionsOverlayBack(void);
/* 1 while the overlay is on screen. */
int optionsOverlayIsOpen(void);
@@ -39,10 +48,70 @@ void optionsOverlayHandleInput(void);
/* Mouse-wheel notch -> move the selection (host event pump). */
void optionsOverlayScroll(int dir);
+/* D383/D385 keyboard + mouse rebinding modal, shared by both options UIs.
+ * Host event pump hands off one key/button; menu thread commits it. */
+int optionsBindingCaptureActive(void);
+int optionsBindingInputBlocked(void); /* active or captured key still held */
+int optionsBindingKeyDown(const SDL_KeyboardEvent *ev); /* 1 = consumed */
+int optionsBindingMouseDown(const SDL_MouseButtonEvent *ev); /* 1 = consumed */
+int optionsBindingCaptureTick(void); /* 1 = swallow menu nav this tick */
+
/* Build the overlay's 2D display list for this frame, or return NULL when the
* overlay is closed. Called by fast3d after running the game DL. */
Gfx *optionsOverlayEmit(void);
+/* D343 (M2): row API for the file-select options screen (frontoptions.c).
+ * Index i runs over the same row table as the F10 overlay; headers split it
+ * into the screen's sections. */
+int optionsRowCount(void);
+int optionsRowIsHeader(int i);
+int optionsRowHeaderParent(int i); /* -1 for root sections */
+int optionsRowChildHeader(int i); /* -1 unless this row opens a nested section */
+int optionsRowIsShown(int i);
+const char *optionsRowLabel(int i);
+int optionsRowIsSlider(int i);
+int optionsRowIsBind(int i);
+void optionsRowBeginBind(int i);
+int optionsRowIsBondChooser(int i); /* D353: the Bond-file chooser row
+ (front options screen only) */
+/* D356: 1 when this row's value lives in the selected save file (content
+ * rows); on a header, when its section contains such rows -- the front page
+ * uses the header form for its "(File N)" title annotation. */
+int optionsRowIsSaveScoped(int i);
+int optionsRowNeedsRestart(int i);
+double optionsRowFraction(int i);
+double optionsRowGetValue(int i); /* D356: raw value (fraction is bar geometry) */
+void optionsRowSetFraction(int i, double f);
+void optionsRowCommit(int i); /* slider release; watch rows persist once */
+void optionsRowValueText(int i, char *out, int n);
+void optionsRowAdjust(int i, int dir);
+
+/* D356: per-section "Reset to defaults" rows (ROW_ACTION, keys
+ * "__Reset*"). Activation is edge-triggered and two-step (arm -> confirm
+ * within 3 s, one commit per confirmed activation; timeout or navigating
+ * away disarms) -- optionsRowIsReset lets the front screen suppress its
+ * held-key repeat for them. optionsResetMaintain runs each frame from the
+ * open UI (selRow = its selected rows[] index, -1 = none); optionsResetClear
+ * when a UI closes. Identical contract in F10 and on the front options
+ * screen (they are never open at the same time). Front: the confirmed reset
+ * writes the section's file rows through watchSettingsSet (direct commit)
+ * and the ini rows live (persisted on screen close, like any ini edit).
+ * In stage: the file rows enter the D352 queue (the game thread applies +
+ * persists them) and the ini rows are applied live immediately. */
+int optionsRowIsReset(int i);
+void optionsRowActivateReset(int i);
+void optionsResetMaintain(int selRow);
+void optionsResetClear(void);
+
+/* D356 GE_WSPROBE_RESET dev hook (env-gated, driven from
+ * watchSettingsGameTick): runs the real arm -> confirm -> dispatch for
+ * EVERY section's reset row and verifies each section's values after its
+ * own dispatch (plan §5.8). Front is one-shot; the stage pair runs on the
+ * tick after the stage drain. */
+void optionsResetProbePrepare(void);
+void optionsResetProbeDispatchStage(void);
+void optionsResetProbeVerifyStage(void);
+
#ifdef __cplusplus
}
#endif
diff --git a/port/include/pc_protos.h b/port/include/pc_protos.h
index 6d4096e8..2bccf078 100644
--- a/port/include/pc_protos.h
+++ b/port/include/pc_protos.h
@@ -71,6 +71,11 @@ void alloc_false_GUARDdata_to_exec_global_action();
void alloc_init_GUARDdata_entries();
void alloc_load_expand_ani_table();
void assert();
+/* D324: GE's stub (#if 0'd body) declares neither; an implicit
+ * `int f()` declaration reads the xmm0 double return from eax -> garbage.
+ * port/src TUs that include get these from port/shim/stdlib.h. */
+double atof();
+double strtod();
char * bgDebPrintROOMID();
void bgFindRoomsAlongSegment();
s32 bgGetConnectedRooms();
diff --git a/port/include/video.h b/port/include/video.h
index f49971e8..4e8ce54b 100644
--- a/port/include/video.h
+++ b/port/include/video.h
@@ -37,9 +37,17 @@ void videoEndFrame(void);
* loop from main(). Exits the process on QUIT/ESC/close. */
void videoPumpEvents(void);
+/* D344: orderly quit. Any thread may request it; the render thread parks at
+ * the next frame boundary (glFinish + context released) and the host thread
+ * then exits. Never call exit() directly for a normal quit: the render thread
+ * could be inside the GL driver (the 0x119 bugchecks). */
+void videoRequestQuit(const char *why);
+int videoQuitRequested(void);
+
/* The game's native video mode (NTSC 640x480, PAL 640x400). fast3d scales
* N64 screen coordinates into window pixels using this. */
void videoUpdateNativeResolution(s32 w, s32 h);
+u32 videoGetFrameCount(void);
s32 videoGetNativeWidth(void);
s32 videoGetNativeHeight(void);
@@ -51,9 +59,9 @@ void videoResetTextureCache(void);
/* Current FPS (measured). */
float videoGetFPS(void);
-/* Re-apply the live-tunable [Video] knobs (VSync / FpsCap / TextureFilter) on
- * the next frame start. Called by the F10 options overlay after an edit. */
-void videoRequestLiveConfig(void);
+/* Apply only the GL/SDL setting named by a changed options row at the next
+ * frame start. Sprite/HUD and direct-read settings need no reconfiguration. */
+void videoRequestLiveConfigForKey(const char *key);
/* F10 options overlay -> window/fullscreen changes. The overlay input handler
* runs on the scheduler thread; SDL window ops must run on the thread that
diff --git a/port/include/watchsettings.h b/port/include/watchsettings.h
new file mode 100644
index 00000000..a61f002b
--- /dev/null
+++ b/port/include/watchsettings.h
@@ -0,0 +1,48 @@
+#ifndef PORT_WATCHSETTINGS_H
+#define PORT_WATCHSETTINGS_H
+
+/* D349: one source of truth for Bond-file-backed GE settings. All GE state and
+ * EEPROM access runs on the game thread. F10 sends requests to that thread. */
+enum WatchSettingField {
+ WATCH_SETTING_MUSIC,
+ WATCH_SETTING_FX,
+ WATCH_SETTING_LOOK,
+ WATCH_SETTING_AUTOAIM,
+ WATCH_SETTING_AIMCONTROL,
+ WATCH_SETTING_SIGHT,
+ WATCH_SETTING_LOOKAHEAD,
+ WATCH_SETTING_AMMO,
+ WATCH_SETTING_COUNT
+};
+
+int watchSettingsFieldForKey(const char *key); /* -1 for non-watch row */
+int watchSettingsFolder(void); /* explicit front chooser; -1 = none */
+/* D356: the chooser cycles folders only -- "none" is retired from the UI
+ * surface (plan §5.5): the front target is always a folder (frontFolder()
+ * resolves -1 to a valid folder lazily, D354; the auto-init fallback builds
+ * file 1 if none exists). The UI shows an unavailable state until the
+ * target's file actually exists instead of a number the write path cannot
+ * use. */
+void watchSettingsChooseFile(int dir); /* game thread only */
+int watchSettingsAvailable(void);
+int watchSettingsRead(enum WatchSettingField field); /* front: saved file, F10: snapshot */
+void watchSettingsSet(enum WatchSettingField field, int value, int commit);
+void watchSettingsCommit(enum WatchSettingField field); /* slider release */
+void watchSettingsGameTick(void); /* game thread; after gfxFrameMsgQ receive */
+
+/* D356: context-aware, READ-ONLY active-file accessor (never mutates the
+ * stored front target): in stage -> the stage's active file; front
+ * contexts -> the resolved front target (frontFolder()'s lazy default).
+ * -1 when nothing usable (e.g. the auto-init fallback not yet applied).
+ * Drives the "(File N)" title annotations in the F10 overlay and the front
+ * options screen (plan §5.5). */
+int watchSettingsActiveFolder(void);
+/* D356: the BLANKSAVEDATA value of a field -- the per-file "default" the
+ * reset-to-defaults rows restore (plan §5.3/§5.6). */
+int watchSettingsBlankValue(enum WatchSettingField field);
+/* D352: queued field commands awaiting the game thread (0 in front
+ * contexts -- nothing is queued there). Drives the in-stage reset probe's
+ * dispatch check. */
+int watchSettingsQueueCount(void);
+
+#endif
diff --git a/port/shim/PR/gbi.h b/port/shim/PR/gbi.h
index ca4a6f92..99e5a2d2 100644
--- a/port/shim/PR/gbi.h
+++ b/port/shim/PR/gbi.h
@@ -136,5 +136,21 @@ typedef union {
#define Gfx Gfx_le
+/*
+ * The gDP* display-list macros in the real gbi.h use _SHIFTL/_SHIFTR, which
+ * the N64 headers define in PR/mbi.h. A port TU that includes
+ * without (e.g. port/src/video.c) never reaches mbi.h; on Linux
+ * no other header in its include chain pulls it in, so the gDP* calls expand
+ * to implicit _SHIFTL()/_SHIFTR() function calls and fail to link (D402).
+ * Define them if absent. Guarded so a TU that also reaches mbi.h keeps
+ * mbi.h's own definitions.
+ */
+#ifndef _SHIFTL
+# define _SHIFTL(v, s, w) ((u32)(((u32)(v) & ((0x01 << (w)) - 1)) << (s)))
+#endif
+#ifndef _SHIFTR
+# define _SHIFTR(v, s, w) ((u32)(((u32)(v) >> (s)) & ((0x01 << (w)) - 1)))
+#endif
+
#endif /* defined(PORT) */
#endif /* _PORT_SHIM_GBI_H_ */
diff --git a/port/shim/PR/ucode.h b/port/shim/PR/ucode.h
index a891cec2..998cf993 100644
--- a/port/shim/PR/ucode.h
+++ b/port/shim/PR/ucode.h
@@ -13,7 +13,18 @@
#if defined(PORT)
# include "include/PR/ucode.h"
-# include "pc_protos.h"
+# /* D402 (Linux build): a TU that includes before
+# * (e.g. port/src/optionsoverlay.c) sets src/bondconstants.h's
+# * include-guard, then that header's own top `#include ` reaches
+# * this pc_protos.h -> bondtypes.h -> bondconstants.h chain while
+# * src/bondconstants.h is still mid-parse (its ITEM_IDS/ACT_TYPE/... are
+# * defined only later in the file). glibc GCC rejects the half-defined
+# * types hard (MinGW tolerated it, so this only surfaced on Linux). The
+# * bondconstants.h shim sets _PORT_DEFER_PC_PROTOS around that pass so the
+# * D38 prototypes are pulled in only once, after the constants are whole. */
+# if !defined(_PORT_DEFER_PC_PROTOS)
+# include "pc_protos.h"
+# endif
#else
# include
#endif
diff --git a/port/shim/bondconstants.h b/port/shim/bondconstants.h
index c876c076..5a75ef0b 100644
--- a/port/shim/bondconstants.h
+++ b/port/shim/bondconstants.h
@@ -21,7 +21,21 @@
* Inert in the N64 build (no -DPORT): pure pass-through.
*/
#if defined(PORT)
+/* D402 (Linux build): parse the PR base headers first, but tell the PR/ucode.h
+ * shim to defer pc_protos.h for this pass. pc_protos.h reaches bondtypes.h,
+ * which needs the bondconstants.h types (ITEM_IDS, ACT_TYPE, ...) that are only
+ * defined later in src/bondconstants.h. Without this, a TU that includes
+ * before re-enters the still-parsing
+ * src/bondconstants.h (guard already set) from bondtypes.h and glibc GCC
+ * rejects the half-defined types (MinGW tolerated it). We finish
+ * src/bondconstants.h below and then pull pc_protos.h so the D38 prototypes
+ * are still seen exactly once. */
+# define _PORT_DEFER_PC_PROTOS
+# include
+# undef _PORT_DEFER_PC_PROTOS
+
#include "src/bondconstants.h"
+#include "pc_protos.h"
/*
* D38: src/bondconstants.h defines ntohl()/ntohs() as function-like macros
diff --git a/port/shim/stdlib.h b/port/shim/stdlib.h
index 5512a05f..71f47b50 100644
--- a/port/shim/stdlib.h
+++ b/port/shim/stdlib.h
@@ -17,15 +17,30 @@
#include "hoststdlib.h"
#else
#include "include/stdlib.h"
-#if !defined(_WIN32)
-/* The N64 stub declares only lldiv_t/ldiv_t + lldiv/ldiv. MinGW's other CRT
- * headers leak getenv() etc.; a strict host GCC (Linux/macOS) does not, so
- * getenv()'s 64-bit pointer return is assumed int and truncated (crashed
- * configGetFrameDump). Declare the pointer-returning stragglers the port +
- * game code use that nothing else declares. K&R form = compatible with any
- * real prototype. (malloc/calloc/free are in port/include/pc_protos.h and
- * a few port TUs' own local decls — not repeated here to avoid conflicts.) */
+/* D324: the N64 stub declares nothing else, and -- unlike getenv/realloc
+ * below -- MinGW's leaked CRT headers do NOT declare these in the port TUs
+ * that use them. An implicit `int f()` declaration makes the caller read a
+ * double return from eax instead of xmm0 (silent garbage) and truncates
+ * pointer/64-bit-long returns to 32 bits (latent on LP64 for strtol).
+ * Declared unconditionally; K&R form is compatible with any real prototype.
+ * Game TUs that never include get these from pc_protos.h. */
+extern double atof();
+extern double strtod();
+extern long int strtol();
+extern int atoi();
+extern int atexit();
+/* D324: getenv was previously declared only on non-Windows, on the (wrong)
+ * assumption that MinGW's other CRT headers leak a prototype -- config.c gets
+ * none, so its implicit `int f()` declaration truncated the pointer return to
+ * 32 bits. Declare unconditionally; K&R form is compatible with any leaked or
+ * real prototype. */
extern char *getenv();
+#if !defined(_WIN32)
+/* The N64 stub declares only lldiv_t/ldiv_t + lldiv/ldiv, and a strict host
+ * GCC (Linux/macOS) leaks no CRT prototypes at all, so realloc's pointer
+ * return would be assumed int and truncated (crashed configGetFrameDump).
+ * (malloc/calloc/free are in port/include/pc_protos.h and a few port TUs'
+ * own local decls — not repeated here to avoid conflicts.) */
extern void *realloc();
#endif
#endif
diff --git a/port/src/audio.c b/port/src/audio.c
index 35a45a02..6f4a1246 100644
--- a/port/src/audio.c
+++ b/port/src/audio.c
@@ -17,6 +17,9 @@
#include "config.h"
#include "audio.h"
#include "audiotrace.h"
+#ifdef PORT
+#include "envflag.h" /* cached getenv for hot-path probes */
+#endif
static SDL_AudioDeviceID dev = 0;
@@ -85,6 +88,18 @@ static int d204OldMode(void)
int audioInit(void)
{
+#if defined(_WIN32) && defined(SDL_HINT_AUDIODRIVER)
+ /* D322: prefer DirectSound over SDL2's WASAPI backend on Windows. Measured
+ * side by side on the same USB interface (tools_pc/sdl_drain_monitor.c,
+ * four simultaneous streams, 700 s): WASAPI fell to 81-88 % drain for
+ * ~30-50 s every ~317 s and lost 1.4 % of playback overall, whichever
+ * buffer size was used, while DirectSound drained at exactly real time
+ * throughout. Each WASAPI shortfall overflowed our queue and dropped whole
+ * blocks (the periodic "garbled audio" bursts). WASAPI stays as the
+ * fallback, and the SDL_AUDIODRIVER environment variable still overrides
+ * this (SDL_HINT_NORMAL yields to the environment). */
+ SDL_SetHint(SDL_HINT_AUDIODRIVER, "directsound,wasapi");
+#endif
if (SDL_InitSubSystem(SDL_INIT_AUDIO) < 0) {
sysLogPrintf(LOG_ERROR, "audioInit: SDL_InitSubSystem: %s", SDL_GetError());
return -1;
@@ -101,7 +116,8 @@ int audioInit(void)
return -1;
}
SDL_PauseAudioDevice(dev, 0);
- sysLogPrintf(LOG_INFO, "audioInit: opened SDL audio device at %d Hz", have.freq);
+ sysLogPrintf(LOG_INFO, "audioInit: opened SDL audio device at %d Hz (driver %s)", have.freq,
+ SDL_GetCurrentAudioDriver() ? SDL_GetCurrentAudioDriver() : "?");
return 0;
}
@@ -289,7 +305,7 @@ void audioSetNextBuffer(const s16 *buf, u32 len)
* baseline after quiet sections, is the voice-leak signature; q pinning at
* queueLimit with drop climbing is the overproduction/queue signature.
* Cheap (one locked list walk per 5 s) -- safe for a full playtest. */
- if (getenv("GE_D322")) {
+ if (GE_ENVFLAG("GE_D322")) {
static u64 d322StartUs = 0, d322NextUs = 0;
extern void sndD322PoolSummary(s32 *, s32 *, s32 *, s32 *, s32 *, s32 *);
u64 now = sysGetMicroseconds();
diff --git a/port/src/config.c b/port/src/config.c
index 211d51fe..cc90544f 100644
--- a/port/src/config.c
+++ b/port/src/config.c
@@ -25,6 +25,7 @@
#include "platform.h"
#include "system.h"
#include "config.h"
+#include "envflag.h"
/*
* [Debug] knobs mirroring the dev env vars. The env var always wins so
@@ -43,14 +44,14 @@ PD_CONSTRUCTOR static void configDebugInit(void)
const char *configGetFrameDump(void)
{
- const char *e = getenv("GE_PCDUMP");
+ const char *e = GE_ENVSTR("GE_PCDUMP"); /* per frame (videoPreSwapCapture) */
if (e && *e) return e;
return dbgFrameDump[0] ? dbgFrameDump : NULL;
}
int configGetInputLog(void)
{
- return getenv("GE_INPUTLOG") ? 1 : dbgInputLog;
+ return GE_ENVFLAG("GE_INPUTLOG") ? 1 : dbgInputLog; /* several calls per poll */
}
#define MAX_OPTIONS 256
diff --git a/port/src/d318bprobe.c b/port/src/d318bprobe.c
new file mode 100644
index 00000000..618ebfbc
--- /dev/null
+++ b/port/src/d318bprobe.c
@@ -0,0 +1,377 @@
+/* d318bprobe.c -- D318/D320 boundary probe (M-199 step 2).
+ *
+ * Purpose: capture per-tick ground truth of the aim-hold / derail /
+ * anim-pin state machine so the D318/D320/D321 writeups can be rewritten
+ * on verified evidence and each watchdog entry judged for excision on its
+ * own data. Static analysis (this session) established:
+ *
+ * - The hold-pose pin is topologically stable, not a float knife-edge:
+ * once modelConstrainOrWrapAnimFrame clamps both framea and frameb to
+ * ceil(endframe), the tail of modelSetAnimFrame2WithChrStuff re-emits
+ * the stale va==vb pair every tick (the per-frame loop only runs when
+ * floor(frame) crosses an integer boundary, which a pinned animframe1
+ * never does). All comparisons in the frozen state are exact integers.
+ * - Post-derail, escape requires a modelSetAnimation re-init from one of
+ * exactly three paths: chrlvTickAttack's unk36==0 branch (auto
+ * weapons, endframe<=frame), chrlvTickAttackCommon re-init block
+ * branch 1 (unk37!=0, i.e. auto weapon -> kneel-stop) or branch 3
+ * (unk31 armed by the fire gate while frame sits in the recoil/shoot
+ * window). None is reachable in the observed pins (auto weapons,
+ * endframe unbounded) -- BUT the pin itself only persists at 1 sim
+ * tick per rendered frame: a >=2-tick frame advances >=1.0 and
+ * crosses the integer boundary, so no escape path is needed (D329,
+ * lockstep A/B via GE_D318B_CLK below). Not a platform-invariant lock.
+ *
+ * This probe logs, for env-selected chrs, every operand those decisions
+ * read: the drawn variant (identified by pointer against all six firing-
+ * group sets), its frame fields, the unk30..unk44 gate bytes, the weapon
+ * auto-firing rates, full-precision model frame/endframe/speed state, and
+ * each branch condition recomputed from the same fields. Read-only;
+ * inert without GE_D318B.
+ *
+ * Usage: GE_D318B="5,9" (comma-separated chrnums, max 4)
+ * GE_D318B_ALL=1 (log every ACT_ATTACK tick, not just changes)
+ * GE_D318B_CLK=N (lockstep: exactly N sim ticks per rendered frame)
+ * Combined with the D320R forced repro, e.g.:
+ * GE_D320R="900:jump=5,list=0x0414,off=341" GE_D318B="5"
+ *
+ * TEMP: investigation probe, remove when D318/D320 conclude.
+ */
+#include
+#include
+#include
+
+#include "PR/os.h"
+#include "bondtypes.h"
+#include "bondaicommands.h"
+#include "chr.h"
+#include "chraction.h"
+#include "gun.h"
+#include "lv.h"
+#include "model.h"
+
+#ifdef PORT
+
+#define D318B_MAX_CHRS 4
+#define D318B_LINE_CAP 6000
+
+typedef struct
+{
+ s16 chr;
+ /* last-logged state (change detection) */
+ int act;
+ u32 atk;
+ u32 ent;
+ char animid[8];
+ u32 framebits;
+ s32 stable; /* consecutive ticks with unchanged frame bits */
+ s32 hb; /* ticks since last heartbeat log */
+} D318BState;
+
+static int s_on = -1;
+static int s_all = 0; /* GE_D318B_ALL=1: log every attack tick */
+static int s_n = 0;
+static D318BState s_s[D318B_MAX_CHRS];
+static long s_lines = 0;
+static int s_capped = 0;
+
+#define d318bLog osSyncPrintf
+
+static void d318bInit(void)
+{
+ const char *e;
+ char buf[128];
+ char *p, *save;
+
+ if (s_on != -1)
+ {
+ return;
+ }
+ s_on = 0;
+ e = getenv("GE_D318B");
+ if (!e || !e[0])
+ {
+ return;
+ }
+ s_on = 1;
+ s_all = (getenv("GE_D318B_ALL") != NULL);
+ strncpy(buf, e, sizeof(buf) - 1);
+ buf[sizeof(buf) - 1] = 0;
+ p = buf;
+ while (p && *p && s_n < D318B_MAX_CHRS)
+ {
+ save = strchr(p, ',');
+ if (save)
+ {
+ *save = 0;
+ }
+ memset(&s_s[s_n], 0, sizeof(D318BState));
+ s_s[s_n].chr = (s16)strtol(p, NULL, 0);
+ s_s[s_n].act = -1;
+ s_n++;
+ p = save ? save + 1 : NULL;
+ }
+ d318bLog("D318B: probe active for %d chr(s): %s\n", s_n, buf);
+}
+
+static ChrRecord *d318bFindChr(s16 chrnum)
+{
+ s32 i;
+
+ for (i = 0; i < g_NumChrSlots; i++)
+ {
+ if (g_ChrSlots[i].chrnum == chrnum)
+ {
+ return &g_ChrSlots[i];
+ }
+ }
+ for (i = 0; i < g_ActiveChrsCount; i++)
+ {
+ if (g_ActiveChrs[i].chrnum == chrnum)
+ {
+ return &g_ActiveChrs[i];
+ }
+ }
+ return NULL;
+}
+
+/* Identify act_attack.animfloats by pointer against all six firing-group
+ * sets. Returns 1 and fills id (e.g. "P3.2" = pistol set, bearing group
+ * index 3, variant row 2) on success. */
+static int d318bIdentifyAnim(struct weapon_firing_animation_table *row, char *id, int idsz)
+{
+ static const struct
+ {
+ const char tag;
+ struct anim_group_info *const *set;
+ } sets[] = {
+ { 'P', ptr_pistol_firing_animation_groups },
+ { 'R', ptr_rifle_firing_animation_groups },
+ { 'D', ptr_doubles_firing_animation_groups },
+ { 'C', ptr_crouched_rifle_firing_animation_groups },
+ { 'K', ptr_crouched_pistol_firing_animation_groups },
+ { 'L', ptr_crouched_doubles_firing_animation_groups },
+ };
+ s32 si, gi, ri;
+
+ for (si = 0; si < 6; si++)
+ {
+ for (gi = 0; gi < 32; gi++)
+ {
+ struct anim_group_info *g = sets[si].set[gi];
+
+ if (g == NULL)
+ {
+ continue;
+ }
+ for (ri = 0; ri < g->len; ri++)
+ {
+ if (&(*g->table)[ri] == row)
+ {
+ snprintf(id, idsz, "%c%d.%d", sets[si].tag, gi, ri);
+ return 1;
+ }
+ }
+ }
+ }
+ snprintf(id, idsz, "-");
+ return 0;
+}
+
+static s32 d318bAutoRate(ChrRecord *self, GUNHAND hand)
+{
+ PropRecord *prop;
+ s32 item;
+
+ prop = chrGetEquippedWeaponPropWithCheck(self, hand);
+ if (prop == NULL || prop->chr == NULL)
+ {
+ return -99;
+ }
+ item = (s32)prop->chr->act_attack.attack_item;
+ if (item < 0)
+ {
+ return -98;
+ }
+ return (s32)bondwalkItemGetAutomaticFiringRate((ITEM_IDS)item);
+}
+
+/* GE_D318B_CLK=N (test-only): force exactly N sim ticks per rendered frame,
+ * independent of host render speed -- a lockstep model of a console frame
+ * rate of 60/N fps. Rewrites the three values lvlManageMpGame just derived
+ * from speedgraphframes (the hook runs after them, before any chr tick).
+ * Pause/lock frames (g_ClockTimer == 0) are left alone. */
+static void d318bClockLock(void)
+{
+ static s32 s_clk = -1;
+
+ if (s_clk < 0)
+ {
+ const char *e = getenv("GE_D318B_CLK");
+
+ s_clk = (e && strtol(e, NULL, 10) > 0) ? (s32)strtol(e, NULL, 10) : 0;
+ if (s_clk)
+ {
+ d318bLog("D318B: clock lock active, %d tick(s)/frame\n", s_clk);
+ }
+ }
+ if (s_clk && g_ClockTimer > 0 && g_ClockTimer != s_clk)
+ {
+ g_GlobalTimer += s_clk - g_ClockTimer;
+ g_ClockTimer = s_clk;
+ g_GlobalTimerDelta = (f32)s_clk;
+ }
+}
+
+void d318bProbeTick(void)
+{
+ s32 i;
+
+ d318bClockLock();
+ d318bInit();
+ if (!s_on || s_capped)
+ {
+ return;
+ }
+
+ for (i = 0; i < s_n; i++)
+ {
+ D318BState *st = &s_s[i];
+ ChrRecord *c;
+ Model *m;
+ struct act_attack *a;
+ struct weapon_firing_animation_table *f;
+ f32 fr, ef;
+ u32 framebits;
+ int chg, atpin, dolog, gate = 0, k;
+ char animid[8];
+
+ c = d318bFindChr(st->chr);
+ if (c == NULL || c->model == NULL)
+ {
+ continue;
+ }
+ m = c->model;
+ a = &c->act_attack;
+ f = a->animfloats;
+
+ if (c->actiontype != ACT_ATTACK && c->actiontype != ACT_ATTACKROLL)
+ {
+ /* reset change-detection so the next attack logs its entry */
+ st->act = -1;
+ continue;
+ }
+
+ fr = m->animframe1;
+ ef = m->endframe;
+ memcpy(&framebits, &fr, sizeof(framebits));
+
+ d318bIdentifyAnim(f, animid, sizeof(animid));
+
+ chg = (st->act != (int)c->actiontype) || (st->atk != a->attacktype)
+ || (st->ent != a->entityid) || (strcmp(st->animid, animid) != 0);
+
+ if (framebits == st->framebits)
+ {
+ st->stable++;
+ }
+ else
+ {
+ st->stable = 0;
+ }
+ st->hb++;
+
+ atpin = ((ef >= 0.0f) && (ef <= fr + 1e-6f));
+ dolog = s_all || chg || atpin || (st->stable >= 20) || (st->hb >= 60);
+ if (!dolog)
+ {
+ goto update;
+ }
+ if (++s_lines >= D318B_LINE_CAP)
+ {
+ s_capped = 1;
+ d318bLog("D318B: line cap (%ld) reached, probe silent\n",
+ (long) D318B_LINE_CAP);
+ return;
+ }
+
+ /* per-hand fire-loop outcome in chrlvTickAttackCommon (source
+ * order): bit0/1 = unk3a[k]==0 arm, frame in shoot window
+ * [ss,se) -> ToggleHidden(1) (gun fires) + unk44=now;
+ * bit4/5 = unk3a[k]!=0 arm, unk31 re-arm conditions met
+ * (unk31==0, unk32 turn, frame in recoil window or >=ss). */
+ for (k = 0; k < 2; k++)
+ {
+ if (a->unk38[k] == 0)
+ {
+ continue;
+ }
+ if (a->unk3a[k] == 0)
+ {
+ if (f->shoot_start_frame <= fr && fr < f->shoot_end_frame)
+ {
+ gate |= (1 << k);
+ }
+ }
+ else if (a->unk31 == 0
+ && (k == a->unk32 || a->unk3a[(s32)a->unk32] == 0)
+ && ((f->recoil_start_frame >= 0.0f && f->recoil_start_frame <= fr
+ && fr <= f->recoil_end_frame)
+ || (f->recoil_start_frame < 0.0f && f->shoot_start_frame <= fr)))
+ {
+ gate |= (0x10 << k);
+ }
+ }
+
+ d318bLog(
+ "D318B: t=%d clk=%d c%d off=%d act=%d atk=0x%x ent=%u toM=%d | v=%s "
+ "st=%.9g end=%.9g ss=%.9g se=%.9g rs=%.9g re=%.9g as=%.9g ae=%.9g | "
+ "31=%d 32=%d 33=%d 34=%d 36=%d 37=%d 38=%d/%d 3a=%d/%d 40=%u 44=%d atkT=%d | "
+ "fr=%.9g ef=%.9g fa=%d fb=%d spd=%.9g ps=%.9g nn=%.9g oo=%.9g ts=%.9g es=%.9g "
+ "b0=%.9g b4=%.9g ac=%.9g loop=%d a2=%d a2len=%u wr=%d wl=%d | "
+ "aim=%d dnt=%d efle=%d trm=%d b1=%d b2=%d b3=%d gate=%d cf1=%d\n",
+ (int)g_GlobalTimer, (int)g_ClockTimer, st->chr, (int)c->aioffset, (int)c->actiontype,
+ (unsigned)a->attacktype, (unsigned)a->entityid, (int)a->type_of_motion,
+ animid,
+ (double)f->start_frame, (double)f->end_frame,
+ (double)f->shoot_start_frame, (double)f->shoot_end_frame,
+ (double)f->recoil_start_frame, (double)f->recoil_end_frame,
+ (double)f->aim_start_frame, (double)f->aim_end_frame,
+ (int)a->unk31, (int)a->unk32, (int)a->unk33, (int)a->unk34,
+ (int)a->unk36, (int)a->unk37, (int)a->unk38[0], (int)a->unk38[1],
+ (int)a->unk3a[0], (int)a->unk3a[1], (unsigned)a->unk40,
+ (int)a->unk44, (int)a->attack_time,
+ (double)fr, (double)ef, (int)m->framea, (int)m->frameb,
+ (double)m->speed, (double)m->playspeed, (double)m->newspeed,
+ (double)m->oldspeed, (double)m->timespeed, (double)m->elapsespeed,
+ (double)m->unkb0, (double)m->unkb4, (double)m->unkac,
+ (int)m->animlooping, (int)(m->anim2 != NULL),
+ m->anim2 ? (unsigned)m->anim2->unk04 : 0u,
+ d318bAutoRate(c, GUNRIGHT), d318bAutoRate(c, GUNLEFT),
+ (int)((a->attacktype & TARGET_AIM_ONLY) != 0),
+ (int)((a->attacktype & TARGET_DONTTURN) != 0),
+ (int)((ef >= 0.0f) && (ef <= fr)),
+ (int)(a->unk36 == 0 && f->recoil_end_frame > 0.0f && fr <= f->recoil_end_frame
+ && (ef >= 0.0f) && (ef <= fr)),
+ (int)(a->unk37 != 0 || a->unk34 < a->unk33),
+ (int)(a->unk33 == a->unk34),
+ (int)(a->unk31 != 0),
+ gate,
+ (int)(a->attack_time < a->unk44 - 30 && m->anim2 == NULL
+ && f->shoot_start_frame + 10.0f < fr && fr < f->shoot_end_frame));
+
+ update:
+ st->act = (int)c->actiontype;
+ st->atk = a->attacktype;
+ st->ent = a->entityid;
+ strncpy(st->animid, animid, sizeof(st->animid) - 1);
+ st->animid[sizeof(st->animid) - 1] = 0;
+ st->framebits = framebits;
+ if (dolog)
+ {
+ st->hb = 0;
+ }
+ }
+}
+
+#endif /* PORT */
diff --git a/port/src/d318watchdog.c b/port/src/d318watchdog.c
index b704163c..7cfb7144 100644
--- a/port/src/d318watchdog.c
+++ b/port/src/d318watchdog.c
@@ -38,9 +38,12 @@
* TRUE only for ACT_STAND/ACT_ANIM/ACT_PATROL) can never pass
* while c78 is in ACT_ATTACK -> PERMANENT SOFTLOCK.
*
- * The N64 has the identical race and the identical softlock; this watchdog
- * is a deliberate port-layer accommodation (same pattern as the D202 M-66b
- * port-side expiration). It detects the exact unrecoverable signature and,
+ * N64 manifestation unverified (findings D318, 2026-09-22 correction): the
+ * user's empirical prior is that no such freeze occurs on real hardware;
+ * whether the derail window ever opens organically on N64 is open (an
+ * unthrottled-emulator deliberate-timing A/B is the discriminating test).
+ * This watchdog is a deliberate port-layer accommodation either way (same
+ * pattern as the D202 M-66b port-side expiration). It detects the exact unrecoverable signature and,
* after a long margin, re-initializes c78's attack via sub_GAME_7F025560 --
* the same entry point the game itself uses for a fresh attack (chrlvTickAttack
* type_of_motion==2 calls it with (attacktype, entityid)). That runs
@@ -74,6 +77,42 @@
* session (AllUnlocked) is still covered. Logs its detection and
* intervention for playtest audit.
*
+ * D320 generalization (2026-09-22, findings D320): the identical pin was
+ * force-reproduced headless on two more lists, and the watchdog is now
+ * table-driven over all confirmed signatures:
+ *
+ * - 0x0417 ai_22 [321,333) ent=67 c67-alive : D318 Facility execution
+ * - 0x0414 ai_19 [480,548) any-ent : D320 Facility scientist/
+ * pad kneel-aim hold (label
+ * 0x34 stop-check loop; pin
+ * observed at off=483)
+ * - 0x040a ai_9 [60,86) any-ent : D320 Control chr-0xFC aim
+ * hold (label 0x0b stop-check
+ * loop; pin observed at
+ * off=69, ~2/3 of variants)
+ *
+ * Any chr (not just c78) sitting in a listed loop range in ACT_ATTACK with
+ * a LIVE-FIRE attacktype (no TARGET_AIM_ONLY bit) for D318_DEADLOCK_TICKS
+ * consecutive ticks gets the same sub_GAME_7F025560 re-seed. Depot ai_12
+ * (0x040d) tested clean 3/3 under forced repro and is deliberately NOT
+ * covered. In every non-deadlocked state each entry is inert: the escape
+ * path leaves the loop range in < 2 s (60x under the threshold), and a
+ * re-holding guard's anim frame moves between cycles.
+ *
+ * Repeat rescues: for the D320 entries a successful re-seed plays out the
+ * attack, the stop check passes, and the beat RE-ROLLS (re-aim -> wait ->
+ * update) -- which can pin AGAIN immediately (verified: in the forced-repro
+ * contexts every re-roll re-pinned ~240 ticks after each rescue; whether a
+ * given roll pins depends on the random aim-variant and is context/RNG-
+ * stream dependent, sometimes ~100%). So there is NO fire cap: while the
+ * signature persists, the watchdog re-fires every D318W_REFIRE_TICKS (6 s,
+ * > the ~240-tick re-roll cycle; a non-pinned guard leaves the loop range
+ * well inside that). This keeps the beat alive -- the guard fires in
+ * periodic bursts instead of freezing -- until an escape roll lands, the
+ * target dies (the beats progress on their dying/dead checks), or player
+ * action breaks the state. Full re-arm only when the signature has been
+ * absent for D318_REARM_TICKS.
+ *
* Opt out (research only): GE_D318W=0
*/
#include
@@ -93,18 +132,21 @@
/* chrai.c defines these but chrai.h does not declare them. */
extern s32 chraiGetAIListID(AIRecord *AIList, bool *isGlobalAIList);
+/* src/bondaicommands.h:467 -- kept local so this port file doesn't pull in
+ * the AI-command header just for one bit constant. */
+#define D320_TARGET_AIM_ONLY 0x0020 /* "Aim at target instead of firing" */
+
/* chraction.c internal (not in chraction.h): the fresh-attack entry point.
* Signature from the definition; call form mirrors chrlvTickAttack's own
* re-init at chraction.c:7401. */
extern void sub_GAME_7F025560(ChrRecord *self, s32 attack_type, s32 arg2);
#define D318_LIST_AI22 0x0417 /* Ourumov's execution list (ai_22) */
-#define D318_OFF_LOOP_MIN 321 /* label 0x01: sleep/stop-check loop */
-#define D318_OFF_LOOP_MAX 333 /* exclusive: the headshot command */
#define D318_OURUMOV_CHR 78
#define D318_TREVELYAN_CHR 67
#define D318_DEADLOCK_TICKS 600 /* 10 s at 60 Hz; escape takes < 2 s */
#define D318_REARM_TICKS 600 /* signature-absent ticks before re-arm */
+#define D318W_REFIRE_TICKS 360 /* > the ~240-tick re-roll cycle */
static ChrRecord *d318wFindChr(s16 chrnum)
{
@@ -127,73 +169,165 @@ static ChrRecord *d318wFindChr(s16 chrnum)
return NULL;
}
+/* Table-driven pin signatures (D318 + D320). off_max is exclusive. */
+typedef struct
+{
+ u32 listid;
+ s32 off_min, off_max;
+ s32 ent; /* required act_attack.entityid, -1 = any */
+ s32 target_alive_chr; /* must exist w/ prop and be alive, -1 n/a */
+ const char *tag; /* log tag */
+} D318WEntry;
+
+#define D318W_NENTRIES 3
+static const D318WEntry s_wEntries[D318W_NENTRIES] = {
+ { 0x0417, 321, 333, D318_TREVELYAN_CHR, D318_TREVELYAN_CHR, "D318 Facility ai_22" },
+ { 0x0414, 480, 548, -1, -1, "D320 Facility ai_19" },
+ { 0x040a, 60, 86, -1, -1, "D320 Control ai_9" },
+};
+
+static bool d318wChrAlive(s32 chrnum)
+{
+ ChrRecord *c = d318wFindChr((s16)chrnum);
+
+ return c && c->prop && !chrIsDead(c);
+}
+
+/* Does this chr match the entry's pin signature? */
+static bool d318wMatches(const D318WEntry *en, ChrRecord *c)
+{
+ bool g = FALSE;
+
+ if (!c->ailist)
+ {
+ return FALSE;
+ }
+ if (chraiGetAIListID(c->ailist, &g) != (s32)en->listid)
+ {
+ return FALSE;
+ }
+ if (c->aioffset < en->off_min || c->aioffset >= en->off_max)
+ {
+ return FALSE;
+ }
+ if (c->actiontype != ACT_ATTACK)
+ {
+ return FALSE;
+ }
+ /* live-fire only: an AIM_ONLY pre-aim holding its pose is not a pin */
+ if ((s32)c->act_attack.attacktype & D320_TARGET_AIM_ONLY)
+ {
+ return FALSE;
+ }
+ if (en->ent >= 0 && (s32)c->act_attack.entityid != en->ent)
+ {
+ return FALSE;
+ }
+ return TRUE;
+}
+
void d318WatchdogTick(void)
{
- static int s_enabled = -1; /* -1 uncached, 0 off (GE_D318W=0), 1 on */
- static s32 s_run = 0; /* consecutive ticks the signature has held */
- static s32 s_absent = 0; /* consecutive ticks signature absent */
- static bool s_fired = FALSE;
- ChrRecord *c78;
- ChrRecord *c67;
- bool g = FALSE;
- bool sig;
+ static int s_enabled = -1; /* -1 uncached, 0 off (GE_D318W=0), 1 on */
+ static s32 s_run[D318W_NENTRIES]; /* sig-held ticks per entry */
+ static s32 s_absent[D318W_NENTRIES];/* sig-absent ticks per entry */
+ static s32 s_cool[D318W_NENTRIES]; /* ticks since last fire (sig up) */
+ static s32 s_fires[D318W_NENTRIES]; /* rescues this occurrence */
+ static bool s_fired[D318W_NENTRIES];
+ s32 e, i;
if (s_enabled < 0)
{
- const char *e = getenv("GE_D318W");
+ const char *ev = getenv("GE_D318W");
- s_enabled = (e && e[0] == '0') ? 0 : 1;
+ s_enabled = (ev && ev[0] == '0') ? 0 : 1;
}
if (!s_enabled)
{
return;
}
- c78 = d318wFindChr(D318_OURUMOV_CHR);
- c67 = d318wFindChr(D318_TREVELYAN_CHR);
-
- sig = (c78 && c78->ailist
- && chraiGetAIListID(c78->ailist, &g) == D318_LIST_AI22
- && c78->aioffset >= D318_OFF_LOOP_MIN && c78->aioffset < D318_OFF_LOOP_MAX
- && c78->actiontype == ACT_ATTACK
- && (s32)c78->act_attack.entityid == D318_TREVELYAN_CHR
- && c67 && c67->prop && !chrIsDead(c67));
-
- if (sig)
+ for (e = 0; e < D318W_NENTRIES; e++)
{
- s_absent = 0;
- if (!s_fired)
+ const D318WEntry *en = &s_wEntries[e];
+ ChrRecord *hit = NULL;
+ bool sig = FALSE;
+
+ for (i = 0; i < g_NumChrSlots && !hit; i++)
{
- if (s_run == 1)
+ if (d318wMatches(en, &g_ChrSlots[i]))
{
- osSyncPrintf("D318W: t=%d deadlock signature detected (c78 ai_22 off=%d "
- "ACT_ATTACK ent=67, c67 alive) -- starting %d-tick confirmation window\n",
- (int)g_GlobalTimer, (int)c78->aioffset, D318_DEADLOCK_TICKS);
- }
- s_run++;
- if (s_run >= D318_DEADLOCK_TICKS)
- {
- osSyncPrintf("D318W: t=%d D318 execution deadlock confirmed -- re-initializing "
- "c78's attack (sub_GAME_7F025560 atk=0x%x ent=%d) to break the anim pin\n",
- (int)g_GlobalTimer, (unsigned)c78->act_attack.attacktype,
- (int)c78->act_attack.entityid);
- sub_GAME_7F025560(c78, (s32)c78->act_attack.attacktype, (s32)c78->act_attack.entityid);
- s_fired = TRUE;
+ hit = &g_ChrSlots[i];
}
}
- }
- else
- {
- s_run = 0;
- /* One-shot per occurrence: once the deadlock signature has been
- * clear for a while after a fire, re-arm so a later replay of
- * Facility in the same process (AllUnlocked) is still covered. */
- if (s_fired && ++s_absent >= D318_REARM_TICKS)
+ for (i = 0; i < g_ActiveChrsCount && !hit; i++)
{
- s_fired = FALSE;
- s_absent = 0;
- osSyncPrintf("D318W: t=%d signature clear for %d ticks -- watchdog re-armed\n",
- (int)g_GlobalTimer, D318_REARM_TICKS);
+ if (d318wMatches(en, &g_ActiveChrs[i]))
+ {
+ hit = &g_ActiveChrs[i];
+ }
+ }
+
+ sig = hit && (en->target_alive_chr < 0 || d318wChrAlive(en->target_alive_chr));
+
+ if (sig)
+ {
+ s_absent[e] = 0;
+ if (!s_fired[e])
+ {
+ if (s_run[e] == 1)
+ {
+ osSyncPrintf("D318W: t=%d [%s] pin signature detected (c%d off=%d "
+ "ACT_ATTACK atk=0x%x ent=%d) -- starting %d-tick confirmation window\n",
+ (int)g_GlobalTimer, en->tag, (int)hit->chrnum,
+ (int)hit->aioffset, (unsigned)hit->act_attack.attacktype,
+ (int)hit->act_attack.entityid, D318_DEADLOCK_TICKS);
+ }
+ s_run[e]++;
+ if (s_run[e] >= D318_DEADLOCK_TICKS)
+ {
+ osSyncPrintf("D318W: t=%d [%s] anim pin confirmed -- re-initializing "
+ "c%d's attack (sub_GAME_7F025560 atk=0x%x ent=%d) to break it\n",
+ (int)g_GlobalTimer, en->tag, (int)hit->chrnum,
+ (unsigned)hit->act_attack.attacktype,
+ (int)hit->act_attack.entityid);
+ sub_GAME_7F025560(hit, (s32)hit->act_attack.attacktype,
+ (s32)hit->act_attack.entityid);
+ s_fired[e] = TRUE;
+ s_fires[e] = 1;
+ s_cool[e] = 0;
+ }
+ }
+ else if (++s_cool[e] >= D318W_REFIRE_TICKS)
+ {
+ /* The beat re-rolled and re-pinned (see header). No cap: */
+ s_cool[e] = 0;
+ osSyncPrintf("D318W: t=%d [%s] re-pin rescued (%d) -- c%d off=%d "
+ "atk=0x%x ent=%d\n",
+ (int)g_GlobalTimer, en->tag, s_fires[e] + 1,
+ (int)hit->chrnum, (int)hit->aioffset,
+ (unsigned)hit->act_attack.attacktype,
+ (int)hit->act_attack.entityid);
+ sub_GAME_7F025560(hit, (s32)hit->act_attack.attacktype,
+ (s32)hit->act_attack.entityid);
+ s_fires[e]++;
+ }
+ }
+ else
+ {
+ s_run[e] = 0;
+ s_cool[e] = 0;
+ /* Once the signature has been clear for a while after a fire,
+ * re-arm so a later replay of the level in the same process
+ * (AllUnlocked) is still covered. */
+ if (s_fired[e] && ++s_absent[e] >= D318_REARM_TICKS)
+ {
+ s_fired[e] = FALSE;
+ s_absent[e] = 0;
+ s_fires[e] = 0;
+ osSyncPrintf("D318W: t=%d [%s] signature clear for %d ticks -- watchdog re-armed\n",
+ (int)g_GlobalTimer, en->tag, D318_REARM_TICKS);
+ }
}
}
}
@@ -299,6 +433,29 @@ void d318TimelineTick(void)
return;
}
+ /* Unified chr view: real guards live in g_ChrSlots[0..g_NumChrSlots) with
+ * model != NULL; the background script entities (chrnum 0xFE, list IDs
+ * >= 0x1000) live in g_ActiveChrs. Both are ticked by chrlvAllChrTick,
+ * so both must be scanned for pins / fingerprints. (D320, 2026-09-22:
+ * the detector originally walked g_ActiveChrs only and was blind to
+ * every real guard.) */
+ static ChrRecord *s_all[96];
+ static int s_nall = 0;
+ s32 i;
+
+ s_nall = 0;
+ for (i = 0; i < g_NumChrSlots && s_nall < 96; i++)
+ {
+ if (g_ChrSlots[i].model != NULL)
+ {
+ s_all[s_nall++] = &g_ChrSlots[i];
+ }
+ }
+ for (i = 0; i < g_ActiveChrsCount && s_nall < 96; i++)
+ {
+ s_all[s_nall++] = &g_ActiveChrs[i];
+ }
+
c78 = d318wFindChr(D318_OURUMOV_CHR);
c67 = d318wFindChr(D318_TREVELYAN_CHR);
@@ -409,12 +566,11 @@ void d318TimelineTick(void)
if (++s_det >= 60)
{
s32 h = 0x811C9DC5;
- s32 i;
s_det = 0;
- for (i = 0; i < g_ActiveChrsCount; i++)
+ for (i = 0; i < s_nall; i++)
{
- ChrRecord *c = &g_ActiveChrs[i];
+ ChrRecord *c = s_all[i];
union { f32 f; u32 u; } xf, yf, zf, df;
xf.f = c->prop ? c->prop->pos.x : 0.0f;
@@ -432,7 +588,126 @@ void d318TimelineTick(void)
osSyncPrintf("D318T: DET t=%d seed=%08x%08x obj=0x%08x h=%08x nchrs=%d\n",
(int)g_GlobalTimer,
(unsigned)(u32)(g_randomSeed >> 32), (unsigned)(u32)g_randomSeed,
- (unsigned)objectiveregisters1, (unsigned)h, (int)g_ActiveChrsCount);
+ (unsigned)objectiveregisters1, (unsigned)h, s_nall);
+ }
+
+ /* D320: generic D318-class pin DETECTION for the sweep's flagged lists
+ * (Facility ai_19, Control ai_9, Depot ai_12 -- see findings D320).
+ * Detection only: it logs, it never intervenes; the recovery watchdog
+ * above stays hardcoded to ai_22 by design until a second list actually
+ * freezes in play (D320's explicit instruction).
+ *
+ * Signature: a chr in ACT_ATTACK whose attacktype is LIVE FIRE (no
+ * TARGET_AIM_ONLY bit -- it is supposed to be shooting) and whose
+ * modelGetAnimFrame is byte-stable for 600 consecutive ticks (10 s). A
+ * guard that should be firing but whose animation never advances is the
+ * exact D318 frozen shape on any list. The two legitimate long-stable
+ * states are excluded or self-clearing: monologue pre-aims keep the
+ * AIM_ONLY bit (excluded), and a working fire cycle re-inits through
+ * chrlvTickAttackCommon's "endframe <= frame" block, so its frame does
+ * not stay byte-stable for 10 s. One line at first detection, then one
+ * per further 600 stable ticks while it persists; a frozen run keeps
+ * printing, an escaped run stops after the single line (or none). */
+ {
+ typedef struct { s16 chrnum; u32 framebits; s32 stable; s32 nextlog; }
+ D320Pin;
+ static D320Pin s_pins[8];
+ static int s_npins = 0;
+ s32 i, j;
+
+ for (i = 0; i < s_nall; i++)
+ {
+ ChrRecord *c = s_all[i];
+ union { f32 f; u32 u; } fr;
+ D320Pin *p = NULL;
+
+ if (c->actiontype != ACT_ATTACK || c->model == NULL)
+ {
+ continue;
+ }
+ if ((s32)c->act_attack.attacktype & D320_TARGET_AIM_ONLY)
+ {
+ continue; /* legitimate hold pose (monologue pre-aim) */
+ }
+
+ fr.f = modelGetAnimFrame(c->model);
+ for (j = 0; j < s_npins; j++)
+ {
+ if (s_pins[j].chrnum == c->chrnum)
+ {
+ p = &s_pins[j];
+ break;
+ }
+ }
+ if (!p)
+ {
+ if (s_npins < 8)
+ {
+ p = &s_pins[s_npins++];
+ }
+ else
+ {
+ continue; /* table full: drop the newest, keep tracking */
+ }
+ p->chrnum = c->chrnum;
+ p->framebits = fr.u;
+ p->stable = 0;
+ p->nextlog = 600;
+ }
+
+ if (p->framebits == fr.u)
+ {
+ p->stable++;
+ }
+ else
+ {
+ p->framebits = fr.u;
+ p->stable = 0;
+ p->nextlog = 600;
+ }
+
+ if (p->stable >= p->nextlog)
+ {
+ bool g = FALSE;
+ s32 aid = c->ailist ? chraiGetAIListID(c->ailist, &g) : -1;
+
+ osSyncPrintf("D320T: t=%d PIN? c%d aiid=0x%04x%s off=%d atk=0x%x ent=%d "
+ "mot=%d unk54=%u frame=%.2f stable=%d ticks (D318-class pin candidate)\n",
+ (int)g_GlobalTimer, (int)c->chrnum,
+ (unsigned)aid, g ? "G" : "", (int)c->aioffset,
+ (unsigned)c->act_attack.attacktype,
+ (int)c->act_attack.entityid,
+ (s32)c->act_attack.type_of_motion,
+ (unsigned)c->act_attack.unk54,
+ (double)fr.f, p->stable);
+ p->nextlog = p->stable + 600;
+ }
+ }
+
+ /* Drop entries for chrs that left the tracked state so a later
+ * re-entry starts a fresh confirmation window. */
+ for (i = 0; i < s_npins; i++)
+ {
+ bool alive = FALSE;
+
+ for (j = 0; j < s_nall; j++)
+ {
+ ChrRecord *c = s_all[j];
+
+ if (c->chrnum == s_pins[i].chrnum && c->actiontype == ACT_ATTACK
+ && c->model != NULL
+ && !((s32)c->act_attack.attacktype & D320_TARGET_AIM_ONLY))
+ {
+ alive = TRUE;
+ break;
+ }
+ }
+ if (!alive)
+ {
+ s_pins[i] = s_pins[--s_npins];
+ i--;
+ }
+ }
}
}
diff --git a/port/src/d320repro.c b/port/src/d320repro.c
new file mode 100644
index 00000000..57a78a66
--- /dev/null
+++ b/port/src/d320repro.c
@@ -0,0 +1,480 @@
+/* d320repro.c -- D320: forced-repro harness for the three high-risk
+ * D318-class aim-hold softlock lists (Facility ai_19, Control ai_9,
+ * Depot ai_12; findings D320).
+ *
+ * These beats are scripted cutscene moments that do not play in a bare
+ * headless boot (the scene is static with no input), so -- exactly like
+ * D318's mode-5 repro -- the probe forces them: at chosen ticks it can
+ * dump the chr roster, hijack a guard's AI pointer to a loaded list at a
+ * given byte offset, and/or set objective-register bits. The in-tree
+ * D320T pin detector (d318watchdog.c, GE_D318T=1) then reports any
+ * live-fire attack whose anim frame stays byte-stable for 600 ticks --
+ * the exact frozen shape.
+ *
+ * TEMP: investigation probe, remove when D320 concludes. Inert without
+ * the env var (zero cost).
+ *
+ * Usage: GE_D320R="tick:action;tick:action;..." where action is one of
+ * roster -- dump every active chr (num, act,
+ * list id, offset, alive)
+ * jump=,list=,off= -- set the chr's ailist to
+ * ailistFindById(hexid) at byte
+ * offset n, sleep=0
+ * bit= -- objectiveregisters1 |= hex
+ * rename=, -- reassign a chr's chrnum (used to
+ * materialize an absent target, e.g.
+ * Control ai_9 aims at 0xFC which is
+ * not spawned in a bare boot)
+ * seed=, -- overwrite g_randomSeed (the boot
+ * seed is deterministic, so vary this
+ * between runs to sample different
+ * aim-variant branches)
+ * explode=, -- D252: explosionCreate()
+ * units in front of Bond (headless
+ * explosion-particle capture)
+ * cheat=gl|rl|inv| -- D252: cheatButtonHandleCheatsTurnedOn
+ * trace= -- from this tick on, log the chr's
+ * off/act/atk/ent/frame/endframe on
+ * any change + a 120-tick heartbeat
+ * while it is in ACT_ATTACK
+ *
+ * Example (Facility ai_19 hold block):
+ * GE_D320R="60:roster;900:jump=5,list=0x0414,off=341;900:trace=5"
+ */
+#include
+#include
+
+#include "PR/os.h"
+#include "bondtypes.h"
+#include "chr.h"
+#include "chrai.h"
+#include "chraction.h"
+#include "lv.h"
+#include "model.h"
+#include "explosion.h"
+#include "player.h"
+#include "cheat.h"
+extern void cheatButtonHandleCheatsTurnedOn(CHEAT_ID cheat_id);
+
+#ifdef PORT
+
+/* chrai.c defines these but chrai.h does not declare them. */
+extern s32 chraiGetAIListID(AIRecord *AIList, bool *isGlobalAIList);
+
+/* port/src/random.c; not declared in a shared header. */
+extern u64 g_randomSeed;
+
+#define D320R_MAX_ACTIONS 16
+#define D320R_MAX_TRACES 4
+
+typedef struct
+{
+ s32 tick;
+ int kind; /* 0 roster, 1 jump, 2 bit, 3 trace */
+ s16 chr;
+ s32 listid;
+ u16 off;
+ u32 bit; /* also: new chrnum for rename */
+ int fired;
+} D320RAction;
+
+typedef struct
+{
+ s16 chr;
+ s32 off;
+ s32 act;
+ s32 atk;
+ s32 ent;
+ u32 framebits;
+ s32 hb;
+} D320RTrace;
+
+static ChrRecord *d320rFindChr(s16 chrnum)
+{
+ s32 i;
+
+ for (i = 0; i model == NULL)
+ {
+ continue;
+ }
+ aid = c->ailist ? chraiGetAIListID(c->ailist, &g) : -1;
+ osSyncPrintf("D320R: S c%d act=%d aiid=0x%04x%s off=%d atk=0x%x ent=%d %s\n",
+ (int)c->chrnum, (int)c->actiontype, (unsigned)aid,
+ g ? "G" : "", (int)c->aioffset,
+ (unsigned)c->act_attack.attacktype,
+ (int)c->act_attack.entityid,
+ chrIsDead(c) ? "DEAD" : "");
+ }
+ for (i = 0; i < g_ActiveChrsCount; i++)
+ {
+ ChrRecord *c = &g_ActiveChrs[i];
+ bool g = FALSE;
+ s32 aid = c->ailist ? chraiGetAIListID(c->ailist, &g) : -1;
+
+ osSyncPrintf("D320R: B c%d act=%d aiid=0x%04x%s off=%d\n",
+ (int)c->chrnum, (int)c->actiontype, (unsigned)aid,
+ g ? "G" : "", (int)c->aioffset);
+ }
+}
+
+void d320ReproTick(void)
+{
+ static int s_on = -1; /* -1 uncached, 0 off, 1 on */
+ static D320RAction s_a[D320R_MAX_ACTIONS];
+ static int s_na = 0;
+ static D320RTrace s_tr[D320R_MAX_TRACES];
+ static int s_nt = 0;
+ s32 i;
+
+ if (s_on < 0)
+ {
+ const char *e = getenv("GE_D320R");
+
+ s_on = (e && e[0]) ? 1 : 0;
+ if (s_on)
+ {
+ /* Parse "tick:action;..." -- one pass, fixed budget. */
+ char buf[512];
+ char *p, *save;
+
+ strncpy(buf, e, sizeof(buf) - 1);
+ buf[sizeof(buf) - 1] = 0;
+ p = buf;
+ while (p && *p && s_na < D320R_MAX_ACTIONS)
+ {
+ char *semi = strchr(p, ';');
+ char *colon;
+ D320RAction *a = &s_a[s_na];
+
+ if (semi)
+ {
+ *semi = 0;
+ }
+ colon = strchr(p, ':');
+ if (!colon)
+ {
+ break;
+ }
+ *colon = 0;
+ a->tick = (s32)strtol(p, NULL, 10);
+ a->kind = -1;
+ a->chr = -1;
+ a->listid = 0;
+ a->off = 0;
+ a->bit = 0;
+ a->fired = 0;
+
+ if (strcmp(colon + 1, "roster") == 0)
+ {
+ a->kind = 0;
+ }
+ else if (strncmp(colon + 1, "jump=", 5) == 0)
+ {
+ const char *q = colon + 6;
+
+ /* documented form "jump=,list=..": a leading bare
+ * number (no '=' before the first ',') is the chr */
+ {
+ char *eq0 = strchr(q, '=');
+ char *cm0 = strchr(q, ',');
+
+ if (cm0 && (!eq0 || cm0 < eq0))
+ {
+ *cm0 = 0;
+ a->chr = (s16)strtol(q, NULL, 0);
+ q = cm0 + 1;
+ }
+ }
+
+ while (q && *q)
+ {
+ char *eq = strchr(q, '=');
+ char *cm = strchr(q, ',');
+ const char *key;
+
+ if (!eq)
+ {
+ break;
+ }
+ if (cm && cm < eq)
+ {
+ break; /* malformed */
+ }
+ *eq = 0;
+ key = q; /* const-cast: parsed in place below */
+ if (strcmp(key, "chr") == 0) { a->chr = (s16)strtol(eq + 1, NULL, 0); }
+ else if (strcmp(key, "list") == 0){ a->listid = (s32)strtol(eq + 1, NULL, 0); }
+ else if (strcmp(key, "off") == 0) { a->off = (u16)strtol(eq + 1, NULL, 0); }
+ q = cm ? cm + 1 : NULL;
+ }
+ if (a->chr >= 0 && a->listid)
+ {
+ a->kind = 1;
+ }
+ }
+ else if (strncmp(colon + 1, "bit=", 4) == 0)
+ {
+ a->bit = (u32)strtol(colon + 5, NULL, 0);
+ a->kind = 2;
+ }
+ else if (strncmp(colon + 1, "trace=", 6) == 0)
+ {
+ a->chr = (s16)strtol(colon + 7, NULL, 0);
+ a->kind = 3;
+ }
+ else if (strncmp(colon + 1, "rename=", 7) == 0)
+ {
+ const char *q = colon + 8;
+ char *cm = strchr(q, ',');
+
+ if (cm)
+ {
+ *cm = 0;
+ a->chr = (s16)strtol(q, NULL, 0);
+ a->bit = (u32)strtol(cm + 1, NULL, 0);
+ a->kind = 4;
+ }
+ }
+ else if (strncmp(colon + 1, "cheat=", 6) == 0)
+ {
+ const char *q = colon + 7;
+
+ a->listid = (strcmp(q, "gl") == 0) ? CHEAT_2X_GRENADE_LAUNCHER
+ : (strcmp(q, "rl") == 0) ? CHEAT_2X_ROCKET_LAUNCHER
+ : (strcmp(q, "inv") == 0) ? CHEAT_INVINCIBILITY
+ : (s32)strtol(q, NULL, 0);
+ a->kind = 7;
+ }
+ else if (strncmp(colon + 1, "explode=", 8) == 0)
+ {
+ const char *q = colon + 9;
+ char *cm = strchr(q, ',');
+
+ a->listid = (s32)strtol(q, NULL, 0); /* type */
+ a->bit = cm ? (u32)strtol(cm + 1, NULL, 0) : 400; /* dist */
+ a->kind = 6;
+ }
+ else if (strncmp(colon + 1, "seed=", 5) == 0)
+ {
+ const char *q = colon + 6;
+ char *cm = strchr(q, ',');
+
+ if (cm)
+ {
+ *cm = 0;
+ a->listid = (s32)(u32)strtol(q, NULL, 0); /* hi */
+ a->bit = (u32)strtol(cm + 1, NULL, 0); /* lo */
+ a->kind = 5;
+ }
+ }
+ if (a->kind >= 0)
+ {
+ s_na++;
+ }
+ p = semi ? semi + 1 : NULL;
+ }
+ osSyncPrintf("D320R: armed %d actions\n", s_na);
+ }
+ }
+ if (!s_on)
+ {
+ return;
+ }
+
+ for (i = 0; i < s_na; i++)
+ {
+ D320RAction *a = &s_a[i];
+
+ if (a->fired || (s32)g_GlobalTimer < a->tick)
+ {
+ continue;
+ }
+ a->fired = 1;
+
+ switch (a->kind)
+ {
+ case 0: /* roster */
+ d320rDumpRoster();
+ break;
+ case 1: /* jump */
+ {
+ ChrRecord *c = d320rFindChr(a->chr);
+
+ if (c)
+ {
+ AIRecord *list = ailistFindById(a->listid);
+
+ if (list)
+ {
+ c->ailist = list;
+ c->aioffset = a->off;
+ c->sleep = 0;
+ osSyncPrintf("D320R: t=%d JUMP c%d -> aiid=0x%04x off=%d\n",
+ (int)g_GlobalTimer, (int)c->chrnum,
+ (unsigned)a->listid, (int)a->off);
+ }
+ else
+ {
+ osSyncPrintf("D320R: t=%d JUMP FAILED c%d aiid=0x%04x not found\n",
+ (int)g_GlobalTimer, (int)c->chrnum,
+ (unsigned)a->listid);
+ }
+ }
+ else
+ {
+ osSyncPrintf("D320R: t=%d JUMP FAILED c%d not active\n",
+ (int)g_GlobalTimer, (int)a->chr);
+ }
+ break;
+ }
+ case 7: /* cheat (D252) */
+ cheatButtonHandleCheatsTurnedOn((CHEAT_ID)a->listid);
+ osSyncPrintf("D320R: t=%d CHEAT %d\n",(int)g_GlobalTimer, (int)a->listid);
+ break;
+ case 6: /* explode (D252) */
+ if (g_CurrentPlayer && g_CurrentPlayer->prop)
+ {
+ struct coord3d pos = g_CurrentPlayer->prop->pos;
+
+ pos.f[0] += -g_CurrentPlayer->vv_sintheta * (f32)a->bit;
+ pos.f[2] += g_CurrentPlayer->vv_costheta * (f32)a->bit;
+ explosionCreate(0, &pos, g_CurrentPlayer->prop->stan, (s16)a->listid,
+ 0, 0, g_CurrentPlayer->prop->rooms, 0);
+ osSyncPrintf("D320R: t=%d EXPLODE type=%d dist=%u\n",
+ (int)g_GlobalTimer, (int)a->listid, (unsigned)a->bit);
+ }
+ break;
+ case 2: /* bit */
+ objectiveregisters1 |= a->bit;
+ osSyncPrintf("D320R: t=%d BIT |= 0x%08x (now 0x%08x)\n",
+ (int)g_GlobalTimer, (unsigned)a->bit,
+ (unsigned)objectiveregisters1);
+ break;
+ case 3: /* trace */
+ if (s_nt < D320R_MAX_TRACES)
+ {
+ s_tr[s_nt].chr = a->chr;
+ s_tr[s_nt].off = -1;
+ s_tr[s_nt].act = -1;
+ s_tr[s_nt].atk = -1;
+ s_tr[s_nt].ent = -1;
+ s_tr[s_nt].framebits = 0;
+ s_tr[s_nt].hb = 0;
+ s_nt++;
+ osSyncPrintf("D320R: t=%d TRACE c%d armed\n",
+ (int)g_GlobalTimer, (int)a->chr);
+ }
+ break;
+ case 4: /* rename */
+ {
+ ChrRecord *c = d320rFindChr(a->chr);
+
+ if (c)
+ {
+ osSyncPrintf("D320R: t=%d RENAME c%d -> c%d\n",
+ (int)g_GlobalTimer, (int)c->chrnum,
+ (int)a->bit);
+ c->chrnum = (s16)a->bit;
+ }
+ else
+ {
+ osSyncPrintf("D320R: t=%d RENAME FAILED c%d not found\n",
+ (int)g_GlobalTimer, (int)a->chr);
+ }
+ break;
+ }
+ case 5: /* seed */
+ g_randomSeed = ((u64)(u32)a->listid << 32) | (u32)a->bit;
+ osSyncPrintf("D320R: t=%d SEED := %08x%08x\n",
+ (int)g_GlobalTimer, (unsigned)(u32)(g_randomSeed >> 32),
+ (unsigned)(u32)g_randomSeed);
+ break;
+ default:
+ break;
+ }
+ }
+
+ for (i = 0; i < s_nt; i++)
+ {
+ D320RTrace *t = &s_tr[i];
+ ChrRecord *c = d320rFindChr(t->chr);
+ u32 fb = 0;
+ f32 fr = 0.0f, ef = -1.0f;
+
+ if (!c)
+ {
+ continue;
+ }
+ if (c->model)
+ {
+ union { f32 f; u32 u; } x;
+
+ x.f = modelGetAnimFrame(c->model);
+ fb = x.u;
+ fr = x.f;
+ ef = modelGetAnimEndFrame(c->model);
+ }
+
+ if (t->off != (s32)c->aioffset || t->act != (s32)c->actiontype
+ || t->atk != (s32)c->act_attack.attacktype
+ || t->ent != (s32)c->act_attack.entityid)
+ {
+ osSyncPrintf("D320R: t=%d c%d off=%d act=%d atk=0x%x ent=%d frame=%.2f endf=%.2f\n",
+ (int)g_GlobalTimer, (int)c->chrnum, (int)c->aioffset,
+ (int)c->actiontype, (unsigned)c->act_attack.attacktype,
+ (int)c->act_attack.entityid, (double)fr, (double)ef);
+ t->off = (s32)c->aioffset;
+ t->act = (s32)c->actiontype;
+ t->atk = (s32)c->act_attack.attacktype;
+ t->ent = (s32)c->act_attack.entityid;
+ }
+ else if (c->actiontype == ACT_ATTACK && fb != t->framebits)
+ {
+ /* frame is moving: note the range so a later stall is visible */
+ osSyncPrintf("D320R: t=%d c%d frame MOVED to %.2f (endf=%.2f off=%d)\n",
+ (int)g_GlobalTimer, (int)c->chrnum, (double)fr,
+ (double)ef, (int)c->aioffset);
+ }
+ t->framebits = fb;
+
+ if (c->actiontype == ACT_ATTACK && ++t->hb >= 120)
+ {
+ t->hb = 0;
+ osSyncPrintf("D320R: t=%d c%d hb off=%d act=%d atk=0x%x ent=%d frame=%.2f endf=%.2f\n",
+ (int)g_GlobalTimer, (int)c->chrnum, (int)c->aioffset,
+ (int)c->actiontype, (unsigned)c->act_attack.attacktype,
+ (int)c->act_attack.entityid, (double)fr, (double)ef);
+ }
+ }
+}
+
+#endif /* PORT */
diff --git a/port/src/dram.c b/port/src/dram.c
index fef2b6cd..f0a5f4f4 100644
--- a/port/src/dram.c
+++ b/port/src/dram.c
@@ -71,7 +71,7 @@ void *dramReserve(void)
sa.bInheritHandle = FALSE;
HANDLE hSec = CreateFileMappingW(INVALID_HANDLE_VALUE, &sa, PAGE_READWRITE,
- (DWORD)(DRAM_SIZE >> 32),
+ (DWORD)((unsigned long long)DRAM_SIZE >> 32),
(DWORD)(DRAM_SIZE & 0xFFFFFFFF), NULL);
if (!hSec) {
sysFatalError("dram: CreateFileMapping failed (%lu)", GetLastError());
diff --git a/port/src/frontoptions.c b/port/src/frontoptions.c
new file mode 100644
index 00000000..7cfc9e0f
--- /dev/null
+++ b/port/src/frontoptions.c
@@ -0,0 +1,844 @@
+/*
+ * frontoptions.c -- D343: PC options as a GE front-end screen.
+ *
+ * GE's front end has no generic menu system, so PC settings get their own
+ * screen, MENU_PC_OPTIONS (route B, docs/dev/OPTIONS-MENU-PLAN.md), built the
+ * way GE builds its Cheat Options screen (front.c init/interface/constructor_
+ * menu15_cheat): the open dossier with tabs and a blank CLASSIFIED page, black
+ * Zurich text on the paper via frontPrintText, GE's translucent highlight
+ * boxes, the PREVIOUS tab as "back", and the game's crosshair.
+ *
+ * page 0: five functional categories; page 1: a category's settings;
+ * deeper pages: Input -> Bindings -> Movement/Actions and Gameplay -> HUD
+ *
+ * The sections and rows are the F10 overlay's row table, reached through the
+ * row API in optionsoverlay.c, so both UIs edit the same settings.
+ *
+ * Game-code surface (Rule 2, user sign-off 2026-09-27): the MENU_PC_OPTIONS
+ * enum value (bondconstants.h) and five one-line dispatch cases (front.c),
+ * all #ifdef PORT. Entry is the "PC Options" label on file select, drawn by
+ * the existing D343 hook in constructor_menu05_fileselect.
+ *
+ * Everything here runs on the game's main thread (the front-end tick and
+ * constructor), like GE's own screens.
+ */
+
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include "front.h"
+#include "textrelated.h"
+#include "language.h"
+
+#include "platform.h"
+#include "system.h"
+#include "config.h"
+#include "envflag.h"
+#include "optionsoverlay.h"
+#include "watchsettings.h"
+#include "frontoptions.h"
+#include "input.h"
+
+/* front.c functions this screen shares with the cheat screen (not all are in
+ * front.h). */
+extern Gfx *frontSetupMenuBackground(Gfx *DL);
+extern Gfx *frontAddPreviousTabText(Gfx *DL);
+extern s32 frontCheckCursorOnPreviousTab(void);
+extern Gfx *frontDrawCursor(Gfx *DL);
+extern Gfx *frontPrintText(Gfx *gdl, s32 *x, s32 *y, s8 *text, s32 second_font_table,
+ s32 first_font_table, s32 arg6, s32 view_x, s32 view_y,
+ s32 arg9, s32 arga);
+extern void load_walletbond(void);
+extern void disable_all_switches(Model *arg0); /* front.c:967 */
+extern void set_item_visibility_in_objinstance(Model *objinstance, s32 item, s32 mode); /* front.c:968 */
+extern s32 folder_selection_screen_option_icon;
+extern struct rectbbox folder_option_ERASE_bound; /* front.c:439 */
+
+/* ---- colours on the paper (RRGGBBAA; 0xFF = opaque black, GE's ink) ---- */
+#define INK 0x000000FFu
+#define INK_ON 0xA00000FFu /* the cheat screen's ON red */
+#define INK_DIM 0x00000080u
+#define HILITE 0x32 /* GE's menu highlight box (black, alpha 0x32) */
+
+/* ---- layout, in the front end's 440x330 canvas (paper; tabs start at 390) ---- */
+#define TITLE_Y 0x2B
+#define ROW_X 0x37 /* the cheat list's x */
+#define ROW_Y0 0x41 /* cheat list starts 0x35; +12 for the title line */
+/* D346b: 20 -> 18 px so a full section fits the paper on one page. Hit bands
+ * stay contiguous at any pitch >= 6; the 22px highlight box still clears the
+ * next row (y+14 < y+17). D353: the largest section is now GRAPHICS at 11
+ * rows (both auto-FOV toggles off); MAX_PROWS=15 keeps headroom, and
+ * buildPages() logs any future overflow instead of silently truncating. */
+#define ROW_DY 18
+#define NUM_X 0x37 /* "1." on the section list */
+#define SEC_X 0x4B
+#define VAL_R 372 /* values right-aligned here */
+#define BAR_X0 232
+#define BAR_X1 316
+#define ROW_HIT_X0 40.0f
+#define ROW_HIT_X1 385.0f
+
+/* ---- file-select label: right end of the Select / Copy / Erase bar ---- */
+#define LABEL_X 351 /* D400: floor = NTSC Erase right (~323) + LABEL_GAP; JP's wider Erase pushes past it */
+#define LABEL_CY 285 /* the bar's centre line, as Copy/Erase */
+#define LABEL_GAP 28 /* D400: text-only now (icon removed), a plain word gap after Erase */
+#define HIT_PAD 4
+
+#define MAX_PAGES 8
+#define MAX_PROWS 15 /* D346b: 12 -> 15 for the merged Video section (see ROW_DY) */
+/* D406: content rows per page. The 440x330 paper ends at y=330; content row k
+ * sits at y = ROW_Y0 + (k+2)*ROW_DY (the D356 profile row takes slot 1), so a
+ * page fits while ROW_Y0 + (CAP+2)*ROW_DY + ~10px of text stays <= 330 (the
+ * bottom line is reserved for the page hint): CAP = 11 at ROW_DY 18. D346b's 18px pitch was sized for 15 rows with no
+ * profile row; D356's profile row plus the v0.4.0 Wave-A INPUT rows (17)
+ * pushed the 14th row to y=335 -- off the bottom of the paper -- and rows
+ * 15-17 (Crouch mode, Reset to defaults, Bindings...) were dropped by the
+ * old silent page cap, unreachable from the front screen. Sections that
+ * outgrow one page now page: the vertical stepper crosses page boundaries
+ * and the title carries a (p/N) marker. The F10 overlay is unaffected
+ * (it scrolls its own window, D345-D347). */
+#define ROWS_PER_PAGE 11
+
+static const char kLabel[] = "PC Options"; /* ASCII only: issue #87 / D295 */
+static const char kLabelNL[] = "PC Options\n"; /* height measure only, D400 */
+
+/* ---- screen state (game thread) ---- */
+static int s_level = 0; /* 0 = categories, 1 = category, 2/3 = nested pages */
+static int s_page = 0; /* current root category */
+static int s_pageno = 0; /* D406: page within the active section */
+static int s_rowTotal = 0; /* D406: visible rows in the active section */
+static int s_subHeader = -1; /* rows[] header index for the nested page */
+static int s_hl = -1; /* highlighted row / section, -1 = none */
+static int s_dragRow = -1; /* global row index being dragged */
+static int s_repeatDir = 0, s_repeatTimer = 0;
+/* D345(e): hold-to-repeat state for vertical row stepping (separate from
+ * the left/right value-adjust repeat; both can be held at once). */
+static int s_vrepeatDir = 0, s_vrepeatTimer = 0;
+
+static int s_pageHdr[MAX_PAGES];
+static int s_pageN = 0;
+static int s_rowIdx[MAX_PROWS];
+static int s_rowN = 0;
+#define MAX_SECT_ROWS 48 /* D406: per-section gather buffer (INPUT = 17) */
+
+/* ------------------------------------------------------------------------ */
+
+static void playSfx(s16 id)
+{
+ sndPlaySfx((struct ALBankAlt_s *)g_musicSfxBufferPtr, id, NULL);
+}
+
+static s32 measureW(const char *str)
+{
+ s32 h = 0, w = 0;
+ textMeasure(&h, &w, (char *)str, ptrFontZurichBoldChars, ptrFontZurichBold, 0);
+ return w;
+}
+
+static Gfx *ink(Gfx *DL, s32 x, s32 y, const char *str, u32 colour)
+{
+ return frontPrintText(DL, &x, &y, (s8 *)str, (s32)(uintptr_t)ptrFontZurichBoldChars,
+ (s32)(uintptr_t)ptrFontZurichBold, (s32)colour,
+ viGetX(), viGetY(), 0, 0);
+}
+
+static Gfx *inkR(Gfx *DL, s32 xr, s32 y, const char *str, u32 colour)
+{
+ return ink(DL, xr - measureW(str), y, str, colour);
+}
+
+/* D406b: per-row slider-bar origin. Long labels ("X axis look sensitivity
+ * (controller)") used to run into the fixed 232px bar and overlap the track;
+ * the bar now starts 10px past the label. Rows whose labels leave less than
+ * 12px of track show the value only. */
+static int rowBarX0(const char *label)
+{
+ int x0 = ROW_X + measureW(label) + 10;
+ return x0 < BAR_X0 ? BAR_X0 : x0;
+}
+
+/* "MOUSE / AIM" -> "Mouse / Aim" (GE's menus use title case). */
+static void titleCase(const char *in, char *out, int n)
+{
+ int j = 0, start = 1;
+ for (int i = 0; in[i] && j < n - 1; i++) {
+ char c = in[i];
+ if (c >= 'A' && c <= 'Z' && !start) c = (char)(c - 'A' + 'a');
+ if (c >= 'a' && c <= 'z' && start) c = (char)(c - 'a' + 'A');
+ start = (c == ' ' || c == '/');
+ out[j++] = c;
+ }
+ out[j] = '\0';
+}
+
+static int rowY(int k)
+{
+ /* Every open section, including a nested section, has the profile row. */
+ if (s_level >= 1) k++;
+ return ROW_Y0 + k * ROW_DY;
+}
+
+static int activeHeader(void)
+{
+ return s_level >= 2 ? s_subHeader : s_pageHdr[s_page];
+}
+
+static void buildPages(void)
+{
+ int n = optionsRowCount();
+ s_pageN = 0;
+ for (int i = 0; i < n && s_pageN < MAX_PAGES; i++) {
+ if (optionsRowIsHeader(i) && optionsRowHeaderParent(i) < 0)
+ s_pageHdr[s_pageN++] = i;
+ }
+ if (s_page >= s_pageN) {
+ s_page = 0;
+ }
+ s_rowN = 0;
+ s_rowTotal = 0;
+ if (s_pageN > 0) {
+ /* D406: gather every visible row of the active section, then page it
+ * (ROWS_PER_PAGE fits the paper, see above). The old code capped at
+ * MAX_PROWS-1 and silently dropped the rest -- a warning, no access.
+ * s_pageno is re-clamped on every build so rows that auto-hide
+ * (aim range, auto-FOV) cannot strand the cursor past the end. */
+ int tmp[MAX_SECT_ROWS];
+ int tn = 0;
+ for (int i = activeHeader() + 1; i < n && !optionsRowIsHeader(i); i++) {
+ if (!optionsRowIsShown(i)) continue;
+ if (tn < MAX_SECT_ROWS) tmp[tn++] = i;
+ }
+ s_rowTotal = tn;
+ int pages = (tn + ROWS_PER_PAGE - 1) / ROWS_PER_PAGE;
+ if (pages < 1) pages = 1;
+ if (s_pageno > pages - 1) s_pageno = pages - 1;
+ int off = s_pageno * ROWS_PER_PAGE;
+ for (int k = 0; k < ROWS_PER_PAGE && off + k < tn; k++)
+ s_rowIdx[s_rowN++] = tmp[off + k];
+ }
+}
+
+static int itemCount(void)
+{
+ /* D356: level 1 carries the top save-file row (item 0, not in rows[]) on
+ * top of the page's content rows. */
+ return s_level == 0 ? s_pageN : s_rowN + 1;
+}
+
+/* Put the crosshair on item k, unconditionally. */
+static void cursorToItemRaw(int k)
+{
+ /* D345(g): snap into the left gutter (x=44), not onto the row text
+ * (the old ROW_X+20 centred the sprite over the label/highlight box).
+ * 44 stays inside [ROW_HIT_X0..1] so the highlight hit-test still
+ * resolves row k, and clears the number column (NUM_X=55) / highlight
+ * boxes (from x=53/73). The PREVIOUS-tab hitbox (x>390 && y>223) is
+ * far away. Mouse users are unaffected: the pointer sits under the
+ * real mouse, never at this snap position. */
+ cursor_h_pos = 44.0f;
+ cursor_v_pos = (f32)(rowY(k) + 6);
+}
+
+/* Put the crosshair on item k (entering a page / going back). */
+static void cursorToItem(int k)
+{
+ /* D345(b): while the 1:1 pointer owns the crosshair (mouse recently
+ * used), it is already under the OS pointer -- snapping would yank it
+ * to the top-left item and it would stay there until the next mouse
+ * movement. Keyboard/D-pad users (stale mouse) keep the snap. */
+ if (inputMenuPointerLive()) return;
+ cursorToItemRaw(k);
+}
+
+/* ------------------------------------------------------------------------ */
+/* MENU_PC_OPTIONS: init / update / interface / constructor (front.c cases) */
+
+void frontOptionsMenuInit(void)
+{
+ /* As init_menu15_cheat: reset the tab state and make sure the dossier
+ * model is loaded (the background draws walletinst[0]). */
+ tab_start_selected = FALSE;
+ tab_next_selected = FALSE;
+ tab_prev_selected = FALSE;
+ tab_prev_highlight = FALSE;
+ tab_next_highlight = FALSE;
+ tab_start_highlight = FALSE;
+ load_walletbond();
+
+ /* frontSetupMenuBackground frames the folder at folderpositions[
+ * selected_folder_num]; file select leaves it at -1. Use folder 1, as a
+ * return from mode select would (file select's init does the same). */
+ if (selected_folder_num < FOLDER1 || selected_folder_num >= MAX_FOLDER_COUNT) {
+ selected_folder_num = FOLDER1;
+ }
+ folder_selection_screen_option_icon = 0; /* crosshair, not Copy/Erase */
+
+ s_level = 0;
+ s_page = 0;
+ s_pageno = 0; /* D406 */
+ s_subHeader = -1;
+ s_hl = -1;
+ s_dragRow = -1;
+ s_repeatDir = 0;
+ s_vrepeatDir = 0;
+ buildPages();
+ /* Isolated headless screenshot of a nested page. This diagnostic only
+ * changes menu navigation; it never edits profile or config values. */
+ const char *testPage = getenv("GE_FRONTOPTIONS_SECTION");
+ if (testPage) {
+ for (int i = 0; i < optionsRowCount(); i++) {
+ if (!optionsRowIsHeader(i) || strcmp(optionsRowLabel(i), testPage)) continue;
+ int root = i, depth = 1;
+ while (optionsRowHeaderParent(root) >= 0) {
+ root = optionsRowHeaderParent(root);
+ depth++;
+ }
+ for (int p = 0; p < s_pageN; p++) {
+ if (s_pageHdr[p] == root) {
+ s_page = p;
+ s_pageno = 0;
+ s_level = depth;
+ s_subHeader = depth > 1 ? i : -1;
+ buildPages();
+ sysLogPrintf(LOG_INFO, "frontoptions: diagnostic page %s (depth %d, %d rows)",
+ testPage, depth, s_rowN);
+ break;
+ }
+ }
+ break;
+ }
+ }
+ cursorToItem(0);
+ sysLogPrintf(LOG_INFO, "frontoptions: opened");
+}
+
+void frontOptionsMenuUpdate(void)
+{
+}
+
+static void goBack(void)
+{
+ playSfx(DOOR_METAL_CLOSE2_SFX);
+ if (s_dragRow >= 0) optionsRowCommit(s_dragRow);
+ s_dragRow = -1;
+ optionsResetClear(); /* D356: navigating away (or closing) disarms a pending reset */
+ if (s_level >= 2) {
+ int child = s_subHeader;
+ int parent = optionsRowHeaderParent(child);
+ s_pageno = 0; /* D406: every fresh page view starts at page 1 */
+ if (parent == s_pageHdr[s_page]) {
+ s_level = 1;
+ s_subHeader = -1;
+ } else {
+ s_level--;
+ s_subHeader = parent;
+ }
+ buildPages();
+ int selected = 0;
+ for (int k = 0; k < s_rowN; k++)
+ if (optionsRowChildHeader(s_rowIdx[k]) == child) selected = k + 1;
+ cursorToItem(selected);
+ s_hl = -1;
+ sysLogPrintf(LOG_INFO, "frontoptions: back to %s", optionsRowLabel(activeHeader()));
+ return;
+ }
+ if (s_level == 1) {
+ s_level = 0;
+ s_pageno = 0; /* D406 */
+ cursorToItem(s_page);
+ sysLogPrintf(LOG_INFO, "frontoptions: back to section list");
+ return;
+ }
+ configSave();
+ sysLogPrintf(LOG_INFO, "frontoptions: closed");
+ frontChangeMenu(MENU_FILE_SELECT, FALSE);
+}
+
+void frontOptionsMenuInterface(void)
+{
+ viSetFovY(FOV_Y_F);
+ viSetAspect(ASPECT_RATIO_SD);
+ viSetZRange(100.0f, 10000.0f);
+ viSetUseZBuf(0);
+
+ buildPages(); /* an auto toggle may have hidden or shown % rows */
+ /* D383: modal key capture owns events on the host thread. Suppress
+ * navigation until its one-shot key has been consumed and released. */
+ if (optionsBindingCaptureTick()) return;
+
+ /* D345(f): NO raw stick integration on this screen (the D345(c) call to
+ * frontUpdateControlStickPosition() was removed): vertical input is the
+ * discrete row stepper below, horizontal is the left/right block -- F10
+ * model. Raw drift fought the pointer over cursor_h/v_pos. */
+
+ /* Highlight: as the cheat screen, recomputed while A is not held. */
+ if (joyGetButtons(PLAYER_1, A_BUTTON | Z_TRIG) == 0) {
+ tab_prev_highlight = FALSE;
+ s_hl = -1;
+ if (frontCheckCursorOnPreviousTab()) {
+ tab_prev_highlight = TRUE;
+ } else if (cursor_h_pos >= ROW_HIT_X0 && cursor_h_pos <= ROW_HIT_X1) {
+ for (int k = itemCount() - 1; k >= 0; k--) {
+ if (cursor_v_pos >= (f32)(rowY(k) - 3) &&
+ cursor_v_pos < (f32)(rowY(k) + ROW_DY - 3)) {
+ s_hl = k;
+ break;
+ }
+ }
+ /* D407(b): past the top/bottom edge of the visible rows, clamp
+ * the highlight to the edge row instead of dropping it (a
+ * mouse-only user keeps a row selected at the page edge; wheel
+ * / S then turns the page). */
+ if (s_hl < 0 && itemCount() > 0) {
+ if (cursor_v_pos < (f32)rowY(0))
+ s_hl = 0;
+ else if (cursor_v_pos <= (f32)(rowY(itemCount() - 1) + ROW_DY))
+ s_hl = itemCount() - 1;
+ }
+ }
+ }
+
+ if (joyGetButtonsPressedThisFrame(PLAYER_1, A_BUTTON | Z_TRIG | START_BUTTON)) {
+ if (tab_prev_highlight) {
+ goBack();
+ } else if (s_hl >= 0 && s_level == 0) {
+ playSfx(DOOR_METAL_CLOSE2_SFX);
+ s_page = s_hl;
+ s_level = 1;
+ s_pageno = 0; /* D406 */
+ sysLogPrintf(LOG_INFO, "frontoptions: section %d", s_page + 1);
+ buildPages();
+ cursorToItem(0);
+ s_hl = -1;
+ } else if (s_hl == 0 && s_level >= 1) {
+ /* D356: the top save-file row -- A steps to the next file (the
+ * D352 chooser; folders only, "none" retired, plan §5.5). */
+ playSfx(DOOR_LOCK_SFX);
+ watchSettingsChooseFile(1);
+ } else if (s_hl >= 1 && s_hl <= s_rowN) {
+ /* s_hl is item-numbered (0 = the save-file row); content row k
+ * is item k+1, so the row index is s_hl - 1. */
+ int i = s_rowIdx[s_hl - 1];
+ if (getenv("GE_FRONTNAVLOG"))
+ sysLogPrintf(LOG_INFO, "frontnav: select %s/%s (item %d)",
+ optionsRowLabel(activeHeader()), optionsRowLabel(i), s_hl);
+ playSfx(DOOR_LOCK_SFX);
+ int child = optionsRowChildHeader(i);
+ int bx0 = optionsRowIsSlider(i) ? rowBarX0(optionsRowLabel(i)) : BAR_X0;
+ if (child >= 0) {
+ optionsResetClear();
+ s_subHeader = child;
+ s_level++;
+ s_pageno = 0; /* D406 */
+ buildPages();
+ sysLogPrintf(LOG_INFO, "frontoptions: opened %s (depth %d)",
+ optionsRowLabel(child), s_level);
+ cursorToItem(0);
+ s_hl = -1;
+ } else if (optionsRowIsSlider(i) && bx0 <= BAR_X1 - 12 &&
+ cursor_h_pos >= bx0 - 4 && cursor_h_pos <= BAR_X1 + 4) {
+ optionsRowSetFraction(i, ((double)cursor_h_pos - bx0) / (BAR_X1 - bx0));
+ s_dragRow = i;
+ } else if (optionsRowIsBind(i)) {
+ /* D395: pad A/X may navigate the keyboard/mouse binding
+ * pages but cannot enter a modal that only a key can finish.
+ * Enter or a mouse click still starts ordinary key capture. */
+ const Uint8 *ks = SDL_GetKeyboardState(NULL);
+ if (ks[SDL_SCANCODE_RETURN] || ks[SDL_SCANCODE_KP_ENTER] ||
+ (SDL_GetMouseState(NULL, NULL) & SDL_BUTTON(SDL_BUTTON_LEFT)))
+ optionsRowBeginBind(i);
+ } else if (optionsRowIsReset(i)) {
+ /* D356: reset rows are two-step arm -> confirm (edge
+ * activation: joyGetButtonsPressedThisFrame fires once per
+ * press). The value column shows "Confirm" while armed. */
+ optionsRowActivateReset(i);
+ } else {
+ optionsRowAdjust(i, +1); /* toggle / cycle / step (wraps) */
+ }
+ }
+ } else if (joyGetButtonsPressedThisFrame(PLAYER_1, B_BUTTON)) {
+ goBack();
+ }
+
+ /* Drag a slider while A is held. */
+ if (s_dragRow >= 0) {
+ if (joyGetButtons(PLAYER_1, A_BUTTON | Z_TRIG)) {
+ int bx0 = rowBarX0(optionsRowLabel(s_dragRow));
+ optionsRowSetFraction(s_dragRow, ((double)cursor_h_pos - bx0) / (BAR_X1 - bx0));
+ } else {
+ optionsRowCommit(s_dragRow);
+ s_dragRow = -1;
+ }
+ }
+
+ /* Left/right (D-pad, C buttons; keyboard A/D) adjust the highlighted row,
+ * with hold-to-repeat. D356: on the save-file row it cycles the file;
+ * on a reset row a fresh press activates (arm/confirm) and the held
+ * repeat is suppressed (the activation contract, plan §5.4). */
+ if (s_level >= 1 && s_hl >= 0) {
+ int dir = 0;
+ /* D345(f): stick X joins the D-pad/C-buttons, so Left/Right arrows
+ * (and the pad's left stick) adjust values like F10's left/right. */
+ s8 vsx = joyGetStickX(PLAYER_1);
+ if (joyGetButtons(PLAYER_1, L_JPAD | L_CBUTTONS) || vsx < -5) dir = -1;
+ if (joyGetButtons(PLAYER_1, R_JPAD | R_CBUTTONS) || vsx > 5) dir = +1;
+ if (dir != 0) {
+ int fire = 0;
+ if (dir != s_repeatDir) {
+ fire = 1;
+ s_repeatTimer = 18;
+ } else if (--s_repeatTimer <= 0) {
+ fire = 1;
+ s_repeatTimer = 4;
+ }
+ if (fire) {
+ if (s_hl == 0) {
+ watchSettingsChooseFile(dir); /* save-file row */
+ } else {
+ int i = s_rowIdx[s_hl - 1];
+ if (optionsRowIsReset(i))
+ optionsRowActivateReset(i);
+ else if (optionsRowChildHeader(i) < 0)
+ optionsRowAdjust(i, dir);
+ }
+ }
+ }
+ s_repeatDir = dir;
+ } else {
+ s_repeatDir = 0;
+ }
+
+ /* D356: reset arm state -- the screen's selected rows[] index (s_hl 0 is
+ * the save row, not a rows[] entry); moving on disarms, goBack clears. */
+ optionsResetMaintain(s_level >= 1 && s_hl > 0 ? s_rowIdx[s_hl - 1] : -1);
+
+ /* The dossier: tabs + a blank CLASSIFIED page, as the cheat screen. */
+ disable_all_switches(walletinst[0]);
+ set_item_visibility_in_objinstance(walletinst[0], SW_TABS, 1);
+ set_item_visibility_in_objinstance(walletinst[0], SW_BLANK, 1);
+ set_item_visibility_in_objinstance(walletinst[0], SW_CLASSIFIED, 1);
+
+ /* D345(e): F10-style discrete vertical navigation. W/S / Up-Down arrows
+ * (stick via D345(d)) or the pad's stick/D-pad step the highlight one
+ * row at a time -- clamped at the ends, hold-to-repeat with the same
+ * 18/4-frame cadence as left/right -- and snap the crosshair onto the
+ * new row (ungated: explicit keyboard navigation owns the cursor even
+ * right after mouse use). Replaces the raw stick drift of D345(c),
+ * which fought the pointer over cursor_v_pos. The mouse is untouched:
+ * any movement re-owns the cursor and the highlight hit-test above
+ * follows it next frame. Skipped while a slider drag holds A. */
+ if (s_dragRow < 0) {
+ int vdir = 0;
+ s8 vsy = joyGetStickY(PLAYER_1);
+ if (joyGetButtons(PLAYER_1, U_JPAD) || vsy > 5) vdir = -1; /* up */
+ else if (joyGetButtons(PLAYER_1, D_JPAD) || vsy < -5) vdir = +1; /* down */
+ if (vdir != 0) {
+ int fire = 0;
+ if (vdir != s_vrepeatDir) {
+ fire = 1;
+ s_vrepeatTimer = 18;
+ } else if (--s_vrepeatTimer <= 0) {
+ fire = 1;
+ s_vrepeatTimer = 4;
+ }
+ if (fire) {
+ int n = itemCount();
+ if (n > 0) {
+ int q = (s_hl < 0 ? 0 : s_hl) + vdir;
+ /* D406: stepping past the end of the visible page turns
+ * the page (cursor lands on the first row of the next
+ * page, or the last row of the previous one); with one
+ * page the ends simply clamp, as before. */
+ if (q >= n && s_pageno * ROWS_PER_PAGE + s_rowN < s_rowTotal) {
+ s_pageno++;
+ buildPages();
+ q = 1;
+ } else if (q < 0 && s_pageno > 0) {
+ s_pageno--;
+ buildPages();
+ q = itemCount() - 1;
+ } else {
+ if (q < 0) q = 0;
+ if (q >= n) q = n - 1;
+ }
+ if (q != s_hl) {
+ if (getenv("GE_FRONTNAVLOG"))
+ sysLogPrintf(LOG_INFO, "frontnav: %s -> item %d%s%s (page %d)",
+ s_level ? optionsRowLabel(activeHeader()) : "categories",
+ q, s_level && q > 0 ? " " : "",
+ s_level && q > 0 ? optionsRowLabel(s_rowIdx[q - 1]) : "",
+ s_level ? s_pageno + 1 : 0);
+ s_hl = q;
+ cursorToItemRaw(q);
+ }
+ }
+ }
+ }
+ s_vrepeatDir = vdir;
+ } else {
+ s_vrepeatDir = 0;
+ }
+}
+
+Gfx *frontOptionsMenuDraw(Gfx *DL)
+{
+ char buf[48];
+
+ DL = viSetFillColor(DL, 0, 0, 0);
+ DL = viFillScreen(DL);
+#ifdef VERSION_EU
+ DL = viFillScreen(DL);
+ DL = viFillScreen(DL);
+ DL = viFillScreen(DL);
+#endif
+ DL = frontSetupMenuBackground(DL);
+ DL = microcode_constructor(DL);
+
+ if (s_level == 0) {
+ DL = ink(DL, ROW_X, TITLE_Y, "PC Options\n", INK);
+ for (int k = 0; k < s_pageN; k++) {
+ char name[32], num[8];
+ titleCase(optionsRowLabel(s_pageHdr[k]), name, sizeof(name));
+ strcat(name, "\n");
+ snprintf(num, sizeof(num), "%d.\n", k + 1);
+ if (k == s_hl) {
+ DL = microcode_constructor_related_to_menus(DL, SEC_X - 2, rowY(k) - 1,
+ SEC_X + measureW(name) + 5, rowY(k) + 0xE, HILITE);
+ }
+ DL = ink(DL, NUM_X, rowY(k), num, INK);
+ DL = ink(DL, SEC_X, rowY(k), name, INK);
+ }
+ } else {
+ char title[32];
+ titleCase(optionsRowLabel(activeHeader()), title, sizeof(title));
+ int titleW = measureW(title);
+ strcat(title, "\n");
+ DL = ink(DL, ROW_X, TITLE_Y, title, INK);
+
+ /* D356: scope annotation -- a section that carries per-file rows has
+ * its title carry the active file ("GAMEPLAY (File 2)"), PD-style.
+ * watchSettingsActiveFolder() is the context-aware accessor (front:
+ * the resolved front target; -1 while no file is usable, which the
+ * annotation renders as an unavailable state, never a number the
+ * write path cannot use). */
+ if (optionsRowIsSaveScoped(activeHeader())) {
+ int f = watchSettingsActiveFolder();
+ char note[16];
+ if (f < 0) snprintf(note, sizeof(note), "(none)");
+ else snprintf(note, sizeof(note), "(Profile %d)", f + 1);
+ DL = ink(DL, ROW_X + titleW + 8, TITLE_Y, note, INK_DIM);
+ }
+
+ /* D406b: page marker for sections that span pages -- "Page p/N"
+ * right of the title row, dim, so page 2 is unmistakable. */
+ if (s_rowTotal > ROWS_PER_PAGE) {
+ int pages = (s_rowTotal + ROWS_PER_PAGE - 1) / ROWS_PER_PAGE;
+ char pg[24];
+ snprintf(pg, sizeof(pg), "Page %d/%d", s_pageno + 1, pages);
+ DL = inkR(DL, VAL_R, TITLE_Y, pg, INK_DIM);
+ }
+
+ /* D356: the top profile row (item 0, not in the row table; the label
+ * reads "Profile" -- the end-user term for a per-player game file,
+ * see D357 plan section 4): the single file control of the front
+ * screen (F10 always targets the active file, so it has no file
+ * row). L/R (and A) cycle folders; (none) is the unavailable state
+ * until one is created. */
+ {
+ int y = rowY(0);
+ char sv[32];
+ if (s_hl == 0) {
+ DL = microcode_constructor_related_to_menus(DL, ROW_X - 2, y - 1,
+ ROW_X + 140, y + 0xE, HILITE);
+ }
+ DL = ink(DL, ROW_X, y, "Profile\n", INK);
+ int f = watchSettingsActiveFolder();
+ if (f < 0) snprintf(sv, sizeof(sv), "(none)");
+ else snprintf(sv, sizeof(sv), "%d\n", f + 1);
+ DL = inkR(DL, VAL_R, y, sv, INK);
+ }
+
+ for (int k = 0; k < s_rowN; k++) {
+ int i = s_rowIdx[k];
+ /* D406b: one-shot label-metrics log (layout diagnostics). */
+ static int widthsLogged = 0;
+ if (!widthsLogged && getenv("GE_FRONTOPTS_WIDTHS")) {
+ widthsLogged = 1;
+ for (int w2 = 0; w2 < s_rowN; w2++) {
+ int j = s_rowIdx[w2];
+ sysLogPrintf(LOG_INFO, "frontoptions: '%s' w=%d barx0=%d",
+ optionsRowLabel(j), measureW(optionsRowLabel(j)),
+ rowBarX0(optionsRowLabel(j)));
+ }
+ }
+ /* Content row k is item k+1 (item 0 is the save-file row), so
+ * it sits one line below the save row -- rowY() already shifts
+ * level-1 items down by one. */
+ int y = rowY(k + 1);
+ char label[48];
+ snprintf(label, sizeof(label), "%s\n", optionsRowLabel(i));
+ if (k + 1 == s_hl) {
+ DL = microcode_constructor_related_to_menus(DL, ROW_X - 2, y - 1,
+ ROW_X + measureW(label) + 5, y + 0xE, HILITE);
+ }
+ DL = ink(DL, ROW_X, y, label, INK);
+
+ /* GAMEPLAY/HUD mix ini and profile options. AUDIO is wholly
+ * profile-scoped and already has (Profile N) in its title. */
+ if (optionsRowIsSaveScoped(i) &&
+ (strcmp(optionsRowLabel(activeHeader()), "GAMEPLAY") == 0 ||
+ strcmp(optionsRowLabel(activeHeader()), "HUD") == 0)) {
+ DL = ink(DL, ROW_X + measureW(label) + 4, y, "(per profile)\n", INK_DIM);
+ }
+
+ if (optionsRowIsSlider(i)) {
+ int x0 = rowBarX0(optionsRowLabel(i));
+ if (BAR_X1 - x0 >= 12) {
+ s32 fx = x0 + (s32)((BAR_X1 - x0) * optionsRowFraction(i) + 0.5);
+ DL = microcode_constructor_related_to_menus(DL, x0, y + 5, BAR_X1, y + 8, 0x00000040);
+ DL = microcode_constructor_related_to_menus(DL, x0, y + 5, fx, y + 8, 0xA00000C0);
+ }
+ } else if (optionsRowNeedsRestart(i)) {
+ DL = ink(DL, BAR_X0, y, "(restart)\n", INK_DIM);
+ }
+
+ optionsRowValueText(i, buf, sizeof(buf) - 1);
+ if (strcmp(optionsRowLabel(i), "Crosshair colour") == 0 && buf[0]) {
+ extern void portCrosshairPreview(s32 *, s32 *, s32 *);
+ s32 red, green, blue;
+ portCrosshairPreview(&red, &green, &blue);
+ s32 x = VAL_R - measureW(buf) - 17;
+ DL = microcode_constructor_related_to_menus(DL, x, y + 2,
+ x + 10, y + 12,
+ ((u32)red << 24) | ((u32)green << 16) |
+ ((u32)blue << 8) | 0xff);
+ }
+ if (buf[0]) {
+ u32 col = (buf[0] == 'O' && buf[1] == 'N') ? INK_ON : INK;
+ /* D346: kOnOff is title-case; M7 (D387) tags the
+ * experimental AllUnlocked ON value "ON - UNSAFE". */
+ strcat(buf, "\n");
+ DL = inkR(DL, VAL_R, y, buf, col);
+ }
+ }
+ if (strcmp(optionsRowLabel(activeHeader()), "MOVEMENT") == 0 ||
+ strcmp(optionsRowLabel(activeHeader()), "ACTIONS") == 0) {
+ DL = ink(DL, ROW_X, rowY(s_rowN + 2),
+ optionsBindingCaptureActive()
+ ? "Press key/mouse B/ESC cancel DEL clear\n"
+ : "Keys/mouse only Enter: bind B: back\n", INK_DIM);
+ } else if (s_rowTotal > ROWS_PER_PAGE) {
+ /* D406b: bottom hint on every page of a multi-page section (11
+ * rows per page leaves the last line of the paper for it). */
+ int last = s_pageno * ROWS_PER_PAGE + s_rowN >= s_rowTotal;
+ int first = s_pageno == 0;
+ /* D406d: one row below the last content row (rowY(s_rowN + 1))
+ * -- pitch-aligned with the list; +2 sat ~26px clear of it and
+ * looked orphaned at the paper's bottom edge. */
+ DL = ink(DL, ROW_X, rowY(s_rowN + 1),
+ first ? "Down: next page\n"
+ : last ? "Up: previous page\n"
+ : "Up: previous page Down: next page\n",
+ INK_DIM);
+ }
+ }
+
+ DL = frontAddPreviousTabText(DL);
+ DL = frontDrawCursor(DL);
+ return DL;
+}
+
+/* F10 / Select must not stack the overlay on this screen. */
+int frontOptionsBlocksOverlay(void)
+{
+ return current_menu == MENU_PC_OPTIONS;
+}
+
+/* ------------------------------------------------------------------------ */
+/* File select: the "Options" entry, drawn from the D343 hook in */
+/* constructor_menu05_fileselect (game thread, after the interface tick). */
+/* ------------------------------------------------------------------------ */
+
+Gfx *optionsFileSelectLabel(Gfx *gdl)
+{
+ s32 h = 0, w = 0;
+ s32 x, y;
+ int hot;
+
+ /* The F10 overlay pads the controller away from front.c, so file select's
+ * idle timer never resets and it dropped to the legal screen after 30 s.
+ * This hook only runs on MENU_FILE_SELECT, the one screen where
+ * g_MenuTimer is an idle timer (elsewhere it is the intro / cast-roll
+ * clock). Same write front.c makes when a button is pressed. */
+ if (optionsOverlayIsOpen()) {
+ g_MenuTimer = 0;
+ }
+
+ /* D400 (user sign-off, "most straightforward design"): text-only label,
+ * the D398 dot icon is gone. Exact Copy/Erase pattern from front.c:
+ * measure the single-line label (no trailing newline -- the kLabelNL
+ * trick measured a taller box and sat the text ~7px higher than the
+ * bar's other words, the "different size/look" complaint), centre it
+ * on the bar line, draw with the same font. Hot colour is the game's
+ * own packed gold 0xEBD879FF, the very constant front.c passes to
+ * textRender for its gold folder text -- no per-channel unpacking, so
+ * no purple (the D398 dot bug came from unpacking that word for
+ * gDPSetEnvColor in the wrong byte order). */
+ /* NB: textMeasure's signature is (textheight, textwidth) -- height FIRST
+ * (an earlier cut passed these swapped and drew the label 35px above the
+ * bar, hit band transposed). Height is measured WITH a trailing newline:
+ * Copy/Erase's localised strings end in a newline, so textMeasure gives
+ * them a full 14px line height and y = 285 - 7 = 278 (PCDUMP tops
+ * 279); measuring the bare label gives h=0 -> y=285, sitting 7px LOWER
+ * than the bar's other words. Measuring kLabelNL matches their tops
+ * exactly. (This was the D398 "kLabelNL trick"; the D400 rewrite
+ * dropped it and that is what broke the vertical alignment.) */
+ textMeasure(&h, &w, (char *)kLabelNL, ptrFontZurichBoldChars, ptrFontZurichBold, 0);
+ /* Follow the Erase label's measured right edge (set by the constructor
+ * just before this hook), so a wider localised "Erase" (JP glyphs)
+ * pushes the label right instead of overlapping it. */
+ x = (s32)folder_option_ERASE_bound.right + LABEL_GAP;
+ if (x < LABEL_X) {
+ x = LABEL_X;
+ }
+ /* Same centring front.c uses for Copy/Erase (front.c:2779/2791). */
+ y = LABEL_CY - (h / 2);
+
+ /* D406: test hook -- enter the screen without a mouse click so headless
+ * runs (GE_INPUTSCRIPT D-pad stepping + GE_FRONTNAVLOG / diagnostics)
+ * can exercise it. Diagnostic-only: never active without the env. */
+ if (getenv("GE_FRONTOPTIONS_AUTO") && menu_update == MENU_INVALID &&
+ folder_selected_for_deletion < 0) {
+ static int autoEntered = 0;
+ if (!autoEntered) {
+ autoEntered = 1;
+ playSfx(DOOR_LOCK_SFX);
+ frontChangeMenu(MENU_PC_OPTIONS, FALSE);
+ }
+ }
+
+ hot = !optionsOverlayIsOpen()
+ && menu_update == MENU_INVALID
+ && folder_selected_for_deletion < 0
+ && cursor_h_pos >= (f32)(x - HIT_PAD) && cursor_h_pos <= (f32)(x + w + HIT_PAD)
+ && cursor_v_pos >= (f32)(y - HIT_PAD) && cursor_v_pos <= (f32)(y + h + HIT_PAD);
+
+ if (hot && joyGetButtonsPressedThisFrame(PLAYER_1, A_BUTTON | Z_TRIG | START_BUTTON)) {
+ playSfx(DOOR_LOCK_SFX); /* Copy/Erase's click */
+ frontChangeMenu(MENU_PC_OPTIONS, FALSE);
+ }
+
+ /* Same font, size and centre line as Copy/Erase; white idle, the
+ * game's gold while the cursor is over it. */
+ return textRender(gdl, &x, &y, (char *)kLabel, ptrFontZurichBoldChars,
+ ptrFontZurichBold, hot ? 0xEBD879FF : 0xFFFFFFFF,
+ viGetX(), viGetY(), 0, 0);
+}
diff --git a/port/src/gimgfixup.c b/port/src/gimgfixup.c
index daa3b380..7fb7e637 100644
--- a/port/src/gimgfixup.c
+++ b/port/src/gimgfixup.c
@@ -11,6 +11,9 @@
#include
#include
#include "gimgfixup.h"
+extern char *getenv(const char *name); /* D324: no CRT proto via GE stdlib.h */
+#include "envflag.h"
+extern void osSyncPrintf(const char *fmt, ...);
#define GIMG_REGION_SIZE 0x13F8u
@@ -99,9 +102,17 @@ void gimgFixupGlobalimagetable(u8 *base)
}
}
+/* D252 (2026-09-26): which compiled G_SETTIMG slots are IMAGESEG texture
+ * references. Learned on the first sync, while they still hold the 0xABCD
+ * marker; every later sync re-copies exactly these slots. */
+#define GIMG_MAX_CMDS 256
+static u8 s_imgslot[N_DLS][GIMG_MAX_CMDS];
+static int s_imgslot_learned = 0;
+
void gimgSyncCompiledGlobalDLs(u8 *base)
{
int i;
+ int nslots = 0, nchanged = 0; /* D252 TEMP verify counters */
u8 *end = base + GIMG_REGION_SIZE;
for (i = 0; i < N_DLS; i++)
@@ -110,21 +121,44 @@ void gimgSyncCompiledGlobalDLs(u8 *base)
Gfx *dst = s_dl_syms[i];
int j = 0;
- while (p + 8 <= end && p[0] != (u8)G_ENDDL)
+ while (p + 8 <= end && p[0] != (u8)G_ENDDL && j < GIMG_MAX_CMDS)
{
/* D68/C2: texLoadFromDisplayList() has already replaced every
* IMAGESEG w1 word in the ROM copy with a real texture pointer,
- * so the 0xABCDxxxx marker is gone from `p`. Identify the slots
- * that need syncing from the COMPILED array instead: a G_SETTIMG
- * command whose w1 is still an unresolved IMAGESEG marker
- * (0xABCDxxxx). Copy the ROM copy's resolved word into it. */
- if ((u8)(dst[j].words.w0 >> 24) == (u8)G_SETTIMG &&
+ * so the 0xABCDxxxx marker is gone from `p`. On the first call,
+ * identify the slots from the COMPILED array instead (a
+ * G_SETTIMG whose w1 is still an unresolved 0xABCDxxxx marker)
+ * and remember them.
+ *
+ * D252: texReset() runs on EVERY stage load and re-resolves the
+ * ROM copy into the new stage's texture pool. The original sync
+ * only ever copied into slots still holding the marker, i.e.
+ * only on the first stage (the title screen). Every later stage
+ * kept the title's pointers into its freed texture pool; once
+ * the new stage's allocations overwrote that memory, explosion
+ * smoke/fire/debris sampled garbage (rainbow particles). Re-copy
+ * the remembered slots on every call, as the N64 does by running
+ * the freshly patched ROM copy directly. */
+ if (!s_imgslot_learned &&
+ (u8)(dst[j].words.w0 >> 24) == (u8)G_SETTIMG &&
(dst[j].words.w1 >> 16) == 0xABCDu)
{
+ s_imgslot[i][j] = 1;
+ }
+ if (s_imgslot[i][j])
+ {
+ nslots++;
+ if ((u32)dst[j].words.w1 != *(u32 *)(p + 4))
+ nchanged++;
dst[j].words.w1 = *(u32 *)(p + 4);
}
p += 8;
j++;
}
}
+ s_imgslot_learned = 1;
+ /* D252 TEMP: before the fix, every stage after the first left
+ * `nchanged` explosion texture pointers stale. */
+ if (GE_ENVFLAG("GE_D252POOL"))
+ osSyncPrintf("D252SYNC: %d texture slots re-synced, %d changed\n", nslots, nchanged);
}
diff --git a/port/src/input.c b/port/src/input.c
index 639adec1..08ecc0d0 100644
--- a/port/src/input.c
+++ b/port/src/input.c
@@ -19,15 +19,13 @@
* GE's default "1.1" control style: analog stick = move/strafe, the four
* C-buttons = aim/turn/look (DIGITAL on N64), R = aim mode, Z = fire.
*
- * Keyboard + mouse (controller 0):
- * W/S/A/D or arrows .. analog stick (move / strafe)
- * mouse motion ....... aim (mode-aware -- see MOUSE-LOOK below)
- * left mouse / LCtrl . Z trigger (fire)
- * right mouse / LShift R trigger (aim mode)
- * Space / Z / E ...... A button (action / use)
- * X / R / F ......... B button (reload / cancel)
- * Q ................. L trigger
- * Enter / Tab ....... Start
+ * Keyboard + mouse (controller 0, migrated PC defaults):
+ * W/S/A/D or arrows .. movement / strafe
+ * mouse motion ....... look (mode-aware -- see MOUSE-LOOK below)
+ * left mouse ......... fire; right mouse / LShift .. aim
+ * Q .................. next weapon; E .. dedicated use
+ * R .................. dedicated reload; LCtrl .. independent crouch
+ * Enter / Tab ........ Start
*
* Xbox / SDL_GameController (controller 0 merges pad 0 with kbd/mouse;
* pads 1-3 -> controllers 1-3):
@@ -35,12 +33,14 @@
* right stick ....... C-buttons (digital, 50% threshold -- aim)
* right trigger ..... Z trigger (fire)
* left trigger ...... R trigger (aim mode)
- * A / X ............. A button
- * B / Y ............. B button
- * LB ............... L trigger
- * RB ............... B button (reload)
+ * A ................. use/interact (in-game); accept (menus)
+ * X ................. reload (in-game); accept (menus)
+ * B ................. cycle inventory gadgets (in-game); cancel (menus)
+ * Y ................. next weapon (in-game); cancel (menus)
+ * stick clicks ...... crouch (in-game)
+ * LB ................ aim (alternate to LT); RB reserved (no HD assets)
* D-pad ............ N64 D-pad
- * Start ............ Start
+ * Start ............ Start; Back opens the PC options overlay
*
* MOUSE-LOOK (mode-aware, no src/ changes)
* GE's aim model (bondview2.c bondviewProcessInput / MoveData) is
@@ -70,6 +70,7 @@
*/
#include "port_math.h" /* real system math decls; see header for why */
+#include
#include
#include
@@ -79,15 +80,28 @@
#include "system.h"
#include "config.h"
#include "input.h"
+#include "envflag.h"
#include "optionsoverlay.h"
+#include "frontoptions.h"
/* D194 absolute aim: read-only access to the live camera (struct player).
* Game header pulled in through the same shim path every other compiled game
* file uses; we only READ vv_theta/vv_verta/speedtheta/speedverta/aspect. */
#include "player.h"
+#include "gun.h" /* native weapon flags + dedicated reload entry points */
+#include "bondinv.h" /* inventory list for the Xbox-style gadget cycle */
/* D194 spazz diagnosis: game ticks batched into the current poll (lv.h).
* Read-only; declared locally to avoid pulling lv.h's wider dependency set. */
extern s32 g_ClockTimer;
+extern s32 lvlGetControlsLockedFlag(void); /* same gate as bondviewProcessInput */
+extern int gameScriptedCameraActive(void);
+extern bool bond_interact_object(void); /* chrprop.c: use target, no reload fallback */
+extern s32 g_PlayerIsInTank; /* bondview.c: tank state (D407) */
+extern s32 g_BondCanEnterTank; /* bondview.c: tank board gate (D407) */
+extern s32 g_EnterTankAudioState; /* bondview2.c: tank entry/running state (D407) */
+/* gun.c: native weapon-switch entry points (not exposed in gun.h). */
+extern ITEM_IDS get_next_weapon_in_cycle_for_hand(GUNHAND hand, s32 direction);
+extern void gunRequestHandWeaponChange(enum GUNHAND hand, s32 nextWeapon, s32 cycleDirection);
/* N64 button bits (from PR/os.h -- duplicated here to avoid pulling os.h,
* whose `u8 errno;` field collides with 's macro). */
@@ -263,6 +277,9 @@ extern s32 lvlGetCurrentStageToLoad(void);
#define MENU_CURSOR_LO 20.0
#define MENU_CURSOR_HI_H 300.0
#define MENU_CURSOR_HI_V 220.0
+/* D345: after a front-end screen change, re-assert the 1:1 pointer for a few
+ * frames if the mouse was used within this many ms (see inputComputePad). */
+#define MENU_POINTER_REASSERT_MS 1500.0
#define MENU_CURSOR_MID_H 160.0
#define MENU_CURSOR_MID_V 120.0
@@ -274,12 +291,69 @@ static int numControllers = 1;
static int connectedMask = 0x1; /* controller 0 always present */
static SDL_GameController *pads[MAX_PADS];
-static int padShoulderPrev[MAX_PADS]; /* LB/RB edge state for weapon cycling */
+static int padBPrev[MAX_PADS]; /* B/Y edges; track through menus too */
+static int padYPrev[MAX_PADS];
static int padSelectPrev = 0; /* Select (BACK) edge: overlay toggle */
+/* D401: per-pad haptics (N64 Rumble Pak -> SDL_GameControllerRumble).
+ * padRumbleOn is detected when the pad opens (inputOpenPads); gRumbleScale
+ * is the single global Input.RumbleScale config value (0 = silent,
+ * 1 = full), applied to every pad. */
+static int padRumbleOn[MAX_PADS];
+static float gRumbleScale = 0.5f;
+
static int mouseEnabled = 1;
static int mouseGrabbed = 1; /* released while the window is unfocused */
+/* D287: SDL's mouse-mode and cursor calls belong on the window's thread (the
+ * host thread that created it and pumps its events). Controller polls run on
+ * the scheduler thread (osContStartReadData -> inputComputePad ->
+ * reconcileGrab), so they called SDL_SetRelativeMouseMode / SDL_ShowCursor
+ * concurrently with the host thread's own focus/click handling. On Windows
+ * relative mode starts/stops SDL's raw-input thread and re-clips the cursor;
+ * two threads flipping it at once (clicking back into the window after being
+ * unfocused) can wedge. Callers now only record the wanted state; the host
+ * thread applies it in inputApplyMouseRequests() from videoPumpEvents(). The
+ * logical state (mouseGrabbed, deltas) still updates immediately. */
+static SDL_SpinLock s_mouseReqLock;
+static int s_reqRelative = -1, s_reqCursor = -1; /* -1: nothing pending */
+static int s_appRelative = -1, s_appCursor = -1; /* host thread only */
+
+static void mouseRequestRelative(int on)
+{
+ SDL_AtomicLock(&s_mouseReqLock);
+ s_reqRelative = on ? 1 : 0;
+ SDL_AtomicUnlock(&s_mouseReqLock);
+}
+
+static void mouseRequestCursor(int show)
+{
+ SDL_AtomicLock(&s_mouseReqLock);
+ s_reqCursor = show ? 1 : 0;
+ SDL_AtomicUnlock(&s_mouseReqLock);
+}
+
+/* Host thread only (videoPumpEvents, inputInit). */
+void inputApplyMouseRequests(void)
+{
+ SDL_AtomicLock(&s_mouseReqLock);
+ const int rel = s_reqRelative, cur = s_reqCursor;
+ s_reqRelative = s_reqCursor = -1;
+ SDL_AtomicUnlock(&s_mouseReqLock);
+
+ if (rel >= 0 && rel != s_appRelative) {
+ s_appRelative = rel;
+ SDL_SetRelativeMouseMode(rel ? SDL_TRUE : SDL_FALSE);
+ if (rel) {
+ SDL_GetRelativeMouseState(NULL, NULL); /* drain the accumulated jump */
+ }
+ }
+ if (cur >= 0 && cur != s_appCursor) {
+ s_appCursor = cur;
+ SDL_ShowCursor(cur ? SDL_ENABLE : SDL_DISABLE);
+ }
+}
+
/* WI-1: Quake-style click-to-lock cursor capture. The cursor is free until
* you click in the game window; ESC (or focus loss, or opening a menu) frees
* it again. Re-entering a stage while still "armed" re-locks automatically so
@@ -297,6 +371,12 @@ static int gepdSens = 38; /* D194 Input.GepdSens: GEPD SENSITIVITY set
(20 -> 25 "a bit slow" -> 30 user-calibrated match point
-> 38: user asked defaults ~20-35% faster than that) */
static int aimBand = 20; /* aim mode: usable stick range above the 60 gate */
+/* D407: mouse->stick gain multiplier (%, 100 = unchanged) applied ONLY while
+ * aiming in tank. In-tank aim must use the legacy velocity-stick path (the
+ * game's turret code reads raw stick deflection only), whose px->screen feel
+ * differs from the direct-write on-foot models; this knob lets the user dial
+ * the turret to taste without touching on-foot aim. */
+static int tankAimScale = 100;
/* D194/D238: default 100 -> 40 (M-123 user calibration). The old gain
* (MOUSE_TURN_GAIN=6 stick/px) saturated the game's quadratic natural-turn
* curve at ~13 px/poll, i.e. hipfire ran at full 315 deg/s for any normal
@@ -336,6 +416,79 @@ static int mouseDirectLook = 1; /* WI-1 (GEPD-INPUT-PLAN.md #89): 1 = hipfir
* as aim mode; 0 = legacy stick-curve path
* (MOUSE_TURN_GAIN), kept as an escape hatch. */
+/* Input.PdMouseAim -- use the Perfect Dark port's mouse-aim model instead of
+ * GEPD's. Mirrors fgsfdsfgs/perfect_dark exactly (bondmove.c/bondgun.c): PD
+ * accumulates the mouse delta into a position (`swivelpos`, clamped [-1,1])
+ * and then pushes that POSITION into the game's crosshair integrator as its
+ * INPUT, with a near-zero crosshair damp (`bgunSwivelWithDamp(x, y, 0.01f)`).
+ *
+ * The near-zero damp is the whole point. GE's aim path uses the weapon's
+ * CrosshairSpeed (~0.8) -- a value chosen for the analog stick, where the
+ * integrator does the smoothing. With a mouse that slow integrator is what
+ * makes the drawn crosshair step: the port writes once per input poll while
+ * the game damps once per sim tick, so the displayed value is write*damp^k
+ * with k varying (findings D332). At damp 0.01 the input dominates and k stops
+ * mattering -- PD never had this bug because it never gave the mouse a slow
+ * damp.
+ *
+ * In this mode the port only ACCUMULATES; the game's own integrator does the
+ * rest, driven by the port-supplied turn through a #ifdef PORT hook in
+ * sub_GAME_7F067FBC (gunfire.c). Default off: it changes aim feel, so it is
+ * opt-in. */
+static int pdMouseAim = 0;
+
+/* D337 -- aim mode, folded (user decision 2026-09-24):
+ * Input.AimMode 0 = N64 (default): the N64 aim model -- crosshair travels,
+ * camera edge-scrolls -- with the mouse fed through the game's own
+ * crosshair integrator at PD's mouse damp (D332 / PR #96). Same range,
+ * edge-scroll and bullet mapping as the old GEPD overwrite, without its
+ * poll-vs-tick step jitter.
+ * Input.AimMode 1 = Centred (PC, opt-in, #104): the mouse turns the camera
+ * and the crosshair stays centred. Not an N64 behaviour; kept for players
+ * who asked for FPS-style aiming.
+ * Input.AimLegacyGepd=1 (hidden, one release) restores the old GEPD overwrite
+ * for the N64 mode. GE_PDMOUSEAIM=0/1 still forces the integration at launch.
+ * Legacy keys: Input.AimStyle 2 (Centred) migrates to AimMode 1; AimStyle 0/1
+ * and Input.PdMouseAim map to the default N64 mode. */
+enum { AIMMODE_N64 = 0, AIMMODE_CENTRED = 1 };
+static int aimMode = AIMMODE_N64;
+static int aimLegacyGepd = 0;
+static int aimStyleLegacy = 0; /* Input.AimStyle (D333, superseded) */
+
+static int aimModeGet(void)
+{
+ if (aimStyleLegacy == 2 && aimMode == AIMMODE_N64) { /* one-time migration */
+ aimMode = AIMMODE_CENTRED;
+ }
+ aimStyleLegacy = 0;
+ pdMouseAim = 0;
+ return aimMode;
+}
+
+/* D337: which device drove aim last. PD picks its crosshair damp per input
+ * device; the mouse integration must not swallow a gamepad's stick aim just
+ * because the mouse happens to be grabbed. Set by mouse motion, cleared by a
+ * deflected pad stick (inputComputePad). */
+static int s_aimDevMouse = 0;
+
+/* D338: Input.AimRange -- how far the N64-mode crosshair can travel.
+ * 0 = PC (default, the GEPD/mouse-injector feel): the crosshair reaches the
+ * screen edge, camera edge-scroll from 72% of the way there.
+ * 1 = N64: the original stick limits. N64 aim turns the crosshair by
+ * stick*0.65/80 per tick, so full deflection settles at 65% of the
+ * half-width whatever the weapon damp, and the camera starts turning past
+ * stick 60/80 -- 75% of that range (~49% of the half-width). */
+static int aimRange = 0;
+static double aimRangeScale(void) { return aimRange ? 0.65 : 1.0; }
+static double aimEdgeThreshold(void) { return aimRange ? 0.75 : (double)GEPD_EDGE_THRESHOLD; }
+
+static int pdMouseAimEnabled(void)
+{
+ const char *e = GE_ENVSTR("GE_PDMOUSEAIM");
+ if (e) return atoi(e) != 0;
+ return aimModeGet() == AIMMODE_N64 && !aimLegacyGepd;
+}
+
/* D194 GEPD-aim state. Touched ONLY in inputComputePad (game thread), the
* same confinement as every other static here. The per-poll grabbed delta
* drives the view, so there is no free-running angle accumulator for
@@ -348,8 +501,11 @@ static int mouseDirectLook = 1; /* WI-1 (GEPD-INPUT-PLAN.md #89): 1 = hipfir
* overwritten each tick (GEPD model, D194). */
static double s_gepdCrossX = 0.0, s_gepdCrossY = 0.0;
static int s_gepdHeldPrev = 0; /* aim held last tick -> adopt on entry */
+static int aimGepdAccumulate(double dxPx, double dyLook);
+static void aimGepdEdgeScroll(void);
static int aimGepdCompute(double dxPx, double dyLook);
static int hipDirectCompute(double dxPx, double dyLook);
+static int padDirectCompute(int dx, int dy); /* D404: pad twin of the above */
/* D194: bondview2's "look-ahead" pitch centreing (docentreupdown) arms during
* hip-fire walking whenever the pitch strays from the horizon target, and --
* once armed -- keeps pulling vv_verta back to it even in aim mode, EXCEPT
@@ -374,10 +530,27 @@ static int naturalPitchMode = 1; /* D194/D238: 1 = force GE's own 1.2/SOLITAR
* 0 = legacy 1.1/HONEY + D166 digital pulse. */
static Uint64 s_lastLookPollCounter = 0; /* D194(b): monotonic clock, gameplay-look drain only */
-/* Gamepad tuning -- defaults reproduce the old hardcoded constants exactly. */
-static int padDeadzone = STICK_DEADZONE; /* left-stick deadzone, raw 0..32767 */
-static int padTriggerPct = 23; /* trigger press point, % of travel (~30*256) */
-static int padLookInvertY = 0; /* 1 = invert right-stick (look) Y */
+/* Gamepad tuning -- defaults reproduce the old hardcoded constants exactly.
+ * Wave A (v0.5.0 controller/input wave, docs/dev/CONTROLLER-INPUT-PLAN.md):
+ * per-stick deadzone splits the single Input.PadDeadzone into a left (movement)
+ * and a right (look) value (padDeadzone stays registered so old inis load; the
+ * unset L/R values migrate from it once in inputInit). The look knobs
+ * (sens X/Y, smoothing) are feel-only and identity at their defaults. */
+#define PAD_DZ_UNSET (-1) /* C init for padDeadzoneL/R: "not in ini -> migrate" */
+static int padDeadzoneL = PAD_DZ_UNSET; /* left (movement) stick deadzone, raw 0..32767 */
+static int padDeadzoneR = PAD_DZ_UNSET; /* right (look) stick deadzone, raw 0..32767 */
+static int padDeadzone = STICK_DEADZONE; /* legacy shared deadzone (Input.PadDeadzone) */
+static int padLookSensX = 100; /* right-stick look: horizontal sensitivity, % (100 = native) */
+static int padLookSensY = 100; /* right-stick look: vertical (pitch) sensitivity, % */
+static int padSouthpaw = 0; /* 1 = swap fire (G) / grenade (R) trigger actions */
+static int padLookSmooth = 0; /* right-stick look low-pass strength, 0-10 (0 = off, 10 = max) */
+static int padTriggerPct = 23; /* trigger press point, % of travel (~30*256) */
+static int padLookInvertY = 0; /* 1 = invert right-stick (look) Y */
+/* Per-pad look-smoothing EMA state (Wave A item 5), one X/Y pair per pad. Reset
+ * on hot-unplug (inputRescanPads) so a smoothed value can't stick after the
+ * stick snaps back; the low-pass otherwise recentres it toward 0 on its own. */
+static double padSmSX[MAX_PADS] = {0};
+static double padSmSY[MAX_PADS] = {0};
/* Smoothed mouse delta carried between polls when mouseSmoothing > 0. */
static double mouseSmDX = 0.0, mouseSmDY = 0.0;
@@ -412,6 +585,20 @@ static int hipfirePitchSpeed = 100; /* D166: hipfire pitch pulse rate, percen
static int menuPrevActive = 0;
static double hipPitchPhase = 0.0; /* D166: hipfire pitch pulse phase 0..1 */
static int lastMenuMouseX = -1, lastMenuMouseY = -1; /* WI-2: last abs cursor seen in a menu */
+/* D345: carry pointer ownership across front-end screen changes. The game
+ * teleports cursor_h/v_pos on some transitions (file select -> mode select
+ * calls setCursorPOSforMode(0), front.c:2516); the absolute write below only
+ * fires while the OS mouse moves, so with an idle mouse the snap persists
+ * until the next movement. lastMenuId detects the change; transitionFrames is
+ * a short countdown during which a recent-mouse re-assert is allowed;
+ * lastAbsWrite is the perf-counter stamp of the last absolute write (mouse
+ * recency). */
+static int menuPointerLastMenuId = -1;
+static int menuPointerTransitionFrames = 0;
+static Uint64 menuPointerLastAbsWrite = 0;
+/* D345(b): 1 while the 1:1 pointer can own cursor_h/v_pos this poll
+ * (menu, mouse enabled + grabbed). Refreshed every pad-0 poll. */
+static int s_menuPointerLive = 0;
/* ------------------------------------------------------------------------ */
@@ -427,11 +614,27 @@ static void inputOpenPads(void)
if (pads[i]) {
continue;
}
+ padRumbleOn[i] = 0; /* D401: (re)detected below when the pad opens */
pads[i] = SDL_GameControllerOpen(i);
if (pads[i]) {
connectedMask |= (1 << i);
- sysLogPrintf(LOG_NOTE, "input: opened gamepad %d '%s' as controller %d",
- i, SDL_GameControllerName(pads[i]), i);
+ /* D401: haptics detection (PD pattern, pd_port input.c:320-324).
+ * SDL_GameControllerHasRumble() landed in SDL 2.0.18 even though
+ * SDL_GameControllerRumble() is from 2.0.9. */
+#if SDL_VERSION_ATLEAST(2, 0, 18)
+ padRumbleOn[i] = SDL_GameControllerHasRumble(pads[i]);
+#else
+ padRumbleOn[i] = SDL_JoystickIsHaptic(SDL_GameControllerGetJoystick(pads[i]));
+ if (!padRumbleOn[i]) {
+ /* At least on Windows some controllers report no haptics but
+ * rumble will still function: assume it's supported for a
+ * known controller type. */
+ const SDL_GameControllerType ctype = SDL_GameControllerGetType(pads[i]);
+ padRumbleOn[i] = ctype && (ctype != SDL_CONTROLLER_TYPE_VIRTUAL);
+ }
+#endif
+ sysLogPrintf(LOG_NOTE, "input: opened gamepad %d '%s' as controller %d (rumble %s)",
+ i, SDL_GameControllerName(pads[i]), i, padRumbleOn[i] ? "yes" : "no");
}
}
for (int i = 0; i < MAX_PADS; ++i) {
@@ -457,13 +660,19 @@ static void inputOpenPads(void)
* LEFT RIGHT CUP CDOWN CLEFT CRIGHT) pulse for INPUTSCRIPT_PULSE controller
* reads from . Analog-stick tokens (SUP SDOWN SLEFT SRIGHT) are
* SUSTAINED: the stick stays deflected until a later entry changes it; SNONE
- * re-centres it. "Frame" = count of controller-0 reads since launch (roughly
+ * re-centres it. CHOLD/CREL hold/release GEPD crouch (D377);
+ * UHOLD/UREL and RELOADHOLD/RELOADREL exercise dedicated use/reload (D378).
+ * "Frame" = count of controller-0 reads since launch (roughly
* 2 per rendered frame -- watch GE_INPUTLOG to calibrate). Unset env => no
* effect; when set it is the ONLY controller-0 input source. */
#define INPUTSCRIPT_MAX 64
#define INPUTSCRIPT_PULSE 6
-struct scriptEntry { long frame; unsigned mask; int sx, sy; int hasStick; };
+struct scriptEntry { long frame; unsigned mask; int sx, sy; int hasStick;
+ int hasMouse, mdx, mdy; int hasHold, hold; /* D337 mouse/aim tokens */
+ int hasZHold, zhold; /* D207: sustained fire */
+ int hasCrouch, crouch; /* D377: free-crouch QA */
+ int hasUse, use, hasReload, reload; }; /* D378: split-action QA */
static struct scriptEntry scriptEntries[INPUTSCRIPT_MAX];
static int scriptCount = -1; /* -1 = not parsed yet, 0 = parsed empty */
static long scriptFrame = 0;
@@ -487,6 +696,29 @@ static void scriptApplyToken(struct scriptEntry *e, const char *s, int n)
return;
}
}
+ /* D337: sustained scripted mouse (px per poll) and aim hold, for headless
+ * aim-model measurements: MDX MDY (latest entry wins, 0 stops),
+ * RHOLD / RREL (hold / release the game's R aim button). */
+ if (n > 3 && (SDL_strncasecmp("MDX", s, 3) == 0 || SDL_strncasecmp("MDY", s, 3) == 0)) {
+ char num[16]; int k = n - 3; if (k > 15) k = 15;
+ memcpy(num, s + 3, k); num[k] = 0;
+ e->hasMouse = 1;
+ if (s[2] == 'X' || s[2] == 'x') e->mdx = atoi(num); else e->mdy = atoi(num);
+ return;
+ }
+ if (n == 5 && SDL_strncasecmp("RHOLD", s, 5) == 0) { e->hasHold = 1; e->hold = 1; return; }
+ if (n == 5 && SDL_strncasecmp("LHOLD", s, 5) == 0) { e->hasHold = 1; e->hold = 2; return; }
+ if (n == 4 && SDL_strncasecmp("RREL", s, 4) == 0) { e->hasHold = 1; e->hold = 0; return; }
+ /* D207: ZHOLD / ZREL hold / release fire (Z) for automatic weapons;
+ * independent of RHOLD so aim + fire can be held together. */
+ if (n == 5 && SDL_strncasecmp("ZHOLD", s, 5) == 0) { e->hasZHold = 1; e->zhold = 1; return; }
+ if (n == 4 && SDL_strncasecmp("ZREL", s, 4) == 0) { e->hasZHold = 1; e->zhold = 0; return; }
+ if (n == 5 && SDL_strncasecmp("CHOLD", s, 5) == 0) { e->hasCrouch = 1; e->crouch = 1; return; }
+ if (n == 4 && SDL_strncasecmp("CREL", s, 4) == 0) { e->hasCrouch = 1; e->crouch = 0; return; }
+ if (n == 5 && SDL_strncasecmp("UHOLD", s, 5) == 0) { e->hasUse = 1; e->use = 1; return; }
+ if (n == 4 && SDL_strncasecmp("UREL", s, 4) == 0) { e->hasUse = 1; e->use = 0; return; }
+ if (n == 10 && SDL_strncasecmp("RELOADHOLD", s, 10) == 0) { e->hasReload = 1; e->reload = 1; return; }
+ if (n == 9 && SDL_strncasecmp("RELOADREL", s, 9) == 0) { e->hasReload = 1; e->reload = 0; return; }
e->hasStick = 1;
if (n == 3 && SDL_strncasecmp("SUP", s, 3) == 0) { e->sy = STICK_MAX; return; }
if (n == 5 && SDL_strncasecmp("SDOWN", s, 5) == 0) { e->sy = -STICK_MAX; return; }
@@ -518,6 +750,11 @@ static void scriptParse(void)
e->mask = 0;
e->sx = e->sy = 0;
e->hasStick = 0;
+ e->hasMouse = e->mdx = e->mdy = 0;
+ e->hasHold = e->hold = 0;
+ e->hasZHold = e->zhold = 0;
+ e->hasCrouch = e->crouch = 0;
+ e->hasUse = e->use = e->hasReload = e->reload = 0;
while (*p && *p != ';') {
const char *tok = p;
while (*p && *p != ',' && *p != ';') ++p;
@@ -544,11 +781,49 @@ static int scriptIsActive(void)
* (a relative-mouse SDL window with no focus otherwise spews phantom deltas).
* Returns the scripted button mask for the current frame; advances the frame
* counter (call exactly once per controller-0 read). */
+/* D337: current sustained scripted mouse delta / aim hold (latest entry). */
+static int s_scriptMouseOn = 0, s_scriptMDX = 0, s_scriptMDY = 0, s_scriptHold = 0, s_scriptZHold = 0, s_scriptCrouch = 0, s_scriptUse = 0, s_scriptReload = 0;
+static void scriptPreMouse(void)
+{
+ long bestM = -1, bestH = -1, bestZ = -1, bestC = -1, bestU = -1, bestR = -1;
+ for (int i = 0; i < scriptCount; ++i) {
+ long d = scriptFrame - scriptEntries[i].frame;
+ if (d < 0) continue;
+ if (scriptEntries[i].hasMouse && scriptEntries[i].frame > bestM) {
+ bestM = scriptEntries[i].frame;
+ s_scriptMouseOn = 1;
+ s_scriptMDX = scriptEntries[i].mdx;
+ s_scriptMDY = scriptEntries[i].mdy;
+ }
+ if (scriptEntries[i].hasHold && scriptEntries[i].frame > bestH) {
+ bestH = scriptEntries[i].frame;
+ s_scriptHold = scriptEntries[i].hold;
+ }
+ if (scriptEntries[i].hasZHold && scriptEntries[i].frame > bestZ) {
+ bestZ = scriptEntries[i].frame;
+ s_scriptZHold = scriptEntries[i].zhold;
+ }
+ if (scriptEntries[i].hasCrouch && scriptEntries[i].frame > bestC) {
+ bestC = scriptEntries[i].frame;
+ s_scriptCrouch = scriptEntries[i].crouch;
+ }
+ if (scriptEntries[i].hasUse && scriptEntries[i].frame > bestU) {
+ bestU = scriptEntries[i].frame;
+ s_scriptUse = scriptEntries[i].use;
+ }
+ if (scriptEntries[i].hasReload && scriptEntries[i].frame > bestR) {
+ bestR = scriptEntries[i].frame;
+ s_scriptReload = scriptEntries[i].reload;
+ }
+ }
+}
+
static unsigned scriptApply(unsigned button)
{
if (!scriptIsActive()) {
return button;
}
+ scriptPreMouse(); /* D207: refresh sustained holds even off the mouse path (idempotent) */
unsigned m = 0;
long bestStickFrame = -1;
for (int i = 0; i < scriptCount; ++i) {
@@ -563,11 +838,21 @@ static unsigned scriptApply(unsigned button)
scriptCurSY = scriptEntries[i].sy;
}
}
+ if (s_scriptHold == 1) m |= GE_CONT_R; /* D337 RHOLD */
+ if (s_scriptHold == 2) m |= GE_CONT_L; /* D337 LHOLD (Q / "LeanLeft") */
+ if (s_scriptZHold) m |= GE_CONT_G; /* D207 ZHOLD (fire) */
scriptFrame++;
return m;
}
static void inputRebuildBinds(void); /* D214; defined below with keyDown() */
+static void inputMigrateBinds(void); /* D380; after configLoad */
+static void inputBindingProbe(void); /* D383/D384; opt-in, restores ini state */
+static void pcOptionsKeyboardPad(const Uint8 *ks, Uint32 mb, int blocked,
+ unsigned *button, int *sx, int *sy);
+
+static int bindsVersion = 0; /* D380/D386: versioned migration of effective ini binds */
+static int crouchMode = 0; /* 0 = hold, 1 = toggle (latched crouch input) */
int inputInit(void)
{
@@ -578,13 +863,30 @@ int inputInit(void)
}
}
+ /* D401: make BT PS4/PS5 pads report haptics (PD input.c:696-697). Must
+ * be set before the pads below are opened. */
+ SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS4_RUMBLE, "1");
+ SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS5_RUMBLE, "1");
+
for (int i = 0; i < MAX_PADS; ++i) {
pads[i] = NULL;
}
inputOpenPads();
+ inputMigrateBinds(); /* D380: materialise effective GEPD defaults once */
inputRebuildBinds(); /* D214: parse [Bind] now that configLoad() has run */
+ /* Wave A: one-time per-stick deadzone migration. Old inis carry a single
+ * Input.PadDeadzone; if the player has not set the new per-stick keys, seed
+ * both from it (the C-init sentinel PAD_DZ_UNSET means "not in the ini").
+ * Runs before any configSave, so the saved L/R values are always valid. */
+ if (padDeadzoneL == PAD_DZ_UNSET) padDeadzoneL = padDeadzone;
+ if (padDeadzoneR == PAD_DZ_UNSET) padDeadzoneR = padDeadzone;
+ if (getenv("GE_BINDPROBE")) inputBindingProbe();
+
+ sysLogPrintf(LOG_INFO, "input: PC bindings, crouch mode %s",
+ crouchMode ? "toggle" : "hold");
+
/* Relative mouse mode for mouse-look. Click-to-lock: we start released
* and wait for a click in the window (video.c -> inputNotifyClick). */
mouseGrabbed = 0;
@@ -597,12 +899,11 @@ int inputInit(void)
SDL_SetHint(SDL_HINT_MOUSE_RELATIVE_MODE_WARP, "0");
sysLogPrintf(LOG_INFO, "input: raw mouse input (no OS pointer accel)");
}
- SDL_SetRelativeMouseMode(mouseGrabbed ? SDL_TRUE : SDL_FALSE);
- /* Drain the initial jump. */
- SDL_GetRelativeMouseState(NULL, NULL);
+ mouseRequestRelative(mouseGrabbed);
}
applyCursorVisibility(); /* hide the OS cursor if we start focused */
+ inputApplyMouseRequests(); /* inputInit runs on the host thread */
sysLogPrintf(LOG_INFO, "input: ready (mask=0x%x, %d controller(s), aimSpeed=%d)",
connectedMask, numControllers, mouseAimSpeed);
@@ -616,6 +917,7 @@ void inputDestroy(void)
SDL_GameControllerClose(pads[i]);
pads[i] = NULL;
}
+ padRumbleOn[i] = 0; /* D401: closed */
}
if (SDL_WasInit(SDL_INIT_GAMECONTROLLER)) {
SDL_QuitSubSystem(SDL_INIT_GAMECONTROLLER);
@@ -628,7 +930,10 @@ void inputUpdate(void)
{
SDL_GameControllerUpdate();
- if (!mouseEnabled || !mouseGrabbed) {
+ /* D287: skip until the host thread has actually enabled relative mode
+ * (a benign racy read of a host-thread int), so the few ms between the
+ * logical grab and its application do not read absolute cursor motion. */
+ if (!mouseEnabled || !mouseGrabbed || s_appRelative != 1) {
return;
}
@@ -641,9 +946,8 @@ void inputUpdate(void)
mouseDY += dy;
}
-static int scaleAxis(int v)
+static int scaleAxis(int v, int dz)
{
- int dz = padDeadzone;
if (dz < 0) dz = 0;
if (dz > 30000) dz = 30000;
if (v > -dz && v < dz) {
@@ -668,11 +972,14 @@ static int keyDown(const Uint8 *ks, SDL_Scancode sc)
* (as printed by SDL_GetScancodeName: "W", "Up", "Left Ctrl", "Space", ...).
* The defaults reproduce the previously-hardcoded FPS layout exactly, so a
* fresh or [Bind]-less ini changes nothing. Parsed once in inputInit(), after
- * configLoad(). Mouse buttons (fire = LMB, aim = RMB) stay hardwired.
+ * configLoad(). D385: buttons 1..5 share these slots with keyboard keys.
* ---------------------------------------------------------------------- */
enum {
IA_FORWARD, IA_BACK, IA_STRAFE_L, IA_STRAFE_R, IA_TURN_L, IA_TURN_R,
- IA_FIRE, IA_AIM, IA_ACTION, IA_CANCEL, IA_LEAN_L, IA_START, IA_COUNT
+ IA_FIRE, IA_AIM, IA_ACTION, IA_CANCEL, IA_LEAN_L, IA_START,
+ /* Reload and crouch use the port's dedicated action/stance paths in
+ * playable stages; the native B/C-down buttons have other meanings. */
+ IA_RELOAD, IA_CROUCH, IA_COUNT
};
static const struct { const char *key; const char *def; } kBindDefs[IA_COUNT] = {
@@ -688,11 +995,123 @@ static const struct { const char *key; const char *def; } kBindDefs[IA_COUNT] =
[IA_CANCEL] = { "Input.Bind.Cancel", "X,R,F,Escape" },
[IA_LEAN_L] = { "Input.Bind.LeanLeft", "Q" },
[IA_START] = { "Input.Bind.Start", "Return,Tab" },
+ [IA_RELOAD] = { "Input.Bind.Reload", "" },
+ [IA_CROUCH] = { "Input.Bind.Crouch", "" },
};
-#define BIND_MAX_KEYS 4
+/* Legacy pre-D380 N64 defaults (kBindDefs above) are only a migration
+ * signature. The single PC layout is the GEPD/mouse default: Q = A
+ * button (accept/next weapon), E = B button (use/cancel/reload),
+ * R = dedicated reload, Ctrl = crouch). The preset is the effective default
+ * for a one-time ini conversion only. On disk, every Bind key now stores
+ * its actual effective value; `NONE` explicitly unbinds even a preset key.
+ * Fire loses its Left Ctrl key (Mouse 1 fires; Ctrl crouches), LeanLeft unbound.
+ * NULL entries retain their old defaults (movement etc.). */
+static const char *const kGepdPreset[IA_COUNT] = {
+ [IA_FIRE] = "Mouse 1",
+ [IA_AIM] = "Mouse 3,Left Shift",
+ [IA_ACTION] = "Q",
+ [IA_CANCEL] = "E",
+ [IA_LEAN_L] = "",
+ [IA_RELOAD] = "R",
+ [IA_CROUCH] = "Left Ctrl",
+};
+static int s_crouchLatch = 0;
+static int s_crouchHeldPrev = 0;
+static int s_crouchApplied = 0; /* port-owned stance; not the native C-down crouch */
+static struct player *s_crouchPlayer = NULL;
+static int s_useHeldPrev = 0, s_reloadHeldPrev = 0;
+
+/* GEPD-style crouch operates on the game's existing stance field, without
+ * holding the aim button. The game interpolates ducking_height_offset from
+ * crouchpos (bondview2.c:7038-7074). Only undo a stance we applied; preserve
+ * auto-crouch in tight spaces (autocrouchpos is a separate minimum). */
+static void inputDropCrouch(void)
+{
+ /* The engine can take over the stance (tank, respawn, etc.) between
+ * polls; do not undo its new value or write into a different player. */
+ if (s_crouchApplied && g_CurrentPlayer == s_crouchPlayer &&
+ g_CurrentPlayer->crouchpos == CROUCH_SQUAT)
+ g_CurrentPlayer->crouchpos = CROUCH_STAND;
+ s_crouchApplied = 0;
+ s_crouchPlayer = NULL;
+}
+
+#define BIND_MAX_KEYS INPUT_BIND_SLOTS
static char g_bindStr[IA_COUNT][64];
-static SDL_Scancode g_bind[IA_COUNT][BIND_MAX_KEYS];
+static int s_legacyBindWarned[IA_COUNT];
+static int g_bind[IA_COUNT][BIND_MAX_KEYS];
+
+/* Formerly LMB/RMB were unconditional, even for a NONE keyboard bind. Move
+ * that effective behaviour into the two visible slots on first v2 load.
+ * If both slots are already occupied, preserve both user keys and log the
+ * mouse default that could not fit; never keep an invisible active third. */
+static void migrateMouseDefault(int a, int button)
+{
+ char old[sizeof(g_bindStr[0])];
+ snprintf(old, sizeof(old), "%s", g_bindStr[a]);
+ char name[16];
+ snprintf(name, sizeof(name), "Mouse %d", button);
+ if (strstr(old, name)) return;
+ const char *first = old;
+ const char *comma = strchr(old, ',');
+ if (comma && strchr(comma + 1, ',')) {
+ sysLogPrintf(LOG_WARNING, "input: %s has extra legacy keys; cannot add %s in two slots",
+ kBindDefs[a].key, name);
+ return;
+ }
+ if (comma && strncmp(old, "NONE,", 5) != 0 &&
+ SDL_strcasecmp(comma + 1, "NONE") != 0) {
+ sysLogPrintf(LOG_WARNING, "input: %s already has two keys; rebind a slot to restore %s",
+ kBindDefs[a].key, name);
+ return;
+ }
+ if (SDL_strcasecmp(first, "NONE") == 0 || !*old) first = "";
+ else if (comma && strncmp(old, "NONE,", 5) == 0) first = comma + 1;
+ else if (comma) {
+ /* A trailing NONE slot is empty; preserve the first key. */
+ char *end = strchr(old, ',');
+ *end = 0;
+ first = old;
+ }
+ if (strlen(name) + (*first ? 1 + strlen(first) : 0) >= sizeof(g_bindStr[a])) {
+ sysLogPrintf(LOG_WARNING, "input: %s too long to add %s; unchanged",
+ kBindDefs[a].key, name);
+ return;
+ }
+ strcpy(g_bindStr[a], name);
+ if (*first) {
+ strcat(g_bindStr[a], ",");
+ strcat(g_bindStr[a], first);
+ }
+}
+
+static void inputMigrateBinds(void)
+{
+ if (bindsVersion >= 3) return;
+ if (bindsVersion == 2) {
+ /* D386: D385 called SDL button 2 "right" in its default, but SDL
+ * button 2 is middle; button 3 is right. Only fix the exact old
+ * default, never overwrite a user's custom Aim assignment. */
+ if (strcmp(g_bindStr[IA_AIM], "Mouse 2,Left Shift") == 0)
+ snprintf(g_bindStr[IA_AIM], sizeof(g_bindStr[IA_AIM]),
+ "Mouse 3,Left Shift");
+ }
+ if (bindsVersion == 0) {
+ for (int a = 0; a < IA_COUNT; a++) {
+ const char *preset = kGepdPreset[a];
+ if (preset && strcmp(g_bindStr[a], kBindDefs[a].def) == 0)
+ snprintf(g_bindStr[a], sizeof(g_bindStr[a]), "%s", preset);
+ }
+ }
+ if (bindsVersion < 2) {
+ migrateMouseDefault(IA_FIRE, SDL_BUTTON_LEFT);
+ migrateMouseDefault(IA_AIM, SDL_BUTTON_RIGHT);
+ }
+ bindsVersion = 3;
+ configSave();
+ sysLogPrintf(LOG_INFO, "input: migrated binds to visible PC keyboard/mouse slots");
+}
static void inputRebuildBinds(void)
{
@@ -700,44 +1119,297 @@ static void inputRebuildBinds(void)
for (int k = 0; k < BIND_MAX_KEYS; k++) {
g_bind[a][k] = SDL_SCANCODE_UNKNOWN;
}
+ /* D380: ini values ARE the effective bindings, not hidden defaults.
+ * NONE is an explicit unbind (PD-port convention). */
+ const char *def = SDL_strcasecmp(g_bindStr[a], "NONE") == 0 ? "" : g_bindStr[a];
+ /* D384: do not silently activate invisible 3rd/4th keys in the
+ * two-slot UI. Preserve the old ini text until the user edits this
+ * particular action, at which point only its visible pair is saved. */
+ const char *second = strchr(def, ',');
+ if (second && strchr(second + 1, ',') && !s_legacyBindWarned[a]) {
+ s_legacyBindWarned[a] = 1;
+ sysLogPrintf(LOG_WARNING, "input: %s has legacy extra keys (ignored; edit this action to remove)",
+ kBindDefs[a].key);
+ }
char buf[64];
- strncpy(buf, g_bindStr[a][0] ? g_bindStr[a] : kBindDefs[a].def, sizeof(buf) - 1);
+ strncpy(buf, def, sizeof(buf) - 1);
buf[sizeof(buf) - 1] = 0;
+ /* An empty bind or NONE is intentionally unbound, not a typo. */
+ int hadInput = (def == g_bindStr[a] && g_bindStr[a][0] != 0);
- int n = 0;
+ int n = 0, valid = 0;
for (char *tok = strtok(buf, ","); tok && n < BIND_MAX_KEYS; tok = strtok(NULL, ",")) {
while (*tok == ' ' || *tok == '\t') tok++;
char *end = tok + strlen(tok);
while (end > tok && (end[-1] == ' ' || end[-1] == '\t')) *--end = 0;
if (!*tok) continue;
- SDL_Scancode sc = SDL_GetScancodeFromName(tok);
+ /* Explicit NONE preserves an empty slot before later keys. */
+ if (SDL_strcasecmp(tok, "NONE") == 0) { n++; continue; }
+ int sc;
+ if (!SDL_strncasecmp(tok, "Mouse ", 6) &&
+ tok[6] >= '1' && tok[6] <= '5' && tok[7] == 0) {
+ sc = INPUT_BIND_MOUSE(tok[6] - '0');
+ } else if (!strncmp(tok, "SC:", 3)) {
+ char *endnum;
+ long id = strtol(tok + 3, &endnum, 10);
+ sc = *endnum == 0 && id > 0 && id < SDL_NUM_SCANCODES
+ ? (SDL_Scancode)id : SDL_SCANCODE_UNKNOWN;
+ } else {
+ sc = SDL_GetScancodeFromName(tok);
+ }
if (sc == SDL_SCANCODE_UNKNOWN) {
- sysLogPrintf(LOG_WARNING, "input: %s: unknown key name '%s'",
+ sysLogPrintf(LOG_WARNING, "input: %s: unknown binding '%s'",
kBindDefs[a].key, tok);
continue;
}
g_bind[a][n++] = sc;
+ valid++;
}
- if (n == 0) {
+ /* v0.4.0 M3: an intentionally-empty string (a preset or default
+ * that leaves an action unbound, e.g. GEPD Fire) is silent --
+ * only a non-empty string that produced no valid keys warns. */
+ if (valid == 0 && hadInput) {
sysLogPrintf(LOG_WARNING, "input: %s has no valid keys; action unbound",
kBindDefs[a].key);
}
}
}
+/* Re-derive binds after an F10 input option or future key capture
+ * (optionsoverlay rowSetCommit hook, scheduler thread).
+ * g_bind is read per-frame by the game thread's actHeld(): a mid-poll
+ * rebuild can at worst drop or add one scancode for one frame (the
+ * D214 startup-rebuild is the same thread-pairing, just at boot).
+ * Also drops the crouch latch on a layout/mode change. */
+void inputBindingsApply(void)
+{
+ s_crouchLatch = 0;
+ s_crouchHeldPrev = 0;
+ inputDropCrouch();
+ inputRebuildBinds();
+ sysLogPrintf(LOG_INFO, "input: PC bindings, crouch %s applied",
+ crouchMode ? "toggle" : "hold");
+}
+
+/* D384/D385: the UI edits two honest keyboard/mouse slots. Legacy extra ini tokens
+ * remain unmodified on disk until this action is explicitly edited; they
+ * cannot fire invisibly while only two slots are displayed. */
+static int bindingAction(const char *key)
+{
+ if (!key) return -1;
+ for (int a = 0; a < IA_COUNT; a++)
+ if (!strcmp(kBindDefs[a].key, key)) return a;
+ return -1;
+}
+
+const char *inputBindingSlot(const char *key, int slot)
+{
+ int a = bindingAction(key);
+ if (a < 0 || slot < 0 || slot >= BIND_MAX_KEYS ||
+ g_bind[a][slot] == SDL_SCANCODE_UNKNOWN) return "NONE";
+ if (g_bind[a][slot] > SDL_NUM_SCANCODES &&
+ g_bind[a][slot] <= INPUT_BIND_MOUSE(5)) {
+ static const char *names[] = { "Mouse 1", "Mouse 2", "Mouse 3", "Mouse 4", "Mouse 5" };
+ return names[g_bind[a][slot] - INPUT_BIND_MOUSE(1)];
+ }
+ const char *name = SDL_GetScancodeName((SDL_Scancode)g_bind[a][slot]);
+ if (name && *name && !strchr(name, ',')) return name;
+ /* Rare SDL names contain commas (e.g. keypad comma), which the ini
+ * treats as separators. The stable numeric fallback remains bindable. */
+ static _Thread_local char fallback[16];
+ snprintf(fallback, sizeof(fallback), "SC:%d", (int)g_bind[a][slot]);
+ return fallback;
+}
+
+int inputBindingSetSlot(const char *key, int slot, int sc)
+{
+ int a = bindingAction(key);
+ if (a < 0 || slot < 0 || slot >= BIND_MAX_KEYS ||
+ sc < SDL_SCANCODE_UNKNOWN || sc == SDL_NUM_SCANCODES ||
+ sc > INPUT_BIND_MOUSE(5)) return 0;
+ int slots[BIND_MAX_KEYS];
+ memcpy(slots, g_bind[a], sizeof(slots));
+ if (sc != SDL_SCANCODE_UNKNOWN) {
+ for (int i = 0; i < BIND_MAX_KEYS; i++)
+ if (i != slot && slots[i] == sc) slots[i] = SDL_SCANCODE_UNKNOWN;
+ }
+ slots[slot] = sc;
+ char buf[sizeof(g_bindStr[0])] = {0};
+ int used = 0;
+ for (int i = 0; i < BIND_MAX_KEYS; i++) {
+ if (slots[i] == SDL_SCANCODE_UNKNOWN) {
+ /* Only serialize holes before a later bound slot. */
+ int later = 0;
+ for (int j = i + 1; j < BIND_MAX_KEYS; j++)
+ if (slots[j] != SDL_SCANCODE_UNKNOWN) later = 1;
+ if (!later) continue;
+ if (used + 5 + (used != 0) >= (int)sizeof(buf)) return 0;
+ if (used) buf[used++] = ',';
+ memcpy(buf + used, "NONE", 4);
+ used += 4;
+ buf[used] = 0;
+ continue;
+ }
+ const char *name;
+ char fallback[32];
+ if (slots[i] > SDL_NUM_SCANCODES) {
+ snprintf(fallback, sizeof(fallback), "Mouse %d", slots[i] - SDL_NUM_SCANCODES);
+ name = fallback;
+ } else name = SDL_GetScancodeName((SDL_Scancode)slots[i]);
+ if (!name || !*name || strchr(name, ',')) {
+ snprintf(fallback, sizeof(fallback), "SC:%d", (int)slots[i]);
+ name = fallback;
+ }
+ int len = (int)strlen(name);
+ if (used + len + (used != 0) >= (int)sizeof(buf)) return 0;
+ if (used) buf[used++] = ',';
+ memcpy(buf + used, name, len);
+ used += len;
+ buf[used] = 0;
+ }
+ snprintf(g_bindStr[a], sizeof(g_bindStr[a]), "%s", used ? buf : "NONE");
+ inputBindingsApply();
+ return 1;
+}
+
+int inputBindingResetKey(const char *key)
+{
+ int a = bindingAction(key);
+ if (a < 0) return 0;
+ const char *def = kGepdPreset[a] ? kGepdPreset[a] : kBindDefs[a].def;
+ snprintf(g_bindStr[a], sizeof(g_bindStr[a]), "%s", def);
+ return 1;
+}
+
+/* Isolated boot probe: validate multi-slot persistence without writing ini.
+ * The entire original binding is restored before gameplay begins. */
+static void inputBindingProbe(void)
+{
+ const char *key = "Input.Bind.Crouch";
+ int a = bindingAction(key), ok = a >= 0;
+ char saved[sizeof(g_bindStr[0])];
+ if (!ok) return;
+ memcpy(saved, g_bindStr[a], sizeof(saved));
+ ok &= inputBindingSetSlot(key, 1, SDL_SCANCODE_KP_COMMA);
+ ok &= g_bind[a][1] == SDL_SCANCODE_KP_COMMA;
+ ok &= inputBindingSetSlot(key, 0, SDL_SCANCODE_UNKNOWN);
+ ok &= g_bind[a][0] == SDL_SCANCODE_UNKNOWN && g_bind[a][1] == SDL_SCANCODE_KP_COMMA;
+ ok &= strstr(g_bindStr[a], "NONE,") == g_bindStr[a];
+ ok &= inputBindingSetSlot(key, 1, SDL_SCANCODE_UNKNOWN);
+ ok &= strcmp(g_bindStr[a], "NONE") == 0;
+ memcpy(g_bindStr[a], saved, sizeof(saved));
+ inputBindingsApply();
+ /* Mouse buttons are real, serializable bindings; duplicate assignment
+ * clears the other visible slot rather than leaving a hidden action. */
+ key = "Input.Bind.Fire";
+ a = bindingAction(key);
+ memcpy(saved, g_bindStr[a], sizeof(saved));
+ ok &= inputBindingSetSlot(key, 0, INPUT_BIND_MOUSE(5));
+ ok &= strcmp(inputBindingSlot(key, 0), "Mouse 5") == 0;
+ ok &= inputBindingSetSlot(key, 1, INPUT_BIND_MOUSE(5));
+ ok &= g_bind[a][0] == 0 && g_bind[a][1] == INPUT_BIND_MOUSE(5);
+ ok &= strcmp(g_bindStr[a], "NONE,Mouse 5") == 0;
+ memcpy(g_bindStr[a], saved, sizeof(saved));
+ inputBindingsApply();
+ Uint8 keys[SDL_NUM_SCANCODES] = {0};
+ unsigned menuButton = 0;
+ int menuX = 0, menuY = 0;
+ keys[SDL_SCANCODE_A] = keys[SDL_SCANCODE_D] = keys[SDL_SCANCODE_C] = 1;
+ pcOptionsKeyboardPad(keys, 0, 0, &menuButton, &menuX, &menuY);
+ ok &= !menuButton && !menuX && !menuY; /* gameplay keys cannot steer editor */
+ keys[SDL_SCANCODE_LEFT] = keys[SDL_SCANCODE_RETURN] = 1;
+ pcOptionsKeyboardPad(keys, 0, 0, &menuButton, &menuX, &menuY);
+ ok &= menuX == -STICK_MAX && !menuY && menuButton == GE_CONT_A;
+ pcOptionsKeyboardPad(keys, 0, 1, &menuButton, &menuX, &menuY);
+ ok &= !menuButton && !menuX && !menuY;
+ sysLogPrintf(ok ? LOG_INFO : LOG_ERROR,
+ "GE_BINDPROBE: %s (2 slots, mouse 1-5, sparse/comma, A/D/C ignored, arrows/Enter, modal isolation; ini restored)",
+ ok ? "PASS" : "FAIL");
+}
+
static int actHeld(const Uint8 *ks, int act)
{
+ /* Front-end mouse clicks stay fixed UI controls. Click-to-lock while
+ * playing cannot fire a newly rebound button until the grab succeeds. */
+ Uint32 mb = mouseEnabled && mouseGrabbed &&
+ current_menu == GE_MENU_RUN_STAGE && !optionsOverlayIsOpen()
+ ? SDL_GetMouseState(NULL, NULL) : 0;
for (int k = 0; k < BIND_MAX_KEYS; k++) {
- if (keyDown(ks, g_bind[act][k])) return 1;
+ int code = g_bind[act][k];
+ if (code > SDL_NUM_SCANCODES && code <= INPUT_BIND_MOUSE(5)) {
+ if (mb & SDL_BUTTON(code - SDL_NUM_SCANCODES)) return 1;
+ } else if (code > SDL_SCANCODE_UNKNOWN && code < SDL_NUM_SCANCODES &&
+ keyDown(ks, (SDL_Scancode)code)) return 1;
}
return 0;
}
+/* PC settings use immutable keyboard navigation, independent of gameplay
+ * bindings (which are being edited here). Do not consume A/D/C as controls. */
+static void pcOptionsKeyboardPad(const Uint8 *ks, Uint32 mb, int blocked,
+ unsigned *button, int *sx, int *sy)
+{
+ *button = 0;
+ *sx = *sy = 0;
+ if (blocked) return;
+ if (ks[SDL_SCANCODE_UP] || ks[SDL_SCANCODE_KP_8] || ks[SDL_SCANCODE_W]) *sy = STICK_MAX;
+ if (ks[SDL_SCANCODE_DOWN] || ks[SDL_SCANCODE_KP_2] || ks[SDL_SCANCODE_S]) *sy = -STICK_MAX;
+ if (ks[SDL_SCANCODE_LEFT] || ks[SDL_SCANCODE_KP_4]) *sx = -STICK_MAX;
+ if (ks[SDL_SCANCODE_RIGHT] || ks[SDL_SCANCODE_KP_6]) *sx = STICK_MAX;
+ if (ks[SDL_SCANCODE_RETURN] || ks[SDL_SCANCODE_KP_ENTER]) *button |= GE_CONT_A;
+ if (ks[SDL_SCANCODE_ESCAPE]) *button |= GE_CONT_B;
+ if (mb & SDL_BUTTON(SDL_BUTTON_LEFT)) *button |= GE_CONT_A;
+ if (mb & SDL_BUTTON(SDL_BUTTON_RIGHT)) *button |= GE_CONT_B;
+}
+
+/* Direct use/reload and modern face buttons share the same game-state gate.
+ * In watch, pause, tank, front end or scripted cameras use native menu bits. */
+static int inputCanUseGameplayActions(int menuMode)
+{
+ return !menuMode && g_CurrentPlayer && !g_CurrentPlayer->bonddead &&
+ g_CurrentPlayer->outside_watch_menu && !g_CurrentPlayer->pause_state &&
+ !g_CurrentPlayer->mpmenuon && !g_PlayerIsInTank &&
+ !lvlGetControlsLockedFlag() && !gameScriptedCameraActive();
+}
+
+/* D394: the Xbox B gadget cycle has no N64 button equivalent (native A
+ * deliberately skips IDs >= ITEM_BOMBCASE). Select the next owned,
+ * equippable inventory gadget using the game's normal weapon-switch path;
+ * never conjure a missing item or touch the N64 game logic. The first press
+ * from a gun selects the lowest gadget; the next press wraps the list. */
+static void inputCycleGadget(void)
+{
+ struct player *p = g_CurrentPlayer;
+ if (!p || !p->ptr_inventory_first_in_cycle) return;
+
+ int current = get_next_weapon_in_cycle_for_hand(GUNRIGHT, 1);
+ int first = -1, next = -1;
+ InvItem *head = p->ptr_inventory_first_in_cycle;
+ InvItem *item = head;
+ do {
+ if (item->type == INV_ITEM_WEAPON) {
+ int id = item->type_inv_item.type_weap.weapon;
+ if (id >= ITEM_BOMBCASE && id <= ITEM_WATCHMAGNETATTRACT) {
+ if (first < 0 || id < first) first = id;
+ if (id > current && (next < 0 || id < next)) next = id;
+ }
+ }
+ item = item->next;
+ } while (item && item != head);
+
+ if (next < 0) next = first;
+ if (next >= 0 && next != current) {
+ gunRequestHandWeaponChange(GUNRIGHT, next, 1);
+ gunRequestHandWeaponChange(GUNLEFT, ITEM_UNARMED, 1);
+ }
+}
+
/* Fill button mask + stick for controller idx. Returns the 16-bit mask. */
unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
{
unsigned button = 0;
int sx = 0, sy = 0;
+ int crouchNow = 0;
/* D194/D238: self-correcting every poll -- cheap (plain field writes,
* see options.c cur_player_set_control_type), and re-asserts itself if
@@ -760,6 +1432,16 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
* overlay instead. Mirrors the WI-1 "cursor free in a stage -> withhold
* input" pattern. Controllers 1-3 are untouched. */
if (idx == 0 && optionsOverlayIsOpen()) {
+ const Uint8 *overlayKs = SDL_GetKeyboardState(NULL);
+ s_useHeldPrev = actHeld(overlayKs, IA_CANCEL) ||
+ (pads[0] && SDL_GameControllerGetButton(pads[0], SDL_CONTROLLER_BUTTON_A));
+ s_reloadHeldPrev = actHeld(overlayKs, IA_RELOAD) ||
+ (pads[0] && SDL_GameControllerGetButton(pads[0], SDL_CONTROLLER_BUTTON_X));
+ s_crouchLatch = 0;
+ s_crouchHeldPrev = 0;
+ inputDropCrouch();
+ padBPrev[0] = pads[0] ? SDL_GameControllerGetButton(pads[0], SDL_CONTROLLER_BUTTON_B) : 0;
+ padYPrev[0] = pads[0] ? SDL_GameControllerGetButton(pads[0], SDL_CONTROLLER_BUTTON_Y) : 0;
/* Select closes the overlay. padSelectPrev is tracked on this path
* and the open path below alike, so a button held across the
* transition cannot immediately re-toggle it. */
@@ -770,6 +1452,7 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
optionsOverlayHandleInput();
if (stick_x) *stick_x = 0;
if (stick_y) *stick_y = 0;
+ s_menuPointerLive = 0; /* D345(b): the overlay owns the mouse */
return 0;
}
@@ -780,6 +1463,17 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
int menuMode = (current_menu != GE_MENU_RUN_STAGE &&
current_menu != GE_MENU_INVALID);
+ /* D345: detect front-end screen changes (any current_menu flip,
+ * including into MENU_PC_OPTIONS / the F10 overlay's parent state)
+ * and open a short re-assert window. Runs every poll so the
+ * countdown drains even when the pointer block below is skipped
+ * (mouse disabled, unfocused window, legacy velocity mode). */
+ if ((int)current_menu != menuPointerLastMenuId) {
+ menuPointerLastMenuId = (int)current_menu;
+ menuPointerTransitionFrames = 3; /* this poll + two more */
+ }
+ if (menuPointerTransitionFrames > 0) menuPointerTransitionFrames--;
+
/* D194: aim mode drives the view from GRABBED relative deltas (see
* aimAbsCompute) -- the cursor stays hidden and clipped to the window
* for the whole hold. The earlier free-cursor experiment (suspend the
@@ -790,6 +1484,16 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
* pre-D194. */
s_absAimSuspend = 0;
reconcileGrab(menuMode);
+ /* D337 harness: scripted sustained mouse replaces the real deltas and
+ * behaves as a grabbed mouse (headless windows are never focused). */
+ if (scriptIsActive()) {
+ scriptPreMouse();
+ if (s_scriptMouseOn) {
+ mouseDX = (double)s_scriptMDX;
+ mouseDY = (double)s_scriptMDY;
+ mouseGrabbed = 1;
+ }
+ }
/* D196: the F10 options overlay forces the OS cursor visible via
* inputSuspendForOverlay() but nothing re-hides it on close unless
* reconcileGrab() happens to re-grab (only true if you had already
@@ -816,7 +1520,13 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
* because the stick's Y axis is what carries continuous analog
* pitch there instead. Strafe and turn are unchanged -- both
* schemes read them the same way. */
- if (naturalPitchMode) {
+ /* D345(d): natural pitch is an in-stage aim scheme -- the N64 front
+ * end navigates on the analog stick only, so in menus W/S (and the
+ * Up/Down arrows) must emit stick even with NaturalPitch=1, or no
+ * front-end screen (file select, cheat, MENU_PC_OPTIONS) can be
+ * driven vertically from the keyboard. Watch/pause keeps the C-button
+ * mapping: it is current_menu==RUN_STAGE (menuMode 0), N64-style. */
+ if (naturalPitchMode && !menuMode) {
if (actHeld(ks, IA_FORWARD)) button |= GE_CONT_E; /* C-up = forward */
if (actHeld(ks, IA_BACK)) button |= GE_CONT_D; /* C-down = back */
} else {
@@ -827,11 +1537,21 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
if (actHeld(ks, IA_STRAFE_R)) button |= GE_CONT_F; /* strafe right */
if (actHeld(ks, IA_TURN_L)) sx = -STICK_MAX; /* keyboard turn */
if (actHeld(ks, IA_TURN_R)) sx = STICK_MAX;
+ if (sx) s_aimDevMouse = 0; /* D337: keyboard stick turn is the active aim device */
- if ((mb & SDL_BUTTON(SDL_BUTTON_LEFT)) || actHeld(ks, IA_FIRE))
+ if (actHeld(ks, IA_FIRE))
button |= GE_CONT_G;
- int aimHeld = (mb & SDL_BUTTON(SDL_BUTTON_RIGHT)) != 0 ||
- actHeld(ks, IA_AIM);
+ /* aimButton = the Aim bind (emits the game's R);
+ * aimHeld = "the game is in aim mode" for routing the mouse. D337: the
+ * game's aim buttons in 1.1/1.2 are L|R (bondview2.c aimButtons), so Q
+ * ("LeanLeft", sends L) is a second aim button -- it used to leave the
+ * mouse on the hipfire camera path, the accidental "centred aim" #104
+ * found. Routing also follows the game's own insightaimmode, which
+ * covers the Aim Control "Toggle" option and gamepad aim. The R emit
+ * stays tied to the physical button so Toggle still sees edges. */
+ int aimButton = actHeld(ks, IA_AIM);
+ int aimHeld = aimButton || actHeld(ks, IA_LEAN_L) ||
+ (g_CurrentPlayer != NULL && g_CurrentPlayer->insightaimmode);
int aimRisingEdgeAim = aimHeld && !s_aimHeldPrev;
if (aimRisingEdgeAim && g_CurrentPlayer && g_CurrentPlayer->docentreupdown)
s_centreClearTicks = 2; /* see D194 centre-spring note above */
@@ -841,8 +1561,19 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
* frame so re-entry starts where the game left it. */
s_gepdHeldPrev = 0;
if (g_CurrentPlayer) {
- s_gepdCrossX = (double) g_CurrentPlayer->crosshair_x_pos;
- s_gepdCrossY = (double) g_CurrentPlayer->crosshair_y_pos;
+ if (pdMouseAimEnabled()) {
+ /* D338: the accumulator is in GEPD units (±LIMIT = edge);
+ * adopt through the game's screen mapping (offset =
+ * pos*(1-damp), gunfire.c) and the aim-range scale, so aim
+ * entry starts exactly where the crosshair is drawn. */
+ double k = (1.0 - (double) g_CurrentPlayer->guncrossdamp) / 0.99
+ * GEPD_CROSSHAIR_LIMIT / aimRangeScale();
+ s_gepdCrossX = (double) g_CurrentPlayer->crosshair_x_pos * k;
+ s_gepdCrossY = (double) g_CurrentPlayer->crosshair_y_pos * k;
+ } else {
+ s_gepdCrossX = (double) g_CurrentPlayer->crosshair_x_pos / aimRangeScale();
+ s_gepdCrossY = (double) g_CurrentPlayer->crosshair_y_pos / aimRangeScale();
+ }
}
}
s_aimHeldPrev = aimHeld;
@@ -851,7 +1582,7 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
sysLogPrintf(LOG_NOTE,
"GE_INPUTLOG absaim centre-spring armed at aim entry; nudging to clear");
}
- if (aimHeld)
+ if (aimButton)
button |= GE_CONT_R;
if (actHeld(ks, IA_ACTION))
button |= GE_CONT_A;
@@ -878,8 +1609,71 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
button |= GE_CONT_A | GE_CONT_G;
wheelBack--;
}
- if (actHeld(ks, IA_CANCEL)) /* D145: Escape is in the default Cancel bind */
+ /* D378/D393: GE's B tap calls bond_interact_object(), then reloads
+ * only when no target was found (lv.c:796). E and pad A use the same
+ * interaction with NO fallback; R and pad X only reload. Their
+ * combined edges prevent a held input from repeatedly using a door.
+ * In menus/watch/tank the pad keeps native accept/cancel buttons. */
+ int padUse = pads[0] && SDL_GameControllerGetButton(pads[0], SDL_CONTROLLER_BUTTON_A);
+ int padReload = pads[0] && SDL_GameControllerGetButton(pads[0], SDL_CONTROLLER_BUTTON_X);
+ int useNow = scriptIsActive() ? s_scriptUse : (actHeld(ks, IA_CANCEL) || padUse);
+ int reloadNow = scriptIsActive() ? s_scriptReload : (actHeld(ks, IA_RELOAD) || padReload);
+ int playable = inputCanUseGameplayActions(menuMode);
+ /* D407: on the N64 the B bit also drives bondview2.c's tank
+ * handlers (board when g_BondCanEnterTank, exit while
+ * g_PlayerIsInTank). The D378/D393 split above routes E/pad A to a
+ * dedicated interact call without presenting B, which left tank
+ * board/exit dead on PC (the tank blocks movement but can never be
+ * entered -- Runway, Streets). Present B only in the tank states so
+ * E keeps its no-reload-fallback semantics everywhere else.
+ * In the tank states the dedicated interact call is ALSO skipped:
+ * on the N64 the use/reload fallback (lv.c:796) runs on the B bit
+ * AFTER the board handler in the same tick, so pre-activating the
+ * tank prop on the input side (propobjInteract ->
+ * RUNTIMEBITFLAG_ACTIVATED, propobj.c) reorders the N64 sequence
+ * and flaps g_BondCanEnterTank between input sampling and the
+ * board check (user: still can't board, 2026-09-28). */
+ int tankState = (g_PlayerIsInTank == 1 || g_BondCanEnterTank != 0);
+ /* D407 (cont.): board-animation lockout. The game's B-tap handler
+ * (bondview2.c) is a toggle: while g_PlayerIsInTank is set, a B tap
+ * is the EXIT path. Boarding commits a ~45-frame (0.75 s) sit blend
+ * during which g_EnterTankAudioState stays TANK_RUN_STATE_NOT_RUNNING;
+ * only once it finishes does the engine SFX start. A key bounce or a
+ * player re-pressing E in that silent window ("nothing is happening")
+ * hits the exit a few frames later and silently cancels the whole
+ * animation -- 2026-09-28 playtest: every attempt was a
+ * board->exit microcycle, so no seat, no engine, and the tank camera
+ * transition reverted ("tries to do something, cancels out"). An N64
+ * player simply presses once and waits; give the keyboard the same
+ * guarantee in the port layer: suppress B from E while the entry
+ * animation is running. Once seated, E = exit again. */
+ static int s_tankBoardLock = 0;
+ s_tankBoardLock = (g_PlayerIsInTank == 1 &&
+ g_EnterTankAudioState == TANK_RUN_STATE_NOT_RUNNING);
+ if (playable) {
+ if (useNow && !s_useHeldPrev && !tankState) {
+ bool empty = bond_interact_object();
+ if (configGetInputLog()) sysLogPrintf(LOG_NOTE, "GE_INPUTLOG dedicated use (no target=%d)", empty);
+ }
+ } else if ((scriptIsActive() ? s_scriptUse : actHeld(ks, IA_CANCEL)) && !tankState) {
+ /* A on the pad is still native accept outside playable stages.
+ * D407: NOT in tank states -- there B must come only from the
+ * locked edge path below. This mapping is level-triggered on E;
+ * in-tank (playable=false) it would re-present B on any E press,
+ * bypassing the board-animation lockout and re-edging the game's
+ * own exit toggle (2026-09-28: unprobed board->exit microcycles). */
button |= GE_CONT_B;
+ }
+ if (useNow && !s_useHeldPrev && tankState && !s_tankBoardLock)
+ button |= GE_CONT_B;
+ if (reloadNow && !s_reloadHeldPrev && playable) {
+ attempt_reload_item_in_hand(GUNRIGHT);
+ attempt_reload_item_in_hand(GUNLEFT);
+ if (configGetInputLog()) sysLogPrintf(LOG_NOTE, "GE_INPUTLOG dedicated reload (no use)");
+ }
+ s_useHeldPrev = useNow;
+ s_reloadHeldPrev = reloadNow;
+ crouchNow = actHeld(ks, IA_CROUCH);
if (actHeld(ks, IA_LEAN_L))
button |= GE_CONT_L;
if (actHeld(ks, IA_START))
@@ -909,6 +1703,8 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
edy *= mouseYScale / 100.0;
double dyLook = edy * invert; /* >0 => look down */
+ if (!menuMode && (fabs(edx) > 0.01 || fabs(dyLook) > 0.01))
+ s_aimDevMouse = 1; /* D337: mouse is the active aim device */
/* D194(b): gameplay-look-only dt normalization -- see the
* MOUSE_DT_REF comment above. Computed unconditionally (so the
@@ -992,8 +1788,30 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
lastMenuMouseX = mx;
lastMenuMouseY = my;
}
- if (mx != lastMenuMouseX || my != lastMenuMouseY) {
+ /* D345: re-assert right after a screen change when the
+ * mouse was the recent navigation device (click-to-
+ * enter). A stale idle mouse (WASD/arrow navigation)
+ * keeps the cede-to-keyboard behaviour: no yank. */
+ Uint64 pcNow = SDL_GetPerformanceCounter();
+ int mouseRecent = (menuPointerLastAbsWrite != 0) &&
+ ((double)(pcNow - menuPointerLastAbsWrite) /
+ (double)SDL_GetPerformanceFrequency() * 1000.0) <
+ MENU_POINTER_REASSERT_MS;
+ if ((mx != lastMenuMouseX || my != lastMenuMouseY) ||
+ (menuPointerTransitionFrames > 0 && mouseRecent)) {
double fx = (double)mx / (double)ww;
+ /* D335: under native widescreen the front end is
+ * pillarboxed to a centred 4:3 region
+ * (front.c menu_jump_constructor_handler), so map
+ * the pointer across that region, not the window. */
+ {
+ extern f32 portNativeAspect(void);
+ f32 na = portNativeAspect();
+ if (na > (4.0f / 3.0f)) {
+ double vis = (4.0 / 3.0) / (double)na; /* visible width fraction */
+ fx = (fx - (1.0 - vis) * 0.5) / vis;
+ }
+ }
double fy = (double)my / (double)wh;
if (fx < 0.0) fx = 0.0; else if (fx > 1.0) fx = 1.0;
if (fy < 0.0) fy = 0.0; else if (fy > 1.0) fy = 1.0;
@@ -1001,6 +1819,7 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
cursor_v_pos = (float)(loV + fy * (hiV - loV));
sx = 0; /* pointer owns the cursor this poll */
sy = 0;
+ menuPointerLastAbsWrite = pcNow;
}
lastMenuMouseX = mx;
lastMenuMouseY = my;
@@ -1012,14 +1831,51 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
"GE_INPUTLOG menuptr abs=%d cursor=(%.1f,%.1f) stick=(%d,%d)",
haveAbs, (double)cursor_h_pos, (double)cursor_v_pos, sx, sy);
}
+ } else if (g_CurrentPlayer != NULL &&
+ (!g_CurrentPlayer->outside_watch_menu ||
+ g_CurrentPlayer->pause_state != 0)) {
+ /* D330 (#103): in-stage watch / pause -- the mouse drives
+ * nothing. menuMode is 0 here (current_menu is still
+ * RUN_STAGE) and hipDirectCompute declines on this same gate,
+ * so the legacy stick fallbacks below used to turn mouse
+ * motion into stick deflection (and an RMB hold ran the GEPD
+ * aim writes, which GEPD itself gates on !watch): a ~15 px flick
+ * crossed the watch's +/-0x2E left/right threshold, which on
+ * a selected option means "pick OFF/ON" (options.c
+ * game_option_toggle_input) and otherwise flips watch pages,
+ * while mouse Y scrolled the rows. Auto-aim / look-ahead
+ * "wouldn't stay on". Keyboard and pad still drive the watch
+ * exactly as before. */
} else if (aimHeld) {
/* D194 GEPD-mirror aim: direct crosshair/camera writes, no
* look stick (see aimGepdCompute). Keyboard turn (sx/sy set
* above) still works. Otherwise fall through to the legacy
- * velocity stick below. */
- if (!aimGepdCompute(edx * lookDtScale, dyLook * lookDtScale)) {
+ * velocity stick below.
+ * D407 follow-up: in-tank the game aims the turret from raw
+ * stick deflection ONLY (bondview2.c HONEY/SOLITARE branch:
+ * |stick_x|/|stick_y| > 60 -> aimTurn/speedVerta), so the
+ * direct-write models must be bypassed there and the legacy
+ * velocity stick emitted instead. */
+ int tankAimStick = (g_PlayerIsInTank == 1);
+ if (!tankAimStick && aimModeGet() == AIMMODE_CENTRED) {
+ /* D333: FPS-style centred aim -- the camera takes the
+ * mouse (same path as hipfire, which also declines on
+ * the watch/pause/cutscene gates) and no stick is
+ * emitted, so the crosshair settles at centre. */
+ hipDirectCompute(edx * lookDtScale, dyLook * lookDtScale);
+ } else if (!tankAimStick && pdMouseAimEnabled()) {
+ /* PD model: accumulate only. The game's own integrator is
+ * driven by the port-supplied turn through the
+ * sub_GAME_7F067FBC hook (see Input.PdMouseAim above). */
+ aimGepdAccumulate(edx, dyLook);
+ /* Edge-scroll belongs to the aim position, not to the
+ * crosshair model -- without this the camera stops
+ * following the crosshair at the screen edge. */
+ aimGepdEdgeScroll();
+ } else if (tankAimStick || !aimGepdCompute(edx * lookDtScale, dyLook * lookDtScale)) {
double aimEdx = edx * lookDtScale, aimDyLook = dyLook * lookDtScale;
double aimSens = (mouseAimSpeed / 100.0) * (mouseSensitivity / 100.0);
+ if (tankAimStick) aimSens *= tankAimScale / 100.0; /* D407 */
double gamma = aimCurveGamma / 100.0;
double normX = fabs(aimEdx) * aimSens / AIM_FULL_SPEED_PX;
double normY = fabs(aimDyLook) * aimSens / AIM_FULL_SPEED_PX;
@@ -1101,7 +1957,30 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
if (mb & SDL_BUTTON(SDL_BUTTON_LEFT)) button |= GE_CONT_A;
if (mb & SDL_BUTTON(SDL_BUTTON_RIGHT)) button |= GE_CONT_B;
}
+ /* D384: the PC settings editor MUST NOT navigate using live game
+ * binds. Otherwise capturing A/D/C instantly turns that held key
+ * into C-left/right or stick input, cycling the selected bind slot
+ * and chasing the menu cursor (user repro, bind log). Physical arrow
+ * keys and Enter/Escape are fixed menu controls; mouse/pad still
+ * work. Scripted QA input remains the sole source when active. */
+ if (current_menu == MENU_PC_OPTIONS && !scriptIsActive())
+ {
+ pcOptionsKeyboardPad(ks, mb, optionsBindingInputBlocked(), &button, &sx, &sy);
+ /* D407(b): on this screen the mouse wheel scrolls the row list
+ * -- wheel up = step UP, wheel down = step DOWN (same sign
+ * convention as the W/S keys above: +STICK_MAX = up). The
+ * original mapping had them swapped (2026-09-28 user report).
+ * Front-end menus otherwise leave the wheel queue unconsumed in
+ * menu mode -- clear it here so a scroll can't leak into the
+ * next stage's weapon cycle (D223). */
+ if (!optionsBindingInputBlocked())
+ {
+ if (wheelBack > 0) { sy = STICK_MAX; wheelBack = 0; } /* wheel up */
+ else if (wheelFwd > 0) { sy = -STICK_MAX; wheelFwd = 0; } /* wheel down */
+ }
+ }
menuPrevActive = menuMode;
+ s_menuPointerLive = (menuMode && mouseEnabled && !mouseGrabbed) ? 1 : 0;
mouseDX = 0.0;
mouseDY = 0.0;
@@ -1129,8 +2008,8 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
current_menu != GE_MENU_INVALID);
if (padMenuMode) {
- int px = scaleAxis(lx);
- int py = -scaleAxis(ly); /* SDL up = negative -> N64 up = positive */
+ int px = scaleAxis(lx, padDeadzoneL);
+ int py = -scaleAxis(ly, padDeadzoneL); /* SDL up = negative -> N64 up = positive */
if (px) sx = px;
if (py) sy = py;
} else if (naturalPitchMode) {
@@ -1144,16 +2023,47 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
if (lx < -RSTICK_THRESHOLD) button |= GE_CONT_C; /* strafe left */
if (lx > RSTICK_THRESHOLD) button |= GE_CONT_F; /* strafe right */
- int rxs = scaleAxis(rx);
- int rys = -scaleAxis(ry); /* SDL up = negative -> N64 up = positive */
+ /* Wave A (v0.5.0): right-stick look -- per-stick deadzone (right),
+ * per-axis sensitivity (% of native, 100 = unchanged, re-clamped to
+ * STICK_MAX), and an optional low-pass. All feel-only, identity at
+ * their defaults (sens 100%, smoothing off). */
+ int rxs = scaleAxis(rx, padDeadzoneR);
+ int rys = -scaleAxis(ry, padDeadzoneR); /* SDL up = negative -> N64 up = positive */
+ if (padLookSensX != 100 && rxs) rxs = rxs * padLookSensX / 100;
+ if (padLookSensY != 100 && rys) rys = rys * padLookSensY / 100;
+ if (rxs > STICK_MAX) rxs = STICK_MAX; else if (rxs < -STICK_MAX) rxs = -STICK_MAX;
+ if (rys > STICK_MAX) rys = STICK_MAX; else if (rys < -STICK_MAX) rys = -STICK_MAX;
+ if (rxs || rys) s_aimDevMouse = 0; /* D337: pad aim active */
+ if (padLookSmooth > 0) {
+ double a = padLookSmooth * 0.09; /* 10 -> 0.9 (heaviest); 0 = off */
+ padSmSX[idx] = padSmSX[idx] * a + (double)rxs * (1.0 - a);
+ padSmSY[idx] = padSmSY[idx] * a + (double)rys * (1.0 - a);
+ rxs = (int)lround(padSmSX[idx]);
+ rys = (int)lround(padSmSY[idx]);
+ }
if (padLookInvertY) rys = -rys;
- if (rxs) sx = rxs;
- if (rys) sy = rys;
+ if (aimModeGet() == AIMMODE_CENTRED && padDirectCompute(rxs, rys)) {
+ /* D404 (resolved by implementation, 2026-09-28): pad CENTRED
+ * aim -- the stick drives the camera directly (crosshair
+ * pinned at centre), the pad-side twin of the mouse CENTRED
+ * branch above, so the Aim-style toggle is no longer inert
+ * on a pad. No look stick is emitted this poll; when
+ * padDirectCompute declines (dead/watch/pause/cutscene) the
+ * legacy stick emission below runs, as the mouse branch
+ * falls back to its legacy path. AIMMODE_N64 keeps today's
+ * behaviour: the deflection feeds the game's crosshair
+ * integrator (travel + edge scroll + spring-back -- the
+ * original controller feel). */
+ } else {
+ if (rxs) sx = rxs;
+ if (rys) sy = rys;
+ }
} else {
- int px = scaleAxis(lx);
- int py = -scaleAxis(ly); /* SDL up = negative -> N64 up = positive */
+ int px = scaleAxis(lx, padDeadzoneL);
+ int py = -scaleAxis(ly, padDeadzoneL); /* SDL up = negative -> N64 up = positive */
if (px) sx = px;
if (py) sy = py;
+ if (px || py) s_aimDevMouse = 0; /* D337: pad aim active */
if (padLookInvertY) ry = -ry;
if (rx > RSTICK_THRESHOLD) button |= GE_CONT_F;
@@ -1167,32 +2077,38 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
button |= GE_CONT_G;
if (SDL_GameControllerGetAxis(pad, SDL_CONTROLLER_AXIS_TRIGGERLEFT) > trigPt)
button |= GE_CONT_R;
-
- /* Modern dual-stick layout (Xbox re-release style; the Steam Deck
- * target). A/X = action/use/reload (the game's context-sensitive A
- * line), B/Y = crouch, and LB/RB rising edges cycle weapons.
- * In-game cycling is an A edge (forward) or A+Z held on the same tick
- * (backward -- bondview2.c weaponForwardOffset/weaponBackOffset, the
- * same trick the mouse wheel uses above); emit for exactly one poll
- * so holding RB cannot latch invButtons and block firing. */
- if (SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_A) ||
- SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_X))
- button |= GE_CONT_A;
- if (SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_B) ||
- SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_Y))
- button |= GE_CONT_B;
- {
- int lbNow = SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_LEFTSHOULDER);
- int rbNow = SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_RIGHTSHOULDER);
- int *prev = &padShoulderPrev[idx];
- /* Track edge state in menus too: a shoulder held across the
- * menu->game transition must not fire a cycle on entry. */
- if (!padMenuMode) {
- if (rbNow && !(*prev & 1)) button |= GE_CONT_A; /* next weapon */
- if (lbNow && !(*prev & 2)) button |= GE_CONT_A | GE_CONT_G; /* prev weapon */
- }
- *prev = (rbNow ? 1 : 0) | (lbNow ? 2 : 0);
+ /* Wave A: southpaw swaps the fire (G) and grenade (R) trigger actions
+ * (right<->left trigger), after the raw edges above are captured. */
+ if (padSouthpaw) {
+ int t = button;
+ button = (t & ~(GE_CONT_G | GE_CONT_R))
+ | ((t & GE_CONT_G) ? GE_CONT_R : 0)
+ | ((t & GE_CONT_R) ? GE_CONT_G : 0);
}
+
+ /* Xbox 1.1 Jinx-style gameplay: A=use, X=reload (handled by the
+ * keyboard action gate), B=gadgets, Y=weapons, stick clicks=crouch,
+ * LB=aim. RB's remaster HD toggle is unavailable in the N64 port.
+ * Menus/watch/tank retain native accept/cancel mappings. */
+ int padA = SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_A);
+ int padX = SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_X);
+ int padB = SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_B);
+ int padY = SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_Y);
+ int facePlayable = idx == 0 && inputCanUseGameplayActions(padMenuMode);
+ if (facePlayable) {
+ if (SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_LEFTSTICK) ||
+ SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_RIGHTSTICK))
+ crouchNow = 1;
+ if (padB && !padBPrev[idx]) inputCycleGadget();
+ if (padY && !padYPrev[idx]) button |= GE_CONT_A;
+ } else {
+ if (padA || padX) button |= GE_CONT_A;
+ if (padB || padY) button |= GE_CONT_B;
+ }
+ padBPrev[idx] = padB;
+ padYPrev[idx] = padY;
+ if (facePlayable && SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_LEFTSHOULDER))
+ button |= GE_CONT_R;
if (SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_START))
button |= GE_CONT_START;
if (SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_DPAD_UP))
@@ -1209,11 +2125,57 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
* Deck). The game never reads BACK, so nothing is withheld. */
{
int selNow = SDL_GameControllerGetButton(pad, SDL_CONTROLLER_BUTTON_BACK);
- if (selNow && !padSelectPrev) optionsOverlayToggle();
+ /* D343: not on the PC options screen (one options UI at a time). */
+ if (selNow && !padSelectPrev && !frontOptionsBlocksOverlay()) optionsOverlayToggle();
padSelectPrev = selNow;
}
}
+ if (idx == 0 && scriptIsActive()) crouchNow = s_scriptCrouch;
+
+ /* The GEPD preset's crouch key is independent of aim. Do not turn it
+ * into C-down: outside aim C-down moves backwards in 1.2, and inside
+ * aim it also zooms on weapons that disable crouch. This is a PC input
+ * adapter writing the same stance field the native game adjusts, not
+ * a change to the N64 game logic. */
+ if (idx == 0) {
+ int stage = current_menu == GE_MENU_RUN_STAGE || current_menu == GE_MENU_INVALID;
+ if (!stage) s_crouchLatch = 0;
+ if (crouchMode == 1) {
+ if (stage && crouchNow && !s_crouchHeldPrev) s_crouchLatch ^= 1;
+ s_crouchHeldPrev = crouchNow;
+ crouchNow = s_crouchLatch;
+ }
+ struct player *p = g_CurrentPlayer;
+ int canCrouch = stage && p && !p->bonddead && p->outside_watch_menu &&
+ !p->pause_state && !p->mpmenuon && !g_PlayerIsInTank &&
+ !lvlGetControlsLockedFlag() &&
+ !gameScriptedCameraActive() &&
+ !bondwalkItemCheckBitflags(getCurrentPlayerWeaponId(GUNRIGHT),
+ WEAPONSTATBITFLAG_DISABLE_CROUCH);
+ if (canCrouch && crouchNow) {
+ /* In aim mode the native crouchUp branch runs every tick without
+ * C-down, undoing the port stance by one step. Feed the native
+ * axis ONLY while aiming; hipfire still crouches by stance alone. */
+ if (p->insightaimmode || (button & (GE_CONT_R | GE_CONT_L)))
+ button |= GE_CONT_D;
+ p->crouchpos = CROUCH_SQUAT;
+ if (!s_crouchApplied && configGetInputLog())
+ sysLogPrintf(LOG_NOTE, "GE_INPUTLOG free crouch applied (aim=%d)", p->insightaimmode);
+ s_crouchApplied = 1;
+ s_crouchPlayer = p;
+ } else if (!(canCrouch && (button & GE_CONT_D) && p->insightaimmode)) {
+ /* If native C-down is still held in aim, let the game retain its
+ * own crouch on this tick rather than force a stand. */
+ if (s_crouchApplied && configGetInputLog())
+ sysLogPrintf(LOG_NOTE, "GE_INPUTLOG free crouch released");
+ inputDropCrouch();
+ } else {
+ s_crouchApplied = 0;
+ s_crouchPlayer = NULL;
+ }
+ }
+
if (idx == 0 && scriptIsActive()) {
/* D263: apply BEFORE the stick is written out -- scripted stick
* tokens (SUP/SDOWN/SLEFT/SRIGHT) used to be discarded. */
@@ -1230,14 +2192,37 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y)
if (stick_x) *stick_x = (signed char)sx;
if (stick_y) *stick_y = (signed char)sy;
+ /* D337: GE_AIMLOG=1 -- per-poll aim-model trace (the game's own crosshair
+ * state), for measuring jitter / range / edge-scroll headlessly. */
+ if (idx == 0 && g_CurrentPlayer != NULL && GE_ENVFLAG("GE_AIMLOG") &&
+ (current_menu == GE_MENU_RUN_STAGE || current_menu == GE_MENU_INVALID)) {
+ sysLogPrintf(LOG_NOTE, "AIMLOG f=%ld aim=%d dev=%d drawx=%.3f cx=%.5f cy=%.5f theta=%.4f verta=%.4f acc=%.4f",
+ scriptFrame, (int)g_CurrentPlayer->insightaimmode, s_aimDevMouse,
+ (double)g_CurrentPlayer->crosshair_angle.f[0],
+ (double)g_CurrentPlayer->crosshair_x_pos, (double)g_CurrentPlayer->crosshair_y_pos,
+ (double)g_CurrentPlayer->vv_theta, (double)g_CurrentPlayer->vv_verta, s_gepdCrossX);
+ }
if (configGetInputLog() && (button || sx || sy)) {
sysLogPrintf(LOG_NOTE, "GE_INPUTLOG cont%d: btn=%04x stick=(%d,%d)",
idx, button, sx, sy);
}
-
return button;
}
+int inputMenuPointerLive(void)
+{
+ /* D345(b): true while the 1:1 menu pointer owns cursor_h/v_pos -- in a
+ * menu, the abs pointer is available, and the mouse was used recently.
+ * Port screens that teleport the crosshair (frontoptions.c cursorToItem)
+ * skip their snap when this is set: the cursor is already under the OS
+ * pointer. Keyboard/D-pad users (stale mouse) still get the snap. */
+ if (!s_menuPointerLive || menuPointerLastAbsWrite == 0) return 0;
+ Uint64 now = SDL_GetPerformanceCounter();
+ return ((double)(now - menuPointerLastAbsWrite) /
+ (double)SDL_GetPerformanceFrequency() * 1000.0) <
+ MENU_POINTER_REASSERT_MS;
+}
+
static void applyGrab(int want)
{
want = want && mouseEnabled;
@@ -1245,10 +2230,7 @@ static void applyGrab(int want)
return;
}
mouseGrabbed = want;
- SDL_SetRelativeMouseMode(want ? SDL_TRUE : SDL_FALSE);
- if (want) {
- SDL_GetRelativeMouseState(NULL, NULL); /* drain the accumulated jump */
- }
+ mouseRequestRelative(want); /* D287: applied on the host thread */
mouseDX = mouseDY = 0.0;
}
@@ -1274,7 +2256,7 @@ static void applyCursorVisibility(void)
/* D194: while an aim hold has the cursor out for absolute aim, show it --
* the user is pointing with it and needs to see where. */
int hide = windowFocused && mouseEnabled && !s_absAimSuspend;
- SDL_ShowCursor(hide ? SDL_DISABLE : SDL_ENABLE);
+ mouseRequestCursor(!hide); /* D287: applied on the host thread */
}
/* video.c focus events. Records focus and lets reconcileGrab() decide. */
@@ -1328,10 +2310,10 @@ void inputSuspendForOverlay(void)
{
if (mouseGrabbed) {
mouseGrabbed = 0;
- SDL_SetRelativeMouseMode(SDL_FALSE);
+ mouseRequestRelative(0);
mouseDX = mouseDY = 0.0;
}
- SDL_ShowCursor(SDL_ENABLE);
+ mouseRequestCursor(1);
}
void inputPostWheel(int notches)
@@ -1357,12 +2339,61 @@ void inputRescanPads(void)
SDL_GameControllerClose(pads[i]);
pads[i] = NULL;
}
+ padRumbleOn[i] = 0; /* D401: re-detected by inputOpenPads below */
+ padSmSX[i] = 0.0; /* Wave A: clear the look-smoothing EMA so a stick
+ padSmSY[i] = 0.0; * snap-back after re-plug can't lag from a stale value */
}
inputOpenPads();
sysLogPrintf(LOG_NOTE, "input: rescanned pads (mask=0x%x, %d controller(s))",
connectedMask, numControllers);
}
+/* ------------------------------------------------------------------------
+ * D401: Rumble Pak -> real gamepad haptics.
+ *
+ * The game's Rumble-Pak state machine (src/joy.c joyRumblePakInit/Tick;
+ * events from gunfire.c / bondview2.c / lv.c / sched.c) drives the
+ * osMotor* shims in port/src/libultra.c, which route here. `strength`
+ * is 0..1 and `time` is seconds; a 0-strength / 0-duration call stops
+ * an active rumble. The global Input.RumbleScale config value (0 = fully
+ * silent) scales the strength for every pad. idx is the N64 controller
+ * channel 0..3 (MAXCONTROLLERS may be 6 on JPN; out-of-range is a no-op).
+ * ------------------------------------------------------------------------ */
+int inputRumbleSupported(int idx)
+{
+ if (idx < 0 || idx >= MAX_PADS) return 0;
+ return padRumbleOn[idx];
+}
+
+void inputRumble(int idx, f32 strength, f32 time)
+{
+ if (idx < 0 || idx >= MAX_PADS || !pads[idx]) return;
+ if (gRumbleScale <= 0.f) return;
+ if (padRumbleOn[idx]) {
+ strength *= gRumbleScale;
+ if (strength <= 0.f) {
+ strength = 0.f;
+ time = 0.f;
+ } else {
+ strength *= 65535.f;
+ time *= 1000.f;
+ }
+ SDL_GameControllerRumble(pads[idx], (Uint16)strength, (Uint16)strength, (Uint32)time);
+ }
+}
+
+f32 inputRumbleGetScale(void)
+{
+ return gRumbleScale;
+}
+
+void inputRumbleSetScale(f32 v)
+{
+ if (v < 0.f) v = 0.f;
+ if (v > 1.f) v = 1.f;
+ gRumbleScale = v;
+}
+
int inputConnectedMask(void)
{
return connectedMask;
@@ -1413,12 +2444,16 @@ int inputGetNumControllers(void)
* Returns 1 if it handled this poll (callers must NOT emit a look stick);
* 0 means fall back to the legacy velocity stick.
*/
-static int aimGepdCompute(double dxPx, double dyLook)
+/* Accumulate this poll's mouse delta into the aim position. Shared by both
+ * aim models: GEPD pushes the result into crosshair_x/y_pos itself, PD feeds
+ * it to the game's integrator as the turn (see Input.PdMouseAim). Returns 0
+ * if aim is not usable this poll. */
+static int aimGepdAccumulate(double dxPx, double dyLook)
{
struct player *p = g_CurrentPlayer;
/* Needs the grabbed-cursor relative deltas (capture mode, locked in a
- * stage) and a live player. dxPx/dyLook are this poll's dt-scaled px. */
+ * stage) and a live player. dxPx/dyLook are this poll's px. */
if (!aimAbsolute || !mouseGrabbed || p == NULL)
return 0;
@@ -1442,34 +2477,27 @@ static int aimGepdCompute(double dxPx, double dyLook)
if (s_gepdCrossX < -GEPD_CROSSHAIR_LIMIT) s_gepdCrossX = -GEPD_CROSSHAIR_LIMIT;
if (s_gepdCrossY > GEPD_CROSSHAIR_LIMIT) s_gepdCrossY = GEPD_CROSSHAIR_LIMIT;
if (s_gepdCrossY < -GEPD_CROSSHAIR_LIMIT) s_gepdCrossY = -GEPD_CROSSHAIR_LIMIT;
+ return 1;
+}
+/* Camera edge-scroll: past GEPD_EDGE_THRESHOLD (72%) of the way to the
+ * crosshair limit, scroll the view proportionally to the overshoot, scaled by
+ * zoom like GEPD's (fov/basefov). This belongs to the aim POSITION, not to
+ * whichever model owns the crosshair, so both call it -- PD's model needs it
+ * too, or dragging the crosshair to the edge no longer moves the camera. */
+static void aimGepdEdgeScroll(void)
+{
+ struct player *p = g_CurrentPlayer;
f32 fov = viGetFovY();
- f32 fovratio = (fov > 0.0f) ? fov / GEPD_BASE_FOV : 1.0f;
- /* Pre-overwrite residue: what last tick's damped update left behind
- * (crosshair_pos = pos*damp + turn, gunfire.c caclulate_gun_crosshair_...
- * runs AFTER our write each tick). The gap between this and our write
- * below is the game-side turn term (autoaimx/y or speedtheta*0.3) --
- * logged for the D194 spazz diagnosis. */
- double resX = (double) p->crosshair_x_pos;
- double resY = (double) p->crosshair_y_pos;
-
- /* Crosshair + gun/arm pose (GEPD formulas, RATIOFACTOR=1 for our 4:3
- * viewport; failsafe weapon offsets 0.15/0 as in goldeneye.c). */
- p->crosshair_x_pos = (f32) s_gepdCrossX;
- p->crosshair_y_pos = (f32) s_gepdCrossY;
- p->gun_azimuth_angle = (f32) (s_gepdCrossX * (1.11f + 0.15f * 1.5f) + fovratio - 1.0f);
- p->gun_azimuth_turning = (f32) (s_gepdCrossY * 1.11f + fovratio - 1.0f);
-
- /* Edge scroll: only past 72% of the way to the edge, proportional to
- * overshoot; scaled by zoom like GEPD's (fov/basefov). */
double rX = s_gepdCrossX / GEPD_CROSSHAIR_LIMIT;
double rY = s_gepdCrossY / GEPD_CROSSHAIR_LIMIT;
double aimx = 0.0, aimy = 0.0;
- if (rX > GEPD_EDGE_THRESHOLD) aimx = (rX - GEPD_EDGE_THRESHOLD) * GEPD_SCROLL_SPEED / 60.0;
- else if (rX < -GEPD_EDGE_THRESHOLD) aimx = (rX + GEPD_EDGE_THRESHOLD) * GEPD_SCROLL_SPEED / 60.0;
- if (rY > GEPD_EDGE_THRESHOLD) aimy = (rY - GEPD_EDGE_THRESHOLD) * GEPD_SCROLL_SPEED / 60.0;
- else if (rY < -GEPD_EDGE_THRESHOLD) aimy = (rY + GEPD_EDGE_THRESHOLD) * GEPD_SCROLL_SPEED / 60.0;
+ const double th = aimEdgeThreshold(); /* D338 */
+ if (rX > th) aimx = (rX - th) * GEPD_SCROLL_SPEED / 60.0;
+ else if (rX < -th) aimx = (rX + th) * GEPD_SCROLL_SPEED / 60.0;
+ if (rY > th) aimy = (rY - th) * GEPD_SCROLL_SPEED / 60.0;
+ else if (rY < -th) aimy = (rY + th) * GEPD_SCROLL_SPEED / 60.0;
f32 scale = (fov > 0.0f) ? fov / GEPD_BASE_FOV : 1.0f;
if (aimx != 0.0) {
@@ -1486,6 +2514,34 @@ static int aimGepdCompute(double dxPx, double dyLook)
if (p->vv_verta > 90.0f) p->vv_verta = 90.0f;
if (p->vv_verta < -90.0f) p->vv_verta = -90.0f;
}
+}
+
+static int aimGepdCompute(double dxPx, double dyLook)
+{
+ struct player *p = g_CurrentPlayer;
+
+ if (!aimGepdAccumulate(dxPx, dyLook))
+ return 0;
+
+ f32 fov = viGetFovY();
+ f32 fovratio = (fov > 0.0f) ? fov / GEPD_BASE_FOV : 1.0f;
+
+ /* Pre-overwrite residue: what last tick's damped update left behind
+ * (crosshair_pos = pos*damp + turn, gunfire.c caclulate_gun_crosshair_...
+ * runs AFTER our write each tick). The gap between this and our write
+ * below is the game-side turn term (autoaimx/y or speedtheta*0.3) --
+ * logged for the D194 spazz diagnosis. */
+ double resX = (double) p->crosshair_x_pos;
+ double resY = (double) p->crosshair_y_pos;
+
+ /* Crosshair + gun/arm pose (GEPD formulas, RATIOFACTOR=1 for our 4:3
+ * viewport; failsafe weapon offsets 0.15/0 as in goldeneye.c). */
+ p->crosshair_x_pos = (f32) (s_gepdCrossX * aimRangeScale()); /* D338 */
+ p->crosshair_y_pos = (f32) (s_gepdCrossY * aimRangeScale());
+ p->gun_azimuth_angle = (f32) (s_gepdCrossX * (1.11f + 0.15f * 1.5f) + fovratio - 1.0f);
+ p->gun_azimuth_turning = (f32) (s_gepdCrossY * 1.11f + fovratio - 1.0f);
+
+ aimGepdEdgeScroll();
if (configGetInputLog()) {
/* ct = g_ClockTimer: game ticks batched into this poll. If the
@@ -1553,7 +2609,6 @@ static int hipDirectCompute(double dxPx, double dyLook)
* directly, bypassing that dispatch entirely. Reuses the same
* scripted-camera test D243's clamps use (bondview2.c, gameScriptedCameraActive,
* formerly d243mCutsceneActive -- renamed since this isn't D243-specific). */
- extern int gameScriptedCameraActive(void);
if (gameScriptedCameraActive())
return 0;
@@ -1577,10 +2632,100 @@ static int hipDirectCompute(double dxPx, double dyLook)
return 1;
}
+/* D404 (resolved by implementation, 2026-09-28): the pad-side twin of
+ * hipDirectCompute's CENTRED path -- the right stick drives the camera
+ * directly and the crosshair stays pinned at centre, so the Aim-style
+ * toggle is no longer inert on a pad (it used to be: the pad always
+ * emitted deflection into the game's N64 crosshair integrator, i.e. it
+ * was *always in N64 mode*). Same safety gates as hipDirectCompute
+ * (dead / watch / pause / scripted camera); the mouseDirectLook/
+ * mouseGrabbed gate is dropped -- it is meaningless for a pad. Input
+ * units are stick deflection (STICK_MAX = full, the same unit the N64
+ * path feeds the game's integrator).
+ *
+ * Gain = the N64 natural-turn curve VERBATIM (bondview2.c, canNaturalTurn
+ * / canNaturalPitch branches): v = clamp(deflection/70, \u00b11); v =
+ * sign(v)\u00b7v\u00b2; camera += v \u00b7 (fov/60) \u00b7 3.5 deg/poll. First pass used a
+ * linear 2.0 deg/poll \u00b7 (fov/GEPD_BASE_FOV) gain, which the user measured
+ * as a dramatic sensitivity loss in Centred mode (linear vs quadratic
+ * curve, and the 60-vs-90 FOV base compounded it to ~2.6\u00d7 slower at full
+ * stick). With the verbatim curve, full-stick Centred turns at exactly
+ * the N64 path's top rate -- switching modes changes the crosshair
+ * behaviour, not the feel. */
+static int padDirectCompute(int dx, int dy)
+{
+ struct player *p = g_CurrentPlayer;
+
+ if (p == NULL)
+ return 0;
+ if (p->bonddead || !p->outside_watch_menu || p->pause_state != 0)
+ return 0;
+ if (gameScriptedCameraActive())
+ return 0;
+
+ if (dx == 0 && dy == 0)
+ return 1; /* nothing to turn; caller simply emits no stick */
+
+ f32 fov = viGetFovY();
+ double k = (fov > 0.0f) ? (double)fov / 60.0 : 1.0; /* bondview2's base */
+ double v;
+
+ v = (double)dx / 70.0; /* analogTurn = raw stick \u00b1 5, /70 -- bondview2.c:6222 */
+ if (v > 1.0) v = 1.0; else if (v < -1.0) v = -1.0;
+ if (v >= 0.0) v *= v; else v = -v * v;
+ p->vv_theta += (f32) (v * k * 3.5);
+
+ v = (double)dy / 70.0; /* analogPitch, /70 -- bondview2.c:6161 */
+ if (v > 1.0) v = 1.0; else if (v < -1.0) v = -1.0;
+ if (v >= 0.0) v *= v; else v = -v * v;
+ p->vv_verta -= (f32) (v * k * 3.5); /* game: speedverta = -v\u00b7(fov/60) */
+ if (p->vv_verta > 90.0f) p->vv_verta = 90.0f;
+ if (p->vv_verta < -90.0f) p->vv_verta = -90.0f;
+
+ if (configGetInputLog()) {
+ sysLogPrintf(LOG_NOTE,
+ "GE_INPUTLOG paddirect d=(%d,%d) cam=(%.1f,%.1f)",
+ dx, dy, (double)p->vv_theta, (double)p->vv_verta);
+ }
+ return 1;
+}
+
+/* --- Input.PdMouseAim: the port side of PD's mouse-aim model ----------------
+ * See the flag's comment above for the design. PD's own implementation lives
+ * in its GAME files (bondmove.c/bondgun.c) behind `#ifndef PLATFORM_N64`, with
+ * the port supplying the input; we mirror that with `#ifdef PORT` hooks in
+ * GE's aim path (sub_GAME_7F067FBC, gunfire.c).
+ *
+ * The port accumulates the mouse delta (aimGepdAccumulate, shared with GEPD's
+ * model) and the game's own crosshair integrator does the rest. PD's range is
+ * swivelpos in [-1,1] == the screen edge, which is what the game's display
+ * formula maps to half a screen width; GEPD's crosshair limit is that same
+ * edge in its own units, so dividing by it gives PD's units. */
+int portMouseAimPdActive(void)
+{
+ /* D337: only while the mouse is the device driving aim -- otherwise the
+ * pad's stick turn (the N64 path, weapon damp) must reach the game. */
+ return pdMouseAimEnabled() && mouseGrabbed && s_aimDevMouse;
+}
+
+int portMouseAimPdGetTurn(f32 *tx, f32 *ty)
+{
+ if (!portMouseAimPdActive() || g_CurrentPlayer == NULL)
+ return 0;
+ if (tx) *tx = (f32) (s_gepdCrossX / GEPD_CROSSHAIR_LIMIT * aimRangeScale()); /* D338 */
+ if (ty) *ty = (f32) (s_gepdCrossY / GEPD_CROSSHAIR_LIMIT * aimRangeScale());
+ return 1;
+}
+
PD_CONSTRUCTOR static void inputConfigInit(void)
{
configRegisterInt("Input.MouseEnabled", &mouseEnabled, 0, 1);
configRegisterInt("Input.MouseAimSpeed", &mouseAimSpeed, 1, 500);
+ configRegisterInt("Input.PdMouseAim", &pdMouseAim, 0, 1); /* legacy -> AimStyle 1 */
+ configRegisterInt("Input.AimStyle", &aimStyleLegacy, 0, 2); /* D333 legacy -> AimMode */
+ configRegisterInt("Input.AimMode", &aimMode, 0, 1); /* D337 */
+ configRegisterInt("Input.AimLegacyGepd", &aimLegacyGepd, 0, 1); /* D337 hidden fallback */
+ configRegisterInt("Input.AimRange", &aimRange, 0, 1); /* D338 PC / N64 */
configRegisterInt("Input.AimAbsolute", &aimAbsolute, 0, 1); /* D194 */
configRegisterInt("Input.MouseDirectLook", &mouseDirectLook, 0, 1); /* WI-1 */
/* D194: renamed Input.GepdSens -> Input.AimModeSens (community name for
@@ -1599,6 +2744,7 @@ PD_CONSTRUCTOR static void inputConfigInit(void)
configRegisterInt("Input.AimModeSens", &gepdSens, 1, 500);
configRegisterInt("Input.GepdSens", &gepdSens, 1, 500); /* deprecated alias */
configRegisterInt("Input.AimBand", &aimBand, 5, 40);
+ configRegisterInt("Input.TankAimScale", &tankAimScale, 10, 300); /* D407 */
configRegisterInt("Input.MouseTurnSpeed", &mouseTurnSpeed, 1, 500);
configRegisterInt("Input.SensLink", &sensLink, 0, 1);
configRegisterInt("Input.MenuPointerSpeed", &menuPointerSpeed, 10, 500);
@@ -1612,15 +2758,31 @@ PD_CONSTRUCTOR static void inputConfigInit(void)
configRegisterInt("Input.MouseSensitivity", &mouseSensitivity, 1, 500); /* D238 */
configRegisterInt("Input.MouseAimCurve", &aimCurveGamma, 50, 400); /* D194(a), x100 */
configRegisterInt("Input.NaturalPitch", &naturalPitchMode, 0, 1); /* D194/D238 */
- configRegisterInt("Input.PadDeadzone", &padDeadzone, 0, 30000);
+ configRegisterInt("Input.PadDeadzone", &padDeadzone, 0, 30000); /* legacy; Wave A migrates to L/R */
+ configRegisterInt("Input.PadDeadzoneL", &padDeadzoneL, 0, 30000); /* Wave A: left (movement) stick */
+ configRegisterInt("Input.PadDeadzoneR", &padDeadzoneR, 0, 30000); /* Wave A: right (look) stick */
+ configRegisterInt("Input.PadLookSensX", &padLookSensX, 25, 200); /* Wave A: look horizontal sensitivity (%) */
+ configRegisterInt("Input.PadLookSensY", &padLookSensY, 25, 200); /* Wave A: look vertical (pitch) sensitivity (%) */
+ configRegisterInt("Input.PadSouthpaw", &padSouthpaw, 0, 1); /* Wave A: swap fire/grenade triggers */
+ configRegisterInt("Input.PadLookSmooth", &padLookSmooth, 0, 10); /* Wave A: look low-pass strength, 0-10 (0 = off) */
configRegisterInt("Input.PadTriggerPct", &padTriggerPct, 1, 99);
configRegisterInt("Input.PadLookInvertY", &padLookInvertY, 0, 1);
+ /* D401: global rumble scale (0 = silent .. 1 = full), applied to every
+ * pad in inputRumble(). One setting for all pads (user call, 2026-09-28):
+ * the original per-pad Input.PlayerN.RumbleScale keys were dropped --
+ * stale ones in an existing ini log a one-time "unknown key" note and
+ * self-clean on the next configSave. */
+ configRegisterFloat("Input.RumbleScale", &gRumbleScale, 0.f, 1.f);
+ /* D380: single PC layout. Old Input.Layout is ignored and removed on
+ * configSave; BindingsVersion marks the one-time stale-default migration. */
+ configRegisterInt("Input.BindingsVersion", &bindsVersion, 0, 3);
+ configRegisterInt("Input.CrouchMode", &crouchMode, 0, 1);
- /* D214: keyboard rebinding. Seed each buffer with its default so the knob
- * is visible/editable in a fresh ge007.ini; configLoad() overwrites any the
- * user set, then inputInit() calls inputRebuildBinds(). */
+ /* Seed fresh ini files with the REAL PC binding strings. Older ini
+ * values are migrated at inputInit after configLoad, exactly once. */
for (int a = 0; a < IA_COUNT; a++) {
- strncpy(g_bindStr[a], kBindDefs[a].def, sizeof(g_bindStr[a]) - 1);
+ const char *def = kGepdPreset[a] ? kGepdPreset[a] : kBindDefs[a].def;
+ strncpy(g_bindStr[a], def, sizeof(g_bindStr[a]) - 1);
configRegisterString(kBindDefs[a].key, g_bindStr[a], sizeof(g_bindStr[a]));
}
}
diff --git a/port/src/libultra.c b/port/src/libultra.c
index 3b48d744..6c29fe5f 100644
--- a/port/src/libultra.c
+++ b/port/src/libultra.c
@@ -50,6 +50,7 @@
#include "input.h"
#include "fs.h"
#include "romdata.h"
+#include "watchsettings.h"
#include "crash.h"
#if defined(PLATFORM_WINDOWS)
@@ -706,6 +707,10 @@ s32 osRecvMesg(OSMesgQueue *mq, OSMesg *msg, s32 flag)
d60logRecv(mq, m); /* TEMP D60 */
pthread_cond_signal(&pq->cond);
pthread_mutex_unlock(&pq->lock);
+ /* gfxFrameMsgQ is consumed only by boss.c's game thread. Apply queued
+ * F10 watch edits there, after releasing the OS queue lock; the SDL
+ * input/scheduler and render threads never touch GE watch/save state. */
+ if (mq == &gfxFrameMsgQ) watchSettingsGameTick();
return 0;
}
@@ -1094,7 +1099,10 @@ static void contSnapshotFromKeyboard(void)
u16 button = connected ? (u16)inputComputePad(i, &sx, &sy) : 0;
g_contStatus[i].type = connected ? CONT_TYPE_NORMAL : 0;
- g_contStatus[i].status = 0;
+ /* D401: present a Rumble Pak (CONT_CARD_ON) whenever the connected
+ * pad can actually rumble. joyRumblePakInit (src/joy.c:184) gates
+ * the whole rumble-pak path on this bit. */
+ g_contStatus[i].status = (connected && inputRumbleSupported(i)) ? CONT_CARD_ON : 0;
g_contStatus[i].errno = connected ? 0 : CONT_NO_RESPONSE_ERROR;
g_contPad[i].button = button;
@@ -1279,26 +1287,55 @@ static void geEepromPatchAllCheats(u8 *buf)
memset(&slots[5], 0, sizeof(ge_save_slot));
int changed = 0;
- for (int i = 0; i < 5; i++) {
- /* D259: a fresh ge007.eep is zero-filled, so every slot reads as
- * all-zero. Normally such a slot fails fileValidateSaves' CRC and is
- * reset to BLANKSAVEDATA (music_vol/sfx_vol = 0xFF); but this patch
- * gives the slot a valid CRC below, so it survives with volume 0
- * -> silence. Emulate fileResetSave: seed max volume on all-zero
- * slots only -- real saves (incl. a deliberately muted one) keep
- * their bytes. */
- {
+
+ /* D259 + D281: a fresh ge007.eep is zero-filled, so every slot reads as
+ * all-zero. Without this patch such a slot fails fileValidateSaves' CRC
+ * and becomes a free BLANKSAVEDATA slot (fileResetSave), after which
+ * fileBuildWriteNewSave fills the first free slot for each folder with
+ * no save, in folder order. This patch gives every slot a valid CRC
+ * below, so an all-zero slot would instead SURVIVE as-is: five slots all
+ * claiming folder 1 with volume 0 (D259: silence) and options 0 (D281:
+ * sight-on-screen, auto-aim, look-ahead and ammo display all off, which
+ * reads as "RMB aim broken"). D259 seeded only the volumes. Reproduce
+ * the game's full result instead: all-zero slots take the folders that
+ * no real slot holds, in order, built exactly as fileBuildWriteNewSave
+ * builds them (BLANKSAVEDATA + folder + not-free + bond); any left over
+ * become free BLANKSAVEDATA slots. Real saves keep their bytes. */
+ {
+ extern void fileSetSaveFoldernum(void *save, u32 folder);
+ extern void fileSetSaveFlagDoReset(void *save, s32 enable);
+ extern void fileSetSelectedBond(void *save, s32 bond);
+ static const ge_save_slot blank = {
+ 0, 0, 0x80 /* SAVEFLAGS_SET(0,0,BOND_BROSNAN,1): free */, 0x00,
+ 0xFF, 0xFF, 0x3A /* DEFAULT_OPTIONS */, 0, 0, 0, 0, {0}
+ };
+ int allzero[5], present[4] = {0, 0, 0, 0};
+ for (int i = 0; i < 5; i++) {
const u8 *raw = (const u8 *)&slots[i];
- int allzero = 1;
+ allzero[i] = 1;
for (int b = 0; b < (int)sizeof(ge_save_slot); b++)
- if (raw[b]) { allzero = 0; break; }
- if (allzero && (slots[i].music_vol != 0xFF ||
- slots[i].sfx_vol != 0xFF)) {
- slots[i].music_vol = 0xFF;
- slots[i].sfx_vol = 0xFF;
- changed = 1;
+ if (raw[b]) { allzero[i] = 0; break; }
+ if (!allzero[i] && !(slots[i].completion_bitflags & 0x80 /* DORESET */)) {
+ int f = slots[i].completion_bitflags & 0x7; /* SAVEFLAG_FOLDER */
+ if (f < 4) present[f] = 1; /* MAX_FOLDER_COUNT */
}
}
+ int folder = 0;
+ for (int i = 0; i < 5; i++) {
+ if (!allzero[i]) continue;
+ slots[i] = blank;
+ while (folder < 4 && present[folder]) folder++;
+ if (folder < 4) {
+ fileSetSaveFoldernum(&slots[i], (u32)folder);
+ fileSetSaveFlagDoReset(&slots[i], 0);
+ fileSetSelectedBond(&slots[i], folder);
+ present[folder] = 1;
+ }
+ changed = 1;
+ }
+ }
+
+ for (int i = 0; i < 5; i++) {
/* Cheat ids are level ids 0..19 (CHEAT_INPUT_BUFFER_SIZE == 20):
* bits 0-7 in _1, 8-15 in _2, 16-19 in the low nibble of _3. */
if (slots[i].unlocked_cheats_1 != 0xFF ||
@@ -1375,15 +1412,62 @@ s32 osEepromLongRead(OSMesgQueue *mq, u8 addr, u8 *buf, int nbytes)
s32 osEepromLongWrite(OSMesgQueue *mq, u8 addr, u8 *buf, int nbytes)
{ (void)mq; return geEepromRW(addr, buf, nbytes, 1); }
-/* Memory Pak (PFS) + Rumble Pak (motor): no accessories on the PC. */
+/* Memory Pak (PFS) + Rumble Pak (motor): no Memory Pak on the PC (saves are
+ * file-backed EEPROM), but the Rumble Pak path is routed to real gamepad
+ * haptics (D401): a pad that can rumble presents itself as a "card is on"
+ * accessory, and the motor calls become SDL_GameControllerRumble via
+ * inputRumble (port/src/input.c). */
s32 osPfsInit(OSMesgQueue *queue, OSPfs *pfs, int channel)
-{ (void)queue; (void)pfs; (void)channel; return PFS_ERR_NOPACK; }
+{
+ (void)queue; (void)pfs;
+ /* PFS_ERR_DEVICE ("wrong device type") = a Rumble Pak is present, not a
+ * Memory Pak: exactly what joyRumblePakInit (src/joy.c:186-190) needs
+ * to proceed to osMotorInit. PD's osPfsInitPak does the same. */
+ return inputRumbleSupported(channel) ? PFS_ERR_DEVICE : PFS_ERR_NOPACK;
+}
s32 osPfsIsPlug(OSMesgQueue *queue, u8 *pattern)
-{ (void)queue; if (pattern) *pattern = 0; return 0; }
+{
+ (void)queue;
+ if (pattern) {
+ *pattern = 0;
+ /* JPN: MAXCONTROLLERS can be 6 > MAX_PADS; inputRumbleSupported()
+ * bounds-checks, so the extra channels just never light up. */
+ for (int i = 0; i < MAXCONTROLLERS; ++i)
+ if (inputRumbleSupported(i)) *pattern |= (u8)(1 << i);
+ }
+ return 0;
+}
s32 osMotorInit(OSMesgQueue *mq, OSPfs *pfs, int channel)
-{ (void)mq; (void)pfs; (void)channel; return -1; }
-s32 osMotorStart(OSPfs *pfs) { (void)pfs; return -1; }
-s32 osMotorStop(OSPfs *pfs) { (void)pfs; return -1; }
+{
+ if (pfs && inputRumbleSupported(channel)) {
+ pfs->queue = mq;
+ pfs->channel = channel;
+ pfs->activebank = 0xff;
+ /* NOTE: PFS_MOTOR_INITIALIZED is not defined in GE's headers and
+ * joy.c never reads pfs->status -- it only tests osMotorInit()==0
+ * (src/joy.c:190) to mark the pad RUMBLEPAKINITSTATE_READY, so no
+ * status store is needed (PD's osMotorProbe writes one). */
+ return 0;
+ }
+ (void)mq; (void)channel;
+ return PFS_ERR_NOPACK;
+}
+s32 osMotorStart(OSPfs *pfs)
+{
+ if (!pfs) return PFS_ERR_NOPACK;
+ /* The N64 motor has no duration; the game's joyRumblePakTimer60 (src/joy.c)
+ * arms a per-event countdown and turns the motor off via osMotorStop.
+ * Issue one generous 5 s window, PD-style ("hope the timer stops it");
+ * inputRumble no-ops internally for non-rumble pads. */
+ inputRumble(pfs->channel, 1.0f, 5.0f);
+ return 0;
+}
+s32 osMotorStop(OSPfs *pfs)
+{
+ if (!pfs) return PFS_ERR_NOPACK;
+ inputRumble(pfs->channel, 0.0f, 0.0f); /* zero strength/duration = stop */
+ return 0;
+}
/* ------------------------------------------------------------------------ */
/* SP (RSP) — runs the software RSP inline, then posts the done messages */
diff --git a/port/src/main.c b/port/src/main.c
index 93c598da..b35e596e 100644
--- a/port/src/main.c
+++ b/port/src/main.c
@@ -121,7 +121,7 @@ int main(int argc, char **argv)
}
/* 1. Platform + config + filesystem. Steam Deck / SteamOS: seed the
- * first-run preset (native 1280x800 fullscreen, MSAA 4, longer draw/LOD
+ * first-run preset (native 1280x800 fullscreen, MSAA 2, midpoint draw/LOD
* distances) before the load so a missing ini saves these values; an
* existing ini always wins. */
if (getenv("STEAMOS")) {
diff --git a/port/src/optionsoverlay.c b/port/src/optionsoverlay.c
index 9caa1937..68122989 100644
--- a/port/src/optionsoverlay.c
+++ b/port/src/optionsoverlay.c
@@ -8,9 +8,9 @@
*
* The panel adapts to whatever 2D space it is drawn in (320x240 in-game vs
* 440x330 on front-end screens -- viSetXY differs) and scrolls when the row
- * list outgrows the viewport (wheel / arrows at the edges). Cyclic rows
- * (MSAA, texture filter, resolution, toggles) wrap in both directions; the
- * manual % rows (draw/LOD distance) are hidden while their "auto" toggle is on.
+ * list outgrows the viewport (wheel / arrows at the edges). F10 opens at a
+ * category list; each category has its own short page and Back row. Cyclic
+ * rows (MSAA, texture filter, resolution, toggles) wrap in both directions.
*
* Text + fill helpers are the game's own (textRender / microcode_constructor /
* gDPFillRectangle) reached by extern -- same pattern input.c uses to read
@@ -38,20 +38,36 @@
#define _SHIFTR(v, s, w) ((u32)(((u32)(v) >> (s)) & ((0x01 << (w)) - 1)))
#endif
#include
+#include
+
+extern MENU current_menu;
#include "platform.h"
#include "system.h"
#include "config.h"
#include "video.h"
#include "input.h"
+#include "front.h" /* selected_folder_num (D356 stage probe) */
+#include "file.h" /* save_data (D356 reset probe: second-file isolation) */
#include "optionsoverlay.h"
+#include "watchsettings.h"
#include "../fast3d/gfx_api.h"
+/* file2.c; same extern as watchsettings.c (not in a header). */
+extern save_data *fileGetSaveForFoldernum(u32 folder);
+
+/* D324 class: -Iinclude resolves to GE's N64 stub, which does not
+ * declare lround; without this the call is an implicit `int lround()`
+ * (GCC only rescued it via its builtin signature). */
+long lround(double x);
+
/* ---- game symbols (rendering/UI only; see input.c for the same pattern) ---- */
struct font;
struct fontchar;
extern struct font *ptrFontBankGothic;
extern struct fontchar *ptrFontBankGothicChars;
+extern struct font *ptrFontZurichBold;
+extern struct fontchar *ptrFontZurichBoldChars;
extern Gfx *microcode_constructor(Gfx *gdl);
extern Gfx *textRender(Gfx *gdl, s32 *x, s32 *y, char *text, struct fontchar *chars,
struct font *font, u32 colour, s32 width, s32 height,
@@ -63,11 +79,35 @@ extern s16 viGetY(void);
/* ------------------------------------------------------------------------ */
-enum { ROW_TOGGLE, ROW_SLIDER, ROW_ENUM, ROW_MSAA, ROW_RES, ROW_ACTION, ROW_FPSCAP };
+enum { ROW_TOGGLE, ROW_SLIDER, ROW_ENUM, ROW_MSAA, ROW_RES, ROW_ACTION, ROW_FPSCAP,
+ ROW_HEADER, ROW_BOND_FILE, ROW_BIND /* key / mouse-button capture */ };
-static const char *const kOnOff[] = { "OFF", "ON", NULL };
-static const char *const kTexFilter[] = { "NEAREST", "BILINEAR", "3-POINT", NULL };
+/* D346: wording pass -- Nightdive/Turok + PD-port conventions: title-case
+ * On/Off, no all-caps value strings. Display-only; config stores 0/1 either way. */
+static const char *const kOnOff[] = { "Off", "On", NULL };
+static const char *const kReverse[] = { "Reverse", "Upright", NULL };
+static const char *const kHold[] = { "Hold", "Toggle", NULL };
+static const char *const kTexFilter[] = { "Nearest", "Bilinear", "3-Point", NULL };
+static const char *const kAimMode[] = { "N64", "Centred (PC)", NULL }; /* D337 */
+static const char *const kAimRange[] = { "PC", "N64", NULL }; /* D338 */
static const int kMsaaSeq[] = { 1, 2, 4, 8 };
+/* v0.4.0 modern options wave: value names for the new rows (they land
+ * in the functional sections -- Turok standard, D356 -- not a bucket). */
+static const char *const kOnOffRev[] = { "On", "Off", NULL }; /* 0 = On */
+/* M7 (v0.4.0, D387): AllUnlocked ships as a loudly-documented EXPERIMENTAL,
+ * irreversible option. Enabling it and then saving (even a profile-settings
+ * change) bakes synthetic cheat/completion times into the EEPROM that
+ * switching OFF does not undo; it also breaks audio/aim on a fresh save with
+ * no real save yet (D257/D259/D281). Back up data/ge007.eep before enabling.
+ * Display-only value names; config still stores 0/1. */
+static const char *const kAllUnlocked[] = { "Off", "ON - UNSAFE", NULL };
+/* D379: the authored N64 sprite is red. Original is the identity path;
+ * White at index 7 uses the same alpha-mask combiner as other true hues. */
+static const char *const kCrosshairColor[] = {
+ "Original (red)", "Green", "Red", "Blue", "Yellow", "Cyan", "Magenta",
+ "White", "Custom", NULL,
+};
+static const char *const kCrosshairStyle[] = { "Original", "Thin cross", NULL };
/* D186: the sim's own tick pacemaker is hardcoded to the console's native VI
* rate (60Hz NTSC / 50Hz PAL, port/src/libultra.c) -- Video.FpsCap can only
* throttle down from there, never past it, and throttling it below 30
@@ -95,6 +135,8 @@ static const int kResList[][2] = {
static int s_resFit[NUM_RES]; /* indices into kResList that fit the desktop */
static int s_resFitN = 0;
static int s_resSel = 0; /* index into s_resFit */
+static int s_dragRow = -1; /* scheduler-thread mouse drag */
+static SDL_atomic_t s_dragWatchField; /* field+1, host may close F10 mid-drag */
struct Row {
const char *key;
@@ -111,58 +153,216 @@ struct Row {
void *ptr;
double cfgMin, cfgMax;
- /* Hidden while the option named here is nonzero (a manual % row
- * disappears while its "auto" toggle is on). Resolved to hidePtr at init. */
+ /* Optional conditional row visibility (e.g. Aim range while centred).
+ * Resolved to hidePtr at init. */
const char *hiddenIfOn;
int *hidePtr;
+ /* Show RGB sliders only while the colour selector is set to Custom. */
+ const char *shownWhen;
+ int shownValue;
+ int *showPtr;
+
+ /* D346: value-text decoration (display only). unit is appended to integer
+ * slider values ("%"/"x"); dispDiv>0 divides the raw value before display
+ * (e.g. deadzone raw 0..30000 -> % of full stick). NULL/0 = plain int. */
+ const char *unit;
+ int dispDiv;
+ int dispMax; /* >0: show the int value as "N/dispMax" (e.g. 0-10 -> "5/10") */
+
+ /* D356: 1 = this row's value lives in the selected save file (per-file
+ * watch rows). Carries the dim "(per profile)" tag on the only section that
+ * mixes scopes (GAMEPLAY/HUD); on headers the flag is recomputed at init to
+ * mean "this section contains per-file rows" (drives the "(File N)"
+ * title annotation in both UIs). */
+ int saveScoped;
+ int bindSlot; /* D383/D385: selected key/mouse slot, 0..1 */
};
static struct Row rows[] = {
- { "Video.Fullscreen", "Fullscreen", ROW_TOGGLE, 1, kOnOff, 0, 0, 0, 0,0,0,0,0 },
- { "__Resolution", "Resolution", ROW_RES, 0, NULL, 0, 0, 0, 0,0,0,0,0 },
- { "Video.VSync", "VSync", ROW_TOGGLE, 1, kOnOff, 0, 0, 0, 0,0,0,0,0 },
- { "Video.FpsCap", "Frame cap", ROW_FPSCAP, 0, NULL, 0, 0, 0, 0,0,0,0,0 },
- { "Video.MSAA", "MSAA", ROW_MSAA, 0, NULL, 1, 0, 0, 0,0,0,0,0 },
- { "Video.TextureFilter", "Texture filter", ROW_ENUM, 1, kTexFilter, 0, 0, 0, 0,0,0,0,0 },
- { "Video.Anisotropy", "Anisotropic", ROW_SLIDER, 1, NULL, 0, 0, 0, 0,0,0,0,0 },
- { "Video.FovScale", "FOV scale %", ROW_SLIDER, 5, NULL, 0, 0, 0, 0,0,0,0,0 },
- { "Video.WidescreenAuto", "Widescreen auto FOV",ROW_TOGGLE,1, kOnOff, 0, 0, 0, 0,0,0,0,0 },
- { "Video.SafeAreaCrop", "Crop overscan bars", ROW_TOGGLE,1, kOnOff, 0, 0, 0, 0,0,0,0,0 },
- { "Video.DrawDistance", "Draw distance %", ROW_SLIDER, 25, NULL, 0, 0, 0, 0,0,0,0,0, "Video.DrawDistanceAutoFov" },
- { "Video.DrawDistanceAutoFov","Draw dist. auto", ROW_TOGGLE, 1, kOnOff, 0, 0, 0, 0,0,0,0,0 },
- { "Video.LodDistance", "LOD distance %", ROW_SLIDER, 25, NULL, 0, 0, 0, 0,0,0,0,0, "Video.LodDistanceAutoFov" },
- { "Video.LodDistanceAutoFov", "LOD dist. auto", ROW_TOGGLE, 1, kOnOff, 0, 0, 0, 0,0,0,0,0 },
- /* Aim row edits Input.AimModeSens -- the knob the default GEPD aim path
- * actually uses (Input.MouseAimSpeed only feeds the legacy velocity-stick
- * fallback, so it was inert here). D304: both this row and the turn-speed
- * row below now use the (0,0) "inherit the registered clamp" sentinel
- * instead of hardcoded uiMin/uiMax -- they were previously (1,80) and
- * (0,100), two arbitrary, DIFFERENT, and (for turn speed) outright wrong
- * ceilings that didn't even match either row's own backing config clamp
- * (both are actually registered 1..500 in input.c) -- fixed as an earlier
- * part of this same D304 pass. **Follow-up, same day, user feedback after
- * trying the widened sliders live:** exposing BOTH per-mode knobs still
- * let a player decouple them into a bad, hard-to-diagnose state (e.g. aim
- * mode very slow, hipfire very fast, or vice versa) with no indication
- * anything was wrong -- worse than a narrow range. Per the user's request,
- * these two rows and the "Link aim/turn sens" toggle that tried to paper
- * over the same risk are pulled from the menu entirely; only the single
- * master `Input.MouseSensitivity` row below remains player-facing, exactly
- * mirroring the reference GEPD/PD injector model this input was ported
- * from (GEPD-Edition Mouse Injector's goldeneye.c -- source no longer
- * vendored in the public repo, see reference/mouse-injector/README.md) --
- * ONE user-facing
- * sensitivity value, with the two per-mode constants staying fixed at
- * their calibrated (38/50) defaults, never independently player-tunable.
- * Config vars + SensLink logic stay in `input.c`/`rowSet()` untouched
- * (ini power users can still hand-edit them; existing ini files with
- * either key keep working) -- only the menu surface changes, same
- * established pattern as the D181/D216 pulled rows below. */
+ /* D356/D388: Turok-style functional categories in order (INPUT,
+ * GAMEPLAY, GRAPHICS, AUDIO, VIDEO; docs/dev/D356-SETTINGS-REGROUP-PLAN.md).
+ * The D353 "BOND FILE" section -- the one named after WHERE a value is
+ * saved -- is gone: its surviving rows split into GAMEPLAY (the per-file
+ * watch toggles, dim "(per profile)" tagged since the section mixes scopes) and
+ * AUDIO (the per-file volume sliders, homogeneous so title annotation
+ * only), and the "Edit file" chooser row is retired in favour of the
+ * single top save-file row on the front options screen (frontoptions.c).
+ * Per-section "Reset to defaults" rows (D356, plan Gate E) share
+ * rowResetSection(); "(File N)" title annotations come from
+ * watchSettingsActiveFolder(). All rows use designated initializers
+ * (D351 class). */
+ { .key="__HdrInput", .label="INPUT", .kind=ROW_HEADER },
+ { .key="Input.MouseSensitivity", .label="Mouse horizontal sensitivity", .kind=ROW_SLIDER, .step=5 }, /* calibrated UI midpoint = raw 100 */
+ /* Wave A (v0.5.0, CONTROLLER-INPUT-PLAN item 6): the pre-existing
+ * Input.MouseYScale key (extra vertical/pitch sensitivity, %) finally gets
+ * a row so the asymmetric X/Y mouse aim is tunable without editing the ini.
+ * Row only -- input.c already applies it (mouseYScale). */
+ { .key="Input.MouseYScale", .label="Mouse vertical sensitivity", .kind=ROW_SLIDER, .step=5 },
+ { .key="Input.MouseInvertY", .label="Invert look (mouse)", .kind=ROW_TOGGLE, .step=1, .names=kOnOff },
+ /* Input.PdMouseAim (findings D332): the Perfect Dark port's mouse-aim
+ * model. The port only accumulates the mouse; the game's own crosshair
+ * integrator is driven by the port-supplied turn with PD's near-zero damp
+ * (0.01), instead of GEPD's overwrite with the weapon's ~0.8 -- which is
+ * what made the reticule step. Changes aim feel, so opt-in. GE_PDMOUSEAIM=0/1
+ * overrides it at launch for A/B runs. */
+ /* D337: N64 = the N64 aim model (crosshair travels, camera edge-scrolls)
+ * with the mouse fed through the game's integrator at PD's mouse damp --
+ * the default. CENTRED (PC) = opt-in FPS-style aim (#104), not N64.
+ * Device-neutral (D404, resolved by implementation 2026-09-28): mouse --
+ * N64 feeds the game's integrator, CENTRED drives the camera directly
+ * (hipDirectCompute); pad -- N64 emits right-stick deflection into the
+ * same integrator (the original controller feel: crosshair travel +
+ * edge scroll + spring-back), CENTRED drives the camera directly from
+ * the stick (padDirectCompute, crosshair pinned at centre). Applies to
+ * every aim input (RMB, Shift, Q/L, Toggle mode, pad right stick in
+ * natural-pitch mode). */
+ { .key="Input.AimMode", .label="Aim style", .kind=ROW_ENUM, .step=1, .names=kAimMode },
+ /* D338: how far the N64-style crosshair travels. PC = to the screen edge
+ * (GEPD / mouse-injector feel, default); N64 = the original stick limits
+ * (65% of the half-width, camera turn from ~49%). Hidden while the aim
+ * style is CENTRED (PC), where the crosshair doesn't travel. */
+ { .key="Input.AimRange", .label="Aim range", .kind=ROW_ENUM, .step=1, .names=kAimRange, .hiddenIfOn="Input.AimMode" },
+ { .key="Input.PadLookInvertY", .label="Invert look (controller)", .kind=ROW_TOGGLE, .step=1, .names=kOnOff },
+ /* Wave A (v0.5.0, docs/dev/CONTROLLER-INPUT-PLAN.md): per-stick deadzone
+ * (left = movement, right = look) replaces the single "Stick deadzone" row;
+ * the look-sensitivity / look-smoothing / southpaw knobs are right-stick
+ * (natural-pitch) feel options, identity at their defaults. All port-layer. */
+ { .key="Input.PadDeadzoneL", .label="Deadzone (left stick)", .kind=ROW_SLIDER, .step=500 },
+ { .key="Input.PadDeadzoneR", .label="Deadzone (right stick)", .kind=ROW_SLIDER, .step=500 },
+ { .key="Input.PadLookSensX", .label="X axis look sensitivity (controller)", .kind=ROW_SLIDER, .step=5 },
+ { .key="Input.PadLookSensY", .label="Y axis look sensitivity (controller)", .kind=ROW_SLIDER, .step=5 },
+ { .key="Input.PadLookSmooth", .label="Look smoothing (controller)", .kind=ROW_SLIDER, .step=1, .dispMax=10 },
+ { .key="Input.PadSouthpaw", .label="Southpaw", .kind=ROW_TOGGLE, .step=1, .names=kOnOff },
+ { .key="Input.PadTriggerPct", .label="Trigger threshold", .kind=ROW_SLIDER, .step=1 },
+ /* D401 item 3 (docs/dev/RUMBLE-PLAN.md): Rumble Pak strength (D224
+ * re-promotion). One global Input.RumbleScale for all pads (user call,
+ * 2026-09-28 -- the original four per-player rows were dropped). The
+ * value lives in input.c's gRumbleScale (0 = silent, 1 = full, dflt
+ * 0.5), applied in inputRumble(); 0.05 detents give 20 steps, the
+ * generic float value path displays them as %.2f. Inert (inputRumble
+ * no-ops) when no rumble-capable pad is connected -- a generic
+ * row-disabled state for that case is deferred with the per-pad tuning
+ * row (QOL-INVENTORY). */
+ { .key="Input.RumbleScale", .label="Vibration", .kind=ROW_SLIDER, .step=0.05 },
+ /* v0.4.0 M3 (modern options wave, D371): the GEPD mouse-injector
+ * key-layout preset (docs/dev/notes/GEPORT-REFERENCE-DEEPDIVE.md
+ * section 7.1) + the crouch bind's fire mode. Turok standard: these are
+ * control bindings, so they live in INPUT -- no provenance bucket.
+ * D374: the GEPD layout is the default (a mouse FPS layout is the
+ * natural default; the N64 had no keyboard); crouch defaults to hold. */
+ { .key="Input.CrouchMode", .label="Crouch mode", .kind=ROW_ENUM, .step=1, .names=kHold },
+ { .key="__ResetInput", .label="Reset to defaults", .kind=ROW_ACTION },
+ { .key="__OpenBindings", .label="Bindings...", .kind=ROW_ACTION },
+
+ /* D388: nested Input -> Bindings -> two short pages, each with a reset.
+ * Mouse buttons 1-5 share the visible slots with keys; pads stay fixed. */
+ { .key="__HdrBindings", .label="BINDINGS", .kind=ROW_HEADER },
+ { .key="__OpenMoveKeys", .label="Movement...", .kind=ROW_ACTION },
+ { .key="__OpenActionKeys", .label="Actions...", .kind=ROW_ACTION },
+
+ { .key="__HdrMoveKeys", .label="MOVEMENT", .kind=ROW_HEADER },
+ { .key="Input.Bind.Forward", .label="Move forward", .kind=ROW_BIND },
+ { .key="Input.Bind.Back", .label="Move backward", .kind=ROW_BIND },
+ { .key="Input.Bind.StrafeLeft", .label="Strafe left", .kind=ROW_BIND },
+ { .key="Input.Bind.StrafeRight", .label="Strafe right", .kind=ROW_BIND },
+ { .key="Input.Bind.TurnLeft", .label="Turn left", .kind=ROW_BIND },
+ { .key="Input.Bind.TurnRight", .label="Turn right", .kind=ROW_BIND },
+ { .key="__ResetMoveKeys", .label="Reset to defaults", .kind=ROW_ACTION },
+
+ { .key="__HdrActionKeys", .label="ACTIONS", .kind=ROW_HEADER },
+ { .key="Input.Bind.Fire", .label="Fire key", .kind=ROW_BIND },
+ { .key="Input.Bind.Aim", .label="Aim key", .kind=ROW_BIND },
+ { .key="Input.Bind.Action", .label="Action / next weapon", .kind=ROW_BIND },
+ { .key="Input.Bind.Cancel", .label="Use / back", .kind=ROW_BIND },
+ { .key="Input.Bind.LeanLeft", .label="Lean left", .kind=ROW_BIND },
+ { .key="Input.Bind.Start", .label="Pause / start", .kind=ROW_BIND },
+ { .key="Input.Bind.Reload", .label="Reload", .kind=ROW_BIND },
+ { .key="Input.Bind.Crouch", .label="Crouch", .kind=ROW_BIND },
+ { .key="__ResetActionKeys", .label="Reset to defaults", .kind=ROW_ACTION },
+
+ { .key="__HdrGameplay", .label="GAMEPLAY", .kind=ROW_HEADER },
+ /* D356 exposure filter (menu surface only, the D181/D216/D304 pattern --
+ * config keys, the N64 watch paths and ini hand-edits all stay live):
+ * Bond.Look duplicates Input.MouseInvertY + Input.PadLookInvertY
+ * (stacking both is a double-negation trap);
+ * Bond.AimControl collides with Input.AimMode (two rows fighting over
+ * the same N64-vs-PC aim model).
+ * Two gameplay/profile toggles stay here; HUD-specific save rows move
+ * under Gameplay -> HUD. Both pages carry their own reset. */
+ /* { .key="Bond.Look", .label="Look up/down (watch; stacks)", .kind=ROW_TOGGLE,
+ .names=kReverse, .found=1, .uiMax=1, .cfgMax=1 }, */
+ { .key="Bond.AutoAim", .label="Auto-aim", .kind=ROW_TOGGLE,
+ .names=kOnOff, .found=1, .uiMax=1, .cfgMax=1, .saveScoped=1 },
+ /* { .key="Bond.AimControl", .label="Aim control", .kind=ROW_TOGGLE,
+ .names=kHold, .found=1, .uiMax=1, .cfgMax=1 }, */
+ { .key="Bond.LookAhead", .label="Look ahead", .kind=ROW_TOGGLE,
+ .names=kOnOff, .found=1, .uiMax=1, .cfgMax=1, .saveScoped=1 },
+ /* D216/Game.SkipIntro: user report (v0.2.1 testing) that it breaks audio
+ * -- pulled from the menu until root-caused; re-exposed since. D408
+ * (2026-09-28): still EXPERIMENTAL -- with it on, a failed/aborted
+ * mission skips the post-mission failure dossier (MENU_MISSION_FAILED)
+ * and returns straight to the menus. Not recommended for regular users;
+ * keep the loud label until both quirks are root-caused. */
+ { .key="Game.SkipIntro", .label="Skip intro (EXPERIMENTAL)", .kind=ROW_TOGGLE, .step=1, .names=kOnOff },
+ /* D232: the community "no damage flash" toggle (suppresses the red/green
+ * hit-flash overlay in bondview2). */
+ { .key="Game.NoHitFlash", .label="No hit flash", .kind=ROW_TOGGLE, .step=1, .names=kOnOff },
+ /* D257: everything-unlocked goodie. The C initializers (port/src/video.c)
+ * start it OFF (0) -- the old "default ON" note was stale and is
+ * corrected by D356. Consumed at startup by main.c -- applies from the
+ * next launch. */
+ { .key="Game.AllUnlocked", .label="All unlocked (EXPERIMENTAL)",
+ .kind=ROW_TOGGLE, .step=1, .names=kAllUnlocked },
+ { .key="__ResetGameplay", .label="Reset to defaults", .kind=ROW_ACTION },
+ { .key="__OpenHud", .label="HUD...", .kind=ROW_ACTION },
+ /* D293: only quit path used to be the OS window-close / Alt+F4 -- no
+ * discoverable in-game way to exit, a real gap on Deck/controller-only
+ * setups. Not config-backed (like __Resolution); activating it exits
+ * the same way video.c's SDL_QUIT/Alt+F4 handlers already do. */
+ { .key="__QuitToDesktop", .label="Quit to desktop", .kind=ROW_ACTION },
+
+ /* D388: Gameplay -> HUD collects both profile HUD visibility and global
+ * crosshair/scale controls. Its reset honours each row's own scope. */
+ { .key="__HdrHUD", .label="HUD", .kind=ROW_HEADER },
+ { .key="Bond.Sight", .label="Sight on screen", .kind=ROW_TOGGLE,
+ .names=kOnOff, .found=1, .uiMax=1, .cfgMax=1, .saveScoped=1 },
+ { .key="Bond.Ammo", .label="Ammo on screen", .kind=ROW_TOGGLE,
+ .names=kOnOff, .found=1, .uiMax=1, .cfgMax=1, .saveScoped=1 },
+ /* D226: scales the ammo counter, pickup/status text and dialogue. */
+ { .key="Game.HudScale", .label="HUD scale", .kind=ROW_SLIDER, .step=5, .unit="%" },
+ { .key="Video.CrosshairHide", .label="Show crosshair", .kind=ROW_TOGGLE, .step=1, .names=kOnOffRev },
+ { .key="Video.CrosshairColor", .label="Crosshair colour", .kind=ROW_ENUM, .step=1, .names=kCrosshairColor },
+ { .key="Video.CrosshairRed", .label="Red", .kind=ROW_SLIDER, .step=1, .shownWhen="Video.CrosshairColor", .shownValue=8 },
+ { .key="Video.CrosshairGreen", .label="Green", .kind=ROW_SLIDER, .step=1, .shownWhen="Video.CrosshairColor", .shownValue=8 },
+ { .key="Video.CrosshairBlue", .label="Blue", .kind=ROW_SLIDER, .step=1, .shownWhen="Video.CrosshairColor", .shownValue=8 },
+ { .key="Video.CrosshairSize", .label="Crosshair size", .kind=ROW_SLIDER, .step=5, .unit="%" },
+ { .key="Video.CrosshairStyle", .label="Crosshair style", .kind=ROW_ENUM, .step=1, .names=kCrosshairStyle },
+ { .key="__ResetHUD", .label="Reset to defaults", .kind=ROW_ACTION },
+
+ { .key="__HdrGraphics", .label="GRAPHICS", .kind=ROW_HEADER },
+ { .key="Video.MSAA", .label="Anti-aliasing", .kind=ROW_MSAA, .restart=1 },
+ { .key="Video.TextureFilter", .label="Texture filter", .kind=ROW_ENUM, .step=1, .names=kTexFilter },
+ { .key="Video.Anisotropy", .label="Anisotropic filtering", .kind=ROW_SLIDER, .step=1, .unit="x" },
+ { .key="Video.FovScale", .label="FOV scale", .kind=ROW_SLIDER, .step=5, .unit="%" },
+ /* D334: native widescreen (world projected at the window aspect, Hor+).
+ * While on, "Widescreen auto FOV" has no effect (it was the stretch-era
+ * vertical-FOV compensation). */
+ { .key="Video.NativeWidescreen", .label="Native widescreen", .kind=ROW_TOGGLE, .step=1, .names=kOnOff },
+ { .key="Video.WidescreenAuto", .label="Widescreen auto FOV", .kind=ROW_TOGGLE, .step=1, .names=kOnOff },
+ { .key="Video.SafeAreaCrop", .label="Crop overscan", .kind=ROW_TOGGLE, .step=1, .names=kOnOff },
+ /* 100..400% of authored distance; 250% is the midpoint (50/100).
+ * Legacy AutoFov ini keys remain supported but no longer hide sliders. */
+ { .key="Video.DrawDistance", .label="Draw distance", .kind=ROW_SLIDER, .step=25, .uiMin=100, .uiMax=400 },
+ { .key="Video.LodDistance", .label="LOD distance", .kind=ROW_SLIDER, .step=25, .uiMin=100, .uiMax=400 },
+ /* D304: the per-mode aim/turn-sensitivity sliders and the "Link" toggle
+ * that papered over their decoupling risk were pulled from the menu
+ * (user feedback after the D304 widening); the ONE master
+ * Input.MouseSensitivity row above is the player-facing knob, mirroring
+ * the GEPD/PD injector model. Config vars + SensLink logic stay live in
+ * input.c/rowSetCommit() for ini hand-edits. */
/* { "Input.AimModeSens", "Mouse aim speed", ROW_SLIDER, 5, NULL, 0, 0, 0, 0,0,0,0,0 }, */
/* { "Input.MouseTurnSpeed", "Mouse turn speed", ROW_SLIDER, 5, NULL, 0, 0, 0, 0,0,0,0,0 }, */
/* { "Input.SensLink", "Link aim/turn sens",ROW_TOGGLE,1, kOnOff, 0, 0, 0, 0,0,0,0,0 }, */
- { "Input.MouseSensitivity", "Mouse sensitivity",ROW_SLIDER, 5, NULL, 0, 0, 0, 0,0,0,0,0 },
- { "Input.MouseInvertY", "Mouse invert Y", ROW_TOGGLE, 1, kOnOff, 0, 0, 0, 0,0,0,0,0 },
/* D181/Game.ScreenShakeIntensity: user testing (v0.2.1) found the slider
* "basically useless" -- viShake() is only called from explosion.c, so it
* scales explosion shake alone; it never touches the always-on walking
@@ -170,41 +370,205 @@ static struct Row rows[] = {
* reads as to a player. Pulled from the menu until it covers all
* screen-shake/view-bob sources, not just explosions. Config var + fr.c
* hook stay in place. */
- /* D232: the community "no damage flash" toggle (suppresses the red/green
- * hit-flash overlay in bondview2). */
- { "Game.NoHitFlash", "No hit flash", ROW_TOGGLE, 1, kOnOff, 0, 0, 0, 0,0,0,0,0 },
- /* D216/Game.SkipIntro: user report (v0.2.1 testing) that it breaks audio
- * -- pulled from the menu until root-caused. Not exposed to players; the
- * config var + lv.c hook stay in place (dead unless an existing ini has
- * it set, which no menu path can do any more). Do not re-add without
- * fixing the underlying issue first. */
- /* D257: everything-unlocked goodie (default ON). Consumed at startup by
- * main.c -- applies from the next launch. */
- { "Game.AllUnlocked", "All unlocked", ROW_TOGGLE, 1, kOnOff, 0, 0, 0, 0,0,0,0,0 },
- /* D293: only quit path used to be the OS window-close / Alt+F4 -- no
- * discoverable in-game way to exit, a real gap on Deck/controller-only
- * setups. Not config-backed (like __Resolution); activating it exits
- * the same way video.c's SDL_QUIT/Alt+F4 handlers already do. */
- { "__QuitToDesktop", "Quit to desktop", ROW_ACTION, 0, NULL, 0, 0, 0, 0,0,0,0,0 },
+ { .key="__ResetGraphics", .label="Reset to defaults", .kind=ROW_ACTION },
+
+ { .key="__HdrAudio", .label="AUDIO", .kind=ROW_HEADER },
+ /* D356: the per-file volume sliders get a real AUDIO section (the D353
+ * "AUDIO (BOND FILE)" header was retired with the BOND FILE section).
+ * A port-level master volume (M3) will join here as Turok's
+ * Master/Sound/Music trio. */
+ { .key="Bond.Music", .label="Music volume", .kind=ROW_SLIDER, .step=128,
+ .uiMax=32767, .cfgMax=32767, .found=1, .unit="%", .dispDiv=328, .saveScoped=1 },
+ { .key="Bond.FX", .label="FX volume", .kind=ROW_SLIDER, .step=128,
+ .uiMax=32767, .cfgMax=32767, .found=1, .unit="%", .dispDiv=328, .saveScoped=1 },
+ { .key="__ResetAudio", .label="Reset to defaults", .kind=ROW_ACTION },
+
+ /* D353's DISPLAY section renamed VIDEO (the presentation knobs), with
+ * Video.DisplayFPS moved here from the old GAME section. */
+ { .key="__HdrVideo", .label="VIDEO", .kind=ROW_HEADER },
+ { .key="Video.Fullscreen", .label="Fullscreen", .kind=ROW_TOGGLE, .step=1, .names=kOnOff },
+ { .key="__Resolution", .label="Resolution", .kind=ROW_RES },
+ { .key="Video.VSync", .label="VSync", .kind=ROW_TOGGLE, .step=1, .names=kOnOff },
+ { .key="Video.FpsCap", .label="Frame rate cap", .kind=ROW_FPSCAP },
+ { .key="Video.DisplayFPS", .label="Show FPS", .kind=ROW_TOGGLE, .step=1, .names=kOnOff },
+ { .key="__ResetVideo", .label="Reset to defaults", .kind=ROW_ACTION },
+ /* D356: the D353 BOND FILE header + "Edit file" chooser row are retired --
+ * the front options screen's top save-file row (frontoptions.c, D356) is
+ * the single file control; F10 always targets the active file. */
+ /* { .key="__HdrBond", .label="BOND FILE", .kind=ROW_HEADER, .found=1 }, */
+ /* { .key="__BondFile", .label="Edit file", .kind=ROW_BOND_FILE, .found=1 }, */
};
#define NUM_ROWS ((int)(sizeof(rows) / sizeof(rows[0])))
+static int rowIndexByKey(const char *key)
+{
+ for (int i = 0; i < NUM_ROWS; i++)
+ if (strcmp(rows[i].key, key) == 0) return i;
+ return -1;
+}
+
+/* D388: one shared page tree, used by both the front and F10 surfaces.
+ * ROW_HEADER still delimits each section's reset range; link actions only
+ * navigate and must never be treated as settings or the Quit action. */
+int optionsRowHeaderParent(int i)
+{
+ if (i < 0 || i >= NUM_ROWS || rows[i].kind != ROW_HEADER) return -1;
+ const char *key = rows[i].key;
+ if (!strcmp(key, "__HdrBindings")) return rowIndexByKey("__HdrInput");
+ if (!strcmp(key, "__HdrMoveKeys") || !strcmp(key, "__HdrActionKeys"))
+ return rowIndexByKey("__HdrBindings");
+ if (!strcmp(key, "__HdrHUD")) return rowIndexByKey("__HdrGameplay");
+ return -1;
+}
+
+int optionsRowChildHeader(int i)
+{
+ if (i < 0 || i >= NUM_ROWS) return -1;
+ const char *key = rows[i].key;
+ if (!strcmp(key, "__OpenBindings")) return rowIndexByKey("__HdrBindings");
+ if (!strcmp(key, "__OpenMoveKeys")) return rowIndexByKey("__HdrMoveKeys");
+ if (!strcmp(key, "__OpenActionKeys")) return rowIndexByKey("__HdrActionKeys");
+ if (!strcmp(key, "__OpenHud")) return rowIndexByKey("__HdrHUD");
+ return -1;
+}
+
static int s_inited = 0;
static volatile int s_open = 0;
static int s_sel = 0; /* selection, index into s_visIdx (visible list) */
+static int s_section = -1; /* -1 = category list; otherwise rows[] header index */
+static SDL_atomic_t s_backPending; /* ESC is received on the host thread */
-/* Visible-row list: rows whose hiddenIfOn option is nonzero are omitted
- * (manual % rows hide while their auto toggle is on). Rebuilt every frame in
- * overlayUpdateVisible(); s_scroll is the first visible-list entry drawn. */
+/* Visible-row list: category headers on the root page, or the active
+ * section's Back header and content (including conditional rows). Built
+ * on the scheduler thread; s_scroll is the first displayed entry. */
static int s_visIdx[NUM_ROWS];
static int s_visN = 0;
static int s_scroll = 0;
+static SDL_atomic_t s_wheelPending; /* D314: host-thread wheel notches */
-/* D213: optional on-screen FPS readout (PD parity: Video.DisplayFPS). Drawn
- * top-right whenever enabled, independent of the F10 panel. Config-only knob
- * (kept off the 13-row panel, which is at its layout limit) -- matches PD,
- * whose DisplayFPS is also file-only. Default 0 => emit path unchanged =>
- * golden dumps byte-identical. */
+/* D383: SDL_KEYDOWN belongs to videoPumpEvents (host), while both menu
+ * controllers consume it on their own threads. Only the one-shot scancode
+ * crosses threads; binding strings/row state remain menu-thread owned. */
+static SDL_atomic_t s_bindCaptureActive;
+static SDL_atomic_t s_bindPadCancelHold; /* swallow B until released after cancel */
+static SDL_SpinLock s_bindCaptureLock;
+static int s_bindPending = -1, s_bindCaptureRow = -1, s_bindError = 0;
+static SDL_atomic_t s_bindHold; /* captured key/mouse button, shared with pad poll */
+
+static void bindingBegin(struct Row *r)
+{
+ if (!r || !r->found || r->kind != ROW_BIND) return;
+ SDL_AtomicLock(&s_bindCaptureLock);
+ s_bindPending = -1;
+ SDL_AtomicUnlock(&s_bindCaptureLock);
+ s_bindCaptureRow = (int)(r - rows);
+ s_bindError = 0;
+ SDL_AtomicSet(&s_bindPadCancelHold, 0);
+ SDL_AtomicSet(&s_bindHold, SDL_SCANCODE_UNKNOWN);
+ SDL_AtomicSet(&s_bindCaptureActive, 1);
+}
+
+int optionsBindingCaptureActive(void)
+{
+ return SDL_AtomicGet(&s_bindCaptureActive) != 0;
+}
+
+int optionsBindingInputBlocked(void)
+{
+ if (optionsBindingCaptureActive()) return 1;
+ int code = SDL_AtomicGet(&s_bindHold);
+ if (code > SDL_NUM_SCANCODES && code <= INPUT_BIND_MOUSE(5))
+ return (SDL_GetMouseState(NULL, NULL) & SDL_BUTTON(code - SDL_NUM_SCANCODES)) != 0;
+ return code > SDL_SCANCODE_UNKNOWN && code < SDL_NUM_SCANCODES &&
+ SDL_GetKeyboardState(NULL)[code];
+}
+
+/* Called before F10/ESC/screenshot handling on the SDL event host thread.
+ * Do not bind reserved system shortcuts or gamepad events. */
+int optionsBindingKeyDown(const SDL_KeyboardEvent *ev)
+{
+ if (!optionsBindingCaptureActive()) return 0;
+ if ((ev->keysym.sym == SDLK_F4 && (ev->keysym.mod & KMOD_ALT))) return 0;
+ if (ev->repeat) return 1;
+ SDL_Scancode sc = ev->keysym.scancode;
+ if (sc == SDL_SCANCODE_F10 || sc == SDL_SCANCODE_F12 ||
+ sc == SDL_SCANCODE_UNKNOWN) return 1;
+ SDL_AtomicLock(&s_bindCaptureLock);
+ if (s_bindPending < 0) s_bindPending = (int)sc;
+ SDL_AtomicUnlock(&s_bindCaptureLock);
+ return 1;
+}
+
+/* Keep menu clicking and click-to-lock out of a mouse-binding capture. */
+int optionsBindingMouseDown(const SDL_MouseButtonEvent *ev)
+{
+ if (!optionsBindingCaptureActive()) return 0;
+ if (ev->button >= 1 && ev->button <= 5) {
+ SDL_AtomicLock(&s_bindCaptureLock);
+ if (s_bindPending < 0) s_bindPending = INPUT_BIND_MOUSE(ev->button);
+ SDL_AtomicUnlock(&s_bindCaptureLock);
+ }
+ return 1;
+}
+
+/* The capturing screen calls this once per input tick. Return 1 while the
+ * modal is active and through the captured key's release, so it cannot
+ * simultaneously trigger menu navigation or a second action. */
+int optionsBindingCaptureTick(void)
+{
+ /* D395: capture only accepts keyboard/mouse. A controller B must be
+ * able to cancel a modal entered with a keyboard/mouse; swallow B until
+ * release so that the same press cannot also back out of the page. */
+ if (SDL_AtomicGet(&s_bindPadCancelHold)) {
+ if (!inputPadButton(0, SDL_CONTROLLER_BUTTON_B))
+ SDL_AtomicSet(&s_bindPadCancelHold, 0);
+ return 1;
+ }
+ if (optionsBindingCaptureActive()) {
+ if (inputPadButton(0, SDL_CONTROLLER_BUTTON_B)) {
+ SDL_AtomicLock(&s_bindCaptureLock);
+ s_bindPending = -1;
+ SDL_AtomicUnlock(&s_bindCaptureLock);
+ SDL_AtomicSet(&s_bindCaptureActive, 0);
+ SDL_AtomicSet(&s_bindPadCancelHold, 1);
+ return 1;
+ }
+ SDL_AtomicLock(&s_bindCaptureLock);
+ int sc = s_bindPending;
+ s_bindPending = -1;
+ SDL_AtomicUnlock(&s_bindCaptureLock);
+ if (sc >= 0) {
+ struct Row *r = &rows[s_bindCaptureRow];
+ if (sc != SDL_SCANCODE_ESCAPE) {
+ int chosen = sc == SDL_SCANCODE_DELETE
+ ? SDL_SCANCODE_UNKNOWN : sc;
+ if (!inputBindingSetSlot(r->key, r->bindSlot, chosen)) {
+ s_bindError = 1; /* oversized name: leave ini unchanged */
+ } else {
+ configSave();
+ sysLogPrintf(LOG_INFO, "bind capture: %s slot %d = %s",
+ r->key, r->bindSlot + 1,
+ inputBindingSlot(r->key, r->bindSlot));
+ }
+ }
+ SDL_AtomicSet(&s_bindHold, sc);
+ SDL_AtomicSet(&s_bindCaptureActive, 0);
+ }
+ return 1;
+ }
+ int hold = SDL_AtomicGet(&s_bindHold);
+ if (hold != SDL_SCANCODE_UNKNOWN) {
+ const Uint8 *ks = SDL_GetKeyboardState(NULL);
+ if (hold > SDL_NUM_SCANCODES && hold <= INPUT_BIND_MOUSE(5)) {
+ if (SDL_GetMouseState(NULL, NULL) & SDL_BUTTON(hold - SDL_NUM_SCANCODES)) return 1;
+ } else if (hold > SDL_SCANCODE_UNKNOWN && hold < SDL_NUM_SCANCODES && ks[hold]) return 1;
+ SDL_AtomicSet(&s_bindHold, SDL_SCANCODE_UNKNOWN);
+ }
+ return 0;
+}
+
+/* D213: optional on-screen FPS readout (PD parity: Video.DisplayFPS).
+ * Drawn top-right whenever enabled, independent of the F10 panel. */
static int s_showFps = 0;
static char s_fpsText[16] = "";
@@ -238,81 +602,126 @@ static void fpsTick(void)
* emit path and the mouse hit-testing in optionsOverlayHandleInput().
* BankGothic caps are ~9 units tall here, so rows need ~16 units of pitch and
* values are right-aligned to the panel edge to survive the wide font. */
-#define OV_X0 20
-#define OV_LABEL_X 28
-#define OV_TOP 12
-#define OV_LINE 15 /* row pitch (13 rows must fit ~240) */
-#define OV_HDR 2 /* header rows above row 0 (title+hint) */
-/* i is a VISIBLE-POSITION (already scroll-adjusted by the caller). */
-#define OV_ROW_Y(i) (OV_TOP + ((i) + OV_HDR) * OV_LINE)
+#define OV_TOP 8
+#define OV_LINE 16
+#define OV_FOOTER_HEIGHT 38 /* two help lines, including gap below content */
+#define OV_FOOTER_LINE 13
+/* Dark 2D watch-style overlay; the in-stage watch's 3D model is not loaded
+ * here. All rendering AND hit-testing take coordinates from this layout;
+ * 320x240 stages and 440x330 front screens share the same geometry. */
+struct OvLayout {
+ s32 left, right, top, sectionY, contentY, bottom, footerY;
+ s32 labelX, valueR, barX0, barX1, maxRows;
+};
-/* How many rows fit between the header and the bottom edge of whatever 2D
- * space we are in right now (320x240 in-game, 440x330 on front-end screens --
- * viSetXY differs, see src/game/front.c). More rows than this => scroll. */
-static int maxVisibleRows(void)
+static struct OvLayout overlayLayout(void)
{
- int n = (viGetY() - 6 - OV_TOP - OV_HDR * OV_LINE) / OV_LINE;
- if (n < 4) {
- n = 4;
- }
- return n;
+ struct OvLayout o;
+ s32 w = viGetX(), h = viGetY();
+ s32 cardW = w - 16;
+ if (cardW > 376) cardW = 376;
+ o.left = (w - cardW) / 2;
+ o.right = o.left + cardW;
+ o.top = OV_TOP;
+ o.sectionY = o.top + 31;
+ o.contentY = o.top + 49;
+ o.labelX = o.left + 18;
+ o.valueR = o.right - 12;
+ o.barX1 = o.right - 65;
+ o.barX0 = o.barX1 - 74;
+ o.maxRows = (h - 6 - o.contentY - OV_FOOTER_HEIGHT) / OV_LINE;
+ if (o.maxRows < 4) o.maxRows = 4;
+ /* Root has five categories. A section's title/Back sits in its own
+ * pinned band; only the content rows consume this scrollable area. */
+ int contentN = s_visN - (s_section >= 0 ? 1 : 0);
+ int drawn = contentN < o.maxRows ? contentN : o.maxRows;
+ o.bottom = o.contentY + drawn * OV_LINE + OV_FOOTER_HEIGHT;
+ if (o.bottom > h - 6) o.bottom = h - 6;
+ o.footerY = o.bottom - 29;
+ return o;
}
-#define OV_RIGHT (viGetX() - OV_X0) /* right edge for right-aligned text */
-#define OV_NUM_W 36 /* reserved width for a slider's number */
-#define OV_BAR_X 150
-/* Slider fill bar span in overlay space (shared by emit + hit-testing). */
+static int maxVisibleRows(void) { return overlayLayout().maxRows; }
+
+/* Shared with slider dragging and its render path. */
static void sliderBarSpan(s32 *x0, s32 *x1)
{
- *x0 = OV_BAR_X;
- *x1 = OV_RIGHT - OV_NUM_W;
- if (*x1 < *x0 + 16) {
- *x1 = *x0 + 16;
- }
+ struct OvLayout o = overlayLayout();
+ *x0 = o.barX0;
+ *x1 = o.barX1;
}
-/* Visible position (0..s_visN-1) the given overlay-space y falls in, or -1.
- *
- * D304 fix: this used to loop over ALL s_visN entries (0..s_visN-1)
- * regardless of which ones are actually scrolled into view and drawn this
- * frame -- OV_ROW_Y(p - s_scroll) is a plain linear function of p, so a row
- * sitting just past the last visibly-drawn one (pLast, see the emit path)
- * still produces a geometrically valid, in-range Y band immediately below
- * the panel's real bottom edge. A click near that boundary (real-pixel-to-
- * virtual-2D-space rounding in the ox/oy scale-up, or simply a slightly low
- * click on the last visible row) could therefore resolve to the NEXT,
- * invisible, scrolled-off-the-bottom row instead of the one actually drawn
- * there. Concretely: with "All unlocked" sitting in the last visible slot,
- * this let a click on it silently hit "Quit to desktop" (the very next row)
- * instead -- reported as "I clicked unlock all and I think it crashed"
- * (user, 2026-09-18): the game didn't crash, __QuitToDesktop's ROW_ACTION
- * fired and closed it via the game's normal exit path. Fix: bound the scan
- * to the same [s_scroll, pLast] range the draw loop actually renders.
- *
- * D316 fix: the band used to be [Y-3, Y+12) -- 3 units above OV_ROW_Y but
- * 12 below it, a systematic downward bias baked in independent of the
- * mouse-mapping bug above. Centered here on OV_ROW_Y so a borderline click
- * no longer favors the row below. */
-static int overlayRowAtY(double oy)
+/* Visible position or -1. Match the rendered [s_scroll, pLast] window
+ * exactly (D304: never hit the invisible row below Quit). D391: the text
+ * baseline is BELOW rowY: measured glyph pixels occupy rowY+3..+9 at
+ * 320x240, so the former symmetric [rowY-8,rowY+8) hit band assigned
+ * the bottom of a visible label to the NEXT row. Leave a small dead zone
+ * between rows instead of guessing which adjacent item was intended. */
+static int overlayRowAtY(double ox, double oy)
{
- int maxV = maxVisibleRows();
- int pLast = (s_visN - s_scroll < maxV) ? s_visN - 1 : s_scroll + maxV - 1;
+ struct OvLayout o = overlayLayout();
+ if (ox < o.left + 3 || ox >= o.right - 3) return -1;
+ if (s_section >= 0 && oy >= o.sectionY - 2 &&
+ oy < o.sectionY + 12) return 0; /* pinned Back/title */
+ int pLast = s_scroll + o.maxRows - 1;
+ if (pLast >= s_visN) pLast = s_visN - 1;
for (int p = s_scroll; p <= pLast; p++) {
- double top = OV_ROW_Y(p - s_scroll) - OV_LINE / 2;
- if (oy >= top && oy < top + OV_LINE) {
- return p;
- }
+ double y = o.contentY + (p - s_scroll) * OV_LINE;
+ if (oy >= y - 2 && oy < y + 12) return p;
}
return -1;
}
+/* Opt-in geometry check on both the first and last scroll windows. The
+ * visible glyph ink occupies approximately y+3..y+9, so both ends must
+ * select the drawn row, with a dead zone after it and no off-card hit. */
+static int overlayHitCheck(void)
+{
+ struct OvLayout o = overlayLayout();
+ int first = s_section >= 0 ? 1 : 0;
+ int lastScroll = s_visN - first > o.maxRows ? s_visN - o.maxRows : first;
+ int saved = s_scroll, bad = 0;
+ for (int scroll = first; scroll <= lastScroll; scroll += lastScroll - first ? lastScroll - first : 1) {
+ s_scroll = scroll;
+ int last = scroll + o.maxRows < s_visN ? scroll + o.maxRows : s_visN;
+ for (int p = scroll; p < last; p++) {
+ double y = o.contentY + (p - scroll) * OV_LINE;
+ if (overlayRowAtY(o.labelX, y + 3) != p ||
+ overlayRowAtY(o.labelX, y + 9) != p ||
+ overlayRowAtY(o.labelX, y + 12) != -1 ||
+ overlayRowAtY(o.left - 1, y + 9) != -1) bad++;
+ }
+ if (first && overlayRowAtY(o.labelX, o.sectionY + 9) != 0) bad++;
+ }
+ s_scroll = saved;
+ return bad;
+}
+
/* Rebuild the visible-row list and keep the selection in range. */
+/* Move through the current category list or section (including its Back row). */
+static int overlayStepSel(int pos, int dir)
+{
+ int q = pos + dir;
+ return (q < 0 || q >= s_visN) ? pos : q;
+}
+
static void overlayUpdateVisible(void)
{
s_visN = 0;
for (int i = 0; i < NUM_ROWS; i++) {
- if (!(rows[i].hidePtr && *rows[i].hidePtr != 0)) {
- s_visIdx[s_visN++] = i;
+ /* Root: only top-level headers. Nested sections are opened through
+ * link rows in Input/Bindings and Gameplay. Section header = Back. */
+ if (s_section < 0) {
+ if (rows[i].kind == ROW_HEADER && optionsRowHeaderParent(i) < 0)
+ s_visIdx[s_visN++] = i;
+ } else if (i == s_section) {
+ s_visIdx[s_visN++] = i; /* Back */
+ } else if (i > s_section) {
+ if (rows[i].kind == ROW_HEADER) break;
+ if (rows[i].kind != ROW_BOND_FILE &&
+ !(rows[i].hidePtr && *rows[i].hidePtr) &&
+ (!rows[i].showPtr || *rows[i].showPtr == rows[i].shownValue))
+ s_visIdx[s_visN++] = i;
}
}
if (s_visN == 0) { /* cannot happen (toggles have no hide source) */
@@ -343,32 +752,62 @@ static void overlayUpdateVisible(void)
static void overlayUpdateScroll(void)
{
int maxV = maxVisibleRows();
- if (s_visN <= maxV) {
- s_scroll = 0;
+ int first = s_section >= 0 ? 1 : 0; /* section header stays pinned */
+ int count = s_visN - first;
+ if (count <= maxV) {
+ s_scroll = first;
return;
}
- if (s_sel < s_scroll) {
+ if (s_scroll < first) s_scroll = first;
+ if (s_sel >= first && s_sel < s_scroll) {
s_scroll = s_sel;
} else if (s_sel > s_scroll + maxV - 1) {
s_scroll = s_sel - (maxV - 1);
}
- if (s_scroll < 0) {
- s_scroll = 0;
- }
- if (s_scroll > s_visN - maxV) {
- s_scroll = s_visN - maxV;
+ if (s_scroll > s_visN - maxV) s_scroll = s_visN - maxV;
+}
+
+static void overlayOpenHeader(int hdr)
+{
+ s_section = hdr;
+ s_sel = 1;
+ s_scroll = 0;
+ optionsResetClear();
+ overlayUpdateVisible();
+ overlayUpdateScroll();
+ sysLogPrintf(LOG_INFO, "optionsoverlay: opened %s", rows[hdr].label);
+}
+
+static void overlayBackOne(void)
+{
+ int child = s_section;
+ if (child < 0) return;
+ s_section = optionsRowHeaderParent(child);
+ s_sel = 0;
+ s_scroll = 0;
+ optionsResetClear();
+ overlayUpdateVisible();
+ /* Re-select the link that opened this child (or the root category).
+ * This works for both keyboard and pointer navigation. */
+ for (int p = 0; p < s_visN; p++) {
+ int i = s_visIdx[p];
+ if ((s_section < 0 && i == child) ||
+ (s_section >= 0 && optionsRowChildHeader(i) == child)) {
+ s_sel = p;
+ break;
+ }
}
+ overlayUpdateScroll();
+ sysLogPrintf(LOG_INFO, "optionsoverlay: back from %s to %s",
+ rows[child].label, s_section < 0 ? "categories" : rows[s_section].label);
}
/* The close box brackets the title row at the panel's right edge. */
-#define OV_CB_X0 (OV_RIGHT - 14)
-#define OV_CB_X1 (OV_RIGHT + 7)
-#define OV_CB_Y0 (OV_TOP - 3)
-#define OV_CB_Y1 (OV_TOP + 12)
static int overlayInCloseBox(double ox, double oy)
{
- return ox >= OV_CB_X0 && ox <= OV_CB_X1 &&
- oy >= OV_CB_Y0 && oy <= OV_CB_Y1;
+ struct OvLayout o = overlayLayout();
+ return ox >= o.right - 40 && ox <= o.right - 5 &&
+ oy >= o.top + 3 && oy <= o.top + 22;
}
/* ------------------------------------------------------------------------ */
@@ -405,7 +844,9 @@ static void overlayInit(void)
configForEachOption(resolveCb, NULL);
for (int i = 0; i < NUM_ROWS; i++) {
- if (rows[i].kind == ROW_RES || rows[i].kind == ROW_ACTION) {
+ if (rows[i].kind == ROW_RES || rows[i].kind == ROW_ACTION ||
+ rows[i].kind == ROW_HEADER || rows[i].kind == ROW_BOND_FILE ||
+ watchSettingsFieldForKey(rows[i].key) >= 0) {
rows[i].found = 1; /* not config-backed */
continue;
}
@@ -418,18 +859,91 @@ static void overlayInit(void)
/* Resolve the hidden-while-on sources (manual % rows vs their auto
* toggles), then build the initial visible list. */
for (int i = 0; i < NUM_ROWS; i++) {
- if (!rows[i].hiddenIfOn) {
- continue;
- }
for (int j = 0; j < NUM_ROWS; j++) {
- if (strcmp(rows[j].key, rows[i].hiddenIfOn) == 0 && rows[j].found) {
+ if (!rows[j].found) continue;
+ if (rows[i].hiddenIfOn &&
+ strcmp(rows[j].key, rows[i].hiddenIfOn) == 0)
rows[i].hidePtr = rows[j].ptr;
- break;
- }
+ if (rows[i].shownWhen &&
+ strcmp(rows[j].key, rows[i].shownWhen) == 0)
+ rows[i].showPtr = rows[j].ptr;
}
}
+ /* D356: on a header row, saveScoped means "this section contains
+ * per-file rows" (drives the "(File N)" title annotation in both UIs);
+ * on a content row it is the literal per-file flag from the table. */
+ for (int i = 0; i < NUM_ROWS; i++) {
+ if (rows[i].kind != ROW_HEADER) {
+ continue;
+ }
+ int has = 0;
+ for (int j = i + 1; j < NUM_ROWS && rows[j].kind != ROW_HEADER; j++)
+ has |= rows[j].saveScoped;
+ rows[i].saveScoped = has;
+ }
overlayUpdateVisible();
+ /* D388: opt-in in-memory walk of the real F10 page tree. No settings,
+ * binds or profile bytes are written; restore the UI state afterward. */
+ if (getenv("GE_OPTIONTREEPROBE")) {
+ int oldSection = s_section, oldSel = s_sel, oldScroll = s_scroll;
+ int bad = 0;
+ static const char *const roots[] = {
+ "__HdrInput", "__HdrGameplay", "__HdrGraphics", "__HdrAudio", "__HdrVideo"
+ };
+ s_section = -1; s_sel = 0; s_scroll = 0;
+ overlayUpdateVisible();
+ bad += overlayHitCheck();
+ if (s_visN != 5) bad++;
+ for (int p = 0; p < 5 && p < s_visN; p++)
+ if (strcmp(rows[s_visIdx[p]].key, roots[p])) bad++;
+ static const struct { const char *page, *childA, *childB; int maxRows; } pages[] = {
+ { "__HdrInput", "__OpenBindings", NULL, 14 },
+ { "__HdrBindings", "__OpenMoveKeys", "__OpenActionKeys", 14 },
+ { "__HdrMoveKeys", "Input.Bind.Forward", "__ResetMoveKeys", 14 },
+ { "__HdrActionKeys", "Input.Bind.Fire", "__ResetActionKeys", 14 },
+ { "__HdrGameplay", "__OpenHud", "__ResetGameplay", 14 },
+ { "__HdrHUD", "Video.CrosshairColor", "__ResetHUD", 14 },
+ { "__HdrVideo", "Video.FpsCap", "__ResetVideo", 14 },
+ };
+ for (size_t k = 0; k < sizeof(pages) / sizeof(pages[0]); k++) {
+ int hdr = rowIndexByKey(pages[k].page), gotA = 0, gotB = 0;
+ overlayOpenHeader(hdr);
+ bad += overlayHitCheck();
+ if (s_visN - 1 > pages[k].maxRows) bad++;
+ for (int p = 1; p < s_visN; p++) {
+ gotA |= !strcmp(rows[s_visIdx[p]].key, pages[k].childA);
+ if (pages[k].childB)
+ gotB |= !strcmp(rows[s_visIdx[p]].key, pages[k].childB);
+ if (!strcmp(rows[s_visIdx[p]].key, "Video.LowEndMode")) bad++;
+ }
+ if (!gotA || (pages[k].childB && !gotB)) bad++;
+ overlayBackOne();
+ int parent = optionsRowHeaderParent(hdr);
+ if (s_section != parent) bad++;
+ }
+ int hud = rowIndexByKey("__HdrHUD");
+ overlayOpenHeader(hud);
+ static const char *const hudKeys[] = {
+ "Bond.Sight", "Bond.Ammo", "Game.HudScale"
+ };
+ for (size_t k = 0; k < sizeof(hudKeys) / sizeof(hudKeys[0]); k++) {
+ int seen = 0;
+ for (int p = 1; p < s_visN; p++)
+ seen |= !strcmp(rows[s_visIdx[p]].key, hudKeys[k]);
+ if (!seen) bad++;
+ }
+ if (optionsRowChildHeader(rowIndexByKey("__OpenHud")) != hud ||
+ optionsRowHeaderParent(hud) != rowIndexByKey("__HdrGameplay") ||
+ optionsRowHeaderParent(rowIndexByKey("__HdrMoveKeys")) !=
+ rowIndexByKey("__HdrBindings")) bad++;
+ s_section = oldSection; s_sel = oldSel; s_scroll = oldScroll;
+ overlayUpdateVisible();
+ sysLogPrintf(bad ? LOG_ERROR : LOG_INFO,
+ "GE_OPTIONTREEPROBE: %s (5 roots, nested links/back, two bind pages, HUD, row caps, no M5)",
+ bad ? "FAIL" : "PASS");
+ }
+
/* Build the windowed-resolution preset list: presets that fit the desktop,
* plus the current window size snapped to the nearest surviving entry. */
{
@@ -458,12 +972,69 @@ static void overlayInit(void)
const char *e = getenv("GE_OPTIONSOVERLAY");
if (e && atoi(e) != 0) {
s_open = 1;
- sysLogPrintf(LOG_INFO, "optionsoverlay: auto-opened (GE_OPTIONSOVERLAY)");
+ /* Diagnostic screenshots: 1 = root, 2..6 = five categories;
+ * GE_OPTIONSOVERLAY_SECTION=__HdrHUD etc. targets nested pages. */
+ int page = atoi(e) - 2;
+ if (page >= 0) {
+ int found = 0;
+ for (int i = 0; i < NUM_ROWS; i++) {
+ if (rows[i].kind == ROW_HEADER && optionsRowHeaderParent(i) < 0 &&
+ found++ == page) {
+ overlayOpenHeader(i);
+ break;
+ }
+ }
+ }
+ /* Isolated screenshot/probe of a nested page, never a menu mode. */
+ const char *section = getenv("GE_OPTIONSOVERLAY_SECTION");
+ int nested = section ? rowIndexByKey(section) : -1;
+ if (nested >= 0 && rows[nested].kind == ROW_HEADER)
+ overlayOpenHeader(nested);
+ /* Screenshot-only selection state for checking the pinned Back
+ * row against a normal selected setting on the same page. */
+ if (s_section >= 0 && getenv("GE_OPTIONSOVERLAY_SELECTBACK"))
+ s_sel = 0;
+ sysLogPrintf(LOG_INFO, "optionsoverlay: auto-opened (GE_OPTIONSOVERLAY=%s)", e);
+ /* D383 isolated host->menu smoke probe. Only with explicit opt-in;
+ * test data dir must be private because capture persists the key. */
+ const char *probe = getenv("GE_BINDCAPTUREPROBE");
+ if (probe && *probe) {
+ SDL_Scancode sc = !SDL_strcasecmp(probe, "ESC") ? SDL_SCANCODE_ESCAPE :
+ !SDL_strcasecmp(probe, "DEL") ? SDL_SCANCODE_DELETE :
+ SDL_SCANCODE_F2;
+ const char *bindKey = getenv("GE_BINDCAPTUREACTION");
+ if (!bindKey || !*bindKey) bindKey = "Input.Bind.Fire";
+ for (int i = 0; i < NUM_ROWS; i++) {
+ if (!strcmp(rows[i].key, bindKey) && rows[i].kind == ROW_BIND) {
+ bindingBegin(&rows[i]);
+ sysLogPrintf(LOG_INFO, "GE_BINDCAPTUREPROBE: %s found=%d active=%d",
+ bindKey, rows[i].found, optionsBindingCaptureActive());
+ SDL_Event ev = {0};
+ if (!SDL_strcasecmp(probe, "MOUSE5")) {
+ ev.button.type = SDL_MOUSEBUTTONDOWN;
+ ev.button.state = SDL_PRESSED;
+ ev.button.button = SDL_BUTTON_X2;
+ } else {
+ ev.key.type = SDL_KEYDOWN;
+ ev.key.state = SDL_PRESSED;
+ ev.key.repeat = 0;
+ ev.key.keysym.scancode = sc;
+ ev.key.keysym.sym = SDL_GetKeyFromScancode(sc);
+ ev.key.keysym.mod = KMOD_NONE;
+ }
+ int sent = SDL_PushEvent(&ev);
+ sysLogPrintf(LOG_INFO, "GE_BINDCAPTUREPROBE: queued=%d (%s)", sent, SDL_GetError());
+ break;
+ }
+ }
+ }
}
}
static double rowGet(const struct Row *r)
{
+ int field = watchSettingsFieldForKey(r->key);
+ if (field >= 0) return (double)watchSettingsRead(field);
if (!r->found || !r->ptr) {
return 0.0;
}
@@ -484,40 +1055,99 @@ static double rowHi(const struct Row *r)
return (r->uiMin != r->uiMax) ? r->uiMax : r->cfgMax;
}
+/* Player-facing calibrated input controls: the original raw defaults are
+ * deliberate (100% mouse speed, 7000/30000 stick deadzone, 23% trigger
+ * travel). Show each as 50/100, without changing input.c, ini ranges,
+ * reset values, or either endpoint. Both UIs and F10 hit/drag use the same
+ * piecewise mapping, so the shown number always matches the filled bar. */
+static double calibratedDefault(const struct Row *r)
+{
+ if (!strcmp(r->key, "Input.MouseSensitivity")) return 100.0;
+ if (!strcmp(r->key, "Input.MouseYScale")) return 100.0; /* Wave A item 6: native */
+ if (!strcmp(r->key, "Input.PadDeadzoneL")) return 7000.0; /* Wave A: = STICK_DEADZONE */
+ if (!strcmp(r->key, "Input.PadDeadzoneR")) return 7000.0; /* Wave A: = STICK_DEADZONE */
+ if (!strcmp(r->key, "Input.PadLookSensX")) return 100.0; /* Wave A: native */
+ if (!strcmp(r->key, "Input.PadLookSensY")) return 100.0; /* Wave A: native */
+ if (!strcmp(r->key, "Input.PadTriggerPct")) return 23.0;
+ return -1.0;
+}
+static double rowFractionAt(const struct Row *r, double v)
+{
+ double lo = rowLo(r), hi = rowHi(r), def = calibratedDefault(r);
+ if (hi <= lo) return 0.0;
+ if (v < lo) v = lo;
+ if (v > hi) v = hi;
+ if (def > lo && def < hi)
+ return v <= def ? 0.5 * (v - lo) / (def - lo)
+ : 0.5 + 0.5 * (v - def) / (hi - def);
+ return (v - lo) / (hi - lo);
+}
+static double rowValueAtFraction(const struct Row *r, double f)
+{
+ double lo = rowLo(r), hi = rowHi(r), def = calibratedDefault(r);
+ if (f < 0.0) f = 0.0;
+ if (f > 1.0) f = 1.0;
+ if (def > lo && def < hi)
+ return f <= 0.5 ? lo + (def - lo) * f * 2.0
+ : def + (hi - def) * (f - 0.5) * 2.0;
+ return lo + (hi - lo) * f;
+}
+
static struct Row *rowByKey(const char *key)
{
- for (int i = 0; i < NUM_ROWS; i++) {
- if (strcmp(rows[i].key, key) == 0) {
- return &rows[i];
- }
- }
- return NULL;
+ int i = rowIndexByKey(key);
+ return i < 0 ? NULL : &rows[i];
}
static int s_linkDepth = 0; /* re-entrancy guard for the sens link below */
-static void rowSet(struct Row *r, double v)
+static void rowSet(struct Row *r, double v);
+
+static void rowSetCommit(struct Row *r, double v, int commit)
{
double lo = rowLo(r), hi = rowHi(r);
if (lo != hi) {
if (v < lo) v = lo;
if (v > hi) v = hi;
}
+ int field = watchSettingsFieldForKey(r->key);
+ if (field >= 0) {
+ int want = (int)lround(v);
+ /* Held drags poll far more often than the mouse moves to a new
+ * slider detent. Don't enqueue another audio update for the same
+ * value; release still commits via watchSettingsCommit(). */
+ if (watchSettingsRead(field) != want)
+ watchSettingsSet(field, want, commit);
+ return;
+ }
switch (r->type) {
- case CONFIG_OPT_INT: *(int *)r->ptr = (int)lround(v); break;
- case CONFIG_OPT_UINT: *(unsigned int *)r->ptr = (unsigned int)(v < 0 ? 0 : lround(v)); break;
- case CONFIG_OPT_FLOAT: *(float *)r->ptr = (float)v; break;
+ case CONFIG_OPT_INT:
+ if (*(int *)r->ptr == (int)lround(v)) return;
+ *(int *)r->ptr = (int)lround(v); break;
+ case CONFIG_OPT_UINT: {
+ unsigned int want = (unsigned int)(v < 0 ? 0 : lround(v));
+ if (*(unsigned int *)r->ptr == want) return;
+ *(unsigned int *)r->ptr = want; break;
+ }
+ case CONFIG_OPT_FLOAT:
+ if (*(float *)r->ptr == (float)v) return;
+ *(float *)r->ptr = (float)v; break;
default: return;
}
- /* Live-apply the video knobs that need a fast3d/SDL call. Everything else
- * is read straight off the pointer by its owner every frame/poll. */
+ /* D382: only the specific live GL/SDL knob that changed needs a render-
+ * thread apply. Reticle, draw/LOD, widescreen and HUD settings are read
+ * directly; rebuilding textures for every RGB/FOV detent caused hitches. */
if (strcmp(r->key, "Video.Fullscreen") == 0) {
videoRequestFullscreen((int)lround(v));
} else if (strncmp(r->key, "Video.", 6) == 0 && !r->restart) {
- videoRequestLiveConfig();
+ videoRequestLiveConfigForKey(r->key);
}
+ /* Crouch mode drops the latch; future bind-capture rows can call the
+ * same rebuild hook. */
+ if (strcmp(r->key, "Input.CrouchMode") == 0) inputBindingsApply();
+
/* Linked aim/turn sensitivity (Input.SensLink, default on): moving either
* knob scales the other to hold the stock default ratio -- AimModeSens 38
* : MouseTurnSpeed 50 (the D194/D238 calibrated defaults). */
@@ -542,11 +1172,23 @@ static void rowSet(struct Row *r, double v)
}
}
+static void rowSet(struct Row *r, double v) { rowSetCommit(r, v, 1); }
+
+/* D356 reset rows (defined below, in the reset block). */
+static struct Row *rowAt(int i);
+static int isResetRow(const struct Row *r);
+static void rowActivateReset(struct Row *r);
+
static void rowAdjust(struct Row *r, int dir)
{
- if (!r->found) {
+ if (!r->found || r->kind == ROW_HEADER) { /* D237: headers have no value */
return;
}
+ if (r->kind == ROW_BOND_FILE) {
+ if (current_menu == MENU_PC_OPTIONS) watchSettingsChooseFile(dir);
+ return;
+ }
+ if (watchSettingsFieldForKey(r->key) >= 0 && !watchSettingsAvailable()) return;
double v = rowGet(r);
switch (r->kind) {
case ROW_TOGGLE:
@@ -591,12 +1233,21 @@ static void rowAdjust(struct Row *r, int dir)
videoRequestWindowSize(kResList[i][0], kResList[i][1]);
break;
}
+ case ROW_BIND:
+ r->bindSlot = (r->bindSlot + dir + INPUT_BIND_SLOTS) % INPUT_BIND_SLOTS;
+ break;
case ROW_ACTION:
+ if (optionsRowChildHeader((int)(r - rows)) >= 0) break; /* link, not a Quit or value */
+ /* D356: reset rows arm/confirm through the shared activation
+ * contract (edge-triggered by their callers: fresh key press / fresh
+ * click / A-press -- the held-repeat paths below never re-fire them).
+ * The quit row is the only non-reset ROW_ACTION left. */
+ if (isResetRow(r)) { rowActivateReset(r); break; }
/* D293: same exit path as SDL_QUIT / Alt+F4 (video.c), just reachable
* without OS window chrome or a keyboard. */
sysLogPrintf(LOG_INFO, "optionsoverlay: quit to desktop requested");
configSave();
- exit(0);
+ videoRequestQuit("Quit to desktop"); /* D344: never exit() off the host thread */
break;
default: /* ROW_SLIDER */
rowSet(r, v + dir * r->step);
@@ -604,18 +1255,211 @@ static void rowAdjust(struct Row *r, int dir)
}
}
+/* ------------------------------------------------------------------------ */
+/* D356: per-section "Reset to defaults" (plan Gate E, made concrete). */
+/* The activation contract (plan §5.4): edge-triggered, two-step */
+/* arm -> confirm within 3 s, one commit per confirmed activation, */
+/* disarm on timeout or navigating away. The arm state is shared by the */
+/* F10 overlay and the front options screen (they are never open at the */
+/* same time; both clear it on open/close). */
+/* ------------------------------------------------------------------------ */
+
+#define RESET_ARM_US 3000000 /* arm expires after 3 s */
+
+static int s_resetArmedRow = -1; /* rows[] index of the armed reset row */
+static uint64_t s_resetArmedUs = 0;
+
+static int isResetRow(const struct Row *r)
+{
+ return r->kind == ROW_ACTION && strncmp(r->key, "__Reset", 7) == 0;
+}
+
+int optionsRowIsReset(int i)
+{
+ struct Row *r = rowAt(i);
+ return r && isResetRow(r);
+}
+
+/* ini rows reset to the port's C initializers (verified at implementation
+ * time, D356 -- each entry cites its source variable): the reset table
+ * mirrors them; config.c itself has no central default table (first-write
+ * "defaults" are just the current values). __Resolution is NOT here: it is
+ * action-backed (s_resSel + videoRequestWindowSize), not a registered config
+ * row, so a numeric default cannot express it -- a documented exclusion (the
+ * findings D356 entry states it); the player's resolution choice survives a
+ * VIDEO reset. */
+static const struct { const char *key; double def; } kResetDefaults[] = {
+ /* INPUT (port/src/input.c initializers) */
+ { "Input.MouseSensitivity", 100 }, /* static int mouseSensitivity = 100 */
+ { "Input.MouseYScale", 100 }, /* = 100 (native), Wave A item 6 */
+ { "Input.MouseInvertY", 0 }, /* = 0 */
+ { "Input.AimMode", 0 }, /* = AIMMODE_N64 (0) */
+ { "Input.AimRange", 0 }, /* = 0 (PC) */
+ { "Input.PadLookInvertY", 0 }, /* = 0 */
+ { "Input.PadDeadzoneL", 7000 },/* = STICK_DEADZONE (7000), Wave A */
+ { "Input.PadDeadzoneR", 7000 },/* = STICK_DEADZONE (7000), Wave A */
+ { "Input.PadLookSensX", 100 }, /* = 100 (native), Wave A */
+ { "Input.PadLookSensY", 100 }, /* = 100 (native), Wave A */
+ { "Input.PadLookSmooth", 0 }, /* = 0 (off), Wave A */
+ { "Input.PadSouthpaw", 0 }, /* = 0 (off), Wave A */
+ { "Input.PadTriggerPct", 23 }, /* = 23 */
+ { "Input.RumbleScale", 0.5 }, /* = gRumbleScale (0.5f), D401 */
+ { "Input.CrouchMode", 0 }, /* = 0 (hold) */
+ /* GRAPHICS (port/src/video.c initializers) */
+ { "Video.MSAA", 2 }, /* = 2 */
+ { "Video.TextureFilter", 1 }, /* = 1 (bilinear) */
+ { "Video.Anisotropy", 4 }, /* = 4 */
+ { "Video.FovScale", 100 }, /* = 100 */
+ { "Video.NativeWidescreen", 1 }, /* = 1 */
+ { "Video.WidescreenAuto", 1 }, /* = 1 */
+ { "Video.SafeAreaCrop", 1 }, /* = 1 */
+ { "Video.DrawDistance", 250 }, /* midpoint: 50/100 */
+ { "Video.LodDistance", 250 }, /* midpoint: 50/100 */
+ { "Video.CrosshairHide", 0 }, /* = 0 (on, N64) */
+ { "Video.CrosshairColor", 0 }, /* = 0 (authored red sprite) */
+ { "Video.CrosshairRed", 255 },
+ { "Video.CrosshairGreen", 255 },
+ { "Video.CrosshairBlue", 255 },
+ { "Video.CrosshairSize", 100 }, /* = 100% */
+ { "Video.CrosshairStyle", 0 }, /* = 0 (original) */
+ /* GAMEPLAY ini rows (port/src/video.c initializers) */
+ { "Game.SkipIntro", 0 }, /* = 0 */
+ { "Game.NoHitFlash", 0 }, /* = 0 */
+ { "Game.AllUnlocked", 0 }, /* = 0 -- D257's "default ON" note was stale */
+ { "Game.HudScale", 100 }, /* = 100 (D226) */
+ /* VIDEO (port/src/video.c initializers; DisplayFPS in overlayConfigInit) */
+ { "Video.Fullscreen", 0 }, /* = 0 (windowed) */
+ { "Video.VSync", 1 }, /* = 1 (on) */
+ { "Video.FpsCap", 60 }, /* = 60 */
+ { "Video.DisplayFPS", 0 }, /* registered 0 */
+};
+
+static double kResetDefault(const char *key)
+{
+ for (size_t i = 0; i < sizeof(kResetDefaults) / sizeof(kResetDefaults[0]); i++)
+ if (strcmp(kResetDefaults[i].key, key) == 0) return kResetDefaults[i].def;
+ return -1.0;
+}
+
+/* The section's DECLARED row range (its header through the next header) --
+ * NOT the visible list, so conditionally hidden rows (e.g. Aim range while
+ * centred) are reset too; their live
+ * values are written through the same ptr/rowSet path, visibility
+ * irrelevant. Per row, its OWN scope is reset: watch rows -> the selected
+ * file's BLANKSAVEDATA values through the normal commit path (front: direct
+ * write; stage: D352 queue, applied+persisted by the game thread); ini rows
+ * -> the table above (applied live now, persisted like any ini edit when the
+ * screen/overlay closes). No cross-scope surprise: a section reset touches
+ * only that section's rows, and a file reset never touches other files. */
+static void rowResetSection(int iReset)
+{
+ int hdr = iReset - 1;
+ while (hdr >= 0 && rows[hdr].kind != ROW_HEADER) hdr--;
+ if (hdr < 0) {
+ sysLogPrintf(LOG_WARNING, "optionsoverlay: reset row %d has no section header", iReset);
+ return;
+ }
+ int end = hdr + 1;
+ while (end < NUM_ROWS && rows[end].kind != ROW_HEADER) end++;
+ int nWatch = 0, nIni = 0, nBinds = 0;
+ for (int j = hdr + 1; j < end; j++) {
+ struct Row *r = &rows[j];
+ if (r->kind == ROW_ACTION) continue; /* reset rows, Quit */
+ if (strcmp(r->key, "__Resolution") == 0) continue; /* documented exclusion */
+ if (r->kind == ROW_BIND) {
+ nBinds += inputBindingResetKey(r->key);
+ r->bindSlot = 0;
+ continue;
+ }
+ int field = watchSettingsFieldForKey(r->key);
+ if (field >= 0) {
+ watchSettingsSet((enum WatchSettingField)field,
+ watchSettingsBlankValue((enum WatchSettingField)field), 1);
+ nWatch++;
+ } else if (r->found && r->ptr) {
+ double def = kResetDefault(r->key);
+ if (def < 0.0) continue;
+ rowSetCommit(r, def, 1);
+ nIni++;
+ }
+ }
+ if (nBinds) inputBindingsApply();
+ sysLogPrintf(LOG_INFO, "optionsoverlay: section reset '%s': %d file row(s), %d ini row(s), %d key row(s)",
+ rows[hdr].label, nWatch, nIni, nBinds);
+}
+
+/* Arm (first edge) or confirm (second edge within RESET_ARM_US). Callers
+ * guarantee edges: the front screen routes only fresh presses (its held-key
+ * repeat is suppressed for reset rows) and F10's fresh-press / fresh-click
+ * paths; the held-repeat branches skip reset rows entirely. */
+static void rowActivateReset(struct Row *r)
+{
+ if (!isResetRow(r)) return;
+ uint64_t now = sysGetMicroseconds();
+ int i = (int)(r - rows);
+ if (s_resetArmedRow == i && now - s_resetArmedUs <= RESET_ARM_US) {
+ s_resetArmedRow = -1;
+ rowResetSection(i); /* exactly one commit per confirmed activation */
+ return;
+ }
+ s_resetArmedRow = i;
+ s_resetArmedUs = now;
+ int h = i - 1;
+ while (h >= 0 && rows[h].kind != ROW_HEADER) h--;
+ sysLogPrintf(LOG_INFO, "optionsoverlay: section reset '%s' armed (confirm within 3 s)",
+ h >= 0 ? rows[h].label : "?");
+}
+
+void optionsRowActivateReset(int i)
+{
+ struct Row *r = rowAt(i);
+ if (r) rowActivateReset(r);
+}
+
+/* Timeout / navigate-away disarm. selRow = the rows[] index the UI has
+ * selected (any other value, e.g. -1, disarms). */
+void optionsResetMaintain(int selRow)
+{
+ if (s_resetArmedRow < 0) return;
+ uint64_t now = sysGetMicroseconds();
+ if (s_resetArmedRow != selRow || now - s_resetArmedUs > RESET_ARM_US)
+ s_resetArmedRow = -1;
+}
+
+void optionsResetClear(void)
+{
+ s_resetArmedRow = -1;
+}
+
/* ------------------------------------------------------------------------ */
void optionsOverlayToggle(void)
{
overlayInit();
+ if (s_open) {
+ if (optionsBindingCaptureActive()) return; /* Escape cancels modal */
+ int pending = SDL_AtomicSet(&s_dragWatchField, 0);
+ if (pending > 0) watchSettingsCommit(pending - 1);
+ }
s_open = !s_open;
+ if (s_open) {
+ s_section = -1;
+ s_sel = s_scroll = 0;
+ }
+ SDL_AtomicSet(&s_backPending, 0);
sysLogPrintf(LOG_INFO, "optionsoverlay: %s", s_open ? "opened" : "closed");
if (!s_open) {
+ optionsResetClear(); /* D356: closing the overlay disarms a pending reset */
configSave();
}
}
+void optionsOverlayBack(void)
+{
+ /* Called from SDL's host event pump; navigation is scheduler-owned. */
+ if (s_open) SDL_AtomicSet(&s_backPending, 1);
+}
+
int optionsOverlayIsOpen(void)
{
if (!s_inited) {
@@ -632,12 +1476,24 @@ void optionsOverlayScroll(int dir)
if (!s_open || dir == 0) {
return;
}
- overlayUpdateVisible();
- s_sel += (dir > 0) ? -1 : 1; /* wheel-up -> move up the list */
- /* Clamp at both ends like a normal PC settings list -- wrapping made the
- * selection "repeat" from the far edge, which read as a duplicate. */
- if (s_sel < 0) s_sel = 0;
- if (s_sel >= s_visN) s_sel = s_visN - 1;
+ /* D314: this runs on the host thread (SDL wheel event), while HandleInput
+ * and Emit rebuild s_visIdx/s_visN/s_scroll on the scheduler thread; the
+ * old direct overlayUpdateVisible() + s_sel write here could leave Emit
+ * drawing a half-rebuilt list. Queue the notches instead (D287 pattern);
+ * optionsOverlayHandleInput() applies them on the scheduler thread. */
+ SDL_AtomicAdd(&s_wheelPending, dir);
+}
+
+/* Scheduler thread: apply wheel notches queued by optionsOverlayScroll().
+ * wheel-up -> move up the list; clamps at both ends like a normal PC settings
+ * list (wrapping read as a duplicate) and skips D237 headers. */
+static void overlayApplyWheel(void)
+{
+ int n = SDL_AtomicSet(&s_wheelPending, 0);
+ while (n != 0) {
+ s_sel = overlayStepSel(s_sel, (n > 0) ? -1 : 1);
+ n += (n > 0) ? -1 : 1;
+ }
}
/* Set a slider row from an overlay-space x inside its value bar, snapped to
@@ -653,10 +1509,10 @@ static void sliderSetFromX(struct Row *r, double ox)
double f = (ox - bx0) / (double)(bx1 - bx0);
if (f < 0) f = 0;
if (f > 1) f = 1;
- double v = lo + f * (hi - lo);
+ double v = rowValueAtFraction(r, f);
double step = (r->step > 0.0) ? r->step : 1.0;
v = lround(v / step) * step;
- rowSet(r, v);
+ rowSetCommit(r, v, 0); /* slider drag: save only on release */
}
/* D314 (findings.md): F10 menu items flicker/mis-land on the file-select
@@ -668,14 +1524,23 @@ static void sliderSetFromX(struct Row *r, double ox)
* see D302: a hot-path getenv() regression cost a real bug before). */
static int s_d314Enabled = -1; /* -1 = not yet resolved */
+/* Shared by paint and mouse input: the area right of a setting's name
+ * changes it; its name only selects it. Reset actions use the whole row. */
+static int overlayControlSpan(int i, s32 *x0, s32 *x1);
+
void optionsOverlayHandleInput(void)
{
- static int prevUp, prevDn, prevLf, prevRt, prevLmb, prevRmb;
- static int dragRow = -1;
+ static int prevUp, prevDn, prevLf, prevRt, prevLmb, prevRmb, prevPadBack;
+ /* D347: hold-to-repeat state (18/4-frame cadence, same as the options
+ * screen's D345(e)/(f) blocks). */
+ static int navDir = 0, navTimer = 0, adjDir = 0, adjTimer = 0;
if (!s_open) {
prevUp = prevDn = prevLf = prevRt = prevLmb = prevRmb = 0;
- dragRow = -1;
+ s_dragRow = -1;
+ SDL_AtomicSet(&s_dragWatchField, 0);
+ navDir = adjDir = 0;
+ SDL_AtomicSet(&s_wheelPending, 0);
return;
}
@@ -690,8 +1555,39 @@ void optionsOverlayHandleInput(void)
/* The overlay owns the mouse while it is open: force the OS cursor free +
* visible (a stage poll would otherwise leave it locked/hidden). */
inputSuspendForOverlay();
+ if (optionsBindingCaptureTick()) return;
- overlayUpdateVisible(); /* % rows may have appeared/vanished (auto toggles) */
+ /* D361: regression probe on the ACTUAL joyPoll/F10 thread (not the
+ * game-thread GE_WSPROBE_FRONT hook). A front-end write used to block
+ * forever in joyDisablePoll waiting for this very poll to acknowledge it.
+ * Opt-in only, once after the file-select save becomes available. */
+ {
+ static int probe = -1, readyTicks = 0;
+ if (probe < 0) probe = getenv("GE_WSPROBE_F10FRONT") ? 1 : 0;
+ if (probe == 1 && current_menu == MENU_FILE_SELECT && watchSettingsAvailable() &&
+ ++readyTicks >= 120) {
+ probe = 2;
+ sysLogPrintf(LOG_INFO, "wsf10front: queue Music+FX from controller poll");
+ watchSettingsSet(WATCH_SETTING_MUSIC, 4096, 1);
+ watchSettingsSet(WATCH_SETTING_FX, 4096, 1);
+ }
+ }
+
+ /* D356: a pending reset arm expires after 3 s or when the selection
+ * leaves the armed row; the overlay's selection is s_visIdx[s_sel]. */
+ overlayUpdateVisible();
+ if (SDL_AtomicSet(&s_backPending, 0)) {
+ if (s_section >= 0) {
+ overlayBackOne();
+ } else {
+ optionsOverlayToggle();
+ return;
+ }
+ }
+ optionsResetMaintain(s_section < 0 ? -1 : s_visIdx[s_sel]);
+
+ /* % rows may have appeared/vanished after a setting change. */
+ overlayApplyWheel(); /* D314: wheel notches queued by the host thread */
overlayUpdateScroll();
const Uint8 *ks = SDL_GetKeyboardState(NULL);
@@ -700,39 +1596,115 @@ void optionsOverlayHandleInput(void)
int lmb = (mb & SDL_BUTTON(SDL_BUTTON_LEFT)) ? 1 : 0;
int rmb = (mb & SDL_BUTTON(SDL_BUTTON_RIGHT)) ? 1 : 0;
- /* Gamepad navigation (controller-only machines, e.g. Steam Deck): the
- * D-pad or left stick moves the selection; A/X step forward (the Enter
- * equivalent), B/Y step back; Start closes. OR-ed into the same edge
- * logic as the keyboard, so repeat/clamp/scroll behaviour is identical.
- * input.c swallows the pad while we are open, so none of this reaches
- * the game. (Select also closes -- handled in input.c's toggle, which
- * runs before this one.) */
+ /* D396: B backs out one F10 page (closes from root); A/X accept/toggle
+ * on an edge, not slider adjustment. Only D-pad/left-stick X (or keyboard
+ * left/right) adjust sliders, with hold-repeat. The pad is swallowed by
+ * input.c while the overlay owns it. Start/Select still close it. */
int gUp = inputPadButton(0, SDL_CONTROLLER_BUTTON_DPAD_UP)
|| inputPadAxis(0, SDL_CONTROLLER_AXIS_LEFTY) < -12000;
int gDn = inputPadButton(0, SDL_CONTROLLER_BUTTON_DPAD_DOWN)
|| inputPadAxis(0, SDL_CONTROLLER_AXIS_LEFTY) > 12000;
+ int gLf = inputPadButton(0, SDL_CONTROLLER_BUTTON_DPAD_LEFT)
+ || inputPadAxis(0, SDL_CONTROLLER_AXIS_LEFTX) < -12000;
+ int gRt = inputPadButton(0, SDL_CONTROLLER_BUTTON_DPAD_RIGHT)
+ || inputPadAxis(0, SDL_CONTROLLER_AXIS_LEFTX) > 12000;
int up = ks[SDL_SCANCODE_UP] || ks[SDL_SCANCODE_KP_8] || gUp;
int dn = ks[SDL_SCANCODE_DOWN] || ks[SDL_SCANCODE_KP_2] || gDn;
- int lf = ks[SDL_SCANCODE_LEFT] || ks[SDL_SCANCODE_KP_4]
- || inputPadButton(0, SDL_CONTROLLER_BUTTON_B)
- || inputPadButton(0, SDL_CONTROLLER_BUTTON_Y);
- int rt = ks[SDL_SCANCODE_RIGHT] || ks[SDL_SCANCODE_KP_6]
- || ks[SDL_SCANCODE_RETURN] || ks[SDL_SCANCODE_KP_ENTER]
- || inputPadButton(0, SDL_CONTROLLER_BUTTON_A)
- || inputPadButton(0, SDL_CONTROLLER_BUTTON_X);
+ int lf = ks[SDL_SCANCODE_LEFT] || ks[SDL_SCANCODE_KP_4] || gLf;
+ int rightNav = ks[SDL_SCANCODE_RIGHT] || ks[SDL_SCANCODE_KP_6] || gRt;
+ int accept = ks[SDL_SCANCODE_RETURN] || ks[SDL_SCANCODE_KP_ENTER] ||
+ inputPadButton(0, SDL_CONTROLLER_BUTTON_A) ||
+ inputPadButton(0, SDL_CONTROLLER_BUTTON_X);
+ int rt = rightNav || accept;
+ int padBack = inputPadButton(0, SDL_CONTROLLER_BUTTON_B);
static int prevStart = 0; /* not reset while closed: a Start held across
close must not re-close on the next open */
int startNow = inputPadButton(0, SDL_CONTROLLER_BUTTON_START);
- if (startNow && !prevStart) optionsOverlayToggle(); /* Start closes */
+ if (startNow && !prevStart) {
+ prevStart = startNow;
+ optionsOverlayToggle(); /* Start closes */
+ return;
+ }
prevStart = startNow;
+ if (padBack && !prevPadBack) {
+ prevPadBack = padBack;
+ navDir = adjDir = 0;
+ prevUp = up; prevDn = dn; prevLf = lf; prevRt = rt;
+ if (s_section >= 0) overlayBackOne();
+ else optionsOverlayToggle();
+ return;
+ }
+ prevPadBack = padBack;
- /* ---- keyboard / D-pad nav (clamped at the ends; scroll follows) ---- */
- if (up && !prevUp && s_sel > 0) s_sel--;
- if (dn && !prevDn && s_sel < s_visN - 1) s_sel++;
+ /* ---- keyboard / D-pad nav (clamped at the ends; scroll follows).
+ * D347: hold-to-repeat so a controller (and held keys) can traverse the
+ * list without one-press-per-row. */
+ {
+ int vdir = (dn && !prevDn) ? +1 : (up && !prevUp) ? -1 : 0;
+ if (vdir != 0) {
+ navDir = vdir;
+ navTimer = 18;
+ s_sel = overlayStepSel(s_sel, vdir); /* skips headers */
+ } else if ((up || dn) && navDir != 0) {
+ if (--navTimer <= 0) {
+ navTimer = 4;
+ s_sel = overlayStepSel(s_sel, navDir);
+ }
+ } else {
+ navDir = 0;
+ }
+ }
overlayUpdateScroll();
- if (lf && !prevLf) rowAdjust(&rows[s_visIdx[s_sel]], -1);
- if (rt && !prevRt) rowAdjust(&rows[s_visIdx[s_sel]], +1);
+ /* ---- value adjust (D347: D-pad/stick left-right wired in; hold repeats) ---- */
+ {
+ int dir = (rt && !prevRt) ? +1 : (lf && !prevLf) ? -1 : 0;
+ if (dir != 0) {
+ adjDir = dir;
+ adjTimer = 18;
+ if (s_section < 0) {
+ overlayOpenHeader(s_visIdx[s_sel]);
+ adjDir = 0;
+ } else if (s_sel == 0) {
+ overlayBackOne();
+ adjDir = 0;
+ } else {
+ int child = optionsRowChildHeader(s_visIdx[s_sel]);
+ if (child >= 0) {
+ if (dir > 0) overlayOpenHeader(child);
+ adjDir = 0;
+ } else {
+ struct Row *r = &rows[s_visIdx[s_sel]];
+ int keyConfirm = ks[SDL_SCANCODE_RETURN] || ks[SDL_SCANCODE_KP_ENTER];
+ if (r->kind == ROW_BIND && dir > 0 && keyConfirm) {
+ bindingBegin(r);
+ adjDir = 0;
+ } else if (r->kind == ROW_BIND && dir > 0 && accept && !rightNav) {
+ /* Pad accept does nothing on keyboard-only binding rows;
+ * D-pad/stick right still changes the displayed slot. */
+ adjDir = 0;
+ } else if (r->kind == ROW_SLIDER && dir > 0 && accept && !rightNav) {
+ /* Accept never moves a slider; left/right own detents. */
+ adjDir = 0;
+ } else {
+ rowAdjust(r, dir);
+ if (accept && !rightNav) adjDir = 0; /* one-shot toggles */
+ }
+ }
+ }
+ } else if ((rt || lf) && adjDir != 0) {
+ if (--adjTimer <= 0) {
+ adjTimer = 4;
+ /* D356: reset actions are edge-triggered -- the held-repeat
+ * re-fires adjust rows but must never re-fire a reset (a held
+ * key could arm AND confirm, or commit repeatedly). */
+ if (s_section >= 0 && s_sel != 0 && !isResetRow(&rows[s_visIdx[s_sel]]))
+ rowAdjust(&rows[s_visIdx[s_sel]], adjDir);
+ }
+ } else {
+ adjDir = 0;
+ }
+ }
/* ---- mouse ----
* D316: mx/my are raw window pixels, but the overlay's own 2D content
@@ -747,7 +1719,26 @@ void optionsOverlayHandleInput(void)
if (rw > 0 && rh > 0) {
double ox = (double)(mx - rx) * (double)viGetX() / rw;
double oy = (double)(my - ry) * (double)viGetY() / rh;
- int hoverVis = overlayRowAtY(oy);
+ int hoverVis = overlayRowAtY(ox, oy);
+ /* Opt-in diagnostic for D391: distinguish hit-band/scroll errors
+ * from logical SDL mouse coords vs GL drawable pixel coords. */
+ static int pointerLog = -1, pointerSamples = 0;
+ if (pointerLog < 0) pointerLog = getenv("GE_OVPOINTERMAP") != NULL;
+ if (pointerLog && ((lmb && !prevLmb) || ++pointerSamples == 120)) {
+ if (pointerSamples >= 120) pointerSamples = 0;
+ int winW = 0, winH = 0, drawW = 0, drawH = 0;
+ SDL_Window *win = SDL_GetMouseFocus();
+ if (!win) win = SDL_GetKeyboardFocus();
+ if (win) {
+ SDL_GetWindowSize(win, &winW, &winH);
+ SDL_GL_GetDrawableSize(win, &drawW, &drawH);
+ }
+ sysLogPrintf(LOG_INFO, "ovpointer: mouse=%d,%d window=%d,%d drawable=%d,%d rect=%d,%d,%d,%d vi=%d,%d logical=%.1f,%.1f section=%d scroll=%d sel=%d hit=%d key=%s click=%d",
+ mx, my, winW, winH, drawW, drawH, rx, ry, rw, rh,
+ viGetX(), viGetY(), ox, oy, s_section, s_scroll, s_sel,
+ hoverVis, hoverVis >= 0 ? rows[s_visIdx[hoverVis]].key : "-",
+ lmb && !prevLmb);
+ }
int onClose = overlayInCloseBox(ox, oy);
/* No hover-to-highlight: merely moving the mouse must not move the
@@ -755,11 +1746,8 @@ void optionsOverlayHandleInput(void)
* new row back into the cursor and made the bottom twitch/echo).
* Selection moves only by click, wheel, or arrows. */
- /* left press. A click in the label column only focuses the row; a
- * click in the value/control column (>= the bar-span start) changes
- * it -- so clicking to select a toggle doesn't also flip it. */
- s32 bx0, bx1;
- sliderBarSpan(&bx0, &bx1);
+ /* Clicking the name selects; anywhere to its right changes the
+ * setting. Reset actions use the whole row. */
if (lmb && !prevLmb) {
if (onClose) {
optionsOverlayToggle(); /* close + configSave */
@@ -768,29 +1756,53 @@ void optionsOverlayHandleInput(void)
if (hoverVis >= 0) {
s_sel = hoverVis; /* explicit click -> select */
overlayUpdateScroll();
- if (ox >= bx0) {
- struct Row *r = &rows[s_visIdx[hoverVis]];
- if (r->kind == ROW_SLIDER && r->found) {
- sliderSetFromX(r, ox);
- dragRow = s_visIdx[hoverVis];
- } else {
- rowAdjust(r, +1); /* toggle / cycle forward (wraps) */
+ if (rows[s_visIdx[hoverVis]].kind == ROW_HEADER) {
+ if (s_section < 0) overlayOpenHeader(s_visIdx[hoverVis]);
+ else overlayBackOne();
+ } else if (optionsRowChildHeader(s_visIdx[hoverVis]) >= 0) {
+ overlayOpenHeader(optionsRowChildHeader(s_visIdx[hoverVis]));
+ } else {
+ int i = s_visIdx[hoverVis];
+ s32 x0, x1;
+ if (overlayControlSpan(i, &x0, &x1) && ox >= x0 && ox < x1) {
+ struct Row *r = &rows[i];
+ if (r->kind == ROW_SLIDER && r->found &&
+ ox >= overlayLayout().barX0 && ox <= overlayLayout().barX1) {
+ sliderSetFromX(r, ox);
+ s_dragRow = i;
+ int field = watchSettingsFieldForKey(r->key);
+ SDL_AtomicSet(&s_dragWatchField, field + 1);
+ } else if (r->kind == ROW_BIND) {
+ bindingBegin(r); /* mouse selects slot via arrows; click captures */
+ } else {
+ rowAdjust(r, +1); /* toggle / cycle forward (wraps) */
+ }
}
}
}
}
/* drag a slider */
- if (lmb && dragRow >= 0 && rows[dragRow].kind == ROW_SLIDER) {
- sliderSetFromX(&rows[dragRow], ox);
+ if (lmb && s_dragRow >= 0 && rows[s_dragRow].kind == ROW_SLIDER) {
+ sliderSetFromX(&rows[s_dragRow], ox);
}
if (!lmb) {
- dragRow = -1;
+ if (s_dragRow >= 0) {
+ int field = watchSettingsFieldForKey(rows[s_dragRow].key);
+ if (field >= 0) watchSettingsCommit(field);
+ }
+ s_dragRow = -1;
+ SDL_AtomicSet(&s_dragWatchField, 0);
}
- /* right press in the value column: cycle back / decrement */
- if (rmb && !prevRmb && hoverVis >= 0 && !onClose && ox >= bx0) {
- s_sel = hoverVis;
- overlayUpdateScroll();
- rowAdjust(&rows[s_visIdx[hoverVis]], -1);
+ /* Right press in the same change region: cycle back. */
+ if (rmb && !prevRmb && hoverVis >= 0 && !onClose && s_section >= 0) {
+ int i = s_visIdx[hoverVis];
+ s32 x0, x1;
+ if (!isResetRow(&rows[i]) && overlayControlSpan(i, &x0, &x1) &&
+ ox >= x0 && ox < x1) {
+ s_sel = hoverVis;
+ overlayUpdateScroll();
+ rowAdjust(&rows[i], -1);
+ }
}
}
@@ -813,20 +1825,65 @@ static Gfx *fillRect(Gfx *gdl, s32 x0, s32 y0, s32 x1, s32 y1,
return gdl;
}
-static void valueText(const struct Row *r, char *out, int n)
+static void valueText(int i, char *out, int n)
{
+ const struct Row *r = &rows[i];
double v = rowGet(r);
+ if (r->kind == ROW_BOND_FILE) {
+ int f = watchSettingsFolder();
+ if (f < 0) snprintf(out, n, "Select a profile");
+ else snprintf(out, n, "%d", f + 1);
+ return;
+ }
+ if (watchSettingsFieldForKey(r->key) >= 0 && !watchSettingsAvailable()) {
+ snprintf(out, n, "Select a profile");
+ return;
+ }
if (r->kind == ROW_RES) {
if (videoIsFullscreen()) {
snprintf(out, n, "(fullscreen)");
} else if (s_resFitN <= 0) {
snprintf(out, n, "n/a");
} else {
- int i = s_resFit[s_resSel];
- snprintf(out, n, "%d x %d", kResList[i][0], kResList[i][1]);
+ int i2 = s_resFit[s_resSel];
+ snprintf(out, n, "%d x %d", kResList[i2][0], kResList[i2][1]);
}
return;
}
+ if (r->kind == ROW_ACTION) {
+ if (optionsRowChildHeader(i) >= 0) {
+ /* PD's selectable submenu rows use a title such as "Stick
+ * Settings...", not a second "Open >" value. The link label
+ * above carries that same affordance on both GE PC menus. */
+ if (n > 0) out[0] = 0;
+ } else if (isResetRow(r)) {
+ /* D356: the label is the button; the value column shows the
+ * armed state so the confirm step is discoverable. */
+ snprintf(out, n, s_resetArmedRow == i ? "Confirm" : "");
+ } else {
+ snprintf(out, n, "[ENTER]");
+ }
+ return;
+ }
+ if (r->kind == ROW_BIND) {
+ if (optionsBindingCaptureActive() && i == s_bindCaptureRow)
+ snprintf(out, n, "Key/mouse...");
+ else if (s_bindError && i == s_bindCaptureRow)
+ snprintf(out, n, "Binding too long");
+ else {
+ static const char *const kSlotName[INPUT_BIND_SLOTS] = {
+ "Primary", "Secondary",
+ };
+ snprintf(out, n, "%s: %s", kSlotName[r->bindSlot],
+ inputBindingSlot(r->key, r->bindSlot));
+ }
+ return;
+ }
+ if (r->kind == ROW_MSAA) {
+ if ((int)lround(v) <= 1) snprintf(out, n, "None");
+ else snprintf(out, n, "%dx", (int)lround(v));
+ return;
+ }
if ((r->kind == ROW_TOGGLE || r->kind == ROW_ENUM) && r->names) {
int idx = (int)lround(v);
int cnt = 0;
@@ -836,15 +1893,6 @@ static void valueText(const struct Row *r, char *out, int n)
return;
}
}
- if (r->kind == ROW_MSAA) {
- if ((int)lround(v) <= 1) snprintf(out, n, "OFF");
- else snprintf(out, n, "%dx", (int)lround(v));
- return;
- }
- if (r->kind == ROW_ACTION) {
- snprintf(out, n, "[ENTER]");
- return;
- }
if (r->kind == ROW_SLIDER && r->type == CONFIG_OPT_FLOAT) {
snprintf(out, n, "%.2f", v);
return;
@@ -855,7 +1903,40 @@ static void valueText(const struct Row *r, char *out, int n)
else snprintf(out, n, "%d FPS", fps);
return;
}
- snprintf(out, n, "%d", (int)lround(v));
+ if (calibratedDefault(r) >= 0.0) {
+ snprintf(out, n, "%d/100", (int)lround(rowFractionAt(r, v) * 100.0));
+ return;
+ }
+ /* Wave A: integer "N/dispMax" knobs (e.g. look smoothing 0-10 -> "5/10"). */
+ if (r->dispMax > 0) {
+ snprintf(out, n, "%d/%d", (int)lround(v), r->dispMax);
+ return;
+ }
+ /* Distance sliders are normalized to a 0..100 UI while retaining the
+ * original percent-based ini format (250% = 50/100). */
+ if (strcmp(r->key, "Video.DrawDistance") == 0 ||
+ strcmp(r->key, "Video.LodDistance") == 0) {
+ int normalized = (int)lround((v - 100.0) / 3.0);
+ if (normalized < 0) normalized = 0; /* legacy LOD ini < 100 */
+ snprintf(out, n, "%d/100", normalized);
+ return;
+ }
+ /* RGB channels are 8-bit values; show their endpoint in both UIs. */
+ if (strcmp(r->key, "Video.CrosshairRed") == 0 ||
+ strcmp(r->key, "Video.CrosshairGreen") == 0 ||
+ strcmp(r->key, "Video.CrosshairBlue") == 0) {
+ snprintf(out, n, "%d/255", (int)lround(v));
+ return;
+ }
+ /* D346: integer sliders -- optional raw->display divide + unit suffix. */
+ if (r->dispDiv > 0) {
+ v = (double)(int)lround(v / (double)r->dispDiv);
+ }
+ if (r->unit) {
+ snprintf(out, n, "%d%s", (int)lround(v), r->unit);
+ } else {
+ snprintf(out, n, "%d", (int)lround(v));
+ }
}
static Gfx *drawText(Gfx *gdl, s32 x, s32 y, const char *str, u32 colour)
@@ -883,6 +1964,104 @@ static Gfx *drawTextR(Gfx *gdl, s32 xr, s32 y, const char *str, u32 colour)
return drawText(gdl, xr - measureText(str), y, str, colour);
}
+/* The N64 watch pages use Bank Gothic with a dim/bright green palette.
+ * Reuse that same loaded font here, without invoking watch rendering. */
+static struct font *bodyFont(void) { return ptrFontBankGothic; }
+static struct fontchar *bodyChars(void) { return ptrFontBankGothicChars; }
+static s32 bodyWidth(const char *str)
+{
+ s32 h = 0, w = 0;
+ textMeasure(&h, &w, (char *)str, bodyChars(), bodyFont(), 0);
+ return w;
+}
+static Gfx *drawBody(Gfx *gdl, s32 x, s32 y, const char *str, u32 colour)
+{
+ s32 px = x, py = y;
+ return textRender(gdl, &px, &py, (char *)str, bodyChars(), bodyFont(),
+ colour, viGetX(), viGetY(), 0, 0);
+}
+static Gfx *drawBodyR(Gfx *gdl, s32 xr, s32 y, const char *str, u32 colour)
+{
+ return drawBody(gdl, xr - bodyWidth(str), y, str, colour);
+}
+
+/* No per-value boxes: the full strip after the rendered setting name is
+ * active, even if there's space between its name and its bar/value. Its
+ * boundary is capped at the label's clipping limit for long names. */
+static int overlayControlSpan(int i, s32 *x0, s32 *x1)
+{
+ struct OvLayout o = overlayLayout();
+ const struct Row *r = &rows[i];
+ char val[64];
+ if (r->kind == ROW_HEADER || (r->kind == ROW_SLIDER && !r->found))
+ return 0;
+ *x1 = o.right - 7;
+ if (isResetRow(r)) {
+ *x0 = o.left + 7; /* reset is an action, not a setting name */
+ } else {
+ valueText(i, val, sizeof(val));
+ s32 limit = r->kind == ROW_SLIDER ? o.barX0 :
+ o.valueR - bodyWidth(val) - 7;
+ if (r->restart) limit = o.barX0 - 6;
+ *x0 = o.labelX + bodyWidth(r->label) + 4;
+ if (*x0 > limit) *x0 = limit;
+ }
+ return *x1 > *x0;
+}
+/* In the watch-style panel, instructions/scope annotations are deliberately
+ * a second font and cool-grey ink, not another setting in green Gothic. */
+static struct font *metaFont(void)
+{
+ return ptrFontZurichBold && ptrFontZurichBoldChars ? ptrFontZurichBold : bodyFont();
+}
+static struct fontchar *metaChars(void)
+{
+ return ptrFontZurichBold && ptrFontZurichBoldChars ? ptrFontZurichBoldChars : bodyChars();
+}
+static s32 metaWidth(const char *str)
+{
+ s32 h = 0, w = 0;
+ textMeasure(&h, &w, (char *)str, metaChars(), metaFont(), 0);
+ return w;
+}
+static Gfx *drawMeta(Gfx *gdl, s32 x, s32 y, const char *str, u32 colour)
+{
+ s32 px = x, py = y;
+ return textRender(gdl, &px, &py, (char *)str, metaChars(), metaFont(),
+ colour, viGetX(), viGetY(), 0, 0);
+}
+/* Label column may be narrow at 320x240. Never paint over a slider/value. */
+static int sectionIsMixedScope(int header)
+{
+ if (header < 0) return 0;
+ int scoped = 0, global = 0;
+ for (int i = header + 1; i < NUM_ROWS && rows[i].kind != ROW_HEADER; i++) {
+ if (rows[i].kind == ROW_ACTION) continue;
+ if (rows[i].saveScoped) scoped = 1;
+ else global = 1;
+ }
+ return scoped && global;
+}
+
+static Gfx *drawBodyFit(Gfx *gdl, s32 x, s32 y, s32 maxW, const char *str, u32 colour)
+{
+ char out[80];
+ snprintf(out, sizeof(out), "%s", str);
+ if (maxW < 12) return gdl;
+ if (bodyWidth(out) > maxW) {
+ int n = (int)strlen(out);
+ while (n > 1) {
+ out[--n] = 0;
+ if (n + 2 < (int)sizeof(out)) {
+ out[n] = '.'; out[n + 1] = '.'; out[n + 2] = 0;
+ }
+ if (bodyWidth(out) <= maxW) break;
+ out[n] = 0;
+ }
+ }
+ return drawBody(gdl, x, y, out, colour);
+}
+
Gfx *optionsOverlayEmit(void)
{
if (!s_inited) {
@@ -915,26 +2094,10 @@ Gfx *optionsOverlayEmit(void)
const s32 W = viGetX();
const s32 H = viGetY();
- const s32 right = OV_RIGHT;
- const s32 panelTop = OV_TOP - 9;
- /* Last visible position actually drawn (window may be shorter than the
- * list at small 2D viewports -- the rest is reached by scrolling).
- * D304 fix: the "everything fits" branch was `s_visN - s_scroll`, which
- * is a COUNT, not the last valid 0-based index -- since that branch only
- * runs when s_scroll==0 (overlayUpdateScroll's own invariant), this
- * evaluated to `s_visN`, one past the last valid entry, so both draw
- * loops below (`for (p = s_scroll; p <= pLast; p++)`) read one row past
- * the end of `s_visIdx[]`/`rows[]` on any 2D viewport large enough to fit
- * the whole list without scrolling (e.g. the larger front-end 440x330
- * screen once a couple of rows are pulled from the menu, as just
- * happened above). Should be `s_visN - 1`, matching overlayRowAtY's
- * identical fix. */
- const int maxV = maxVisibleRows();
- const int pLast = (s_visN - s_scroll < maxV) ? s_visN - 1
- : s_scroll + maxV - 1;
- const s32 panelBottom = OV_ROW_Y(pLast) + OV_LINE / 2 + 3;
- s32 bx0, bx1;
- sliderBarSpan(&bx0, &bx1);
+ struct OvLayout o = overlayLayout();
+ int pLast = s_scroll + o.maxRows - 1;
+ if (pLast >= s_visN) pLast = s_visN - 1;
+ const s32 bx0 = o.barX0, bx1 = o.barX1;
Gfx *gdl = s_buf;
gDPPipeSync(gdl++);
@@ -942,63 +2105,159 @@ Gfx *optionsOverlayEmit(void)
gDPSetTexturePersp(gdl++, G_TP_NONE);
gDPSetScissor(gdl++, G_SC_NON_INTERLACE, 0, 0, W, H);
- /* ---- pass 1: all fills (G_CC_PRIMITIVE) ---- */
- gdl = fillRect(gdl, 0, 0, W, H, 0, 0, 0, 150); /* dim */
- gdl = fillRect(gdl, OV_X0 - 8, panelTop, W - (OV_X0 - 8), panelBottom,
- 8, 10, 24, 210); /* panel */
- gdl = fillRect(gdl, OV_CB_X0, OV_CB_Y0, OV_CB_X1, OV_CB_Y1,
- 150, 40, 40, 235); /* close */
-
+ /* Dark glass like the previous F10 overlay; green ink and highlights
+ * follow GE's watch options (options.c's Bank Gothic/0xA0FFA0F0).
+ * No dossier paper, gold, 3D watch model or new assets. */
+ gdl = fillRect(gdl, 0, 0, W, H, 0, 0, 0, 150);
+ gdl = fillRect(gdl, o.left, o.top, o.right, o.bottom,
+ 5, 17, 13, 225);
+ gdl = fillRect(gdl, o.left + 8, o.top + 25, o.right - 8, o.top + 26,
+ 56, 135, 73, 195);
+ /* Help is a separate control strip, not another setting row. */
+ gdl = fillRect(gdl, o.left + 3, o.footerY - 5, o.right - 3, o.bottom - 2,
+ 1, 9, 7, 235);
+ gdl = fillRect(gdl, o.left + 8, o.footerY - 5, o.right - 8, o.footerY - 4,
+ 55, 102, 68, 170);
+ /* Selection is the brighter text in pass 2; sliders and the reticle
+ * colour's tiny preview swatch are the only controls with a fill. */
for (int p = s_scroll; p <= pLast; p++) {
const struct Row *r = &rows[s_visIdx[p]];
- s32 rowY = OV_ROW_Y(p - s_scroll);
- if (p == s_sel) {
- gdl = fillRect(gdl, OV_X0 - 4, rowY - 3, W - (OV_X0 - 4),
- rowY + OV_LINE - 4, 40, 46, 96, 220);
+ s32 rowY = o.contentY + (p - s_scroll) * OV_LINE;
+ if (strcmp(r->key, "Video.CrosshairColor") == 0 && r->found) {
+ extern void portCrosshairPreview(s32 *, s32 *, s32 *);
+ char val[48];
+ s32 red, green, blue;
+ valueText(s_visIdx[p], val, sizeof(val));
+ portCrosshairPreview(&red, &green, &blue);
+ s32 x = o.valueR - bodyWidth(val) - 17;
+ gdl = fillRect(gdl, x, rowY, x + 10, rowY + 10,
+ red, green, blue, 255);
}
if (r->kind == ROW_SLIDER && r->found) {
- double lo = rowLo(r), hi = rowHi(r);
- double f = (hi > lo) ? (rowGet(r) - lo) / (hi - lo) : 0.0;
- if (f < 0) f = 0; if (f > 1) f = 1;
- s32 by = rowY + 3;
- gdl = fillRect(gdl, bx0, by, bx1, by + 5, 60, 60, 70, 220);
- gdl = fillRect(gdl, bx0, by, bx0 + (s32)((bx1 - bx0) * f), by + 5,
- 210, 200, 90, 255);
+ double f = rowFractionAt(r, rowGet(r));
+ s32 by = rowY + 3; /* INSIDE the row's hit band, not the next row */
+ gdl = fillRect(gdl, bx0, by, bx1, by + 2, 31, 68, 44, 205);
+ gdl = fillRect(gdl, bx0, by, bx0 + (s32)((bx1 - bx0) * f), by + 2,
+ 59, 200, 87, 255);
+ s32 knob = bx0 + (s32)((bx1 - bx0) * f);
+ gdl = fillRect(gdl, knob - 1, by - 2, knob + 2, by + 4,
+ 160, 255, 160, 255);
}
}
/* ---- pass 2: text ---- */
gdl = microcode_constructor(gdl);
- gdl = drawText(gdl, OV_X0, OV_TOP, "PC OPTIONS", 0xffe040ff);
- gdl = drawText(gdl, OV_X0, OV_TOP + OV_LINE,
- "select: scroll/click, change: mouse/arrows",
- 0x8890a0ff); /* hint line */
- gdl = drawText(gdl, (OV_CB_X0 + OV_CB_X1) / 2 - measureText("X") / 2,
- OV_TOP, "X", 0xffffffff); /* close glyph */
+ gdl = drawBody(gdl, o.left + 10, o.top + 8, "PC OPTIONS", 0xa0ffa0ff);
+ gdl = drawBodyR(gdl, o.right - 14, o.top + 8, "X", 0xa0ffa0ff);
+ if (s_section >= 0) {
+ const struct Row *hdr = &rows[s_section];
+ /* The pinned Back row is a real selection (s_visIdx[0]), not an
+ * always-bright title decoration. Give it the same selected/dimmed
+ * ink as content rows so Up from the first setting has feedback. */
+ gdl = drawBody(gdl, o.left + 12, o.sectionY, "< BACK",
+ s_sel == 0 ? 0xa0ffa0ff : 0x66ca77ff);
+ s32 titleX = o.left + 80;
+ gdl = drawBody(gdl, titleX, o.sectionY, hdr->label, 0x829e91ff);
+ if (hdr->saveScoped) {
+ int f = watchSettingsActiveFolder();
+ char note[24];
+ if (f < 0) snprintf(note, sizeof(note), "(none)");
+ else snprintf(note, sizeof(note), "(Profile %d)", f + 1);
+ gdl = drawBody(gdl, titleX + bodyWidth(hdr->label) + 5,
+ o.sectionY, note, 0x829e91ff);
+ }
+ }
for (int p = s_scroll; p <= pLast; p++) {
const struct Row *r = &rows[s_visIdx[p]];
- s32 rowY = OV_ROW_Y(p - s_scroll);
- u32 col = (p == s_sel) ? 0xffffffff : 0xc0c0c8ff;
+ s32 rowY = o.contentY + (p - s_scroll) * OV_LINE;
+ u32 ink = !r->found ? 0x498053ff :
+ p == s_sel ? 0xa0ffa0ff : 0x66ca77ff;
char val[48];
-
- gdl = drawText(gdl, OV_LABEL_X, rowY, (char *)r->label,
- r->found ? col : 0x808080ff);
+ if (s_section < 0) {
+ gdl = drawBody(gdl, o.labelX, rowY, r->label, ink);
+ continue;
+ }
+ valueText(s_visIdx[p], val, sizeof(val));
+ s32 valueW = bodyWidth(val);
+ s32 labelEnd = r->kind == ROW_SLIDER ? bx0 : o.valueR - valueW - 7;
+ if (strcmp(r->key, "Video.CrosshairColor") == 0) labelEnd -= 17;
+ if (r->restart) labelEnd = bx0 - 6;
+ gdl = drawBodyFit(gdl, o.labelX, rowY, labelEnd - o.labelX,
+ r->label, ink);
+ /* GAMEPLAY and HUD mix profile and global values. Their profile
+ * toggles have space for a subdued word; AUDIO is wholly
+ * profile-scoped and says so in its section title. */
+ if (r->saveScoped && sectionIsMixedScope(s_section)) {
+ s32 tagX = o.labelX + bodyWidth(r->label) + 4;
+ if (tagX + bodyWidth("(profile)") < labelEnd - 2)
+ gdl = drawBody(gdl, tagX, rowY, "(profile)", 0x829e91ff);
+ }
if (!r->found) {
- gdl = drawTextR(gdl, right, rowY, "(n/a)", 0x808080ff);
+ gdl = drawBodyR(gdl, o.valueR, rowY, "(n/a)", 0x498053ff);
continue;
}
-
- valueText(r, val, sizeof(val));
if (r->restart) {
- /* value left of the bar span, "(restart)" pinned to the edge */
- gdl = drawText(gdl, bx0, rowY, val, col);
- gdl = drawTextR(gdl, right, rowY, "(restart)", 0x909090ff);
+ gdl = drawBody(gdl, bx0, rowY, val, ink);
+ gdl = drawBodyR(gdl, o.valueR, rowY, "(restart)", 0x498053ff);
} else {
- gdl = drawTextR(gdl, right, rowY, val, col);
+ u32 valueInk = (strncmp(val, "ON", 2) == 0 ||
+ (isResetRow(r) && strcmp(val, "Confirm") == 0))
+ ? 0xa0ffa0ff : ink;
+ gdl = drawBodyR(gdl, o.valueR, rowY, val, valueInk);
}
}
+ /* Keep the help inside the card at 320x240. The old single long line
+ * started after CONTROLS: and ran under / beyond the right edge. Reserve
+ * two lines in overlayLayout so the footer never covers the last row. */
+ const char *help1, *help2;
+ if (optionsBindingCaptureActive()) {
+ help1 = "PRESS KEY/MOUSE 1-5";
+ help2 = "B/ESC CANCEL DEL CLEAR";
+ } else if (s_section < 0) {
+ help1 = "A/ENTER SELECT";
+ help2 = "B/F10 CLOSE";
+ } else if (!strcmp(rows[s_section].key, "__HdrMoveKeys") ||
+ !strcmp(rows[s_section].key, "__HdrActionKeys")) {
+ help1 = "KEY/MOUSE ONLY LEFT/RIGHT SLOT";
+ help2 = "ENTER BIND B/ESC BACK";
+ } else {
+ help1 = "A SELECT LEFT/RIGHT CHANGE";
+ help2 = "B/ESC BACK F10 CLOSE";
+ }
+ s32 helpX = o.left + 16 + metaWidth("CONTROLS:");
+ s32 helpRight = o.right - 10;
+ static int footerProbe = -1;
+ if (footerProbe < 0) footerProbe = getenv("GE_FOOTERPROBE") != NULL;
+ if (footerProbe) {
+ static int reported;
+ if (!reported++) {
+ static const char *const lines[][2] = {
+ { "PRESS KEY/MOUSE 1-5", "ESC CANCEL DEL CLEAR" },
+ { "A/ENTER SELECT", "B/F10 CLOSE" },
+ { "KEY/MOUSE ONLY LEFT/RIGHT SLOT", "ENTER BIND B/ESC BACK" },
+ { "A SELECT LEFT/RIGHT CHANGE", "B/ESC BACK F10 CLOSE" },
+ };
+ for (size_t k = 0; k < sizeof(lines) / sizeof(lines[0]); k++) {
+ s32 a = bodyWidth(lines[k][0]), b = bodyWidth(lines[k][1]);
+ s32 avail1 = helpRight - helpX;
+ s32 avail2 = helpRight - (o.left + 16);
+ sysLogPrintf(a > avail1 || b > avail2 ? LOG_ERROR : LOG_INFO,
+ "footerprobe: variant %d widths %d/%d available %d/%d %s",
+ (int)k, a, b, avail1, avail2,
+ a > avail1 || b > avail2 ? "OVERFLOW" : "FIT");
+ }
+ }
+ }
+ gdl = drawMeta(gdl, o.left + 11, o.footerY, "CONTROLS:", 0xa6baaaff);
+ gdl = drawBody(gdl, helpX, o.footerY, help1, 0x829e91ff);
+ gdl = drawBody(gdl, o.left + 16, o.footerY + OV_FOOTER_LINE,
+ help2, 0x829e91ff);
+ if (s_visN - (s_section >= 0 ? 1 : 0) > o.maxRows)
+ gdl = drawBodyR(gdl, o.right - 10, o.sectionY,
+ s_scroll > (s_section >= 0 ? 1 : 0) ? "^ v" : "v",
+ 0x80d58bff);
gDPPipeSync(gdl++);
gSPEndDisplayList(gdl++);
@@ -1008,3 +2267,346 @@ Gfx *optionsOverlayEmit(void)
}
return s_buf;
}
+
+/* ------------------------------------------------------------------------ */
+/* D343 (M2): row API for the file-select options screen (frontoptions.c). */
+/* The screen shares this file's rows, value text and live-apply logic; it */
+/* keeps its own page/selection state. Both UIs call these from one thread */
+/* at a time: the screen gets no input while the F10 overlay is open. */
+/* ------------------------------------------------------------------------ */
+
+static struct Row *rowAt(int i)
+{
+ overlayInit();
+ return (i >= 0 && i < NUM_ROWS) ? &rows[i] : NULL;
+}
+
+int optionsRowCount(void)
+{
+ overlayInit();
+ return NUM_ROWS;
+}
+
+int optionsRowIsHeader(int i)
+{
+ struct Row *r = rowAt(i);
+ return r && r->kind == ROW_HEADER;
+}
+
+/* Registered and not hidden by its "auto" toggle. */
+int optionsRowIsShown(int i)
+{
+ struct Row *r = rowAt(i);
+ return r && r->found && !(r->hidePtr && *r->hidePtr) &&
+ (!r->showPtr || *r->showPtr == r->shownValue) &&
+ (r->kind != ROW_BOND_FILE || current_menu == MENU_PC_OPTIONS);
+}
+
+const char *optionsRowLabel(int i)
+{
+ struct Row *r = rowAt(i);
+ return r ? r->label : "";
+}
+
+int optionsRowIsSlider(int i)
+{
+ struct Row *r = rowAt(i);
+ return r && r->kind == ROW_SLIDER && rowHi(r) > rowLo(r);
+}
+
+int optionsRowIsBind(int i)
+{
+ struct Row *r = rowAt(i);
+ return r && r->kind == ROW_BIND;
+}
+
+void optionsRowBeginBind(int i)
+{
+ bindingBegin(rowAt(i));
+}
+
+/* D353: the explicit Bond-file chooser row (front options screen only). */
+int optionsRowIsBondChooser(int i)
+{
+ struct Row *r = rowAt(i);
+ return r && r->kind == ROW_BOND_FILE;
+}
+
+/* D356: content rows carry the literal per-file flag from the table; header
+ * rows were recomputed at init to "this section contains per-file rows".
+ * Both drive UI decoration (the "(per profile)" tag, the "(Profile N)" annotation).
+ * The F10 overlay and the front options screen are the only consumers. */
+int optionsRowIsSaveScoped(int i)
+{
+ struct Row *r = rowAt(i);
+ return r && r->saveScoped;
+}
+
+int optionsRowNeedsRestart(int i)
+{
+ struct Row *r = rowAt(i);
+ return r && r->restart;
+}
+
+double optionsRowFraction(int i)
+{
+ struct Row *r = rowAt(i);
+ if (!r || rowHi(r) <= rowLo(r)) {
+ return 0.0;
+ }
+ return rowFractionAt(r, rowGet(r));
+}
+
+/* D356: raw value (the fraction accessors are bar geometry only); the
+ * reset probe verifies against raw defaults. */
+double optionsRowGetValue(int i)
+{
+ struct Row *r = rowAt(i);
+ return r ? rowGet(r) : 0.0;
+}
+
+void optionsRowSetFraction(int i, double f)
+{
+ struct Row *r = rowAt(i);
+ if (!r || !r->found || rowHi(r) <= rowLo(r)) {
+ return;
+ }
+ if (f < 0.0) f = 0.0;
+ if (f > 1.0) f = 1.0;
+ double step = (r->step > 0.0) ? r->step : 1.0;
+ double v = rowValueAtFraction(r, f);
+ rowSetCommit(r, lround(v / step) * step, 0);
+}
+
+void optionsRowCommit(int i)
+{
+ struct Row *r = rowAt(i);
+ if (r) {
+ int field = watchSettingsFieldForKey(r->key);
+ if (field >= 0) watchSettingsCommit(field);
+ }
+}
+
+void optionsRowValueText(int i, char *out, int n)
+{
+ struct Row *r = rowAt(i);
+ if (!r || n <= 0) {
+ return;
+ }
+ valueText(i, out, n);
+}
+
+void optionsRowAdjust(int i, int dir)
+{
+ struct Row *r = rowAt(i);
+ if (r) {
+ rowAdjust(r, dir);
+ }
+}
+
+/* ------------------------------------------------------------------------ */
+/* D356 GE_WSPROBE_RESET dev hook (env-gated; driven from the game-thread */
+/* hook in watchSettingsGameTick, so the dispatch runs on the right */
+/* thread in both contexts). Drives the REAL arm -> confirm -> dispatch */
+/* UI path for EVERY section's reset row and verifies each section's */
+/* values after ITS dispatch (plan §5.8): watch rows vs BLANKSAVEDATA, */
+/* ini rows vs the defaults table, __Resolution untouched, and (front) a */
+/* second file's bytes unchanged (scope isolation). */
+/* ------------------------------------------------------------------------ */
+
+#define PROBE_MAX_SECTIONS 8 /* reset-owning sections including HUD and bind leaves */
+static int probeSectionIdxs[PROBE_MAX_SECTIONS];
+static int probeSectionN = 0;
+static int probeResSelBefore = -2;
+static int probeOtherFolder = -1;
+static save_data probeOtherBytes;
+
+/* The header + end of the section a reset row belongs to. Written in
+ * plain local-variable form on purpose: the pointer-increment style
+ * (`while (*hdr >= 0 && ...) *hdr--;`) miscompiled under -O2 (the loop
+ * pointer walked the stack and the faulted on garbage rows[] indices). */
+static void probeSectionRange(int iReset, int *hdr, int *end)
+{
+ int h, e;
+ if (iReset <= 0 || iReset >= NUM_ROWS) {
+ sysLogPrintf(LOG_ERROR, "wsresetprobe: bad reset row index %d (NUM_ROWS %d)", iReset, NUM_ROWS);
+ *hdr = -1; *end = -1;
+ return;
+ }
+ h = iReset - 1;
+ while (h >= 0 && rows[h].kind != ROW_HEADER) h--;
+ e = h + 1;
+ while (e < NUM_ROWS && rows[e].kind != ROW_HEADER) e++;
+ *hdr = h;
+ *end = e;
+}
+
+/* Verify every row of section iReset is at its default. Returns the
+ * failure count. Watch rows compare against BLANKSAVEDATA via
+ * watchSettingsRead (front: the saved file, stage: the post-drain
+ * snapshot); ini rows against the defaults table. */
+static int probeVerifySection(int iReset)
+{
+ int hdr, end;
+ probeSectionRange(iReset, &hdr, &end);
+ if (hdr < 0) {
+ sysLogPrintf(LOG_WARNING, "wsresetprobe: section %d: no header", iReset);
+ return 1;
+ }
+ int bad = 0;
+ for (int j = hdr + 1; j < end; j++) {
+ struct Row *r = &rows[j];
+ if (r->kind == ROW_ACTION) continue;
+ if (strcmp(r->key, "__Resolution") == 0) continue; /* documented exclusion */
+ int field = watchSettingsFieldForKey(r->key);
+ if (field >= 0) {
+ int want = watchSettingsBlankValue((enum WatchSettingField)field);
+ int got = watchSettingsRead((enum WatchSettingField)field);
+ if (got != want) {
+ sysLogPrintf(LOG_WARNING, "wsresetprobe: section '%s': %s = %d, want %d (BLANKSAVEDATA)",
+ rows[hdr].label, r->key, got, want);
+ bad++;
+ }
+ } else if (r->found && r->ptr) {
+ double want = kResetDefault(r->key);
+ if (want < 0.0) continue; /* no default (the handler skips it too) */
+ double got = optionsRowGetValue(j);
+ if (got < want - 0.001 || got > want + 0.001) {
+ sysLogPrintf(LOG_WARNING, "wsresetprobe: section '%s': %s = %g, want %g (default table)",
+ rows[hdr].label, r->key, got, want);
+ bad++;
+ }
+ }
+ }
+ return bad;
+}
+
+/* Front context (tick 1): dirty one value per section, then arm + confirm
+ * every section's reset row in order, verifying each section right after
+ * its own dispatch (the front commit is direct). Finally: resolution
+ * untouched + a second file's bytes unchanged (scope isolation). */
+void optionsResetProbePrepare(void)
+{
+ overlayInit(); /* idempotent: resolves the ini rows (found + ptr) so
+ * the front probe dispatches every declared row, not
+ * just the watch rows */
+ probeSectionN = 0;
+ for (int i = 0; i < NUM_ROWS; i++)
+ if (isResetRow(&rows[i]) && probeSectionN < PROBE_MAX_SECTIONS)
+ probeSectionIdxs[probeSectionN++] = i;
+ probeResSelBefore = s_resSel;
+ int active = watchSettingsActiveFolder();
+ sysLogPrintf(LOG_INFO, "wsresetprobe: front prepare start (active folder %d, %d sections)", active, probeSectionN);
+ probeOtherFolder = -1;
+ for (int f = FOLDER1; f < MAX_FOLDER_COUNT; f++) {
+ if (f == active) continue;
+ save_data *s = fileGetSaveForFoldernum((u32)f);
+ if (s) {
+ probeOtherFolder = f;
+ probeOtherBytes = *s;
+ break;
+ }
+ }
+ sysLogPrintf(LOG_INFO, "wsresetprobe: other-folder snapshot done (folder %d)", probeOtherFolder);
+ /* 1. dirty a distinct value in each section (its first adjustable row). */
+ for (int s = 0; s < probeSectionN; s++) {
+ int iR = probeSectionIdxs[s];
+ int hdr, end;
+ probeSectionRange(iR, &hdr, &end);
+ if (hdr < 0) continue;
+ int did = 0;
+ for (int j = hdr + 1; j < end && !did; j++) {
+ struct Row *r = &rows[j];
+ if (r->kind == ROW_ACTION || strcmp(r->key, "__Resolution") == 0) continue;
+ int field = watchSettingsFieldForKey(r->key);
+ if (field >= 0) {
+ int blank = watchSettingsBlankValue((enum WatchSettingField)field);
+ int want = (blank > 1) ? 0 : 1; /* distinct from the blank */
+ if (watchSettingsRead((enum WatchSettingField)field) == want) continue;
+ watchSettingsSet((enum WatchSettingField)field, want, 1);
+ did = 1;
+ } else if (r->found && r->ptr && (r->kind == ROW_TOGGLE || r->kind == ROW_ENUM ||
+ r->kind == ROW_SLIDER || r->kind == ROW_MSAA)) {
+ optionsRowAdjust(j, 1); /* one step off the default */
+ did = 1;
+ }
+ }
+ sysLogPrintf(LOG_INFO, "wsresetprobe: section '%s' dirty=%d", rows[hdr].label, did);
+ }
+ /* 2. arm + confirm every section, verifying each after its dispatch. */
+ for (int s = 0; s < probeSectionN; s++) {
+ int iReset = probeSectionIdxs[s];
+ int hdr, end;
+ probeSectionRange(iReset, &hdr, &end);
+ if (hdr < 0) continue;
+ optionsRowActivateReset(iReset); /* arm */
+ sysLogPrintf(LOG_INFO, "wsresetprobe: section %d armed", s);
+ optionsRowActivateReset(iReset); /* confirm -> commit */
+ int bad = probeVerifySection(iReset);
+ sysLogPrintf(LOG_INFO, "wsresetprobe: section %d confirmed + verified", s);
+ if (s_resSel != probeResSelBefore) {
+ sysLogPrintf(LOG_WARNING, "wsresetprobe: __Resolution changed (%d -> %d)", probeResSelBefore, s_resSel);
+ bad++;
+ }
+ sysLogPrintf(LOG_INFO, "wsresetprobe: section '%s' verified, failures=%d", rows[hdr].label, bad);
+ }
+ if (probeOtherFolder >= 0) {
+ save_data *s = fileGetSaveForFoldernum((u32)probeOtherFolder);
+ if (!s || memcmp(&probeOtherBytes, s, sizeof(save_data)) != 0) {
+ sysLogPrintf(LOG_WARNING, "wsresetprobe: file %d bytes CHANGED (scope isolation broken)", probeOtherFolder + 1);
+ } else {
+ sysLogPrintf(LOG_INFO, "wsresetprobe: file %d unchanged (scope isolation)", probeOtherFolder + 1);
+ }
+ } else {
+ sysLogPrintf(LOG_INFO, "wsresetprobe: no second folder to compare");
+ }
+}
+
+/* In stage (tick 2): arm + confirm every section. The file rows enter the
+ * D352 command queue (applied + persisted by the game thread at the next
+ * drain); log the queue depth so the dispatch is visible. */
+void optionsResetProbeDispatchStage(void)
+{
+ if (probeSectionN == 0) {
+ for (int i = 0; i < NUM_ROWS; i++)
+ if (isResetRow(&rows[i]) && probeSectionN < PROBE_MAX_SECTIONS)
+ probeSectionIdxs[probeSectionN++] = i;
+ }
+ probeResSelBefore = s_resSel;
+ for (int s = 0; s < probeSectionN; s++) {
+ optionsRowActivateReset(probeSectionIdxs[s]); /* arm */
+ optionsRowActivateReset(probeSectionIdxs[s]); /* confirm */
+ }
+ int q = watchSettingsQueueCount();
+ if (q < 6) {
+ sysLogPrintf(LOG_WARNING, "wsresetprobe: stage dispatch: queue depth %d, want >= 6 (2 GAMEPLAY + 2 HUD + 2 AUDIO watch rows)", q);
+ } else {
+ sysLogPrintf(LOG_INFO, "wsresetprobe: stage dispatch: %d command(s) queued (game thread drains next tick)", q);
+ }
+}
+
+/* In stage (tick 3, after the drain + persist): verify every section. */
+void optionsResetProbeVerifyStage(void)
+{
+ for (int s = 0; s < probeSectionN; s++) {
+ int iReset = probeSectionIdxs[s];
+ int hdr, end;
+ probeSectionRange(iReset, &hdr, &end);
+ int bad = probeVerifySection(iReset);
+ if (s_resSel != probeResSelBefore) {
+ sysLogPrintf(LOG_WARNING, "wsresetprobe: __Resolution changed (%d -> %d)", probeResSelBefore, s_resSel);
+ bad++;
+ }
+ sysLogPrintf(LOG_INFO, "wsresetprobe: stage: section '%s' verified, failures=%d", rows[hdr].label, bad);
+ }
+ /* The persisted bytes must hold the BLANKSAVEDATA raw values too. */
+ save_data *save = fileGetSaveForFoldernum(selected_folder_num);
+ if (!save) {
+ sysLogPrintf(LOG_WARNING, "wsresetprobe: stage: no save for folder %d", (int)selected_folder_num);
+ } else if (save->music_vol != 0xFF || save->sfx_vol != 0xFF) {
+ sysLogPrintf(LOG_WARNING, "wsresetprobe: stage: saved music=%d fx=%d, want 0xFF/0xFF",
+ (int)save->music_vol, (int)save->sfx_vol);
+ } else {
+ sysLogPrintf(LOG_INFO, "wsresetprobe: stage: saved bytes OK (music/sfx = 0xFF)");
+ }
+}
diff --git a/port/src/video.c b/port/src/video.c
index b70fa827..1739110c 100644
--- a/port/src/video.c
+++ b/port/src/video.c
@@ -30,6 +30,7 @@
#include "video.h"
#include "input.h"
#include "optionsoverlay.h"
+#include "frontoptions.h"
#include "../fast3d/gfx_api.h"
#include "../fast3d/gfx_sdl.h"
@@ -50,21 +51,24 @@ static struct GfxRenderingAPI *renderingAPI;
static int initDone = 0;
/*
- * [Video] ge007.ini knobs. Every default reproduces the previously-hardcoded
- * behaviour, so a fresh config or a missing [Video] section changes nothing.
+ * [Video] ge007.ini knobs. Draw/LOD and MSAA defaults favour a clean picture
+ * at modern resolutions without requiring 4x anti-aliasing.
*/
static int cfgVSync = 1; /* swap interval: 0 = off, 1 = on */
static int cfgFpsCap = 60; /* frame cap in fps; 0 = uncapped (vsync); menu only exposes 30/60 */
-static int cfgMSAA = 4; /* 1/2/4/8 samples; default 4 (modern ports ship AA on; snaps down to the highest supported level) */
+static int cfgMSAA = 2; /* 1/2/4/8 samples; 2x default is lighter on low-end GPUs */
static int cfgTexFilter = 1; /* 0 = nearest, 1 = bilinear (default), 2 = N64 3-point + trilinear */
static int cfgFixMipTex = 1; /* RC2: clip mip-contaminated texture uploads to base height */
+static int cfgDetailBaseTile = 1; /* D236: TEXTURETYPE_DETAIL -> sample the base image, not the detail tile */
static int cfgWrapFix = 0; /* D74 sub-tile UV pre-wrap + RC3/D167 non-PoT mask-period wrap (opt-in; GE_WRAPFIX env overrides) */
static int cfgFovScale = 100; /* D211: percent of the original vertical FOV; 100 = unchanged (byte-identical) */
static int cfgWidescreenAuto = 1; /* WIDESCREEN-FOV-PLAN Phase 4: auto-scale vertical FOV by window aspect ratio; on by default, no-op at 4:3 */
-static int cfgDrawDistance = 150; /* D218: percent of the level's authored far-clip/fog distance. Default raised 100->150 for v0.2.0: at the authored N64 distance, props visibly fade in just before they become visible on modern displays (Dam alarms / wall switches); 150 is the value the Steam Deck preset playtest-validated. 100 = unchanged N64. */
-static int cfgDrawDistanceAutoFov = 1; /* D218: couple draw distance to Video.FovScale unless DrawDistance is set explicitly */
-static int cfgLodDistance = 150; /* D249: percent scale on the geometry/model LOD-swap distance. Default raised 100->150 for v0.2.0 (same pop-in family as DrawDistance: LOD-swapped props like Dam's alarms/wall switches faded in at range); 150 matches the Steam Deck preset. 100 = unchanged N64. */
-static int cfgLodDistanceAutoFov = 0; /* off by default -- unlike DrawDistance, this is meant as a standalone perf lever, not something that should silently get more expensive as FovScale widens */
+static int cfgNativeWidescreen = 1; /* D334 (WIDESCREEN-FOV-PLAN Phase 2): project the world at the real window aspect (Hor+); no-op at 4:3 */
+static int cfgHudScale = 100; /* D226: HUD text/ammo scale %, 100 = original (no emission) */
+static int cfgDrawDistance = 250; /* % of authored far clip; 250% is the UI's 50/100 midpoint */
+static int cfgDrawDistanceAutoFov = 0; /* legacy ini option, no longer exposed in the menu */
+static int cfgLodDistance = 250; /* % of authored geometry LOD distance; 50/100 in the UI */
+static int cfgLodDistanceAutoFov = 0; /* legacy ini option, no longer exposed in the menu */
static int cfgAniso = 4; /* D212: anisotropic filtering samples; 4 = the value fast3d already applied (no visual delta at default) */
static int cfgSafeAreaCrop = 1; /* crop the N64 TV-overscan safe-area margin (visible as black top/bottom bars on PC) instead of showing it; on by default */
static int cfgFullscreen = 0; /* 0 = windowed, 1 = borderless fullscreen */
@@ -98,6 +102,73 @@ s32 portSkipIntro = 0;
* original damage flash. */
s32 portNoHitFlash = 0;
+/* v0.4.0 M2 (D373/D379): in-game crosshair on/off + tint
+ * (gunfire.c gunDrawSight, #ifdef PORT). Default keeps the original
+ * authored red sprite; hide is opt-in. */
+s32 portCrosshairHide = 0;
+static int cfgCrosshairColor = 0; /* 0 = authored sprite; 1..7 = presets; 8 = custom RGB */
+static int cfgCrosshairRed = 255, cfgCrosshairGreen = 255, cfgCrosshairBlue = 255;
+static int cfgCrosshairSize = 100; /* 100% retains the original 32x32 drawing */
+static int cfgCrosshairStyle = 0; /* 0 = original; 1 = unused beta asset */
+
+int portCrosshairStyle(void) { return cfgCrosshairStyle; }
+float portCrosshairScale(void) { return cfgCrosshairSize / 100.0f; }
+
+/* Index 0 leaves the authored RED sprite untouched (white env multiplier);
+ * index 7 is actual white via its alpha silhouette. */
+/* The authored RGBA32 reticle is RED (confirmed from the decoded 32x32
+ * IMAGE_CROSSHAIR1 texture). G_CC_FADEA multiplies TEXEL0.rgb by env.rgb;
+ * no amount of blue/cyan tint can recover channels absent from the texture.
+ * For the opt-in colours, substitute a combiner that uses TEXEL0 alpha as
+ * the grayscale reticle mask and ENVIRONMENT for the requested RGB. The
+ * alpha expression is unchanged: texture alpha * env alpha (0x6e).
+ * The call site points at display_image_at_position's env-color command;
+ * its immediately following command is the combine mode. Original/0
+ * skips this rewrite, keeping the original game's DL byte-identical. */
+void portCrosshairApplyTintCombine(Gfx *envCommand)
+{
+ if (cfgCrosshairColor == 0 || !envCommand) return;
+ gDPSetCombineLERP(envCommand + 1,
+ ENVIRONMENT, 0, TEXEL0_ALPHA, 0, TEXEL0, 0, ENVIRONMENT, 0,
+ ENVIRONMENT, 0, TEXEL0_ALPHA, 0, TEXEL0, 0, ENVIRONMENT, 0);
+}
+
+void portCrosshairTint(s32 *r, s32 *g, s32 *b)
+{
+ static const unsigned char kTints[8][3] = {
+ { 0xFF, 0xFF, 0xFF }, /* Original red sprite (identity multiplier) */
+ { 0x40, 0xFF, 0x40 }, /* Green */
+ { 0xFF, 0x40, 0x40 }, /* Red */
+ { 0x40, 0x40, 0xFF }, /* Blue */
+ { 0xFF, 0xFF, 0x40 }, /* Yellow */
+ { 0x40, 0xFF, 0xFF }, /* Cyan */
+ { 0xFF, 0x40, 0xFF }, /* Magenta */
+ { 0xFF, 0xFF, 0xFF }, /* Actual white, through alpha silhouette */
+ };
+ if (cfgCrosshairColor == 8) {
+ *r = cfgCrosshairRed;
+ *g = cfgCrosshairGreen;
+ *b = cfgCrosshairBlue;
+ return;
+ }
+ int index = (cfgCrosshairColor >= 0 && cfgCrosshairColor <= 7)
+ ? cfgCrosshairColor : 0;
+ *r = (s32)kTints[index][0];
+ *g = (s32)kTints[index][1];
+ *b = (s32)kTints[index][2];
+}
+
+/* The original sprite is red despite its white identity multiplier. Show a
+ * representative red swatch for it rather than misleadingly showing white. */
+void portCrosshairPreview(s32 *r, s32 *g, s32 *b)
+{
+ if (cfgCrosshairColor == 0) {
+ *r = 255; *g = 40; *b = 40;
+ } else {
+ portCrosshairTint(r, g, b);
+ }
+}
+
/* D257: Game.AllUnlocked — everything-unlocked goodie, OFF by default
* (faithful N64 progression: levels unlock as you complete them). Consumed
* once at startup by main.c, which sets the game's own RAM unlock flags
@@ -142,10 +213,44 @@ f32 portFovScale = 1.0f;
* auto-scale formula can otherwise degenerate toward as aspect -> 0; this
* resolves WIDESCREEN-FOV-PLAN.md's open "horizontal extreme-aspect sanity
* clamp" question. */
+/* D334 (WIDESCREEN-FOV-PLAN Option B, Phase 2): the world-projection aspect
+ * for native widescreen, or 0 when off. The game's own N64 16:9 mode
+ * (bondview2.c bondviewMovePlayerUpdateViewport) projects at
+ * viewport_ratio * 0.75 * 16/9 -- an anamorphic pre-squash the TV undid. The
+ * port already stretches the 4:3 logical canvas to the window (fast3d
+ * RATIO_X), exactly like that TV, so the same formula with 16/9 replaced by
+ * the real window aspect renders the world undistorted at any aspect: vertical
+ * FOV stays the game's, horizontal widens (Hor+, the PD standard). Canvas,
+ * portal scissors and culling all stay in the game's 320x240 logical space and
+ * see the same aspect, so there is no second "view width" to reconcile
+ * (WIDESCREEN-FOV-PLAN s6). Clamped to [0.5, 4.0]. At exactly 4:3 the
+ * formula is identity, so 4:3 renders are unchanged. */
+/* D226: Game.HudScale percent (75..150); 100 = original size, nothing emitted. */
+s32 portHudScalePercent(void)
+{
+ return cfgHudScale;
+}
+
+f32 portNativeAspect(void)
+{
+ f32 a = gfx_current_dimensions.aspect_ratio;
+ if (!cfgNativeWidescreen || a < 0.01f) {
+ return 0.0f;
+ }
+ if (a < 0.5f) { a = 0.5f; }
+ if (a > 4.0f) { a = 4.0f; }
+ return a;
+}
+
f32 portScaleFovY(f32 fovy, s32 isTitleScreen)
{
if (!isTitleScreen) {
- if (cfgWidescreenAuto && gfx_current_dimensions.aspect_ratio > 0.01f) {
+ /* D334: native widescreen already widens the horizontal FOV through
+ * the projection aspect; the Phase-4 vertical boost below was the
+ * stretch-era compensation and would double-count, so it is skipped
+ * (vertical FOV stays the game's, as in the PD port). */
+ if (cfgWidescreenAuto && !cfgNativeWidescreen &&
+ gfx_current_dimensions.aspect_ratio > 0.01f) {
fovy *= sqrtf(gfx_current_dimensions.aspect_ratio / (4.0f / 3.0f));
}
if (portFovScale > 0.4f && portFovScale < 2.01f && portFovScale != 1.0f) {
@@ -162,7 +267,7 @@ f32 portScaleFovY(f32 fovy, s32 isTitleScreen)
* both the far clip plane and the fog-saturation distance (levels are tuned
* for the stock ~60deg FOV), plus the character/prop fog-visibility-fade
* cutoff (src/game/propobj.c chrobjFogVisRangeRelated/sub_GAME_7F054C58).
- * Video.DrawDistanceAutoFov (default on) couples the multiplier to
+ * Video.DrawDistanceAutoFov (legacy ini option, default off) couples the multiplier to
* Video.FovScale so a wider FOV doesn't clip its own newly visible far
* geometry -- or fade NPCs out early -- against distances tuned for the
* narrower original view (D218's "blue artifacting"; the M-121 live
@@ -175,7 +280,7 @@ f32 portScaleFovY(f32 fovy, s32 isTitleScreen)
* glow" on far Dam tunnel geometry, so auto coupling is now 4x FovScale)
* to give headroom (max portFovScale is 1.5 at Video.FovScale's registered
* ceiling of 150, so 4x tops out at 6.0x -- ceiling raised to match).
- * Identity (1.0f) at both defaults, bit-for-bit no-op. NOTE: end-to-end
+ * Identity (1.0f) at DrawDistance=100 with auto-FOV off. NOTE: end-to-end
* re-check of fogLoadCurrentEnvironment (bgfog.c) found the fog RAMP
* itself (not just the far-clip cutoff) already scales correctly with
* this multiplier -- g_ScaledFarFogIntensity/scaled_far_fog_dist both
@@ -206,14 +311,14 @@ f32 portDrawDistanceMultiplier(void)
* "closer", so this function returns the INVERSE of the requested percent:
* Video.LodDistance=200 (keep full detail twice as far, more cost) ->
* 0.5x on the distance term; =50 (drop to lower detail twice as soon, less
- * cost -- the perf lever) -> 2.0x. Video.LodDistanceAutoFov (default OFF,
- * unlike DrawDistanceAutoFov) can couple it to Video.FovScale the same
+ * cost -- the perf lever) -> 2.0x. Video.LodDistanceAutoFov (legacy ini
+ * option, default OFF) can couple it to Video.FovScale the same
* direction as draw distance if ever wanted; off by default so this stays a
* standalone dial and doesn't quietly add cost as FovScale widens. Clamped
* to a [0.25, 4.0] distance multiplier (== effective LodDistance 25-400%) --
* far outside that band either does nothing visible (LOD never triggers) or
- * thrashes every frame. Identity (1.0f) at the Video.LodDistance=100
- * default, bit-for-bit no-op. */
+ * thrashes every frame. Identity (1.0f) at Video.LodDistance=100
+ * with auto-FOV off. */
f32 portLodDistanceMultiplier(void)
{
f32 pct;
@@ -229,20 +334,103 @@ f32 portLodDistanceMultiplier(void)
return 100.0f / pct;
}
+/* D294: the room-model pool (mema, sized per level by boss.c's
+ * memallocstringtable `-maNNN` rows) was budgeted by Rare for the N64's fixed
+ * ~60deg FOV / 4:3 / authored far-clip. The PC visibility knobs above widen
+ * what is on screen at once (FovScale + WidescreenAuto widen the frustum;
+ * DrawDistance pushes the far clip; LodDistance keeps high-detail meshes out
+ * further), so more rooms stay resident simultaneously. When demand exceeds
+ * the authored pool, memaAlloc() returns NULL and bgLoadRoomModelData() bails
+ * silently -- the undrawn room's scissor region shows the framebuffer clear
+ * colour: a transient solid-black patch of ground that self-recovers when the
+ * player moves (D294, Statue monument plaza at FovScale 130 + max DD/Lod).
+ *
+ * This returns a pool-size multiplier for boss.c to apply to `-ma` on PC:
+ * - FOV term: manual FovScale composed with the WidescreenAuto aspect
+ * scale (same sqrt(aspect/4:3) factor portScaleFovY uses), since both
+ * widen the frustum horizontally and admit more side rooms;
+ * - DD/Lod terms: square-root of the distance multipliers -- room COUNT
+ * grows far slower than distance-squared, so sqrt keeps the bump modest;
+ * a linear-terms + 3.0-cap variant was tried (Statue's full room set is
+ * 487 KB = 2.2x its authored pool and starves even 2x) but regressed
+ * the renderer on the menu-driven path -- see the in-function note;
+ * capped at 2.0x (worst case `-ma350` level row -> 700 KB, well inside the
+ * PC's ~6 MB STAGE bank; see D95). NOTE: a live instrumented Statue playtest
+ * showed 2x still starves under whole-level room churn (the full set is
+ * 487 KB = 2.2x the authored `-ma220` pool), and the attempted 3x/linear
+ * fix REGRESSED the renderer on the menu-driven path -- reverted, see the
+ * in-function note + findings D294. Identity (1.0) when every knob is at its
+ * N64-faithful value, so all-100 settings keep the exact authored budget.
+ * Called once per stage load from bossMainloop (src/boss.c).
+ *
+ * GE_ROOMPOOL= overrides the computed scale outright (test hook for
+ * before/after captures; also a user opt-out if the larger pool ever causes
+ * trouble on a level). */
+f32 portRoomPoolScale(void)
+{
+ const char *ov = getenv("GE_ROOMPOOL");
+ f32 scale = 1.0f;
+ f32 t;
+
+ if (ov && *ov) {
+ /* Test hook: values < 1.0 shrink the pool BELOW the authored size
+ * to stress-test the exhaustion path (D294 verification). */
+ scale = (f32)atof(ov);
+ if (scale > 2.0f) { scale = 2.0f; }
+ if (scale < 0.25f) { scale = 0.25f; }
+ return scale;
+ }
+
+ t = portFovScale;
+ /* D334: native widescreen widens the horizontal frustum too (via the
+ * projection aspect instead of the vertical boost), so it admits the same
+ * extra side rooms and keeps the pool bump. */
+ if ((cfgWidescreenAuto || cfgNativeWidescreen) && gfx_current_dimensions.aspect_ratio > 0.01f) {
+ t *= sqrtf(gfx_current_dimensions.aspect_ratio / (4.0f / 3.0f));
+ }
+ if (t > scale) { scale = t; }
+
+ /* D294 follow-up (2026-09-22): linear terms + a 3.0 cap were tried to
+ * cover Statue's full 487 KB room set, but that build corrupted model
+ * anim state on the MENU-driven Statue path (D156 NaN frames; direct
+ * -level_22 boots stayed clean headless). Reverted to sqrt + 2.0 cap,
+ * the user-verified working state. Do not re-apply without the capture-bat
+ * repro data (scratch/d294_capture.bat -> D156 hexdump + GE_D294BANK). */
+ t = sqrtf(portDrawDistanceMultiplier());
+ if (t > scale) { scale = t; }
+
+ t = sqrtf(1.0f / portLodDistanceMultiplier());
+ if (t > scale) { scale = t; }
+
+ if (scale > 2.0f) { scale = 2.0f; }
+ if (scale < 1.0f) { scale = 1.0f; }
+ return scale;
+}
+
PD_CONSTRUCTOR static void videoConfigInit(void)
{
configRegisterFloat("Game.ScreenShakeIntensity", &portScreenShakeScale, 0.0f, 10.0f);
configRegisterInt("Game.SkipIntro", &portSkipIntro, 0, 1);
configRegisterInt("Game.NoHitFlash", &portNoHitFlash, 0, 1);
+ configRegisterInt("Video.CrosshairHide", &portCrosshairHide, 0, 1); /* v0.4.0 M2 (D373) */
+ configRegisterInt("Video.CrosshairColor", &cfgCrosshairColor, 0, 8); /* 8 = custom; old ini values unchanged */
+ configRegisterInt("Video.CrosshairRed", &cfgCrosshairRed, 0, 255);
+ configRegisterInt("Video.CrosshairGreen", &cfgCrosshairGreen, 0, 255);
+ configRegisterInt("Video.CrosshairBlue", &cfgCrosshairBlue, 0, 255);
+ configRegisterInt("Video.CrosshairSize", &cfgCrosshairSize, 50, 200);
+ configRegisterInt("Video.CrosshairStyle", &cfgCrosshairStyle, 0, 1);
configRegisterInt("Game.AllUnlocked", &portAllUnlocked, 0, 1);
configRegisterInt("Video.VSync", &cfgVSync, 0, 1);
configRegisterInt("Video.FpsCap", &cfgFpsCap, 0, 1000);
configRegisterInt("Video.MSAA", &cfgMSAA, 1, 8);
configRegisterInt("Video.TextureFilter", &cfgTexFilter, 0, 2);
configRegisterInt("Video.FixMipTextures", &cfgFixMipTex, 0, 1);
+ configRegisterInt("Video.DetailBaseTile", &cfgDetailBaseTile, 0, 1);
configRegisterInt("Video.WrapFix", &cfgWrapFix, 0, 1);
configRegisterInt("Video.FovScale", &cfgFovScale, 50, 150);
configRegisterInt("Video.WidescreenAuto", &cfgWidescreenAuto, 0, 1);
+ configRegisterInt("Video.NativeWidescreen", &cfgNativeWidescreen, 0, 1); /* D334 */
+ configRegisterInt("Game.HudScale", &cfgHudScale, 75, 150); /* D226: capped at 150 (user: little benefit above) */
configRegisterInt("Video.DrawDistance", &cfgDrawDistance, 100, 400);
configRegisterInt("Video.DrawDistanceAutoFov", &cfgDrawDistanceAutoFov, 0, 1);
configRegisterInt("Video.LodDistance", &cfgLodDistance, 25, 400);
@@ -262,10 +450,9 @@ PD_CONSTRUCTOR static void videoConfigInit(void)
* first-run path saves these values to disk and every later launch reads the
* file (user changes via F10 win outright); if an ini already exists its
* values overwrite everything here. So this is a first-launch preset only.
- * 1280x800 is the Deck's native panel resolution; MSAA 4 + VSync is
- * comfortable headroom for the A11 GPU; DrawDistance/LodDistance at 150%
- * because the authored N64 fade distances read "things pop in just before
- * you can see them" on a sharp 7" close-up panel (e.g. the Dam lock).
+ * 1280x800 is the Deck's native panel resolution; 2x MSAA + VSync keep
+ * GPU cost modest. Draw/LOD distances use the standard midpoint (250%)
+ * to avoid obvious N64-era pop-in on modern displays.
* Note: the panel is 16:10 and the game renders 4:3, so this stretches
* uniformly like any non-4:3 window today (letterboxing is the parked
* WIDESCREEN-FOV-PLAN). */
@@ -275,14 +462,18 @@ void videoApplySteamOSDefaults(void)
cfgWinW = 1280;
cfgWinH = 800;
cfgVSync = 1;
- cfgMSAA = 4;
- cfgDrawDistance = 150;
- cfgLodDistance = 150;
+ cfgMSAA = 2;
+ cfgDrawDistance = 250;
+ cfgLodDistance = 250;
}
-/* Set by videoRequestLiveConfig() (F10 overlay, host thread); consumed on the
- * scheduler thread in videoStartFrame() where the GL context is bound. */
-static volatile int liveCfgDirty = 0;
+/* D382: record precisely which live video setting changed. Reticle/FOV/
+ * frame cap changes must not reset texture state (a shader/cache clear).
+ * Option writes may arrive on the menu or scheduler thread; apply on the
+ * render thread with the GL context bound, coalesced per frame. */
+enum { VCFG_VSYNC = 1, VCFG_FPS = 2, VCFG_FILTER = 4,
+ VCFG_FOV = 8, VCFG_ANISO = 16, VCFG_CROP = 32 };
+static SDL_atomic_t liveCfgDirty;
/* D211/D212: push the port-only image knobs where they apply. FovScale is a
* plain float the game re-reads each frame; anisotropy goes to fast3d. */
@@ -307,11 +498,23 @@ static void videoApplyTexFilter(void)
}
}
-/* Re-apply the live-tunable [Video] knobs (VSync / FpsCap / TextureFilter).
- * MSAA and Fullscreen are FBO/window rebuilds -> "(restart)" in the overlay. */
-void videoRequestLiveConfig(void)
+/* Port-only sprite settings and direct-read world/HUD options are not GL
+ * state. Keep the heavyweight texture reset only for the two texture knobs. */
+void videoRequestLiveConfigForKey(const char *key)
{
- liveCfgDirty = 1;
+ int mask = 0;
+ if (!strcmp(key, "Video.VSync")) mask = VCFG_VSYNC;
+ else if (!strcmp(key, "Video.FpsCap")) mask = VCFG_FPS;
+ else if (!strcmp(key, "Video.TextureFilter")) mask = VCFG_FILTER;
+ else if (!strcmp(key, "Video.FovScale")) mask = VCFG_FOV;
+ else if (!strcmp(key, "Video.Anisotropy")) mask = VCFG_ANISO;
+ else if (!strcmp(key, "Video.SafeAreaCrop")) mask = VCFG_CROP;
+ if (mask) {
+ int old;
+ do {
+ old = SDL_AtomicGet(&liveCfgDirty);
+ } while (!SDL_AtomicCAS(&liveCfgDirty, old, old | mask));
+ }
}
/* --- F10 overlay: window / fullscreen changes, deferred to the host thread ---
@@ -460,6 +663,7 @@ int videoInit(void)
* NOT fix the Depot roof, see docs/BRIEF-B2-depot-textures.md). All keep
* point-sampled tiles (HUD, G_TF_POINT) crisp via the per-tile flag. */
gfx_set_fix_mip_textures(cfgFixMipTex);
+ gfx_set_detail_base_tile(cfgDetailBaseTile);
gfx_set_wrap_fix(cfgWrapFix);
videoApplyTexFilter();
@@ -482,6 +686,68 @@ int videoInit(void)
return 0;
}
+/* ---- D344: orderly quit ------------------------------------------------
+ * Rendering runs on the scheduler thread with the GL context bound; quit
+ * events arrive on the host thread (or on fast3d's render-thread pump).
+ * exit() used to run straight from whichever pump saw the event, tearing the
+ * process down while the render thread could be inside the NVIDIA driver --
+ * the likely trigger of the 0x119 VIDEO_SCHEDULER_INTERNAL_ERROR bugchecks
+ * (findings D344). Now:
+ * 1. anyone calls videoRequestQuit();
+ * 2. the render thread, at its next frame boundary, glFinish()es, unbinds
+ * the context and parks for good (videoRenderPark);
+ * 3. the host thread waits until the render thread is parked or outside a
+ * frame (max QUIT_WAIT_MS), then exit(0) (atexit saves config).
+ * Frame entry sets s_inFrame BEFORE checking s_quitReq, and the host sets
+ * s_quitReq BEFORE reading s_inFrame (SDL atomics are sequentially
+ * consistent), so a frame can never start once the host has decided to exit. */
+#define QUIT_WAIT_MS 2000
+static SDL_atomic_t s_quitReq;
+static SDL_atomic_t s_inFrame;
+static SDL_atomic_t s_parked;
+static int s_quitFrame = -1; /* GE_QUITFRAME: harness self-quit, -1 = unread */
+
+void videoRequestQuit(const char *why)
+{
+ if (SDL_AtomicCAS(&s_quitReq, 0, 1)) {
+ sysLogPrintf(LOG_INFO, "video: quit requested (%s)", why ? why : "?");
+ }
+}
+
+int videoQuitRequested(void)
+{
+ return SDL_AtomicGet(&s_quitReq) != 0;
+}
+
+/* Render thread only. Never returns. */
+static void videoRenderPark(void)
+{
+ gfx_sdl_park_for_exit();
+ SDL_AtomicSet(&s_inFrame, 0);
+ SDL_AtomicSet(&s_parked, 1);
+ for (;;) {
+ sysSleep(100000);
+ }
+}
+
+/* Host thread: exit once the render thread is out of the GL driver. */
+static void videoHostExitIfRequested(void)
+{
+ if (!SDL_AtomicGet(&s_quitReq)) {
+ return;
+ }
+ Uint32 start = SDL_GetTicks();
+ while (!SDL_AtomicGet(&s_parked) && SDL_AtomicGet(&s_inFrame) &&
+ SDL_GetTicks() - start < QUIT_WAIT_MS) {
+ SDL_Delay(2);
+ }
+ sysLogPrintf(LOG_INFO, "video: exiting (render %s after %u ms)",
+ SDL_AtomicGet(&s_parked) ? "parked"
+ : (SDL_AtomicGet(&s_inFrame) ? "STILL IN FRAME (timeout)" : "idle"),
+ (unsigned)(SDL_GetTicks() - start));
+ exit(0);
+}
+
void videoDestroy(void)
{
if (initDone) {
@@ -495,19 +761,27 @@ void videoStartFrame(void)
if (!initDone) {
return;
}
+ /* D344: enter the frame first, then check for a quit (see above). */
+ SDL_AtomicSet(&s_inFrame, 1);
+ if (SDL_AtomicGet(&s_quitReq)) {
+ videoRenderPark();
+ }
+
/* Rendering runs on the game's scheduler thread; the GL context was
* created on the host main thread. */
gfx_sdl_make_context_current();
- if (liveCfgDirty) {
- liveCfgDirty = 0;
- wmAPI->set_swap_interval(cfgVSync ? 1 : 0);
- gfx_set_target_fps(cfgFpsCap); /* 0 = uncapped */
- videoApplyTexFilter();
- videoApplyImageOptions();
- sysLogPrintf(LOG_INFO, "video: live config applied "
+ int dirty = SDL_AtomicSet(&liveCfgDirty, 0);
+ if (dirty) {
+ if (dirty & VCFG_VSYNC) wmAPI->set_swap_interval(cfgVSync ? 1 : 0);
+ if (dirty & VCFG_FPS) gfx_set_target_fps(cfgFpsCap);
+ if (dirty & VCFG_FILTER) videoApplyTexFilter();
+ if (dirty & VCFG_FOV) portFovScale = (f32)cfgFovScale / 100.0f;
+ if (dirty & VCFG_ANISO) gfx_set_anisotropy_level(cfgAniso);
+ if (dirty & VCFG_CROP) gfx_set_safe_area_crop(cfgSafeAreaCrop);
+ sysLogPrintf(LOG_INFO, "video: live config applied mask=%02x "
"(vsync=%d fpscap=%d texfilter=%d fov=%d aniso=%d)",
- cfgVSync, cfgFpsCap, cfgTexFilter, cfgFovScale, cfgAniso);
+ dirty, cfgVSync, cfgFpsCap, cfgTexFilter, cfgFovScale, cfgAniso);
}
gfx_start_frame();
@@ -532,15 +806,19 @@ void videoPumpEvents(void)
/* Apply any window/fullscreen change the F10 overlay posted from the
* scheduler thread (must run here, on the window's creating thread). */
videoDrainWindowRequests();
+ inputApplyMouseRequests(); /* D287: mouse mode/cursor, same rule */
+ videoHostExitIfRequested(); /* D344: quit posted last pump or by another thread */
SDL_Event ev;
while (SDL_PollEvent(&ev)) {
switch (ev.type) {
case SDL_QUIT:
- sysLogPrintf(LOG_INFO, "video: quit requested");
- exit(0);
+ videoRequestQuit("quit event");
break;
case SDL_KEYDOWN:
+ /* D383: host owns SDL key events; the capture modal consumes the
+ * next scancode before F10/ESC can close the UI or navigate. */
+ if (optionsBindingKeyDown(&ev.key)) break;
/* D145: bare ESC used to exit(0). On the front-end / debrief
* screens ESC is the natural "back" key, so a player pressing it
* to page back instead quit the whole game (looked like a crash --
@@ -548,32 +826,37 @@ void videoPumpEvents(void)
* (back / cancel) via input.c; quitting is window-close (the X) or
* Alt+F4 only. */
if ((ev.key.keysym.sym == SDLK_F4) && (ev.key.keysym.mod & KMOD_ALT)) {
- sysLogPrintf(LOG_INFO, "video: Alt+F4 -> quit");
- exit(0);
+ videoRequestQuit("Alt+F4");
} else if (ev.key.keysym.sym == SDLK_F12 && !ev.key.repeat) {
screenshotReq = 1;
} else if (ev.key.keysym.sym == SDLK_F10 && !ev.key.repeat) {
- optionsOverlayToggle(); /* F10: port-layer options overlay */
+ /* F10: port-layer options overlay -- not on the PC options
+ * screen (D343: one options UI at a time). */
+ if (optionsOverlayIsOpen() || !frontOptionsBlocksOverlay()) {
+ optionsOverlayToggle();
+ }
} else if (ev.key.keysym.sym == SDLK_ESCAPE && !ev.key.repeat) {
- /* Overlay open: ESC closes it (and is swallowed). Otherwise
+ /* Overlay open: ESC backs out of a category, then closes (swallowed). Otherwise
* WI-1: in click-to-lock mode ESC frees the captured cursor
* (and is swallowed); else it falls through to input.c where
* it feeds the N64 B button (D145). */
if (optionsOverlayIsOpen()) {
- optionsOverlayToggle();
+ optionsOverlayBack();
} else {
inputReleaseCapture();
}
}
break;
case SDL_MOUSEBUTTONDOWN:
+ if (optionsBindingMouseDown(&ev.button)) break;
/* WI-1: a click in the window (re)locks the cursor in
* click-to-lock mode; a no-op otherwise. */
- if (!optionsOverlayIsOpen()) {
+ if (!optionsOverlayIsOpen() && !optionsBindingCaptureActive()) {
inputNotifyClick();
}
break;
case SDL_MOUSEWHEEL:
+ if (optionsBindingCaptureActive()) break;
if (optionsOverlayIsOpen()) {
optionsOverlayScroll(ev.wheel.y); /* move the selection */
} else {
@@ -586,8 +869,7 @@ void videoPumpEvents(void)
break;
case SDL_WINDOWEVENT:
if (ev.window.event == SDL_WINDOWEVENT_CLOSE) {
- sysLogPrintf(LOG_INFO, "video: window closed");
- exit(0);
+ videoRequestQuit("window closed");
} else if (ev.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
gfx_sdl_update_cached_size();
} else if (ev.window.event == SDL_WINDOWEVENT_FOCUS_LOST) {
@@ -612,6 +894,9 @@ void videoPumpEvents(void)
wmAPI->set_window_title(title);
}
}
+
+ /* D287: apply anything the events above (click-to-lock, focus) queued. */
+ inputApplyMouseRequests();
}
void videoSubmitCommands(Gfx *cmds)
@@ -651,6 +936,8 @@ static void videoPreSwapCapture(void)
if (screenshotReq) {
screenshotReq = 0;
+ extern void gfxD157Burst(void); /* D252 TEMP, inert without GE_D157 */
+ gfxD157Burst();
static int shotNum = 0;
char path[128];
GE_MKDIR("ppm");
@@ -672,6 +959,21 @@ void videoEndFrame(void)
}
gfx_end_frame();
+ /* D344: the frame (and its swap) is done. Park here on a quit request, so
+ * the host never exits under an in-flight frame. GE_QUITFRAME= is the
+ * harness's clean self-quit (replaces `timeout` kills). */
+ if (s_quitFrame == -1) {
+ const char *q = getenv("GE_QUITFRAME");
+ s_quitFrame = (q && atoi(q) > 0) ? atoi(q) : 0;
+ }
+ if (s_quitFrame > 0 && frames + 1 >= (u32)s_quitFrame) {
+ videoRequestQuit("GE_QUITFRAME");
+ }
+ if (SDL_AtomicGet(&s_quitReq)) {
+ videoRenderPark();
+ }
+ SDL_AtomicSet(&s_inFrame, 0);
+
++frames;
++fpsNumFrames;
@@ -732,6 +1034,7 @@ void videoUpdateNativeResolution(s32 w, s32 h)
gfx_current_native_aspect = (float)w / (float)h;
}
+u32 videoGetFrameCount(void) { return frames; }
s32 videoGetNativeWidth(void) { return gfx_current_native_viewport.width; }
s32 videoGetNativeHeight(void) { return gfx_current_native_viewport.height; }
diff --git a/port/src/watchsettings.c b/port/src/watchsettings.c
new file mode 100644
index 00000000..68751fa5
--- /dev/null
+++ b/port/src/watchsettings.c
@@ -0,0 +1,642 @@
+/* D349: PC UI reads GE watch settings; PC-UI edits persist per Bond file.
+ * The original watch remains session-only. Never call GE setters/save routines
+ * from the F10 input/render thread. This file is port-only. */
+#include
+#include
+#include
+#include
+#include "file.h"
+#include "file2.h"
+#include "front.h"
+#include "options.h"
+#include "player.h"
+#include "system.h"
+#include "watchsettings.h"
+#include "optionsoverlay.h" /* D356 GE_WSPROBE_RESET probe hooks */
+
+extern s32 g_StageNum; /* boss.c: direct -level_XX starts with MENU_INVALID */
+extern save_data *fileGetSaveForFoldernum(u32 folder);
+extern void fileSaveSettingsForFolder(save_data *save);
+extern void fileWriteSave(save_data *save);
+extern s32 fileGamePakProbe(void);
+extern void fileGenerateCRC(u8 *start, u8 *end, save_data *result);
+extern void sub_GAME_7F0A91A0(u16 volume);
+extern void musicTrack1ApplySeqpVol(u16 volume);
+extern u16 musicTrack1GetVolume(void);
+extern u16 get_mTrack2Vol(void);
+extern void fileBuildWriteNewSave(u32 folder);
+extern void set_cur_player_look_vertical_inverted(u32 value);
+extern void cur_player_set_aim_control(u32 value);
+extern void cur_player_set_sight_onscreen_control(u32 value);
+extern void cur_player_set_ammo_onscreen_setting(u32 value);
+
+static int frontFolder(void); /* defined below; the lazy file default */
+static int stageActive(void); /* defined below; the choose-file guard uses it */
+
+/* D354: env-gated headless probe for the F10 queue->apply->persist path
+ * (same pattern as optionsoverlay's GE_D314). GE_WSPROBE= makes the
+ * -th active stage tick queue an F10-style commit of Music+FX to a
+ * known value, with before/after live+saved logs so a stage run with no
+ * keyboard/mouse can prove the bridge applies and persists. */
+static int wsProbeTick = -1;
+static int wsProbeTickN = 0;
+static int wsProbePhase = 0; /* 0=armed, 1=queued (log pre), 2=done (log post) */
+
+/* D355: per-activation flag for the level-start BGM resync (reset whenever the
+ * stage is not active, so the next activation resyncs again -- lv.c:364
+ * re-pins the BGM player to VOLUME_MAX on every level start). */
+static int wsStageWasActive = 0;
+
+
+static const char *const keys[WATCH_SETTING_COUNT] = {
+ "Bond.Music", "Bond.FX", "Bond.Look", "Bond.AutoAim",
+ "Bond.AimControl", "Bond.Sight", "Bond.LookAhead", "Bond.Ammo"
+};
+static const u16 bits[WATCH_SETTING_COUNT] = {
+ 0, 0, OPTION_INVERTLOOK, OPTION_AUTOAIM, OPTION_AIMCONTROL,
+ OPTION_SIGHTONSCREEN, OPTION_LOOKAHEAD, OPTION_DISPLAYAMMO
+};
+
+/* Explicit chooser is separate from selected_folder_num (which frontoptions
+ * assigns FOLDER1 merely to draw its dossier background). -1 means none
+ * (unresolved; frontFolder() lazily defaults it). D352: guarded by uiLock;
+ * D354: also read/written by F10 front-end contexts (input thread), which
+ * is why the lock exists. */
+static int chosen = -1;
+static int staged[WATCH_SETTING_COUNT];
+static int stagedField = -1, stagedFolder = -1;
+static SDL_SpinLock uiLock;
+
+/* Bounded producer (F10 controller poll) -> consumer (game thread) queues.
+ * Snapshot is also guarded; F10 input/render never call GE save routines. */
+struct Command { int folder, field, value, commit; };
+#define CMD_CAP 128
+static struct Command cmds[CMD_CAP];
+static int head, count;
+/* Front-end F10 also runs on the controller poll thread. fileWriteSave()
+ * calls joyDisablePoll() and waits for that SAME thread to acknowledge it:
+ * writing here deadlocks. Keep front commits separate from stage commands;
+ * the game-thread tick drains them without touching the controller thread. */
+static struct Command frontCmds[CMD_CAP];
+static int frontCount;
+static int snapshot[WATCH_SETTING_COUNT];
+static int snapFolder = -1;
+static SDL_SpinLock lock;
+
+int watchSettingsFieldForKey(const char *key)
+{
+ for (int i = 0; i < WATCH_SETTING_COUNT; i++)
+ if (strcmp(keys[i], key) == 0) return i;
+ return -1;
+}
+
+/* GE_STARTMENU skips fileValidateSaves: zeroed saves[] looks like a valid
+ * folder-0 save to fileGetSaveForFoldernum. Never let a PC UI write that
+ * phantom slot (D299). Check the actual CRC before treating it as a file. */
+static save_data *validSave(int folder)
+{
+ if (folder < FOLDER1 || folder >= MAX_FOLDER_COUNT) return NULL;
+ save_data *save = fileGetSaveForFoldernum((u32)folder);
+ if (!save) return NULL;
+ save_data crc = {0};
+ fileGenerateCRC((u8 *)&save->completion_bitflags, (u8 *)(save + 1), &crc);
+ return crc.chksum1 == save->chksum1 && crc.chksum2 == save->chksum2
+ ? save : NULL;
+}
+
+int watchSettingsFolder(void)
+{
+ SDL_AtomicLock(&uiLock);
+ int f = chosen;
+ SDL_AtomicUnlock(&uiLock);
+ return f;
+}
+
+/* D352: game-thread only. The chooser is a front-end control (options
+ * screen); in-stage F10 targets the active file, so a stage-context call
+ * signals a broken UI invariant -- refuse it loudly rather than race the
+ * front screen's readers.
+ * D356: "none" retired from the cycle (plan §5.5) -- the front target is
+ * always a folder: frontFolder() resolves a -1 chosen lazily to a valid
+ * folder (D354) and the auto-init fallback builds file 1 if none exists,
+ * so the UI shows an unavailable state ("(no file)" annotation, via
+ * watchSettingsActiveFolder) instead of a number the write path cannot
+ * use. */
+void watchSettingsChooseFile(int dir)
+{
+ if (stageActive()) {
+ sysLogPrintf(LOG_WARNING, "watchsettings: choose-file in a stage context; ignored");
+ return;
+ }
+ /* Skip empty slots. Do not load the file here: GE's loader would change
+ * front-end audio. */
+ SDL_AtomicLock(&uiLock);
+ int next = (chosen < FOLDER1) ? FOLDER1 : chosen;
+ for (int n = 0; n < MAX_FOLDER_COUNT; n++) {
+ next += dir;
+ if (next < FOLDER1) next = MAX_FOLDER_COUNT - 1;
+ if (next >= MAX_FOLDER_COUNT) next = FOLDER1;
+ if (validSave(next)) break;
+ }
+ chosen = next;
+ stagedField = -1;
+ int sel = chosen;
+ SDL_AtomicUnlock(&uiLock);
+ sysLogPrintf(LOG_INFO, "watchsettings: selected save file %d", sel + 1);
+}
+
+/* D356: context-aware, read-only (never mutates the stored front target):
+ * in stage -> the stage's active file; front contexts -> the resolved front
+ * target (frontFolder()'s lazy default). -1 when nothing usable (the
+ * auto-init file not yet applied) -- the UIs render that as an unavailable
+ * state ("(no file)"), never a file number the write path cannot use.
+ * NOTE: frontFolder() acquires uiLock itself -- never call it (or this)
+ * while uiLock is held: it is a plain spinlock, not re-entrant. */
+int watchSettingsActiveFolder(void)
+{
+ int f;
+ if (stageActive()) {
+ SDL_AtomicLock(&uiLock);
+ f = selected_folder_num;
+ SDL_AtomicUnlock(&uiLock);
+ } else {
+ f = frontFolder(); /* self-locks; may default + store 'chosen' */
+ }
+ return validSave(f) ? f : -1;
+}
+
+/* D356: stage commands awaiting the game thread (front F10 uses a separate
+ * queue). Drives the in-stage reset probe's
+ * dispatch check (plan §5.8). */
+int watchSettingsQueueCount(void)
+{
+ SDL_AtomicLock(&lock);
+ int n = count;
+ SDL_AtomicUnlock(&lock);
+ return n;
+}
+
+/* D356: the per-file "default" for the reset rows -- BLANKSAVEDATA's watch
+ * fields (file2.c's blank save is a macro, not a global; only the music,
+ * sfx and options fields matter here: full 0xFF volumes, DEFAULT_OPTIONS).
+ * completion_bitflags is left 0 (BLANKSAVEDATA sets BOND_BROSNAN; the reset
+ * rows never touch it). */
+static const save_data kBlankSave = {
+ 0, 0,
+ 0, 0, /* completion_bitflags, flag_007 */
+ 0xFF, 0xFF, /* music_vol, sfx_vol (expanded -> VOLUME_MAX) */
+ DEFAULT_OPTIONS, /* options */
+ 0, 0, 0,
+};
+
+int watchSettingsBlankValue(enum WatchSettingField field)
+{
+ if ((unsigned)field >= WATCH_SETTING_COUNT) return 0;
+ if (field == WATCH_SETTING_MUSIC) return ((int)kBlankSave.music_vol << 7) | (kBlankSave.music_vol >> 1);
+ if (field == WATCH_SETTING_FX) return ((int)kBlankSave.sfx_vol << 7) | (kBlankSave.sfx_vol >> 1);
+ return (kBlankSave.options & bits[field]) ? 1 : 0;
+}
+
+static int stageActive(void)
+{
+ /* Direct -level_XX boots never visit the front-end RUN_STAGE state. */
+ return current_menu == MENU_RUN_STAGE ||
+ (current_menu == MENU_INVALID && g_StageNum != LEVELID_TITLE);
+}
+
+/* D354: lazy default target for front-end (non-stage) contexts. The chooser
+ * used to start at -1 ("none"), which disabled every Bond row -- and in F10
+ * the chooser row is hidden, so on the front end (file select) the rows could
+ * never be enabled at all: the volume sliders read "Select file" and did
+ * nothing. Resolution order: an explicit "Edit file" choice > the folder the
+ * player is viewing (selected_folder_num, set by file select) > the first
+ * valid folder. The first two are pure reads of stable menu state, safe from
+ * the F10 input thread (D350 gate: no GE writes from it; only chosen is
+ * written, under uiLock). A fresh PC eeprom is booted with a BLANKSAVEDATA
+ * slot per folder (libultra.c D259/D281 patch), so a default almost always
+ * resolves; the game-thread fallback in watchSettingsGameTick builds one if
+ * it does not. */
+static int frontFolder(void)
+{
+ SDL_AtomicLock(&uiLock);
+ int f = chosen;
+ if (f < FOLDER1) {
+ if (validSave(selected_folder_num))
+ f = chosen = selected_folder_num;
+ else
+ for (int i = FOLDER1; i < MAX_FOLDER_COUNT; i++)
+ if (validSave(i)) { f = chosen = i; break; }
+ }
+ SDL_AtomicUnlock(&uiLock);
+ return f;
+}
+
+int watchSettingsAvailable(void)
+{
+ if (!stageActive()) {
+ /* Front end: MENU_PC_OPTIONS, or F10 over file select / title.
+ * D354: F10 front contexts were mis-routed through the stage path
+ * (and returned "unavailable"), disabling every Bond row. */
+ return validSave(frontFolder()) != NULL;
+ }
+ int valid;
+ SDL_AtomicLock(&lock);
+ valid = snapFolder >= FOLDER1 && snapFolder < MAX_FOLDER_COUNT;
+ SDL_AtomicUnlock(&lock);
+ return valid;
+}
+
+static int savedValue(const save_data *save, enum WatchSettingField field)
+{
+ if (field == WATCH_SETTING_MUSIC) return ((int)save->music_vol << 7) | (save->music_vol >> 1);
+ if (field == WATCH_SETTING_FX) return ((int)save->sfx_vol << 7) | (save->sfx_vol >> 1);
+ return (save->options & bits[field]) ? 1 : 0;
+}
+
+int watchSettingsRead(enum WatchSettingField field)
+{
+ if ((unsigned)field >= WATCH_SETTING_COUNT) return 0;
+ if (!stageActive()) {
+ int f = frontFolder(), stagedVal = -1;
+ SDL_AtomicLock(&uiLock);
+ if (stagedField == (int)field && stagedFolder == f)
+ stagedVal = staged[field];
+ SDL_AtomicUnlock(&uiLock);
+ if (stagedVal >= 0) return stagedVal;
+ save_data *save = validSave(f);
+ return save ? savedValue(save, field) : 0;
+ }
+ int value = 0;
+ SDL_AtomicLock(&lock);
+ if (snapFolder >= FOLDER1 && snapFolder < MAX_FOLDER_COUNT)
+ value = snapshot[field];
+ SDL_AtomicUnlock(&lock);
+ return value;
+}
+
+/* D352: game-thread only (watchSettingsGameTick, after the setter was
+ * applied). temp serialization must NOT overwrite unrelated, watch-only
+ * edits or the port-forced controller type in the Bond file. */
+static int watchSettingsPersistField(int folder, enum WatchSettingField field)
+{
+ if (folder < FOLDER1 || folder >= MAX_FOLDER_COUNT ||
+ (unsigned)field >= WATCH_SETTING_COUNT) return 0;
+ if (!g_CurrentPlayer) {
+ sysLogPrintf(LOG_WARNING, "watchsettings: persist field %d skipped: no current player", field);
+ return 0;
+ }
+ save_data *save = validSave(folder);
+ if (!save) {
+ sysLogPrintf(LOG_WARNING, "watchsettings: persist field %d in Bond file %d: no valid save (CRC?)", field, folder + 1);
+ return 0;
+ }
+ if (!fileGamePakProbe()) {
+ sysLogPrintf(LOG_WARNING, "watchsettings: persist field %d in Bond file %d: EEPROM absent, edit not saved", field, folder + 1);
+ return 0;
+ }
+ save_data serialized = *save;
+ fileSaveSettingsForFolder(&serialized);
+ if (field == WATCH_SETTING_MUSIC) {
+ if (save->music_vol == serialized.music_vol) return 1;
+ save->music_vol = serialized.music_vol;
+ } else if (field == WATCH_SETTING_FX) {
+ if (save->sfx_vol == serialized.sfx_vol) return 1;
+ save->sfx_vol = serialized.sfx_vol;
+ } else {
+ u16 mask = bits[field];
+ u16 merged = (save->options & (u16)~mask) | (serialized.options & mask);
+ if (save->options == merged) return 1;
+ save->options = merged;
+ }
+ fileWriteSave(save); /* real saves[] slot; writes CRC + EEPROM */
+ sysLogPrintf(LOG_INFO, "watchsettings: persisted field %d in Bond file %d", field, folder + 1);
+ return 1;
+}
+
+/* Front end has no active GE watch/player settings. Edit the explicitly
+ * chosen save field directly instead of calling fileLoadSettingsForFolder
+ * (which also applies the chosen file's audio volume to the front end). */
+static void saveFrontField(int folder, enum WatchSettingField field, int value)
+{
+ save_data *save = validSave(folder);
+ if (!save) {
+ sysLogPrintf(LOG_WARNING, "watchsettings: save field %d in Bond file %d: no valid save (CRC?)", field, folder + 1);
+ return;
+ }
+ if (!fileGamePakProbe()) {
+ sysLogPrintf(LOG_WARNING, "watchsettings: save field %d in Bond file %d: EEPROM absent, edit not saved", field, folder + 1);
+ return;
+ }
+ if (value < 0) value = 0;
+ if (field == WATCH_SETTING_MUSIC || field == WATCH_SETTING_FX) {
+ if (value > 32767) value = 32767;
+ u8 raw = (u8)(value >> 7);
+ u8 *dest = field == WATCH_SETTING_MUSIC ? &save->music_vol : &save->sfx_vol;
+ if (*dest == raw) return;
+ *dest = raw;
+ } else {
+ u16 mask = bits[field];
+ u16 merged = (save->options & (u16)~mask) | (value ? mask : 0);
+ if (save->options == merged) return;
+ save->options = merged;
+ }
+ fileWriteSave(save);
+ sysLogPrintf(LOG_INFO, "watchsettings: persisted field %d in Bond file %d", field, folder + 1);
+}
+
+void watchSettingsSet(enum WatchSettingField field, int value, int commit)
+{
+ if ((unsigned)field >= WATCH_SETTING_COUNT || !watchSettingsAvailable()) return;
+ if (!stageActive()) {
+ int f = frontFolder();
+ SDL_AtomicLock(&uiLock);
+ if (!optionsOverlayIsOpen()) {
+ /* The front options screen and dev probes run on the game thread. */
+ if (commit) stagedField = -1;
+ else { stagedField = field; stagedFolder = f; staged[field] = value; }
+ SDL_AtomicUnlock(&uiLock);
+ if (commit) saveFrontField(f, field, value);
+ return;
+ }
+ /* F10 is polled from joyPoll() via osContStartReadData. Even a
+ * fileGamePakProbe here deadlocks: joyDisablePoll waits for joyPoll's
+ * acknowledgement. Stage the value for UI feedback and enqueue ONLY
+ * under a short lock; the game thread does the EEPROM I/O. */
+ stagedField = field; stagedFolder = f; staged[field] = value;
+ SDL_AtomicUnlock(&uiLock);
+ SDL_AtomicLock(&lock);
+ int i;
+ for (i = 0; i < frontCount; i++) {
+ if (frontCmds[i].folder == f && frontCmds[i].field == (int)field) break;
+ }
+ if (i == frontCount && frontCount >= CMD_CAP) {
+ SDL_AtomicUnlock(&lock);
+ sysLogPrintf(LOG_WARNING, "watchsettings: front command queue full; edit not applied");
+ return;
+ }
+ if (i == frontCount) {
+ frontCount++;
+ frontCmds[i].commit = 0;
+ }
+ frontCmds[i].folder = f;
+ frontCmds[i].field = field;
+ frontCmds[i].value = value;
+ frontCmds[i].commit = commit || frontCmds[i].commit;
+ SDL_AtomicUnlock(&lock);
+ return;
+ }
+ SDL_AtomicLock(&lock);
+ /* D352: coalesce to one pending command per field. A fast slider drag
+ * enqueues many detents per frame; the game thread then applies only
+ * the latest value (commit = OR of the pending commits), so the audio
+ * setters fire once per frame instead of once per detent. */
+ for (int i = 0; i < count; i++) {
+ struct Command *c = &cmds[(head + i) % CMD_CAP];
+ if (c->field == (int)field) {
+ c->value = value;
+ c->commit |= commit;
+ snapshot[field] = value;
+ SDL_AtomicUnlock(&lock);
+ return;
+ }
+ }
+ struct Command cmd = { snapFolder, field, value, commit };
+ if (count < CMD_CAP) {
+ cmds[(head + count++) % CMD_CAP] = cmd;
+ snapshot[field] = value; /* immediate UI feedback for held adjustments */
+ } else {
+ sysLogPrintf(LOG_WARNING, "watchsettings: command queue full; edit not applied");
+ }
+ SDL_AtomicUnlock(&lock);
+}
+
+void watchSettingsCommit(enum WatchSettingField field)
+{
+ if ((unsigned)field >= WATCH_SETTING_COUNT) return;
+ if (!stageActive()) {
+ /* D352: a commit is meaningful only if a drag staged an edit; a
+ * plain click with no file selected stays a silent no-op. */
+ int f = frontFolder(), pending;
+ SDL_AtomicLock(&uiLock);
+ pending = stagedField == (int)field && stagedFolder == f && f >= FOLDER1;
+ SDL_AtomicUnlock(&uiLock);
+ if (!pending) return;
+ } else if (!watchSettingsAvailable()) {
+ /* D352: the UI showed the new value (snapshot) but the stage is not
+ * active, so the persist cannot run -- make the drop visible. */
+ sysLogPrintf(LOG_WARNING, "watchsettings: commit dropped, no active Bond target (field %d)", field);
+ return;
+ }
+ watchSettingsSet(field, watchSettingsRead(field), 1);
+}
+
+static int liveValue(enum WatchSettingField field)
+{
+ switch (field) {
+ case WATCH_SETTING_MUSIC: return get_mTrack2Vol();
+ case WATCH_SETTING_FX: return call_sndGetSfxSlotFirstNaturalVolume();
+ case WATCH_SETTING_LOOK: return get_cur_player_look_vertical_inverted();
+ case WATCH_SETTING_AUTOAIM: return cur_player_get_autoaim();
+ case WATCH_SETTING_AIMCONTROL: return cur_player_get_aim_control();
+ case WATCH_SETTING_SIGHT: return cur_player_get_sight_onscreen_control();
+ case WATCH_SETTING_LOOKAHEAD: return cur_player_get_lookahead();
+ case WATCH_SETTING_AMMO: return cur_player_get_ammo_onscreen_setting();
+ default: return 0;
+ }
+}
+
+static void applyValue(enum WatchSettingField field, int value)
+{
+ if (value < 0) value = 0;
+ if (field <= WATCH_SETTING_FX && value > 32767) value = 32767;
+ else if (field > WATCH_SETTING_FX) value = !!value;
+ switch (field) {
+ case WATCH_SETTING_MUSIC:
+ /* D355: the slider's value drives the X-track (track 2) via the game's
+ * own setter, but in-stage BGM is track 1, which the game pins to
+ * VOLUME_MAX at level start (lv.c:364) and never rescales from
+ * mTrack2Vol in solo -- so the N64 watch (and the F10 before this)
+ * could not change the music a player was actually hearing. The MP
+ * path already does exactly this resync (mpmenu.c:354 feeds
+ * get_mTrack2Vol() to musicTrack1ApplySeqpVol); mirror it here so a
+ * F10/options Music change is audible on the BGM player in real time
+ * (FX already was). Port-side only; the N64 watch path is untouched. */
+ set_mTrack2Vol((u16)value);
+ musicTrack1ApplySeqpVol((u16)value);
+ break;
+ case WATCH_SETTING_FX: sub_GAME_7F0A91A0((u16)value); break;
+ case WATCH_SETTING_LOOK: set_cur_player_look_vertical_inverted(value); break;
+ case WATCH_SETTING_AUTOAIM: cur_player_set_autoaim(value); break;
+ case WATCH_SETTING_AIMCONTROL: cur_player_set_aim_control(value); break;
+ case WATCH_SETTING_SIGHT: cur_player_set_sight_onscreen_control(value); break;
+ case WATCH_SETTING_LOOKAHEAD: cur_player_set_lookahead(value); break;
+ case WATCH_SETTING_AMMO: cur_player_set_ammo_onscreen_setting(value); break;
+ default: break;
+ }
+}
+
+void watchSettingsGameTick(void)
+{
+ /* Drain F10 front-end commits on the game thread. This MUST run before
+ * the early return for non-stage contexts below. Never hold the queue
+ * lock through fileWriteSave/joyDisablePoll: joyPoll must stay runnable. */
+ struct Command frontBatch[CMD_CAP];
+ int nf;
+ SDL_AtomicLock(&lock);
+ nf = frontCount;
+ memcpy(frontBatch, frontCmds, (size_t)nf * sizeof(frontBatch[0]));
+ frontCount = 0;
+ SDL_AtomicUnlock(&lock);
+ for (int i = 0; i < nf; i++) {
+ struct Command *c = &frontBatch[i];
+ if (c->commit) saveFrontField(c->folder, (enum WatchSettingField)c->field, c->value);
+ SDL_AtomicLock(&uiLock);
+ if (stagedFolder == c->folder && stagedField == c->field &&
+ staged[c->field] == c->value && c->commit)
+ stagedField = -1;
+ SDL_AtomicUnlock(&uiLock);
+ }
+
+ /* D356: the GE_WSPROBE_RESET env gate drives the real reset UI dispatch
+ * (arm -> confirm -> handler, no bypass) through optionsoverlay.c's
+ * probe hooks. Each phase waits for its context to be stable (~120
+ * ticks, a couple of seconds, after the front title has been rendering /
+ * after stage start) so the probe's save-file I/O never stalls the boot
+ * black frame. Phase 0 = the pre-stage front probe (dispatch + verify
+ * directly); once a stage is active, phase 1 = dispatch (arms+confirms
+ * all sections, queueing the file rows) and phase 2 = verify (after the
+ * game thread has drained + persisted them). */
+ {
+ static int wsResetProbePhase = 0;
+ static int wsProbeFrontTicks = 0, wsProbeStageTicks = 0;
+ if (getenv("GE_WSPROBE_RESET")) {
+ if (!stageActive()) {
+ if (wsResetProbePhase == 0 && ++wsProbeFrontTicks == 120) {
+ optionsResetProbePrepare();
+ wsResetProbePhase = 1;
+ }
+ } else {
+ /* A direct -level_XX boot enters the stage long before the
+ * front counter would reach 120, so a stage run starts its
+ * own timer from phase 0 (the front prepare is front-only;
+ * the stage dispatch builds its section list lazily). */
+ if (wsResetProbePhase == 0) wsResetProbePhase = 1;
+ if (wsResetProbePhase == 1 && ++wsProbeStageTicks == 120) {
+ optionsResetProbeDispatchStage();
+ wsResetProbePhase = 2;
+ } else if (wsResetProbePhase == 2 && ++wsProbeStageTicks == 121) {
+ optionsResetProbeVerifyStage();
+ wsResetProbePhase = 3;
+ }
+ }
+ }
+ }
+ /* This hook runs on the game thread after a gfxFrameMsgQ receive, even
+ * during load. D352: HOLD pending commands (do not drain) until a stage
+ * is active -- a slider-release commit must not be silently discarded
+ * during a level/menu transition. Each command's folder is re-checked
+ * against selected_folder_num at apply time, so a stale cross-file edit
+ * is still dropped, never misapplied. */
+ if (!stageActive() || !g_CurrentPlayer) {
+ wsStageWasActive = 0;
+ /* D354: one-shot fallback -- if not a single folder holds a valid
+ * save (wiped/corrupt eeprom), build the game's own blank save for
+ * file 1 (game thread, so the EEPROM write is on the owning thread)
+ * and default to it. A fresh PC eeprom never reaches this: boot
+ * patches in a BLANKSAVEDATA slot per folder (libultra.c D259/D281).
+ * frontFolder() re-checks, so an explicit chooser pick is respected. */
+ if (!stageActive() && frontFolder() < FOLDER1) {
+ static int wsAutoInitTried = 0;
+ if (!wsAutoInitTried) {
+ wsAutoInitTried = 1;
+ sysLogPrintf(LOG_INFO, "watchsettings: no valid Bond save in any folder; building a blank file 1 (fileBuildWriteNewSave)");
+ fileBuildWriteNewSave(FOLDER1);
+ SDL_AtomicLock(&uiLock);
+ if (validSave(FOLDER1))
+ chosen = FOLDER1;
+ SDL_AtomicUnlock(&uiLock);
+ sysLogPrintf(validSave(FOLDER1) ? LOG_INFO : LOG_WARNING,
+ "watchsettings: blank file 1 %s", validSave(FOLDER1) ? "created" : "unavailable (no free slot?)");
+ }
+ }
+ /* D354 probe: GE_WSPROBE_FRONT= exercises the front-end
+ * (chooser-default) write path the same way F10-on-file-select
+ * would, headlessly. */
+ {
+ static int wsFrontProbe = -1, wsFrontTickN = 0, wsFrontPhase = 0;
+ if (wsFrontProbe < 0) {
+ const char *e = getenv("GE_WSPROBE_FRONT");
+ wsFrontProbe = (e && atoi(e) > 0) ? atoi(e) : 2147483647;
+ }
+ if (wsFrontPhase == 0 && wsFrontTickN++ == (size_t)wsFrontProbe) {
+ wsFrontPhase = 1;
+ int f = frontFolder();
+ save_data *save = validSave(f);
+ sysLogPrintf(LOG_INFO, "wsfront: folder=%d save.music=%d; committing front-end Music=4096",
+ f, save ? (int)save->music_vol : -1);
+ watchSettingsSet(WATCH_SETTING_MUSIC, 4096, 1);
+ } else if (wsFrontPhase == 1 && wsFrontTickN++ == (size_t)wsFrontProbe + 1) {
+ wsFrontPhase = 2;
+ int f = frontFolder();
+ save_data *s2 = validSave(f);
+ sysLogPrintf(LOG_INFO, "wsfront: post save.music=%d (folder %d)",
+ s2 ? (int)s2->music_vol : -1, f);
+ }
+ }
+ SDL_AtomicLock(&lock);
+ snapFolder = -1;
+ SDL_AtomicUnlock(&lock);
+ return;
+ }
+ /* D355: level start pins the BGM player to VOLUME_MAX (lv.c:364) before
+ * init_watch re-applies the saved music value to the X track; resync the
+ * BGM (track 1) to the same live value once per activation so the player's
+ * music setting survives level starts, not just live F10 edits. */
+ if (!wsStageWasActive && musicTrack1GetVolume() != get_mTrack2Vol())
+ musicTrack1ApplySeqpVol(get_mTrack2Vol());
+ wsStageWasActive = 1;
+ struct Command batch[CMD_CAP];
+ int n;
+ SDL_AtomicLock(&lock);
+ n = count;
+ for (int i = 0; i < n; i++) batch[i] = cmds[(head + i) % CMD_CAP];
+ head = (head + n) % CMD_CAP;
+ count = 0;
+ SDL_AtomicUnlock(&lock);
+ for (int i = 0; i < n; i++) {
+ struct Command *c = &batch[i];
+ if (c->folder != selected_folder_num ||
+ (unsigned)c->field >= WATCH_SETTING_COUNT ||
+ !validSave(c->folder)) continue;
+ applyValue((enum WatchSettingField)c->field, c->value);
+ if (c->commit) watchSettingsPersistField(c->folder, (enum WatchSettingField)c->field);
+ }
+ /* D354 probe: log before/after the queued commit, one tick apart. */
+ if (wsProbeTick < 0) {
+ const char *e = getenv("GE_WSPROBE");
+ wsProbeTick = (e && atoi(e) > 0) ? atoi(e) : 2147483647;
+ }
+ if (wsProbePhase == 0 && wsProbeTickN++ == (size_t)wsProbeTick) {
+ wsProbePhase = 1;
+ save_data *save = validSave(selected_folder_num);
+ sysLogPrintf(LOG_INFO, "wsprobe: pre music=%d fx=%d t1=%d save.music=%d save.fx=%d (folder %d, %d queued)",
+ get_mTrack2Vol(), call_sndGetSfxSlotFirstNaturalVolume(), musicTrack1GetVolume(),
+ save ? (int)save->music_vol : -1, save ? (int)save->sfx_vol : -1,
+ (int)selected_folder_num, n);
+ watchSettingsSet(WATCH_SETTING_MUSIC, 4096, 1);
+ watchSettingsSet(WATCH_SETTING_FX, 4096, 1);
+ } else if (wsProbePhase == 1 && wsProbeTickN++ == (size_t)wsProbeTick + 1) {
+ wsProbePhase = 2;
+ save_data *save = validSave(selected_folder_num);
+ sysLogPrintf(LOG_INFO, "wsprobe: post music=%d fx=%d t1=%d save.music=%d save.fx=%d",
+ get_mTrack2Vol(), call_sndGetSfxSlotFirstNaturalVolume(), musicTrack1GetVolume(),
+ save ? (int)save->music_vol : -1, save ? (int)save->sfx_vol : -1);
+ }
+ int values[WATCH_SETTING_COUNT];
+ for (int f = 0; f < WATCH_SETTING_COUNT; f++)
+ values[f] = liveValue((enum WatchSettingField)f);
+ SDL_AtomicLock(&lock);
+ for (int f = 0; f < WATCH_SETTING_COUNT; f++) snapshot[f] = values[f];
+ snapFolder = selected_folder_num;
+ SDL_AtomicUnlock(&lock);
+}
diff --git a/src/audi.c b/src/audi.c
index cb91d490..da660aaf 100644
--- a/src/audi.c
+++ b/src/audi.c
@@ -10,6 +10,9 @@
#if defined(__x86_64__)
#include
#include
+#ifdef PORT
+#include "envflag.h" /* cached getenv for hot-path probes */
+#endif
#endif
#endif
@@ -664,7 +667,7 @@ s32 amDmaCallback(s32 addr, s32 len, void* state)
dmaPtr->lastFrame = (s32) g_AudioFrameCount;
freeBuffer = (dmaPtr->ptr + addr) - dmaPtr->startAddr;
#if defined(__x86_64__)
- if (getenv("GE_MIXERTRACE")) {
+ if (GE_ENVFLAG("GE_MIXERTRACE")) {
static FILE *dtf2 = NULL;
if (!dtf2) { dtf2 = fopen("mixertrace.log", "a"); if (dtf2) setvbuf(dtf2, NULL, _IONBF, 0); }
if (dtf2) fprintf(dtf2, "[DMAHIT] addr=0x%08x len=%d bufStart=0x%08x bufPtr=%p ret=%p\n",
@@ -732,7 +735,7 @@ s32 amDmaCallback(s32 addr, s32 len, void* state)
osPiStartDma(&g_DmaIOMessageBuffer[g_NextDMa++], OS_MESG_PRI_HIGH, OS_READ, (u32)addr, freeBuffer, AUDIO_DMA_MAX_BUFFER_LENGTH, &g_DmaMessageQueue);
#if defined(__x86_64__)
- if (getenv("GE_MIXERTRACE")) {
+ if (GE_ENVFLAG("GE_MIXERTRACE")) {
static FILE *dtf3 = NULL;
if (!dtf3) { dtf3 = fopen("mixertrace.log", "a"); if (dtf3) setvbuf(dtf3, NULL, _IONBF, 0); }
if (dtf3) fprintf(dtf3, "[DMAMISS] addr=0x%08x len=%d bufPtr=%p ret=0x%08x\n",
diff --git a/src/bondconstants.h b/src/bondconstants.h
index da7fffd4..63f1b0f9 100644
--- a/src/bondconstants.h
+++ b/src/bondconstants.h
@@ -1865,6 +1865,9 @@ typedef enum MENU
MENU_SWITCH_SCREENS,
MENU_DISPLAY_CAST,
MENU_SPECTRUM_EMU,
+#ifdef PORT
+ MENU_PC_OPTIONS, /* D343: PC options screen (port/src/frontoptions.c) */
+#endif
MENU_MAX
} MENU;
diff --git a/src/bondtypes.h b/src/bondtypes.h
index 1ab71800..c8283e37 100644
--- a/src/bondtypes.h
+++ b/src/bondtypes.h
@@ -3189,10 +3189,11 @@ typedef union
* compiler (GCC) resolves by inlining ObjectRecord's members. ObjectRecord
* already has a `pad` member (s16 @0x08, the pad index set by
* New_CCTVRecord), so this `pad` (s32 @0x80) is a duplicate name that GCC
- * rejects as a hard error (IDO tolerates it). The game code's `->pad`
- * (e.g. setupCctv) refers to the INHERITED pad index @0x08, never to this
- * member, so renaming it keeps the layout byte-identical. See
- * docs/dev/findings.md. */
+ * rejects as a hard error (IDO tolerates it). Renaming keeps the layout
+ * byte-identical, BUT on N64 this member shadows the inherited one:
+ * setupCctv's `arg1->pad` is THIS look-at pad (@0x80), not the mount
+ * pad (@0x08). Code that means the look-at pad must read `lookpad` on
+ * PC (D307, M-201: cameras faced backwards). See docs/dev/findings.md. */
s32 lookpad; // lookpad
#else
s32 pad; // lookpad
diff --git a/src/boss.c b/src/boss.c
index b228e333..0f019a32 100644
--- a/src/boss.c
+++ b/src/boss.c
@@ -146,6 +146,9 @@ struct memallocstring memallocstringtable[] = {
};
s32 g_MainStageNum = LEVELID_NONE;
+#ifdef PORT
+static s32 g_d235ReloadStage = -1, g_d235ReloadWait = 0; /* TEMP D235 verification */
+#endif
s32 g_BossIsDebugMenuOpen = FALSE;
OSScMsg g_bossGfxDoneMsg = { OS_SC_DONE_MSG };
@@ -466,9 +469,92 @@ void bossMainloop(void)
{
g_CurentMaMallocValue = (s32) (strtol(tokenFind(1, "-ma"), NULL, 0) * 1024);
}
+#ifdef PORT
+ /* D294: PC visibility settings (Video.FovScale / WidescreenAuto /
+ * DrawDistance / LodDistance) keep more rooms on screen at once than
+ * the N64's fixed view, so the authored `-ma` room pool can exhaust;
+ * memaAlloc() then fails and undrawn rooms render as solid black.
+ * Scale the pool by portRoomPoolScale() (1.0 at all-N64-faithful
+ * settings, capped 2.0x) and never let it eat more than the STAGE
+ * bank minus 128 KB headroom for zbuf/object resources. */
+ {
+ extern f32 portRoomPoolScale(void);
+ f32 scale = portRoomPoolScale();
+ s32 avail;
+ u32 scaled;
+ if (scale < 0.9999f || scale > 1.0001f) {
+ scaled = (u32)(((s64)g_CurentMaMallocValue * (s64)(scale * 4.0f) + 3) / 4);
+ avail = mempGetBankSizeLeft(MEMPOOL_STAGE) - 0x20000;
+ if ((s32)scaled > avail && avail >= (s32)g_CurentMaMallocValue) {
+ scaled = (u32)avail;
+ }
+ g_CurentMaMallocValue = scaled;
+ }
+ }
+#endif
+
+#ifdef PORT
+ if (getenv("GE_D294")) { osSyncPrintf("D294 stage=%d ma=%d\n", (s32)g_StageNum, (s32)g_CurentMaMallocValue); }
+#endif
memaReset(mempAllocBytesInBank(g_CurentMaMallocValue, MEMPOOL_STAGE), g_CurentMaMallocValue);
reset_play_data_ptrs();
+#ifdef PORT
+ /* D235 (wrong textures after dying/restarting a level): the port-side
+ * GL texture cache (port/fast3d/gfx_pc.cpp TextureCacheMap) is keyed by
+ * (source address, palette addrs, fmt, siz, size_bytes, palette_hash)
+ * with NO content check. The MEMPOOL_STAGE arena those stage-texture
+ * source addresses live in was just reset above, so every entry
+ * pointing into it is stale. On a death/restart the texpool can place
+ * a DIFFERENT image at an address that held another one during the
+ * previous run of this level -> cache hit returns the old image =
+ * "wrong textures" that only ever appear on the reload path, never on
+ * a cold boot. The N64 has no such cache (the RSP re-reads DRAM every
+ * frame), so clearing it here is a port-only correctness fix; the
+ * re-uploads are pixel-identical and cost one-time during the loading
+ * screen. videoResetTextureCache() existed for exactly this and had
+ * zero callers -- this is the wiring. GE_D235=1 logs how many stale
+ * entries each transition actually purged (evidence for the finding;
+ * a cold boot into a level should log 0, a death/restart >0). */
+ {
+ static int ge_d235 = -1;
+ if (ge_d235 < 0) ge_d235 = getenv("GE_D235") != NULL;
+ extern void videoResetTextureCache(void);
+ extern int gfx_texture_cache_count(void);
+ if (ge_d235)
+ osSyncPrintf("D235: stage=%d texcache entries purged at load=%d\n",
+ (s32)g_StageNum, gfx_texture_cache_count());
+ if (!getenv("GE_D235_OFF")) /* TEMP: A/B switch for verification */
+ videoResetTextureCache();
+
+ /* TEMP (D235 verification, remove with the probe):
+ * GE_D235_RELOAD= re-enters the first level once,
+ * retraces after the title stage loads following it (e.g. after a
+ * watch Abort) -- so the front end has rendered its own textures
+ * into the reset stage arena first, like a real death/restart --
+ * via the same bossSetLoadedStage() call the front end's mission
+ * start makes (front.c). Inert when unset. */
+ {
+ static int s_reload = -2, s_firstLevel = -1;
+ if (s_reload == -2) {
+ const char *e = getenv("GE_D235_RELOAD");
+ s_reload = e ? atoi(e) : -1;
+ }
+ if (s_reload >= 0) {
+ if (g_StageNum != LEVELID_TITLE && s_firstLevel < 0)
+ s_firstLevel = g_StageNum;
+ else if (g_StageNum == LEVELID_TITLE && s_firstLevel >= 0 &&
+ g_d235ReloadStage < 0 && g_d235ReloadWait == 0) {
+ /* GE_D235_NEXT=: load a DIFFERENT stage
+ * instead (level A -> title -> level B leaks). */
+ const char *nx = getenv("GE_D235_NEXT");
+ g_d235ReloadStage = (nx && *nx) ? (s32)strtol(nx, NULL, 0) : s_firstLevel;
+ g_d235ReloadWait = s_reload + 1;
+ }
+ }
+ }
+ }
+#endif
localSelectedNumPlayers = 0;
if (g_StageNum != LEVELID_TITLE)
@@ -502,7 +588,27 @@ void bossMainloop(void)
while (g_MainStageNum < 0 || pendingGfx != 0)
{
+#ifdef PORT
+ /* #92 perf probe (GE_PERFSTAT=1): game-thread busy time between
+ * waking for a message and blocking again (logic + DL build). */
+ {
+ static int pon = -1; static u32 tw = 0; static f64 busy = 0; static s32 n = 0;
+ if (pon < 0) pon = getenv("GE_PERFSTAT") != NULL;
+ if (pon) {
+ u32 now = osGetCount();
+ if (tw) { busy += (f64)(u32)(now - tw); n++; }
+ if (n == 600) {
+ fprintf(stderr, "PERFSTAT game-thread busy=%.2fms/msg (avg over 600 msgs)\n",
+ busy / (f64)n / (f64)(OS_CPU_COUNTER / 1000));
+ busy = 0; n = 0;
+ }
+ }
+ osRecvMesg(&gfxFrameMsgQ, (OSMesg *)&localGfxFrameMsg, OS_MESG_BLOCK);
+ if (pon) tw = osGetCount();
+ }
+#else
osRecvMesg(&gfxFrameMsgQ, (OSMesg *)&localGfxFrameMsg, OS_MESG_BLOCK);
+#endif
switch (localGfxFrameMsg->gen.type)
{
@@ -524,6 +630,13 @@ void bossMainloop(void)
{
if (g_MainStageNum < 0 && pendingGfx < 2U)
{
+#ifdef PORT
+ /* TEMP D235 verification (see GE_D235_RELOAD above) */
+ if (g_d235ReloadWait > 0 && --g_d235ReloadWait == 0) {
+ osSyncPrintf("D235: RELOAD re-entering stage %d\n", g_d235ReloadStage);
+ bossSetLoadedStage(g_d235ReloadStage);
+ }
+#endif
if (get_is_ramrom_flag())
{
iterate_ramrom_entries_handle_camera_out();
diff --git a/src/game/bg.c b/src/game/bg.c
index bff3d848..2bed832a 100644
--- a/src/game/bg.c
+++ b/src/game/bg.c
@@ -591,6 +591,52 @@ s32 bgGet2dBboxByRoomId(s32 room_id, struct bbox2d *result)
*/
Gfx *sub_GAME_7F0B3C8C(Gfx *gdl)
{
+#if defined(PORT)
+ /* TEMP D104/D294: once per frame (this function builds the whole bg DL),
+ * log the drawn-room list with load state + scissor boxes. */
+ {
+ static s32 d104f = 0;
+ static int d104on = -1; /* D302-class cache: once per frame */
+#ifdef PORT
+ /* TEMP D294-BANK: dump memp bank state to find the STAGE overflow. */
+ static int d294b_on = -1;
+ static s32 d294b_lastma = -1;
+ extern s32 g_CurentMaMallocValue;
+ if (d294b_on < 0) d294b_on = getenv("GE_D294BANK") != NULL;
+ if (d294b_on && g_CurentMaMallocValue != d294b_lastma) {
+ d294b_lastma = g_CurentMaMallocValue;
+ d104f = 0; /* restart window on stage change */
+ }
+ if (d294b_on && d104f < 6) {
+ extern MemoryPool g_mempPools[];
+ extern s32 needmemallocation;
+ osSyncPrintf("D294B f=%d STAGE[%p..%p] pos=%p left=%d PERM[%p..%p] pos=%p left=%d needmm=%d\n",
+ d104f, (void*)g_mempPools[MEMPOOL_STAGE].start, (void*)g_mempPools[MEMPOOL_STAGE].end,
+ (void*)g_mempPools[MEMPOOL_STAGE].pos, (s32)(g_mempPools[MEMPOOL_STAGE].end - g_mempPools[MEMPOOL_STAGE].pos),
+ (void*)g_mempPools[MEMPOOL_PERMANENT].start, (void*)g_mempPools[MEMPOOL_PERMANENT].end,
+ (void*)g_mempPools[MEMPOOL_PERMANENT].pos, (s32)(g_mempPools[MEMPOOL_PERMANENT].end - g_mempPools[MEMPOOL_PERMANENT].pos),
+ (int)needmemallocation);
+ }
+#endif
+ if (d104on < 0) d104on = getenv("GE_D104") != NULL;
+ if (d104on && (d104f++ % 30) == 0 && d104f < 240000) {
+ s32 rr;
+ osSyncPrintf("D104 f=%d curRoom=%d roomsDrawn=%d budget=%d :",
+ d104f, (s32)g_CurrentPlayer->curRoomIndex,
+ (s32)g_BgNumberOfRoomsDrawn, (s32)g_RoomLoadBudget);
+ for (rr = 0; rr < g_BgNumberOfRoomsDrawn && rr < 40; rr++) {
+ s32 rid = dword_CODE_bss_8007FFA0[rr].roomid;
+ osSyncPrintf(" r%d(ld=%d %d,%d-%d,%d)", rid,
+ (s32)g_BgRoomInfo[rid].model_bin_loaded,
+ (s32)dword_CODE_bss_8007FFA0[rr].bbox.min.x,
+ (s32)dword_CODE_bss_8007FFA0[rr].bbox.min.y,
+ (s32)dword_CODE_bss_8007FFA0[rr].bbox.max.x,
+ (s32)dword_CODE_bss_8007FFA0[rr].bbox.max.y);
+ }
+ osSyncPrintf("\n");
+ }
+ }
+#endif
#ifdef VERSION_EU
s16 i;
#else
@@ -647,28 +693,6 @@ Gfx *sub_GAME_7F0B3C8C(Gfx *gdl)
}
gSPMatrix(gdl++, osVirtualToPhysical((void*)get_BONDdata_field_10E0()), (G_MTX_NOPUSH | G_MTX_LOAD | G_MTX_PROJECTION));
-#ifdef PORT
- /* TEMP D104: room render-pass geometry-visibility probe. */
- {
- static s32 d104c = 0;
- if (getenv("GE_D104") && (d104c++ % 600) == 0 && d104c < 12000) {
- s32 rr;
- osSyncPrintf("D104 tick=%d curRoom=%d roomsDrawn=%d budget=%d :",
- d104c, (s32)g_CurrentPlayer->curRoomIndex,
- (s32)g_BgNumberOfRoomsDrawn, (s32)g_RoomLoadBudget);
- for (rr = 0; rr < g_BgNumberOfRoomsDrawn && rr < 24; rr++) {
- s32 rid = dword_CODE_bss_8007FFA0[rr].roomid;
- osSyncPrintf(" r%d(ld=%d %d,%d-%d,%d)", rid,
- (s32)g_BgRoomInfo[rid].model_bin_loaded,
- (s32)dword_CODE_bss_8007FFA0[rr].bbox.min.x,
- (s32)dword_CODE_bss_8007FFA0[rr].bbox.min.y,
- (s32)dword_CODE_bss_8007FFA0[rr].bbox.max.x,
- (s32)dword_CODE_bss_8007FFA0[rr].bbox.max.y);
- }
- osSyncPrintf("\n");
- }
- }
-#endif
gdl = fogSetRenderFogColor(
bgScissorCurrentPlayerViewF(
gdl++,
@@ -2413,48 +2437,6 @@ s32 bgLoadRoomVtxData(s32 roomnum, u8 *dst, s32 len)
#ifdef PORT
/* D85: byte-swap the raw N64 Vtx table (16-byte struct, no widening). */
bgSwapRoomVtx((Vtx *)dst, result);
-
- /* D236 pass 16 (TEMP): pass 15's live GE_D236ALPHA capture found the
- * Surface1 tree-card render class's vertex alpha capped at <=36/255
- * (<=14% opacity) across a full far->close walk to the tree line, and
- * confirmed (via the combine_mode decode) that this vertex alpha
- * directly multiplies the texture's own alpha in the blend equation --
- * so a capped-low vertex alpha caps every tree pixel's final opacity
- * outright. bgSwapRoomVtx (just above) explicitly leaves colour bytes
- * untouched, so whatever is in cn[] here is exactly what bgDecompress
- * produced -- this is the earliest point after decompression the raw
- * per-room vertex colour data can be inspected, independent of which
- * camera path a live player happens to walk. Unlike GE_D236ALPHA (which
- * only sees whatever triangles are actually drawn from wherever the
- * player is standing), this dumps a histogram of EVERY vertex's alpha
- * byte (cn[3]) for the WHOLE room's table the moment it loads --
- * settling whether the low ceiling pass 15 measured is a sampling
- * artifact (the walked path just never reached a vertex with a high
- * baked alpha) or a genuine property of this room's authored data (no
- * vertex in the whole table goes above the low ceiling, regardless of
- * where the camera is). Zero cost unset. Remove once D236 pass 16
- * concludes. */
- if (getenv("GE_D236RAW")) {
- s32 vtxCount = result / (s32)sizeof(Vtx);
- s32 vi;
- u32 buckets[5] = {0, 0, 0, 0, 0}; /* 0 / 1-36 / 37-100 / 101-200 / 201-255 */
- u8 amin = 255, amax = 0;
- Vtx *vtxArr = (Vtx *)dst;
- for (vi = 0; vi < vtxCount; vi++) {
- u8 a = vtxArr[vi].v.cn[3];
- if (a < amin) amin = a;
- if (a > amax) amax = a;
- if (a == 0) buckets[0]++;
- else if (a <= 36) buckets[1]++;
- else if (a <= 100) buckets[2]++;
- else if (a <= 200) buckets[3]++;
- else buckets[4]++;
- }
- osSyncPrintf(
- "D236RAW room=%d n=%d amin=%u amax=%u buckets[0,1-36,37-100,101-200,201-255]=%u,%u,%u,%u,%u\n",
- (int)roomnum, (int)vtxCount, amin, amax,
- buckets[0], buckets[1], buckets[2], buckets[3], buckets[4]);
- }
#endif
room->vertices = (Vtx *)dst;
@@ -2704,11 +2686,20 @@ void bgLoadRoomModelData(s32 roomID)
used = 0;
+#if defined(PORT)
+ static int d104ld_on = -1; /* D302-class cache */
+ static s32 d104ld_n = 0;
+ if (d104ld_on < 0) d104ld_on = getenv("GE_D104") != NULL;
+#define D104LD(...) do { if (d104ld_on && d104ld_n++ < 4000) osSyncPrintf(__VA_ARGS__); } while (0)
+#else
+#define D104LD(...) do {} while (0)
+#endif
+
// Room ID is out of range?
- if (roomID >= g_MaxNumRooms) goto end;
+ if (roomID >= g_MaxNumRooms) { D104LD("D104 LOAD room=%d FAIL oob\n", roomID); goto end; }
// Room is already loaded?
- if (g_BgRoomInfo[roomID].model_bin_loaded) goto end;
+ if (g_BgRoomInfo[roomID].model_bin_loaded) { D104LD("D104 LOAD room=%d skip ld=%d\n", roomID, (s32)g_BgRoomInfo[roomID].model_bin_loaded); goto end; }
// Get the cached file size. Is zero when the size is not yet known.
allocsize = g_BgRoomInfo[roomID].cur_room_totalsize;
@@ -2732,7 +2723,7 @@ void bgLoadRoomModelData(s32 roomID)
*/
data = memaAlloc(allocsize);
- if (data == NULL) goto end;
+ if (data == NULL) { D104LD("D104 LOAD room=%d FAIL alloc size=%d\n", roomID, allocsize); goto end; }
if (g_BgRoomInfo[roomID].csize_point_index_binary)
{
@@ -2782,6 +2773,10 @@ void bgLoadRoomModelData(s32 roomID)
g_BgRoomInfo[roomID].cur_room_totalsize = ((used + 0x20) & ~0xf);
g_BgRoomInfo[roomID].model_bin_loaded = 1;
+ D104LD("D104 LOAD room=%d OK used=%d vtx=%d prim=%d sec=%d\n", roomID, used,
+ (s32)g_BgRoomInfo[roomID].usize_point_index_binary,
+ (s32)g_BgRoomInfo[roomID].usize_primary_DL_binary,
+ (s32)g_BgRoomInfo[roomID].usize_secondary_DL_binary);
// If wasted space is detected, shrink allocated memory block.
if (allocsize != ((used + 0x20) & ~0xf))
@@ -2944,6 +2939,18 @@ Gfx *bgRenderRoomPrimary(Gfx *gdl, s32 room_index)
if (g_BgRoomInfo[room_index].model_bin_loaded == 0)
{
+#if defined(PORT)
+ /* TEMP D104/D294: room is on the drawn list but not rendered this
+ * frame (no load budget / load failed) -- its scissor region shows
+ * whatever was behind it. Log every occurrence, capped. */
+ static int d104skip_on = -1;
+ static s32 d104skip_n = 0;
+ if (d104skip_on < 0) d104skip_on = getenv("GE_D104") != NULL;
+ if (d104skip_on && d104skip_n++ < 4000) {
+ osSyncPrintf("D104 SKIP room=%d budget=%d\n",
+ room_index, (s32)g_RoomLoadBudget);
+ }
+#endif
return gdl;
}
else
@@ -2952,7 +2959,9 @@ Gfx *bgRenderRoomPrimary(Gfx *gdl, s32 room_index)
#if defined(PORT)
/* TEMP D63: log primary GDL at render time (env GE_D63=1) */
- if (getenv("GE_D63")) {
+ static int d63on_p = -1; /* D302-class cache: per room, per frame */
+ if (d63on_p < 0) d63on_p = getenv("GE_D63") != NULL;
+ if (d63on_p) {
u32 *w = (u32 *)g_BgRoomInfo[room_index].ptr_expanded_mapping_info;
osSyncPrintf("D63 bgRenderRoomPrimary room=%d ptr=%p usize=%d w0..3=(%08x,%08x,%08x,%08x)\n",
room_index, (void *)g_BgRoomInfo[room_index].ptr_expanded_mapping_info,
@@ -2997,7 +3006,9 @@ Gfx *bgRenderRoomSecondary(Gfx *gdl, s32 room_index)
#if defined(PORT)
/* TEMP D63: log secondary GDL at render time (env GE_D63=1) */
- if (getenv("GE_D63")) {
+ static int d63on_s = -1; /* D302-class cache: per room, per frame */
+ if (d63on_s < 0) d63on_s = getenv("GE_D63") != NULL;
+ if (d63on_s) {
u32 *w = (u32 *)g_BgRoomInfo[room_index].ptr_secondary_expanded_mapping_info;
osSyncPrintf("D63 bgRenderRoomSecondary room=%d ptr=%p usize=%d w0..3=(%08x,%08x,%08x,%08x)\n",
room_index, (void *)g_BgRoomInfo[room_index].ptr_secondary_expanded_mapping_info,
@@ -3509,7 +3520,9 @@ bool bgTestRayIntersectionInRoom(coord3d *from, coord3d *to, coord3d *dir, RoomV
idx[2] = (((u8 *) gdl)[7] / 10) - vtxoff;
#endif
#ifdef PORT
- if (getenv("GE_D154"))
+ static int d154on = -1; /* D302-class cache: inner GDL walk, fires per bullet hit */
+ if (d154on < 0) d154on = getenv("GE_D154") != NULL;
+ if (d154on)
osSyncPrintf("D154 TRI1 w1=%08x idx=%d,%d,%d\n",
(unsigned)(u32)gdl->words.w1, (int)idx[0], (int)idx[1], (int)idx[2]);
#endif
@@ -3694,7 +3707,9 @@ bool bgTestRayIntersectionInRoom(coord3d *from, coord3d *to, coord3d *dir, RoomV
}
#endif
#ifdef PORT
- if (getenv("GE_D154"))
+ static int d154on4 = -1; /* D302-class cache: inner GDL walk, fires per bullet hit */
+ if (d154on4 < 0) d154on4 = getenv("GE_D154") != NULL;
+ if (d154on4)
osSyncPrintf("D154 TRI4 s2=%d w0=%08x w1=%08x idx2=%d,%d,%d\n",
(int)s2, (unsigned)(u32)gdl->words.w0, (unsigned)(u32)gdl->words.w1,
(int)idx2[0], (int)idx2[1], (int)idx2[2]);
diff --git a/src/game/bondhead.c b/src/game/bondhead.c
index e86e4ffe..d4d47996 100644
--- a/src/game/bondhead.c
+++ b/src/game/bondhead.c
@@ -39,9 +39,28 @@ void bheadSetdamp(f32 headdamp);
+#ifdef PORT
+/* D336 (ABI/layout, A1 raw-offset alias; the D100 follow-up): on N64 the
+ * player's `model` at 0x598 is an INLINE struct Model, and the decomp's
+ * placeholders after it alias its fields by raw offset: animFlipFlag (0x5BC)
+ * is model+0x24 = model.gunhand, field_5C0 (0x5C0) is model+0x28 =
+ * model.animframe1 (4-byte pointers, no holes: unk25/unk27/unk2c confirm).
+ * D100 made `model` a real inline struct on PC, so those placeholders became
+ * separate fields that nothing initialises or keeps in sync -- the head-bob
+ * start frame was read from never-written memory (uninitialised player-struct
+ * bytes; garbage like 2e32 poisoned Bond's anim -> D156, and with the D294
+ * 2x room pool the player struct lands on dirty memory, corrupting Frigate).
+ * Read/write the real model fields, i.e. the bytes the N64 code touches. */
+#define BHEAD_FLIPFLAG (g_CurrentPlayer->model.gunhand)
+#define BHEAD_CURFRAME (g_CurrentPlayer->model.animframe1)
+#else
+#define BHEAD_FLIPFLAG (g_CurrentPlayer->animFlipFlag)
+#define BHEAD_CURFRAME (g_CurrentPlayer->field_5C0)
+#endif
+
void bheadFlipAnimation()
{
- g_CurrentPlayer->animFlipFlag = !g_CurrentPlayer->animFlipFlag;
+ BHEAD_FLIPFLAG = !BHEAD_FLIPFLAG;
}
void bheadUpdateIdleRoll()
@@ -410,7 +429,7 @@ void bheadAdjustAnimation(f32 speed)
if (g_CurrentPlayer->headanim >= 0)
{
- startframe = (g_CurrentPlayer->field_5C0 - g_BondMoveAnimationSetup[g_CurrentPlayer->headanim].loopframe)
+ startframe = (BHEAD_CURFRAME - g_BondMoveAnimationSetup[g_CurrentPlayer->headanim].loopframe)
/ (g_BondMoveAnimationSetup[g_CurrentPlayer->headanim].endframe - g_BondMoveAnimationSetup[g_CurrentPlayer->headanim].loopframe);
startframe = g_BondMoveAnimationSetup[i].loopframe + ((g_BondMoveAnimationSetup[i].endframe - g_BondMoveAnimationSetup[i].loopframe) * startframe);
@@ -420,7 +439,7 @@ void bheadAdjustAnimation(f32 speed)
&g_CurrentPlayer->model,
// match hack: addu address backwards
(struct ModelAnimation *) ((s32)g_BondMoveAnimationSetup[i].anim_id + (s32)&ptr_animation_table->data),
- (s32) g_CurrentPlayer->animFlipFlag,
+ (s32) BHEAD_FLIPFLAG,
startframe,
0.5f,
12.0f);
diff --git a/src/game/bondview2.c b/src/game/bondview2.c
index 6ef32715..f565b948 100644
--- a/src/game/bondview2.c
+++ b/src/game/bondview2.c
@@ -48,6 +48,12 @@
#include "stan.h"
#include "stanintersection.h"
#include "textrelated.h"
+#ifdef PORT
+#include "envflag.h" /* cached getenv for hot-path probes */
+#ifdef PORT
+#include "hudaspect.h" /* D335 HUD alignment under native widescreen */
+#endif
+#endif
#ifdef VERSION_EU
@@ -1689,7 +1695,7 @@ void bondviewFrozenCameraTick(u16 buttons, u16 oldbuttons, struct coord3d *pos,
* M-141/M-142 -- the pad-orbit branch below was ruled out with
* zero hits, so the Dam cutscene must be reaching this branch
* or the gBondViewCutscene branch further down instead). */
- if (getenv("GE_D243CAM")) {
+ if (GE_ENVFLAG("GE_D243CAM")) {
osSyncPrintf("D243CAM lookatpad: pos=%.2f,%.2f,%.2f pos2=%.2f,%.2f,%.2f\n",
(double) pos->f[0], (double) pos->f[1], (double) pos->f[2],
(double) pos2->f[0], (double) pos2->f[1], (double) pos2->f[2]);
@@ -1742,7 +1748,7 @@ void bondviewFrozenCameraTick(u16 buttons, u16 oldbuttons, struct coord3d *pos,
* zero hits in M-141/M-142). theta/verta are the spherical
* angles driving pos2's look-at offset from pos; a jump/jitter
* in either between calls would show up as the reported shake. */
- if (getenv("GE_D243CAM")) {
+ if (GE_ENVFLAG("GE_D243CAM")) {
osSyncPrintf("D243CAM cutscene: pos=%.2f,%.2f,%.2f pos2=%.2f,%.2f,%.2f theta=%.4f verta=%.4f\n",
(double) pos->f[0], (double) pos->f[1], (double) pos->f[2],
(double) pos2->f[0], (double) pos2->f[1], (double) pos2->f[2],
@@ -1789,7 +1795,7 @@ void bondviewFrozenCameraTick(u16 buttons, u16 oldbuttons, struct coord3d *pos,
* wall-clock sim-tick count, normally clamped to <=6 but not
* necessarily steady at 1) is jittering frame-to-frame in a way that
* would show up as this exact judder. */
- if (getenv("GE_D243CAM")) {
+ if (GE_ENVFLAG("GE_D243CAM")) {
osSyncPrintf("D243CAM: dt=%.4f angle=%.4f pos=%.2f,%.2f,%.2f pad=%d\n",
(double) g_GlobalTimerDelta, (double) flt_CODE_bss_80079A00,
(double) pos->f[0], (double) pos->f[1], (double) pos->f[2],
@@ -8213,6 +8219,14 @@ void bondviewMovePlayerUpdateViewport(s8 stick_x, s8 stick_y, u16 buttons)
}
#ifdef VERSION_EU
+#ifdef PORT
+ extern f32 portNativeAspect(void); /* D334, see the NTSC branch below */
+ if (portNativeAspect() > 0.0f)
+ {
+ faspect = ((f32) bondviewGetCurrentPlayerViewportWidth() / (f32) bondviewGetCurrentPlayerViewportHeight()) * 0.75f * portNativeAspect();
+ }
+ else
+#endif
if (get_screen_ratio() == SCREEN_RATIO_16_9)
{
faspect = ((f32) bondviewGetCurrentPlayerViewportWidth() / (f32) bondviewGetCurrentPlayerViewportHeight()) * 0.75f * WIDESCREEN_ASPECT;
@@ -8232,6 +8246,19 @@ void bondviewMovePlayerUpdateViewport(s8 stick_x, s8 stick_y, u16 buttons)
#else
+#ifdef PORT
+ /* D334 (native widescreen, Rule-2 approved 2026-09-23): the N64 16:9 mode
+ * below with WIDESCREEN_ASPECT replaced by the real window aspect -- see
+ * portNativeAspect() (port/src/video.c). The in-game "Ratio 16:9" option
+ * is ignored while this is on (the window is the ratio). */
+ extern f32 portNativeAspect(void);
+ if (portNativeAspect() > 0.0f)
+ {
+ set_cur_player_aspect(((f32) bondviewGetCurrentPlayerViewportWidth() / (f32) bondviewGetCurrentPlayerViewportHeight()) * 0.75f * portNativeAspect());
+ viSetAspect(((f32) bondviewGetCurrentPlayerViewportWidth() / (f32) bondviewGetCurrentPlayerViewportHeight()) * 0.75f * portNativeAspect());
+ }
+ else
+#endif
if (get_screen_ratio() == SCREEN_RATIO_16_9)
{
set_cur_player_aspect(((f32) bondviewGetCurrentPlayerViewportWidth() / (f32) bondviewGetCurrentPlayerViewportHeight()) * 0.75f * WIDESCREEN_ASPECT);
@@ -9233,6 +9260,63 @@ Gfx *bondviewRenderCredits(Gfx *gdl)
}
+#ifdef PORT
+/* D226: the top dialogue's line breaks are authored into the text (no runtime
+ * wrap), so an unconditional HUD scale pushes long lines off-screen. Scale it
+ * as far as the widest line still fits between the text origin (view left +
+ * 0x1e, msg.x) and the same margin on the right. */
+static s32 portDialogueHudScalePercent(void)
+{
+ s32 hp = portHudScalePercent();
+ s32 th = 0, tw = 0;
+ s32 avail;
+ char *text;
+
+ if (hp <= 100) {
+ return hp;
+ }
+#if defined(LEFTOVERDEBUG)
+ text = stringbuffer_top[upper_text_buffer_index];
+#else
+ text = dword_CODE_bss_80079DC8[upper_text_buffer_index];
+#endif
+ if (text == NULL || text[0] == '\0') {
+ return hp;
+ }
+ textMeasure(&th, &tw, text, ptrFontZurichBoldChars, ptrFontZurichBold, 0);
+ avail = viGetViewWidth() - 2 * 0x1e;
+ if (tw > 0 && tw * hp > avail * 100) {
+ hp = (avail * 100) / tw;
+ if (hp < 100) hp = 100;
+ }
+ return hp;
+}
+#endif
+
+#ifdef PORT
+/* D226: same width cap for the lower-left pickup / status message (and the
+ * level title card), measured with the fonts the bottom renderer uses. */
+static s32 portBottomHudScalePercent(void)
+{
+ s32 hp = portHudScalePercent();
+ s32 th = 0, tw = 0;
+ s32 avail;
+ char *text = stringbuffer_lowerleft[status_bar_text_buffer_index];
+
+ if (hp <= 100 || text[0] == 0) {
+ return hp;
+ }
+ textMeasure(&th, &tw, text, BONDVIEW_2ND_FONTTABLE(status_bar_text_buffer_index),
+ BONDVIEW_1ST_FONTTABLE(status_bar_text_buffer_index), 0);
+ avail = viGetViewWidth() - 2 * 0x1e;
+ if (tw > 0 && tw * hp > avail * 100) {
+ hp = (avail * 100) / tw;
+ if (hp < 100) hp = 100;
+ }
+ return hp;
+}
+#endif
+
Gfx *maybe_mp_interface(Gfx *gdl)
{
s32 ulx;
@@ -9249,18 +9333,62 @@ Gfx *maybe_mp_interface(Gfx *gdl)
if (g_CurrentPlayer->cameramode == 1)
{
bondviewIntroCameraTextTick();
+#ifdef PORT
+ PORT_HUD_ASPECT(gdl, GE_HUD_ASPECT_CENTER);
+#endif
+#ifdef PORT
+ PORT_HUD_SCALE_PCT(gdl, portBottomHudScalePercent(), viGetViewLeft(), viGetViewTop() + viGetViewHeight()); /* D226: bottom messages, width-capped */
+#endif
gdl = hudmsgBottomRender(gdl);
+#ifdef PORT
+ PORT_HUD_SCALE_END(gdl);
+#endif
+#ifdef PORT
+ PORT_HUD_ASPECT(gdl, GE_HUD_ASPECT_NONE);
+#endif
bondviewUpperTextWindowTimerTick();
+#ifdef PORT
+ PORT_HUD_ASPECT(gdl, GE_HUD_ASPECT_CENTER);
+#endif
+#ifdef PORT
+ PORT_HUD_SCALE_PCT(gdl, portDialogueHudScalePercent(), viGetViewLeft() + 0x1e, viGetViewTop()); /* D226: dialogue, width-capped */
+#endif
gdl = sub_GAME_7F08AAE8(gdl);
+#ifdef PORT
+ PORT_HUD_SCALE_END(gdl);
+#endif
+#ifdef PORT
+ PORT_HUD_ASPECT(gdl, GE_HUD_ASPECT_NONE);
+#endif
+#ifdef PORT
+ PORT_HUD_ASPECT(gdl, GE_HUD_ASPECT_CENTER);
+#endif
gdl = countdownTimerRender(gdl);
+#ifdef PORT
+ PORT_HUD_ASPECT(gdl, GE_HUD_ASPECT_NONE);
+#endif
gdl = currentPlayerDrawFade(gdl);
+#ifdef PORT
+ /* D335: end credits are centred 4:3 text -> keep their shape. */
+ PORT_HUD_ASPECT(gdl, GE_HUD_ASPECT_CENTER);
+ gdl = bondviewRenderCredits(gdl);
+ PORT_HUD_ASPECT(gdl, GE_HUD_ASPECT_NONE);
+ return gdl;
+#else
return bondviewRenderCredits(gdl);
+#endif
}
gunUpdateAndFireBothHands();
gunRenderCasings(&gdl);
gunRenderFirstPersonGunModels(&gdl);
+#ifdef PORT
+ PORT_HUD_ASPECT(gdl, GE_HUD_ASPECT_CENTER);
+#endif
gdl = bondviewRenderWatch(gdl);
+#ifdef PORT
+ PORT_HUD_ASPECT(gdl, GE_HUD_ASPECT_NONE);
+#endif
if (g_CurrentPlayer->mpmenuon != 0)
{
@@ -9275,11 +9403,23 @@ Gfx *maybe_mp_interface(Gfx *gdl)
if (bondviewGetIfCurrentPlayerHealthShowTime() &&
(g_CurrentPlayer->watch_animation_state == 0))
{
+#ifdef PORT
+ PORT_HUD_ASPECT(gdl, GE_HUD_ASPECT_CENTER);
+#endif
gdl = bondviewRenderGaugeBars(gdl);
+#ifdef PORT
+ PORT_HUD_ASPECT(gdl, GE_HUD_ASPECT_NONE);
+#endif
}
else if (mpwatchShouldDisplayGauges())
{
+#ifdef PORT
+ PORT_HUD_ASPECT(gdl, GE_HUD_ASPECT_CENTER);
+#endif
gdl = bondviewRenderGaugeBars(gdl);
+#ifdef PORT
+ PORT_HUD_ASPECT(gdl, GE_HUD_ASPECT_NONE);
+#endif
if (g_CurrentPlayer->healthdisplaytime > 0)
{
g_CurrentPlayer->healthdisplaytime -= g_ClockTimer;
@@ -9388,12 +9528,42 @@ Gfx *maybe_mp_interface(Gfx *gdl)
}
bondviewIntroCameraTextTick();
+#ifdef PORT
+ PORT_HUD_ASPECT(gdl, GE_HUD_ASPECT_CENTER);
+#endif
+#ifdef PORT
+ PORT_HUD_SCALE_PCT(gdl, portBottomHudScalePercent(), viGetViewLeft(), viGetViewTop() + viGetViewHeight()); /* D226: bottom messages, width-capped */
+#endif
gdl = hudmsgBottomRender(gdl);
+#ifdef PORT
+ PORT_HUD_SCALE_END(gdl);
+#endif
+#ifdef PORT
+ PORT_HUD_ASPECT(gdl, GE_HUD_ASPECT_NONE);
+#endif
bondviewUpperTextWindowTimerTick();
+#ifdef PORT
+ PORT_HUD_ASPECT(gdl, GE_HUD_ASPECT_CENTER);
+#endif
+#ifdef PORT
+ PORT_HUD_SCALE_PCT(gdl, portDialogueHudScalePercent(), viGetViewLeft() + 0x1e, viGetViewTop()); /* D226: dialogue, width-capped */
+#endif
gdl = sub_GAME_7F08AAE8(gdl);
+#ifdef PORT
+ PORT_HUD_SCALE_END(gdl);
+#endif
+#ifdef PORT
+ PORT_HUD_ASPECT(gdl, GE_HUD_ASPECT_NONE);
+#endif
gunDrawSight(&gdl);
gdl = generate_ammo_total_microcode(gdl);
+#ifdef PORT
+ PORT_HUD_ASPECT(gdl, GE_HUD_ASPECT_CENTER);
+#endif
gdl = countdownTimerRender(gdl);
+#ifdef PORT
+ PORT_HUD_ASPECT(gdl, GE_HUD_ASPECT_NONE);
+#endif
gdl = display_red_blue_on_radar(gdl);
return currentPlayerDrawFade(gdl);
}
diff --git a/src/game/bondview_r.c b/src/game/bondview_r.c
index eea3dd65..1a32f115 100644
--- a/src/game/bondview_r.c
+++ b/src/game/bondview_r.c
@@ -372,6 +372,19 @@ void bondviewLoadSetupIntroSection(void)
{
ptr_random06cam_entry = g_CurrentSetupIntroCamera;
rand_camera_index = (s32)(randomGetNext() % (u32) g_SetupIntroCameraCount);
+#ifdef PORT
+ /* TEMP D306 (M-201): test-only override of the first intro camera,
+ * GE_INTROCAM= (n < count). The PRNG draw above still happens, so
+ * the rest of the random sequence is unchanged. Inert unset. */
+ {
+ extern char *getenv(const char *name);
+ extern int atoi(const char *s);
+ const char *ic = getenv("GE_INTROCAM");
+ if (ic || getenv("GE_D306C")) osSyncPrintf("D306: intro camera index %d of %d%s\n", (int)rand_camera_index,
+ (int)g_SetupIntroCameraCount, ic ? " (GE_INTROCAM override)" : "");
+ if (ic) rand_camera_index = (s32)((u32)atoi(ic) % (u32)g_SetupIntroCameraCount);
+ }
+#endif
while (rand_camera_index > 0)
{
rand_camera_index--;
@@ -384,6 +397,25 @@ void bondviewLoadSetupIntroSection(void)
}
bondinvAddInvItem(ITEM_FIST);
+#ifdef PORT
+ /* TEMP D303 (M-201): test-only start weapon, GE_STARTWEAPON=,
+ * so headless repros can hold a specific gun (pair with GE_D320R
+ * cheat=3 for ammo). Inert unset. */
+ {
+ extern char *getenv(const char *name);
+ extern int atoi(const char *s);
+ const char *sw = getenv("GE_STARTWEAPON");
+ if (sw) {
+ bondinvAddInvItem(atoi(sw));
+ {
+ extern void add_ammo_to_weapon(ITEM_IDS weapon, s32 ammo);
+ add_ammo_to_weapon(atoi(sw), 400);
+ }
+ starting_weapon[GUNRIGHT] = atoi(sw);
+ set_starting_weapon = 1;
+ }
+ }
+#endif
if (set_starting_weapon == 0)
{
diff --git a/src/game/chraction.c b/src/game/chraction.c
index e560b19f..bd2ae1dc 100644
--- a/src/game/chraction.c
+++ b/src/game/chraction.c
@@ -6212,6 +6212,33 @@ void chrlvUpdateAimendbackShoulders(ChrRecord *self, void *arg1, s32 same, s32 s
next_aimendback = 0.0f;
next_lshoulder = next;
+#ifdef PORT
+ /* The leading anim union in weapon_firing_animation_table grows from
+ * four to eight bytes on PC. N64's float slots [12,13,16,17] are now
+ * different fields; read the intended limits/arm fractions by name. */
+ if (arg1 != NULL)
+ {
+ struct weapon_firing_animation_table *config = arg1;
+ if (config->max_up < next)
+ {
+ next_aimendback = next - config->max_up;
+ next_lshoulder = config->max_up;
+ }
+ else if (next < config->max_down)
+ {
+ next_aimendback = next - config->max_down;
+ next_lshoulder = config->max_down;
+ }
+ if (next_lshoulder > 0.0f)
+ {
+ next_rshoulder = config->free_arm_frac_up * next_lshoulder;
+ }
+ else
+ {
+ next_rshoulder = config->free_arm_frac_down * next_lshoulder;
+ }
+ }
+#else
if (arg1 != NULL)
{
if (((f32*)arg1)[12] < next)
@@ -6235,6 +6262,7 @@ void chrlvUpdateAimendbackShoulders(ChrRecord *self, void *arg1, s32 same, s32 s
next_rshoulder = ((f32*)arg1)[17] * next_lshoulder;
}
}
+#endif
if (swap != 0)
{
@@ -9994,35 +10022,6 @@ bool chrHasFlags2ById(ChrRecord *self, s32 chrNum, u8 flags2)
*/
void chrSetStageFlags(ChrRecord *self, s32 arg1)
{
-#ifdef PORT
- /* D318-followup (diagnosis only, GE_OBJT=1): attribute writes to the LOW
- * bits of objectiveregisters1 (the Facility execution derail triggers:
- * 0x04 gas/combat, 0x20 surrender/monologue, 0x40 flee -- none of which
- * the level setup script ever sets; only character-action scripts do).
- * Logs the writing chr + its AI position so the writer's script state is
- * correlatable against a simultaneous D318T capture. Read-only, capped,
- * no behavior change; N64 build unaffected. */
- extern char *getenv(const char *);
- extern s32 chraiGetAIListID(AIRecord *AIList, bool *isGlobalAIList); /* chrai.c */
- static int s_objt = -1;
- static int s_objtn = 0;
- int objtlog;
- s32 aid;
- bool aig;
-
- if (s_objt < 0) { s_objt = getenv("GE_OBJT") != NULL; }
- objtlog = s_objt && (arg1 & 0x0000FFFF) && (s_objtn < 500);
- if (objtlog)
- {
- aid = self->ailist ? chraiGetAIListID(self->ailist, &aig) : -1;
- osSyncPrintf("OBJT: t=%d SET mask=0x%08x by chr %d (act=%d off=%d aiid=0x%04x%s ailist=%p) reg=0x%08x\n",
- (int)g_GlobalTimer, (unsigned)arg1, (int)self->chrnum,
- (int)self->actiontype, (int)self->aioffset, (unsigned)aid,
- aig ? "G" : "", (void *)self->ailist,
- (unsigned)objectiveregisters1);
- s_objtn++;
- }
-#endif
objectiveregisters1 |= arg1;
}
@@ -10032,28 +10031,6 @@ void chrSetStageFlags(ChrRecord *self, s32 arg1)
*/
void chrUnsetStageFlags(ChrRecord *self, u32 flags)
{
-#ifdef PORT
- extern char *getenv(const char *);
- extern s32 chraiGetAIListID(AIRecord *AIList, bool *isGlobalAIList); /* chrai.c */
- static int s_objtu = -1;
- static int s_objtun = 0;
- int objtulog;
- s32 aid;
- bool aig;
-
- if (s_objtu < 0) { s_objtu = getenv("GE_OBJT") != NULL; }
- objtulog = s_objtu && (flags & 0x0000FFFF) && (s_objtun < 500);
- if (objtulog)
- {
- aid = self->ailist ? chraiGetAIListID(self->ailist, &aig) : -1;
- osSyncPrintf("OBJT: t=%d UNS mask=0x%08x by chr %d (act=%d off=%d aiid=0x%04x%s ailist=%p) reg=0x%08x\n",
- (int)g_GlobalTimer, (unsigned)flags, (int)self->chrnum,
- (int)self->actiontype, (int)self->aioffset, (unsigned)aid,
- aig ? "G" : "", (void *)self->ailist,
- (unsigned)objectiveregisters1);
- s_objtun++;
- }
-#endif
objectiveregisters1 = ~flags & objectiveregisters1; //shorthand does not match
}
diff --git a/src/game/chrprop.c b/src/game/chrprop.c
index e8e4fd32..eb504362 100644
--- a/src/game/chrprop.c
+++ b/src/game/chrprop.c
@@ -39,6 +39,9 @@
#include "objective_status.h"
#include "stan.h"
#include "tex.h"
+#ifdef PORT
+#include "envflag.h" /* cached getenv for hot-path probes */
+#endif
// bss
@@ -433,7 +436,7 @@ Gfx *chrpropRender(Gfx * gdl, PropRecord *prop, s32 withalpha)
u8 type;
#if defined(PORT)
Gfx *gdl_in = gdl;
- int probe = getenv("GE_D96") != NULL;
+ int probe = GE_ENVFLAG("GE_D96");
#endif
type = prop->type;
@@ -504,7 +507,7 @@ Gfx *chrpropsRenderPass(Gfx *gdl, s32 roomid, s32 renderpass)
}
#if defined(PORT)
- if (getenv("GE_D96")) {
+ if (GE_ENVFLAG("GE_D96")) {
s32 n = (s32)((PropRecord **)g_LastOnScreenProp - g_OnScreenPropList);
osSyncPrintf("D96 chrpropsRenderPass room=%d pass=%d gdl=%p list=[%p..%p) n=%d cap=%d\n",
(int)roomid, (int)renderpass, (void*)gdl,
diff --git a/src/game/front.c b/src/game/front.c
index 26bb7c9e..5043ef8d 100644
--- a/src/game/front.c
+++ b/src/game/front.c
@@ -49,6 +49,9 @@
#include "ob.h"
#include "gbi_extension.h"
#include "model.h"
+#ifdef PORT
+#include "hudaspect.h" /* D335 front-end pillarbox under native widescreen */
+#endif
/**
@@ -954,6 +957,9 @@ Gfx *constructor_menu12_mpstage(Gfx *DL);
Gfx *constructor_menu13_mpscenario(Gfx *DL);
Gfx *constructor_menu14_mpteams(Gfx *DL);
Gfx *constructor_menu15_cheat(Gfx *DL);
+#ifdef PORT
+#include "frontoptions.h" /* D343: MENU_PC_OPTIONS lives in port/src/frontoptions.c */
+#endif
Gfx *constructor_menu16_nocontrollers(Gfx *DL);
Gfx *constructor_menu17_switchscreens(Gfx *DL);
Gfx *constructor_menu18_displaycast(Gfx *DL);
@@ -2203,7 +2209,17 @@ void frontCleanUpWalletBond(void)
void init_menu05_fileselect(void)
{
s32 size = 0x6e000;
+#ifdef PORT
+ /* File-select folders (M-201): the PC wallet model is 0x1664C bytes and
+ * loads at the start of this buffer with a 0x17000 reservation (D45, see
+ * load_walletbond), not N64's 0xA000. Scratch placed after it at the N64
+ * offset 4096*10 landed INSIDE the wallet model, corrupting the folder
+ * body/photo geometry once written. Same N64 budget: 0x85000 - 0x17000
+ * == 0x78000 - 0xA000 == 0x6e000. Layout-only. */
+ Gfx* DL = (Gfx *)(ptr_logo_and_walletbond_DL + 0x17000);
+#else
Gfx* DL = (s32)(ptr_logo_and_walletbond_DL) + (s32)(4096*10);
+#endif
int i;
prev_keypresses = FALSE;
@@ -2762,7 +2778,14 @@ Gfx *constructor_menu05_fileselect(Gfx *DL)
textMeasure(&textsize.p[1], &textsize.p[0], langp, ptrFontZurichBoldChars, ptrFontZurichBold, 0);
textpos.p[0] = 285 - (textsize.p[1] / 2);
+#ifdef PORT
+ /* D343 (user sign-off 2026-09-27): Copy/Erase shift left to make room
+ * for "PC Options" at the right end of the bar (N64: 247/225/357/335).
+ * D399: whole row shifted a further 15px left (user: "a bit cramped"). */
+ textpos.p[1] = 207;
+#else
textpos.p[1] = 247;
+#endif
DL = textRender(DL, &textpos.p[1], &textpos.p[0], langp, ptrFontZurichBoldChars, ptrFontZurichBold, -1, viGetX(), viGetY(), 0, 0);
folder_option_COPY_bound.right = (f32) (textsize.p[0] + textpos.p[1]);
@@ -2774,14 +2797,28 @@ Gfx *constructor_menu05_fileselect(Gfx *DL)
textMeasure(&textsize.p[1], &textsize.p[0], langp, ptrFontZurichBoldChars, ptrFontZurichBold, 0);
+#ifdef PORT
+ textpos.p[1] = 289; /* D343: see Copy above; D399: -15 row shift */
+#else
textpos.p[1] = 357;
+#endif
textpos.p[0] = 285 - (textsize.p[1] / 2);
DL = textRender(DL, &textpos.p[1], &textpos.p[0], langp, ptrFontZurichBoldChars, ptrFontZurichBold, -1, viGetX(), viGetY(), 0, 0);
folder_option_ERASE_bound.right = (f32) (textsize.p[0] + textpos.p[1]);
+#ifdef PORT
+ /* D343: "PC Options" at the right end of this bar; a click enters
+ * MENU_PC_OPTIONS. All logic lives in port/src/frontoptions.c. */
+ DL = optionsFileSelectLabel(DL);
+#endif
+
+#ifdef PORT
+ copypos.f[0] = 185.0f; /* D343; D399: -15 row shift */
+#else
copypos.f[0] = 225.0f;
+#endif
copypos.f[1] = 285.0f;
copyhalfsize.f[0] = (f32) (mainfolderimages + IMG_COPY)->width * 0.5f;
@@ -2794,7 +2831,11 @@ Gfx *constructor_menu05_fileselect(Gfx *DL)
folder_option_COPY_bound.up = copypos.f[1] - copyhalfsize.f[1];
folder_option_COPY_bound.down = copypos.f[1] + copyhalfsize.f[1];
+#ifdef PORT
+ erasepos.f[0] = 267.0f; /* D343; D399: -15 row shift */
+#else
erasepos.f[0] = 335.0f;
+#endif
erasepos.f[1] = 285.0f;
erasehalfsize.f[0] = (mainfolderimages + IMG_DEL)->width * 0.5f;
@@ -2807,7 +2848,11 @@ Gfx *constructor_menu05_fileselect(Gfx *DL)
folder_option_ERASE_bound.up = (f32) (erasepos.f[1] - erasehalfsize.f[1]);
folder_option_ERASE_bound.down = (f32) (erasepos.f[1] + erasehalfsize.f[1]);
+#ifdef PORT
+ selectpos.f[0] = 95.0f; /* D399: row shift -15 (was 110) */
+#else
selectpos.f[0] = 110.0f;
+#endif
selectpos.f[1] = 285.0f;
selecthalfsize.f[0] = (mainfolderimages + IMG_SEL)->width * 0.5f;
@@ -3461,6 +3506,13 @@ Gfx *constructor_menu07_missionsel(Gfx *DL)
DL = frontAddPreviousTabText(DL);
DL = frontDrawCursor(DL);
+#ifdef AVOID_UB
+ /* porting-notes D6: the decomp falls off the end of this Gfx*-returning
+ * function and the caller uses the result; N64 and x86-64 GCC -O2 return
+ * frontDrawCursor()'s result by accident of the return register (verified:
+ * identical machine code with and without this line). */
+ return DL;
+#endif
}
@@ -6602,7 +6654,15 @@ void load_briefing_text_for_stage(void)
s32 argg;
// what is this
+#ifdef PORT
+ /* File-select folders (M-201): past the PC wallet model's 0x17000
+ * reservation (see init_menu05_fileselect). The briefing data loaded here
+ * overwrote the wallet model tail -> folder bodies/photos vanished after
+ * backing out of a file. Layout-only. */
+ temp_s0 = (Gfx *)(ptr_logo_and_walletbond_DL + 0x17000);
+#else
temp_s0 = (s32)(ptr_logo_and_walletbond_DL) + (s32)(4096*10);
+#endif
// alright
argg = 0x200;
@@ -7500,6 +7560,13 @@ Gfx *constructor_menu0D_missioncomplete(Gfx *DL)
DL = frontAddNextTabText(DL);
DL = frontAddPreviousTabText(DL);
DL = frontDrawCursor(DL);
+#ifdef AVOID_UB
+ /* porting-notes D6: the decomp falls off the end of this Gfx*-returning
+ * function and the caller uses the result; N64 and x86-64 GCC -O2 return
+ * frontDrawCursor()'s result by accident of the return register (verified:
+ * identical machine code with and without this line). */
+ return DL;
+#endif
}
@@ -8640,6 +8707,9 @@ static const char *d243MenuName(MENU m)
case MENU_NO_CONTROLLERS: return "MENU_NO_CONTROLLERS";
case MENU_DISPLAY_CAST: return "MENU_DISPLAY_CAST";
case MENU_SPECTRUM_EMU: return "MENU_SPECTRUM_EMU";
+#ifdef PORT
+ case MENU_PC_OPTIONS: return "MENU_PC_OPTIONS";
+#endif
default: return "MENU_";
}
}
@@ -8745,6 +8815,9 @@ void menu_init(void)
case MENU_NO_CONTROLLERS: update_menu16_nocontrollers(); break;
case MENU_DISPLAY_CAST: update_menu18_displaycast(); break;
case MENU_SPECTRUM_EMU: update_menu19_spectrum(); break;
+#ifdef PORT
+ case MENU_PC_OPTIONS: frontOptionsMenuUpdate(); break;
+#endif
}
if (menu_update > MENU_INVALID)
@@ -8798,6 +8871,9 @@ void menu_init(void)
case MENU_NO_CONTROLLERS: init_menu16_nocontroller(); break;
case MENU_DISPLAY_CAST: init_menu18_displaycast(); break;
case MENU_SPECTRUM_EMU: init_menu19_spectrum(); break;
+#ifdef PORT
+ case MENU_PC_OPTIONS: frontOptionsMenuInit(); break;
+#endif
}
}
@@ -8827,6 +8903,9 @@ void menu_init(void)
case MENU_NO_CONTROLLERS: interface_menu16_nocontrollers(); break;
case MENU_DISPLAY_CAST: interface_menu18_displaycast(); break;
case MENU_SPECTRUM_EMU: interface_menu19_spectrum(); break;
+#ifdef PORT
+ case MENU_PC_OPTIONS: frontOptionsMenuInterface(); break;
+#endif
case MENU_RUN_STAGE:
if (interface_menu0B_runstage())
{
@@ -8855,6 +8934,22 @@ void menu_init(void)
Gfx * menu_jump_constructor_handler(Gfx *DL)
{
+#ifdef PORT
+ /* D335: under native widescreen the front end is 4:3 logical content;
+ * pillarbox it (centred, undistorted) instead of stretching it to the
+ * window. input.c maps the menu pointer into the same centred region. */
+ PORT_HUD_ASPECT(DL, GE_HUD_ASPECT_CENTER);
+ {
+ /* D335a: a full-canvas scissor set while CENTER is active is squeezed
+ * to the pillarbox, so off-canvas draws (the gun-barrel backdrop sliding
+ * in from logical x > 320) clip at the 4:3 edge as they would at the
+ * N64 screen edge instead of painting into the side bars. */
+ extern f32 portNativeAspect(void);
+ if (portNativeAspect() > 1.3334f) {
+ gDPSetScissor(DL++, G_SC_NON_INTERLACE, 0, 0, viGetX(), viGetY());
+ }
+ }
+#endif
switch(current_menu) {
case MENU_LEGAL_SCREEN:
DL = constructor_menu00_legalscreen(DL);
@@ -8930,8 +9025,28 @@ Gfx * menu_jump_constructor_handler(Gfx *DL)
break;
case MENU_SPECTRUM_EMU:
DL = constructor_menu19_spectrum(DL);
+#ifdef PORT
+ break;
+ case MENU_PC_OPTIONS:
+ DL = frontOptionsMenuDraw(DL);
+#endif
}
+#ifdef PORT
+ PORT_HUD_ASPECT(DL, GE_HUD_ASPECT_NONE);
+ {
+ /* D335a follow-up: fast3d stores the scissor as adjusted when it was
+ * set, so the pillarbox scissor above would persist -- squeezed to the
+ * 4:3 centre -- into the first stage frames after the front end hands
+ * over, clipping anything the stage draws before setting its own
+ * scissor (right-anchored HUD such as the ammo counter vanished).
+ * Re-set the full-canvas scissor with the aspect mode off. */
+ extern f32 portNativeAspect(void);
+ if (portNativeAspect() > 1.3334f) {
+ gDPSetScissor(DL++, G_SC_NON_INTERLACE, 0, 0, viGetX(), viGetY());
+ }
+ }
+#endif
return DL;
}
diff --git a/src/game/glass2.c b/src/game/glass2.c
index 62323df6..4e721fd4 100644
--- a/src/game/glass2.c
+++ b/src/game/glass2.c
@@ -646,7 +646,9 @@ void bullet_spark_render(s_bullet_spark *thing, Gfx *gdlarg, s32 zbufferMode)
* the local directly instead of reading 12 bytes past a 4-byte global
* (harmless in practice so far, but real UB the PC compiler already
* flags with -Warray-bounds). */
- memset(&vtx, 0, sizeof(vtx));
+ /* __builtin_memset: this TU sees no memset prototype (D324); the builtin
+ * needs none and generates identical code. */
+ __builtin_memset(&vtx, 0, sizeof(vtx));
#else
vtx = *((Vtx *) (&D_80040980));
#endif
diff --git a/src/game/gunfire.c b/src/game/gunfire.c
index 8b0eb4d9..d21ff3bc 100644
--- a/src/game/gunfire.c
+++ b/src/game/gunfire.c
@@ -40,6 +40,9 @@
#ifdef PORT
#include
#include
+#ifdef PORT
+#include "hudaspect.h" /* D335 HUD alignment under native widescreen */
+#endif
/* D102: the 1P weapon Model and its RW-data pool were punned onto
* hand->field_B68 / hand->modeldatas; on x86-64 struct Model (0xE8) is too
* big for that layout and modelInit() aliases objinst->datas onto the pool
@@ -500,6 +503,16 @@ void gunUpdateAndFire(GUNHAND handnum)
gunofs.y += g_CurrentPlayer->ducking_height_offset / (-100.0f);
gunofs.z += (3.0f * g_CurrentPlayer->ducking_height_offset) / (-100.0f);
+#ifdef PORT
+ /* D334: a native-widescreen window counts as the N64's 16:9 ratio for
+ * the rocket launcher's lowered viewmodel (>= 16:10 treated as wide). */
+ extern f32 portNativeAspect(void);
+ if ((item == ITEM_ROCKETLAUNCH) && portNativeAspect() >= 1.55f)
+ {
+ gunofs.y -= 3.0f;
+ }
+ else
+#endif
if ((item == ITEM_ROCKETLAUNCH) && (((cur_player_get_screen_setting() == SCREEN_SIZE_WIDESCREEN) || (cur_player_get_screen_setting() == SCREEN_SIZE_CINEMA)) || (get_screen_ratio() == SCREEN_RATIO_16_9)))
{
gunofs.y -= 3.0f;
@@ -2029,7 +2042,21 @@ Gfx* watchRenderController(Gfx* gdl, Mtxf* basemtx, s32 envcolour, bool animateb
struct coord3d coord_node12_pos;
struct coord3d coord_node12_base;
+#ifdef PORT
+ /* D290 (ABI/layout class; same pattern as D264 above): on N64 this
+ * 64-byte copy starts at the last word of D_80035D04 (0) and runs into
+ * watchControllerButtonBases {1, 3, 0..} (gun.c), i.e. the same
+ * ModelRenderData template as D264: basemtx=NULL, zbufferenabled=TRUE,
+ * flags=3, rest zero. On PC the struct is wider (8-byte pointers) and the
+ * globals are not laid out at those byte offsets, so flags read 0 and
+ * subdraw() skipped every node: the watch control-options page showed
+ * its labels but no controller model. Use the explicit template. */
+ renderdata = (ModelRenderData){0};
+ renderdata.zbufferenabled = TRUE;
+ renderdata.flags = 3;
+#else
renderdata = *(ModelRenderData *)((u8 *)D_80035D04 + 0x3c);
+#endif
sub_GAME_7F05DA8C(GUNRIGHT, 0x55);
@@ -2091,7 +2118,13 @@ Gfx* watchRenderController(Gfx* gdl, Mtxf* basemtx, s32 envcolour, bool animateb
for (i = 0; i < objheader->numMatrices; i++)
{
+#ifdef PORT
+ /* D290: N64 does this pointer math through a u32 cast, which truncates a
+ * 64-bit pointer (fatal once the pool sits above 4 GB, e.g. Linux PIE). */
+ matrix_4x4_copy((Mtxf *)((u8 *)modelstack.render_pos + i * sizeof(Mtxf)), &sp41c);
+#else
matrix_4x4_copy((u32)modelstack.render_pos + i * sizeof(Mtxf), &sp41c);
+#endif
matrix_4x4_f32_to_s32(&sp41c, &modelstack.render_pos[i]);
}
@@ -2326,7 +2359,13 @@ Gfx* watchRenderController(Gfx* gdl, Mtxf* basemtx, s32 envcolour, bool animateb
for (i = 0; i < objheader->numMatrices; i++)
{
+#ifdef PORT
+ /* D290: N64 does this pointer math through a u32 cast, which truncates a
+ * 64-bit pointer (fatal once the pool sits above 4 GB, e.g. Linux PIE). */
+ matrix_4x4_copy((Mtxf *)((u8 *)modelstack.render_pos + i * sizeof(Mtxf)), &sp41c);
+#else
matrix_4x4_copy((u32)modelstack.render_pos + i * sizeof(Mtxf), &sp41c);
+#endif
matrix_4x4_f32_to_s32(&sp41c, &modelstack.render_pos[i]);
}
@@ -3287,6 +3326,23 @@ void gunTickHandState(enum GUNHAND hand, s32 triggerOn)
sndDeactivate((struct ALSoundState *) handptr->field_A48);
}
+#ifdef PORT
+ /* TEMP D207/D241 (M-201): classify every shot-sound
+ * attempt: into handle 1, handle 2, or SKIPPED because
+ * both per-hand handles are still held (the stop above
+ * is async). GE_D207S=1; one line per attempt. */
+ if (bondwalkItemGetSound(var_s1) != 0)
+ {
+ extern char *getenv(const char *name);
+ static int d207s = -1;
+ if (d207s < 0) d207s = getenv("GE_D207S") != NULL;
+ if (d207s)
+ osSyncPrintf("D207S t=%d hand=%d item=%d rate=%d -> %s\n", (int)g_GlobalTimer, (int)hand,
+ (int)var_s1, (int)bondwalkItemGetSoundTriggerRate(var_s1),
+ handptr->audioHandle == NULL ? "H1"
+ : (struct ALSoundState *)handptr->field_A48 == 0 ? "H2" : "SKIP");
+ }
+#endif
if (bondwalkItemGetSound(var_s1) != 0)
{
if (handptr->audioHandle == NULL)
@@ -4935,6 +4991,33 @@ void sub_GAME_7F067FBC(f32 turn_x, f32 turn_y)
gunaimdamp = item_stats->AimLockSpeed;
#endif
+#ifdef PORT
+ /* Defined in port/src/input.c. Declared here rather than including
+ * port/include/input.h, which pulls in SDL types game code cannot see --
+ * the same pattern other game files use for port functions. */
+ extern int portMouseAimPdGetTurn(f32 *tx, f32 *ty);
+
+ /* Input.PdMouseAim (findings D332): PD's mouse-aim model.
+ *
+ * PD chooses its crosshair damp PER INPUT DEVICE -- its mouse path calls
+ * bgunSwivelWithDamp(x, y, 0.01f) while its stick path uses ~0.945. GE has
+ * one damp for both, and the weapon's CrosshairSpeed (~0.8) is a stick
+ * value: the integrator is what smooths a stick. With a mouse that slow
+ * integrator is what makes the drawn crosshair step, because the port
+ * writes once per input poll while the game damps once per sim tick -- the
+ * displayed value is write*damp^k with k varying (D332). At 0.01 the input
+ * dominates and k stops mattering.
+ *
+ * The port supplies the turn; PD does the same from its own game files
+ * (bondmove.c/bondgun.c, behind #ifndef PLATFORM_N64) calling port
+ * functions. Identity when Input.PdMouseAim is off, and the N64 build
+ * never sees any of it. Behavioural hook, not the ABI exception:
+ * Rule-2 sign-off given by the maintainer 2026-09-23. */
+ if (portMouseAimPdGetTurn(&turn_x, &turn_y)) {
+ guncrossdamp = 0.01f;
+ }
+#endif
+
caclulate_gun_crosshair_position_rotation(turn_x, turn_y, guncrossdamp, gunaimdamp);
}
@@ -5735,7 +5818,19 @@ void sub_GAME_7F068EC4(CasingRecord *casing, Gfx **gdl)
ModelFileHeader *model_header = casing->header;
RenderPosView *model_matrices = dynAllocate(model_header->numMatrices * sizeof(RenderPosView));
ModelHead model;
+#ifdef PORT
+ /* D331 (ABI/layout class; D264/D290 pattern): g_DefaultCasingModelRenderData
+ * is a u32[15] {0, 1, 3, 0..} holding the N64 ModelRenderData template
+ * (basemtx NULL, zbufferenabled TRUE, flags 3). Read as the PC struct
+ * (8-byte basemtx) it gives zbufferenabled 3 / flags 0 -- subdraw() then
+ * draws nothing, so ejected shell casings were invisible -- and the read
+ * runs past the 60-byte array. Use the explicit template. */
+ ModelRenderData render_data = {0};
+ render_data.zbufferenabled = TRUE;
+ render_data.flags = 3;
+#else
ModelRenderData render_data = *(ModelRenderData *)g_DefaultCasingModelRenderData;
+#endif
Mtxf casing_model_mtx;
s32 axis_offset;
s32 matrix_translation_in_range = TRUE;
@@ -6141,6 +6236,11 @@ Gfx *generate_ammo_total_microcode(Gfx *gdl)
rightx = 109;
}
+#ifdef PORT
+ /* D335: right-hand ammo anchors to the right edge (native widescreen). */
+ PORT_HUD_ASPECT(gdl, GE_HUD_ASPECT_RIGHT);
+ PORT_HUD_SCALE(gdl, viGetViewLeft() + viGetViewWidth(), viGetViewTop() + viGetViewHeight()); /* D226 */
+#endif
if (weapon_right != ITEM_UNARMED)
{
ammotype = get_ammo_type_for_weapon(weapon_right);
@@ -6207,6 +6307,12 @@ Gfx *generate_ammo_total_microcode(Gfx *gdl)
}
}
+#ifdef PORT
+ /* D335: left-hand (dual-wield) ammo anchors to the left edge. */
+ PORT_HUD_SCALE_END(gdl);
+ PORT_HUD_ASPECT(gdl, GE_HUD_ASPECT_LEFT);
+ PORT_HUD_SCALE(gdl, viGetViewLeft(), viGetViewTop() + viGetViewHeight()); /* D226 */
+#endif
if (weapon_left != ITEM_UNARMED)
{
ammotype = get_ammo_type_for_weapon(weapon_left);
@@ -6275,6 +6381,10 @@ Gfx *generate_ammo_total_microcode(Gfx *gdl)
}
}
+#ifdef PORT
+ PORT_HUD_SCALE_END(gdl);
+ PORT_HUD_ASPECT(gdl, GE_HUD_ASPECT_NONE);
+#endif
return gdl;
}
@@ -6391,6 +6501,17 @@ void gunDrawSight(s32 *gdl) {
* stack as the pointer high word → wild `gdl` write in texSetRenderMode
* when the player raises the crosshair (right-mouse aim). §A. */
Gfx *sp54;
+ /* D373/D381: opt-in hide/colour/size/style. With the defaults the
+ * original red sprite, 16-unit half-size, and N64 combiner are intact.
+ * Non-PORT builds keep the original literals and control flow. */
+ extern s32 portCrosshairHide;
+ extern void portCrosshairTint(s32 *r, s32 *g, s32 *b);
+ extern void portCrosshairApplyTintCombine(Gfx *envCommand);
+ extern int portCrosshairStyle(void);
+ extern float portCrosshairScale(void);
+ extern struct sImageTableEntry *betacrosshairimage;
+ struct sImageTableEntry *sightimage;
+ s32 crosshair_r, crosshair_g, crosshair_b;
#else
s32 sp54;
#endif
@@ -6399,8 +6520,13 @@ void gunDrawSight(s32 *gdl) {
if ((g_CurrentPlayer->gunsightmode == 0) && (g_CurrentPlayer->mpmenuon == FALSE)) {
#ifdef PORT
+ if (portCrosshairHide)
+ return;
+ portCrosshairTint(&crosshair_r, &crosshair_g, &crosshair_b);
+ sightimage = portCrosshairStyle() && betacrosshairimage
+ ? betacrosshairimage : crosshairimage;
sp54 = *(Gfx **)gdl;
- texSelect(&sp54, crosshairimage, 4, 0, 0);
+ texSelect(&sp54, sightimage, 4, 0, 0);
#else
sp54 = *gdl;
texSelect(&sp54, crosshairimage, 4, 0, 0);
@@ -6410,14 +6536,37 @@ void gunDrawSight(s32 *gdl) {
xypos[1] = g_CurrentPlayer->crosshair_angle.f[1];
halfedxy[0] = 16.0f;
halfedxy[1] = 16.0f;
+#ifdef PORT
+ /* Opt-in size changes only the sprite footprint, never the aim point,
+ * texture dimensions, or the N64/default display list. */
+ halfedxy[0] *= portCrosshairScale();
+ halfedxy[1] *= portCrosshairScale();
+#endif
+#ifdef PORT
+ /* D334: native widescreen generalises the N64 16:9 sprite fix
+ * (x0.75 = (4/3)/(16/9)) to the window aspect, so the crosshair
+ * sprite isn't stretched by the canvas-to-window mapping. */
+ extern f32 portNativeAspect(void);
+ if (portNativeAspect() > 0.0f) {
+ halfedxy[0] = halfedxy[0] * ((4.0f / 3.0f) / portNativeAspect());
+ } else
+#endif
if (get_screen_ratio() == SCREEN_RATIO_16_9) {
halfedxy[0] = halfedxy[0] * 0.75f;
}
#ifdef VERSION_EU
halfedxy[1] = halfedxy[1] * g_GunSightAspectRatio;
#endif
+#ifdef PORT
+ Gfx *envCommand = sp54;
+ display_image_at_position(&sp54, &xypos, &halfedxy, 0x20, 0x20, 0, 0, 1, crosshair_r, crosshair_g, crosshair_b, 0x6E, (sightimage->level > 0), 0);
+ /* D379: only recoloured reticles use the texture-alpha silhouette.
+ * Original (the default) keeps the N64 G_CC_FADEA commands. */
+ portCrosshairApplyTintCombine(envCommand);
+#else
display_image_at_position(&sp54, &xypos, &halfedxy, 0x20, 0x20, 0, 0, 1, 0xFF, 0xFF, 0xFF, 0x6E, (crosshairimage->level > 0), 0);
+#endif
#ifdef PORT
*(Gfx **)gdl = sp54;
#else
diff --git a/src/game/image.c b/src/game/image.c
index 627bea4e..b1d68f27 100644
--- a/src/game/image.c
+++ b/src/game/image.c
@@ -2575,8 +2575,27 @@ void texLoad(s32 *updateword, struct texpool *pool)
// pointer to the start of the pool. It'll be garbage data but the
// only other option is a crash. GBI commands contain texture IDs
// instead of pointers, and they must be replaced with pointers.
+#if defined(PORT)
+ /* D252 TEMP (2026-09-26): Facility explosions turn rainbow after the
+ * player passes into the middle of the level (user). Log every new
+ * texture load with pool identity + free bytes, and pool-full
+ * events, to test the pool-exhaustion hypothesis. Load-time only. */
+ {
+ static int d252pool = -1;
+ if (d252pool < 0) d252pool = getenv("GE_D252POOL") != NULL;
+ if (d252pool)
+ osSyncPrintf("D252POOL load texnum=%d pool=%s free=%d zlib=%d\n",
+ (int)g_TexNumToLoad,
+ pool == (struct texpool *)&ptr_texture_alloc_start ? "main" : "other",
+ (int)texFreeBytesInBuffer(pool), (int)iszlib);
+ }
+#endif
if ((!iszlib && (texFreeBytesInBuffer(pool) < 0x10CC)) || (iszlib && texFreeBytesInBuffer(pool) < 0xA28)) {
#if defined(PORT)
+ if (getenv("GE_D252POOL"))
+ osSyncPrintf("D252POOL POOL-FULL texnum=%d pool=%s free=%d\n", (int)g_TexNumToLoad,
+ pool == (struct texpool *)&ptr_texture_alloc_start ? "main" : "other",
+ (int)texFreeBytesInBuffer(pool));
/* D85: env-gated -- a room rendering with placeholder textures
* usually means the stage pool filled up here. */
if (getenv("GE_D85TEX"))
diff --git a/src/game/initmttex.c b/src/game/initmttex.c
index 0418f1b3..9f415492 100644
--- a/src/game/initmttex.c
+++ b/src/game/initmttex.c
@@ -2,6 +2,9 @@
#include
#include "image.h"
#include "initmttex.h"
+#ifdef PORT
+#include
+#endif
void set_mt_tex_alloc(void)
{
@@ -13,4 +16,16 @@ void set_mt_tex_alloc(void)
}
texInitPool(&ptr_texture_alloc_start, mempAllocBytesInBank(bytes, MEMPOOL_STAGE), bytes);
+#ifdef PORT
+ /* D336 probe: the stage texture pool size actually in effect. When it
+ * fills, texLoad points textures at the pool start (garbage textures,
+ * e.g. rainbow Frigate water) -- see GE_D85TEX's pool-full line. */
+ {
+ extern s32 g_StageNum;
+ if (getenv("GE_D85TEX") || getenv("GE_D252POOL"))
+ osSyncPrintf("D85TEX stage=%d texpool bytes=%d (-mt token %s) range=[%p..%p)\n", (int)g_StageNum,
+ (int)bytes, tokenFind(1, "-mt") ? "present" : "ABSENT",
+ (void *)ptr_texture_alloc_start.start, (void *)ptr_texture_alloc_start.end);
+ }
+#endif
}
diff --git a/src/game/language.c b/src/game/language.c
index 9462f889..cfdac343 100644
--- a/src/game/language.c
+++ b/src/game/language.c
@@ -8,6 +8,9 @@
/* D50: language banks carry a big-endian offset table; decode it in place
* after each load (see romdataFixupLangBank). */
#include "romdata.h"
+#ifdef PORT
+#include "envflag.h" /* cached getenv for hot-path probes */
+#endif
extern resource_lookup_data_entry resource_lookup_data_array[]; /* ob.c */
static void langFixupLoadedBank(char *name, void *p)
@@ -428,7 +431,7 @@ u8 * langGet(s32 slotID)
#endif
#ifdef PORT
/* TEMP D65: log NULL-bank derefs (cast screen langGet crash). */
- if (getenv("GE_D63")) {
+ if (GE_ENVFLAG("GE_D63")) {
static int d65first = 1;
if (d65first) {
d65first = 0;
diff --git a/src/game/lv.c b/src/game/lv.c
index a400c8a0..cdf4b0f1 100644
--- a/src/game/lv.c
+++ b/src/game/lv.c
@@ -1053,8 +1053,12 @@ void lvlManageMpGame(void)
{
extern void d318WatchdogTick(void);
extern void d318TimelineTick(void);
+ extern void d320ReproTick(void);
+ extern void d318bProbeTick(void);
d318WatchdogTick(); /* D318 permanent port-side deadlock recovery (findings D318) */
d318TimelineTick(); /* D318T env-gated diagnostic timeline (GE_D318T=1) */
+ d320ReproTick(); /* D320R env-gated forced-repro harness (GE_D320R, TEMP) */
+ d318bProbeTick(); /* D318B env-gated attack-state boundary probe (GE_D318B, TEMP) */
}
#endif
if ((g_CurrentStageToLoad != LEVELID_TITLE) && (D_80048394 == 0) && (g_ClockTimer > 0))
diff --git a/src/game/model.c b/src/game/model.c
index 24c5b537..ad54419f 100644
--- a/src/game/model.c
+++ b/src/game/model.c
@@ -18,6 +18,9 @@
#include "objecthandler.h"
#include "quaternion.h"
#include "random.h"
+#ifdef PORT
+#include "envflag.h" /* cached getenv for hot-path probes */
+#endif
typedef struct ModelGroupMtxBuildArg {
@@ -541,7 +544,7 @@ union ModelRwData* modelGetNodeRwData(Model *Objinst, ModelNode *root)
#ifdef PORT
/* TEMP D51: trace rwdata pool addressing */
- if (getenv("GE_D51")) {
+ if (GE_ENVFLAG("GE_D51")) {
static FILE *f = NULL;
if (!f) { f = fopen("d52rw.log", "a"); setvbuf(f, NULL, _IONBF, 0); }
fprintf(f, "GND obj=%p datas=%p idx=%d rwdatalen=%d op=%d data=%p res=%p\n",
@@ -2717,8 +2720,26 @@ void modelCopyAnimForMerge(Model *model, f32 timemerge)
}
+#ifdef PORT
+/* D311 (M-194): the upstream poison source is still unknown -- GDB caught
+ * oldspeed=-1.75e18 / newspeed=-3.2e7 but not which call first wrote a
+ * garbage speed. Every write of model->speed funnels through
+ * modelSetAnimSpeed (startframe<=0 path) or modelSetAnimation2, so flag any
+ * anomalous value entering OR already resident at those two choke points.
+ * Fires only on |v|>1e4 or NaN -- silent in healthy play; the line pins the
+ * exact transition that poisons the model. Same unconditional-on-fire
+ * pattern as the D156 guards (which log when they fire, no env var). */
+static int d311Anomalous(f32 v) { return (v != v) || (v > 10000.0f) || (v < -10000.0f); }
+#endif
+
void modelSetAnimation2(Model *model, ModelAnimation *anim, s32 flip, f32 frame, f32 speed, f32 arg5)
{
+#ifdef PORT
+ if (model != NULL && d311Anomalous(speed)) {
+ osSyncPrintf("D311: setanimation2 ANOMALY in=%.6g resident=%.6g frame=%.6g model=%p\n",
+ (double)speed, (double)model->speed, (double)frame, (void *)model);
+ }
+#endif
s32 hadNoAnim = !model->anim;
s32 padding;
s32 type;
@@ -2953,6 +2974,13 @@ void sub_GAME_7F06FE44(Model *model, s32 arg1) {
void modelSetAnimSpeed(Model *model, f32 anim_speed, f32 startframe) {
#ifdef PORT
+ if (model != NULL && (d311Anomalous(anim_speed) || d311Anomalous(model->speed))) {
+ osSyncPrintf("D311: setanimspeed ANOMALY in=%.6g resident=%.6g startframe=%.6g "
+ "oldspeed=%.6g newspeed=%.6g timespeed=%.6g model=%p\n",
+ (double)anim_speed, (double)model->speed, (double)startframe,
+ (double)model->oldspeed, (double)model->newspeed,
+ (double)model->timespeed, (void *)model);
+ }
/* D243 M-180: log all modelSetAnimSpeed calls during scripted camera modes
* to identify if unusual speed values are being set during cutscenes.
* Env-gated on GE_D243M (already cached elsewhere). */
@@ -3460,6 +3488,64 @@ void modelSetAnimFrame2WithChrStuff(Model *model, f32 framea, f32 frameb, f32 fr
va = modelptr->framea;
vb = modelptr->frameb;
+#ifdef PORT
+ /* D329 (RULE-2-SIGNOFF 2026-09-23, user approved): tick-
+ * granularity fix. The per-frame loop above only writes
+ * framea/frameb when floor(frame) crosses an integer. N64 GE
+ * renders at ~20-30 fps, so modelTickAnim folds 2-3 ticks into
+ * this call and any attack-speed advance (0.5-0.8/tick) crosses
+ * at once; the port at 60 fps folds 1 tick, which does not. A
+ * pair clamped equal by an earlier endframe (aim hold: both at
+ * ceil(H)) is then re-emitted stale after the endframe is raised
+ * (actor_fire_or_aim_at_target_update -> chrlvAttackActionRelated
+ * changes only the endframe) and the sub-frame progress below is
+ * discarded every call -> permanent pin (D318/D320). Only when
+ * the loop did not run, progress was requested, and the CURRENT
+ * endframe no longer blocks the next frame, re-derive frameb the
+ * way modelSetAnimFrame does, so the model advances at the rate
+ * the N64 does. Genuine holds (endframe still at the frame) and
+ * every >=2-tick call are untouched. */
+ /* GE_D329=0 disables (A/B only); GE_D329LOG=1 logs each trigger
+ * (capped). Both cached -- this is a per-model per-frame path. */
+ static s32 s_d329 = -1;
+ static s32 s_d329log = 0;
+ static s32 s_d329n = 0;
+
+ if (s_d329 < 0)
+ {
+ const char *e = getenv("GE_D329");
+
+ s_d329 = !(e && e[0] == '0');
+ s_d329log = (getenv("GE_D329LOG") != NULL);
+ }
+
+ if (s_d329 && (vb == va)
+ && (forward ? ((endframe < floorFloatToInt(framea) + 1) && (framea < frameb))
+ : ((ceilFloatToInt(framea) - 1 < endframe) && (frameb < framea))))
+ {
+ s32 next = modelConstrainOrWrapAnimFrame(forward ? va + 1 : va - 1, modelptr->anim, modelptr->endframe);
+
+ if (next == (forward ? va + 1 : va - 1))
+ {
+ if (s_d329log && s_d329n < 2000)
+ {
+ extern s32 g_GlobalTimer;
+ extern s32 g_ClockTimer;
+
+ s_d329n++;
+ osSyncPrintf("D329: t=%d clk=%d model=%p va=%d next=%d framea=%.4f frameb=%.4f "
+ "endframe=%.4f speed=%.4f playspeed=%.4f\n",
+ (int) g_GlobalTimer, (int) g_ClockTimer, (void *) modelptr, (int) va,
+ (int) next, (double) framea, (double) frameb,
+ (double) modelptr->endframe, (double) modelptr->speed,
+ (double) modelptr->playspeed);
+ }
+ modelptr->frameb = next;
+ vb = next;
+ }
+ }
+#endif
+
if (vb == va)
{
modelptr->unk2c = 0.0f;
@@ -3871,15 +3957,23 @@ void modelTickAnim(struct Model *model, s32 numticks, s32 update_chrstuff)
{
ge_d156_logged = 1;
fprintf(stderr,
- "[D156] bad anim frame guarded: frame=%g animframe1=%g "
+ "[D156] bad anim frame guarded: model=%p frame=%g animframe1=%g "
"speed=%g playspeed=%g newspeed=%g oldspeed=%g "
"timespeed=%g elapsespeed=%g unkb0=%g unk88=%g anim=%p\n",
- (double)frame, (double)model->animframe1,
+ (void *)model, (double)frame, (double)model->animframe1,
(double)model->speed, (double)model->playspeed,
(double)model->newspeed, (double)model->oldspeed,
(double)model->timespeed, (double)model->elapsespeed,
(double)model->unkb0, (double)model->unk88,
(void *)model->anim);
+ /* TEMP D294: hexdump the struct to identify the overwriter. */
+ {
+ const unsigned char *p = (const unsigned char *)model;
+ int i;
+ fprintf(stderr, "[D156] model=%p bytes:", (void *)model);
+ for (i = 0; i < 128; i++) { if ((i & 15) == 0) fprintf(stderr, "\n +%02x: ", i); fprintf(stderr, "%02x ", p[i]); }
+ fprintf(stderr, "\n");
+ }
fflush(stderr);
}
/* D311: heal the stored state BEFORE falling back to it -- if
@@ -4591,7 +4685,7 @@ void modelRenderNodeGundl(ModelRenderData* renderdata, ModelNode* arg1)
#if defined(PORT)
/* TEMP D63: log Primary/Secondary before emit (env GE_D63=1) */
- if (getenv("GE_D63") && ((renderdata->flags & 1) && rodata->Primary))
+ if (GE_ENVFLAG("GE_D63") && ((renderdata->flags & 1) && rodata->Primary))
osSyncPrintf("D63 modelRenderNodeGundl Primary=%p Secondary=%p BaseAddr=%p ModelType=%d\n",
(void *)rodata->Primary, (void *)rodata->Secondary,
(void *)rodata->BaseAddr, (int)rodata->ModelType);
diff --git a/src/game/options.c b/src/game/options.c
index 28bc3b86..1a8a321e 100644
--- a/src/game/options.c
+++ b/src/game/options.c
@@ -3420,7 +3420,12 @@ Gfx *draw_watch_controller(Gfx *gdl)
}
else
{
+#ifdef PORT
+ /* D290: the (s32) cast truncates a 64-bit stack pointer. */
+ gdl = watchRenderControllerOpaque(gdl, &finalmtx, 1, watchTable, &contpadnum0);
+#else
gdl = watchRenderControllerOpaque(gdl, &finalmtx, 1, (s32) watchTable, &contpadnum0);
+#endif
}
if (controllerCheckDualControllerTypesAllowed())
@@ -3460,7 +3465,15 @@ Gfx *draw_watch_controller(Gfx *gdl)
*
* but it doesn't match for some reason.
*/
+#ifdef PORT
+ /* D290 (B class): a hand-encoded N64 G_MTX word does not decode as the
+ * same command in the PC Gfx layout; emit it through the macro (the
+ * form the comment above says it really is). */
+ cmd1 = gdl++;
+ gSPMatrix(cmd1, osVirtualToPhysical(perspmtx), G_MTX_NOPUSH | G_MTX_LOAD | G_MTX_PROJECTION);
+#else
cmd1 = gdl++; cmd1->words.w0 = 0x01030040; cmd1->words.w1 = osVirtualToPhysical(perspmtx);
+#endif
gdl = sub_GAME_7F0A6EE8(gdl);
green = g_WatchBackgroundGreen;
diff --git a/src/game/prop.c b/src/game/prop.c
index 17378cee..551bab45 100644
--- a/src/game/prop.c
+++ b/src/game/prop.c
@@ -646,17 +646,30 @@ void setupCctv(s32 arg0, CCTVRecord *arg1, s32 cmdindex)
domakedefaultobj(arg0, (struct ObjectRecord*)arg1, cmdindex);
- if (arg1->pad >= 0)
+#ifdef PORT
+ /* D307 (M-201): on N64, CCTVRecord's own s32 `pad` @0x80 (the LOOK-AT
+ * pad) shadows the inherited ObjectRecord s16 `pad` @0x08 (the mount pad),
+ * so `arg1->pad` here is the look-at pad. The PC struct renames the
+ * duplicate to `lookpad` (GCC rejects duplicate members), which silently
+ * re-pointed these reads at the MOUNT pad: the look vector became the
+ * lens's own offset from its mount and every camera faced backwards.
+ * Read the correctly named field (A1 class: layout/naming only; PD's
+ * setupCreateCctv uses cctv->lookatpadnum for the same step). */
+#define CCTV_LOOKPAD (arg1->lookpad)
+#else
+#define CCTV_LOOKPAD (arg1->pad)
+#endif
+ if (CCTV_LOOKPAD >= 0)
{
temp_a2 = (struct coord3d*)arg1->model->obj->Switches[0]->Data;
- if (isNotBoundPad(arg1->pad))
+ if (isNotBoundPad(CCTV_LOOKPAD))
{
- sp50 = &g_CurrentSetup.pads[arg1->pad];
+ sp50 = &g_CurrentSetup.pads[CCTV_LOOKPAD];
}
else
{
- sp50 = (struct PadRecord *)&g_CurrentSetup.boundpads[getBoundPadNum(arg1->pad)];
+ sp50 = (struct PadRecord *)&g_CurrentSetup.boundpads[getBoundPadNum(CCTV_LOOKPAD)];
}
sp44.f[0] = temp_a2->f[0];
@@ -688,6 +701,40 @@ void setupCctv(s32 arg0, CCTVRecord *arg1, s32 cmdindex)
arg1->unkC8 = arg1->unkCC;
arg1->unkC4 = atan2f(sp44.f[0] - sp50->pos.f[0], sp44.f[2] - sp50->pos.f[2]);
arg1->timer = 0;
+
+#ifdef PORT
+ /* D307 probe: dump every input + the derived orientation so a PC run
+ * can be compared word-for-word against a ROM decode of the same
+ * record (facing-backwards CCTV report). Env-gated, load-time only. */
+ if (getenv("GE_D307")) {
+ fprintf(stderr,
+ "D307: cctv cmd=%d obj=%d pad=%d bound=%d pos=(%.6g,%.6g,%.6g) "
+ "mtx=(%.6g,%.6g,%.6g %.6g,%.6g,%.6g %.6g,%.6g,%.6g) "
+ "sw=(%.6g,%.6g,%.6g) watch=(%.6g,%.6g,%.6g) padpos=(%.6g,%.6g,%.6g) "
+ "target=(%.6g,%.6g,%.6g) C4=%.6f CC=%.6f basis=(%.6g,%.6g,%.6g %.6g,%.6g,%.6g %.6g,%.6g,%.6g)\n",
+ cmdindex, arg1->obj, arg1->pad, !isNotBoundPad(arg1->pad),
+ arg1->prop->pos.f[0], arg1->prop->pos.f[1], arg1->prop->pos.f[2],
+ arg1->mtx.m[0][0], arg1->mtx.m[0][1], arg1->mtx.m[0][2],
+ arg1->mtx.m[1][0], arg1->mtx.m[1][1], arg1->mtx.m[1][2],
+ arg1->mtx.m[2][0], arg1->mtx.m[2][1], arg1->mtx.m[2][2],
+ temp_a2->f[0], temp_a2->f[1], temp_a2->f[2],
+ sp44.f[0], sp44.f[1], sp44.f[2],
+ sp50->pos.f[0], sp50->pos.f[1], sp50->pos.f[2],
+ sp44.f[0] - sp50->pos.f[0], sp44.f[1] - sp50->pos.f[1], sp44.f[2] - sp50->pos.f[2],
+ arg1->unkC4, arg1->unkCC,
+ sp3C->m[0][0], sp3C->m[0][1], sp3C->m[0][2],
+ sp3C->m[1][0], sp3C->m[1][1], sp3C->m[1][2],
+ sp3C->m[2][0], sp3C->m[2][1], sp3C->m[2][2]);
+ if (arg1->pad == 10018) {
+ for (int d307i = 16; d307i <= 24; d307i++) {
+ struct PadRecord *d307p = &g_CurrentSetup.boundpads[d307i];
+ fprintf(stderr, "D307: bpad[%d] ptr=%p pos=(%.6g,%.6g,%.6g)\n",
+ d307i, (void *)d307p,
+ d307p->pos.f[0], d307p->pos.f[1], d307p->pos.f[2]);
+ }
+ }
+ }
+#endif
}
}
diff --git a/src/game/propobj.c b/src/game/propobj.c
index 7acc4213..1c38e18b 100644
--- a/src/game/propobj.c
+++ b/src/game/propobj.c
@@ -9427,24 +9427,6 @@ apply_damage:
{
if (objGetDestroyedLevel(obj) == 1)
{
-#ifdef PORT
- /* D318-followup (diagnosis only, GE_OBJT=1): timestamp each
- * GAS_RELEASING prop destruction so the tank-blow tick is
- * correlatable against the OBJT stage-flag writes. Read-only,
- * capped, no behavior change; N64 build unaffected. */
- extern char *getenv(const char *);
- static int s_gas = -1;
- static int s_gasn = 0;
-
- if (s_gas < 0) { s_gas = getenv("GE_OBJT") != NULL; }
- if (s_gas && (s_gasn < 200))
- {
- osSyncPrintf("OBJT: t=%d GAS prop destroyed at (%.0f,%.0f,%.0f) [gas effect armed]\n",
- (int)g_GlobalTimer, (double)obj->runtime_pos.x,
- (double)obj->runtime_pos.y, (double)obj->runtime_pos.z);
- s_gasn++;
- }
-#endif
init_trigger_toxic_gas_effect(&obj->runtime_pos);
}
}
diff --git a/src/game/sky.c b/src/game/sky.c
index 6c41f0f1..94d56766 100644
--- a/src/game/sky.c
+++ b/src/game/sky.c
@@ -5,6 +5,10 @@
#include
#endif
#include "sky.h"
+#ifdef PORT
+#include "floatvtx.h" /* D245: G_FLOATVTX_EXT */
+#include "envflag.h"
+#endif
#include "player.h"
#include "unk_092E50.h"
#include "bondview.h"
@@ -16,6 +20,9 @@
#include "image_bank.h"
#ifdef PORT
#include "dyn.h"
+#ifdef PORT
+#include "envflag.h" /* cached getenv for hot-path probes */
+#endif
#endif
#define SKYABS(val) (val >= 0.0f ? (val) : -(val))
@@ -343,7 +350,7 @@ Gfx *skyRender(Gfx *gdl)
env = fogGetCurrentEnvironmentp();
#ifdef PORT
- if (getenv("GE_D176")) {
+ if (GE_ENVFLAG("GE_D176")) {
static int n = 0;
if (n++ < 4)
fprintf(stderr, "D176 sky: Clouds=%d RGB=%d,%d,%d SkyImageId=%d CloudRGB=%.1f,%.1f,%.1f "
@@ -404,7 +411,7 @@ Gfx *skyRender(Gfx *gdl)
sp52c = skyIsScreenCornerInSky(&sp680, &sp620, &sp580);
#ifdef PORT
- if (getenv("GE_D176")) {
+ if (GE_ENVFLAG("GE_D176")) {
static int m = 0;
if (m++ < 3) {
coord3d *eye = bondviewGetCurrentPlayersPosition();
@@ -943,7 +950,7 @@ Gfx *skyRender(Gfx *gdl)
* comment. Off by default (unset env => identical to the current
* allowShift=TRUE behaviour this row is still reopened against). */
{
- const char *fs = getenv("GE_D245_FIXEDSHIFT");
+ const char *fs = GE_ENVSTR("GE_D245_FIXEDSHIFT");
if (fs) {
s32 kFixed = atoi(fs);
if (kFixed < 0) kFixed = 0;
@@ -959,7 +966,7 @@ Gfx *skyRender(Gfx *gdl)
* still-open question after allowShift=TRUE didn't fix the live
* symptom. Recomputed locally rather than reading skyPortBeginFan's
* statics (defined later in this TU, not forward-declared). */
- if (getenv("GE_D245V")) {
+ if (GE_ENVFLAG("GE_D245V")) {
static int callN = 0;
f32 minS = sp274[0].unk20, maxS = sp274[0].unk20;
f32 minT = sp274[0].unk24, maxT = sp274[0].unk24;
@@ -1993,6 +2000,53 @@ static Gfx *skyPortRenderPoly(Gfx *gdl, SkyRelated38 **v, s32 nverts)
wScale = (maxAbsW > 30000.0f) ? (maxAbsW / 30000.0f) : 1.0f;
}
+ /* D245 (M-201): full-precision path. Hand fast3d the clip-space position
+ * (ndcX*w, ndcY*w, 0, w) and the folded S/T as floats (G_FLOATVTX_EXT,
+ * port/include/floatvtx.h) instead of squeezing them through s16 Vtx
+ * ob/tc: no wScale position quantisation, no 2^k tc shift. The fold is
+ * still applied (a multiple of the repeat period, exact under wrapping)
+ * to keep the float magnitudes small. GE_D245_OLDVTX=1 restores the old
+ * s16 route for A/B. */
+ if (!GE_ENVFLAG("GE_D245_OLDVTX"))
+ {
+ PortFloatVtx *fv = (PortFloatVtx *) dynAllocateVertices(2 * (nverts < 3 ? 3 : nverts));
+
+ for (i = 0; i < nverts; i++)
+ {
+ f32 screenX = v[i]->unk28 * 0.25f;
+ f32 screenY = v[i]->unk2c * 0.25f;
+ f32 ndcX = 2.0f * ((screenX - l) / (r - l)) - 1.0f;
+ f32 ndcY = 1.0f - 2.0f * ((screenY - t) / (b - t));
+ f32 w = v[i]->unk0c;
+
+ fv[i].x = ndcX * w;
+ fv[i].y = ndcY * w;
+ fv[i].z = 0.0f;
+ fv[i].w = w;
+ fv[i].s = v[i]->unk20 - foldS;
+ fv[i].t = v[i]->unk24 - foldT;
+ fv[i].r = (u8) v[i]->r;
+ fv[i].g = (u8) v[i]->g;
+ fv[i].b = (u8) v[i]->b;
+ fv[i].a = (u8) v[i]->a;
+ }
+
+ gSPClearGeometryMode(gdl++, G_LIGHTING | G_CULL_BOTH | G_FOG);
+ gSPSetGeometryMode(gdl++, G_SHADE | G_SHADING_SMOOTH);
+ gSPFloatVertexExt(gdl++, fv, nverts, 0);
+
+ if (nverts >= 4)
+ {
+ gSP2Triangles(gdl++, 0, 1, 3, 0, 3, 2, 0, 0);
+ }
+ else
+ {
+ gSP1Triangle(gdl++, 0, 1, 2, 0);
+ }
+
+ return gdl;
+ }
+
guMtxIdentF(projf.m);
/* row = input axis (matches this codebase's row-vector * M convention,
* e.g. sub_GAME_7F097388 / gfx_pc.cpp's own MP_matrix use):
@@ -2039,7 +2093,7 @@ static Gfx *skyPortRenderPoly(Gfx *gdl, SkyRelated38 **v, s32 nverts)
* one sky quad spans ~30,000 texels in S and T, but tc is S10.5, so
* (S - foldS) * 32 only holds +/-1024 texels. Same env-gate style as
* the GE_D176 probes above. */
- if (getenv("GE_D227V")) {
+ if (GE_ENVFLAG("GE_D227V")) {
static int n = 0;
if (n++ < 200)
fprintf(stderr, "D227V poly nv=%d i=%d w=%.1f 1/w=%.6g sx=%.1f sy=%.1f S=%.1f T=%.1f "
diff --git a/src/libultra/audio/csplayer.c b/src/libultra/audio/csplayer.c
index e4669d4a..3da596ee 100644
--- a/src/libultra/audio/csplayer.c
+++ b/src/libultra/audio/csplayer.c
@@ -47,6 +47,9 @@
#include "seqp.h"
#include "cseqp.h"
#include "cseq.h"
+#ifdef PORT
+#include "envflag.h" /* cached getenv for hot-path probes */
+#endif
static ALMicroTime __CSPVoiceHandler(void *node);
@@ -461,7 +464,7 @@ static void __CSPHandleMIDIMsg(ALCSPlayer *seqp, ALEvent *event)
alSynAllocVoice(seqp->drvr, voice, &config);
#ifdef PORT
- if (getenv("GE_AUDIOTRACE"))
+ if (GE_ENVFLAG("GE_AUDIOTRACE"))
geTracePrintf("audiotrace.log", "[MUSICNOTE] csp=%p key=%d vel=%d\n",
(void *)seqp, (int)key, (int)vel);
#endif
diff --git a/src/libultra/audio/event.c b/src/libultra/audio/event.c
index ddec4090..3dbb1f70 100644
--- a/src/libultra/audio/event.c
+++ b/src/libultra/audio/event.c
@@ -22,6 +22,7 @@
#include
#include
#ifdef PORT
+#include /* getenv() prototype (D324 class: no implicit int decl) */
#include "audiotrace.h" /* D202/M-71: evtq drop probe */
#endif
diff --git a/src/libultra/audio/load.c b/src/libultra/audio/load.c
index d3d5520e..154440f1 100644
--- a/src/libultra/audio/load.c
+++ b/src/libultra/audio/load.c
@@ -27,6 +27,9 @@
#include
#include
#include "audiotrace.h" /* D202/M-70: serialized trace writer */
+#ifdef PORT
+#include "envflag.h" /* cached getenv for hot-path probes */
+#endif
#endif
#ifndef MIN
@@ -391,7 +394,7 @@ alLoadParam(void *filter, s32 paramID, void *param)
case (AL_FILTER_SET_WAVETABLE):
a->table = (ALWaveTable *) param;
#if defined(__x86_64__)
- if (getenv("GE_AUDIOTRACE")) {
+ if (GE_ENVFLAG("GE_AUDIOTRACE")) {
extern uint64_t sysGetMicroseconds(void); /* port/src/system.c */
geTracePrintf("audiotrace_wire.log", "[WIRE] t=%llu filter=%p <- table=%p base=%p len=%d book=%p\n",
(unsigned long long)sysGetMicroseconds(),
@@ -399,7 +402,7 @@ alLoadParam(void *filter, s32 paramID, void *param)
a->table->len,
(a->table->type == AL_ADPCM_WAVE) ? (void *)a->table->waveInfo.adpcmWave.book : NULL);
}
- if (getenv("GE_MIXERTRACE")) {
+ if (GE_ENVFLAG("GE_MIXERTRACE")) {
static FILE *btf = NULL;
if (!btf) { btf = fopen("mixertrace.log", "a"); if (btf) setvbuf(btf, NULL, _IONBF, 0); }
if (btf) fprintf(btf, "[BINDTABLE] filter=%p <- table=%p base=%p len=%d book=%p\n",
@@ -494,7 +497,7 @@ Acmd *_decodeChunk(Acmd *ptr, ALLoadFilter *f, s32 tsam, s32 nbytes, s16 outp, s
if (nbytes > 0){
#if defined(__x86_64__)
- if (getenv("GE_MIXERTRACE")) {
+ if (GE_ENVFLAG("GE_MIXERTRACE")) {
static FILE *dtf = NULL;
if (!dtf) { dtf = fopen("mixertrace.log", "a"); if (dtf) setvbuf(dtf, NULL, _IONBF, 0); }
if (dtf) fprintf(dtf, "[DMAREQ] filter=%p memin=0x%08x nbytes=%d\n",
diff --git a/src/snd.c b/src/snd.c
index 09b57d14..2fa125df 100644
--- a/src/snd.c
+++ b/src/snd.c
@@ -7,6 +7,9 @@
#include
#include
#include "audiotrace.h" /* D202/M-70: serialized trace writer */
+#ifdef PORT
+#include "envflag.h" /* cached getenv for hot-path probes */
+#endif
#endif
//likely named gslibaudio.c from xbla
/**
@@ -417,7 +420,7 @@ void sndHandleEvent(ALSndPlayer *sndp, ALSndpEvent *event) {
}
iterState = (ALSoundState *) iterState->link.prev;
} while (limitReached && iterState != NULL);
- else if (getenv("GE_D305")) {
+ else if (GE_ENVFLAG("GE_D305")) {
/* D305: log the desync state for a future occurrence --
* confirms whether Game.AllUnlocked correlates or was
* coincidental, and whether it recurs at all now that the
@@ -500,7 +503,7 @@ void sndHandleEvent(ALSndPlayer *sndp, ALSndpEvent *event) {
expireEvt.common.type = AL_SNDP_PORT_EXPIRE_EVT;
expireEvt.common.state = soundState;
alEvtqPostEvent(&sndp->evtq, (ALEvent *) &expireEvt, D202_EXPIRE_DELAY_US);
- if (getenv("GE_AUDIOTRACE")) {
+ if (GE_ENVFLAG("GE_AUDIOTRACE")) {
geTracePrintf("audiotrace.log", "[EXPIRE] sound=%p state=%p delay=%dus fade=%dus (ownerless infinite loop; D202/M-66)\n",
(void *)sound, (void *)soundState, (int)D202_EXPIRE_DELAY_US, (int)D202_EXPIRE_FADE_US);
}
diff --git a/tools_pc/bg_gdl_census.py b/tools_pc/bg_gdl_census.py
new file mode 100644
index 00000000..56f4ec1f
--- /dev/null
+++ b/tools_pc/bg_gdl_census.py
@@ -0,0 +1,250 @@
+#!/usr/bin/env python3
+"""D236 pass 22: offline census of a level's bg room display lists.
+
+Decodes `bg/bg__all_p.seg` straight out of the ROM and walks every
+room's primary and secondary GDL, reporting the distinct render-mode
+(`G_SETOTHERMODE_L`, i.e. the `oml` words the live `GE_D2xx` probes log) and
+texture-image classes each room draws. This answers "which bg draw class
+carries the trees, and which room draws it" without building or running the
+game.
+
+Pipeline (mirrors `bg.c` exactly):
+ * the .seg file is stored UNCOMPRESSED in ROM (obLoadBGFileBytesAtOffset
+ random-accesses it by byte offset); only the per-room blobs inside it are
+ deflated;
+ * word[1] of the decompressed file is the segment offset of the
+ `bg_room_data` array (`bg.c:865`), 24 bytes/record:
+ u32 pPointTableBin, u32 pPriMappingBin, u32 pSecMappingBin, coord3d pos;
+ * a room's compressed DL size is the distance to the *next* non-zero
+ mapping-bin offset, taking primary and secondary as one interleaved
+ stream (`bg.c:944-990` + `getPri/SecMappingBinCount`);
+ * each blob is itself raw-deflate behind its own 2-byte header
+ (`bgDecompress` -> `decompressdata`).
+
+The words reported are the ones **as authored in the file**, i.e. BEFORE
+`bgApplyDynamicCCRMLUT` rewrites them at room load. The `->` column shows
+what the applied LUT turns each one into, so the output can be compared
+directly against what the runtime probes log. Only LUTs 1/5/6/7 are ever
+applied (`bg.c:2797/2804/2812/2819`); see D236 pass 21.
+
+Usage: python tools_pc/bg_gdl_census.py [sevx] [ntsc-final]
+"""
+import collections
+import contextlib
+import io
+import os
+import struct
+import sys
+import zlib
+
+import rendermode
+import texheader
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(ROOT, "tools_pc"))
+
+G_SETOTHERMODE_L = 0xB9
+G_SETTIMG = 0xFD
+G_NOOP = 0xC0
+G_ENDDL = 0xB8
+G_DL = 0xDE
+# gsDPSetRenderMode's w0: SETOTHERMODE_L, shift 3, length 29 -> the render-mode
+# field. Other 0xB9 commands (alpha compare, z source) use different shifts
+# and are counted separately.
+RENDERMODE_W0 = 0xB900031D
+
+IM_FMT = {0: "RGBA", 1: "YUV", 2: "CI", 3: "IA", 4: "I"}
+IM_SIZ = {0: "4b", 1: "8b", 2: "16b", 3: "32b"}
+
+
+def load_rom_file(name, region):
+ """Decompress one ROM file table entry by name."""
+ sys.argv = ["", region]
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ import d43_emit as m
+ key, row = m.find_row(name)
+ if not key:
+ raise SystemExit("not in ROM file table: %s" % name)
+ addr, size = row
+ raw = m.rom[addr:addr + size]
+ # Model files are raw-deflate behind a 2-byte 0x1172 header, but the bg
+ # .seg files are stored UNCOMPRESSED in ROM -- they have to be, because
+ # obLoadBGFileBytesAtOffset() random-accesses them by byte offset to pull
+ # one room's blob at a time. Only the per-room blobs inside are deflated.
+ try:
+ return zlib.decompress(raw[2:], -15)
+ except zlib.error:
+ return raw
+
+
+def seg_off(v):
+ """Segment-0x0F address -> file offset (bg.c's BG_SEG_TO_PTR)."""
+ return v & 0x00FFFFFF
+
+
+def read_rooms(data):
+ """Parse the bg_room_data array: [(point, pri, sec, pos), ...]."""
+ list_off = seg_off(struct.unpack_from(">I", data, 4)[0])
+ rooms = []
+ off = list_off
+ while off + 24 <= len(data):
+ point, pri, sec = struct.unpack_from(">III", data, off)
+ pos = struct.unpack_from(">fff", data, off + 12)
+ rooms.append((point, pri, sec, pos))
+ # The array is terminated by the sentinel record whose pPriMappingBin
+ # is 0 AND which is not just an interior room with no primary DL, so
+ # walk until all three offsets are zero.
+ if len(rooms) > 1 and point == 0 and pri == 0 and sec == 0:
+ break
+ off += 24
+ return rooms, list_off
+
+
+def next_nonzero(rooms, start, field):
+ i = start
+ while i < len(rooms) and rooms[i][field] == 0:
+ i += 1
+ return i if i < len(rooms) else None
+
+
+def blob_sizes(rooms, i):
+ """csize_primary / csize_secondary for room i, per bg.c:944-990."""
+ _point, pri, sec, _pos = rooms[i]
+ csz_pri = csz_sec = 0
+ if pri:
+ pidx = next_nonzero(rooms, i + 1, 1)
+ sidx = next_nonzero(rooms, i, 2)
+ if pidx is not None and (sidx is None or pidx <= sidx):
+ csz_pri = seg_off(rooms[pidx][1]) - seg_off(pri)
+ elif sidx is not None:
+ csz_pri = seg_off(rooms[sidx][2]) - seg_off(pri)
+ if sec:
+ pidx = next_nonzero(rooms, i + 1, 1)
+ sidx = next_nonzero(rooms, i + 1, 2)
+ if pidx is not None and (sidx is None or pidx <= sidx):
+ csz_sec = seg_off(rooms[pidx][1]) - seg_off(sec)
+ elif sidx is not None:
+ csz_sec = seg_off(rooms[sidx][2]) - seg_off(sec)
+ return csz_pri, csz_sec
+
+
+def inflate_blob(data, off, size):
+ if size <= 2:
+ return None
+ try:
+ return zlib.decompress(data[off + 2:off + size], -15)
+ except zlib.error as e:
+ return ("ERR", str(e))
+
+
+def walk_gdl(gdl):
+ """Walk an 8-byte big-endian N64 Gfx stream.
+
+ Returns (rendermodes, textures, opcodes, ncmd) where rendermodes maps an
+ oml word to how many draw-setup commands used it, and textures maps
+ (fmt, siz) to the set of image addresses set while it was active.
+ """
+ rms = collections.Counter()
+ texs = collections.Counter()
+ ops = collections.Counter()
+ pairs = collections.Counter()
+ cur_rm = None
+ n = len(gdl) // 8
+ for k in range(n):
+ w0, w1 = struct.unpack_from(">II", gdl, k * 8)
+ op = w0 >> 24
+ ops[op] += 1
+ if op == G_SETOTHERMODE_L and w0 == RENDERMODE_W0:
+ cur_rm = w1
+ rms[w1] += 1
+ elif op == G_NOOP:
+ # bg room DLs never carry G_SETTIMG -- they reference textures by
+ # NUMBER in a G_NOOP, which texLoadFromGdl() resolves into real
+ # texture commands at room load (tex.c:779, `case G_NOOP:
+ # texnum = in->words.w1 & 0xfff`).
+ texs[w1 & 0xFFF] += 1
+ pairs[(cur_rm, w1 & 0xFFF)] += 1
+ return rms, texs, ops, pairs, n
+
+
+def report(level, region):
+ name = "bg_%s_all_p" % level # file-table basename (no dir/.seg)
+ data = load_rom_file(name, region)
+ print("%s: %d bytes decompressed" % (name, len(data)))
+ rooms, list_off = read_rooms(data)
+ print("room table at file offset 0x%x, %d records\n" % (list_off, len(rooms)))
+
+ all_rms = collections.Counter()
+ all_pairs = collections.Counter()
+ rm_rooms = collections.defaultdict(set)
+ tex_rooms = collections.defaultdict(set)
+ bad = 0
+ for i in range(1, len(rooms)):
+ csz_pri, csz_sec = blob_sizes(rooms, i)
+ for label, off_raw, csz in (("pri", rooms[i][1], csz_pri),
+ ("sec", rooms[i][2], csz_sec)):
+ if not off_raw or csz <= 0:
+ continue
+ out = inflate_blob(data, seg_off(off_raw), csz)
+ if out is None:
+ continue
+ if isinstance(out, tuple):
+ bad += 1
+ print("room %3d %s: INFLATE FAILED at 0x%x size %d (%s)"
+ % (i, label, seg_off(off_raw), csz, out[1]))
+ continue
+ rms, texs, ops, pairs, ncmd = walk_gdl(out)
+ all_rms.update(rms)
+ all_pairs.update(pairs)
+ for w in rms:
+ rm_rooms[w].add(i)
+ for t in texs:
+ tex_rooms[t].add(i)
+ print("room %3d %s: csize=%-6d usize=%-6d cmds=%-5d rm=%d tex=%d %s"
+ % (i, label, csz, len(out), ncmd, len(rms), sum(texs.values()),
+ " ".join("0x%08x" % w for w in sorted(rms))))
+
+ print("\n%s" % ("=" * 78))
+ rm = rendermode.RenderModes()
+ luts = rendermode.applied_luts(rm)
+ print("RENDER-MODE CENSUS (as authored in the file, pre-LUT)")
+ print(" the '->' column is what bgApplyDynamicCCRMLUT rewrites the word to at")
+ print(" room load when fog is on (LUT 1 primary / LUT 5 secondary) -- i.e. the")
+ print(" value a live GE_D2xx probe would log. With fog OFF, LUTs 6/7 rewrite")
+ print(" COMBINE modes only, so render modes stay exactly as authored.")
+ print("=" * 78)
+ for w, n in all_rms.most_common():
+ rr = sorted(rm_rooms[w])
+ to = luts.get(1, {}).get(w) or luts.get(5, {}).get(w)
+ print(" 0x%08x -> %-12s %4d setups %3d rooms rooms=%s"
+ % (w, ("0x%08x" % to) if to else "(unchanged)", n, len(rr),
+ ",".join(map(str, rr[:14])) + ("..." if len(rr) > 14 else "")))
+ print(" %s" % " | ".join(rm.decode(w)["flags"]))
+
+ print("\n%s" % ("=" * 78))
+ print("TEXTURES PER RENDER-MODE CLASS (texture numbers from G_NOOP w1)")
+ print(" ROM header decoded offline; 'live' is what a runtime probe prints.")
+ print("=" * 78)
+ rom, trows = texheader.load(region)
+ bytex = collections.defaultdict(collections.Counter)
+ for (w, t), _n in all_pairs.items():
+ bytex[w][t] = len(tex_rooms[t])
+ for w, _n in all_rms.most_common():
+ print(" rm=0x%08x" % w)
+ for t, nrooms in bytex[w].most_common():
+ try:
+ desc = texheader.describe(t, rom, trows)
+ except Exception:
+ desc = "tex%-5d (not in image list)" % t
+ print(" %2d rooms %s" % (nrooms, desc))
+
+ if bad:
+ print("\n%d blob(s) failed to inflate -- size derivation may be off for those."
+ % bad)
+
+
+if __name__ == "__main__":
+ lvl = sys.argv[1] if len(sys.argv) > 1 else "sevx"
+ reg = sys.argv[2] if len(sys.argv) > 2 else "ntsc-final"
+ report(lvl, reg)
diff --git a/tools_pc/bg_lod_audit.py b/tools_pc/bg_lod_audit.py
new file mode 100644
index 00000000..c71f8ca8
--- /dev/null
+++ b/tools_pc/bg_lod_audit.py
@@ -0,0 +1,143 @@
+#!/usr/bin/env python3
+"""LOD-binding audit: which bg room draw classes combine G_TL_LOD (other_mode_H
+bit 16) with a texture that has AUTHORED mip levels (explicit-LOD header,
+imagelist control byte bit 7).
+
+Why this matters (D236-class follow-up): on N64, `texWriteTileLods` places each
+authored LOD level at successive TMEM addresses (tile n @ cumulative size) and
+the RDP's G_TL_LOD sampling selects the coarser tile as geometry minifies.
+The port folds every G_TL_LOD draw to tile 0 (D107, with the D236 type-1
+exception) and lets GL generate driver mips from the finest level instead. For
+ordinary textures that is a subtle approximation; for 1-bit-alpha cut-outs it
+is the D236 smear mechanism via a different binding type.
+
+GE's GBI dialect (modified fast3d, see gmain.s): G_SETOTHERMODE_L = 0xB9 with
+w0 bits 8-15 = shift, bits 0-7 = num_bits; OML is shift 3 / 29 bits
+(0xB900031D), OMH is shift 16 / 16 bits (0xBA001010).
+
+Usage: python tools_pc/bg_lod_audit.py [level ...] (no args: all levels)
+"""
+import collections
+import io
+import os
+import struct
+import sys
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(ROOT, "tools_pc"))
+
+import bg_gdl_census as census
+import texheader as th
+
+G_NOOP = 0xC0
+
+
+def walk_lod_binds(gdl):
+ """Yield (oml, omh, texnum, type) for every G_NOOP texture bind.
+
+ GE's modified fast3d encodes G_SETOTHERMODE_L/H (0xB9/0xBA) as partial
+ field writes: w0 bits 8-15 = shift, bits 0-7 = num_bits. For OMH the value
+ sits at its ABSOLUTE bit position in w1 (fast3d calls
+ gfx_sp_set_other_mode(shift+32, nbits, w1<<32) -- an identity map, see
+ gfx_pc.cpp:3707-3708): e.g. w1=0x00010000 sets TEXTLOD (bit 16),
+ w1=0x00040000 sets TEXTDETAIL=G_TD_DETAIL (bits 17-18), w1=0x00100000
+ sets CYCLETYPE=G_CYC_2CYCLE (bit 20). For OML fast3d ORs w1 in at bits
+ 3-31; the raw w1 is what probes log as `oml`, so keep it as the class key.
+ The texture TYPE of a G_NOOP bind is w0 & 7 (tex.c `switch (in->words.w0
+ & 7)`); texnum = w1 & 0xfff.
+ """
+ cur_oml, cur_omh = 0, 0
+ n = len(gdl) // 8
+ for k in range(n):
+ w0, w1 = struct.unpack_from(">II", gdl, k * 8)
+ op = w0 >> 24
+ if op == 0xB9:
+ cur_oml = (cur_oml & 7) | w1
+ elif op == 0xBA:
+ shift = (w0 >> 8) & 0xFF
+ mask = ((1 << (w0 & 0xFF)) - 1) << shift
+ cur_omh = (cur_omh & ~mask) | (w1 & mask)
+ elif op == G_NOOP:
+ yield cur_oml, cur_omh, w1 & 0xFFF, w0 & 7
+
+
+def main():
+ region = "ntsc-final"
+ levels = sys.argv[1:] or None
+ rom, rows = th.load(region)
+
+ import contextlib
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ import d43_emit as m
+ bg_files = sorted(k for k in m.fl_by_base if k.startswith("bg_") and k != "bgx")
+ if levels:
+ bg_files = ["bg_%s_all_p" % l for l in levels]
+
+ grand = collections.Counter()
+ alpha_hits = []
+ for name in bg_files:
+ level = name[3:-6] # strip 'bg_' and '_all_p'
+ try:
+ data = census.load_rom_file(name, region)
+ except SystemExit as e:
+ print("%s: %s" % (name, e))
+ continue
+ if not data:
+ print("%-28s (empty file-table entry -- level reuses another bg file)"
+ % name)
+ continue
+ rooms, _ = census.read_rooms(data)
+ lod_explicit = collections.Counter() # texnum -> binds under LOD-on
+ lod_total = 0
+ type1_explicit = collections.Counter()
+ for i in range(1, len(rooms)):
+ csz_pri, csz_sec = census.blob_sizes(rooms, i)
+ for off_raw, csz in ((rooms[i][1], csz_pri), (rooms[i][2], csz_sec)):
+ if not off_raw or csz <= 0:
+ continue
+ out = census.inflate_blob(data, census.seg_off(off_raw), csz)
+ if out is None or isinstance(out, tuple):
+ continue
+ for oml, omh, texnum, ttype in walk_lod_binds(out):
+ if omh is None or not (omh & (1 << 16)):
+ continue
+ lod_total += 1
+ off, size = rows[texnum]
+ blob = rom[off:off + size]
+ if not blob:
+ continue
+ d = th.parse(blob[:5])
+ if d["explicit_lods"]:
+ lod_explicit[texnum] += 1
+ if ttype == 1:
+ type1_explicit[texnum] += 1
+ n_exp = sum(lod_explicit.values())
+ print("%-28s LOD-on binds=%-5d explicit-LOD texnums=%-3d binds=%-4d"
+ % (name, lod_total, len(lod_explicit), n_exp))
+ if lod_explicit:
+ detail = []
+ for t, c in lod_explicit.most_common(12):
+ off, size = rows[t]
+ d = th.parse(rom[off:off + 5][:5])
+ tag = " T1" if t in type1_explicit else ""
+ detail.append("tex%d(%s x%d%s)" % (t, d["format_name"], d["lods"] + 1, tag))
+ print(" " + " ".join(detail))
+ grand[level] = (lod_total, n_exp)
+ for t in lod_explicit:
+ off, size = rows[t]
+ d = th.parse(rom[off:off + 5][:5])
+ if d["gbi_fmt"] == 0 and d["gbi_siz"] == 2:
+ alpha_hits.append((level, t))
+
+ print("\nlevels with explicit-LOD textures bound under G_TL_LOD:")
+ for lvl, (tot, n) in sorted(grand.items()):
+ if n:
+ print(" %-8s %d/%d LOD-on binds" % (lvl, n, tot))
+ print("\nRGBA5551 (1-bit alpha) explicit-LOD textures bound under G_TL_LOD:")
+ for lvl, t in sorted(set(alpha_hits)):
+ print(" %-8s tex%d" % (lvl, t))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools_pc/bg_portals.py b/tools_pc/bg_portals.py
new file mode 100644
index 00000000..796cdfa7
--- /dev/null
+++ b/tools_pc/bg_portals.py
@@ -0,0 +1,188 @@
+#!/usr/bin/env python3
+"""D294: offline dump + structural analysis of a level's bg portal graph.
+
+Decodes `bg/bg__all_p.seg` straight out of the ROM (same pipeline as
+bg_gdl_census.py) and walks the portal table that `bg.c` uses for its
+per-frame room-visibility walk (`bgDetermineVisibleRooms` ->
+`sub_GAME_7F0B7F84` / `sub_GAME_7F0B5864`).
+
+File layout (decompressed .seg, big-endian):
+ word[0] format flag (0 = normal path)
+ word[1] offset of the bg_room_data array (24 B/record; see bg_gdl_census)
+ word[2] offset of the g_BgPortals table: 8-byte records
+ u32 offset_portal (segment-0x0F file offset of the point data,
+ 0 = end of table)
+ u8 connectedRoom1
+ u8 connectedRoom2
+ u8 controlbytes1 (bit0 PORTALFLAG_DISABLED, bit1 _SPECIAL)
+ u8 controlbytes2
+ word[3] envdata table offset (not needed here)
+
+Point data at `offset_portal & 0x00FFFFFF` (bg_portal_entry):
+ u8 numPoints; u8 pad[3]; then numPoints * coord3d (f32 x,y,z).
+
+The analysis section checks the walk's hard limits against the graph:
+ * portal count vs PORTMAX (200, src/game/bg.h)
+ * undirected BFS depth from every room vs the walk's `depth >= 16` /
+ D_8004489C (=0xF) cut-offs (sub_GAME_7F0B7F84)
+ * per-room portal degree vs the `bgIncrementRoomPortalVisitCount >= 9`
+ enqueue drop (bgQueuePortalTraversal, depth >= 2)
+ * worst-case single-frame enqueue volume vs BG_PORTAL_QUEUE_LEN (500 NTSC /
+ 250 EU) -- a rough upper bound: every portal enqueued once per endpoint
+ room visit.
+
+Usage: python tools_pc/bg_portals.py stat [ntsc-final]
+"""
+import os
+import struct
+import sys
+import collections
+import contextlib
+import io
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(ROOT, "tools_pc"))
+
+PORTMAX = 200
+QUEUE_LEN_NTSC = 500
+QUEUE_LEN_EU = 250
+
+
+def load_bg_file(name, region):
+ sys.argv = ["", region]
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ import d43_emit as m
+ key, row = m.find_row("bg_%s_all_p" % name)
+ if not key:
+ raise SystemExit("not in ROM file table: bg_%s_all_p" % name)
+ addr, size = row
+ raw = m.rom[addr:addr + size]
+ import zlib
+ try:
+ return zlib.decompress(raw[2:], -15)
+ except zlib.error:
+ return raw
+
+
+def seg_off(v):
+ return v & 0x00FFFFFF
+
+
+def main():
+ level = sys.argv[1] if len(sys.argv) > 1 else "stat"
+ region = sys.argv[2] if len(sys.argv) > 2 else "ntsc-final"
+ data = load_bg_file(level, region)
+
+ w0, roomlist_off, portal_off = struct.unpack_from(">III", data, 0)
+ print("word0=%#x rooms@%#x portals@%#x filelen=%d" % (w0, seg_off(roomlist_off), seg_off(portal_off), len(data)))
+
+ # --- room array (24 B records; same walk as bg_gdl_census.read_rooms) ---
+ nrooms = 0
+ off = seg_off(roomlist_off)
+ while off + 24 <= len(data):
+ point, pri, sec = struct.unpack_from(">III", data, off)
+ if nrooms > 1 and point == 0 and pri == 0 and sec == 0:
+ break
+ nrooms += 1
+ off += 24
+ print("rooms: %d" % nrooms)
+
+ # --- portal table (8 B records, N64 stride; offset_portal==0 terminates) ---
+ portals = []
+ poff = seg_off(portal_off)
+ while poff + 8 <= len(data):
+ optr, r1, r2, cb1, cb2 = struct.unpack_from(">IBBBB", data, poff)
+ if optr == 0:
+ break
+ portals.append((optr & 0x00FFFFFF, r1, r2, cb1, cb2))
+ poff += 8
+ print("portals: %d (PORTMAX=%d%s)" % (len(portals), PORTMAX, " <-- OVER" if len(portals) >= PORTMAX else ""))
+
+ # --- point data per portal ---
+ adj = collections.defaultdict(list) # room -> [portal index]
+ bad = 0
+ for i, (poff2, r1, r2, cb1, cb2) in enumerate(portals):
+ if poff2 + 4 > len(data):
+ print("portal %d: point data %#x past EOF" % (i, poff2))
+ bad += 1
+ continue
+ npts = data[poff2]
+ pts = struct.unpack_from(">%df" % (3 * npts), data, poff2 + 4) if npts else ()
+ for r in (r1, r2):
+ adj[r].append(i)
+ if bad:
+ print("WARNING: %d portals with unreadable point data" % bad)
+
+ # flag census
+ flags = collections.Counter()
+ for i, (_, _, _, cb1, _) in enumerate(portals):
+ f = []
+ if cb1 & 0x01:
+ f.append("DIS")
+ if cb1 & 0x02:
+ f.append("SPE")
+ flags["+".join(f) or "-"] += 1
+ print("flags:", dict(flags))
+
+ # point-count census (sub_GAME_7F0B5864's points[19] holds numPoints +
+ # near-plane clip points; >12 authored points is close to the edge)
+ ptc = collections.Counter()
+ for i, (poff2, _, _, _, _) in enumerate(portals):
+ if poff2 + 4 <= len(data):
+ ptc[data[poff2]] += 1
+ print("numPoints census:", dict(sorted(ptc.items())))
+
+ # per-room degree vs the visit-count limit of 9
+ hot = [(r, len(ps)) for r, ps in sorted(adj.items()) if len(ps) >= 9]
+ if hot:
+ print("rooms with >=9 portals (visit-count drop risk at depth>=2): %s" % hot)
+ else:
+ print("max room portal degree: %d (limit 9)" % max((len(p) for p in adj.values()), default=0))
+
+ # undirected BFS min-depth from every room
+ rooms = sorted(adj.keys())
+ depth_from = {}
+ for s in rooms:
+ d = {s: 0}
+ q = collections.deque([s])
+ while q:
+ u = q.popleft()
+ for pi in adj[u]:
+ _, r1, r2, _, _ = portals[pi]
+ v = r1 ^ r2 ^ u
+ if v not in d:
+ d[v] = d[u] + 1
+ q.append(v)
+ depth_from[s] = d
+ worst = []
+ for s in rooms:
+ d = depth_from[s]
+ missing = [r for r in rooms if r not in d]
+ maxd = max(d.values()) if d else -1
+ if maxd >= 15 or missing:
+ worst.append((s, maxd, missing))
+ if worst:
+ print("rooms unreachable (<16) from some start room (depth>=15 or disconnected):")
+ for s, maxd, missing in worst:
+ print(" from room %d: maxdepth=%d missing=%s" % (s, maxd, missing))
+ else:
+ allmax = max(max(d.values()) for d in depth_from.values())
+ print("all rooms pairwise reachable; max BFS depth over all start rooms: %d (walk cut-off 16)" % allmax)
+
+ # worst-case enqueue volume: each portal can be enqueued once per frame
+ # from each endpoint room's visit; the walk visits a room at most once per
+ # portal path... conservative bound = 2 * nportals.
+ print("conservative enqueue bound 2*P = %d vs queue %d (NTSC) / %d (EU)" %
+ (2 * len(portals), QUEUE_LEN_NTSC, QUEUE_LEN_EU))
+
+ # dump the full portal table for eyeballing
+ print()
+ print("%-4s %-6s %-6s %-4s %-4s %-3s points" % ("idx", "room1", "room2", "cb1", "cb2", "n"))
+ for i, (poff2, r1, r2, cb1, cb2) in enumerate(portals):
+ npts = data[poff2] if poff2 + 4 <= len(data) else -1
+ print("%-4d %-6d %-6d %#-5x %#-5x %-3d" % (i, r1, r2, cb1, cb2, npts))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools_pc/d43_convert.py b/tools_pc/d43_convert.py
index acdb562e..34899d6f 100644
--- a/tools_pc/d43_convert.py
+++ b/tools_pc/d43_convert.py
@@ -154,9 +154,10 @@ for r in rows:
if nv and puo:
dstpos = add_region(puo, 2*nv, dstpos) # PointUsage s16 x numVertices
elif op == 22:
+ # D303: op-22 count = star-flash arms; 4 vertices each (see d43_emit).
nv = struct.unpack_from(">i", src, data)[0]; vo = be32o(src, data+4)
if nv and vo:
- vtx_regions.append((vo, nv)); dstpos = add_region(vo, 16*nv, dstpos)
+ vtx_regions.append((vo, 4*nv)); dstpos = add_region(vo, 16*4*nv, dstpos)
# zero-count vertex arrays: size up to the next higher object offset
obj_offs_now = sorted(set([no for no,_ in nodes]) | set(rec_newoff.keys()) |
diff --git a/tools_pc/d43_emit.py b/tools_pc/d43_emit.py
index 194361e6..0b20764b 100644
--- a/tools_pc/d43_emit.py
+++ b/tools_pc/d43_emit.py
@@ -343,9 +343,14 @@ def process(name):
dstpos = add_region(puo, 2 * nv, dstpos)
op24_pointusage[puo] = nv
elif op == 22:
+ # D303 (M-201): the op-22 count is the number of star-flash ARMS
+ # (quads), not vertices -- dorottex (model.c) and PD's
+ # modelRenderNodeStarGunfire read count*4 vertices. Sizing this as
+ # 16*nv copied only the first quarter, so the rest of each muzzle
+ # star rendered from neighbouring bytes (long spikes, M16 etc.).
nv = struct.unpack_from(">i", src, data)[0]; vo = be32o(src, data + 4)
if nv and vo:
- vtx_regions.append((vo, nv)); dstpos = add_region(vo, 16 * nv, dstpos)
+ vtx_regions.append((vo, 4 * nv)); dstpos = add_region(vo, 16 * 4 * nv, dstpos)
# All record-referenced GDLs (needed before zero-vtx/blob layout so the
# "next object" boundaries include them).
@@ -402,8 +407,11 @@ def process(name):
merged[-1][1] = max(merged[-1][1], iv[1])
else:
merged.append(iv)
+ blob_regions = [] # (old_off, old_size, new_off) raw pixel spans
for (t, e) in merged:
+ n = dstpos
dstpos = add_region(t, e - t, dstpos)
+ blob_regions.append((t, e - t, n))
# GDLs last, tight-packed 16B slots. Visited GDLs go in VISIT order (the
# compaction span arithmetic requires it). Referenced-but-unvisited GDLs
@@ -669,9 +677,17 @@ def process(name):
j += 1
o += 8
+ # Embedded image blobs: raw pixel copy (the RDP reads them as-is; no
+ # transform). Layout reserves the span and remaps G_SETTIMG w1 to it, but
+ # without this copy the buffer stays zeroed and the texture renders black
+ # (M-197 / D75: PnintendologoZ white wordmark invisible on PC).
+ for (o, osz, n) in blob_regions:
+ buf[n:n + osz] = src[o:o + osz]
+
# ---- round-trip validation ----
validate(name, src, D, NS, NT, nodes, R0, placed, all_gdls, buf, D_PC,
- regions, node_newoff, rec_newoff, gdl_newoff, remap, inv_remap)
+ regions, blob_regions, node_newoff, rec_newoff, gdl_newoff,
+ remap, inv_remap)
# ---- compress ----
co = zlib.compressobj(6, zlib.DEFLATED, -15)
@@ -693,9 +709,15 @@ op24_is_collision = {}
op24_pointusage = {}
def validate(name, src, D, NS, NT, nodes, R0, placed, all_gdls, buf, D_PC,
- regions, node_newoff, rec_newoff, gdl_newoff, remap, inv_remap):
+ regions, blob_regions, node_newoff, rec_newoff, gdl_newoff,
+ remap, inv_remap):
def err(m): errors.append(f"{name}: RT {m}")
+ # embedded image blobs: raw identity round-trip (M-197)
+ for (o, osz, n) in blob_regions:
+ if bytes(buf[n:n + osz]) != bytes(src[o:o + osz]):
+ err(f"blob {o:#x}+{osz:#x}: pixel bytes differ at new {n:#x}")
+
# tiling: regions cover [0, D_PC) exactly
rs = sorted(regions, key=lambda x: x[2])
pos = 0
diff --git a/tools_pc/framediff.py b/tools_pc/framediff.py
index bd6fdcc6..caba350f 100644
--- a/tools_pc/framediff.py
+++ b/tools_pc/framediff.py
@@ -51,8 +51,9 @@ threshold, 1 = at least one frame/region over threshold, 2 = usage / IO
error (e.g. a golden frame with no candidate).
Regenerate the golden set (after a deliberate visual change):
- GE_PCDUMP="200-445" ./build-pc/ge007.x86_64.exe -level_09 -ml0 -me0 \
- -mgfx100 -mvtx50 -mt700 -ma150
+ GE_PCDUMP="640-1120:240" ./build-pc/ge007.x86_64.exe -level_09
+ (with data/ge007.ini = only [Window] Width=640 Height=480; see
+ tools_pc/golden/README.md)
python tools_pc/framediff.py ppm --update
"""
import argparse
diff --git a/tools_pc/gen_env_probes.py b/tools_pc/gen_env_probes.py
index 0cc7eb9a..15aa6358 100644
--- a/tools_pc/gen_env_probes.py
+++ b/tools_pc/gen_env_probes.py
@@ -4,8 +4,9 @@
That doc is a hand-curated table of every `GE_*` env probe -- var, file:line,
what it does, live/dead. It was last snapshotted by a manual grep and drifts
as probes are added/removed. This script does NOT regenerate the prose (the
-curation is the value); it re-greps the live `getenv("GE_...")` sites and
-reports:
+curation is the value); it re-greps the live env-read sites — bare `getenv("GE_...")` plus the
+`GE_ENVFLAG("GE_...")` / `GE_ENVSTR("GE_...")` wrappers from
+`port/include/envflag.h` — and reports:
* NEW -- a var with live sites that the doc never names
* GONE -- a var the doc names that has no live site any more
* a fresh var -> file:line site map (paste into the doc's File:line cells)
@@ -23,7 +24,10 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DOC = ROOT / "docs" / "dev" / "GE-ENV-PROBES.md"
SCAN_DIRS = ["src", "port"]
-GETENV_RE = re.compile(r'getenv\(\s*"(GE_[A-Z0-9_]+)"\s*\)')
+# envflag.h's GE_ENVFLAG/GE_ENVSTR are the cached-env wrappers most port code
+# uses; matching only bare getenv() reported every wrapper-read var as GONE.
+GETENV_RE = re.compile(
+ r'(?:getenv|GE_ENVFLAG|GE_ENVSTR)\(\s*"(GE_[A-Z0-9_]+)"\s*\)')
def live_sites() -> dict[str, list[str]]:
@@ -49,7 +53,13 @@ def doc_vars() -> tuple[set[str], set[str]]:
return set(), set()
named, tombstone = set(), set()
for line in DOC.read_text(encoding="utf-8").splitlines():
- vs = re.findall(r"GE_[A-Z0-9_]+", line)
+ # `GE_FOO[` in prose is a probe's LOG-LINE TAG, not an env var -- several
+ # probes print a tag that is not the name of any getenv() (e.g.
+ # GE_TEXDUMP_ADDR prints "GE_TEXA[...]"). Counting those as documented
+ # vars reports a permanent phantom GONE. A row for a real var always
+ # names it plainly in the table's first cell, so it is still picked up.
+ vs = [m.group(1) for m in re.finditer(r"(GE_[A-Z0-9_]+)(\[?)", line)
+ if not m.group(2)]
named.update(vs)
if "tombstone" in line.lower():
tombstone.update(vs)
diff --git a/tools_pc/golden/README.md b/tools_pc/golden/README.md
index 58ad5120..e2d71774 100644
--- a/tools_pc/golden/README.md
+++ b/tools_pc/golden/README.md
@@ -1,19 +1,41 @@
# Golden baseline frames
`frame_NNNNNN.png` — reference `GE_PCDUMP` captures for `framediff.py`
-(`-level_09` BUNKER1 intro pan). Compared structurally, not exactly — the port
+(`-level_09` BUNKER1: settled gameplay after the intro). Compared structurally, not exactly — the port
is not frame-deterministic (see `docs/internals.md` "D117").
Regenerate after a deliberate visual change:
```sh
-GE_PCDUMP="200-440:120" ./build-pc/ge007.x86_64.exe -level_09
-python tools_pc/framediff.py ppm --update # then rm any stray *.png it copied
+# data/ge007.ini must hold ONLY this (back yours up) — every other option at
+# its compiled-in default; a personal FovScale/DrawDistance/MSAA fails the gate
+printf '[Window]
+Width = 640
+Height = 480
+' > data/ge007.ini
+GE_PCDUMP="640-1120:240" ./build-pc/ge007.x86_64.exe -level_09
+python tools_pc/framediff.py ppm --update # then git rm any stale stems
```
+`tools_pc/verify.sh bunker1` does the ini pin + capture + diff for you.
+
## History
- **D168 (2026-09-01):** the `GE_PCDUMP` PPM writer emitted `glReadPixels` rows
unreversed, so every capture — and this baseline set — was vertically
flipped. Writer fixed (`port/fast3d/gfx_opengl.cpp`); this set regenerated
from a fresh, correctly-oriented `-level_09` run.
+- **2026-09-23 (v0.4.0):** re-baselined. The 2026-09-09 (D215) set was captured
+ while the port still rendered at 30 fps (2 sim ticks per frame); D248 moved
+ it to 60 fps (1 tick/frame), so frame N now lands at half the sim time, and
+ frames 200/320/440 fell in the intro cutscene, whose camera is
+ nondeterministic (D117); two fresh captures disagreed at 440. The window
+ moved to `640-1120:240`, the same sim ticks the original set sampled.
+ Two independent captures of it agree 3/3. The capture ini is now
+ defaults-only, and `verify.sh` pins it the same way (it used to rewrite
+ only Width/Height, which kept personal settings).
+- **2026-09-23 (later):** window moved again, `400-880:240` → `640-1120:240`.
+ Frame 400 is the last beat of the intro cutscene (Bond in the shaft) and
+ its camera is nondeterministic (D117): 1 in ~5 gate runs failed it (dmean
+ 0.7 vs 20.7 run to run). All three frames are now settled gameplay; three
+ independent captures agree (worst dmean ≤ 6.8).
diff --git a/tools_pc/golden/frame_000200.png b/tools_pc/golden/frame_000200.png
deleted file mode 100644
index ec76888e..00000000
Binary files a/tools_pc/golden/frame_000200.png and /dev/null differ
diff --git a/tools_pc/golden/frame_000320.png b/tools_pc/golden/frame_000320.png
deleted file mode 100644
index dfefdea5..00000000
Binary files a/tools_pc/golden/frame_000320.png and /dev/null differ
diff --git a/tools_pc/golden/frame_000440.png b/tools_pc/golden/frame_000440.png
deleted file mode 100644
index 3f06f3a8..00000000
Binary files a/tools_pc/golden/frame_000440.png and /dev/null differ
diff --git a/tools_pc/golden/frame_000640.png b/tools_pc/golden/frame_000640.png
new file mode 100644
index 00000000..b89243e6
Binary files /dev/null and b/tools_pc/golden/frame_000640.png differ
diff --git a/tools_pc/golden/frame_000880.png b/tools_pc/golden/frame_000880.png
new file mode 100644
index 00000000..9a5dabf4
Binary files /dev/null and b/tools_pc/golden/frame_000880.png differ
diff --git a/tools_pc/golden/frame_001120.png b/tools_pc/golden/frame_001120.png
new file mode 100644
index 00000000..26fadafc
Binary files /dev/null and b/tools_pc/golden/frame_001120.png differ
diff --git a/tools_pc/golden_gate.sh b/tools_pc/golden_gate.sh
new file mode 100644
index 00000000..631d868d
--- /dev/null
+++ b/tools_pc/golden_gate.sh
@@ -0,0 +1,91 @@
+#!/usr/bin/env bash
+# golden_gate.sh - one-command per-patch visual gate.
+#
+# Pins data/ge007.ini to a defaults-only 640x480 window, captures Bunker 1
+# frames with GE_PCDUMP, and compares against the golden baseline with
+# tools_pc/framediff.py. The maintainer's ini is ALWAYS restored (EXIT/INT/TERM
+# trap); data/ge007.eep is never touched.
+#
+# Usage:
+# bash tools_pc/golden_gate.sh # golden compare (default)
+# bash tools_pc/golden_gate.sh --level 20 # custom level, no compare
+# bash tools_pc/golden_gate.sh --level 20 --dump 400-1000:600
+#
+# Custom mode (--level/--dump) skips the golden compare and just reports
+# frames captured + any 'Crash!' in the log.
+#
+# Exit codes: golden mode -> framediff.py's (0 pass, 1 over threshold,
+# 2 usage/IO). Custom mode -> 0 if frames captured and no 'Crash!', else 1.
+set -u
+
+REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$REPO_ROOT" || exit 2
+
+INI=data/ge007.ini
+LOG=scratch/golden_gate.log
+FRAMEDIFF_OUT=scratch/golden_gate.framediff
+LEVEL=09
+DUMP="640-1120:240"
+GOLDEN=1
+
+usage() { sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'; }
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --level) shift; LEVEL="${1:?--level needs a value}"; GOLDEN=0 ;;
+ --dump) shift; DUMP="${1:?--dump needs lo-hi:step}"; GOLDEN=0 ;;
+ -h|--help) usage; exit 0 ;;
+ *) echo "unknown arg: $1" >&2; usage; exit 2 ;;
+ esac
+ shift
+done
+
+case "$LEVEL" in *[!0-9]*|"") echo "--level must be numeric, got '$LEVEL'" >&2; exit 2;; esac
+case "$DUMP" in *[!0-9:-]*|:*|*::*) echo "--dump must look like lo-hi:step, got '$DUMP'" >&2; exit 2;; esac
+
+mkdir -p scratch
+
+# --- ini pin + guaranteed restore -------------------------------------------
+INI_EXISTED=1
+[ -f "$INI" ] || INI_EXISTED=0
+BAK="$(mktemp)" || { echo "mktemp failed" >&2; exit 2; }
+if [ "$INI_EXISTED" = 1 ]; then cp "$INI" "$BAK"; fi
+
+restore_ini() {
+ if [ "$INI_EXISTED" = 1 ]; then
+ cp "$BAK" "$INI"
+ else
+ rm -f "$INI"
+ fi
+ rm -f "$BAK"
+}
+trap restore_ini EXIT
+trap 'exit 130' INT
+trap 'exit 143' TERM
+
+printf '[Window]\nWidth = 640\nHeight = 480\n' > "$INI"
+
+# --- run ---------------------------------------------------------------------
+rm -rf ppm
+# D344: quit cleanly 30 frames after the last capture (GE_QUITFRAME) instead
+# of being killed by `timeout`; killing the game mid-frame is the likely
+# trigger of the host's 0x119 bugchecks. timeout stays as a backstop only.
+DUMP_HI=${DUMP#*-}; DUMP_HI=${DUMP_HI%%:*}
+PATH="/c/msys64/mingw64/bin:$PATH" GE_PCDUMP="$DUMP" GE_QUITFRAME=$((DUMP_HI + 30)) \
+ timeout 120 ./build-pc/ge007.x86_64.exe "-level_$LEVEL" > "$LOG" 2>&1
+
+if [ "$GOLDEN" = 1 ]; then
+ python tools_pc/framediff.py ppm > "$FRAMEDIFF_OUT" 2>&1
+ RC=$?
+ tail -n 1 "$FRAMEDIFF_OUT"
+ rm -rf ppm
+ exit $RC
+else
+ N=0
+ [ -d ppm ] && N=$(ls ppm | wc -l)
+ CRASH=no
+ grep -q 'Crash!' "$LOG" && CRASH=yes
+ echo "level_$LEVEL dump=$DUMP frames=$N crash=$CRASH (log: $LOG)"
+ rm -rf ppm
+ if [ "$N" -gt 0 ] && [ "$CRASH" = no ]; then exit 0; else exit 1; fi
+fi
diff --git a/tools_pc/rendermode.py b/tools_pc/rendermode.py
new file mode 100644
index 00000000..3ff93609
--- /dev/null
+++ b/tools_pc/rendermode.py
@@ -0,0 +1,225 @@
+#!/usr/bin/env python3
+"""D236 pass 22: evaluate N64 render-mode words offline, straight from the repo.
+
+Two jobs:
+
+1. `decode(word)` -- turn a raw `G_SETOTHERMODE_L` value (the `oml` the live
+ `GE_D2xx` probes print) into the flag names and blender equation it stands
+ for, and name it if it matches a `G_RM_*` composite in `include/PR/gbi.h`.
+
+2. `applied_luts()` -- evaluate the `DL_LUT_*` tables in `src/game/bg.c` into
+ plain (from -> to) word maps, for the four LUT indices `bg.c` actually
+ applies at room load (1/5/6/7). This is what lets a *static* census of a
+ bg file's authored render modes be compared against the words a *live*
+ probe logs, which are the post-rewrite ones.
+
+Everything is parsed from the real headers/sources rather than hardcoded, so
+it cannot drift from the build. No C compiler required.
+"""
+import os
+import re
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+GBI_H = os.path.join(ROOT, "include", "PR", "gbi.h")
+BG_C = os.path.join(ROOT, "src", "game", "bg.c")
+
+# Order matters for readable decoding: mask-style fields first.
+_FIELDS = [
+ ("CVG_DST", 0x300, {0x000: "CVG_DST_CLAMP", 0x100: "CVG_DST_WRAP",
+ 0x200: "CVG_DST_FULL", 0x300: "CVG_DST_SAVE"}),
+ ("ZMODE", 0xC00, {0x000: "ZMODE_OPA", 0x400: "ZMODE_INTER",
+ 0x800: "ZMODE_XLU", 0xC00: "ZMODE_DEC"}),
+]
+_BITS = [(0x8, "AA_EN"), (0x10, "Z_CMP"), (0x20, "Z_UPD"), (0x40, "IM_RD"),
+ (0x80, "CLR_ON_CVG"), (0x1000, "CVG_X_ALPHA"),
+ (0x2000, "ALPHA_CVG_SEL"), (0x4000, "FORCE_BL")]
+
+_BL_P = {0: "CLR_IN", 1: "CLR_MEM", 2: "CLR_BL", 3: "CLR_FOG"}
+_BL_A = {0: "A_IN/1MA", 1: "A_FOG/A_MEM", 2: "A_SHADE/1", 3: "0"}
+
+
+def _flag_defines():
+ """Simple `#define NAME ` pairs from gbi.h."""
+ out = {}
+ for line in open(GBI_H, encoding="utf-8", errors="replace"):
+ m = re.match(r"#define\s+(\w+)\s+(0x[0-9a-fA-F]+|\d+)\s*(/\*.*)?$", line)
+ if m:
+ out[m.group(1)] = int(m.group(2), 0)
+ return out
+
+
+def _macro_bodies():
+ """`#define NAME(args) body` and `#define NAME body` with continuations."""
+ src = open(GBI_H, encoding="utf-8", errors="replace").read()
+ src = re.sub(r"/\*.*?\*/", " ", src, flags=re.S)
+ src = src.replace("\\\n", " ")
+ fn, obj = {}, {}
+ for line in src.split("\n"):
+ m = re.match(r"#define\s+(\w+)\(([^)]*)\)\s+(.*)$", line)
+ if m:
+ fn[m.group(1)] = ([a.strip() for a in m.group(2).split(",")], m.group(3).strip())
+ continue
+ m = re.match(r"#define\s+(\w+)\s+(.+)$", line)
+ if m:
+ obj[m.group(1)] = m.group(2).strip()
+ return fn, obj
+
+
+class RenderModes(object):
+ def __init__(self):
+ self.flags = _flag_defines()
+ self.fn, self.obj = _macro_bodies()
+ self.by_name = {}
+ for name in self.obj:
+ if not name.startswith("G_RM_"):
+ continue
+ try:
+ v = self.eval_name(name)
+ except Exception:
+ continue
+ if v is not None:
+ self.by_name[name] = v & 0xFFFFFFFF
+ self.by_value = {}
+ for n, v in self.by_name.items():
+ self.by_value.setdefault(v, []).append(n)
+
+ def _expand(self, text, depth=0):
+ if depth > 12:
+ raise ValueError("macro recursion")
+ # GBL_c1/GBL_c2 are the only function-like macros we need.
+ def gbl(m):
+ which, args = m.group(1), [a.strip() for a in m.group(2).split(",")]
+ vals = [self._value(a) for a in args]
+ sh = (30, 26, 22, 18) if which == "1" else (28, 24, 20, 16)
+ return "(%d)" % sum(v << s for v, s in zip(vals, sh))
+ prev = None
+ while prev != text:
+ prev = text
+ text = re.sub(r"GBL_c([12])\s*\(([^()]*)\)", gbl, text)
+ return text
+
+ def _value(self, tok):
+ tok = tok.strip()
+ if re.match(r"^(0x[0-9a-fA-F]+|\d+)$", tok):
+ return int(tok, 0)
+ if tok in self.flags:
+ return self.flags[tok]
+ if tok in self.obj:
+ return self.eval_name(tok)
+ raise ValueError("unknown token %r" % tok)
+
+ def eval_name(self, name, _depth=0):
+ if _depth > 12:
+ raise ValueError("recursion")
+ body = self.obj[name]
+ # G_RM_FOO -> RM_FOO(1) / G_RM_FOO2 -> RM_FOO(2)
+ m = re.match(r"^(RM_\w+)\((\d)\)$", body)
+ if m:
+ macro, clk = m.group(1), m.group(2)
+ if macro not in self.fn:
+ return None
+ _args, mbody = self.fn[macro]
+ mbody = mbody.replace("GBL_c##clk", "GBL_c%s" % clk)
+ return self._eval_expr(mbody)
+ return self._eval_expr(body)
+
+ def _eval_expr(self, expr):
+ expr = self._expand(expr)
+ expr = re.sub(r"\b([A-Za-z_]\w*)\b",
+ lambda m: str(self._value(m.group(1))), expr)
+ return eval(expr, {"__builtins__": {}}, {}) & 0xFFFFFFFF # noqa: S307
+
+ def decode(self, word):
+ word &= 0xFFFFFFFF
+ low = word & 0xFFFF
+ parts = [n for b, n in _BITS if low & b]
+ for _label, mask, table in _FIELDS:
+ parts.append(table[low & mask])
+ c1 = "(%s*%s + %s*%s)" % (_BL_P[(word >> 30) & 3], _BL_A[(word >> 26) & 3],
+ _BL_P[(word >> 22) & 3], _BL_A[(word >> 18) & 3])
+ c2 = "(%s*%s + %s*%s)" % (_BL_P[(word >> 28) & 3], _BL_A[(word >> 24) & 3],
+ _BL_P[(word >> 20) & 3], _BL_A[(word >> 16) & 3])
+ names = self.by_value.get(word, [])
+ return {"word": word, "flags": parts, "cyc1": c1, "cyc2": c2, "names": names}
+
+ def name_of(self, word):
+ n = self.by_value.get(word & 0xFFFFFFFF, [])
+ return "/".join(n) if n else ""
+
+
+# ---------------------------------------------------------------- bg.c LUTs
+
+LUT_NAMES = {1: "DL_LUT_PRIMARY_ADDFOG", 5: "DL_LUT_SECONDARY_ADDFOG",
+ 6: "DL_LUT_PRIMARY", 7: "DL_LUT_SECONDARY"}
+
+
+def _lut_entries(body, rm):
+ """Evaluate a DL_LUT_* initializer body into a flat list of (w0, w1)."""
+ body = re.sub(r"//[^\n]*", " ", body)
+ body = re.sub(r"/\*.*?\*/", " ", body, flags=re.S)
+ words = []
+ for tok in re.finditer(r"gsDPSetRenderMode\s*\(([^,]+),([^)]+)\)|(0x[0-9a-fA-F]+|\b0\b)",
+ body):
+ if tok.group(1):
+ a = rm._eval_expr(tok.group(1).strip())
+ b = rm._eval_expr(tok.group(2).strip())
+ words.append((0xB900031D, (a | b) & 0xFFFFFFFF))
+ else:
+ words.append(("RAW", int(tok.group(3), 0)))
+ # Flatten raw scalar runs into (w0, w1) pairs the same way C brace-elision
+ # fills Gfx.words.w0/w1.
+ out, pend = [], []
+ for kind, val in words:
+ if kind == "RAW":
+ pend.append(val)
+ if len(pend) == 2:
+ out.append((pend[0], pend[1]))
+ pend = []
+ else:
+ if pend:
+ out.append((pend[0], 0))
+ pend = []
+ out.append((kind, val))
+ if pend:
+ out.append((pend[0], 0))
+ return out
+
+
+def applied_luts(rm=None):
+ """{lut_index: {from_word: to_word}} for the LUTs bg.c actually applies."""
+ rm = rm or RenderModes()
+ src = open(BG_C, encoding="utf-8", errors="replace").read()
+ maps = {}
+ for idx, name in LUT_NAMES.items():
+ m = re.search(r"Gfx\s+%s\[\]\s*=\s*\{(.*?)\n\};" % re.escape(name), src, re.S)
+ if not m:
+ continue
+ ents = _lut_entries(m.group(1), rm)
+ pairs = {}
+ # (match, replacement) pairs, terminated by an entry with w0 == 0.
+ i = 0
+ while i + 1 < len(ents) and ents[i][0] != 0:
+ (aw0, aw1), (bw0, bw1) = ents[i], ents[i + 1]
+ if aw0 == 0xB900031D and bw0 == 0xB900031D:
+ pairs[aw1] = bw1
+ i += 2
+ maps[idx] = pairs
+ return maps
+
+
+if __name__ == "__main__":
+ import sys
+ rm = RenderModes()
+ if len(sys.argv) > 1:
+ for a in sys.argv[1:]:
+ d = rm.decode(int(a, 16))
+ print("0x%08x %s" % (d["word"], "/".join(d["names"]) or "(no G_RM_* match)"))
+ print(" flags: %s" % " | ".join(d["flags"]))
+ print(" cyc1 %s cyc2 %s" % (d["cyc1"], d["cyc2"]))
+ else:
+ print("known G_RM_* composites: %d" % len(rm.by_name))
+ for idx, pairs in sorted(applied_luts(rm).items()):
+ print("\n%s (index %d): %d rewrite pairs" % (LUT_NAMES[idx], idx, len(pairs)))
+ for a, b in sorted(pairs.items()):
+ print(" 0x%08x %-26s -> 0x%08x %s"
+ % (a, rm.name_of(a), b, rm.name_of(b)))
diff --git a/tools_pc/scan_op12.py b/tools_pc/scan_op12.py
new file mode 100644
index 00000000..5ceb0350
--- /dev/null
+++ b/tools_pc/scan_op12.py
@@ -0,0 +1,51 @@
+#!/usr/bin/env python3
+"""D236 pass 19 (TEMP): count op-12 (GunfireRecord / dogfnegx billboard) and
+op-13 (ShadowRecord / doshadow) nodes per model file, and dump op-12 record
+fields (Offset/Size/Image) for files that contain them. Remove once D236 concludes."""
+import struct, sys, os
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+sys.argv = [sys.argv[0], "ntsc-final"] if len(sys.argv) < 2 else sys.argv
+import d43_emit as m
+
+def plaus_vma(v):
+ return v == 0 or (v >> 24) == 5
+
+counts12, counts13 = {}, {}
+for name in m.model_names:
+ key, row = m.find_row(name)
+ if not key:
+ continue
+ addr, size = row
+ src = __import__("zlib").decompress(m.rom[addr:addr + size][2:], -15)
+ D = len(src)
+ hkey = None
+ for cand in (key, key[1:], key[:-1], key[1:-1]):
+ if cand in m.nsnt:
+ hkey = cand
+ break
+ if not hkey:
+ continue
+ NS, NT = m.nsnt[hkey]
+ nodes, R0 = m.build_nodes(src, D, NS, NT)
+ if not nodes:
+ continue
+ placed = m.placement_order(nodes, src, R0)
+ n12 = n13 = 0
+ for (no, op) in placed:
+ if op == 12:
+ n12 += 1
+ d = m.be32o(src, no + 4)
+ raw = src[d:d + 0x28]
+ ox, oy, oz = struct.unpack_from(">fff", raw, 0)
+ sx, sy, sz = struct.unpack_from(">fff", raw, 12)
+ img = struct.unpack_from(">I", raw, 24)[0] & 0xFFFFFF
+ print(f"{name}: op12 node={no:#x} rec={d:#x} offset=({ox:.1f},{oy:.1f},{oz:.1f}) size=({sx:.1f},{sy:.1f},{sz:.1f}) img={img:#08x} vma?{plaus_vma(img)}")
+ elif op == 13:
+ n13 += 1
+ if n12 or n13:
+ print(f"== {name}: op12={n12} op13={n13}")
+ counts12[name] = n12
+ counts13[name] = n13
+
+print(f"\ntotal files with op12: {len(counts12)}, total op12 nodes: {sum(counts12.values())}")
+print(f"total files with op13: {len(counts13)}, total op13 nodes: {sum(counts13.values())}")
diff --git a/tools_pc/scan_op13.py b/tools_pc/scan_op13.py
new file mode 100644
index 00000000..f90beed3
--- /dev/null
+++ b/tools_pc/scan_op13.py
@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+"""D236 pass 17c (TEMP): dump every op-13 (ShadowRecord) record's raw bytes
+from the ROM model files, to settle the field-layout question (does the ROM
+put HeaderNode at +0x14 as the C struct says, or +0x18 as d43_emit.py's emit
+assumes?). Prints per file: node offset, record offset, and the 16 bytes at
+d+0x10..d+0x20 with both interpretations annotated. Remove once D236 concludes."""
+import struct, sys, os
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+sys.argv = [sys.argv[0], "ntsc-final"] if len(sys.argv) < 2 else sys.argv
+import d43_emit as m
+
+def plaus_vma(v):
+ # file-relative seg-5 vma: 0x05xxxxxx, or null
+ return v == 0 or (v >> 24) == 5
+
+def plaus_f32(b):
+ f = struct.unpack_from(">f", b)[0]
+ return abs(f) < 1e6 and f == f # finite, sane magnitude
+
+n_found = 0
+for name in m.model_names:
+ key, row = m.find_row(name)
+ if not key:
+ continue
+ addr, size = row
+ src = __import__("zlib").decompress(m.rom[addr:addr + size][2:], -15)
+ D = len(src)
+ hkey = None
+ for cand in (key, key[1:], key[:-1], key[1:-1]):
+ if cand in m.nsnt:
+ hkey = cand
+ break
+ if not hkey:
+ continue
+ NS, NT = m.nsnt[hkey]
+ nodes, R0 = m.build_nodes(src, D, NS, NT)
+ if not nodes:
+ continue
+ placed = m.placement_order(nodes, src, R0)
+ for (no, op) in placed:
+ if op != 13:
+ continue
+ n_found += 1
+ d = m.be32o(src, no + 4)
+ raw = src[d + 0x10:d + 0x20]
+ v_10 = struct.unpack_from(">I", raw, 0)[0] & 0xFFFFFF
+ v_14 = struct.unpack_from(">I", raw, 4)[0] & 0xFFFFFF
+ v_18 = struct.unpack_from(">I", raw, 8)[0] & 0xFFFFFF
+ v_1c = struct.unpack_from(">I", raw, 12)[0] & 0xFFFFFF
+ f_18 = struct.unpack_from(">f", raw, 8)[0]
+ f_1c = struct.unpack_from(">f", raw, 12)[0]
+ print(f"{name}: node={no:#x} rec={d:#x}")
+ print(f" +0x10 image = {v_10:#08x} vma?{plaus_vma(v_10)}")
+ print(f" +0x14 (A:Header) = {v_14:#08x} vma?{plaus_vma(v_14)}")
+ print(f" +0x18 (B:Header) = {v_18:#08x} vma?{plaus_vma(v_18)} as f32={f_18!r}")
+ print(f" +0x1C (A:Scale) = as f32={f_1c!r} raw={v_1c:#08x} vma?{plaus_vma(v_1c)}")
+print(f"\ntotal op13 records: {n_found}")
diff --git a/tools_pc/scan_setup_props.py b/tools_pc/scan_setup_props.py
new file mode 100644
index 00000000..402dc762
--- /dev/null
+++ b/tools_pc/scan_setup_props.py
@@ -0,0 +1,96 @@
+#!/usr/bin/env python3
+"""D236 pass 21: list the PROP_* models a level's setup actually places.
+
+Parses the PROP enum out of src/bondconstants.h and the object records out of
+one or more assets/obseg/setup/Usetup*Z.c files, then reports which props each
+setup instantiates. Used to decide, statically, whether a given piece of level
+scenery can possibly be a prop model at all -- if the setup places none of the
+candidate models, the geometry must come from the level's bg file instead.
+
+Note: only the record types that carry a real ObjectRecord header (StandardProp,
+Door, Collectable, ...) have a meaningful `obj` field; Guard/Tag rows reuse the
+same word layout for something else and are reported but should not be trusted
+as model placements.
+
+Usage: python tools_pc/scan_setup_props.py [setup.c ...]
+ (default: UsetupsevxZ.c UsetupsevxbZ.c -- Surface 1 / Surface 2)
+"""
+import collections
+import os
+import re
+import sys
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+CONSTANTS = os.path.join(ROOT, "src", "bondconstants.h")
+SETUP_DIR = os.path.join(ROOT, "assets", "obseg", "setup")
+
+# One object record: the PropDefHeaderRecord word, then _mkword(obj, pad).
+RECORD_RE = re.compile(
+ r"/\* Type = (\w+); index = \d+ \*/\s*"
+ r"_mkword\(\s*-?\d+,\s*_mkshort\(\s*-?\d+,\s*-?\d+\s*\)\s*\),\s*"
+ r"_mkword\(\s*(-?\d+)\s*,")
+
+FOLIAGE_RE = re.compile(r"PROP_(JUNGLE\d*_TREE|PALM|PALMTREE|PLANT\d*B?)$")
+
+
+def load_prop_enum(path=CONSTANTS):
+ lines = open(path, encoding="utf-8", errors="replace").read().split("\n")
+ # Exact match only: "typedef enum PROPFLAG" / "PROPDEF_TYPE" / "PROP_TYPE"
+ # all share the prefix, and picking one of those silently yields an enum
+ # with no PROP_* members at all (every obj then resolves to "?").
+ i = next(n for n, l in enumerate(lines) if l.strip() == "typedef enum PROP")
+ while "{" not in lines[i]:
+ i += 1
+ i += 1
+ val, out = 0, {}
+ while not lines[i].strip().startswith("}"):
+ m = re.match(r"(PROP_[A-Z0-9_]+)\s*(?:=\s*(-?\w+))?\s*,?", lines[i].strip())
+ if m:
+ if m.group(2):
+ val = int(m.group(2), 0)
+ out[val] = m.group(1)
+ val += 1
+ i += 1
+ return out
+
+
+def scan(path, enum):
+ txt = open(path, encoding="utf-8", errors="replace").read().replace("\r", "")
+ counts = collections.Counter()
+ for m in RECORD_RE.finditer(txt):
+ counts[(m.group(1), int(m.group(2)))] += 1
+ declared = txt.count("/* Type = ")
+ return counts, declared
+
+
+def main(argv):
+ enum = load_prop_enum()
+ # Self-test / positive control: a "0 foliage" result is only meaningful if
+ # the detector can fire at all. Print the foliage props the enum defines
+ # and that FOLIAGE_RE recognises, so a zero count below reads as "not
+ # placed", never as "the matcher never matched anything".
+ known = sorted(n for n in enum.values() if FOLIAGE_RE.match(n))
+ print("PROP enum: %d entries; foliage props recognised (%d): %s"
+ % (len(enum), len(known), ", ".join(known)))
+ if not known:
+ print("ERROR: foliage detector matches nothing -- results below are meaningless")
+ return 1
+ names = argv[1:] or ["UsetupsevxZ.c", "UsetupsevxbZ.c"]
+ for name in names:
+ path = name if os.path.exists(name) else os.path.join(SETUP_DIR, os.path.basename(name))
+ counts, declared = scan(path, enum)
+ print("=" * 70)
+ print(os.path.basename(path), "-- %d object records matched of %d total records"
+ % (sum(counts.values()), declared))
+ foliage = 0
+ for (typ, obj), n in sorted(counts.items(), key=lambda kv: -kv[1]):
+ nm = enum.get(obj, "?")
+ tag = " <== FOLIAGE" if FOLIAGE_RE.match(nm) else ""
+ if tag:
+ foliage += n
+ print(" %4d %-22s obj=%4d %s%s" % (n, typ, obj, nm, tag))
+ print(" FOLIAGE PROPS PLACED: %d" % foliage)
+
+
+if __name__ == "__main__":
+ main(sys.argv)
diff --git a/tools_pc/sdl_drain_monitor.c b/tools_pc/sdl_drain_monitor.c
new file mode 100644
index 00000000..baa843e0
--- /dev/null
+++ b/tools_pc/sdl_drain_monitor.c
@@ -0,0 +1,36 @@
+/* sdl_drain_monitor: is the host audio device draining at real time?
+ *
+ * Opens SDL queued audio exactly like port/src/audio.c (22050 Hz, S16,
+ * stereo, 512 samples), keeps the queue deep with silence, and measures the
+ * drain rate per 1 s window against the performance counter. No game, no
+ * window. Windows of +-2.3% are one 512-frame pull landing either side of a
+ * window edge (granularity, ignore); a sustained run of lower values means the
+ * device/driver/audio engine is falling behind, and the game's audio queue
+ * will overflow (D322: a Focusrite USB interface dipped to 88% for ~15 s
+ * every ~312 s, the whole "garbled audio" signature, with no game running).
+ *
+ * Build (MSYS2 MINGW64):
+ * gcc -O2 tools_pc/sdl_drain_monitor.c -o sdl_drain_monitor.exe * $(sdl2-config --cflags --libs | sed 's/-mwindows//') -lwinmm
+ * Run: ./sdl_drain_monitor.exe [seconds, default 660]
+ */
+#include
+#include
+#include
+static SDL_AudioDeviceID d; static volatile long long pushed=0; static volatile int run=1;
+static int prod(void*a){(void)a; static Sint16 b[735*2];
+ while(run){ if(SDL_GetQueuedAudioSize(d)/4<8000){SDL_QueueAudio(d,b,735*4); pushed+=735;} SDL_Delay(5);} return 0;}
+int main(int c,char**v){ int secs=c>1?atoi(v[1]):660; timeBeginPeriod(1); SDL_Init(SDL_INIT_AUDIO);
+ SDL_AudioSpec w={0},h; w.freq=22050;w.format=AUDIO_S16SYS;w.channels=2;w.samples=512;
+ d=SDL_OpenAudioDevice(NULL,0,&w,&h,0);
+ printf("device=%s driver=%s\n",SDL_GetAudioDeviceName(0,0),SDL_GetCurrentAudioDriver()); fflush(stdout);
+ SDL_PauseAudioDevice(d,0); SDL_Thread*t=SDL_CreateThread(prod,"p",0); SDL_Delay(1000);
+ Uint64 f=SDL_GetPerformanceFrequency(), t0=SDL_GetPerformanceCounter(), tp=t0;
+ long long p0=pushed; Uint32 q0=SDL_GetQueuedAudioSize(d)/4; double mn=1e9,mx=0,sum=0; int n=0,bad=0;
+ for(int s=1;s<=secs;s++){ SDL_Delay(1000);
+ Uint64 now=SDL_GetPerformanceCounter(); long long p=pushed; Uint32 q=SDL_GetQueuedAudioSize(d)/4;
+ double el=(double)(now-tp)/f, rate=((double)(p-p0)+(double)q0-(double)q)/el;
+ if(ratemx)mx=rate; sum+=rate;n++;
+ if(rate<22002*0.98||rate>22002*1.02){bad++; printf("t=%ds drain=%.0f frames/s (%.1f%%) q=%u\n",s,rate,100*rate/22002,q); fflush(stdout);}
+ tp=now;p0=p;q0=q; }
+ printf("done %ds: avg=%.1f min=%.0f max=%.0f anomalous_windows=%d\n",n,sum/n,mn,mx,bad);
+ run=0; SDL_WaitThread(t,0); return 0;}
diff --git a/tools_pc/sweep-captures/cap-m2-default/frame_000080.ppm b/tools_pc/sweep-captures/cap-m2-default/frame_000080.ppm
new file mode 100644
index 00000000..3bb5b606
Binary files /dev/null and b/tools_pc/sweep-captures/cap-m2-default/frame_000080.ppm differ
diff --git a/tools_pc/sweep-captures/cap-m2-default/frame_000120.ppm b/tools_pc/sweep-captures/cap-m2-default/frame_000120.ppm
new file mode 100644
index 00000000..56e3e795
Binary files /dev/null and b/tools_pc/sweep-captures/cap-m2-default/frame_000120.ppm differ
diff --git a/tools_pc/sweep-captures/cap-m2-default/frame_000160.ppm b/tools_pc/sweep-captures/cap-m2-default/frame_000160.ppm
new file mode 100644
index 00000000..4fedd549
Binary files /dev/null and b/tools_pc/sweep-captures/cap-m2-default/frame_000160.ppm differ
diff --git a/tools_pc/sweep-captures/cap-m2-default/frame_000200.ppm b/tools_pc/sweep-captures/cap-m2-default/frame_000200.ppm
new file mode 100644
index 00000000..e725fa74
Binary files /dev/null and b/tools_pc/sweep-captures/cap-m2-default/frame_000200.ppm differ
diff --git a/tools_pc/sweep-captures/cap-m2-default/frame_000240.ppm b/tools_pc/sweep-captures/cap-m2-default/frame_000240.ppm
new file mode 100644
index 00000000..4a946ae6
--- /dev/null
+++ b/tools_pc/sweep-captures/cap-m2-default/frame_000240.ppm
@@ -0,0 +1,19411 @@
+P6
+640 480
+255
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)- 0"1"1"1"1"/!- %
), 0"0"0"0"0". +#
+
$+/!/"0#0#0#0#/!.!'*, /!0#1#1#1#0#/!+#/!/!0"1#1#0"/!/!0"1#1#0"/"/")) (.!/"2#2#2#2#2#0"-!% &
+
+
+
+
+"*+
+
+!*."3$4%4%4%4%2#/#(
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
($!
+
&"
+
+
+
.!#@,&C.(G1(G1(G1(G1(G1'E0&C.8(."##0#">+%B.(F0(F0(F0(F0(G0&D.$@,4%&
#."5&$@-'D/(G1(G1(G1(G1(G1(F0&C/!;*/"* $@,%B.(F0)H2)H2)H2)H2(G1'E0$?,5&0"'E/(F0)G0)H1)H1)H1)G0)G0)H1)H1)H1)G0(F0'E/#>,#>,/#2$"<+(D0)F1*I2*I2*I2*I2*I2)G1&C. 7', " -!!;*,!
+
+
+
+"1$%@,0"
+
/"#=,'D/,J3,J3,J3,J3,J3*H2)F1";*/##
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ :&6%4"#
& 9&1!(
+
+
+
+
+
+
+
+
+
8_=O‚TM€SL~RJzPGwNGwNHxNIzOJ{PGwNDqJ0R6$B,4ZhEO‚UN‚TM€SK}QIyOHwNHwNIyOJ{PJzPFuLErKGvMHwNJzOK|PM~RPƒUTŠYU‹ZP„UM~RK}QJzOHwNGuLN€TN€TCoIDpJHwNL}QK|QK{PK{PK{PK{PK|QK|QK{PErK@jF.N3 :a?O�T>hE-M52%
+
+
+&5&6Z)'4"4X9PƒSknL}P)G.1
]˜`Ì�j¬nW�\K|QAlGAlGAmHJ{PS‰Yiªm}ÉZ”]N�Rl°pg¨kc¢hU‹ZFuLFuLFuLFuLEtLR‡W_›de¤ij¬nMPY’\{Æ~m°p[”`P„VIyOGuMGuMGuMFuLL~RTŠYa�ep¶tv¾y�Ѓu½xažfR‡XFuLAmHAmHFuLO‚U^šcs¹v}ÉDqJErKFtLJ{PR‡WažezÄ|~Ê€d¢hSˆXK}QGuLErKDpJ�Ѓ�Ѓ{Ç~w¿ze£iUŠZN€SHwNHwNHwNHwNIxOQ„VZ“_jªmw¾yV‹Y bŸd…Ö‡r¸u_›dGtM.O64&1$-M4VŒZ~Ê€\•^
+:b?Q…V^˜bj«m[“_JyOIwNJxOJxOIwNM}RQ„VW�[]—aDpH
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
#>(3V8MPg§iUŒYErI0R52"5X9N€RT‰WX�[:b@2""
`œc€Î‚[•`9`@%B.(()#@,3X;^™b‡Ù‰b d\–^Ì�g§kOƒU5[=2$2$4%2$0#/Q7FuL]—at»wUŒXb eˆÚŠj«nAmH-M59)3%4&3%3%%A.4YažeŠÝŒd£f`œb„Ô†f¦jHyO*J25&0R8CpJVŽ\@kDe¤g‹ß�g¨k8_@!;*$
.")H2K}QxÁz‡Ù‰†×ˆpµsL~R3W:!;*/"/" :).O6K|Qv¾yŠÞŒ
'#>+?iEk®or¸uDqJ#>+*‰ÛŠ‰ÛŠŒà�€Í‚P„V)G10##"=+6\=bŸfˆÙ‰c d e£f‰ÛŠtºwaže_šc[•`ErK0P66' 7'0P6Z’^…Õ‡ažc
+EqI_šccŸff¥jM}R1Q8/O60P70P7/O63U:9]?@iFIwN5Y:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
$?*=eAT‰WkmaždWŽZ]˜`d¢gGuK(F.,
aŸc‚Ñ„d£hHxN9aA,M4,M4-O59`@FuLiªm‹ÞŒe£f`œc…Õ‡e¤iFuL'E0"2$#@,-N5 :(e£f‹ÞŒf¦j5Z<4&
'%B.JzPwÀz‡Ù‰‡Øˆt»wVŽ\AmH3W:-M5-M53X;?iFW�\zÄ}‹ÞŒ !8'gCVŒ[d¢hs¹ve£iU‰YS‡XS‡XS‡XS‡XS‡XTˆYM}REqK1R7
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
"1!>hCbŸepµr€Î�i«lR‡V3U8#
+
+
+
b dƒÓ…l¯oV�[J{P@lG@lGAlGHyOR‡Wf¦jzÅ}X‘[`œc…Õ‡d¤iEsK%B.'1# ;), e£f‹ÞŒe¤i3X;1$
+%$@,HxOwÀz‡Ù‰‡Ù‰yÃ|ažfQ…WEtLAlGAlGEsKM€S\—aq·t{Å}6&;cAiªlo´s@kF6& ˆÚŠˆÚŠ‹ßŒ~Ë�M~S$>,&4%2U:aœdˆÙ‰c d ažd‚ЄW�[1R8?iFNSY�]bžeHvN0P62S94W;\–`„Ó…`œb
+2S7EqKW�[jªmd¡g]–a\•`\•`\”`\”`\•`]–aY�]S‡X+-N5:'e£f‹ÞŒe¤i3X;1$
+%$@,HxOwÀz‡Ù‰ˆÚŠ~Ê€m°pa�eX�]TŠYTŠYV�[Y’^\–a]˜b^™b
5&;cAiªlo´s@kF6&!ˆÚŠˆÚŠ‹ßŒ~Ë�M~S$>,&4%2U:aœdˆÙ‰c d bŸd‚ЄT‰Y)G1)G1*H2EqJ^—bY‘^UŠZJxO>gD`œdƒÒ…_›a
+ 8',K4fD?gE?gE@hE?gE?gEFrKNSg¦k‚Єažd
+
+
+
+
+
+
+
+
+
+
+
+
+,&A+ErJf¤hf¥ig¦jh§jh©kIyM(F..
aŸc‚Ñ„c¢hEtL5[=&D/'E0(F0'E0'E0#?,9(*_šaƒÒ…g¨kL}R/Q7&&&&%$A-8_?M€Sc¢hIyMe¤g‹ß�iªmfD6Y<,J3,I3,J3,J3,I34W;?gE_™c‚σb d
+
+
+
+
+
+
+
+
+
+
+
+
!,L2HvLT‰W_šbM}P:a?N�Rd¢gY’]MQ1S7'
_›a~Ë€U‹Z.P6/"[•]~Ë€h©lSˆX:bA!<*"=+#?,">+!<*6\=MSd£h|Ç~[•]b dˆÚ‰k®oEsK2V9%B-">+">+">+">++L39a@U‹YxÁz„Ó…„Ó…knBnH$@-
5&:bAg¨kn²q?jF6& ‡Ù‰‡Ù‰ˆÚŠÌ�Y‘^9`@.M5$?,$?,$?,$?,&C.4X;DqJg§k„Ô†`œb bŸd‚ЄR‡X%A.* -K4IwNbžezÄ}�σˆÚŠc¡e
+ErI`›dR†XDpJ4W;$=,$<+%>,$=,$<+-L49]?Z‘^Ì�aœb
+
+
+
+
+
+
+
+
+
+
+
#2"AjEd¢g\•_T‰W4X9&6Z;Y‘\bžei«lHvL#>)+
+
+
+ W�Zt¼wN�T*J2, K|Oh©lf¥jc¢hUŒ[HxNHxNIzOHyOHxNS‰Y`œde¤ii«mL~PV�YwÀzknZ”_Q†WK|PIyOIyOIyOIyON�SV�[a�en³rs¹vyÄ|b f=gC"=*
3%6[=`œef¦j;cB3%~Ê€~Ê€wÀzt»wc¢gU‹ZN�TJzPJzPJzPJzPK|QS‡X[”_h¨ltºvT‰V [”]yÂ|M~R$>,(
0#*G1DpJ_™co²r~Ê€\–^
+BmE[”_[”_\•`S‡XJzPJyOKzPJyOJyOO�TUŠZcŸfr¸uUŠW
+
+
+
+
+
+
+
+
+
+
+
%?)5Y:M~Qc fK|P4X9!:'"<(7\