diff --git a/docs/dev/findings.md b/docs/dev/findings.md index cbaa22cd..742db319 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -448,7 +448,13 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D216 | **`Game.SkipIntro` — boot straight to the file-select menu (M-83, port + one `#ifdef PORT` game-code line, PD parity, Phase 4 quick win).** New `s32 portSkipIntro` (`port/src/video.c`, `configRegisterInt("Game.SkipIntro", 0, 1)`), read once in `src/game/lv.c` at `LEVELID_TITLE` stage load — in the `else` of the existing `GE_STARTMENU` `#ifdef PORT` block. When set it does what every intro stage's own `!is_first_time_on_main_menu` branch does (`front.c`): `is_first_time_on_main_menu = FALSE; prev_keypresses = TRUE; maybe_is_in_menu = TRUE; menu_update = MENU_FILE_SELECT;` — i.e. it reuses the game's own post-intro route rather than inventing one, so the legal screen + Nintendo/Rare/GoldenEye logo attract loop is skipped. `GE_STARTMENU` still wins when both are set. Default 0 ⇒ untouched. | **LANDED + VERIFIED this session.** Build clean. Headless: default boot frame 90 = the TWYCROSS classification screen (10% non-black), `SkipIntro=1` frame 90 = the **SELECT FILE menu** (folders/photos/cursor all render, 89% non-black), no crash across repeated boots. Screenshots captured + eyeballed. **Owed:** nothing critical — a human boot to confirm the menu is fully interactive (cursor moves, a save slot opens). | | D217 | **1P weapon viewmodel + third-person Bond model textures render garbled — wrong/stale palette on model-DL CI textures (M-84, user QA; surfaced by D215 now that the viewmodel draws).** BUNKER1/Facility: the PP7 viewmodel has yellow+blue scrambled texels and a solid **green** patch by the hammer; Bond's in-game **watch face** shows the same green mismatch; user reports the **third-person Bond world model** does this "sometimes" too. Signature matches the **D161 family** — a CI-format tile drawn without the expected TLUT state, so fast3d's `import_texture` indexes a **stale `rdp.palette`**. D161 fixed the explicit `G_TT_NONE` case; this looks like model DLs not emitting `gDPLoadTLUT` through the port path, or the palette segment not resolving for weapon/character geometry. **Not a D215 regression** — D215 only un-gated the DL. | **FIXED (M-90/M-92/M-97; PR #47 texpool alignment + PR #49 CI-cache palette key) — the green-texture defect is fixed and merged, confirmed by two independent human playtests. The white missing-texture residual (AK47 / NPC-held world-model weapons) is split out as D228.** Status cell had gone stale at OPEN; corrected M-105. History: root cause characterised M-86; headless repro: `-level_34` FACILITY ~frame 300–400, PP7 **grip = flat green block** + green ring at the barrel joint + green-cast hand; barrel/slide upper correct black. Also green on `-level_30` DEPOT. **Clean on `-level_09` BUNKER1** (A/B control — same PP7, identical DL). **Two separate defects found:** ① **Wrong palette bound (the visible green — upstream of fast3d).** The grip is a CI4 solid-fill of palette index 0. Green `(24,65,0)` == RGBA5551 `0x1A00` == `rdp.palette[0]` at grip-import time. The grip's `count=1` model `G_LOADTLUT` is fed **different source bytes per level** for the same tile: BUNKER `00 01`→`PD_BE16`→`0x0001` (black ✓), FACILITY `1a 00`→`0x1A00` (green ✗). fast3d byteswaps + stores faithfully (proven correct by BUNKER) — the wrong bytes *arrive* from the model-DL / segment-address path. Strong tell: every *larger* model TLUT has `raw[0]==raw[1]==0x0000` with real colour from index 2 ⇒ GE's model-TLUT payload likely carries a **4-byte (2-entry) leading zero pad**, OR the port's model-TLUT source pointer is **4 bytes / 2 entries too low** (then the `count=1` grip load reads the tail of the preceding CI blob — BUNKER lucky, FACILITY not; fits "widespread across levels + weapons"). Ruled out this session: not tail-overflow (index 0 is in-range; matches M-85 `GE_D217Z`), not the fast3d byteswap. **Next:** dump the grip TLUT `G_SETTIMG` target vs the ROM/`pcmodels` sidecar PP7-grip material bytes; confirm the 4-byte offset vs N64; fix in model-DL segment resolution or `tools_pc/d43_emit.py` blob placement (sidecar regen). ② **CI texture-cache collision (proven, real, separate; fix landed).** `gfx_texture_cache_lookup` (`gfx_pc.cpp:522`) keyed CI textures on `{addr, palette_addrs[0/1], palidx, size_bytes}` with **no palette-content check**; GE reissues `gDPLoadTLUT` from repeated scratch-arena addresses with different content, so a later material could take a stale HIT and sample another material's decoded palette. **Fix on branch `fix/d217-model-texture-palette` (commit `f6fc0399`):** FNV-1a hash of the 512-byte `rdp.palette` computed only in `gfx_dp_load_tlut()`, added to the CI cache key (`port/fast3d/gfx_pc.{cpp,h}`, fast3d-internal, no `#ifdef`). Cheap, cannot worsen a decode; **owes a full 21-level `verify.sh sweep`** before merge (touches every palettized world texture's import path — cache-churn risk only). Does **not** fix defect ①. Screenshots: session scratchpad `d217/`, `fac_gun_crop.png`. Cross-ref `docs/dev/GRAPHICS-BACKLOG.md`. **User playtest matrix (M-87, raw notes — not yet triaged):** DAM/PPK: watch still green; AK47 viewmodel has white (missing-texture) patches on the magazine + barrel. FACILITY: PPK garbled **green AND blue**; 3rd-person Bond world model shows greenness on level load-in; AK47 has "weird blue / glitched" textures. JUNGLE: M16 viewmodel is orange in spots. Confirms multi-weapon (PP7/AK47/M16), multi-color (green/blue/white/orange) — consistent with the upstream model-TLUT-source theory (different trailing garbage per weapon/material) rather than one single fixed offset. **Scope clarified (M-87):** not just Bond's 1P viewmodel / 3P world model — **NPC-held weapon models (other characters' guns in third person) show the same glitched-color/white-missing texture symptom.** So the defect is in the shared weapon-model-DL/TLUT path used by *any* character holding that weapon, not something specific to Bond's model or the 1P viewmodel rig — widens the likely fix surface (weapon model loading in general) beyond "Bond's gun." **M-87 clarification:** not uniform across levels — severity and glitch *type* both vary per level (some levels show it lightly, some not at all in a given session, and the specific wrong-color/missing pattern differs), consistent with the per-material trailing-garbage theory rather than one fixed global offset. **M-88 update: defect ① is NOT a `tools_pc/d43_*.py` sidecar bug — the theory in this row is refuted; the model converter never touches texture/palette bytes at all.** Root cause narrowed to the runtime `texWriteLoadToTmemAddr`/`gfx_dp_load_tlut` TLUT-address path (`src/game/tex.c` + `port/fast3d/gfx_pc.cpp`, both out of this session's file scope). See full write-up: §H "D217 — M-88 defect ① root-cause narrowing". **M-89 update: the `gfx_dp_load_tlut` pitch/width=1 hypothesis is REFUTED** (hand-derived: it already computes `pitch=1` for every model-DL TLUT call via the GBI `width-1` packing — a proposed `pitch=1` patch would be a no-op, do not apply it) — **the addressing math is correct by inspection**. New leads, neither yet probed: (a) does `g_Textures[texturenum]`/`_imagesSegmentRomStart` actually resolve to identical ROM bytes for this weapon's grip texture on BUNKER1 vs FACILITY (an image-table/asset-resolution path distinct from the already-ruled-out per-model `TextureID`/`d43_emit.py`/`Globalimagetable`/D68 path), or (b) does the shared `texpool` arena (`image.c` `texLoad`, two-ended `leftpos`/`rightpos` allocator) serve a stale/aliased `tex->data` for what should be a fresh per-level decode. See full write-up: §H "D217 — M-89: the `gfx_dp_load_tlut` width=1/pitch hypothesis is REFUTED". **M-90: defect ① ROOT CAUSE FOUND + FIXED** — neither lead (a)/(b) was it: the texpool arena base for the weapon pools lands **%8==4 on PC** (memp bump drift from pointer-bloat allocation sizes), and `texAlignIndices` pads CI rows to *absolute* 8-byte boundaries, so every palette in a misaligned pool sits **4 bytes early** while the model-DL `G_LOADTLUT` still reads the N64 offset — the grip's count=1 load lands on the next tenant's header slot (`texnum=26` → LE s16 → `0x1A00` = the green), and larger loads read two zero-pad entries then the real palette (this is M-86's unexplained "leading zero pad" tell). **Fix:** `texInitPool` rounds the arena base up to 8 under `#ifdef PORT` (no-op on N64; ≤7-byte pool shrink) — ABI/layout-class edit, no logic touched. Verified: FACILITY 188/188 decodes aligned + draw reads `pal0=0001` (was `1a00`) + 0 green pixels in frames 250–450 (was ~1300); BUNKER1 + DEPOT still clean. **Pending:** full 21-level sweep result + human playtest eyeball of the M-87 weapon matrix. See §H "D217 — M-90". **M-92: sweep 21/21 PASS + human eyeball done — PP7/PPK/watch CONFIRMED FIXED by this alignment fix. AK47 (viewmodel + world model) and NPC-held world-model weapons (white/missing-texture patches, e.g. magazine/receiver) are a DISTINCT, still-OPEN residual — not fixed by this PR, not yet root-caused. PR #47 should still merge (real, confirmed partial fix); reopen defect ① scoped to the AK47/NPC-world-model white-texture failure mode. See §H "D217 — M-92". **M-97: independently re-confirmed by a second human playtest** (this branch merged up to date with `main`, fresh build, `-level_34` FACILITY): PP7/PPK grip+hammer clean, watch face clean, no green anywhere; **NPC-held AK47 still shows white missing-texture patches.** Two independent playtests, same split — the green defect is fixed, the white defect is not. The white residual is now split out under its own label, **D228** (rather than leaving defect ① ambiguously half-open), so this row can close on the green mechanism it actually describes. | | D218 | **Wide FOV exposes the per-level draw-distance / fog boundary — "blue artifacting" down long peripheral sightlines (M-84, user QA on the D211 FOV slider).** BUNKER→**Dam main tunnel** at `FovScale` ~150: geometry / backdrop past the level's fog-end distance shows at the widened frustum edges (blue = Dam's fog tint). Not a render bug — `fogLoadCurrentEnvironment` (`bgfog.c:305`) does `viSetZRange(Visibility.BlendMultiplier, Visibility.FarFog)`, so **`Visibility.FarFog` is both the far clip plane and the fog-saturation distance**; levels tune it for the stock ~60° FOV. | **Logged (M-84), fix proposed + parked.** Plan: `Video.DrawDistance` (percent, 100–400, default 100 ⇒ no-op) `#ifdef PORT` multiplier on `FarFog` at `bgfog.c:305` (+ the direct `arg0->Visibility.FarFog` reads just below, so fog stays consistent) plus `Video.DrawDistanceAutoFov` (0/1, default 1 — couples draw distance to `FovScale` unless `DrawDistance` is set explicitly). Clamp effective multiplier ≲2.5× (small znear ⇒ far-field z-fighting risk). **M-87: user confirms still occurring** at high FOV, and — reversing the earlier "not implemented by user request" — **now wants `Video.DrawDistance` implemented as a real QoL feature**, plus scope it to cover **LOD/detail-distance in general**, not just the fog/far-clip boundary this row root-caused. Open question for that broader ask: does GE have a *separate* geometry/object LOD-swap or draw-culling distance mechanism (distinct from `Visibility.FarFog`) worth exposing too, or is fog-distance the only meaningful "how far can you see" knob in this engine? Check for a per-object visibility-radius / simple-model-swap system before assuming `FarFog` is the whole story. | -| D219 | **PPK explosions render purple on Dam (M-87, user QA, "not 3D but noting").** Effect-color defect, not a texture-decode one — likely the D172 particle-color family (`gfx_lod_tile_offset` / `G_CC_INTERFERENCE` channel-order class) recurring on a different particle type/weapon than the D172 fix covered, or a fresh instance of the same class. | **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_INTERFERENCE,G_CC_MODULATEIA2` is used nowhere else in the codebase, so it's safe to key a probe on it. The port's vertex path (`port/fast3d/gfx_pc.cpp:1301-1346`) branches hard on `rsp.geometry_mode & G_LIGHTING`: if lighting is on when these particles draw, the authored RGBA tint in `v->v.cn[]` gets reinterpreted as a normal vector, and the resulting SHADE color (fed into `G_CC_MODULATEIA2`'s `COMBINED*SHADE`) becomes a scene-light color instead of the DL's actual red/orange fire tint — a mechanism that would plausibly read as "wrong color, state-dependent, game-wide" exactly as reported, and would explain why a synthetic scripted-fire repro (M-90, no nearby lit models) came back clean while live firefights (guards/lit models on screen) don't. **Not yet confirmed** — this depends on whatever last touched `geometry_mode` before the particle draw in a given frame, which the game's own N64-accurate command order controls; it is not yet established whether this is a genuine port bug (fast3d misordering/dropping a `G_GEOMETRYMODE` command relative to real hardware) or something that would reproduce identically on N64 (in which case it isn't actually the D219 mechanism, since N64 doesn't show purple explosions). **Instrumented, not fixed:** added a `#ifdef PORT`/`GE_D172=1`-gated log line to the existing D172 multitex-tri probe (`port/fast3d/gfx_pc.cpp` ~line 1740, `gfx_sp_tri1`) that now also prints `geometry_mode` and flags `LIGHTING=ON <-- D219 SUSPECT` whenever one of these particle triangles draws with lighting on. Sanity-checked against the known-clean M-90 repro (headless `-level_33`, 40 scripted Z-pulses): probe fires exactly 40/40 as before, all show `LIGHTING=off` — consistent with the already-known-clean case, confirming the probe works and doesn't false-positive. **Next, concrete:** run `build-pc/d172_silo.bat` (or equivalent, `GE_D172=1`) and trigger a real explosion (any level, any explosive source) near visible character models; grep the log for `D219 SUSPECT`. A hit confirms the mechanism and turns this into a real fix (clear `G_LIGHTING` in the port's particle-draw path or in fast3d's geometry-mode tracking, whichever turns out to be the actual divergence from N64); a miss across a real purple-explosion capture rules this whole lead out. | +| D219 | **PPK explosions render purple on Dam (M-87, user QA, "not 3D but noting").** Effect-color defect, not a texture-decode one — likely the D172 particle-color family (`gfx_lod_tile_offset` / `G_CC_INTERFERENCE` channel-order class) recurring on a different particle type/weapon than the D172 fix covered, or a fresh instance of the same class. | **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_INTERFERENCE,G_CC_MODULATEIA2` is used nowhere else in the codebase, so it's safe to key a probe on it. The port's vertex path (`port/fast3d/gfx_pc.cpp:1301-1346`) branches hard on `rsp.geometry_mode & G_LIGHTING`: if lighting is on when these particles draw, the authored RGBA tint in `v->v.cn[]` gets reinterpreted as a normal vector, and the resulting SHADE color (fed into `G_CC_MODULATEIA2`'s `COMBINED*SHADE`) becomes a scene-light color instead of the DL's actual red/orange fire tint — a mechanism that would plausibly read as "wrong color, state-dependent, game-wide" exactly as reported, and would explain why a synthetic scripted-fire repro (M-90, no nearby lit models) came back clean while live firefights (guards/lit models on screen) don't. **Not yet confirmed** — this depends on whatever last touched `geometry_mode` before the particle draw in a given frame, which the game's own N64-accurate command order controls; it is not yet established whether this is a genuine port bug (fast3d misordering/dropping a `G_GEOMETRYMODE` command relative to real hardware) or something that would reproduce identically on N64 (in which case it isn't actually the D219 mechanism, since N64 doesn't show purple explosions). **Instrumented, not fixed:** added a `#ifdef PORT`/`GE_D172=1`-gated log line to the existing D172 multitex-tri probe (`port/fast3d/gfx_pc.cpp` ~line 1740, `gfx_sp_tri1`) that now also prints `geometry_mode` and flags `LIGHTING=ON <-- D219 SUSPECT` whenever one of these particle triangles draws with lighting on. Sanity-checked against the known-clean M-90 repro (headless `-level_33`, 40 scripted Z-pulses): probe fires exactly 40/40 as before, all show `LIGHTING=off` — consistent with the already-known-clean case, confirming the probe works and doesn't false-positive. **Next, concrete:** run `build-pc/d172_silo.bat` (or equivalent, `GE_D172=1`) and trigger a real explosion (any level, any explosive source) near visible character models; grep the log for `D219 SUSPECT`. A hit confirms the mechanism and turns this into a real fix (clear `G_LIGHTING` in the port's particle-draw path or in fast3d's geometry-mode tracking, whichever turns out to be the actual divergence from N64); a miss across a real purple-explosion capture rules this whole lead out. + +**M-111 (2026-09-12, live session, 6 build/relaunch cycles): G_LIGHTING lead RULED OUT; texture-cache-collision lead RULED OUT; ROOT CAUSE FOUND — R/B channel swap in the decoded fire particle texture data itself.** Live-captured 4 rounds against a real user playtest (not scripted) with the user confirming explosions were still blue/purple in every capture: +1. **G_LIGHTING probe fired 5000+ times across a full playtest with explosions triggered, 100% `LIGHTING=off`, 0 `D219 SUSPECT` hits.** (First attempt used the original 40-hit cap from the D172 probe, which silently exhausted on ordinary bullet-impact particles before any explosion — fixed to exempt the lit case from the cap and add an always-on periodic counter, confirming the code path really was being hit throughout, not just early. See `port/fast3d/gfx_pc.cpp` `gfx_sp_tri1`'s D172/D219 probe block.) **G_LIGHTING is conclusively not the mechanism.** +2. **Texture-cache-collision lead also ruled out**: extended the same probe to log the actual bound `TEXEL1` texture address/GL-texture-id/byte-count for every particle draw. 7 distinct 448-byte (16×14 RGBA16, matching the DL's own `CALC_LRS(16,14,...)`) addresses observed, each internally consistent — no address aliasing, no size mismatch, no stale-cache-slot signature. +3. **Root cause: dumped the actual decoded fire textures with `GE_TEXDUMP=1`** (extended the existing dumper in `port/fast3d/gfx_opengl.cpp` to exempt 16×14 RGBA images from its own 400-image cap, same masking problem as the D172 cap) — captured all 15 `IMAGE_FIRE_0`..`14` variants as PPM after a live playtest with confirmed-purple explosions. **Every single one of the 15 decoded textures is blue/purple-dominant** (e.g. `fire000`: avg RGB `(102, 67, 138)` — B is the highest channel). Swapping the decoded R and B channels post-hoc on all 15 turns every one of them into a plausible warm fire palette (`fire000` swapped: `(138, 67, 102)`) — consistent across all 15, not a coincidence on one sample. +4. **This is NOT a bug in the shared RGBA16 decoder** (`import_texture_rgba16`, `port/fast3d/gfx_pc.cpp:653-681`) — that function's bit layout (`r=col16>>11, g=(col16>>6)&0x1f, b=(col16>>1)&0x1f, a=col16&1`) is standard N64 RGBA5551 and is proven correct by every other RGBA16 texture in the game rendering correctly; a bug in the shared function would break far more than 15 particle images. Also ruled out: the general C-array byte-order normalization (`gfx_tex_normalize_source`, D71) already runs upstream of every `import_texture_*` call including this one (`gfx_pc.cpp:1001-1008`), so it isn't a missing-normalize gap either. **The wrongness is in the decoded bytes themselves**, i.e. either (a) the ROM-resolved `Globalimagetable` entries for these 15 `IMAGE_FIRE_N` ids point at the wrong ROM offset/wrong data (an extraction/table-mapping bug, not a runtime rendering bug), or (b) these 15 images were authored/stored in a different sub-format than the rest of the game's RGBA16 assets that the shared decoder doesn't special-case. **Do NOT attempt a blanket R/B swap in `import_texture_rgba16`** — it's shared by the whole game and would break every correctly-rendering RGBA16 texture; whatever's wrong is specific to these 15 image ids. **Next, concrete:** audit how `IMAGE_FIRE_0`..`14`'s `Globalimagetable` entries resolve to ROM offsets (`port/src/gimgfixup.c`, `src/game/image.c`, `port/src/romassets_u.s`) and cross-reference against a real ROM dump / reference emulator screenshot of the same texture to confirm whether the extracted bytes are simply wrong-offset (fixable at the table/extraction level, in `port/`, no game-logic change) vs. a genuine N64-side sub-format quirk unique to `oddtextures.c`'s assets. Diagnostic infra left in tree (env-gated, inert by default): D172/D219 probe's uncapped lit-case + periodic summary counter (`gfx_sp_tri1`), and `GE_TEXDUMP`'s 16×14-exempt-from-cap fire dumper (`gfx_opengl_upload_texture`) — both safe to reuse for the follow-up. | | D220 | **1P muzzle flash position shifts upward at higher `Video.FovScale` (M-87, user QA, Jungle/M16).** Likely shares D211's noted residual: the viewmodel draws through `c_perspfovy`/unscaled fovy while the world view is FovScale-scaled (`fr.c:737`), so a flash sprite anchored to viewmodel-space vs. screen-space math can drift as the two diverge more at higher scale. | **ANALYZED (M-91) — the suspected unscaled-viewmodel root does NOT hold; this is correct perspective behavior, likely cosmetic/QoL.** The 1P hand + flash are model nodes in ONE gun DL (`gunfire.c` builds `rwmtx[1..4]` for hammer/flash/billboard nodes) drawn through the single per-frame projection from `viSetupCurrentPlayerView` — which IS FovScale-scaled on PC. So the flash and the muzzle it attaches to share projection and matrix chain: their relative position is geometrically preserved. What moves is the whole camera-anchored viewmodel: under perspective, screen offset from center ∝ 1/tan(fovy/2), so widening the FOV migrates every camera-space point (gun body included) toward screen center — a muzzle sitting slightly below/right of center visibly drifts up-left at FovScale 150. Same family as the M-85 accepted "viewmodel scales down, stays bottom-right anchored". `c_perspfovy`/`currentPlayerSetCameraScale` do stay unscaled (the D211 note) but that feeds frustum-cull/screen↔world math (→ D222), not the flash's render path. **Disposition: human call** — if it bothers at high FovScale, the fix is a QoL viewmodel re-anchor/scale-at-FOV (VIEWMODEL-RESEARCH.md family), not a fidelity bug; no game-logic change either way. Optional headless confirm: Jungle M16 fire at FovScale 100 vs 150, measure flash centroid vs muzzle in PPM pairs (expected: both shift toward center together). | | D85 | room primary/secondary DL → `texLoadFromGdl` garbage | OPEN (safety-netted; widen/pool fixes landed, geometry now renders) | | D86 · D87 | modelInitRwData truncated ptr · attract-demo BE `ramromfilestructure` | resolved | diff --git a/port/fast3d/gfx_opengl.cpp b/port/fast3d/gfx_opengl.cpp index 532a2a69..05457295 100644 --- a/port/fast3d/gfx_opengl.cpp +++ b/port/fast3d/gfx_opengl.cpp @@ -699,9 +699,19 @@ static void gfx_opengl_upload_texture(const uint8_t* rgba32_buf, uint32_t width, /* GE_TEXDUMP: PPM-dump every uploaded texture (first N) for B2/D161 triage. */ if (getenv("GE_TEXDUMP")) { static int td = 0; - if (td < 400 && width && height && width < 4096 && height < 4096) { + static int td_fire = 0; + /* D219: the 400-cap fills up during ordinary level load, long before + * a player-triggered explosion. Never suppress the 16x14 RGBA fire + * particle images (assets/oddtextures.c IMAGE_FIRE_N via + * globalDL_0x078..0x9a8) -- that's the one shape we actually need. */ + const bool is_fire_shape = (width == 16 && height == 14); + if ((is_fire_shape || td < 400) && width && height && width < 4096 && height < 4096) { char nm[128]; - snprintf(nm, sizeof nm, "texdump/t%03d_%ux%u_mip%d.ppm", td++, width, height, (int)gen_mipmaps); + if (is_fire_shape) { + snprintf(nm, sizeof nm, "texdump/fire%03d_%ux%u_mip%d.ppm", td_fire++, width, height, (int)gen_mipmaps); + } else { + snprintf(nm, sizeof nm, "texdump/t%03d_%ux%u_mip%d.ppm", td++, width, height, (int)gen_mipmaps); + } FILE* f = fopen(nm, "wb"); if (f) { fprintf(f, "P6\n%u %u\n255\n", width, height); diff --git a/port/fast3d/gfx_pc.cpp b/port/fast3d/gfx_pc.cpp index 00798190..f41b06e3 100644 --- a/port/fast3d/gfx_pc.cpp +++ b/port/fast3d/gfx_pc.cpp @@ -1077,11 +1077,24 @@ static void import_texture(int i, int tile, bool importReplacement) { /* GE_TEXRAW=1 additionally writes the raw source bytes handed to the * importer (texdump/rNNN_f_s_x.bin) -- lets a decode * bug be told apart from a source-data bug offline (D183). */ - if (tdc <= 400 && getenv("GE_TEXRAW")) { + const int tw = (int)(((rdp.texture_tile[tile].lrs - rdp.texture_tile[tile].uls) >> 2) + 1); + const int th = (int)(((rdp.texture_tile[tile].lrt - rdp.texture_tile[tile].ult) >> 2) + 1); + /* D219: same cap-exhausted-before-the-explosion problem as the + * D172/GE_TEXDUMP probes -- never suppress the fire particle image + * dump. NOTE: `tw`/`th` above come from gsDPSetTileSize (the DL sets + * the fire tile's logical wrap size to 56x56, unrelated to its real + * 16x14 pixel content), so they do NOT identify this texture -- a + * first attempt at this exemption keyed on tw/th==16/14 and silently + * never matched, burning a whole live-playtest cycle for nothing. + * The real, always-correct signature is the load's byte count: 16 * + * 14 * 2 bytes/texel (RGBA16) = 448, matching CALC_LRS(16,14,...) in + * every one of assets/oddtextures.c's 15 fire DLs and confirmed + * against the TEXEL1_bytes=448 field already proven out via the + * gfx_sp_tri1 D172/D219 probe. */ + const bool is_fire_bytes = (loaded_texture.size_bytes == 448); + if ((is_fire_bytes || tdc <= 400) && getenv("GE_TEXRAW")) { char nm[160]; - snprintf(nm, sizeof nm, "texdump/r%03d_f%u_s%u_%ux%u.bin", tdc - 1, fmt, siz, - ((rdp.texture_tile[tile].lrs - rdp.texture_tile[tile].uls) >> 2) + 1, - ((rdp.texture_tile[tile].lrt - rdp.texture_tile[tile].ult) >> 2) + 1); + snprintf(nm, sizeof nm, "texdump/r%03d_f%u_s%u_%ux%u.bin", tdc - 1, fmt, siz, tw, th); FILE* bf = fopen(nm, "wb"); if (bf) { fwrite(loaded_texture.addr, 1, loaded_texture.size_bytes, bf); fclose(bf); } } @@ -1738,7 +1751,22 @@ static void gfx_sp_tri1(uint8_t vtx1_idx, uint8_t vtx2_idx, uint8_t vtx3_idx, bo * (different tmem) -- filters out mip/LOD-bilerp false positives */ if (rdp.texture_tile[c1].tmem != rdp.texture_tile[fi].tmem) { static int d172x = 0; - if (d172x < 40) { + static int d172_total = 0; + /* D219: the original 40-hit cap (sized for M-90's short scripted + * repro) silently went dark for the rest of a real play session + * after ~40 ordinary bullet-impact particles, well before any + * actual explosion -- and the silence itself was indistinguishable + * from "no more particle draws happened at all". Never suppress + * the interesting (lit) case, and keep an always-on counter with + * periodic summaries so a long session still proves whether this + * code path is being hit throughout, not just early on. */ + const bool lit = (rsp.geometry_mode & G_LIGHTING) != 0; + d172_total++; + if (!lit && d172x >= 40) { + if (d172_total % 200 == 0) { + sysLogPrintf(LOG_NOTE, "D172: (summary) %d multitex tris seen so far, still LIGHTING=off", d172_total); + } + } else { d172x++; sysLogPrintf(LOG_NOTE, "D172: multitex tri combine=%llx tex_lod=%d first=%u | " @@ -1752,14 +1780,29 @@ static void gfx_sp_tri1(uint8_t vtx1_idx, uint8_t vtx2_idx, uint8_t vtx3_idx, bo * scene-light color instead of red/orange, which would * explain a state-dependent purple/blue that a synthetic * scripted repro (M-90) might not reproduce. */ - "geometry_mode=%08x LIGHTING=%s", + "geometry_mode=%08x LIGHTING=%s | " + /* D219 round 2: G_LIGHTING is ruled out (5000/5000 + * samples off, live-confirmed still purple/blue). Next + * suspect: a D217-style texture-cache collision -- the + * cache key is {texture_addr, fmt, siz, size_bytes, + * palette_hash} (gfx_pc.h) with no content hash for + * non-CI textures, so if two of the 15 FIRE_N images + * ever resolve to the same address (Globalimagetable + * fixup aliasing) or the cache evicts/reuses a texture_id + * without a real re-upload, this tile's GL texture could + * be serving stale/wrong-image content while every + * bookkeeping field above still looks correct. */ + "TEXEL1_addr=%p TEXEL1_texid=%u TEXEL1_bytes=%u", (unsigned long long)rdp.combine_mode, (int)rdp.tex_lod, fi, c1, rdp.texture_tile[c1].tmem, rdp.texture_tile[c1].fmt, rdp.texture_tile[c1].siz, s0, rdp.texture_tile[s0].tmem, s1, rdp.texture_tile[s1].tmem, rdp.texture_tile[s1].fmt, (s1 == s0) ? " <-- TEXEL1 == TEXEL0 (BUG)" : "", rsp.geometry_mode, - (rsp.geometry_mode & G_LIGHTING) ? "ON <-- D219 SUSPECT" : "off"); + (rsp.geometry_mode & G_LIGHTING) ? "ON <-- D219 SUSPECT" : "off", + rendering_state.textures[1] ? (const void*)rendering_state.textures[1]->first.texture_addr : nullptr, + rendering_state.textures[1] ? rendering_state.textures[1]->second.texture_id : 0u, + rendering_state.textures[1] ? rendering_state.textures[1]->first.size_bytes : 0u); } } }