Widescreen FOV Phase 4, D246/D247 overscan crop, D219 rainbow-spark fix

- Widescreen/FOV Phase 4: automatic vertical-FOV scale by window aspect
  ratio (Video.WidescreenAuto, default on), composed before the existing
  manual Video.FovScale multiplier. New symmetric FOV floor (20 deg)
  alongside the existing 160 deg ceiling.
- D247/D246: the game's own TV-overscan safe-area viewport inset (real on
  N64, hidden by CRT overscan, visible as black bars on PC with no crop)
  now gets cropped instead of shown -- gfx_adjust_viewport_or_scissor
  remaps against the actual captured gameplay viewport bounds (vertical)
  plus a measured, resolution-independent 1-logical-unit trim (horizontal)
  instead of the fixed VI canvas size. New Video.SafeAreaCrop toggle,
  default on.
- D219 (rainbow bullet-spark colors), a real 32->64-bit ABI bug:
  bullet_spark_render (glass2.c) read the spark's stored RGBA color via a
  hardcoded raw byte offset instead of the named s_bullet_spark struct
  field -- on 64-bit PC an earlier void* field's widening + alignment
  padding shifts the true offset by 4 bytes, so the read landed in
  unrelated f32 velocity fields instead. Fixed by reading the named
  fields. Also fixed an adjacent UB (a 16-byte Vtx read from a 4-byte
  global) in the same function. This turned out to be the actual root
  cause behind the D252 "lingering particle sprites after an explosion"
  report as well (same render path via objHit and friends) -- closed as
  a duplicate.
- Moved docs/dev/WIDESCREEN-FOV-PLAN.md to docs/dev/notes/ (gitignored
  local notes), matching this project's convention of keeping
  in-progress planning docs out of the tracked tree.

User live-verified all of the above interactively this session.
This commit is contained in:
James Dansereau committed 2026-09-18 01:10:27 -04:00
1 parent 814ecf5fce
commit 643785c2c5
7 files changed
+179 -342

No files matched your search

