D219 M-157: first visual capture of the rainbow-particle bug, cross-validates M-110's G_LIGHTING lead

Chasing D252 further, got the first-ever screenshot proof of this bug
family: a plain bullet-impact spark on a Bunker1 door cycling magenta ->
purple -> green -> teal -> purple across consecutive shots, captured
headlessly via GE_INPUTSCRIPT (Z fire pulses) + GE_PCDUMP together, then
reading the resulting PPMs directly (PIL) instead of only grepping a
probe log.

Traced the call chain (glass2.c's bullet_spark_render, a different code
path from both D219's explosion.c particles and D252's IMAGE_FIRE_N
system) and ruled out three mechanisms with actual code evidence before
finding one that fits:
- Not D252's fire-tile system (GE_D252 got zero hits on this repro).
- Not a struct-layout/ABI bug (sImageTableEntry is 12 bytes on both
  32- and 64-bit, no pointer members -- checked before assuming, per
  this session's own rule-2 bucket-(a) discipline).
- Not the primitive color (g_BulletSparkColors[1], a hardcoded literal
  for world-prop hits -- always pale yellow).
- The texture itself (explosion_smokeimages, IA8, verified via the
  asset table) has no color channels and decodes correctly.

That leaves M-110's G_LIGHTING-not-cleared mechanism (already proposed,
unconfirmed, for explosion.c) as the one that fits: bullet_spark_render
never clears G_LIGHTING either (only ORs in G_CULL_BACK), so if lighting
is on, fast3d reinterprets the vertex color bytes as a lighting normal
and computes a scene-light SHADE color instead -- independently
converging on the same mechanism at a second call site, for a visually
distinct symptom (bullet sparks vs explosions). Flagged honestly as
unresolved bucket (b) vs (c): bullet_spark_render's missing lighting
clear is decomp-verified N64 ground truth, so a src/game fix needs the
port-layer alternative ruled out first, per docs/dev-process.md's own
new taxonomy -- not assumed just because the mechanism fits.

Also documented the headless-visual-capture technique itself
(GE_INPUTSCRIPT + GE_PCDUMP together, then actually opening the PPMs)
in porting-notes.md as a reusable method -- several other long-open
visual findings (D290/D291/D294) have been stalled purely for lack of
eyes on the pixels, not for lack of a working probe.

No src/game or port/ changes -- diagnosis only, screenshots are
session-local (scratch/, gitignored). findings-index.csv regen was a
no-op.
This commit is contained in:
James Dansereau committed 2026-09-16 22:20:49 -04:00
1 parent 1e1041c6ba
commit c58797c2da
2 files changed
+33 -1

No files matched your search

