Release notes: known-issues pass from v0.2.0-pre playtest

- D197 (Silo face wrap): closed per user playtest, dropped
- D75: reworded — Nintendo logo still broken, Rareware logo filtering off;
  mission-complete/mode-select screens have no 3D models (old framing wrong)
- F10 ghost row: now deterministic bottom-row duplicate of the selected item
  (D251 addendum); earlier intermittent 4K framing superseded
This commit is contained in:
James Dansereau committed 2026-09-13 19:02:04 -04:00
1 parent 49f843a156
commit f091619553
3 files changed
+11 -10

No files matched your search

+7 -6
View File
@@ -44,12 +44,13 @@
instead of holding their intended grey/orange palette (D252). instead of holding their intended grey/orange palette (D252).
- Water on `IsWater` levels shows a moving seam between two patterns (D245); - Water on `IsWater` levels shows a moving seam between two patterns (D245);
thin pixel strips at the left/right screen edges at non-integer window thin pixel strips at the left/right screen edges at non-integer window
scales (D246); character face textures can wrap on Silo (D197). scales (D246).
- Some front-end 3D models are mispositioned or absent — the spinning - The front-end **Nintendo logo renders as two white blobs**, and the
Nintendo logo and the MISSION COMPLETE / mode-select models (D75). Rareware logo is close but its texture filtering looks off (D75).
- The F10 overlay occasionally shows a ghost repeat of the top row at the - The F10 overlay's **bottom row duplicates whatever item is currently
panel bottom in true 4K fullscreen with MSAA on (intermittent, display- selected** (e.g. the MSAA value appears both on its own row and again at
specific; windowed is unaffected). the panel bottom); earlier builds showed it as an intermittent 4K-
fullscreen ghost of the top row.
- **Not yet verified on real Steam Deck hardware** — the bundle is built for - **Not yet verified on real Steam Deck hardware** — the bundle is built for
it and its prime crash suspect from v0.1.0 was fixed, but please report any it and its prime crash suspect from v0.1.0 was fixed, but please report any
Deck-specific faults (a `ge007.crash.log` next to the exe helps). Deck-specific faults (a `ge007.crash.log` next to the exe helps).
+1 -1
View File
@@ -22,7 +22,7 @@ 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) — 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,PARTIAL,Mouse input needs another tuning pass —,PARTIAL — M-120 human playtest:
D195,FIXED,"Transparency / alpha surfaces broken on Control (M-49, user playtest) —","FIXED (port/fast3d/gfx_pc.cpp, gfx_sp_vertex) —" D195,FIXED,"Transparency / alpha surfaces broken on Control (M-49, user playtest) —","FIXED (port/fast3d/gfx_pc.cpp, gfx_sp_vertex) —"
D197,OPEN,"Character face/head textures wrap around the head, seen on Silo (M-49, user playtest).","OPEN — not investigated (M-49), needs a capture" D197,?,"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) — 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,PARTIAL,Phase-3 software audio mixer:,"LANDED (port/src/mixer.c, port/include/mixer.h, include/PR/abi.h) —"
D200,FIXED,-level_09 segfault: reverb's ALDelay.input/output are u32 —,FIXED — 5-line cast in reverb.c (this session) D200,FIXED,-level_09 segfault: reverb's ALDelay.input/output are u32 —,FIXED — 5-line cast in reverb.c (this session)
1 # GENERATED by tools_pc/gen_findings_index.py -- do not hand-edit.
22 D193
23 D194
24 D195
25 D197
26 D198
27 D199
28 D200
+3 -3
View File
@@ -380,7 +380,7 @@ 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 | | 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 | | 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 | | 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".** | | 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.** |
| D76 · D164 | disclaimer/legal screen only draws line 1 — **root-caused (M-31)**: `constructor_menu00_legalscreen` text loop bounds `legal_text_end` on `&legalscreen_MRD`, a linker-adjacency assumption that mingw breaks (`legalscreen_MRD` links 0x60 *before* `legalpage_text_array`) → `do{}while(ptr<end)` runs once. NOT an image-table bug (screen references zero `sImageTableEntry`). | fix proposed (not applied — `front.c` owned by another agent) | | D76 · D164 | disclaimer/legal screen only draws line 1 — **root-caused (M-31)**: `constructor_menu00_legalscreen` text loop bounds `legal_text_end` on `&legalscreen_MRD`, a linker-adjacency assumption that mingw breaks (`legalscreen_MRD` links 0x60 *before* `legalpage_text_array`) → `do{}while(ptr<end)` runs once. NOT an image-table bug (screen references zero `sImageTableEntry`). | fix proposed (not applied — `front.c` owned by another agent) |
| 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`) | | 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`) | | 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`) |
@@ -395,7 +395,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
| 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 playthrough — "the biggest overall flaw").** Not (primarily) an animation-playback bug — the *travel speed* of scripted/AI movement is wrong: guards, named NPCs (Trevelyan, Ourumov) and friendlies (Natalya) all take noticeably longer to run/walk to their goal positions than on N64. General, not chr-specific. **Leading hypothesis — the D117/D134/D155/D156 wall-clock-timing family:** `chr.c` / `chrai.c` integrate movement as `pos += speed * g_GlobalTimerDelta` and step sim loops `while (i < g_ClockTimer)` / `-= g_ClockTimer` (`chr.c:1437,1446,1491-1502,1862,2359,2569`; `chrai.c:76`). On the port `osGetCount()` is free-running wall-clock (D117), `g_GlobalTimerDelta`/`g_ClockTimer` are derived from it, and D155's `FRAMETIMING_PORT_MAX_CATCHUP = 6` clamps `nextFrameTime`. If the effective per-second sum of `g_GlobalTimerDelta` (or the `g_ClockTimer` tick count) runs low — quantization, the catch-up clamp biting during normal play, or a units mismatch (`g_JP_GlobalTimerDelta` vs `g_GlobalTimerDelta`, `chr.c:1435/1437`) — every delta-scaled AI motion integrates slow while **player** movement (input-driven per frame) still feels right, which is exactly the reported split. Related: **D170** (Ourumov/Trevelyan "flee" looks wrong — flagged there as possibly the same locomotion-speed issue rather than an AI divergence). Also possible: `chrGoToBond/Chr(SPEED_RUN/WALK/SPRINT)` speed enum → path-follow step, or pathfinding advancing ≤1 waypoint per frame-quantized tick. **Instances seen (M-49 full playthrough):** (a) Facility — the post-scripted-scene beat with Ourumov + the soldier squad: the soldiers **walk in super slowly and take a long time to path into firing positions** before they start shooting (this is regular squad combat AI, not a named-NPC script — good evidence the slowdown is general delta-scaled `chr` locomotion / path-following, not one ailist); (b) the named-NPC flee behaviour on Silo (Ourumov) and Cradle (Trevelyan) — see D170; (c) **Natalya (the escort NPC) always walks really slow** — on every escort level (Bunker ii, Statue, Control, Archives) she trails far behind, forcing the player to wait. Escort follow-AI is the same `chrGoToChr`/path-follow path as (a). **WORST CASE — Cradle (`-level_51`) is "bugged out completely" due to Trevelyan's behaviour (M-49).** The final level is Trevelyan-fleeing-and-scripted end to end (he runs the antenna cradle, climbs, drops to the cradle floor, the helicopter beat); if his locomotion timing / path-following is off he never hits the trigger points and the level's scripted progression stalls or goes haywire. This makes D193 effectively a **can't-properly-finish-the-game** bug, not just cosmetic sluggishness. **Next:** log `g_GlobalTimerDelta` + `g_ClockTimer` per frame during normal play and check the 1-second sum vs the N64's ~60; time a guard's run across a known distance vs reference footage; then watch Trevelyan's chr state / current AI command on Cradle. | **FIXED + user-playtest-verified (M-81) — root cause is D209**: `act_ubytes.padding[45]`, a raw-byte union alias for `act_gopos.unk59` (the SPEED tier), lands on offset 45 which is `unk59` on N64 but is byte 5 of `waypoints[1]` at 64-bit — always `0x00`. So the locomotion-animation selector `get_sound_at_range()` always received tier 0 and bound `ANIM_DATA_walking`/`walking_unarmed` for **every AI chr in the game**, and since travel is anim-root-motion driven they all moved at walk pace no matter what the ailist commanded. User verified on Cradle: Trevelyan and the guards run. Full elimination trail below — **steady-state timing keystone MEASURED and the "sim sums low" hypothesis is CONTRADICTED (M-80).** Added a `GE_D193=1` per-wall-second probe in `waitForNextFrame()` (`src/game/frametiming.c`, `#ifdef PORT`, env-gated): logs render frames, sim ticks delivered (= sum of the `deltaFrames` fed to `updateFrameCounters` = sum of `g_ClockTimer`), catch-up clamp hits, and max raw pre-clamp delta. Built ntsc-final, ran `-level_09 / _20 / _27 / _34` idle-after-load, ~20 s each. **Every level, rock-steady: render = 30.0 fps, sim = 60.0 ticks/s, clamped = 0, maxraw = 2.** i.e. each rendered frame carries `g_ClockTimer = 2` and the per-second sum is *exactly* the N64's ~60 — the wall-clock timer is NOT running low in steady state, and `FRAMETIMING_PORT_MAX_CATCHUP` never bites in normal play. VSync on/off made no difference (still 30/60). So the B1 premise ("`g_GlobalTimerDelta`/`g_ClockTimer` sum low over a real second") is wrong for the common case; D193 must come from elsewhere. **Residual quantization risk (not yet observed):** `nextFrameTime = (dt_counts + 387937) / 775875` is integer-rounded per frame with no remainder carry, so a scene that renders at a *steady* rate between 30 and 60 that is not an integeLine truncated | 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 playthrough — "the biggest overall flaw").** Not (primarily) an animation-playback bug — the *travel speed* of scripted/AI movement is wrong: guards, named NPCs (Trevelyan, Ourumov) and friendlies (Natalya) all take noticeably longer to run/walk to their goal positions than on N64. General, not chr-specific. **Leading hypothesis — the D117/D134/D155/D156 wall-clock-timing family:** `chr.c` / `chrai.c` integrate movement as `pos += speed * g_GlobalTimerDelta` and step sim loops `while (i < g_ClockTimer)` / `-= g_ClockTimer` (`chr.c:1437,1446,1491-1502,1862,2359,2569`; `chrai.c:76`). On the port `osGetCount()` is free-running wall-clock (D117), `g_GlobalTimerDelta`/`g_ClockTimer` are derived from it, and D155's `FRAMETIMING_PORT_MAX_CATCHUP = 6` clamps `nextFrameTime`. If the effective per-second sum of `g_GlobalTimerDelta` (or the `g_ClockTimer` tick count) runs low — quantization, the catch-up clamp biting during normal play, or a units mismatch (`g_JP_GlobalTimerDelta` vs `g_GlobalTimerDelta`, `chr.c:1435/1437`) — every delta-scaled AI motion integrates slow while **player** movement (input-driven per frame) still feels right, which is exactly the reported split. Related: **D170** (Ourumov/Trevelyan "flee" looks wrong — flagged there as possibly the same locomotion-speed issue rather than an AI divergence). Also possible: `chrGoToBond/Chr(SPEED_RUN/WALK/SPRINT)` speed enum → path-follow step, or pathfinding advancing ≤1 waypoint per frame-quantized tick. **Instances seen (M-49 full playthrough):** (a) Facility — the post-scripted-scene beat with Ourumov + the soldier squad: the soldiers **walk in super slowly and take a long time to path into firing positions** before they start shooting (this is regular squad combat AI, not a named-NPC script — good evidence the slowdown is general delta-scaled `chr` locomotion / path-following, not one ailist); (b) the named-NPC flee behaviour on Silo (Ourumov) and Cradle (Trevelyan) — see D170; (c) **Natalya (the escort NPC) always walks really slow** — on every escort level (Bunker ii, Statue, Control, Archives) she trails far behind, forcing the player to wait. Escort follow-AI is the same `chrGoToChr`/path-follow path as (a). **WORST CASE — Cradle (`-level_51`) is "bugged out completely" due to Trevelyan's behaviour (M-49).** The final level is Trevelyan-fleeing-and-scripted end to end (he runs the antenna cradle, climbs, drops to the cradle floor, the helicopter beat); if his locomotion timing / path-following is off he never hits the trigger points and the level's scripted progression stalls or goes haywire. This makes D193 effectively a **can't-properly-finish-the-game** bug, not just cosmetic sluggishness. **Next:** log `g_GlobalTimerDelta` + `g_ClockTimer` per frame during normal play and check the 1-second sum vs the N64's ~60; time a guard's run across a known distance vs reference footage; then watch Trevelyan's chr state / current AI command on Cradle. | **FIXED + user-playtest-verified (M-81) — root cause is D209**: `act_ubytes.padding[45]`, a raw-byte union alias for `act_gopos.unk59` (the SPEED tier), lands on offset 45 which is `unk59` on N64 but is byte 5 of `waypoints[1]` at 64-bit — always `0x00`. So the locomotion-animation selector `get_sound_at_range()` always received tier 0 and bound `ANIM_DATA_walking`/`walking_unarmed` for **every AI chr in the game**, and since travel is anim-root-motion driven they all moved at walk pace no matter what the ailist commanded. User verified on Cradle: Trevelyan and the guards run. Full elimination trail below — **steady-state timing keystone MEASURED and the "sim sums low" hypothesis is CONTRADICTED (M-80).** Added a `GE_D193=1` per-wall-second probe in `waitForNextFrame()` (`src/game/frametiming.c`, `#ifdef PORT`, env-gated): logs render frames, sim ticks delivered (= sum of the `deltaFrames` fed to `updateFrameCounters` = sum of `g_ClockTimer`), catch-up clamp hits, and max raw pre-clamp delta. Built ntsc-final, ran `-level_09 / _20 / _27 / _34` idle-after-load, ~20 s each. **Every level, rock-steady: render = 30.0 fps, sim = 60.0 ticks/s, clamped = 0, maxraw = 2.** i.e. each rendered frame carries `g_ClockTimer = 2` and the per-second sum is *exactly* the N64's ~60 — the wall-clock timer is NOT running low in steady state, and `FRAMETIMING_PORT_MAX_CATCHUP` never bites in normal play. VSync on/off made no difference (still 30/60). So the B1 premise ("`g_GlobalTimerDelta`/`g_ClockTimer` sum low over a real second") is wrong for the common case; D193 must come from elsewhere. **Residual quantization risk (not yet observed):** `nextFrameTime = (dt_counts + 387937) / 775875` is integer-rounded per frame with no remainder carry, so a scene that renders at a *steady* rate between 30 and 60 that is not an integeLine truncated
| D194 | **Mouse input needs another tuning pass — RMB aim over-sensitive + sensitivity couples to frame/sim rate (M-49, user playtest).** Two parts. **(a) Aim mode (RMB) is near bang-bang, not proportional** (`port/src/input.c:697-707`): any per-poll delta past `AIM_MOVE_THRESH` emits stick `m = clamp(61 + |Δ|·(MouseAimSpeed/100)·AIM_GAIN, 61, 60+AimBand)` = `[61,80]` — with the M-29 defaults (`MouseAimSpeed=16`, `AIM_GAIN=4.0`, `AimBand=20`) `m` is already 61 (fast) at ~1.5 px/poll and saturates at 80 by ~30 px/poll, so the usable proportional band is tiny and sits near the top of GE's aim range → "way too sensitive," turn rate barely tracks how fast you actually move the mouse. Needs a real response curve (low floor, wider proportional band, maybe a gamma), not a threshold+clamp. **(b) Overall sensitivity correlates with movement/frame rate** (user's words: "correlated with movement speed, does not feel good"): `input.c` emits raw stick counts **per controller poll** with no `g_GlobalTimerDelta` / polls-per-frame normalization (hipfire yaw `sx += edx·(MouseTurnSpeed/100)·6.0` at `:709`; aim as above). GE integrates the stick scaled by its sim delta, and the port's poll cadence vs render/sim cadence drifts with scene load — so effective sensitivity shifts when the framerate does (which reads as "when I'm moving"). Fix direction: accumulate mouse delta and convert to stick using a fixed reference dt (or feed the game a dt-consistent value), so a given hand motion = a given view rotation regardless of fps. Lineage: D118a (hipfire pitch digital vs analog), D165 (menu pointer), D166 (hipfire pitch pulses), D180-B3 (`MouseAimSpeed` 25→16). New adjacent QoL ask, root-caused → **D223** (mouse-wheel direction should reverse weapon cycle direction, currently both directions cycle forward). Config knobs exist (`Input.MouseAimSpeed/AimBand/MouseTurnSpeed/HipfirePitchSpeed`) but the *shape* of the mapping is the problem. **M-87 design-goal note (user):** overall mouse feel should move toward **Quake Remaster / the Perfect Dark PC port** — raw, direct, responsive 1:1 mouse-look — while explicitly **not losing GE's original aiming nuance** (the analog-stick aim-mode vs digital-hipfire split this whole D118/D194 lineage exists to preserve). Reframes the fix direction from "just widen the proportional band" to: normalize by a fixed reference dt first (part (b), the frame-rate coupling — this alone should fix most of "feels unresponsive"), then re-derive the aim-mode response curve, and treat raw hipfire mouse-look (PD/Quake-style, bypassing the N64 digital-stick emulation entirely when the player is *not* in aim mode) as a real design option to evaluate against "keep GE's aim-mode feel," not assume the N64 stick-emulation path is mandatory everywhere. **M-87 — found it: GE already has exactly this control scheme, unused by the port.** User asked why vertical mouse feels less responsive than horizontal; investigation found the port forces `controldef = CONTROLLER_CONFIG_HONEY` (1.1), under which hipfire pitch is genuinely digital-only (C-up/C-down) while yaw is analog — an intentional N64-era asymmetry (D166), not a port bug, but avoidable. **`CONTROLLER_CONFIG_SOLITARE` (1.2) / `GOODNIGHT` (1.4)** — user's own recollection, verified in code — redirect forward/back/strafe-L/R onto digital C-buttons/D-pad (`bondview2.c:5195-5253`, `moveData.digitalStep{Forward,Back,Left,Right}`), which frees the **entire primary analog stick** for continuous, unclamped pitch **and** yaw: `moveData.canNaturalPitch`/`canNaturalTurn` (set `!insightaimmode`, i.e. active outside aim mode, not gated behind holding aim) drive `analogPitch/70.0f` / `analogTurn/70.0f` directly (`bondview2.c:5950`/`6008`) — no threshold, no digital step, no `[61,80]`-style clamp band (that clamp is specific to the *aim-mode* zoom-turn path, a separate codepath, `insightaimmode && stick_y>60`). `analogPitch`/`analogTurn` default to the primary stick's raw X/Y for every 1.x scheme (`bondview2.c:4881-4882`) unless a 2.x dual-controller scheme overrides them from a second controller. **This is architecturally identical to a modern FPS mouselook**: continuous analog look on the stick, discrete step movement on buttons — a legitimate, unmodified original GE control scheme, not a port invention. Recommended fix: force the port's `controldef` to SOLITAIRE or GOODNIGHT instead of HONEY, map WASD to the digital step-movement buttons (already close to how keyboard hipfire movement works today) and mouse X/Y directly to the primary stick's continuous X/Y — this should resolve the vertical-vs-horizontal asymmetry completely (no more digital-pulse emulation needed at all, D166 becomes largely moot) and likely fixes much of "feels unresponsive" too, since it's direct continuous stick input rather than any thresholded emulation. The frame-rate-coupling issue (part b) is orthogonal and still needs fixing regardless of scheme. Cross-ref D166, D22Line truncated | D194 | **Mouse input needs another tuning pass — RMB aim over-sensitive + sensitivity couples to frame/sim rate (M-49, user playtest).** Two parts. **(a) Aim mode (RMB) is near bang-bang, not proportional** (`port/src/input.c:697-707`): any per-poll delta past `AIM_MOVE_THRESH` emits stick `m = clamp(61 + |Δ|·(MouseAimSpeed/100)·AIM_GAIN, 61, 60+AimBand)` = `[61,80]` — with the M-29 defaults (`MouseAimSpeed=16`, `AIM_GAIN=4.0`, `AimBand=20`) `m` is already 61 (fast) at ~1.5 px/poll and saturates at 80 by ~30 px/poll, so the usable proportional band is tiny and sits near the top of GE's aim range → "way too sensitive," turn rate barely tracks how fast you actually move the mouse. Needs a real response curve (low floor, wider proportional band, maybe a gamma), not a threshold+clamp. **(b) Overall sensitivity correlates with movement/frame rate** (user's words: "correlated with movement speed, does not feel good"): `input.c` emits raw stick counts **per controller poll** with no `g_GlobalTimerDelta` / polls-per-frame normalization (hipfire yaw `sx += edx·(MouseTurnSpeed/100)·6.0` at `:709`; aim as above). GE integrates the stick scaled by its sim delta, and the port's poll cadence vs render/sim cadence drifts with scene load — so effective sensitivity shifts when the framerate does (which reads as "when I'm moving"). Fix direction: accumulate mouse delta and convert to stick using a fixed reference dt (or feed the game a dt-consistent value), so a given hand motion = a given view rotation regardless of fps. Lineage: D118a (hipfire pitch digital vs analog), D165 (menu pointer), D166 (hipfire pitch pulses), D180-B3 (`MouseAimSpeed` 25→16). New adjacent QoL ask, root-caused → **D223** (mouse-wheel direction should reverse weapon cycle direction, currently both directions cycle forward). Config knobs exist (`Input.MouseAimSpeed/AimBand/MouseTurnSpeed/HipfirePitchSpeed`) but the *shape* of the mapping is the problem. **M-87 design-goal note (user):** overall mouse feel should move toward **Quake Remaster / the Perfect Dark PC port** — raw, direct, responsive 1:1 mouse-look — while explicitly **not losing GE's original aiming nuance** (the analog-stick aim-mode vs digital-hipfire split this whole D118/D194 lineage exists to preserve). Reframes the fix direction from "just widen the proportional band" to: normalize by a fixed reference dt first (part (b), the frame-rate coupling — this alone should fix most of "feels unresponsive"), then re-derive the aim-mode response curve, and treat raw hipfire mouse-look (PD/Quake-style, bypassing the N64 digital-stick emulation entirely when the player is *not* in aim mode) as a real design option to evaluate against "keep GE's aim-mode feel," not assume the N64 stick-emulation path is mandatory everywhere. **M-87 — found it: GE already has exactly this control scheme, unused by the port.** User asked why vertical mouse feels less responsive than horizontal; investigation found the port forces `controldef = CONTROLLER_CONFIG_HONEY` (1.1), under which hipfire pitch is genuinely digital-only (C-up/C-down) while yaw is analog — an intentional N64-era asymmetry (D166), not a port bug, but avoidable. **`CONTROLLER_CONFIG_SOLITARE` (1.2) / `GOODNIGHT` (1.4)** — user's own recollection, verified in code — redirect forward/back/strafe-L/R onto digital C-buttons/D-pad (`bondview2.c:5195-5253`, `moveData.digitalStep{Forward,Back,Left,Right}`), which frees the **entire primary analog stick** for continuous, unclamped pitch **and** yaw: `moveData.canNaturalPitch`/`canNaturalTurn` (set `!insightaimmode`, i.e. active outside aim mode, not gated behind holding aim) drive `analogPitch/70.0f` / `analogTurn/70.0f` directly (`bondview2.c:5950`/`6008`) — no threshold, no digital step, no `[61,80]`-style clamp band (that clamp is specific to the *aim-mode* zoom-turn path, a separate codepath, `insightaimmode && stick_y>60`). `analogPitch`/`analogTurn` default to the primary stick's raw X/Y for every 1.x scheme (`bondview2.c:4881-4882`) unless a 2.x dual-controller scheme overrides them from a second controller. **This is architecturally identical to a modern FPS mouselook**: continuous analog look on the stick, discrete step movement on buttons — a legitimate, unmodified original GE control scheme, not a port invention. Recommended fix: force the port's `controldef` to SOLITAIRE or GOODNIGHT instead of HONEY, map WASD to the digital step-movement buttons (already close to how keyboard hipfire movement works today) and mouse X/Y directly to the primary stick's continuous X/Y — this should resolve the vertical-vs-horizontal asymmetry completely (no more digital-pulse emulation needed at all, D166 becomes largely moot) and likely fixes much of "feels unresponsive" too, since it's direct continuous stick input rather than any thresholded emulation. The frame-rate-coupling issue (part b) is orthogonal and still needs fixing regardless of scheme. Cross-ref D166, D22Line truncated
| D195 | **Transparency / alpha surfaces broken on Control (M-49, user playtest)** — Natalya's console room, the glass partitions + translucent projected wall displays. Originally reported as a blend-state defect. Reinvestigated this session (M-116) after the user reported the glass rendering **solid black** — traced to a real, separate, much bigger bug: `G_TEXTURE_GEN` (RSP environment-mapped texture coordinates, used by shiny/reflective materials game-wide — gold/silver weapon skins, chrome trim, and this glass) was never implemented in fast3d. D72.1 (an earlier session) removed the PD-inherited envmap UV-generation block wholesale to fix the Rareware logo, reasoning "GE never generates texture coordinates from vertex normals" — true for the logo (lit, but does not set `G_TEXTURE_GEN`) but false in general: every envmap draw was left sampling whatever raw `tc[]` happened to be authored (near 0), landing on a fixed corner texel of the reflection map every frame regardless of view angle — for a typically dark-edged reflection texture, a solid near-black surface. | **FIXED (`port/fast3d/gfx_pc.cpp`, `gfx_sp_vertex`) — restored envmap UV generation gated on the real `G_TEXTURE_GEN` bit** (ported from the reference Perfect Dark PC port's already-correct implementation), so D72.1's actual fix is untouched (the logo never sets this bit) while every other envmap draw gets real reflection coordinates. **User-verified live:** console glass and shiny gold/silver guns are now visibly reflective instead of flat black. Confirmed crash-free on a full 21-level sweep. **The remaining "not translucent" complaint is NOT a bug**: user confirmed after seeing the fixed reflective glass that it was never see-through on the original N64 either — it's a reflective (mirror-like) surface by design, not transparent glass. Traced (before the user's confirmation settled it) to the material's own combine formula, `alpha = (TEXEL0/1 LOD-crossfade alpha) * SHADE_alpha + PRIMITIVE_alpha` — an additive constant term that saturates alpha near 1.0 by design, matching "reflective, mostly opaque" intent, not a blend-state defect. **Closed.** Cross-ref: this same envmap fix likely also explains part of D228 (white NPC-weapon patches) if any of those are chrome/shiny weapon variants — worth checking before further D228 investigation. | | D195 | **Transparency / alpha surfaces broken on Control (M-49, user playtest)** — Natalya's console room, the glass partitions + translucent projected wall displays. Originally reported as a blend-state defect. Reinvestigated this session (M-116) after the user reported the glass rendering **solid black** — traced to a real, separate, much bigger bug: `G_TEXTURE_GEN` (RSP environment-mapped texture coordinates, used by shiny/reflective materials game-wide — gold/silver weapon skins, chrome trim, and this glass) was never implemented in fast3d. D72.1 (an earlier session) removed the PD-inherited envmap UV-generation block wholesale to fix the Rareware logo, reasoning "GE never generates texture coordinates from vertex normals" — true for the logo (lit, but does not set `G_TEXTURE_GEN`) but false in general: every envmap draw was left sampling whatever raw `tc[]` happened to be authored (near 0), landing on a fixed corner texel of the reflection map every frame regardless of view angle — for a typically dark-edged reflection texture, a solid near-black surface. | **FIXED (`port/fast3d/gfx_pc.cpp`, `gfx_sp_vertex`) — restored envmap UV generation gated on the real `G_TEXTURE_GEN` bit** (ported from the reference Perfect Dark PC port's already-correct implementation), so D72.1's actual fix is untouched (the logo never sets this bit) while every other envmap draw gets real reflection coordinates. **User-verified live:** console glass and shiny gold/silver guns are now visibly reflective instead of flat black. Confirmed crash-free on a full 21-level sweep. **The remaining "not translucent" complaint is NOT a bug**: user confirmed after seeing the fixed reflective glass that it was never see-through on the original N64 either — it's a reflective (mirror-like) surface by design, not transparent glass. Traced (before the user's confirmation settled it) to the material's own combine formula, `alpha = (TEXEL0/1 LOD-crossfade alpha) * SHADE_alpha + PRIMITIVE_alpha` — an additive constant term that saturates alpha near 1.0 by design, matching "reflective, mostly opaque" intent, not a blend-state defect. **Closed.** Cross-ref: this same envmap fix likely also explains part of D228 (white NPC-weapon patches) if any of those are chrome/shiny weapon variants — worth checking before further D228 investigation. |
| D197 | **Character face/head textures wrap around the head, seen on Silo (M-49, user playtest).** Reads as `G_TX_CLAMP` not honoured (or a non-power-of-two wrap period) on a head/face texture tile — the texture repeats around the mesh instead of clamping at the UV edge. Not yet isolated to a specific character or texture; no `GE_PCDUMP`/`GE_TEXDUMP` capture taken. Likely fast3d texture-tile setup (clamp/wrap mode bits from the tile descriptor not round-tripped) rather than a data bug, given it's cosmetic and geometry-independent. **Next:** `GE_TEXDUMP` the offending head texture's tile descriptor on Silo, diff clamp/mirror bits against `gmain.s`'s expectations for that material. | OPEN — not investigated (M-49), needs a capture | | D197 | **Character face/head textures wrap around the head, seen on Silo (M-49, user playtest).** Reads as `G_TX_CLAMP` not honoured (or a non-power-of-two wrap period) on a head/face texture tile — the texture repeats around the mesh instead of clamping at the UV edge. Not yet isolated to a specific character or texture; no `GE_PCDUMP`/`GE_TEXDUMP` capture taken. Likely fast3d texture-tile setup (clamp/wrap mode bits from the tile descriptor not round-tripped) rather than a data bug, given it's cosmetic and geometry-independent. **Next:** `GE_TEXDUMP` the offending head texture's tile descriptor on Silo, diff clamp/mirror bits against `gmain.s`'s expectations for that material. **v0.2.0-pre playtest (user):** no longer observed on Silo — closed, dropped from release known-issues. | 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`).** `K0_TO_PHYS` is the N64 kseg0→physical mask, valid only because N64 pointers fit in 32 bits; on PC's 64-bit heap it truncates any pointer whose value exceeds 512MB, corrupting the `aLoadADPCM`/`aSetLoop`/`aADPCMdec` DRAM addresses at 3 call sites (`load.c:71`, `:468`, `:474`). Every sibling call site in `env.c`/`resample.c`/`reverb.c` already resolves state pointers via `osVirtualToPhysical` (the port shim, `port/src/libultra.c`), which is bit-identical to `K0_TO_PHYS` for real N64 kseg0 addresses but correctly PC-shimmed. **Fix (ABI-layer, `#ifdef`-free — same call resolves correctly on both platforms):** replace the 3 `K0_TO_PHYS(...)` calls with `osVirtualToPhysical(...)`. No behavior change on N64 (identical result for kseg0 pointers); fixes PC. Surfaced while building the Phase-3 software mixer (D199) — the acmd list these opcodes feed now actually executes on PC instead of being ignored. | FIXED (`src/libultra/audio/load.c`) — part of the Phase-3 mixer landing, not yet build/runtime-verified in isolation | | D198 | **`alAdpcmPull`/`_decodeChunk` used `K0_TO_PHYS` (an unconditional `& 0x1FFFFFFF` mask) on 64-bit heap pointers (`src/libultra/audio/load.c`).** `K0_TO_PHYS` is the N64 kseg0→physical mask, valid only because N64 pointers fit in 32 bits; on PC's 64-bit heap it truncates any pointer whose value exceeds 512MB, corrupting the `aLoadADPCM`/`aSetLoop`/`aADPCMdec` DRAM addresses at 3 call sites (`load.c:71`, `:468`, `:474`). Every sibling call site in `env.c`/`resample.c`/`reverb.c` already resolves state pointers via `osVirtualToPhysical` (the port shim, `port/src/libultra.c`), which is bit-identical to `K0_TO_PHYS` for real N64 kseg0 addresses but correctly PC-shimmed. **Fix (ABI-layer, `#ifdef`-free — same call resolves correctly on both platforms):** replace the 3 `K0_TO_PHYS(...)` calls with `osVirtualToPhysical(...)`. No behavior change on N64 (identical result for kseg0 pointers); fixes PC. Surfaced while building the Phase-3 software mixer (D199) — the acmd list these opcodes feed now actually executes on PC instead of being ignored. | 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`, `port/include/mixer.h`, `include/PR/abi.h`).** The RSP audio ucode (`aspMain`) that would normally execute GE's acmd list never runs on PC (`port/src/ucode.c`); adapting the Perfect Dark PC port's macro-swap trick (`docs/dev/AUDIO-PLAN.md`), `include/PR/abi.h` now `#include`s `port/include/mixer.h` under `#ifdef PORT`, which redefines every `aXxx` macro to call an `Impl` function in `mixer.c` immediately against a small software "DMEM" scratch buffer, instead of packing RSP command words for later execution. GE uses the classic IDO libaudio ABI (verified against every call site in `src/libultra/audio/*.c` + `src/libultrare/audio/{env,reverb}.c`), not PD's differently-shaped "naudio New" ABI — DSP math (ADPCM decode, linear resample, linear envelope mix) is the same Nintendo/SGI ucode PD's `mixer.c` already ported; only the ABI/addressing layer is GE-specific and new here. Several opcodes (`aADPCMdec`, `aResample`, `aEnvMixer`, `aLoadBuffer`, `aSaveBuffer`) take only a state/DRAM pointer — their DMEM source/dest addresses and byte counts come from the most recent `aSetBuffer` call(s), which there is no RSP disassembly to check against; the persistent-context shape (`sCtx.{in,out,count,dryR,wetL,wetR}`) was reconstructed purely from reading every `aSetBuffer`+opcode call-site pair across `resample.c`/`load.c`/`reverb.c`/`mainbus.c`/`save.c`/`env.c` (see the field comments on `sCtx` in `mixer.c:52-82`). `aSegmentImpl` is a no-op — GE's audio DRAM addresses are already resolved via `osVirtualToPhysical` before reaching the mixer (see D198), so segment/base never factor into DMEM addressing here. **Risk:** the `aSetBuffer` context reconstruction is an inference, not a verified spec — if a call site this session didn't audit sets the context differently, audio for that path will mix from the wrong DMEM offsets. **Verified:** `-level_09` now runs full music + SFX 120s+ crash-free (post-D200 fix); no cross-level or opcode-level audio-correctness verification done yet (no golden-audio comparison tooling exists). | 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`, `port/include/mixer.h`, `include/PR/abi.h`).** The RSP audio ucode (`aspMain`) that would normally execute GE's acmd list never runs on PC (`port/src/ucode.c`); adapting the Perfect Dark PC port's macro-swap trick (`docs/dev/AUDIO-PLAN.md`), `include/PR/abi.h` now `#include`s `port/include/mixer.h` under `#ifdef PORT`, which redefines every `aXxx` macro to call an `Impl` function in `mixer.c` immediately against a small software "DMEM" scratch buffer, instead of packing RSP command words for later execution. GE uses the classic IDO libaudio ABI (verified against every call site in `src/libultra/audio/*.c` + `src/libultrare/audio/{env,reverb}.c`), not PD's differently-shaped "naudio New" ABI — DSP math (ADPCM decode, linear resample, linear envelope mix) is the same Nintendo/SGI ucode PD's `mixer.c` already ported; only the ABI/addressing layer is GE-specific and new here. Several opcodes (`aADPCMdec`, `aResample`, `aEnvMixer`, `aLoadBuffer`, `aSaveBuffer`) take only a state/DRAM pointer — their DMEM source/dest addresses and byte counts come from the most recent `aSetBuffer` call(s), which there is no RSP disassembly to check against; the persistent-context shape (`sCtx.{in,out,count,dryR,wetL,wetR}`) was reconstructed purely from reading every `aSetBuffer`+opcode call-site pair across `resample.c`/`load.c`/`reverb.c`/`mainbus.c`/`save.c`/`env.c` (see the field comments on `sCtx` in `mixer.c:52-82`). `aSegmentImpl` is a no-op — GE's audio DRAM addresses are already resolved via `osVirtualToPhysical` before reaching the mixer (see D198), so segment/base never factor into DMEM addressing here. **Risk:** the `aSetBuffer` context reconstruction is an inference, not a verified spec — if a call site this session didn't audit sets the context differently, audio for that path will mix from the wrong DMEM offsets. **Verified:** `-level_09` now runs full music + SFX 120s+ crash-free (post-D200 fix); no cross-level or opcode-level audio-correctness verification done yet (no golden-audio comparison tooling exists). | 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) | | 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) |
@@ -528,7 +528,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121).
| 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).** The port's pacemaker (`port/src/libultra.c` `portTickThread`) posts a VI-retrace event at a true 60Hz (NTSC) cadence, so the halving had to be somewhere downstream of that. | **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. every other retrace" flag by writing into the *next array slot's* `.next` field (`c[1].next = next`) rather than adding a real struct member — `gfxClient` (`src/init.c:246`) is registered with `next=NULL` (send every retrace → 60Hz) and the audio client `g_AudioClient[0]` (`src/audi.c:441`) with a nonzero sentinel `1` (send every other retrace → 30Hz, its intended cadence). `__scHandleRetrace` (`src/sched.c:334`) reads that flag back with a hardcoded byte offset, `*((s32*)client + 2)` (2×`sizeof(s32)` = 8 bytes). On the N64 (32-bit pointers) `sizeof(OSScClient)` is exactly 8 bytes, so offset 8 correctly lands on `client[1].next` — matching the write. On this 64-bit port `sizeof(OSScClient)` is 16 bytes, so the same hardcoded 8-byte offset instead reads the low 32 bits of `client->msgQ` itself (always a real, non-NULL pointer) — the gfx client's flag reads as permanently "nonzero" and silently falls onto the audio client's every-other-frame path, halving the game's real render/tick cadence to 30Hz although the retrace pacemaker itself was always ticking at the correct 60Hz. Same D122/D126/D132/D209 bug class (32→64-bit pointer-width struct-size growth desyncing a hand-rolled offset), just in the scheduler instead of a ROM-serialized struct. **Fix (`#ifdef PORT`, N64 line kept verbatim under `#else`):** read the real field by indexing the struct (`client[1].next == 0`) instead of hardcoding a byte offset — this naturally scales with pointer width on either platform and is the direct decompilation of what `osScAddClient`'s write side already does. **Verified:** `Video.DisplayFPS=1` overlay (`port/src/optionsoverlay.c`, D213) read a rock-stable "30 FPS" on `-level_09` (BUNKER1) pre-fix and "60 FPS" post-fix, same build/scene, screenshots captured via `GE_PCDUMP`. Rebuilt clean (`ninja -j1`, pre-existing unrelated warnings only); `-level_09` and `-level_20` both ran 12-30s post-fix with no crash (`ge007.crash.log` absent). No game-code files touched beyond this one mechanical read (`src/sched.c` is compiled for the PC target per `CMakeLists.txt`'s `SRC_ENGINE` list, not one of the excluded os/io files) and the audio client's own flag/path is completely untouched, so `#ifdef PORT`-gated audio pacing (D204 tempo work) is unaffected. **Note for `docs/dev/UNLOCKED-FPS-PLAN.md`:** this settles that plan's Phase 0 open question — the ~30fps default was a real port bug, not original N64 behavior or an artifact of retrace-rate tracking sim speed; the plan's default-preset baseline should be re-measured at the corrected 60fps before Phase 1 pacing work resumes. Not yet done: a full `verify.sh sweep` across all 21 levels (only `-level_09`/`-level_20` spot-checked this session) and a live human playtest for feel (input latency, animation smoothness, any level-specific perf regression now that render work happens twice as often per second). | | 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).** The port's pacemaker (`port/src/libultra.c` `portTickThread`) posts a VI-retrace event at a true 60Hz (NTSC) cadence, so the halving had to be somewhere downstream of that. | **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. every other retrace" flag by writing into the *next array slot's* `.next` field (`c[1].next = next`) rather than adding a real struct member — `gfxClient` (`src/init.c:246`) is registered with `next=NULL` (send every retrace → 60Hz) and the audio client `g_AudioClient[0]` (`src/audi.c:441`) with a nonzero sentinel `1` (send every other retrace → 30Hz, its intended cadence). `__scHandleRetrace` (`src/sched.c:334`) reads that flag back with a hardcoded byte offset, `*((s32*)client + 2)` (2×`sizeof(s32)` = 8 bytes). On the N64 (32-bit pointers) `sizeof(OSScClient)` is exactly 8 bytes, so offset 8 correctly lands on `client[1].next` — matching the write. On this 64-bit port `sizeof(OSScClient)` is 16 bytes, so the same hardcoded 8-byte offset instead reads the low 32 bits of `client->msgQ` itself (always a real, non-NULL pointer) — the gfx client's flag reads as permanently "nonzero" and silently falls onto the audio client's every-other-frame path, halving the game's real render/tick cadence to 30Hz although the retrace pacemaker itself was always ticking at the correct 60Hz. Same D122/D126/D132/D209 bug class (32→64-bit pointer-width struct-size growth desyncing a hand-rolled offset), just in the scheduler instead of a ROM-serialized struct. **Fix (`#ifdef PORT`, N64 line kept verbatim under `#else`):** read the real field by indexing the struct (`client[1].next == 0`) instead of hardcoding a byte offset — this naturally scales with pointer width on either platform and is the direct decompilation of what `osScAddClient`'s write side already does. **Verified:** `Video.DisplayFPS=1` overlay (`port/src/optionsoverlay.c`, D213) read a rock-stable "30 FPS" on `-level_09` (BUNKER1) pre-fix and "60 FPS" post-fix, same build/scene, screenshots captured via `GE_PCDUMP`. Rebuilt clean (`ninja -j1`, pre-existing unrelated warnings only); `-level_09` and `-level_20` both ran 12-30s post-fix with no crash (`ge007.crash.log` absent). No game-code files touched beyond this one mechanical read (`src/sched.c` is compiled for the PC target per `CMakeLists.txt`'s `SRC_ENGINE` list, not one of the excluded os/io files) and the audio client's own flag/path is completely untouched, so `#ifdef PORT`-gated audio pacing (D204 tempo work) is unaffected. **Note for `docs/dev/UNLOCKED-FPS-PLAN.md`:** this settles that plan's Phase 0 open question — the ~30fps default was a real port bug, not original N64 behavior or an artifact of retrace-rate tracking sim speed; the plan's default-preset baseline should be re-measured at the corrected 60fps before Phase 1 pacing work resumes. Not yet done: a full `verify.sh sweep` across all 21 levels (only `-level_09`/`-level_20` spot-checked this session) and a live human playtest for feel (input latency, animation smoothness, any level-specific perf regression now that render work happens twice as often per second). |
| 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, user QA report).** Distinct from **D222** (which is specifically high `Video.FovScale` breaking *near-screen-edge* culling on NPCs/objects — not implicated here, default FOV) and from **D218** (the fog/far-clip *tint* boundary at wide FOV — also not the same symptom). This report is at the stock FOV, on the game's two biggest/most-open outdoor-scale levels, affecting *far* geometry specifically — points at either (a) a portal/room-visibility BFS cutting off too aggressively at range on large multi-room layouts (same neighbourhood as D233/D234's near-camera portal-cull bugs — worth checking whether D233's `gfx_sp_tri1` w<0 trivial-reject fix, which only guarded *behind-camera* vertices, has a sibling far-plane/depth-range case), or (b) GE's own N64 LOD/detail-swap system (mip-tile / simple-model substitution at distance — see D218's still-open question of "does GE have a separate geometry/object LOD-swap distinct from `Visibility.FarFog`") not being ported faithfully, popping or dropping geometry instead of swapping detail level. **Second, separate ask bundled in the same report:** the user would like LOD **improved** in general (presumably distance-based detail transitions look rougher/more abrupt on PC than remembered) but explicitly wants this **gated behind a toggle** so the original N64-accurate LOD behaviour stays selectable ("in case you want old school graphics") — i.e. this is a QoL feature request layered on top of a correctness bug, not one fix. | OPEN — not investigated. Next: reproduce on Streets (`-level_20`) and Egyptian (`-level_08`, confirm level id) at default `Video.FovScale`; `GE_PCDUMP` a long sightline where geometry visibly drops/pops, cross-referencing whatever governs GE's LOD-tile selection (`gfx_lod_tile_offset` family, already touched by D172/D219) and the portal/room-visibility walk (D233/D234 neighbourhood) to determine which of (a)/(b) is actually firing before designing a fix or the requested toggle. | | 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, user QA report).** Distinct from **D222** (which is specifically high `Video.FovScale` breaking *near-screen-edge* culling on NPCs/objects — not implicated here, default FOV) and from **D218** (the fog/far-clip *tint* boundary at wide FOV — also not the same symptom). This report is at the stock FOV, on the game's two biggest/most-open outdoor-scale levels, affecting *far* geometry specifically — points at either (a) a portal/room-visibility BFS cutting off too aggressively at range on large multi-room layouts (same neighbourhood as D233/D234's near-camera portal-cull bugs — worth checking whether D233's `gfx_sp_tri1` w<0 trivial-reject fix, which only guarded *behind-camera* vertices, has a sibling far-plane/depth-range case), or (b) GE's own N64 LOD/detail-swap system (mip-tile / simple-model substitution at distance — see D218's still-open question of "does GE have a separate geometry/object LOD-swap distinct from `Visibility.FarFog`") not being ported faithfully, popping or dropping geometry instead of swapping detail level. **Second, separate ask bundled in the same report:** the user would like LOD **improved** in general (presumably distance-based detail transitions look rougher/more abrupt on PC than remembered) but explicitly wants this **gated behind a toggle** so the original N64-accurate LOD behaviour stays selectable ("in case you want old school graphics") — i.e. this is a QoL feature request layered on top of a correctness bug, not one fix. | OPEN — not investigated. Next: reproduce on Streets (`-level_20`) and Egyptian (`-level_08`, confirm level id) at default `Video.FovScale`; `GE_PCDUMP` a long sightline where geometry visibly drops/pops, cross-referencing whatever governs GE's LOD-tile selection (`gfx_lod_tile_offset` family, already touched by D172/D219) and the portal/room-visibility walk (D233/D234 neighbourhood) to determine which of (a)/(b) is actually firing before designing a fix or the requested toggle. |
| D250 | **FPS is inconsistent/low on many levels since the port started actually running at 60fps — drops to 20–40fps intermittently, described as worse than before (M-119, user QA report, follow-on from D248).** Very likely a direct consequence of **D248** shipping: the port's render+tick loop had been silently running at half rate (30Hz) for the entire life of the project (D248, M-118) — the *real* per-level CPU/GPU cost of rendering and simulating this port was always this high, it was simply invisible while D248 halved the work being asked of the hardware every second. D248's own write-up flagged this as an open risk ("any level-specific perf regression now that render work happens twice as often per second") and explicitly left a live playtest / perf pass as un-done. Not yet investigated which side (CPU sim, CPU fast3d command generation, or GPU draw) is the bottleneck, or whether it's uniform or level-specific (the user's report implies specific/intermittent, which fits a per-scene cost — draw-call count, overdraw, or a specific expensive subsystem — rather than a flat doubled baseline cost). **M-120: partial progress, real bottleneck not yet caught.** User report sharpened: ~45fps at 720p on a self-described high-end PC, present even outside of dedicated stress levels — worse than the coarse "many levels, intermittent" framing suggested. Two things ruled out this session: (1) **build flags are not the cause** — `build-pc/CMakeCache.txt` confirms `CMAKE_BUILD_TYPE=RelWithDebInfo` (`-O2 -DNDEBUG`) is the actual default (`CMakeLists.txt:75-76`, unconditional unless a cache already exists), matching what `./build-pc.sh` produces with no extra flags — a debug/`-O0` build is not the explanation. (2) **idle-load is not the bottleneck, at least not in this dev box:** `GE_D193=1` on `-level_09` (BUNKER1, small level) idles at a rock-steady `render=60 sim=60`, frame-render time ~2.5 µs·10^3 (2.5 ms); `-level_29` (STREETS, one of D249's flagged large/open levels) idles at `render=55-60 sim=60-62`, frame-render time 6-7 ms — both comfortably under the 16.7 ms/60fps budget with no catch-up clamp hits. **This does not reproduce the user's 45fps** — either the real cost only shows up under actual gameplay load (AI/combat/particles active, not an empty idle scene — matches D193's own "player fine, AI-driven cost is different" pattern) or it's resolution/GPU-fill-rate-bound in a way this box's small idle test window doesn't exercise (this session could not drive real keyboard/mouse input into the game to generate a loaded scene — no interactive input tooling available headlessly). Also spotted and ruled out in passing: the `D51 vi post` debug log firing every render frame in `port/src/libultra.c:781` is throttled to ~once/sec (`% 60 == 1`), not a per-frame cost — not the culprit, though it's a `TEMP D51` instrument that's been live since early porting and could be removed as cleanup. **M-120 continued, live session with the user driving the game directly — the sim/render split is now confirmed live, and several strong candidates are ruled out, but the actual cost is still unlocated.** Reproduced directly on Dam (`-level_33`): `sim` holds a healthy 59-67/s throughout, `render` sags to 45-50/s, and the CPU-side `frame rendered in Nus` cost (`gfx_run`'s software-RSP interpretation, `port/src/libultra.c:1252`) stays only 6-10ms the whole time — comfortably under the 16.7ms/60fps budget. That leaves a genuine, unexplained ~10-15ms/frame, and a live A/B pass ruled out every obvious GPU-side suspect one at a time, each via the F10 in-game overlay while watching `GE_D193`: **MSAA 2→1 and Anisotropy 16→1 — no change.** **FovScale 125→100 — no change.** **VSync 1→0 (true uncapped rendering) — render still pinned at ~60 max, never higher, and the bad-scene numbers were unchanged** (also tells us the ~60fps ceiling itself is not a vsync artifact — something else already caps the loop at 60, vsync or not). **Resolution 1280×960 fullscreen → 640×480 windowed — no change** (rules out GPU fill-rate/pixel-count entirely; the cost does not scale with pixels at all). **Found and fixed a real, separate bug in passing (did not turn out to be the active cause here):** `sync_framerate_with_timer()` (`port/fast3d/gfx_sdl2.cpp`) computes the remaining frame budget in 100ns units (`qpc_to_100ns`) and passes it straight to `sysSleep()`, which takes **microseconds** — a genuine 10x oversleep whenever that branch fires. Fixed (`left / 10`). But this path only runs when `target_fps` is nonzero (tied to `Video.FpsCap`), and the user's test config had `FpsCap=0`/`VSync=0` — confirmed live that the bug fix made no difference to the reported drops, because the buggy code path was never executing in that config. Still worth landing as a correctness fix for whenever `FpsCap`/`VSync` *is* active. **Added `GE_D250`** (`src/boss.c`, wraps `lvlRender()`, the DL-building/culling/scene-traversal steLine truncated | D250 | **FPS is inconsistent/low on many levels since the port started actually running at 60fps — drops to 20–40fps intermittently, described as worse than before (M-119, user QA report, follow-on from D248).** Very likely a direct consequence of **D248** shipping: the port's render+tick loop had been silently running at half rate (30Hz) for the entire life of the project (D248, M-118) — the *real* per-level CPU/GPU cost of rendering and simulating this port was always this high, it was simply invisible while D248 halved the work being asked of the hardware every second. D248's own write-up flagged this as an open risk ("any level-specific perf regression now that render work happens twice as often per second") and explicitly left a live playtest / perf pass as un-done. Not yet investigated which side (CPU sim, CPU fast3d command generation, or GPU draw) is the bottleneck, or whether it's uniform or level-specific (the user's report implies specific/intermittent, which fits a per-scene cost — draw-call count, overdraw, or a specific expensive subsystem — rather than a flat doubled baseline cost). **M-120: partial progress, real bottleneck not yet caught.** User report sharpened: ~45fps at 720p on a self-described high-end PC, present even outside of dedicated stress levels — worse than the coarse "many levels, intermittent" framing suggested. Two things ruled out this session: (1) **build flags are not the cause** — `build-pc/CMakeCache.txt` confirms `CMAKE_BUILD_TYPE=RelWithDebInfo` (`-O2 -DNDEBUG`) is the actual default (`CMakeLists.txt:75-76`, unconditional unless a cache already exists), matching what `./build-pc.sh` produces with no extra flags — a debug/`-O0` build is not the explanation. (2) **idle-load is not the bottleneck, at least not in this dev box:** `GE_D193=1` on `-level_09` (BUNKER1, small level) idles at a rock-steady `render=60 sim=60`, frame-render time ~2.5 µs·10^3 (2.5 ms); `-level_29` (STREETS, one of D249's flagged large/open levels) idles at `render=55-60 sim=60-62`, frame-render time 6-7 ms — both comfortably under the 16.7 ms/60fps budget with no catch-up clamp hits. **This does not reproduce the user's 45fps** — either the real cost only shows up under actual gameplay load (AI/combat/particles active, not an empty idle scene — matches D193's own "player fine, AI-driven cost is different" pattern) or it's resolution/GPU-fill-rate-bound in a way this box's small idle test window doesn't exercise (this session could not drive real keyboard/mouse input into the game to generate a loaded scene — no interactive input tooling available headlessly). Also spotted and ruled out in passing: the `D51 vi post` debug log firing every render frame in `port/src/libultra.c:781` is throttled to ~once/sec (`% 60 == 1`), not a per-frame cost — not the culprit, though it's a `TEMP D51` instrument that's been live since early porting and could be removed as cleanup. **M-120 continued, live session with the user driving the game directly — the sim/render split is now confirmed live, and several strong candidates are ruled out, but the actual cost is still unlocated.** Reproduced directly on Dam (`-level_33`): `sim` holds a healthy 59-67/s throughout, `render` sags to 45-50/s, and the CPU-side `frame rendered in Nus` cost (`gfx_run`'s software-RSP interpretation, `port/src/libultra.c:1252`) stays only 6-10ms the whole time — comfortably under the 16.7ms/60fps budget. That leaves a genuine, unexplained ~10-15ms/frame, and a live A/B pass ruled out every obvious GPU-side suspect one at a time, each via the F10 in-game overlay while watching `GE_D193`: **MSAA 2→1 and Anisotropy 16→1 — no change.** **FovScale 125→100 — no change.** **VSync 1→0 (true uncapped rendering) — render still pinned at ~60 max, never higher, and the bad-scene numbers were unchanged** (also tells us the ~60fps ceiling itself is not a vsync artifact — something else already caps the loop at 60, vsync or not). **Resolution 1280×960 fullscreen → 640×480 windowed — no change** (rules out GPU fill-rate/pixel-count entirely; the cost does not scale with pixels at all). **Found and fixed a real, separate bug in passing (did not turn out to be the active cause here):** `sync_framerate_with_timer()` (`port/fast3d/gfx_sdl2.cpp`) computes the remaining frame budget in 100ns units (`qpc_to_100ns`) and passes it straight to `sysSleep()`, which takes **microseconds** — a genuine 10x oversleep whenever that branch fires. Fixed (`left / 10`). But this path only runs when `target_fps` is nonzero (tied to `Video.FpsCap`), and the user's test config had `FpsCap=0`/`VSync=0` — confirmed live that the bug fix made no difference to the reported drops, because the buggy code path was never executing in that config. Still worth landing as a correctness fix for whenever `FpsCap`/`VSync` *is* active. **Added `GE_D250`** (`src/boss.c`, wraps `lvlRender()`, the DL-building/culling/scene-traversal steLine truncated
| D251 | **F10 options overlay QoL pass: (a) bottom rows drawn off-screen in the in-game 2D space — the layout assumed a ~320×240 field and hard-fit all rows, but `viSetXY` gives 440×330 on front-end screens (`src/game/front.c:8618`) vs 320×240 in-game (`front.c:8628`), so with 17 rows × 15u pitch + 2 header rows the last rows land at y≈282, off-screen whenever the panel is opened in-level; (b) MSAA row only cycled forward — `rowAdjust()`'s `ROW_MSAA` case clamped its index to [0,3] instead of wrapping, so left-click stuck it at 8x with no way down via clicks; (c) manual % rows (`Video.DrawDistance`/`Video.LodDistance`) showed unconditionally even while their `*AutoFov` toggles were on, where the percent is inert/misleading. | **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: a visible-row window (`s_scroll`) follows the selection, driven by wheel (`optionsOverlayScroll`) and arrow/D-pad at the list edges; layout, mouse hit-testing (`overlayRowAtY` now returns a visible position) and keyboard nav all index the visible list (`s_visIdx`/`s_visN`, `s_sel` is now a visible-list index), so it fits both 240- and 330-tall spaces with no per-space tuning. (b) `ROW_MSAA` wraps `(idx + dir + 4) % 4` — OFF→2x→4x→8x→OFF in both directions, matching the already-wrapping `ROW_ENUM`/`ROW_RES`; sliders keep clamping (standard for drag-able values). (c) New `Row.hiddenIfOn`/`hidePtr` fields: a row is hidden while the named option is nonzero — `Draw distance %` hides under `Video.DrawDistanceAutoFov`, `LOD distance %` under `Video.LodDistanceAutoFov`; rebuilt every frame in `overlayUpdateVisible()` (selection clamped into range when its row vanishes). Hint line updated to "click / drag / arrows / wheel". **Verified:** compiles clean in the full 241-target build; final link of this session was blocked only by a running game instance holding the exe (re-link pending). Not yet eyeballed live: scroll feel at 320×240, hover/scroll interaction, and the auto/% row appear-vanish transitions. Cross-ref D213 (FPS readout in the same file), D218/D249 (the auto-coupling these rows expose). **Follow-up after live test:** (d) list navigation (wheel + arrows) wrapped at both ends, so scrolling past the bottom made the highlight "repeat" from the top — now clamped at both edges like a normal PC settings list; (e) new `Input.SensLink` toggle (default ON, registered in `port/src/input.c`) links the aim/turn sensitivity knobs: moving either rescales the other to hold the stock 38 (`Input.AimModeSens`) : 50 (`Input.MouseTurnSpeed`) ratio, via a re-entrancy-guarded pair-write in `rowSet()`; (f) the "Mouse aim speed" row now edits `Input.AimModeSens` (the knob the default GEPD aim path actually uses — it previously edited `Input.MouseAimSpeed`, which only feeds the legacy velocity-stick fallback and was inert under default settings). **Second follow-up:** hover-to-highlight removed entirely -- moving the mouse over the panel no longer moves the selection or scrolls; near a list edge the hover->select->scroll-shift->new-row-under-cursor feedback loop made the bottom of the list twitch/echo (the persistent "duplicate row" report). Selection now moves only by explicit click (which also selects the clicked row), wheel, or arrows. **Reassessment of the 4K-fullscreen ghost-row bug:** the presentation-UV-wrap theory is DEAD — `gfxFramebuffer` has zero readers (the textured-quad present never happens); presentation is a full-frame `glBlitFramebuffer` from the same-size MSAA FBO to the default framebuffer (fb index 0 is reserved for the window, `gfx_opengl_init`), which has no UVs and cannot wrap. The overlay DL draws each visible row exactly once, in-bounds, with its own scissor. Local repro (maximized 16:9 window + MSAA=4 + `GE_OPTIONSOVERLAY=1` + `GE_PCDUMP`) shows NO ghost — the FBO/MSAA path alone is not the trigger; the remaining variable is true fullscreen on the user's 3072×1728 display (driver/display-level suspect). Next: user bisection — Fullscreen ON + MSAA OFF (restart) → does the ghost persist? **User follow-up (logged, not fixed):** the 4K-fullscreen ghost is *intermittent* across launches, and the user suspects the `hiddenIfOn` appear/vanish transitions of the Draw/LOD distance % rows (visible-list resize while the panel is open) — unverified; note `overlayUpdateVisible()`+`overlayUpdateScroll()` are re-run together in both emit and input each frame, so a stale-state window would have to come from elsewhere (e.g. the one frame a row count changes). MSAA-off bisection still pending. User: in the sessions seen so far the ghost is present on *first open* of F10 (not only after an auto-toggle flip) — weakens but does not kill the appear/vanish theory (toggles had flipped in earlier sessions before a restart). **QoL follow-up:** make the auto-linked % rows visually subordinate tLine truncated | D251 | **F10 options overlay QoL pass: (a) bottom rows drawn off-screen in the in-game 2D space — the layout assumed a ~320×240 field and hard-fit all rows, but `viSetXY` gives 440×330 on front-end screens (`src/game/front.c:8618`) vs 320×240 in-game (`front.c:8628`), so with 17 rows × 15u pitch + 2 header rows the last rows land at y≈282, off-screen whenever the panel is opened in-level; (b) MSAA row only cycled forward — `rowAdjust()`'s `ROW_MSAA` case clamped its index to [0,3] instead of wrapping, so left-click stuck it at 8x with no way down via clicks; (c) manual % rows (`Video.DrawDistance`/`Video.LodDistance`) showed unconditionally even while their `*AutoFov` toggles were on, where the percent is inert/misleading. | **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: a visible-row window (`s_scroll`) follows the selection, driven by wheel (`optionsOverlayScroll`) and arrow/D-pad at the list edges; layout, mouse hit-testing (`overlayRowAtY` now returns a visible position) and keyboard nav all index the visible list (`s_visIdx`/`s_visN`, `s_sel` is now a visible-list index), so it fits both 240- and 330-tall spaces with no per-space tuning. (b) `ROW_MSAA` wraps `(idx + dir + 4) % 4` — OFF→2x→4x→8x→OFF in both directions, matching the already-wrapping `ROW_ENUM`/`ROW_RES`; sliders keep clamping (standard for drag-able values). (c) New `Row.hiddenIfOn`/`hidePtr` fields: a row is hidden while the named option is nonzero — `Draw distance %` hides under `Video.DrawDistanceAutoFov`, `LOD distance %` under `Video.LodDistanceAutoFov`; rebuilt every frame in `overlayUpdateVisible()` (selection clamped into range when its row vanishes). Hint line updated to "click / drag / arrows / wheel". **Verified:** compiles clean in the full 241-target build; final link of this session was blocked only by a running game instance holding the exe (re-link pending). Not yet eyeballed live: scroll feel at 320×240, hover/scroll interaction, and the auto/% row appear-vanish transitions. Cross-ref D213 (FPS readout in the same file), D218/D249 (the auto-coupling these rows expose). **Follow-up after live test:** (d) list navigation (wheel + arrows) wrapped at both ends, so scrolling past the bottom made the highlight "repeat" from the top — now clamped at both edges like a normal PC settings list; (e) new `Input.SensLink` toggle (default ON, registered in `port/src/input.c`) links the aim/turn sensitivity knobs: moving either rescales the other to hold the stock 38 (`Input.AimModeSens`) : 50 (`Input.MouseTurnSpeed`) ratio, via a re-entrancy-guarded pair-write in `rowSet()`; (f) the "Mouse aim speed" row now edits `Input.AimModeSens` (the knob the default GEPD aim path actually uses — it previously edited `Input.MouseAimSpeed`, which only feeds the legacy velocity-stick fallback and was inert under default settings). **Second follow-up:** hover-to-highlight removed entirely -- moving the mouse over the panel no longer moves the selection or scrolls; near a list edge the hover->select->scroll-shift->new-row-under-cursor feedback loop made the bottom of the list twitch/echo (the persistent "duplicate row" report). Selection now moves only by explicit click (which also selects the clicked row), wheel, or arrows. **Reassessment of the 4K-fullscreen ghost-row bug:** the presentation-UV-wrap theory is DEAD — `gfxFramebuffer` has zero readers (the textured-quad present never happens); presentation is a full-frame `glBlitFramebuffer` from the same-size MSAA FBO to the default framebuffer (fb index 0 is reserved for the window, `gfx_opengl_init`), which has no UVs and cannot wrap. The overlay DL draws each visible row exactly once, in-bounds, with its own scissor. Local repro (maximized 16:9 window + MSAA=4 + `GE_OPTIONSOVERLAY=1` + `GE_PCDUMP`) shows NO ghost — the FBO/MSAA path alone is not the trigger; the remaining variable is true fullscreen on the user's 3072×1728 display (driver/display-level suspect). Next: user bisection — Fullscreen ON + MSAA OFF (restart) → does the ghost persist? **User follow-up (logged, not fixed):** the 4K-fullscreen ghost is *intermittent* across launches, and the user suspects the `hiddenIfOn` appear/vanish transitions of the Draw/LOD distance % rows (visible-list resize while the panel is open) — unverified; note `overlayUpdateVisible()`+`overlayUpdateScroll()` are re-run together in both emit and input each frame, so a stale-state window would have to come from elsewhere (e.g. the one frame a row count changes). MSAA-off bisection still pending. User: in the sessions seen so far the ghost is present on *first open* of F10 (not only after an auto-toggle flip) — weakens but does not kill the appear/vanish theory (toggles had flipped in earlier sessions before a restart). **v0.2.0-pre playtest (user, latest build):** the symptom has changedLine truncated
| D252 | **Particle effects cycle through a rainbow palette (M-124, user playtest of the v0.2.0-pre candidate).** Shooting repeatedly makes the bullet-impact "sparks" slowly progress through the full hue wheel (~10 shots to sweep it); other lingering particle effects — smoke / explosion residue — show the same rainbow colouration instead of their intended grey/orange palette. **Mechanism context (verified this session):** particle tris carry no per-primitive material; their colour comes entirely from the ROM-resident state records `globalDL_0x078 … globalDL_0xa50` (`assets/oddtextures.c`, 15 of them) replayed per particle batch (`explosion.c:894`). Each record binds a **distinct** texture pair — `IMAGE_SMOKE_N` (IA, tile 0 @ TMEM 0) × `IMAGE_FIRE_N` (RGBA16, tile 1 @ TMEM 0x188) under `G_CC_INTERFERENCE` (TEXEL0·TEXEL1), and `explosionRenderPart` uses a fixed all-white default vertex (`explosion.c:215`) — so the intended per-stage colour is whatever those 15 fire/smoke pairs encode, and particles **age through the records** (`var_s2` 14→0 at `frame/flareanimspeed`), so a per-texture decode error surfaces exactly as a hue sweep over time / across shots. **Ruled out this session:** (a) the M-82/D172 tile-offset fix is correctly configured for this path — `gsDPSetTextureLOD(G_TL_TILE)` does NOT set the `G_TL_LOD` bit, so `rdp.tex_lod` stays false (`gfx_pc.cpp:2860`) and `gfx_lod_tile_offset` returns `i`, i.e. TEXEL1 genuinely samples tile 1; (b) the RGBA16/5551 texel decode in `import_texture_rgba16` is correct (big-endian, R=bits15-11 G=10-6 B=5-1 A=bit0). **Prime suspect: the width/height derivation in `import_texture_rgba16`** — `width = rdp.texture_tile[tile].line_size_bytes / 2`, `height = size_bytes / line_size_bytes`. The fire tiles are 16 px wide with a MASK_16 TMEM line (64 bytes), so `width` computes 32 (true: 16) and, with `size_bytes` = the LOADBLOCK's 16×14×2 = 448, `height` computes 7 (true: 14) — the GL texture would be built from the right pixels at the wrong dimensions, scrambling each of the 15 fire sprites into a different wrong-hued smear; that fits "slow rainbow progression" precisely (each aging stage = a different scrambled sprite). **Not yet confirmed** — needs one imported fire tile dumped and compared against the raw `IMAGE_FIRE_N` bytes (`GE_TEXDUMP`/`GE_PCDUMP`, or a targeted probe at the `upload_texture` call). **Per user instruction: documented only, NOT fixed for v0.2.0-pre** — ships as a known issue; pick up afterwards (cheap if the suspect holds: derive width/height from the LOADBLOCK's dtx/dlh rather than the tile line size, or special-case non-32-texel-wide RGBA16 tiles). D172/M-82 lineage. | | D252 | **Particle effects cycle through a rainbow palette (M-124, user playtest of the v0.2.0-pre candidate).** Shooting repeatedly makes the bullet-impact "sparks" slowly progress through the full hue wheel (~10 shots to sweep it); other lingering particle effects — smoke / explosion residue — show the same rainbow colouration instead of their intended grey/orange palette. **Mechanism context (verified this session):** particle tris carry no per-primitive material; their colour comes entirely from the ROM-resident state records `globalDL_0x078 … globalDL_0xa50` (`assets/oddtextures.c`, 15 of them) replayed per particle batch (`explosion.c:894`). Each record binds a **distinct** texture pair — `IMAGE_SMOKE_N` (IA, tile 0 @ TMEM 0) × `IMAGE_FIRE_N` (RGBA16, tile 1 @ TMEM 0x188) under `G_CC_INTERFERENCE` (TEXEL0·TEXEL1), and `explosionRenderPart` uses a fixed all-white default vertex (`explosion.c:215`) — so the intended per-stage colour is whatever those 15 fire/smoke pairs encode, and particles **age through the records** (`var_s2` 14→0 at `frame/flareanimspeed`), so a per-texture decode error surfaces exactly as a hue sweep over time / across shots. **Ruled out this session:** (a) the M-82/D172 tile-offset fix is correctly configured for this path — `gsDPSetTextureLOD(G_TL_TILE)` does NOT set the `G_TL_LOD` bit, so `rdp.tex_lod` stays false (`gfx_pc.cpp:2860`) and `gfx_lod_tile_offset` returns `i`, i.e. TEXEL1 genuinely samples tile 1; (b) the RGBA16/5551 texel decode in `import_texture_rgba16` is correct (big-endian, R=bits15-11 G=10-6 B=5-1 A=bit0). **Prime suspect: the width/height derivation in `import_texture_rgba16`** — `width = rdp.texture_tile[tile].line_size_bytes / 2`, `height = size_bytes / line_size_bytes`. The fire tiles are 16 px wide with a MASK_16 TMEM line (64 bytes), so `width` computes 32 (true: 16) and, with `size_bytes` = the LOADBLOCK's 16×14×2 = 448, `height` computes 7 (true: 14) — the GL texture would be built from the right pixels at the wrong dimensions, scrambling each of the 15 fire sprites into a different wrong-hued smear; that fits "slow rainbow progression" precisely (each aging stage = a different scrambled sprite). **Not yet confirmed** — needs one imported fire tile dumped and compared against the raw `IMAGE_FIRE_N` bytes (`GE_TEXDUMP`/`GE_PCDUMP`, or a targeted probe at the `upload_texture` call). **Per user instruction: documented only, NOT fixed for v0.2.0-pre** — ships as a known issue; pick up afterwards (cheap if the suspect holds: derive width/height from the LOADBLOCK's dtx/dlh rather than the tile line size, or special-case non-32-texel-wide RGBA16 tiles). D172/M-82 lineage. |
| D253 | **Facility (and any level with a FORCECROUCH/LADDER linked special tile) SIGSEGV on Linux/Steam Deck — `stanCheckLinkedSpecialTile` stores to a garbage `outFlags` pointer (D203 root cause).** The B-callback typedef `standTileLocusCallback_B_t` (`bondtypes.h:573`) declares the middle three params as `f32 f32 f32`, so on x86-64 SysV the caller (`sub_GAME_7F0B1DDC`, `stan.c:2228`) passes the 6th arg (`record`) in **`%rdx`** (3rd integer slot; the floats take xmm0–2). But the actual implementation `stanCheckLinkedSpecialTile` was decompiled with an all-`s32` prototype, so it reads `outFlags` from **`%r9`** (6th integer slot) — a stale scratch value left by earlier callees. On the N64 (MIPS o32) both prototypes agree: everything flows through GPRs a0–a5 in order, 6th arg = a5 either way, so the original is fine and the decompilation looked consistent. The mismatch stays dormant until a tile actually hits the FORCECROUCH or LADDER branch and *stores* through `outFlags` → SEGV; that's why it was level-specific (Facility's opening crouch tile) and input-dependent, and why idle repros were negative. The sibling B-callback `stanLocusEdgeIsAboveY` already had the correct float prototype, which is why only this path was booby-trapped. **Fix (ABI-class, semantics-preserving):** declare the three middle params of `stanCheckLinkedSpecialTile` as `f32` in both the forward decl and definition (`stan.c:152`, `stan.c:2407`) to match the typedef; the body never uses them, and on N64 o32 f32 args occupy the same GPRs, so N64 codegen is unaffected. **Verified:** gdb run on WSL (gcc 15) — pre-fix, SIGSEGV at `stan.c:2441` with `r9=0x3` and valid `record=0x409fea00` in the caller frame; post-fix, the same walk route completes with no crash. Side benefit: the FORCECROUCH/LADDER branches were effectively dead-on-arrival on PC (any hit = crash); they now function. **Also observed post-fix (separate, WSLg-only):** a segfault *inside libgallium* during `gfx_opengl_upload_texture` (32×48 RGBA + mipmaps) while walking further — GL-driver-side under WSLg's Mesa; not reproducible on Windows; park as a new finding if it recurs on real hardware. | FIXED — pending Deck re-verification with the re-cut bundle. | | D253 | **Facility (and any level with a FORCECROUCH/LADDER linked special tile) SIGSEGV on Linux/Steam Deck — `stanCheckLinkedSpecialTile` stores to a garbage `outFlags` pointer (D203 root cause).** The B-callback typedef `standTileLocusCallback_B_t` (`bondtypes.h:573`) declares the middle three params as `f32 f32 f32`, so on x86-64 SysV the caller (`sub_GAME_7F0B1DDC`, `stan.c:2228`) passes the 6th arg (`record`) in **`%rdx`** (3rd integer slot; the floats take xmm0–2). But the actual implementation `stanCheckLinkedSpecialTile` was decompiled with an all-`s32` prototype, so it reads `outFlags` from **`%r9`** (6th integer slot) — a stale scratch value left by earlier callees. On the N64 (MIPS o32) both prototypes agree: everything flows through GPRs a0–a5 in order, 6th arg = a5 either way, so the original is fine and the decompilation looked consistent. The mismatch stays dormant until a tile actually hits the FORCECROUCH or LADDER branch and *stores* through `outFlags` → SEGV; that's why it was level-specific (Facility's opening crouch tile) and input-dependent, and why idle repros were negative. The sibling B-callback `stanLocusEdgeIsAboveY` already had the correct float prototype, which is why only this path was booby-trapped. **Fix (ABI-class, semantics-preserving):** declare the three middle params of `stanCheckLinkedSpecialTile` as `f32` in both the forward decl and definition (`stan.c:152`, `stan.c:2407`) to match the typedef; the body never uses them, and on N64 o32 f32 args occupy the same GPRs, so N64 codegen is unaffected. **Verified:** gdb run on WSL (gcc 15) — pre-fix, SIGSEGV at `stan.c:2441` with `r9=0x3` and valid `record=0x409fea00` in the caller frame; post-fix, the same walk route completes with no crash. Side benefit: the FORCECROUCH/LADDER branches were effectively dead-on-arrival on PC (any hit = crash); they now function. **Also observed post-fix (separate, WSLg-only):** a segfault *inside libgallium* during `gfx_opengl_upload_texture` (32×48 RGBA + mipmaps) while walking further — GL-driver-side under WSLg's Mesa; not reproducible on Windows; park as a new finding if it recurs on real hardware. | FIXED — pending Deck re-verification with the re-cut bundle. |