-328
View File
@@ -1,328 +0,0 @@
# Widescreen & FOV Plan
Status: **proposal, parked** — like `UNLOCKED-FPS-PLAN.md`, resume after audio
work (project Phase 3). Research current as of this writing.
Scope decision (agreed): **target exactly two display aspects — 4:3 and 16:9.**
Arbitrary aspect ratios are TBD and out of scope; the window manager should
snap/letterbox to one of the two rather than trying to serve anything else.
Standard (agreed): **Option B (native 16:9 render, PD-parity) is the goal.**
The PD PC port's widescreen architecture is the reference standard for this
engine family. Option A (4:3-internal + stretch) remains an acceptable
**case-by-case workaround** only where B proves impractical for a specific
surface — each such fallback documented individually.
## Problem
At high resolutions (e.g. 4K) in a 16:9 window the game is disorienting. Root
cause, confirmed in code: the port renders in *window-resolution space*. All
320×240 logical screen coordinates are scaled per-axis by
`RATIO_X = win_w/320`, `RATIO_Y = win_h/240` (`port/fast3d/gfx_pc.cpp:53-54`).
In a 16:9 window everything — world geometry **and** HUD — is stretched 4/3
horizontally: circles become ellipses, and the horizontal FOV balloons from
~78° to ~91° while vertical stays 60°. That stretch is the disorientation.
## Research findings
### 1. The FOV model (where FOV lives)
- Vertical FOV is a fixed 60° base: `FOV_Y_F` (`src/fr.h:137`); per-player
`fovy`/`aspect` fields default to 60 / `DEFAULT_ASPECT`
(`src/game/player.c:454-455`).
- Setters: `viSetFovY` / `viSetAspect` / `viSetFov(fovx, fovy)`
(`src/fr.c:911-934`) → `guPerspectiveF(g_viProjectionMatrixF, …, fovy,
aspect, znear, zfar, 1.0f)` (`src/fr.c:718`).
- **FOV is dynamic**: aim zoom rewrites fovy every frame via
`viSetFovY(g_CurrentPlayer->zoominfovy)` (`src/game/bondview2.c:3123`), and
the camera path resets fovy/aspect per frame
(`bondviewMovePlayerUpdateViewport`, `src/game/bondview2.c:7967-8034`).
⇒ Any FOV option must be a *multiplicative correction composable with zoom*,
not an override of a constant.
### 2. The game ships with a (stretch-based) 16:9 path
This decomp contains a player-facing widescreen path from the original:
options-menu **"Ratio": Normal / 16:9** (`GAME_OPTIONS_INDEX_RATIO`, string
`OPTION_STR_23_169_LF`, `src/game/options.c:129`), save bit
`OPTION_SCREENRATIO` (`src/game/file2.c:1266-1270, 1323`),
`get/set_screen_ratio()` (`src/game/options.c:553-558`). When active, the
camera sets `aspect = (vp_w/vp_h) × 0.75 × 16/9` (= viewport ratio × 4/3,
`src/game/bondview2.c:8007-8034`) — the pre-distortion for a **4:3 render
stretched to 16:9** (N64-out-4:3, TV stretches). HUD compensation exists for
crosshair (`gunfire.c:6316-6318`, half-width ×0.75) and rocket aim
(`gunfire.c:501`); EU-only extras need an NTSC audit.
⇒ This path is exactly **Option A's** game-side machinery. It is *not* the
PD standard (which renders native 16:9), so under Option B it stays dormant
except as fallback.
### 3. Current port rendering pipeline (what to build on)
- Render happens at window size; an FBO path already exists and activates when
internal dims ≠ window dims or MSAA > 1 (`gfx_pc.cpp:3041-3115+`).
- Native-res bookkeeping: `GE_NATIVE_W/H` = 640×480 (NTSC) / 640×400 (PAL)
(`port/src/video.c:40-44`); VI size requests clamp to ≤640
(`port/src/libultra.c:816`). Auto window defaults to 4:3
(`port/fast3d/gfx_sdl2.cpp:107`).
### 4. fast3d EXT aspect commands — already present in GE's RSP
GE's software RSP (adapted from PD's) implements Rare's PC-port extension
geometry-mode commands `G_ASPECT_{LEFT,RIGHT,WIDE,CENTER}_EXT`
(`gfx_pc.cpp:1954-1979`, decoder at `gfx_sp_extra_geometry_mode`), and
`port/fast3d/gbiex.h` carries the `gSP…EXT` command macros. **The RSP side of
the PD standard already works in this codebase**; only the game-code emission
sites are missing.
### 5. The PD standard (reference architecture — what Option B replicates)
From the `pd_port` checkout (N64 PD had no widescreen; the port added it):
1. **Projection aspect comes from the port**: `videoGetAspect()` returns the
real window aspect (`gfx_current_dimensions.aspect_ratio`,
`port/src/video.c:232-235`). Game code calls `viSetAspect(videoGetAspect())`
(e.g. `title.c:2495`; even `#define TITLE_ASPECT (videoGetAspect())`).
Native 16:9 projection ⇒ correct perspective, vertical FOV preserved.
2. **Per-element screen-space corrections** where an element is positioned in
screen coords: `sightGetAdjustedX(x)`, `×(SCREEN_ASPECT / videoGetAspect())`
scalings (`sight.c:77,921`, `sky.c:2728+`, `menu.c:2332,4771`,
`player.c:3108`).
3. **HUD alignment via the EXT commands**: screen-space draw segments wrapped
in `gSPSetExtraGeometryModeEXT(gdl, G_ASPECT_CENTER_EXT)` …
`gSPClearExtraGeometryModeEXT(…)`, all under `#ifndef PLATFORM_N64`. With
the mode active, fast3d scales 4:3-logical screen coords by the *native*
aspect and centers (or left/right-aligns) them in the wider window — no
distortion. PD scale: **52 emission sites across 10 game files** (sight,
hudmsg, bondgun, menu, radar, title, propobj, credits, activemenu,
game_1531a0).
4. User-facing: vsync/framerate options + HUD-centering option
(`g_HudCenter` → `g_HudAlignModeL/R`, `port/src/main.c:77-83`).
**M-87 PD-legacy-survey notes (`docs/dev/notes/PD-LEGACY-SURVEY.md`):**
- **Overscan/safe-frame (candidate 2, deliberate divergence, recorded here):**
PD hardcodes its native viewport to 320×220 (`port/src/video.c:77-79`) — the
N64-on-a-TV visible area after ~20 lines top/bottom of overscan. **GE's port
renders the full 640×480/640×400 frame, no overscan crop** (`port/src/video.c:38-47`)
— a deliberate PC-default choice (nothing hidden, no HUD-clipping risk from a
TV-only limitation), not an oversight, and not something to "fix" toward PD's
behavior. Net effect: GE shows ~9% more vertical content than PD at the
equivalent native resolution. Revisit only if a `Video.SafeAreaOverlay` debug
knob is ever wanted to visualize the original TV-safe region.
- **"Ratio" 4:3/16:9 option (candidate 3) — correction, M-87 review:** PD
deleted its N64-era per-player Ratio dropdown on PC (`options.c` forces
`SCREENRATIO_NORMAL` outside `PLATFORM_N64`). GE's equivalent (`SCREEN_RATIO_16_9`,
`get/set_screen_ratio`, `options.c:553-558`, persisted per-save, consumed by
the camera) is a live, functioning code path — but it's an old **anamorphic
stretch toggle**, not true wider-FOV rendering, and this doc's own **Open
Questions** (below) already flag the tension and lean the *other* way: leave
this legacy flag **dormant** and have a **separate, new, port-owned
`Video.Aspect` config** be Option B's actual trigger — the same
separation-of-concerns choice PD itself made (PD didn't repurpose its legacy
flag either). Repurposing the shipped save-flag to drive a brand-new render
path risks desync (existing saves silently gaining/missing the modern
feature, or the flag and `Video.Aspect` disagreeing). **Not yet decided**
("Decide Phase 1" below) — correcting an earlier note here that overstated
this as settled.
### 6. GEVR portal-scissor findings — read before Phase 2/3 game-code work
The sibling GEVR VR port (same n64decomp/007 lineage, reference-only survey
in `scratchpad/GEVR-TRIAGE.md` item A) independently measured, on the same
decomp, that widescreen "missing wall / see-through slab" artifacts come
from the **per-room portal `SETSCISSOR` rects**, not the room-visibility
pass (their cull census was byte-identical between arms) — giving every room
the full view removed the artifacts (one level, one viewpoint; treat as a
hypothesis, not settled). Re-derived in **our** tree (2026-07):
| Class | Our sites | Notes |
|---|---|---|
| SCISSOR — room passes | `bg.c:685`, `bg.c:747` (`bgScissorCurrentPlayerViewF`) | primary + secondary/xlu |
| SCISSOR — default variants | `bg.c:723`, `bg.c:1306`, `chrprop.c:598` (via `bgScissorCurrentPlayerViewDefault`, `bg.c:1332`) | |
| SCISSOR — characters | `chr.c:3070/3074` (`chrRender`) | |
| SCISSOR — explosions | `explosion.c:876/880` **and `explosion.c:1488/1492` (smoke)** | the smoke site is the one GEVR missed on their first pass |
| CULL (removes props, not pixels) | `propobj.c:13584` `posIsOnScreen` (used at `:5881`) | a too-tight rect *deletes* a prop — GEVR's black-wall candidate |
| **AI / SPAWN logic** | `chraction.c:10628` `chrIsPosOffScreen` → `bgGet2dBboxByRoomId` (`bg.c:573`) | reads the rect for character AI/spawn decisions |
**Design trap (their hardest-won lesson):** do NOT put a clip/widescreen
knob inside `bgGet2dBboxByRoomId` — it's the chokepoint that also feeds
`chrIsPosOffScreen`, so a "clip-only" change there would silently alter what
characters do. Scissor sites can be widened directly; the cull box and the
AI/spawn consumer must be reasoned about separately.
**Caveats:** (1) removing all per-room scissoring has an unknown
overdraw/perf cost — measure before shipping; (2) their fix evidence is one
level + one viewpoint; (3) the "two notions of view width" trap (their doc
278): a port knob that redefines native content width from the *window*
aspect while the game's scissor-tracked canvas stays 4:3 leaves ~29% of
columns undrawn → stale-buffer slabs. Whatever this plan's implementation
does, the game's canvas notion and the window notion must be reconciled
explicitly at one named place — re-verify the line numbers above (cheap
greps: `bgScissorCurrentPlayerView`, `bgGet2dBboxByRoomId`, `posIsOnScreen`,
`chrIsPosOffScreen`) before acting on them, since this table predates any
Phase 2/3 work actually landing.
## Option B — the standard (native 16:9, PD-parity)
### Architecture
Render directly at the 16:9 window resolution. World: projection aspect =
window aspect (vertical FOV preserved, no post-stretch). HUD/screen-space:
4:3-logical coordinates mapped undistorted via the EXT aspect modes
(centered by default; left/right alignment available à la PD). 4:3 windows:
byte-identical to today.
### Change inventory
**Port layer (no rules issues):**
- [ ] `videoGetAspect()` equivalent in `port/src/video.c` (return window
aspect; GE already tracks `gfx_current_dimensions.aspect_ratio`).
- [ ] Window/aspect policy: `Video.Aspect` {4:3, 16:9}, snap on create/resize;
out-of-scope aspects → nearest + note (TBD).
- [ ] Make `port/fast3d/gbiex.h` reachable from game TUs (include path or a
shim header) — build-system only.
- [ ] HUD alignment mode plumbing if we adopt PD's center/wide option
(`g_HudAlignModeL/R` pattern) — decide in Phase 1; default = centered.
**Game code (exception class — see below):**
- **(a) Projection aspect path.** `bondviewMovePlayerUpdateViewport`
(`bondview2.c:8007-8034`): under PORT + 16:9, use window aspect instead
of the ×4/3 stretch formula. Plus the menu/title/briefing call sites
`viSetFovY(FOV_Y_F); viSetAspect(ASPECT_RATIO_SD)` (`front.c` — ~7+
sites; enumerate in Phase 1) → PD pattern `viSetAspect(videoGetAspect())`.
- **(b) HUD / screen-space draw sites.** Audit + wrap with EXT aspect modes
and/or per-element corrections, per surface: gameplay HUD (crosshair —
replaces the existing ×0.75 compensation; ammo/timer/text via
`textrelated.c`), menus (`front.c`), title/briefing/credits, letterbox
bars (`fr.c` gDPFillRectangle calls). GE's known screen-space emitters:
`front.c`, `textrelated.c`, `gunfire.c`, `bondwalk2.c` (initial grep;
Phase 1 completes the audit). Expect PD-comparable scale (~10 files),
possibly less.
- **(c) Anything the audit surfaces** (e.g. mouse→screen mapping for menus,
cutscene camera framing) — each item logged individually.
### Rule #2 exception framing (needs explicit sign-off to start Phase 2)
(This project's now-generalized rule-2 sign-off procedure lives in
`docs/dev-process.md` §7; this section is the original case that
motivated it, kept for its PD-precedent argument.)
This is a behavior-affecting game-code edit class, outside the ABI-only D3x
exception. The case for granting it:
1. **Direct sister-engine precedent**: PD's port made exactly these edits on
the same Rare engine/fast3d; the project designates PD as standing
reference for port patterns. We replicate a proven, bounded change class,
not inventing one.
2. **Bounded and gated**: every edit is `#ifdef PORT` (and where sensible
runtime aspect-gated) so the N64 build and the 4:3 default path stay
byte-identical; golden/determinism tests unaffected at 4:3.
3. **The decomp already contains** both a shipped 16:9 branch and `#ifdef
PORT` precedents in game files — the pattern is not foreign to this tree.
Conditions: explicit user sign-off before Phase 2; each change (or tight
cluster) gets a findings.md entry with §F index label citing the PD analogue;
4:3 byte-identity verified per phase; any edit that would alter *sim* behavior
(not just presentation) stops the work and comes back for review.
### Fallback (case-by-case, documented)
If the Phase 1 audit shows a specific surface is disproportionately costly or
can't be made clean under B (candidate: complex cutscene framing), that
surface may fall back to **Option A** — 4:3-internal render + edge-to-edge
stretch using the game's shipped `SCREEN_RATIO_16_9` path (`set_screen_ratio`
is an existing API; FBO+blit infra exists) — recorded as a finding with the
reason. Whole-feature fallback to A only if B fails verification overall.
## FOV adjustment design (port-only, applies to both options)
- **Hook: software RSP projection post-scale.** Wherever the RSP applies the
uploaded projection matrix, scale its vertical row by
`tan(fov_orig/2) / tan(fov_adj/2)`. Zoom rewrites the matrix every frame, so
post-scaling whatever arrives composes with aim zoom for free. No game code.
- Rejected: editing `viSetFovY` call sites (game code); writing
`g_ViBackData` from the port (racy).
- UI: `Video.FovScale` (percent of original vertical FOV, e.g. 50–150),
default 100 = byte-identical to today.
## Phases
### Phase 0 — Verification research (port-only, read-mostly)
- [ ] Playtest the in-game Ratio option as-is on PC; document + screenshot
what the shipped stretch path does under the current pipeline.
- [ ] Measure baseline FOVs (4:3 and current 16:9-stretch) from the projection
matrix or a calibration render; record numbers.
- [ ] Confirm `gbiex.h` EXT commands round-trip through GE's RSP with a test
DL (they should — decoder exists).
- [ ] Audit EU-only aspect constants (`g_GunSightAspectRatio`,
`EU_CAMERA_8003642C_ASPECT`) for NTSC/PAL effect.
- [ ] Record a Dxxx finding consolidating baselines.
### Phase 1 — Port foundation + full change audit (port-only)
- [ ] `videoGetAspect()`, window/aspect policy, gbiex.h include path.
- [ ] Complete the screen-space draw-site audit for inventory item (b): every
emitter, its coordinate assumptions, proposed wrap/correction, PD
analogue cited. Output = the signed change list for Phase 2/3.
- [ ] Decide HUD alignment default (centered vs PD's center/wide option).
### Phase 2 — Projection aspect (game-code exception class, small wave)
- [ ] Item (a): camera path + front.c call sites, `#ifdef PORT`-gated.
- [ ] Verify: world perspective correct at 16:9 (FOV measurement vs Phase 0),
aim zoom composes, 4:3 byte-identical.
### Phase 3 — HUD alignment wave (game-code exception class)
- [ ] Item (b), per surface in order: gameplay HUD → menus →
title/briefing/credits → letterbox/misc. Each surface: change, visual
verification at 4:3 + 16:9, finding entry. Fallback-to-A decisions
happen here, per surface, with documented reason.
### Phase 4 — FOV slider (port-only) — **LANDED (M-83, D211)**
- [x] RSP projection post-scale (`gfx_apply_fov_scale()` in
`port/fast3d/gfx_pc.cpp`, load-path only); `Video.FovScale` config
(percent of vertical FOV, 50–150, default 100 = byte-identical) + F10
overlay row. Perspective-only (ortho/HUD untouched); columns 0+1 scaled
together so aspect is preserved. Build clean, `bunker1` no-op confirmed.
- [ ] Owed: interactive eyeball of composition with aim-zoom at scale ≠ 100.
This phase does **not** depend on the Phase 2/3 game-code exception — it
shipped standalone ahead of the rest of the plan.
### Phase 5 — Verification & sign-off
- [ ] FOV measurements per mode/scale vs Phase 0 baselines.
- [ ] Full HUD pass at 4:3 + 16:9; cosmetics → `GRAPHICS-BACKLOG.md`.
- [ ] 4K performance check; MSAA interaction; config persistence; screenshots.
- [ ] `./build-pc.sh` + `/linkcheck`; findings complete with §F index labels.
## Open questions
- HUD alignment UX: always-centered (simple) vs PD's center/wide option
(more faithful, more surface area). Decide Phase 1.
- In-game Ratio menu entry under Option B: the shipped option means "stretch
mode" — keep it as the *fallback* trigger, hide it, or repurpose? Decide
Phase 1 (leaning: leave dormant; port config owns display mode).
- Mouse/menu coordinate mapping at 16:9 (PD had `videoGetAspect()`-scaled
mouse math in menu.c) — confirm GE's menu input path needs the same.
- Ordering vs `UNLOCKED-FPS-PLAN.md`: both parked behind audio; B renders
native so the FPS plan's present-stage duplication slots in unchanged.
Decide order when audio lands.
- PAL: `GE_NATIVE` 640×400 bookkeeping — confirm aspect math per region.
- Split-screen viewports under 16:9 — likely out of scope for PC; note and close.
- **M-87 QoL ask (user, not critical, backlogged):** expose `Video.FovScale` as
a real horizontal-or-vertical **degree** value instead of the current
percent-of-original-vertical-FOV scale (D211) — the "50–150%" knob doesn't
tell the user what degree FOV they're actually getting. Needs: pick a
convention (report vertical, since that's what `frFovY` actually is post-D211
refix — `fr.c:737`) and either (a) relabel the F10 row + INI value as degrees
directly (`frFovY = clamp(requestedDeg, ~24°, 160°)`, drop the percent
multiply), or (b) keep percent internally but show the *computed* degree
value live in the F10 overlay/INI comment for transparency. (a) is cleaner
and removes a level of indirection; check whether anything else reads
`Video.FovScale` as a percent before renaming the key (search besides
`fr.c`/`video.c`). Cosmetic/UX only, no render-path change beyond the
input unit.
+33 -3
View File
@@ -11152,13 +11152,15 @@ D173 (level-start floating), D292 (death sinking), and the Cradle spin (also und
**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).** Uninvestigated. The port renders at native 640×480 (`GE_NATIVE_W/H`; `gfx_current_native_aspect` set in `port/src/video.c:253,570`) and scales to the window on present, so the leading candidate is a scaling/blit rounding artifact: non-integer scale factors leaving 1–2 columns of uncleared backbuffer content at the edges (or an off-by-one in the blit rect). Secondary candidates: an RDP fill-rect/scissor off-by-one in a game DL or the F10 overlay hook (`gfx_run()` seam, D184), or GL edge filtering on the scaled texture.
**Status (from §F table):** **OPEN — one candidate ruled out (M-116 follow-up), root cause not found.** Step (1) from the original plan is done: a `GE_PCDUMP` capture (Frigate, native 640×483 buffer, both against the existing save and a fresh EEPROM) shows clean, continuous scene content in the leftmost/rightmost columns — no dead/black/garbage strip at either edge. That means the defect is **not** baked into the native render itself (rules out an RDP fill-rect/scissor off-by-one in a game DL, and rules out D227/D245-style tc issues) — it must be introduced later, specifically in the SDL-window presentation path that `GE_PCDUMP` bypasses entirely (`gfx_run()` reads back the game's own render target directly, never the actual window backbuffer the user sees). Re-examined `gfx_adjust_viewport_or_scissor` (`gfx_pc.cpp:2174`): its floor/ceil rounding is already deliberately anti-gap (comment: "prevents sub-pixel gaps at viewport edges"), so a plain rounding bug there is a weaker lead than it looked; more likely candidates now are the `game_renders_to_framebuffer`-path MSAA resolve `glBlitFramebuffer` (`gfx_opengl.cpp:1252`, only exercised when `Video.MSAA>1` or the window size actually differs from the game's target size mid-frame — a path `GE_PCDUMP`'s headless capture doesn't exercise the same way a live resizable window does) or a genuine sub-pixel/DPI scaling remainder specific to the user's actual window dimensions. **Next, still needs the user (no live window access in this environment):** (1) confirm current `Video.MSAA` setting and window size/aspect at the time the strips were seen; (2) if `MSAA>1`, retest with `Video.MSAA=1` to see if the strips disappear (isolates the resolve-blit path); (3) report whether the strip width changes with window size (fixed 1px = blit-rect off-by-one; scales with window = scale-factor remainder). Cross-ref D247 (separate, now closed — don't conflate the two; D247's letterbox is confirmed-intended and top/bottom only, D246 is left/right and still unexplained). **M-141: Steam Deck playtest confirmation, new platform data point** — user reports "some artifacts left and right of screen, only a sliver... like the fullscreen isn't quite going to full screen" specifically in **Full Screen mode on Steam Deck**. Same symptom description as the original M-116 report (Windows). Checked: the Deck's own first-run preset (`videoApplySteamOSDefaults()`, `port/src/video.c:255`) sets `cfgMSAA = 4` by default — exactly the condition this row already flagged as the leading remaining suspect (the MSAA-resolve `glBlitFramebuffer` path, step 2 of the still-open bisection ask). Next: have the user set `Video.MSAA=1` via F10 and retest on the Deck specifically — if the strips vanish, this row's root cause is confirmed without needing any more code tracing.
**Status (from §F table):** **FIXED (2026-09-18 session, port-only, same `Video.SafeAreaCrop` toggle as D247).** Live user playtest (fullscreen and windowed, multiple resolutions) plus headless `GE_PCDUMP` measurement finally pinned this down precisely: the gap is **exactly 1.0 logical unit out of the 320-wide native screen space, on both edges, always** — confirmed at three different `RATIO_X` scales (2px at ~640-wide, 3px at 960-wide, 4px at 1280-wide — precisely `1 * RATIO_X` every time, not a fixed device-pixel count) and the revealed colour changes per level (Bunker1 vs Dam gave different flat colours matching each level's own background/ambient, not a fixed engine clear colour) — i.e. real background/sky content peeking through a narrow gap where the foreground scene doesn't quite reach the true edge column. **Root cause of the gap itself was not isolated to a specific call site** despite a real attempt this session: the RSP viewport math (`src/fr.c:707-710` → `gfx_calc_and_set_viewport`) computes an exact, uninset `[0, 320]` viewport for solo play (confirmed by hand from `vscale`/`vtrans`), and `gfx_adjust_viewport_or_scissor`'s floor/ceil rounding is provably exact (no gap possible) at the integer `RATIO_X` values tested — so this is not a rounding bug in either. Also crucially: `GE_PCDUMP`'s prior-session assumption that it "bypasses the SDL presentation path" (see the old write-up below) **does not hold on the current code** — `videoPreSwapCapture` (`port/src/video.c`) hooks `gfx_pre_swap_hook`, which runs "with the composited frame still in the back buffer, just before `SDL_GL_SwapWindow`" — i.e. it reads the true final backbuffer, and the gap reproduced there with `Video.MSAA=1`, directly refuting the old leading suspect (the MSAA-resolve `glBlitFramebuffer` path). **Fix applied pragmatically rather than root-caused:** given the exact, reproducible 1-unit magnitude, `gfx_adjust_viewport_or_scissor` now also trims a fixed 1-unit margin from both left and right (same function, same mechanism, same `Video.SafeAreaCrop` toggle as the D247 vertical fix — see that entry) rather than a device-pixel-count crop, since the gap is provably a logical-space quantity, not a raw-pixel one. This resolves the visible symptom but a future session revisiting the *why* should start from: real triangle/clip-plane precision at the frustum's left/right edges (the vertex/projection pipeline, not viewport/scissor coverage, both now ruled out) — a natural next comparison point is D233's `w<0` trivial-reject fix (a *different* clip-precision bug in the same `gfx_sp_tri1` neighbourhood, already fixed) and whether an analogous small-epsilon issue exists for the side clip planes. **Verified:** headless `GE_PCDUMP` at 640/960/1280-wide windows, Bunker1 and Dam, `Video.MSAA=1`; live user playtest confirmed clean left/right edges post-fix (windowed; the user's later "fullscreen bars" report was traced to their own remote-desktop session, not this build, and is not part of this finding).
Superseded history below (the original OPEN investigation, root cause not found at the time; the MSAA/GE_PCDUMP-bypass reasoning in it is now known to be based on an incorrect assumption about what `GE_PCDUMP` captures — corrected above, not edited in place so the reasoning trail stays visible): Step (1) from the original plan is done: a `GE_PCDUMP` capture (Frigate, native 640×483 buffer, both against the existing save and a fresh EEPROM) shows clean, continuous scene content in the leftmost/rightmost columns — no dead/black/garbage strip at either edge. That means the defect is **not** baked into the native render itself (rules out an RDP fill-rect/scissor off-by-one in a game DL, and rules out D227/D245-style tc issues) — it must be introduced later, specifically in the SDL-window presentation path that `GE_PCDUMP` bypasses entirely (`gfx_run()` reads back the game's own render target directly, never the actual window backbuffer the user sees). Re-examined `gfx_adjust_viewport_or_scissor` (`gfx_pc.cpp:2174`): its floor/ceil rounding is already deliberately anti-gap (comment: "prevents sub-pixel gaps at viewport edges"), so a plain rounding bug there is a weaker lead than it looked; more likely candidates now are the `game_renders_to_framebuffer`-path MSAA resolve `glBlitFramebuffer` (`gfx_opengl.cpp:1252`, only exercised when `Video.MSAA>1` or the window size actually differs from the game's target size mid-frame — a path `GE_PCDUMP`'s headless capture doesn't exercise the same way a live resizable window does) or a genuine sub-pixel/DPI scaling remainder specific to the user's actual window dimensions. **Next, still needs the user (no live window access in this environment):** (1) confirm current `Video.MSAA` setting and window size/aspect at the time the strips were seen; (2) if `MSAA>1`, retest with `Video.MSAA=1` to see if the strips disappear (isolates the resolve-blit path); (3) report whether the strip width changes with window size (fixed 1px = blit-rect off-by-one; scales with window = scale-factor remainder). Cross-ref D247 (separate, now closed — don't conflate the two; D247's letterbox is confirmed-intended and top/bottom only, D246 is left/right and still unexplained). **M-141: Steam Deck playtest confirmation, new platform data point** — user reports "some artifacts left and right of screen, only a sliver... like the fullscreen isn't quite going to full screen" specifically in **Full Screen mode on Steam Deck**. Same symptom description as the original M-116 report (Windows). Checked: the Deck's own first-run preset (`videoApplySteamOSDefaults()`, `port/src/video.c:255`) sets `cfgMSAA = 4` by default — exactly the condition this row already flagged as the leading remaining suspect (the MSAA-resolve `glBlitFramebuffer` path, step 2 of the still-open bisection ask). Next: have the user set `Video.MSAA=1` via F10 and retest on the Deck specifically — if the strips vanish, this row's root cause is confirmed without needing any more code tracing.
## D247 — Black bars top and bottom of screen — possible aspect-ratio defect (M-116, user QA report).
**Black bars top and bottom of screen — possible aspect-ratio defect (M-116, user QA report).** Uninvestigated. Context: the port currently renders native 4:3 (640×480); widescreen is a *parked proposal* (`docs/dev/WIDESCREEN-FOV-PLAN.md` — PD-parity native 16:9 render, needs rule-#2 sign-off for its game-code HUD wave), so if the user's window/display is wider than 4:3 and the present path letterboxes to preserve aspect, the bars are current intended behaviour rather than a bug. If they appear in a 4:3 window too, it is a real defect (framebuffer sized wrong vs window, `videoCreateFramebuffer(w,h,upscale,autoresize)` → `gfx_create_framebuffer`).
**Status (from §F table):** **NOT A BUG — confirmed faithful, unmodified N64 decomp behaviour, no code change made.** `GE_PCDUMP` captures (both against the pre-existing save and a fresh-EEPROM run, to rule out a stale `Screen` option from earlier test sessions) show the exact same inset on Frigate: real scene content occupies rows ~20–459 of a 640×483 capture (the 483 vs 480 is a separate, harmless 3-row capture-tool rounding quirk, not investigated further), i.e. roughly 220/240 of the vertical field — and `src/game/fr.h:34` defines `VIEWPORT_HEIGHT_DEFAULT_NTSC 220` (240 for the full field), consumed unmodified by `bondviewGetCurrentPlayerViewportHeight()` (`bondview2.c:7858`) as the **default in-game viewport height whenever `PLAYER_OPTION_SCREEN` is `SCREEN_SIZE_FULLSCREEN`** (the decomp's own default value, `options.c:128`) — `VIEWPORT_HEIGHT_FULLSCREEN` (304, no inset) is a red herring name; it's only reachable via the `cameraBufferToggle` branch, not normal solo play. This is GoldenEye 007's well-documented original TV-safe-area letterboxing, baked into ground-truth decomp constants with no `#ifdef PORT` anywhere near it — the N64 game genuinely doesn't fill the full field during normal gameplay. Ruled out a stale/leftover EEPROM `Screen=Wide/Cinema` setting from earlier QoL-testing sessions as an alternative explanation: a fresh EEPROM (default options) shows the identical inset. **No fix needed; this row closes.** If the user's window shows noticeably *thicker* bars than this ~8–9%-per-edge margin, or bars appear on the *sides* instead of top/bottom, that would be a genuinely different (and real) symptom worth reopening under a new label — but "thin bars top and bottom, present at any window size" is correct, original behaviour. Cross-ref D226 (separate, already-tracked HUD-scale QoL ask — unrelated mechanism, don't conflate).
**Status (from §F table):** **FIXED (2026-09-18 session, port-only, `Video.SafeAreaCrop`).** The M-116 root-cause analysis below stands (this is faithful, unmodified N64 TV-safe-area letterboxing, `VIEWPORT_HEIGHT_DEFAULT_NTSC 220` of 240) — real N64 hardware never showed it because a CRT's own overscan crops exactly that margin away; this port shows the full VI frame with no equivalent crop, so the margin became visible black bars. Per user request (2026-09-18, explicit follow-up after the WIDESCREEN-FOV-PLAN.md Phase 4 FOV work), reopened and given a real fix rather than left as "correct original behaviour": `port/fast3d/gfx_pc.cpp`'s `gfx_adjust_viewport_or_scissor` (the single chokepoint used for both the 3D viewport and RDP fill-rect/texture-rect draws, which deliberately bypass the SP viewport and use the full VI canvas) now remaps Y against the *currently active gameplay viewport's own bounds* (cached from the last real `gSPViewport` command, `gfx_calc_and_set_viewport`) instead of the fixed `[0, SCREEN_HEIGHT]` VI canvas, so that region fills the window edge-to-edge — mirroring exactly what a CRT's overscan crop did. Self-gating: front-end/menus and PAL "Full" never inset their viewport (top=0, full height), so untouched there; as a side effect, the legacy in-game "Screen: Wide/Cinema" pause-viewport options (a deliberate *smaller* viewport, a different, player-chosen letterbox, not the overscan margin) also get stretched to fill the window under this crop — treated as an acceptable simplification for a PC port with no CRT overscan concept, not a regression, since those options no longer serve their original purpose here anyway. New `Video.SafeAreaCrop` config (default **on**) is the escape hatch (`port/src/video.c`, `port/src/optionsoverlay.c` F10 row "Crop overscan bars"). **Verified:** headless `GE_PCDUMP` captures at three window sizes (640ish/960/1280 width) confirm bars present with the toggle off and absent with it on, byte-for-byte consistent with the captured viewport bounds; live user playtest confirmed clean top/bottom edges in windowed mode. Cross-ref D226 (separate, already-tracked HUD-scale QoL ask — unrelated mechanism, don't conflate); cross-ref D246 (the left/right sibling of this exact bug family, fixed the same session with the same mechanism).
## 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).
@@ -11382,6 +11384,16 @@ Added a small, targeted diagnostic (`GE_D157`, `port/fast3d/gfx_pc.cpp`, right a
**Next, concrete:** (1) ask the user to reproduce on the Steam Deck with `GE_D157=1` (stdout to a file; optionally `GE_PCDUMP`) and send the log back — the probes now capture the full RGBA path + texture identity on whatever frames are bad, which is exactly the data M-157's next-steps asked for. (2) Re-examine uninitialized-read candidates in the texture-cache key/collision path under the flakiness hypothesis before assuming a driver-state cause. (3) Probes stay in tree; do not remove them on the strength of 0/6 — two independent environments have seen the symptom.
**ROOT-CAUSED AND FIXED (2026-09-18 session).** Two blockers in the probe infrastructure were fixed first: `D157T`/`D157I` (`gfx_pc.cpp`) were both hard-gated to `num_dls` in `[90,240]` — sized for the old short `GE_INPUTSCRIPT` synthetic repro, silently discarding ~100% of the data in a real, minutes-long interactive session (which is what finally reproduced this live, on Windows, per the user's confirmation this is not Deck-exclusive). Removed that gate; kept the existing per-run/per-address hit caps to bound log size. Re-ran live with the user actively triggering bullet sparks: `D157T` finally caught the actual bad vertices, all sharing one exact draw signature (`geom=00002205`, `combine=f39e4f1f39e4f1`, `fmt=3 siz=1` = IA8, matching `glass2.c`'s `explosion_smokeimages`/`scattered_explosions`/`flareimage2` family) but with wildly varying, clearly-non-palette `cn[]` vertex colours (e.g. `(5,57,245)`, `(141,175,37)`, `(198,187,42)` — nothing close to `g_BulletSparkColors[]`'s white/pale-yellow/red/black), while the alpha byte stayed confined to just two values — good alpha, garbage RGB, a strong tell for a byte-offset/struct-layout bug rather than a lighting or combiner issue (both already ruled out above).
**Mechanism, confirmed against source (`src/game/glass.h:51-67`, `src/game/glass2.c:644-647`):** `s_bullet_spark` has `void* unk0C` at byte offset 0x0C, followed by six `f32` fields (`unk10..unk24`) and then the spark's stored RGBA colour bytes `unk28..unk2B` (set correctly in `bullet_sparks_init` from `g_BulletSparkColors[arg2]`). On 32-bit N64, `void*` is 4 bytes, so `unk28` sits at raw byte offset 0x28 — and `bullet_spark_render` reads it that way: `vtx.v.cn[0] = ((u8*)thing)[0x28]` etc., a **hardcoded raw-offset read, not a named-field access**. On 64-bit PC, the pointer widens to 8 bytes and the struct needs 4 bytes of alignment padding before it, pushing the *compiler-computed* offset of `unk28` to 0x30 — but the hardcoded `0x28` literal doesn't move with it, so the read instead lands inside `unk1c`/`unk20` (the spark's `cosf`/`sinf`-derived random velocity components), reinterpreting arbitrary float bytes as the vertex's RGBA colour. This is exactly the D3x/A1 hardcoded-offset-into-a-struct-that-grew pattern (`docs/porting-notes.md` §A1) — M-157's own D3x/A1 check for this function (see above) tested the *wrong* raw-offset call site (`glass2.c:678`'s `sImageTableEntry` stride, confirmed safe since that struct has no pointer members) and didn't look at this one, a plain hardcoded literal with no struct cast to raise suspicion in a static read.
**Fix (ABI-class, semantics-preserving, `#ifdef PORT`; `src/game/glass2.c`):** read `thing->unk28`/`unk29`/`unk2A`/`unk2B` (the correctly-typed named fields) instead of the hardcoded byte-offset literals — the compiler places these at the right offset on either pointer width, bit-identical to the old code on 32-bit N64. **Also fixed in passing, same function, same session:** `vtx = *((Vtx*)(&D_80040980))` (`glass2.c:638`) reads a full 16-byte `Vtx` from a 4-byte `u32` global — real UB the PC compiler flags directly (`-Warray-bounds`). Every field this sets (`ob`/`tc`/`cn`) is unconditionally overwritten later in the function; only `v.flag` survived from here, and `D_80040980`'s own `=0` initializer shows the N64-side intent was a zeroed vertex — replaced with a direct `memset(&vtx, 0, sizeof(vtx))` under the same `#ifdef PORT` guard (N64 line kept verbatim under `#else`).
**Verified:** rebuilt clean (only pre-existing unrelated warnings; the `-Warray-bounds` warning is gone post-fix). Re-ran the exact `GE_INPUTSCRIPT` door-spark repro with `GE_D157=1`: every `fmt=3 siz=1` (IA8 spark family) `shaded=` value is now pure grayscale (`r==g==b`) across the whole capture — zero hue variation, matching the intended white/pale-yellow/red palette shaded through an intensity-only texture. **User live-confirmed** on the same session, after triggering the bug interactively with the probes active, that the fix "looks better." D219 CLOSED.
**Scope note for D252 (`## D252`, the sibling fire/explosion-particle rainbow report):** this session's fix is specifically `glass2.c`'s bullet-spark path — `explosion.c`'s particle rendering (the original D252 report) was checked for the same hardcoded-raw-offset pattern and does **not** have it (`grep` for the `0x28]`-style literals in `explosion.c` found nothing), so D252 is very likely a **separate bug** sharing only the visual symptom family, not automatically fixed by this. D252 should be picked up next as its own investigation, not assumed closed.
## D250 — FPS is inconsistent/low on many levels after 60 Hz shipped (M-119, user QA; D248 follow-on).
@@ -11423,6 +11435,8 @@ This is a **perfect, smooth white-hot → yellow → orange → deep-red → fad
> The §F row had no status cell; the table row's status is derived from this entry's verdict text (ctx-opt Bucket B, 2026-09-15).
**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.
## 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-Line truncated
@@ -11610,4 +11624,20 @@ Returns "cheat not unlocked" — the same safe default other cheat-state paths a
**Status:** FIXED locally on `release/v0.3.0` (not yet pushed) — build-verified, crash-free smoke test; live confirmation against the actual #87 repro still owed.
**M-183/M-185 (2026-09-17, this session):** Added playspeed and endframe clamp workarounds for D243's Bond position/animation issue. M-183 clamps playspeed > 10.0 to 1.0 during scripted camera modes; M-185 clamps corrupted endframe values (< 0.0 or > 1000.0) to 100.0. Both are + env-gated on GE_D243M. The clamp fixes prevent rapid animation advancement (~97 frames/tick) but Bond's animation still "repeats" — he can't complete his animation during the Dam abseil cutscene. Root cause (struct layout corruption from 32→64-bit pointer widening, D100/D140 family) not yet addressed.
**M-183/M-185 (2026-09-17, this session):** Added playspeed and endframe clamp workarounds for D243's Bond position/animation issue. M-183 clamps playspeed > 10.0 to 1.0 during scripted camera modes; M-185 clamps corrupted endframe values (< 0.0 or > 1000.0) to 100.0. Both are + env-gated on GE_D243M. The clamp fixes prevent rapid animation advancement (~97 frames/tick) but Bond's animation still "repeats" — he can't complete his animation during the Dam abseil cutscene. ~~Root cause (struct layout corruption from 32→64-bit pointer widening, D100/D140 family) not yet addressed.~~
**M-186 (2026-09-17, later recovery session) — the M-183/M-185 diagnosis above is WRONG; correcting the record.** A follow-up agent acted on "struct layout corruption from pointer widening" and converted `Model`'s 7 pointer fields to u32 slots + accessor macros, rewriting ~200 call sites with blind `sed` across 12 files — this broke the build (macro name collisions with unrelated structs' same-named fields, malformed chained-access substitutions) and was **reverted this session** (changes stashed, not committed: `git stash` "D243 broken sed attempt (superseded)"). The diagnosis itself doesn't hold up: a `Model*` accessed normally is always self-consistent post-compilation; this codebase's actual pointer-widening bug class only bites when something *puns/overlays* a Model onto raw hardcoded-offset bytes (§A, `porting-notes.md`), and all three known instances of that for models (D100 Bond's own inline model, D102 weapon model, D140 watch model) are already fixed with real inline storage. No unfixed punning gap exists for Bond's world model. **The real, already-established lead is above this entry, in M-169 through M-178** — the 3rd `chrlvPerformAnimationForActor` scripted restart (`chraction.c:605`) triggers the rapid `animframe` advancement; M-178's own stated next step (diff that call's caller-side state writes, `chraction.c:615-627`, against a normal `modelTickAnim` loop-wrap) was never carried out and is the correct place to resume. See `docs/HANDOFF.md` for the fuller writeup, including a new unconfirmed candidate (`modelSetAnimation`/`modelSetAnimation2` never reset `playspeed`/`unkb0`/`unkb4`/`unkac`/`animrate`, only `modelSetAnimPlaySpeed` does) that needs a live `GE_D243M` capture to check, and a reminder not to lose D243 CONSOLIDATED NEXT STEPS item 5's model-pointer-reuse caveat as an alternative explanation for the "playspeed already corrupted before `modelSetAnimPlaySpeed` was even called" observation.
### M-187 (2026-09-18, same recovery session): 25-minute parallel triage sweep of `docs/dev/BUG-TRIAGE-REPORT.md`'s open P0/P1 items — 5 read-only investigations, no fixes applied. **v0.3.0 priority set by user this session: D252 and the D243/D173/D292 family are release-blocking; D286/D287/D296 are explicitly deprioritized (old reports).**
**D243/D173/D292 (this family, continuing directly from M-169–M-178 above):** Confirmed as code fact, high confidence on the "what": `modelSetAnimation2` (`model.c:2720-2846`, unmodified decomp) never resets `unkb0`/`unkb4`/`unkac`/`animrate` on a scripted animation restart — those four are written only by `modelSetAnimPlaySpeed` (`model.c:3011-3034`) and consumed by the blend branch in `modelTickAnim` (`model.c:3559-3574`). A scripted restart landing while `unkb0` is still small-but-nonzero (orphaned mid-blend from an earlier call) divides by that stale `unkb0` using an `unkac`/`animrate` pair describing the *previous* animation — a plausible mechanism for the observed `playspeed≈388-410` one-tick spikes. **Rule-2 complication:** `modelSetAnimation2` has zero `#ifdef PORT` — this gap is equally present on N64 given identical prior state, so a reset inside it would be a behavioral change needing sign-off, not a free port fix. The legitimate PORT-scoped angle is narrower: find what's *PC-specific* about `unkb0` being stale at that exact moment — leading candidate is D193's frame-catchup mechanism delivering multiple sim ticks in one burst, which single-tick-per-frame N64 execution would never produce. **Not verified — needs a live `GE_D243M` capture logging `unkb0/unkb4/unkac/animrate/playspeed` for ~10 ticks before the 3rd `chrlvPerformAnimationForActor` restart (frame ~4100-4107 in the existing repro), cross-referenced against D193's catchup-tick counter for that same window.** If `unkb0` is already 0 going in, this hypothesis is refuted and the search moves to `unka0` (annotated "likely a function pointer" at `bondtypes.h:1643`, stored as `s32` — worth a grep for raw pointer-arithmetic use). **Correction to the shipped M-183/M-185 clamp workarounds' in-code comment:** it attributes the corruption to "D100/D140 struct-layout pun family" — this does not hold up on inspection; `Model` is accessed everywhere by named field, not raw-offset/union-alias, and no pun pattern was found anywhere in this call path. Do not carry that citation forward without evidence. D173/D292 overlap with the same `modelSetAnimation2` entry point is plausible (M-177 already groups all three as one family) but not confirmed this pass — D173's `stage_intro_anim_table`/`setanim_caller` path and D292's death-anim trigger need their own read, not done.
**D252 — rainbow particles.** Static pass this session: the leading hypothesis (uninitialized `TextureCacheKey` field, `port/fast3d/gfx_pc.cpp:1039-1045`) is **REFUTED, high confidence** — every field of the 7-member struct is explicitly assigned on both the CI and non-CI branches before the first read in `gfx_texture_cache_lookup`; `operator==` is member-wise so struct padding is irrelevant; the one under-specified literal (`gfx_texture_cache_delete`, `:582`) is only ever hashed, never compared. **User correction (2026-09-18, overrides the triage report's framing): reproduces on Windows too, not just Steam Deck** — the report's "0/6 dev-box repro" and "environment-dependent" framing is wrong or at least incomplete; this is NOT Deck/driver-specific. This changes the next step: the two GL-driver-state leads from the refuted-hypothesis writeup (wrong-texture-object-bound vs. correct-object-wrongly-colored) are now testable on a normal Windows dev box with the existing `GE_D157`/`GE_D157T`/`GE_D157I` probes — no Deck required. **Elevated to v0.3.0-blocking per user; next session should run `GE_D157=1` (and the widened-cap `GE_D157T` variant, dropping the ≤16KiB tile-0 size cap per M-158's own unexecuted next step) on this Windows box where it's now confirmed to reproduce.**
**D245 — water tc-shift seam.** Root cause CONFIRMED: `skyPortPickShift()` (`sky.c:1768`) is a stateless per-frame function of the current S/T span; ordinary view rotation drifts the water quad's span (~290k-390k units) across a `k` breakpoint every rotation, causing a one-frame re-quantization pop at the shared vertex tc. Proposed fix (untested, not applied): replace the direct `skyPortPickShift` call in `skyPortBeginFan` (`sky.c:1830-1831`) with a hysteresis wrapper — keep the previous `k` unless the span clearly needs to move (a Schmitt-trigger margin, e.g. ~5-10%, on both the up and down thresholds), scoped `#ifdef PORT` with static per-call-site "last k" state (careful not to share state across unrelated fans/levels). Should look better than the existing untested `GE_D245_FIXEDSHIFT` opt-in at the boundary while avoiding its always-coarser quantization elsewhere. Needs the same live A/B (baseline / fixed-k / hysteresis) that `GE_D245_FIXEDSHIFT` itself has never gotten. Not v0.3.0-blocking per user priority (not in the D252/D243-family list) but cheap to pick up if a live A/B session happens anyway.
**D236 — Surface 1 billboard-tree wall (tenth pass).** **Correction to `docs/dev/BUG-TRIAGE-REPORT.md:24`'s claim that "I2:A2 decode + AC-aware edge fix re-applied in-tree (uncommitted)":** false — confirmed via `git log`/`git diff` that this fix (`gfx_pc.cpp:743-766`, `import_texture_ia4`, tagged D266) landed in `532092c7`, predates D272-D280, and was already accounted for as ruled-out in the existing D236 write-up (fixed a real but separate IA4 edge-dither bug, not the wall symptom). No new root-cause lead found this pass; the actual next step is the already-drafted eleventh-pass framing implied by M-155's own data — trace the `0x0c184b50` discrete-tree-card class's on-screen behavior (does it render at all, does the noise "wall" quad draw over/under it incorrectly) using the frame ranges M-155 already identified (`-level_36`, frames 1-536/537-747) and the existing `GE_D236RM` probe. Ten passes with no resolution — not v.0.3.0-blocking per user priority; recommend parking as a known cosmetic defect rather than dispatching an eleventh pass unless it's specifically requested.
**D240/D241 — gunshot SFX.** **D241 CONFIRMED (not "likely") duplicate of D207**: identical mechanism — GE's 8-voice SFX cap, with D206's fix correctly making the alarm klaxon a genuine infinite looped voice that legitimately occupies one slot; once 8/8 are held, new one-shot gunshot requests are silently dropped by the preemption scan's refusal to steal an owned LOOPED voice. **Correction to the triage report's "monitoring status" framing:** D207 has **no fix in the tree** — an M-77b last-resort LOOPED-preemption pass was implemented but reverted after its BUNKER1 test harness failed to reproduce starvation (`[D207-DROP]=0`). D241 supplies the repro D207 was missing (alarm active + PP7/AK47 specifically; Klobb unaffected, asymmetry still unexplained — both share `AutomaticFiringRate=0x03` so fire-rate alone doesn't explain it). Recommend: close D241 as a duplicate, fold its repro into D207, re-apply M-77b's preemption pass and retest against D241's specific repro rather than the harness that never triggered it. **D240** (SFX cadence mismatch): low-medium confidence this is a distinct live bug — both plausible tick chains (D209 game-tick side, D204 audio-DAC-rate side) are already fixed/merged; the report may simply predate those merges. Not touched by user's v0.3.0 priority list.
**D286/D287/D296 — freezes/crash. Deprioritized per user (2026-09-18): "old reports," not v0.3.0 scope.** Recorded for completeness, no further action planned this cycle: **D296 is mislabeled in the triage report** — the actual finding is a *stutter* report ("maybe from the drone guns"), not a crash; it got conflated with D295 (a separate, already-fixed crash that did not reproduce in that same playtest session). `docs/dev/BUG-TRIAGE-REPORT.md` and `findings-index.csv` both need correcting from "crash" to "stutter." **D287**: architecture confirmed (32-slot scheduler completion queue, drained only by the main frame loop), but the evidence file previously believed to show the queue full at freeze-time (`scratch/d287_freeze_gdb.txt`) is actually a normal clean-exit capture with zero hits for the freeze markers — no real freeze-time state has ever been captured; needs a live `GE_D287` heartbeat variant that doesn't cap at 3 reports. **D286**: no new narrowing, asset-streaming-stall remains an untested guess.