+17 -1
View File
@@ -11129,6 +11129,22 @@ if that's recorded anywhere in the D280 write-up above.
**Status (from §F table):** **RESOLVED (M-115, 2026-09-12)** — byte-order fix in `src/game/image.c` scoped by pixel format (16-bit family bswap16-at-store, 32-bit identity); user playtest-confirmed. Superseded history below, which began: **OPEN — observed, not investigated.** Next: `GE_PCDUMP`/`GE_TEXDUMP` a Dam PPK explosion; check whether it's the same `gfx_lod_tile_offset` tile-sampling defect D172 fixed for blood, on an explosion DL D172's fix didn't touch. **Status: re-checked at runtime M-90 — mechanism covered by the D172 fix on current `main`; close pending a human by-eye pass (folds into PR #47 playtest).** **M-89 triage (local-Qwen, static analysis only, not yet runtime-confirmed):** the explosion DLs (`assets/oddtextures.c` `globalDL_0x078/0x120/0x1c8/0x270…`) use the same `G_CYC_2CYCLE`+`G_CC_INTERFERENCE,G_CC_MODULATEIA2`+`G_TL_TILE`, tile0=IA smoke/tile1=RGBA fire mechanism as the already-fixed D172 blood/spark records — no separate/unfixed code path found in `explosion.c` or the asset DLs. If still purple after D172's fix (which the user's Dam report postdates — needs re-confirming on current `main`), the next suspect is the GLSL combiner implementation of `G_CC_INTERFERENCE`+`G_CC_MODULATEIA2` for cycle 2 specifically (`gfx_generate_cc`, `port/fast3d/gfx_pc.cpp:312-494`), not the tile-offset fix. **Next, concrete:** run Dam (`-level_33`) with `GE_D172=1` while triggering a PPK/grenade explosion and check for "BUG" lines in the log (silence = D172's fix already covers this DL and the row can likely close on a re-test; needs a scripted-fire repro, not yet built). **M-90 runtime re-check DONE (scripted-fire repro built with `GE_INPUTSCRIPT`): the D172 fix demonstrably covers Dam's particle DLs — row can close on a human by-eye pass.** Ran `-level_33` headless with `GE_D172=1`, 64 scripted Z-pulses (`GE_INPUTSCRIPT="200:Z;215:Z;..."`, ~15 controller-reads apart) and a `GE_PCDUMP` window over the same span. Result: **40/40 particle multitex tris log `SAMPLED1=tile1 (tmem=392)`** — the correct RGBA fire tile, i.e. exactly the post-D172-fix behaviour (pre-fix these read `SAMPLED1 = tile0 (tmem=0 fmt=IA)`, the smoke texture); **zero** "BUG"/unknown-GBI/D146-abort lines; **0** purple/magenta pixels (`r>120, b>120, g<90, r-g>50, b-g>50`) across all 28 captured frames. Caveat: the user's M-87 "purple" report may predate the D172 fix on their binary (playtests run on whatever build was current); this re-check proves the sampling path is correct on current `main`, which is as far as a headless pass can take it — a final by-eye look at a live Dam PPK firefight is the honest closer (cheap for the user, folds into the D217 PR #47 playtest). **M-108 — REOPENED, the by-eye closer FAILED.** User played a live Dam (`-level_33`) PPK firefight on the current `main` build (post-D233/D234 fixes, same session as D243) and confirmed explosions are **still blue/purple**, directly contradicting M-90's clean scripted-repro result (40/40 correct-tile samples, 0 purple pixels, 28 frames). This means the M-90 `GE_INPUTSCRIPT` scripted-fire repro was very likely **not exercising the same explosion instance/DL the live firefight hits** — plausible gaps: a different explosion variant (grenade vs. bullet-impact spark vs. enemy-death vs. PPK-specific muzzle/hit effect) using a DL the scripted test's Z-pulses didn't trigger, or a state/lighting/prim-color condition (M-89's alternate suspect: the `G_CC_INTERFERENCE`+`G_CC_MODULATEIA2` cycle-2 GLSL combiner implementation, `gfx_generate_cc`, `port/fast3d/gfx_pc.cpp:312-494`) that only manifests under conditions the synthetic repro didn't reproduce. **Do not trust the M-90 all-clear for this finding going forward** — it proved one particle path clean, not that Dam's live-fire purple is fixed. Next: a live `GE_PCDUMP`/`GE_D172=1` capture during an actual purple explosion in real play (not a scripted approximation) is now the only way forward — need to pin down exactly what triggers it (PPK gunshots hitting a surface/enemy? a thrown/tossed explosive? which). **User has now confirmed (2026-09-12) this is NOT Dam-exclusive — game-wide, not just suspected.** Treat as confirmed scope, not pending a second-level check. **User wants this actively fixed, not just triaged** — next session should prioritize a live `GE_PCDUMP`/`GE_D172=1` capture during a real explosion (any level) over further static analysis, since the M-89/M-90 static passes are exhausted on this finding without producing a fix. **New HIGH-interest lead (M-110): `explosion.c` never clears `G_LIGHTING` before drawing any of the 15 particle DLs** (`globalDL_0x078` through `0x9a8` in `assets/oddtextures.c`, all structurally identical, all `G_CC_INTERFERENCE`+`G_CC_MODULATEIA2`) — grepped every particle-draw call site in `explosion.c` (`gSPClearGeometryMode` calls at lines 883/1495/1718/2227 only ever clear `G_CULL_BOTH`/`G_FOG`/`G_CULL_FRONT`, never `G_LIGHTING`) and confirmed `G_CC_INTERFERENLine truncated
**M-157 (2026-09-16/17, this session): the M-110 `G_LIGHTING` mechanism is now CROSS-VALIDATED at a second, independent particle-draw call site — and this one has a real visual capture.** While chasing D252 (see that entry's M-156), reused the `GE_INPUTSCRIPT` Z-pulse technique on a bare `-level_09` (Bunker1) boot with `GE_PCDUMP` running, aimed at a plain metal door. **Got the first-ever visual capture of this bug family**, and it is dramatic: the same bullet-impact spark, hitting the same spot on the same door across consecutive shots, renders as bright **magenta** (frame 108), fading pale-purple (114), **green** (129), **teal/cyan** (144), purple again (198) — a literal hue-wheel cycle, screenshots in `scratch/d252_impact_zoom_*.png` (session-local, not committed — small enough to re-capture on demand with the command below). This is a plain rifle/pistol bullet hitting a static door, nowhere near an explosion — a different, previously uninvestigated code path from both D219's `explosion.c` particles and D252's `IMAGE_FIRE_N` aging system.
**Traced the actual call chain and ruled out three plausible mechanisms with code evidence before finding a fourth that fits:**
1. **Not the D252 fire-tile system.** `GE_D252=1` (hooked in `import_texture_rgba16`) got **zero hits** on this exact repro — this bug fires through a different texture-loading path entirely.
2. **Not a struct-layout/ABI bug.** The spark's texture-frame lookup (`glass2.c:678` etc.) does raw pointer arithmetic — `((struct sImageTableEntry *)((u8*)thing->unk0C + frame*12))->width` — which looks exactly like the D3x/A1 "hardcoded stride into a struct that grew" pattern this session's rule-2 work catalogued. **Checked and refuted**: `sImageTableEntry` (`src/bondtypes.h:4085`) is `{u32 index; u8 width,height,level,format,depth,flagsS,flagsT,pad;}` — no pointer members, `sizeof == 12` on both 32- and 64-bit. The hardcoded `12` is correct on this platform; bucket (a) does not apply here.
3. **Not the fixed primitive-color path.** `bullet_spark_render` sets vertex color (`vtx.v.cn[]`) from `g_BulletSparkColors[arg2]` (`glass2.c:20-27`); for a world-prop hit (`propobj.c:9633`, `objHit`) `arg2` is a hardcoded literal `1`, giving a fixed pale-yellow `{0xFF,0xFF,0xC8,0xFF}` every time — cannot itself produce magenta/green/cyan.
4. **The actual texture (`explosion_smokeimages`, `assets/oddtextures.c:484`) is genuinely IA8** (`G_IM_FMT_IA, G_IM_SIZ_8b`, 6 frames, no color channels at all) — confirmed fast3d's format dispatch (`gfx_pc.cpp:~1200`) correctly routes `fmt=IA,siz=16b` (after `texSelect`'s legitimate 8b→16b RDP-upload promotion, `othermodemicrocode.c:~400`) to `import_texture_ia16`, which decodes it as plain intensity+alpha with no byte-order bug (unlike D219's RGBA16 case) — so the texture itself cannot be the color source either.
**With texture and primitive color both ruled out as direct color sources, the M-110 `G_LIGHTING` mechanism is the one that fits everything: `bullet_spark_render` (`glass2.c:606-702`) never clears `G_LIGHTING`** — its only geometry-mode call is `gSPSetGeometryMode(gdl++, G_CULL_BACK)` (line 696), which only sets a bit, never clears one. If `G_LIGHTING` is on (inherited from whatever level geometry drew immediately before this overlay), fast3d's vertex path reinterprets the authored `vtx.v.cn[]` color bytes as a lighting normal vector and computes a scene-light-derived SHADE color instead — exactly the mechanism M-110 proposed for `explosion.c`'s particles, now found completely independently in a second file, for a completely different visual symptom (bullet sparks, not explosions), both showing the same "wrong, state-dependent, cycling" color family. This is strong convergent evidence for the G_LIGHTING theory generally, even though neither call site has a *confirmed* fix yet.
**Bucket classification, per this session's own dev-process.md §taxonomy — genuinely unresolved, flagging honestly rather than guessing:** `bullet_spark_render` carries a real decomp address comment (`// Address: 7F0A3F04`), i.e. its lack of a lighting-clear call is decomp-verified N64 ground truth, not a decompilation error — so if this were simply "add `gSPClearGeometryMode(G_LIGHTING)` to glass2.c," that would be changing verified-original game code, squarely bucket (c) territory needing a rule-2 sign-off with proof N64 hardware doesn't show this (not yet gathered). But it's equally plausible this is bucket (b): a port-layer bug in *which* lights are considered, or in geometry-mode tracking/restoration between draws, that N64's actual per-DL state machine would never have produced — fast3d's vertex-lighting implementation (`gfx_pc.cpp:~1301-1346`) hasn't been audited for a normal-vs-reference-implementation divergence in this session. **Do not add a lighting-clear call to `glass2.c` or `explosion.c` without first ruling out (b)** — that's the two-question gate `docs/dev-process.md`'s §7 requires before any bucket-(c) fix.
**Next, concrete:** (1) audit `port/fast3d/gfx_pc.cpp`'s vertex-lighting path against the real RSP lighting equation for a divergence that would explain why this manifests as literal rainbow hues rather than a plausible (if wrong) lit color — a genuine implementation bug there would be bucket (b), fixable entirely in `port/`, no sign-off needed, and should be checked FIRST. (2) If that's clean, trace what actually leaves `G_LIGHTING` on at the moment these overlays draw and whether N64's real command stream would have the same state (needs either real hardware or a from-scratch RSP command trace, not available this session) before treating this as a genuine bucket-(c) case. (3) A cheap falsifier for a future session with build access: force `G_LIGHTING` off immediately before `bullet_spark_render`'s draw call under a temporary `#ifdef PORT`/env-gated probe (not a permanent fix) and see if the spark holds a stable pale-yellow — a clean result narrows the mechanism definitively without committing to a fix path yet.
Repro for future sessions (headless, no game code touched): `GE_PCDUMP="0-1200:3" GE_INPUTSCRIPT="60:Z;70:Z;80:Z;...;350:Z" ./build-pc/ge007.x86_64.exe -level_09`, then inspect `ppm/frame_0001*.ppm` visually (or via any PPM viewer) near the door directly ahead of Bunker1's spawn — the spark reproduces on nearly every shot. This headless-visual-capture technique (`GE_INPUTSCRIPT` + `GE_PCDUMP` together, then inspecting the resulting PPMs directly) is itself a reusable finding for future "does this actually look wrong" investigations in this project — no live human needed to get eyes on a rendering bug, which several other OPEN findings (D290, D291, D294) have been stalled on for exactly that reason.
## D250 — FPS is inconsistent/low on many levels after 60 Hz shipped (M-119, user QA; D248 follow-on).
@@ -11166,7 +11182,7 @@ This is a **perfect, smooth white-hot → yellow → orange → deep-red → fad
**Checked the texture-cache-key collision lead from M-141 and it does NOT hold as stated:** `TextureCacheKey` is `{texture_addr, palette_addrs, fmt, siz, palette_index, size_bytes, palette_hash}` (`gfx_pc.h:23-42`) — all 14 fire stages captured this session have **distinct `texture_addr` values** (14 different ROM addresses, confirmed in the probe log), so a same-address stale-cache-hit cannot explain cross-stage color bleed; each stage's texture is a genuinely separate cache entry. The shared `tmem=392` is an RDP TMEM slot number (where the RSP's interpretation of the texture lives during the *current* draw call), not a cache key component — irrelevant to GL-side caching. This specific theory is now ruled out; the M-141 paragraph proposing it should be read as superseded by this correction.
**Next, concrete:** since source-side decode is now proven clean, trace forward from `import_texture_rgba16`'s `gfx_rapi->upload_texture(tex_upload_buffer, width, height, gen_mipmaps)` call (`gfx_pc.cpp:718`) through to the actual GL texture object and the `G_CC_INTERFERENCE` (TEXEL0×TEXEL1) shader combiner — the smoke (tile 0, IA) × fire (tile 1, RGBA16) multiply is the last place color assembly happens before the pixel hits the framebuffer. A live capture with `GE_PCDUMP` alongside `GE_D252` during the same kind of repro (bullet impact against a wall) would let a future session correlate a specific on-screen rainbow frame against which of the 14 (now known-correct) source textures was bound at that moment — if the bound texture is stage N but the on-screen color doesn't match stage N's known-correct palette above, the bug is confirmed to be in upload/sampling/combiner, not stage-selection; if the *wrong* stage is bound (e.g. game logic's `var_s2` aging index reads garbage), that's a `src/game` question requiring the bucket-(a)/(b)/(c) check (`docs/dev-process.md`'s new taxonomy) before any fix. Raw dumps kept at `scratch/d252_raw_*.bin` (gitignored) for a follow-up session to reuse without re-capturing. **Tried, no data:** re-ran the identical Z-pulse script with `GE_D172=1` instead (the existing multitex-tile probe that checks whether TEXEL1 actually samples the tile the DL configured) hoping to catch a stale-tile-binding bug directly — **zero hits**, meaning the probe's own gate condition (`use_2cyc && comb->used_textures[1] && !rdp.tex_lod`, tmem mismatch between configured/sampled tiles) never matched during this repro. Not investigated why; could mean this particular repro's particles don't take the 2-cycle dual-texunit path the probe expects, or that path genuinely isn't where the bug lives. Don't re-run `GE_D172` on this exact repro expecting different results without first checking why it stayed silent.
**Next, concrete:** since source-side decode is now proven clean, trace forward from `import_texture_rgba16`'s `gfx_rapi->upload_texture(tex_upload_buffer, width, height, gen_mipmaps)` call (`gfx_pc.cpp:718`) through to the actual GL texture object and the `G_CC_INTERFERENCE` (TEXEL0×TEXEL1) shader combiner — the smoke (tile 0, IA) × fire (tile 1, RGBA16) multiply is the last place color assembly happens before the pixel hits the framebuffer. A live capture with `GE_PCDUMP` alongside `GE_D252` during the same kind of repro (bullet impact against a wall) would let a future session correlate a specific on-screen rainbow frame against which of the 14 (now known-correct) source textures was bound at that moment — if the bound texture is stage N but the on-screen color doesn't match stage N's known-correct palette above, the bug is confirmed to be in upload/sampling/combiner, not stage-selection; if the *wrong* stage is bound (e.g. game logic's `var_s2` aging index reads garbage), that's a `src/game` question requiring the bucket-(a)/(b)/(c) check (`docs/dev-process.md`'s new taxonomy) before any fix. Raw dumps kept at `scratch/d252_raw_*.bin` (gitignored) for a follow-up session to reuse without re-capturing. **Tried, no data:** re-ran the identical Z-pulse script with `GE_D172=1` instead (the existing multitex-tile probe that checks whether TEXEL1 actually samples the tile the DL configured) hoping to catch a stale-tile-binding bug directly — **zero hits**, meaning the probe's own gate condition (`use_2cyc && comb->used_textures[1] && !rdp.tex_lod`, tmem mismatch between configured/sampled tiles) never matched during this repro. Not investigated why; could mean this particular repro's particles don't take the 2-cycle dual-texunit path the probe expects, or that path genuinely isn't where the bug lives. Don't re-run `GE_D172` on this exact repro expecting different results without first checking why it stayed silent. **M-157 update (same session, see `## D219`):** found and visually captured the rainbow-cycling symptom in a completely different code path — `glass2.c`'s `bullet_spark_render` (plain bullet-impact sparks on walls/doors, not fire/explosion particles at all) — and traced it to the same `G_LIGHTING`-not-cleared mechanism M-110 already proposed for `explosion.c`. That entry now has the strongest lead and the only real screenshots for this whole particle-color bug family; read it before continuing D252 specifically, since the two may turn out to share one root cause once the `G_LIGHTING` mechanism is confirmed or refuted.
> The §F row had no status cell; the table row's status is derived from this entry's verdict text (ctx-opt Bucket B, 2026-09-15).
+16
View File
@@ -1392,6 +1392,22 @@ made mutually acceptable (dual validation / one-time migration).
ge007.x86_64.exe` instead to reliably terminate it from this shell, and
`ps aux | grep -i ge007` to confirm before moving on — don't trust a
`kill`/`pkill` exit code alone.
- **Headless visual verification of a rendering bug is possible without a
live human** (M-156/M-157, D252/D219 sessions) — combine `GE_INPUTSCRIPT`
(scripted controller input, e.g. `Z` pulses to fire a weapon) with
`GE_PCDUMP="lo-hi:step"` (writes `./ppm/frame_NNNNNN.ppm` — NOT a separate
configurable output dir, just `./ppm` relative to cwd; `GE_MKDIR` creates
it) in the *same* run, then actually open the resulting PPMs (Python
`PIL.Image.open(...).convert('RGB')` reads them fine, no format quirks)
instead of only grepping a probe's text log. A text-only probe proves a
code path fired; it does not prove what ended up on screen — several
long-OPEN findings (D290/D291/D294, and D252/D219's whole "rainbow
particle" family before this session) were stalled for lack of eyes on
the actual pixels, not for lack of a working probe. Build a low-res
contact-sheet montage first (tile many frames small, e.g. 160×120, to
scan for the moment of interest cheaply) before zooming into individual
full-res frames — full Dam/Bunker1 frames are 3072×1728, too large to
eyeball one at a time across a whole capture window.
- **A crash fault address of `0xffffffffffffffff` on x86-64 usually means a
non-canonical #GP from a garbage-high-bits pointer (bits 63:48 not a sign
extension of bit 47), not a computed `-1` sentinel** (cross-validated