From 2f002a31aac5d0a0a878c920a721ccd223ef66df Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:49:34 -0400 Subject: [PATCH] D252: document the rainbow particle-palette bug (no fix for v0.2.0-pre) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User playtest report: bullet-impact sparks and lingering smoke/explosion residue drift through the full hue wheel over time instead of holding their intended grey/orange palette. Logged in findings §F (D252) with this session's mechanism work: particle colour comes entirely from the 15 distinct IMAGE_SMOKE_N x IMAGE_FIRE_N (RGBA16) pairs the particles age through (assets/oddtextures.c records, white default vertex); the M-82/D172 tile-offset fix and the 5551 decode are both verified correct for this path; prime suspect is import_texture_rgba16 deriving width/height from the tile line size (32x7 instead of 16x14 for the 16px-wide fire tiles), which would scramble each aging stage into a different wrong hue -- exactly the observed slow rainbow sweep. Per user instruction: documented only, ships as a known issue (README + release notes + GRAPHICS-BACKLOG); index csv regenerated. --- .github/release-notes.md | 3 +++ docs/dev/GRAPHICS-BACKLOG.md | 1 + docs/dev/findings-index.csv | 6 ++++-- docs/dev/findings.md | 1 + 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/release-notes.md b/.github/release-notes.md index 4d8eb22b..d0eeb370 100644 --- a/.github/release-notes.md +++ b/.github/release-notes.md @@ -33,6 +33,9 @@ The most visible gap in this release. - **Music quality on some tracks** — a wrong-sounding bass instrument on a few elevator/level tracks (D230). +- **Particle colours cycle through a rainbow palette** — bullet-impact sparks + and lingering smoke/explosion residue drift through the hues over time + instead of holding their intended grey/orange palette (D252). - 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 scales (D246); character face textures can wrap on Silo (D197). diff --git a/docs/dev/GRAPHICS-BACKLOG.md b/docs/dev/GRAPHICS-BACKLOG.md index ffdc11d7..65ccf616 100644 --- a/docs/dev/GRAPHICS-BACKLOG.md +++ b/docs/dev/GRAPHICS-BACKLOG.md @@ -33,6 +33,7 @@ once the game is playable start-to-finish without crashes. | **D182** | SELECT FILE screen degrades after navigating away and back (main menu → file select, or backing out of file select and re-entering). On the fresh screen the 007 gun-barrel spiral / Bond silhouette renders behind the file boxes; after a round-trip **the Bond image stops drawing entirely** and the whole background gains the **comb / interlacing** artifact (horizontal line scramble). File boxes, text and the Copy/Erase icons still draw. | **Observed, screenshot committed** (M-35, user QA; `docs/img/bugs/d182-file-select-backout-20260902.png`). Two symptoms, likely linked: **(1)** background 3D image stops rendering after re-entry → a front-end model/DL whose `render_pos` / segment binding is only valid on first construction (D149 / D75 `constructor_menuXX` family — the dossier/wallet models have the same "renders once" behavior). **(2)** interlacing = **M-36 triage: Family A**, the `import_texture_*` `line` vs `full_image_line` shear (see D176(b) / `docs/dev/M-36-TRIAGE.md`) — **not** the D159 `texSwapAltRowBytes` path (that is a full PORT no-op). Open question: why only *after* a round-trip — first import may land `line == width`, re-import may not, or the tile descriptor is stale on re-entry. Repro: `GE_STARTMENU` to file-select, back out with ESC/B, re-enter; `GE_DTEX` on the spiral texture across both visits. Shares defect (2) with D176(b). | | **D197** | Character face/head textures wrap wrong — the face texture repeats around the side of the head "and continues weird" instead of clamping to the front. Seen on **Silo** (M-49 playtest), intermittent ("sometimes"). Reads as **`G_TX_CLAMP` not honoured** for the character-skin tile in fast3d (→ `GL_REPEAT`, so the face UV region tiles around the skull), or wrong `shifts`/`shiftt` / LOD-tile selection on the head texture, or the D167 non-PoT wrap-period issue (head textures are often non-PoT) hitting skinned models. | **Observed, not investigated** (M-49, user bug report — needs a capture + which chr/level). Distinct from D176(b)/D182(2) (those are *bg wall* pitch shear). Related: RC3/D167 (`Video.WrapFix`, non-PoT wrap period) + the `texWriteTileLods` `shifts=shiftt=lod` note in findings §F "D176(b) → 4. candidate 2". First checks: does `gfx_pc.cpp` map N64 `cms/cmt` `G_TX_CLAMP` → `GL_CLAMP_TO_EDGE` for character DLs; is the head tile getting the base tile vs an LOD tile (D107); try `Video.WrapFix=1` on a repro. | | **D195** | Transparency / alpha-blended surfaces render wrong — reported on **Control** (Severnaya bunker basement — Natalya's console room; heavy on glass partitions and the translucent projected/holographic wall displays). Exact failure mode not yet pinned by the reporter (M-49 playtest): candidates are alpha-blended geometry drawing fully opaque, translucent panels drawing invisible, or draw-order/sort so translucent surfaces occlude what's behind. | **Observed, not investigated** (M-49, user bug report — needs a `GE_PCDUMP` capture + which surfaces). fast3d render-mode / combiner path. Same neighborhood as D161 (CI8 tile + `G_TT_NONE` → garbage), D172 (particle-sprite channel order), D176(b) (texture pitch shear) — but this is a *blend/alpha* defect, not a texture-decode one. Note D128 already touched Control's `specialportalarray` (portal struct stride) — check whether the affected surfaces are portal-adjacent. First checks: does `gfx_pc.cpp` honour `G_RM_*` XLU render modes + `G_AC_THRESHOLD`/`G_CC` alpha for these DLs; is `gDPSetRenderMode` `w1` being parsed (cf. findings §F "zbuf-clear w1 scribble"); is Z-write-vs-blend ordering right for XLU surfaces. Get the reporter to say: opaque-where-should-be-clear, invisible, or wrong edges? | +| **D252** | Particle effects cycle through a rainbow palette — bullet-impact sparks and lingering smoke/explosion residue drift through the full hue wheel over time (~10 shots to sweep it) instead of holding their intended grey/orange palette (M-124, user playtest). Full mechanism + suspect in findings §F **D252**: colour comes from 15 distinct `IMAGE_SMOKE_N`×`IMAGE_FIRE_N` (RGBA16) pairs the particles age through; prime suspect is `import_texture_rgba16` deriving width/height from the tile line size (32×7 instead of 16×14 for the fire tiles), scrambling each stage's sprite into a different wrong hue. | **Documented only — not fixed for v0.2.0-pre** (user instruction). Confirm with one dumped fire tile vs raw `IMAGE_FIRE_N` bytes, then derive width/height from the LOADBLOCK dtx/dlh. D172/M-82 lineage. | | **1P weapon viewmodel** | **RESOLVED (M-83, D215).** In-level first-person weapon models drew nothing. Root cause: `gunRenderFirstPersonGunModels` built its `ModelRenderData` via `*(ModelRenderData*)&D_80035CC0` — a reinterpret across two separately-declared adjacent globals that only lands right on the N64 linker + 4-byte pointers; on x86-64 `renderdata.flags` read 0, gating every weapon DL node off in `modelRenderNodeGundl`. All 3 ranked suspects in VIEWMODEL-RESEARCH.md were ruled out first by the `GE_DVM=1` probe. Fixed `#ifdef PORT` (explicit field init). `subdraw` 1→153 gfx cmds; owed one BUNKER1 eyeball. | ## Resolved (for reference) diff --git a/docs/dev/findings-index.csv b/docs/dev/findings-index.csv index 4cedf676..5a76ed16 100644 --- a/docs/dev/findings-index.csv +++ b/docs/dev/findings-index.csv @@ -27,7 +27,7 @@ D198,FIXED,alAdpcmPull/_decodeChunk used K0_TO_PHYS (an unconditional & 0x1FFFFF 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) D196,FIXED,"OS mouse cursor stays visible after closing the F10 options overlay in click-to-lock mode (M-49, user playtest).","FIXED (6e758f1d, merged to main) —" -D192,OPEN,Front-end mission/level-select grid pointer still can't reach the outer cells —,"OPEN — root-caused, not fixed (M-49)" +D192,?,Front-end mission/level-select grid pointer still can't reach the outer cells —,"CLOSED — the buggy mode 0 was removed entirely in the v0.2.0 prep pass (see D239); click-to-lock is now the only capture mode, so the unreachable-cells path no longer exists" D221,FIXED,SELECT FILE screen: the mouse hit-box for a file slot is vertically offset from its visible box —,FIXED (M-91) — root cause was NOT a coordinate/origin mismatch: D222,FIXED,High Video.FovScale breaks culling —,"FIXED, awaiting visual verification (M-121)." D223,FIXED,QoL ask: mouse-wheel weapon cycling should be directional like a normal PC shooter —,"FIXED (M-105, port/src/input.c) —" @@ -121,7 +121,7 @@ D235,OPEN,Wrong textures load after dying/restarting a level —,OPEN — needs: D236,OPEN,"Surface 1: 2D billboard trees render as a wall/sheet of tree texture instead of discrete camera-facing tree sprites (M-106, user QA report).",OPEN — not investigated D237,OPEN,"QoL ask (M-106, user QA):",OPEN — not designed; port-only D238,OPEN,"QoL ask (M-106, user QA):",OPEN — not designed; port-only (port/src/input.c + config.c) -D239,OPEN,"QoL ask (M-106, user QA):",OPEN — supersedes/re-confirms the M-82 always-grab removal item; port-only +D239,?,"QoL ask (M-106, user QA):",CLOSED — executed in the v0.2.0 prep pass (port-only): D240,OPEN,"Player gunshot SFX timing/cadence reportedly does not match the N64 original's rate (M-106, user QA report).","OPEN — not root-caused; static call-site tracing only, no runtime verification possible in this environment." D241,OPEN,Player gunshot SFX goes silent under some conditions —,"OPEN — not newly root-caused; high confidence this is D207 reproduced, not an independent bug." D242,?,Player-death sequence UX regression vs N64 original:,NOT A PORT-LOGIC BUG as far as static analysis can tell. @@ -132,3 +132,5 @@ D247,RESOLVED,Black bars top and bottom of screen —,"NOT A BUG — confirmed f D248,FIXED,"Port runs a stable 30 FPS instead of 60 FPS game-wide (M-117, user asked to verify FPS after the D209 timing-keystone fixes).","FIXED, root-caused to a genuine pointer-width ABI bug in src/sched.c (compiled for the PC build, not excluded)." D249,OPEN,"LOD/culling breaks down on large open levels (Streets, Egyptian named specifically) at *default* FOV —",OPEN — not investigated. D250,?,FPS is inconsistent/low on many levels since the port started actually running at 60fps —, +D251,FIXED,F10 options overlay QoL pass:,"FIXED (port-only, port/src/optionsoverlay.c)." +D252,?,"Particle effects cycle through a rainbow palette (M-124, user playtest of the v0.2.0-pre candidate).", diff --git a/docs/dev/findings.md b/docs/dev/findings.md index 01a3351c..941d1432 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -529,6 +529,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | 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 step that runs *before* `gfx_run()` and is separate from it) to check whether DL-building was hiding the missing time — live-tested on Dam: **`lvlRender` averages only 0.5-1.5ms, also not the bottleneck.** So: known costs (`gfx_run` ~6-10ms + `lvlRender` ~1ms) sum to ~7-11ms, real frame period is ~20-22ms (45-50fps) — the remaining ~10-15ms/frame is resolution-independent, GPU-setting-independent, and not in either of the two obvious CPU call sites. Traced the dataflow one level further: the game's main loop (`src/boss.c` `bossMainloop`) blocks on `osRecvMesg(&gfxFrameMsgQ, ..., OS_MESG_BLOCK)` waiting for `OS_SC_RETRACE_MSG`, posted by a dedicated pacemaker thread (`portTickThread`, `port/src/libultra.c`) at a fixed cadence independent of how fast the consumer drains it — meaning the observed render rate is whatever the consumer (game+render loop, one OS thread hop away from the pacemaker) can actually sustain, not a directly-configured cap. **Leading remaining suspect: the emulated N64 inter-"thread" message-passing/scheduler round-trip itself** (`port/src/gesched.c`/`sched.c`, real OS threads + mutex/condvar standing in for the N64's cooperative SP/DP/main threads) — each hop (game thread → scheduler → SP "thread" → done-message back) is a real OS context-switch-and-wake, and Windows condvar/thread wake latency can easily run several ms per hop, unlike the free N64 hardware handoff this code was ported from. **Not yet confirmed** — this needs either a targeted timer bracketing the full scheduler round-trip (same technique as `GE_D250`, just at a different call site) or a real sampling profiler (Visual Studio's own, or Windows Performance Recorder/WPR) attached to `ge007.x86_64.exe` during a live bad-fps moment, which would show this directly without more printf-probe guessing. Ad-hoc per-call timers have now ruled out the two most obvious candidates without finding the cost; a real profiler is the better next tool. **M-120 — ROOT-CAUSED AND FIXED: `getenv()`.** Set up a real profiler as recommended (`tools_pc/perf.ps1`, new sister script to `debug.ps1`, wraps `xperf` CPU-sampling + context-switch tracing). A live WPR/xperf capture on Dam, with Microsoft's public symbols resolved (`-symbols`, downloads system-DLL PDBs), showed the dominant render thread spending only **5.2% of its CPU time in the game's own code** — **43% was in `msvcrt.dll`**, and within that, `getenv()`/`_getenv_helper_nolock` alone accounted for **~50% of the thread's entire CPU time**. Root cause: `port/fast3d/gfx_pc.cpp`'s `import_texture()` (runs on every texture bind) and several per-triangle/per-`G_SETCOMBINE` combine-mode probe checks (`GE_DTEX`, `GE_TEXDUMP`, `GE_TEXRAW`, `GE_D229` ×2, `GE_D172` ×2, plus `GE_TEXDUMP`/`GE_PCDUMP` in `gfx_opengl.cpp`) called `getenv()` **uncached, every single call** — unlike the safe `static int x = -1; if (x < 0) x = getenv(...) != NULL;` pattern already used everywhere else in this codebase (`ft_d193`, `ge_d116`, `dvm`, etc.). On a texture-heavy scene this is tens of thousands of Windows CRT environment-lookup calls per second (each a locked, locale-aware linear scan per Microsoft's msvcrt implementation), completely invisible to both of M-120's earlier timers (`gfx_run`'s "frame rendered in Nus" and the new `GE_D250` `lvlRender` probe) because neither one specifically isolated CRT/library time from the game's own code. **Fix:** cached every uncached `getenv()` site in `gfx_pc.cpp`/`gfx_opengl.cpp` with the established pattern — narrow, mechanical, zero logic change. **Verified:** the exact idle-Dam headless test that was pinned at 45-50fps before the fix now holds a rock-steady 60-61fps, zero dips, with no other change. This also retroactively explains why M-120's earlier resolution/MSAA/vsync A/B pass (all "no change") was so consistent — the `getenv()` cost was a large, *per-call* (not per-pixel) constant that swamped any smaller resolution-dependent GPU cost at every setting tried, so the A/B genuinely couldn't have detected a fill-rate difference underneath it. **User re-tested 1440p/4K after the fix and confirmed it's fine too** — the initial "still runs badly at 1440p+" report was from before this fix landed (a stale-build mixup, same class as the DLL-load-error false alarm earlier in this session); once actually re-tested on the fixed build, high resolutions hold up fine. **D250 fully resolved, all resolutions tested clean.** | | 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 to their toggle (indent and/or dimmer value colour) so the pairing is obvious in the panel; hint line also reworded to "select: scroll/click, change: mouse/arrows". | +| 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. | Phase 2 replaced the Phase-1 demo loop with the real `mainproc()` on real OS threads, compiled GE's real `src/sched.c`, and brought in PD's fast3d software