From b1cf0eac4cf51fff7522cd0d794c3d16c655537e Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:05:50 -0400 Subject: [PATCH] D219: scope the fire-particle byte-order fix by pixel format; resolved M-115: re-apply M-113's store-time bswap scoped to the 16-bit GBI-size family only (PORT_PIXEL16 = bswap16 under #ifdef PORT; PORT_PIXEL32 stays identity). The fast3d importers disagree on pool byte order: import_texture_rgba16/_ia16 read manually big-endian bytes, while import_texture_rgba32 does PD_BE32() on a native load -- M-113's blanket swap hit both and regressed muzzle flash/ammo HUD (M-114). Format is the structural discriminator, not compmethod (fire frames span LOOKUP/RLE/HUFFMANBLUR/RLELOOKUP) or texnum range. Offline census of all 2698 ROM images (scratch/d219_headers.py / d219_census.txt): 94 non-zlib wide-pixel; the 16-bit family is IMAGE_FIRE_0..14 + texnum 1198-1201/2430/ 2510-2523, every ammo/flare/crosshair/muzzle-flash image is RGBA32. Runtime-verified on Dam (scripted fire): all 15 frames decode format=1, pool dumps warm R-dominant (mean opaque RGB 255,238,139). RGBA32 pool bytes bit-identical to the M-114-revert build. User playtest confirmed; D219 closed. GE_D219RAW probes generalized (no texnum filter), inert. --- docs/dev/findings-index.csv | 2 +- docs/dev/findings.md | 10 ++- docs/porting-notes.md | 67 ++++++++++++++++++ scratch/d219_census.txt | 109 +++++++++++++++++++++++++++++ scratch/d219_headers.py | 136 ++++++++++++++++++++++++++++++++++++ src/game/image.c | 111 +++++++++++++++++++++++------ 6 files changed, 410 insertions(+), 25 deletions(-) create mode 100644 scratch/d219_census.txt create mode 100644 scratch/d219_headers.py diff --git a/docs/dev/findings-index.csv b/docs/dev/findings-index.csv index d92818af..c4a95b50 100644 --- a/docs/dev/findings-index.csv +++ b/docs/dev/findings-index.csv @@ -74,7 +74,7 @@ D215,FIXED,1P weapon viewmodel never drew in-level —,"FIXED (src/game/gunfire. D216,PARTIAL,"Game.SkipIntro — boot straight to the file-select menu (M-83, port + one #ifdef PORT game-code line, PD parity, Phase 4 quick win).",LANDED + VERIFIED this session. D217,FIXED,1P weapon viewmodel + third-person Bond model textures render garbled —,FIXED (M-90/M-92/M-97; PR #47 texpool alignment + PR #49 CI-cache palette key) — D218,PROPOSED,Wide FOV exposes the per-level draw-distance / fog boundary —,"Logged (M-84), fix proposed + parked." -D219,OPEN,"PPK explosions render purple on Dam (M-87, user QA, ""not 3D but noting"").","OPEN — observed, not investigated." +D219,RESOLVED,"PPK explosions render purple on Dam (M-87, user QA, ""not 3D but noting"").","RESOLVED (M-115, 2026-09-12) —" D220,PARTIAL,"1P muzzle flash position shifts upward at higher Video.FovScale (M-87, user QA, Jungle/M16).","ANALYZED (M-91) — the suspected unscaled-viewmodel root does NOT hold; this is correct perspective behavior, likely cosmetic/QoL." D85,OPEN,room primary/secondary DL → texLoadFromGdl garbage,"OPEN (safety-netted; widen/pool fixes landed, geometry now renders)" D86 · D87,RESOLVED,modelInitRwData truncated ptr · attract-demo BE ramromfilestructure,resolved diff --git a/docs/dev/findings.md b/docs/dev/findings.md index 742db319..29e18d93 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -448,13 +448,19 @@ 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. | **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_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-112 (this session): decode-chain code review + two new inert diagnostic probes; runtime capture NOT obtained (environment limitation, see below). Confidence: LOW-MEDIUM on a narrowed next step, no fix applied.** Read the full N64 texture decompression ground truth for these assets end to end: `texLoadFromDisplayList`→`texLoad`→`texInflateNonZlib`/`texInflateZlib`→(`texReadUncompressed` | `texInflateHuffman`/`texInflateRle`+`texChannelsToPixels` | `texBuildLookup`+`texInflateLookup`/`texInflateLookupFromBuffer`) (`src/game/image.c`), plus `gimgFixupGlobalimagetable`/`gimgSyncCompiledGlobalDLs` (`port/src/gimgfixup.c`) and `import_texture_rgba16` (`port/fast3d/gfx_pc.cpp:653-681`). Findings: (1) `IMAGE_FIRE_0..14` are **not** `sImageTableEntry`-table images (unlike `IMAGE_IMPACTMULTI`/`IMAGE_IMPACTREDBRICK*`/flare/ammo images, which live in the 32 `sImageTableEntry` arrays at segment offset 0xAC8+ and get their `G_SETTIMG` marker synthesized at runtime by `texSelect`, `src/game/othermodemicrocode.c:318`) — FIRE_N's `gsDPSetTextureImage(...IMAGESEG(IMAGE_FIRE_N))` is baked directly into the compiled `globalDL_0x078..0x9a8` display lists (segment offset 0x000-0xA50) alongside the interleaved, correctly-rendering `IMAGE_SMOKE_0..14` (IA16). Both FIRE's and SMOKE's markers go through the *same* `gimgFixupGlobalimagetable` part-1 DL-bswap step and the *same* `texLoadFromDisplayList`→`texLoad` resolution — no separate/divergent table-offset math applies to FIRE_N specifically (this narrows away part of the open brief's option (a): there is no `sImageTableEntry`/`s_tbl_offs[]` bounds bug possible here, because FIRE_N never goes through that array class at all). (2) `texChannelsToPixels`'s `TEXFORMAT_RGBA16` case and `texReadUncompressed`'s `TEXFORMAT_RGBA16` case are decomp ground truth (byte-exact, `#ifdef PORT` free) and are format-generic, not texture-specific — a bug there would require a same-format sibling to also be wrong, and IMPACT/FLARE RGBA16 sprites (same `G_IM_FMT_RGBA,G_IM_SIZ_16b`, same underlying `texLoad`) are the only same-format comparison point on hand, **but this session did not runtime-confirm whether those actually render correct colors in game** — flagging as an unverified assumption inherited from earlier sessions' "shared decoder is proven correct" framing, not re-proven here. (3) `texSwapAltRowBytes` is already an unconditional no-op on `PORT` (D159, `src/game/image.c:2219`, `return;` before any format switch) so it cannot be format- or texture-selective on the port — ruled out as a contributor for FIRE specifically (it's equally a no-op for every other texture). **New, added, NOT yet exercised:** two matching `GE_D219RAW`-gated diagnostic probes (inert unless the env var is set, `#ifdef PORT`, no behavior change): (a) in `texInflateNonZlib` (`src/game/image.c`, right after the per-image header read, and again right after decode) — logs `texnum`, `format`, `width`, `height`, `compmethod` from the bitstream header, then the first row of decoded RGBA5551 pixel words, for every image shaped 16x14 (the FIRE tile shape); (b) in `import_texture_rgba16` (`port/fast3d/gfx_pc.cpp`) — logs the same pixel words as read back at upload time, so a session can diff "as decoded" vs "as consumed" to rule pool corruption/mis-addressing in or out. **Environment blocker hit this session:** could not get the built `ge007.x86_64.exe` to produce *any* stderr/log output when launched from this delegated agent's shell, across four different invocation strategies (direct foreground exec, backgrounded `&`/`nohup`, `cmd /c start foo.bat` detached, `cmd /c foo.bat` synchronous with a hard timeout) — the process either exits or is killed before a single `osSyncPrintf`/`sysLogPrintf` line reaches the redirected log file, even though every print call in this codebase does an explicit `fflush(stderr)`. A plain GUI sanity check (launching `notepad.exe` the same way) worked fine, ruling out a blanket "no GUI" sandbox restriction, so the failure is specific to this binary/environment combination and its cause was not identified. Per `env-no-runtime-verify` (session memory): runtime verification is session-dependent; treat this session's empty captures as inconclusive, not as evidence of anything. **Next, concrete (for a session that CAN capture runtime output, interactive or otherwise):** (i) run any level with `GE_D219RAW=1` during an explosion/PPK firefight (or simply on level load, since these are likely eagerly cached) and read the logged `compmethod` for `IMAGE_FIRE_N` — cross-reference against a `GE_D219RAW`-instrumented capture of a *confirmed-correct-on-screen* RGBA16 sprite from the sibling `sImageTableEntry` families (e.g. `IMAGE_IMPACTMULTI`) to see whether FIRE uses a different `TEXCOMPMETHOD_*` (the compressed-bitstream compression method, not the pixel format) than a texture confirmed to render correctly — `TEXCOMPMETHOD_LOOKUP`/`_HUFFMANLOOKUP`/`_RLELOOKUP` (`texBuildLookup`+`texInflateLookup*`) build/consume a packed-color lookup table with a completely different bit-layout code path than `_HUFFMAN`/`_HUFFMANPERHCHANNEL`/`_RLE` (`texChannelsToPixels`, per-plane R/G/B/A order); if FIRE's method differs from the working comparison texture's, that pinpoints exactly which decode function to scrutinize next, rather than continuing to treat "the RGBA16 decoder" as one monolithic suspect. (ii) if the compmethod matches, the bug is genuinely upstream of decode (ROM/asset-extraction data itself, not the decompression code) — at that point compare the decompressed byte stream against a reference N64 emulator's texture-dump of the same asset (this repo has no such reference on hand) to establish definitively whether the ROM bytes for FIRE really do decode to blue/purple on real hardware too (in which case the bug is actually downstream in the color-combiner path after all, reopening the D172/G_CC_INTERFERENCE lead this session did not have budget to re-examine) or whether the extraction into this repo's `assets/oddtextures.c`-adjacent ROM data diverges from the real cart image for just these 15 offsets. Probes left in tree, both inert by default: `GE_D219RAW` (`src/game/image.c` `texInflateNonZlib`, `port/fast3d/gfx_pc.cpp` `import_texture_rgba16`). **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. | +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. + +**M-113 (2026-09-12, interactive session, ~10 build/run cycles): FIXED — genuine byte-order (endianness) bug in `src/game/image.c`'s runtime texture decompressor, not a data/extraction/combiner issue. Verified with real decoded-texture PPM captures (visual + numeric).** Continuation of the M-112 subagent's dead-end: that session's own two probes (`GE_D219RAW` in `texInflateNonZlib`/`import_texture_rgba16`) never fired because their filter assumed the GBI tile's declared width (16) rather than the bitstream's own native width (14) — `IMAGE_FIRE_N`'s real decoded shape is **14×14**, only the GBI's TMEM tile load rounds up to 16 for row alignment. Broadening the filter and getting the probes' env vars to actually reach the process (the M-112 "no stdout" blocker was a red herring — `timeout` was stripping the child's env in that shell; a plain `&` background + `taskkill //IM` works fine, at least in this interactive session) unblocked everything: +1. **Root cause:** `src/game/image.c`'s runtime bitstream decoder (`texReadUncompressed`, `texChannelsToPixels`, `texBuildLookup`, `texInflateLookup`, `texInflateLookupFromBuffer` — ground-truth decomp, unmodified) reconstructs each "wide" (>1 byte/pixel) pixel as a plain shifted-OR integer (e.g. RGBA16 `= R<<11|G<<6|B<<1|A`) and commits it with a **native machine store** (`dst16[x] = value`). On the N64 (big-endian) that store lays the bytes down exactly as the RSP/TMEM expects. `port/fast3d/gfx_pc.cpp import_texture_rgba16` (the shared, unmodified-by-this-fix consumer) reconstructs the same value by **manually reading the byte pair as big-endian** (`addr[0]<<8 | addr[1]`) — matching the N64 layout exactly. On this little-endian x86-64 host, the native store lays down the *opposite* byte order, so every wide-format pixel decoded through these five functions comes out with its bytes reversed — for RGBA5551 specifically, byte-reversing a `RRRRRGGG|GGBBBBBA` word swaps which bits land in which output channel, which manifests as roughly an R/B swap (exactly M-111's empirical finding). **Why only fire, not every RGBA16 texture:** single-byte formats (CI4/CI8/I4/I8/IA4/IA8 — nearly everything else in the game) have no byte order to get wrong; static, compile-time `assets/*.c` C-array textures are separately normalized by `gfx_tex_normalize_source` (D71) before `import_texture_*` ever sees them. `IMAGE_FIRE_0..14` appear to be one of the only asset sets in the game that are simultaneously (a) genuinely 16-bit-per-pixel (RGBA16, not IA8/CI) **and** (b) runtime-decompressed through `texLoad`'s bitstream path rather than a static compiled array — exposing a byte-order gap that was always latent in these five functions but had no other victim on hand to surface it. +2. **Confirmed empirically, not just by inspection:** dumped raw decoded RGBA5551 words at the exact point `texChannelsToPixels`/`texInflateLookup` write them (`GE_D219RAW=1`, Dam `-level_33`, scripted PPK fire via `GE_INPUTSCRIPT`) — e.g. pre-fix texnum 2111 decoded word `0xf1c2` (R=30,G=3,B=1 under standard RGBA5551 — briefly looks *plausible* red until you realize this is the value BEFORE the byte-swap fast3d applies; the two-stage mis-cancellation is what produced the reported blue/purple, not a one-step swap — see the fix verification below for the real signal). The conclusive test is the rendered/uploaded texture, not the intermediate value in isolation. +3. **Fix (`src/game/image.c`, `#ifdef PORT`/`#else` macro, no `src/` logic changed):** added `PORT_PIXEL16`/`PORT_PIXEL32` — `__builtin_bswap16`/`32` under `#ifdef PORT`, identity under `#else` (so the N64 build is byte-for-byte unaffected, verified by inspection: the macro is a no-op expansion, not a code path change) — and wrapped every wide-pixel (`RGBA32`/`RGB24`/`RGBA16`/`IA16`/`RGB15`) store in the five functions listed above. CI/I4/I8/IA4/IA8 branches (single-byte, no byte order) are untouched. This is the D3x/AGENTS.md#2 narrow ABI/byte-layout exception class: mechanical, semantics-preserving (same numeric color, corrected byte order for the LE host), scoped to exactly the format cases that need it. +4. **Verified, numerically and visually:** `GE_TEXDUMP=1` PPM capture of the 15 `IMAGE_FIRE_N` textures post-fix — average RGB flipped from B-dominant (M-111: `fire000 (102,67,138)`) to R-dominant (post-fix `fire000 (141,127,53)`, `fire003 (147,81,24)`, `fire005 (47,10,2)`); a 10x-upscaled render of one frame and a 6-frame animation strip (frames 000-006, session scratchpad) show a clean, unambiguous fire-sprite animation — bright yellow-white core cooling through orange to red/black ash, exactly the expected in-game fire look, no purple/blue anywhere. Sanity-checked a montage of 12 ordinary (non-fire) world textures from the same Dam capture (clouds, hazard stripes, pipes, grating, concrete) — all render correctly, no regression from the format-gated change. `-level_09` (BUNKER1) and `-level_33` (Dam) both boot and render crash-free post-fix (10s+ each, no `ge007.crash.log`). **Not yet done:** a full 21-level `verify.sh sweep` and a genuine live human playtest confirming the fix in real (non-scripted) gameplay — the scripted-fire + texdump evidence here is strong but the row should stay open until a human eyeballs a real firefight on this build. `docs/porting-notes.md` gets a new entry for this bug class (runtime-decoded multi-byte pixel formats need explicit byte-order handling; static C-array textures already get this via D71/`gfx_tex_normalize_source`, runtime-decompressed ones did not). **M-114 — REGRESSION, reverted.** User playtest after M-113 landed reported 1P muzzle flash rendering blue and the ammo HUD counter rendering pink — colors inverted on textures that were fine before. Root cause: M-113's `PORT_PIXEL16`/`PORT_PIXEL32` swap is applied unconditionally to every call site in `texReadUncompressed`, `texChannelsToPixels`, `texInflateLookup`, and `texInflateLookupFromBuffer` — i.e. to every RGBA16/RGB15/IA16/RGBA32/RGB24 texture decoded through these shared functions, not just `IMAGE_FIRE_N`. This is the exact failure mode already logged above against the sibling function ("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"), just repeated one file over. Muzzle flash and the ammo HUD icon evidently decode through this same shared path without needing the swap. **Fix applied:** `PORT_PIXEL16`/`PORT_PIXEL32` reverted to identity (no-op) in `src/game/image.c`; the macro/call-site wrapping is left in place as scaffolding. **D219 is OPEN again** — the fire-particle color bug is unfixed. **Next, concrete, not yet done:** find the actual property that distinguishes `IMAGE_FIRE_N`'s decode from a working same-format sibling (M-112 already narrowed one candidate: FIRE is baked directly into a DL rather than going through the `sImageTableEntry` table that flare/ammo/impact sprites use — confirm whether that DL-embedded path is what needs the swap, or whether it's specifically `compmethod`, per M-112's still-open next step) and scope the macro to that property, not to a hardcoded texnum range. Not runtime-verified this session (static revert only, done to stop an active regression fast) — a human should confirm muzzle flash/ammo colors are back to normal on this build. **M-115 (2026-09-12, delegated session, 3 build/run cycles): fix RE-APPLIED, scoped by GBI pixel format — the M-114 regression resolved by construction, fire fixed again.** Per M-114's brief, found the property that distinguishes `IMAGE_FIRE_N`'s decode from working same-format siblings and scoped the macros to it. (1) **The discriminator is the decoded pixel FORMAT, not compmethod or texnum.** Full offline census of all 2698 ROM image-table entries (`scratch/d219_headers.py`, parsing real bitstream headers with the same MSB-first `texReadBits` contract as `src/game/image_bank.c`; zlib images skipped after the 2-byte `rz` preamble per `decompressdata`): only 94 non-zlib wide-pixel images exist — RGBA16=19, RGB15=14, IA16=1, RGBA32=60. The fire frames span compmethods RLE(4)/LOOKUP(5)/RLELOOKUP(7)/HUFFMANBLUR(8) (confirmed offline AND at runtime), so compmethod cannot scope the fix; a texnum range is a hardcoded landmine. Format IS structural: every 16-bit GBI-size format (RGBA16/RGB15/IA16) is consumed by importers that read the pool as **manually big-endian bytes** (`import_texture_rgba16`: `col16=(addr[0]<<8)|addr[1]`; `import_texture_ia16`: `intensity=addr[0], alpha=addr[1]`), while every 32-bit format (RGBA32/RGB24) is consumed by `import_texture_rgba32`, which does `PD_BE32()` on a **native** u32 load — it expects host-order words. So bswap-at-store is required for exactly the 16-bit family and must be identity for the 32-bit family; M-113's blanket swap hit both (M-114), M-114's full revert broke the 16-bit family again. (2) **Change (`src/game/image.c`, `#ifdef PORT` only, no call-site edits):** `PORT_PIXEL16(x)` = `__builtin_bswap16((unsigned short)(x))`; `PORT_PIXEL32(x)` = `(x)`. Every existing call site already routes by `TEXFORMAT_*` to the right macro, so M-114's scaffolding was already format-scoped per site — only the macro definitions were wrong (both bswapped). 30-line comment at the definition records the importer contract + census. (3) **Runtime verification (Dam `-level_33`, scripted Z-pulse fire via `GE_INPUTSCRIPT`, `GE_D219RAW=1` + `GE_TEXDUMP`/`GE_TEXRAW`):** all 15 fire frames decode at runtime as format=1 (RGBA16) with compmethods 4/5/7/8 exactly matching the offline census (texnum 2085 is genuinely an nc=1 LOOKUP transparent first frame — A=0 everywhere; the visible frames are 2087–2113); the raw pool dumps decode as warm RGBA5551 sprites — mean RGB over opaque texels **(255,238,139)**, R-dominant, correct vertical-blob shape (rows 3–10 of the 16×14 tile); e.g. texnum 2111's pool word is now `0xc2f1`, which the importer reads back as `0xf1c2` = R30,G3,B1 — the true decoded value M-113 recorded pre-fix, i.e. exactly the pool state whose rendered PPMs M-113 proved correct (mean 141,127,53, clean animation). (4) **Regression safety is structural, not empirical:** RGBA32/RGB24 pool bytes are bit-identical to the M-114-revert build (`PORT_PIXEL32` = identity in both), so muzzle flash / ammo HUD / flares / crosshairs — all RGBA32 — render exactly as in the state the user last confirmed fine; no 32-bit path was touched at all. The other non-zlib 16-bit images (texnum 1198–1201 RGBA16, 2430 IA16, 2510–2523 RGB15) were equally byte-reversed before this change and are now corrected by the same importer contract; they do not load during a Dam run, so their on-screen state is owed to the next human playtest in whatever level uses them. (5) **Probes left in tree** (env-gated, inert): `GE_D219RAW` generalized in `texInflateNonZlib` — header line for every non-zlib wide-pixel decode (`format <= TEXFORMAT_IA16`), first-row pixel dump for RGBA16/RGB15/IA16, no texnum filter; plus the pre-existing D172/D219 and `GE_TEXDUMP` fire-dumper probes. **Status: RESOLVED (2026-09-12)** — user playtested this build and confirmed it works (fire correct, no muzzle-flash/ammo-HUD regression); D219 closed. | | 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/docs/porting-notes.md b/docs/porting-notes.md index eadff427..3209c050 100644 --- a/docs/porting-notes.md +++ b/docs/porting-notes.md @@ -298,6 +298,33 @@ through a converter or a runtime bswap fixup reads scrambled. (`GE_IS_NULL()`) so the guard survives. Same class as the D143 textRender NULL guards. Audit any hand-rolled libc primitive in `src/` that a port NULL can reach. +- **D219 (M-112; the finding itself resolved in M-115 — kept as a triage lesson) — "the shared texture decoder is proven correct" is + a claim about *pixel format*, not *compression method*, and the two are + independent knobs on the same runtime `texLoad`.** `src/game/image.c`'s + non-zlib texture decompressor reads a per-image 4-bit `TEXCOMPMETHOD_*` + from the bitstream (uncompressed / huffman / huffman-per-channel / RLE / + lookup / huffman-lookup / RLE-lookup / huffman-blur / RLE-blur) independent + of the 4-bit pixel-`format` (RGBA32/16, IA16, I8/4, CI8/4 …). Two textures + sharing a pixel format can still take completely different code paths: + `_HUFFMAN`/`_HUFFMANPERHCHANNEL`/`_RLE` go through `texChannelsToPixels` + (per-channel planes in R,G,B,A order), while `_LOOKUP`/`_HUFFMANLOOKUP`/ + `_RLELOOKUP` go through `texBuildLookup`+`texInflateLookup*` (a packed + color table, bits read directly in final pixel layout). "Every other + RGBA16 texture in the game renders correctly, so the decoder can't be the + bug" is **not** a valid inference unless you've confirmed the *comparison* + texture used the *same* `TEXCOMPMETHOD_*` as the suspect one — they can + silently diverge into different functions despite an identical + `G_IM_FMT_RGBA,G_IM_SIZ_16b` DL declaration. Also: whether a texture's + `G_SETTIMG` marker is baked directly into a compiled display list + (`gsDPSetTextureImage(...IMAGESEG(id))` in `assets/*.c`, fixed up by + `gimgFixupGlobalimagetable`'s DL-bswap pass) vs. synthesized at runtime + from an `sImageTableEntry`'s `index` field (`texSelect`, + `src/game/othermodemicrocode.c`) is a real, checkable difference in + *resolution* path worth ruling in/out early — but both funnel into the + same `texLoadFromDisplayList`→`texLoad` call, so it is a weaker lead than + the compression-method one. See `docs/dev/findings.md` D219 for the full + writeup and an env-gated (`GE_D219RAW`) diagnostic left in the tree to + check this on the next session that can capture runtime output. - **D157 — a small value in the LOW BYTE of a BE `s32` word, read as `s8` at the word's last offset, reads 0 on LE after the converter byte-swaps it.** Same family as D151/D139. `struct objective_entry.difficulty` is `s8` at @@ -660,6 +687,46 @@ through a converter or a runtime bswap fixup reads scrambled. noise data is flat (~3.7) at every pitch. That three-way split settles decode-vs-pitch-vs-source-data in one pass with no rebuild. +- **D219 — a runtime bitstream decoder that stores a "wide" (>1 byte/pixel) + reconstructed value via a native machine write needs an explicit + byte-order fixup on PC; a compile-time C-array asset does not. And the + fixup is per *importer family*, not per texture: scope it by pixel + format, never by texnum range or compmethod (M-113/M-114/M-115).** + `src/game/image.c`'s texture decompressor (`texReadUncompressed`, + `texChannelsToPixels`, `texBuildLookup`, `texInflateLookup`, + `texInflateLookupFromBuffer`) builds each wide pixel as a shifted-OR + integer and does a native store — correct on N64 (native == big-endian), + byte-reversed on x86-64. This is the **opposite** of the usual "BE ROM + data read as LE" direction in this section: here the PORT *host* writes + native-endian and a downstream *shared, otherwise-correct* consumer has + opinions about byte order. The trap M-113→M-114 hit: the fast3d importer + family is NOT uniform. `import_texture_rgba16`/`_ia16` (G_IM_SIZ_16b: + RGBA16, RGB15, IA16) read the pool as **manually big-endian bytes** + (`(addr[0]<<8)|addr[1]`) → the decoder must store `bswap16(value)`; + `import_texture_rgba32` (G_IM_SIZ_32b: RGBA32, RGB24) does `PD_BE32()` + on a **native** u32 load → it wants host-order words and a store-time + swap *inverts* them (that was the muzzle-flash-blue/ammo-pink + regression). So the correct scope is the decoded `TEXFORMAT_*` — which + every call site already switches on — not compmethod (the fire frames + span LOOKUP/RLE/HUFFMANBLUR/RLELOOKUP) and not a hardcoded texnum range. + Static `assets/*.c` C-array textures don't hit this because + `gfx_tex_normalize_source` (D71) already normalizes them upstream of + `import_texture_*`; only assets that are BOTH genuinely multi-byte-per- + pixel AND decompressed at runtime through `texLoad`'s bitstream path are + exposed. Offline ROM census (M-115): 94 such images — the 16-bit family + is exactly `IMAGE_FIRE_0..14` + texnum 1198–1201/2430/2510–2523, and + every ammo/flare/crosshair/muzzle-flash wide-pixel image is RGBA32. + General rule for this bug class: before "fixing" a shared decoder's + byte order, enumerate **every consumer** of the buffer and check which + endianness each one assumes — the fix belongs at the store site, keyed + on the format each consumer family expects. + Final state (M-115, resolved): `PORT_PIXEL16` = bswap16 under `#ifdef PORT` + (16-bit family only), `PORT_PIXEL32` = identity, wrapped around every + wide-pixel store in the five functions above; CI/I4/I8/IA4/IA8 (single + byte, no byte order) untouched. The intermediate history — M-113's blanket + swap regressing muzzle flash/ammo HUD (M-114), reverted to identity, then + M-115's format-scoped re-application — is in `docs/dev/findings.md` D219. + ## D2. The HUD/model "X-mirror" (D114/D116) — RESOLVED: it was an upside-down capture **M-33 (finding D168).** There was no mirror. `gfx_opengl_dump_bound_fbo` diff --git a/scratch/d219_census.txt b/scratch/d219_census.txt new file mode 100644 index 00000000..2682dcba --- /dev/null +++ b/scratch/d219_census.txt @@ -0,0 +1,109 @@ +== census (kind, format) -> count == + ('NONZLIB', 'I4'): 363 + ('NONZLIB', 'I8'): 231 + ('NONZLIB', 'IA16'): 1 + ('NONZLIB', 'IA4'): 74 + ('NONZLIB', 'IA8'): 192 + ('NONZLIB', 'RGB15'): 14 + ('NONZLIB', 'RGBA16'): 19 + ('NONZLIB', 'RGBA32'): 60 + ('ZLIB', 'IA16_CI4'): 15 + ('ZLIB', 'IA16_CI8'): 91 + ('ZLIB', 'RGBA16_CI4'): 83 + ('ZLIB', 'RGBA16_CI8'): 1555 + +== non-zlib WIDE-pixel images: 94 == + texnum= 0 IMAGE_COPYICON RGBA32 comp=7 32x28 lod=0 + texnum= 2 IMAGE_DELICON RGBA32 comp=8 32x28 lod=0 + texnum= 437 _image437_ID RGBA32 comp=8 32x16 lod=6 + texnum= 439 _image439_ID RGBA32 comp=8 32x16 lod=6 + texnum= 884 _image884_ID RGBA32 comp=4 32x32 lod=0 + texnum=1198 _image1198_ID RGBA16 comp=6 64x17 lod=6 + texnum=1199 _image1199_ID RGBA16 comp=6 64x17 lod=6 + texnum=1200 _image1200_ID RGBA16 comp=6 64x17 lod=6 + texnum=1201 _image1201_ID RGBA16 comp=6 64x17 lod=6 + texnum=2085 IMAGE_FIRE_0 RGBA16 comp=5 14x14 lod=0 + texnum=2087 IMAGE_FIRE_1 RGBA16 comp=8 14x14 lod=0 + texnum=2089 IMAGE_FIRE_2 RGBA16 comp=4 14x14 lod=0 + texnum=2091 IMAGE_FIRE_3 RGBA16 comp=8 14x14 lod=0 + texnum=2093 IMAGE_FIRE_4 RGBA16 comp=8 14x14 lod=0 + texnum=2095 IMAGE_FIRE_5 RGBA16 comp=7 14x14 lod=0 + texnum=2097 IMAGE_FIRE_6 RGBA16 comp=7 14x14 lod=0 + texnum=2099 IMAGE_FIRE_7 RGBA16 comp=4 14x14 lod=0 + texnum=2101 IMAGE_FIRE_8 RGBA16 comp=4 14x14 lod=0 + texnum=2103 IMAGE_FIRE_9 RGBA16 comp=8 14x14 lod=0 + texnum=2105 IMAGE_FIRE_10 RGBA16 comp=8 14x14 lod=0 + texnum=2107 IMAGE_FIRE_11 RGBA16 comp=4 14x14 lod=0 + texnum=2109 IMAGE_FIRE_12 RGBA16 comp=8 14x14 lod=0 + texnum=2111 IMAGE_FIRE_13 RGBA16 comp=8 14x14 lod=0 + texnum=2113 IMAGE_FIRE_14 RGBA16 comp=8 14x14 lod=0 + texnum=2114 _image2114_ID RGBA32 comp=4 32x32 lod=0 + texnum=2115 _image2115_ID RGBA32 comp=4 32x32 lod=0 + texnum=2116 _image2116_ID RGBA32 comp=4 32x32 lod=0 + texnum=2117 _image2117_ID RGBA32 comp=4 32x32 lod=0 + texnum=2119 _image2119_ID RGBA32 comp=4 32x32 lod=0 + texnum=2120 _image2120_ID RGBA32 comp=4 32x32 lod=0 + texnum=2121 _image2121_ID RGBA32 comp=4 32x32 lod=0 + texnum=2122 _image2122_ID RGBA32 comp=4 32x32 lod=0 + texnum=2123 _image2123_ID RGBA32 comp=4 32x32 lod=0 + texnum=2124 _image2124_ID RGBA32 comp=4 32x32 lod=0 + texnum=2125 _image2125_ID RGBA32 comp=4 32x32 lod=0 + texnum=2126 _image2126_ID RGBA32 comp=4 32x32 lod=0 + texnum=2127 _image2127_ID RGBA32 comp=4 32x32 lod=0 + texnum=2128 IMAGE_FLAREORANGELINE RGBA32 comp=4 16x32 lod=6 + texnum=2134 IMAGE_FLAREBLUEROUND RGBA32 comp=8 32x32 lod=0 + texnum=2135 IMAGE_FLAREBLUELINE RGBA32 comp=8 16x32 lod=6 + texnum=2137 _image2137_ID RGBA32 comp=4 32x32 lod=0 + texnum=2138 _image2138_ID RGBA32 comp=4 32x32 lod=0 + texnum=2139 _image2139_ID RGBA32 comp=4 32x32 lod=0 + texnum=2140 _image2140_ID RGBA32 comp=4 32x32 lod=0 + texnum=2141 _image2141_ID RGBA32 comp=4 32x32 lod=0 + texnum=2142 _image2142_ID RGBA32 comp=4 32x32 lod=0 + texnum=2143 _image2143_ID RGBA32 comp=4 32x32 lod=0 + texnum=2144 _image2144_ID RGBA32 comp=4 32x32 lod=0 + texnum=2145 _image2145_ID RGBA32 comp=8 32x32 lod=0 + texnum=2146 _image2146_ID RGBA32 comp=4 32x32 lod=0 + texnum=2147 _image2147_ID RGBA32 comp=4 32x32 lod=0 + texnum=2148 _image2148_ID RGBA32 comp=4 32x32 lod=0 + texnum=2149 _image2149_ID RGBA32 comp=4 32x32 lod=0 + texnum=2150 _image2150_ID RGBA32 comp=4 32x32 lod=0 + texnum=2151 _image2151_ID RGBA32 comp=4 32x32 lod=0 + texnum=2152 _image2152_ID RGBA32 comp=4 32x32 lod=0 + texnum=2153 _image2153_ID RGBA32 comp=4 32x32 lod=0 + texnum=2154 _image2154_ID RGBA32 comp=4 32x32 lod=0 + texnum=2155 _image2155_ID RGBA32 comp=4 32x32 lod=0 + texnum=2156 _image2156_ID RGBA32 comp=4 32x32 lod=0 + texnum=2157 _image2157_ID RGBA32 comp=4 32x32 lod=0 + texnum=2158 _image2158_ID RGBA32 comp=4 32x32 lod=0 + texnum=2159 _image2159_ID RGBA32 comp=4 32x32 lod=0 + texnum=2160 _image2160_ID RGBA32 comp=4 32x32 lod=0 + texnum=2161 IMAGE_ROCKETAMMO RGBA32 comp=4 7x22 lod=0 + texnum=2162 IMAGE_PLAINMINEAMMO RGBA32 comp=4 14x14 lod=0 + texnum=2163 IMAGE_GRENADEAMMO RGBA32 comp=4 14x18 lod=0 + texnum=2164 IMAGE_MAGAMMO RGBA32 comp=7 5x15 lod=0 + texnum=2165 IMAGE_GLAMMO RGBA32 comp=4 8x21 lod=0 + texnum=2166 IMAGE_KNIFEAMMO RGBA32 comp=4 6x24 lod=0 + texnum=2167 IMAGE_SHOTAMMO RGBA32 comp=4 6x20 lod=0 + texnum=2231 IMAGE_9MMAMMO RGBA32 comp=4 5x12 lod=0 + texnum=2232 IMAGE_RIFLEAMMO RGBA32 comp=4 5x28 lod=0 + texnum=2233 IMAGE_GGAMMO RGBA32 comp=4 5x12 lod=0 + texnum=2234 IMAGE_MINEAMMO RGBA32 comp=4 14x14 lod=0 + texnum=2235 IMAGE_PROXAMMO RGBA32 comp=4 14x14 lod=0 + texnum=2236 IMAGE_CROSSHAIR1 RGBA32 comp=4 32x32 lod=0 + texnum=2237 IMAGE_CROSSHAIR2 RGBA32 comp=7 32x32 lod=0 + texnum=2238 IMAGE_TIMEAMMO RGBA32 comp=4 14x14 lod=0 + texnum=2430 _image2430_ID IA16 comp=4 64x32 lod=1 + texnum=2510 _image2510_ID RGB15 comp=7 128x12 lod=2 + texnum=2511 _image2511_ID RGB15 comp=8 128x12 lod=2 + texnum=2512 _image2512_ID RGB15 comp=8 128x12 lod=2 + texnum=2513 _image2513_ID RGB15 comp=6 128x12 lod=2 + texnum=2514 _image2514_ID RGB15 comp=6 128x12 lod=2 + texnum=2515 _image2515_ID RGB15 comp=6 128x12 lod=2 + texnum=2516 _image2516_ID RGB15 comp=8 128x12 lod=2 + texnum=2517 _image2517_ID RGB15 comp=8 128x12 lod=2 + texnum=2518 _image2518_ID RGB15 comp=8 128x12 lod=2 + texnum=2519 _image2519_ID RGB15 comp=8 128x12 lod=2 + texnum=2520 _image2520_ID RGB15 comp=8 128x12 lod=2 + texnum=2521 _image2521_ID RGB15 comp=8 128x12 lod=3 + texnum=2522 _image2522_ID RGB15 comp=8 128x12 lod=3 + texnum=2523 _image2523_ID RGB15 comp=8 128x12 lod=3 diff --git a/scratch/d219_headers.py b/scratch/d219_headers.py new file mode 100644 index 00000000..16d01383 --- /dev/null +++ b/scratch/d219_headers.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""D219 offline probe: parse the non-zlib/zlib texture bitstream headers +straight out of the ROM images segment, replicating texLoad/texInflateNonZlib +header reads (src/game/image.c) so we can compare TEXCOMPMETHOD/TEXFORMAT for +IMAGE_FIRE_N vs muzzle-flash/ammo/flare/impact siblings WITHOUT running the game. + +ROM: data/ge007.ntsc-final.z64 +Images segment cart start 0x108F7DF0 (port/src/romassets_u.s) -> file offset 0x8F7DF0. +Offsets are cumulative sizes from assets/images.def (image_entries_load, no align). +""" +import re, sys + +SEG = 0x8F7DF0 +ROM = open('data/ge007.ntsc-final.z64','rb').read() + +# ---- enum name -> index from image_externs.h ---- +enummap = {} +_in_enum = False +_v = 0 +for line in open('assets/image_externs.h', encoding='utf-8', errors='replace'): + if 'typedef enum IMAGEIDS' in line: + _in_enum = True; continue + if _in_enum and line.strip().startswith('}'): + break + m = re.match(r'\s*([A-Za-z_][A-Za-z0-9_]*)\s*,?\s*(//.*)?$', line) + if m and not line.strip().startswith('//'): + enummap[m.group(1)] = _v; _v += 1 + +# ---- parse images.def: field1 is a bare index or an enum name minus IMAGE_ ---- +entries = [] +for line in open('assets/images.def', encoding='utf-8', errors='replace'): + m = re.match(r'\s*IMAGE\(\s*([A-Za-z0-9_]+)\s*,\s*(0x[0-9A-Fa-f]+|\d+)\s*,', line) + if not m: continue + f1, sz = m.group(1), int(m.group(2), 0) + if f1.isdigit(): + idx = int(f1); name = f"_image{idx}_ID" + else: + key = 'IMAGE_' + f1 if not f1.startswith('IMAGE_') else f1 + idx = enummap.get(key) + name = key + if idx is None: + # named in images.def but left _imageN_ID in the decomp enum -- + # trust position + idx = len(entries) + print(f"# note: {key} not in enum, using positional index {idx}", file=sys.stderr) + assert idx == len(entries), f"images.def out of order at {name} ({idx} vs {len(entries)})" + entries.append((name, sz)) +print(f"# {len(entries)} image entries", file=sys.stderr) + +# cumulative offsets (image_entries_load) +offs = [] +o = 0 +for name, sz in entries: + offs.append(o) + o += sz +total = o +print(f"# images segment total size per g_Textures: {total:#x} (segment room to 0xC00000-0x8F7DF0 = {0xC00000-SEG:#x})", file=sys.stderr) + +class Bits: + def __init__(self, buf, pos): + self.buf = buf; self.pos = pos; self.bit = 0 + def read(self, n): + v = 0 + for _ in range(n): + byte = self.buf[self.pos] + v = (v << 1) | ((byte >> (7 - self.bit)) & 1) + self.bit += 1 + if self.bit == 8: + self.bit = 0; self.pos += 1 + return v + +NAMES = {n:i for i,(n,_) in enumerate(entries)} + +def dump(i, name): + off = offs[i] + base = SEG + off + b0 = ROM[base] + u, z, lod = (b0>>7)&1, (b0>>6)&1, b0 & 0x3f + out = [f"texnum={i:4d} {name:28s} off={off:#08x} byte0={b0:#04x} u={u} z={z} lod={lod}"] + if z: + # zlib path: format(8), numcolours(8)+1, palette... then per-image w/h + b = Bits(ROM, base+1) + fmt = b.read(8); nc = b.read(8)+1 + pal = [b.read(16) for _ in range(nc)] + out.append(f" ZLIB format={fmt} numcolours={nc} pal0={pal[0]:#05x}") + w = b.read(8); h = b.read(8) + out.append(f" image0 {w}x{h} (zlib payload follows -- indices only)") + else: + b = Bits(ROM, base+1) + fmt = b.read(4); w = b.read(8); h = b.read(8); cm = b.read(4) + out.append(f" NONZLIB format={fmt} width={w} height={h} compmethod={cm}") + return ' '.join(out) + +FMT = {0:'RGBA32',1:'RGBA16',2:'RGB24',3:'RGB15',4:'IA16',5:'IA8',6:'IA4',7:'I8',8:'I4',9:'RGBA16_CI8',10:'RGBA16_CI4',11:'IA16_CI8',12:'IA16_CI4'} +CM = {0:'UNCOMPRESSED0',1:'UNCOMPRESSED1',2:'HUFFMAN',3:'HUFFMANPERHCHANNEL',4:'RLE',5:'LOOKUP',6:'HUFFMANLOOKUP',7:'RLELOOKUP',8:'HUFFMANBLUR',9:'RLEBLUR'} + +def dump2(i, name): + off = offs[i] + base = SEG + off + b0 = ROM[base] + z = (b0>>6)&1; lod = b0 & 0x3f + line = f"texnum={i:4d} {name:28s} off={off:#08x} byte0={b0:#04x} z={z} lod={lod}" + if z: + b = Bits(ROM, base+1) + fmt = b.read(8); nc = b.read(8)+1 + pal = [b.read(16) for _ in range(nc)] + w = b.read(8); h = b.read(8) + line += f" ZLIB fmt={FMT.get(fmt,fmt)}({fmt}) ncols={nc} pal0={pal[0]:#05x} img0={w}x{h}" + else: + b = Bits(ROM, base+1) + fmt = b.read(4); w = b.read(8); h = b.read(8); cm = b.read(4) + line += f" NONZLIB fmt={FMT.get(fmt,fmt)}({fmt}) {w}x{h} comp={CM.get(cm,cm)}({cm})" + return line + +targets = [] +# fire + smoke particle range +for i in range(2084, 2116): + targets.append(i) +# flares +for n in ['IMAGE_FLAREORANGELINE','IMAGE_FLAREBLUEROUND','IMAGE_FLAREBLUELINE','IMAGE_FLAREWHITEROUND']: + targets.append(NAMES[n]) +# ammo HUD icons +for n in ['IMAGE_ROCKETAMMO','IMAGE_PLAINMINEAMMO','IMAGE_GRENADEAMMO','IMAGE_MAGAMMO','IMAGE_GLAMMO','IMAGE_KNIFEAMMO','IMAGE_SHOTAMMO']: + targets.append(NAMES[n]) +# impact sprites +for n in ['IMAGE_IMPACT1','IMAGE_IMPACT2','IMAGE_IMPACT3','IMAGE_IMPACT4','IMAGE_IMPACTMULTI','IMAGE_IMPACTREDBRICK2','IMAGE_IMPACTBRICK2','IMAGE_IMPACTBRICK3']: + targets.append(NAMES[n]) +# smoke sprites +for n in ['IMAGE_SMOKE1','IMAGE_SMOKE2','IMAGE_SMOKEBALLS1']: + targets.append(NAMES[n]) + +seen = set() +for i in targets: + if i in seen or i >= len(entries): continue + seen.add(i) + print(dump2(i, entries[i][0])) diff --git a/src/game/image.c b/src/game/image.c index 78a4c8ec..627bea4e 100644 --- a/src/game/image.c +++ b/src/game/image.c @@ -10,6 +10,40 @@ #define TEX_ALPHA_WEIGHT 961 +/* D219 (M-115, see docs/dev/findings.md): the two fast3d importer families + * disagree about the byte order of the texpool buffer, and the split is keyed + * on the GBI texture SIZE -- which corresponds 1:1 to the pixel format + * decoded at each call site below: + * + * G_IM_SIZ_16b (RGBA16, RGB15-via-RGBA, IA16): import_texture_rgba16 / + * import_texture_ia16 read the pool as manually big-endian bytes + * ((addr[0]<<8)|addr[1]; intensity=addr[0], alpha=addr[1]). A native u16 + * store on this little-endian host is therefore read back byte-swapped, + * so the decoder must store bswap16(value). This is what IMAGE_FIRE_0..14 + * (RGBA16) needed: without it, fire particles render blue/purple (M-87/ + * M-108); with it, GE_TEXDUMP PPM captures went from B-dominant to + * R-dominant with a clean fire animation (M-113). + * + * G_IM_SIZ_32b (RGBA32, RGB24): import_texture_rgba32 does PD_BE32() on a + * NATIVE u32 load -- it expects the pool to hold native-order words. A + * store-time swap inverts those colors instead (M-114 regression: muzzle + * flash blue, ammo HUD pink). + * + * compmethod is NOT a distinguisher (the fire frames span LOOKUP/RLE/ + * HUFFMANBLUR/RLELOOKUP); format is, and every call site already routes by + * TEXFORMAT_*, so the scoping lives here. Offline ROM census + * (scratch/d219_census.txt) confirms the non-zlib 16-bit wide-pixel images + * are exactly IMAGE_FIRE_0..14 + texnum 1198-1201 (RGBA16), 2430 (IA16), + * 2510-2523 (RGB15); every ammo/flare/crosshair/muzzle-flash wide-pixel + * image is RGBA32. The blanket M-113 swap hit both families and caused + * M-114; this format-scoped version fixes the 16-bit family only. */ +#ifdef PORT +#define PORT_PIXEL16(x) __builtin_bswap16((unsigned short)(x)) +#else +#define PORT_PIXEL16(x) (x) +#endif +#define PORT_PIXEL32(x) (x) /* 32b importer expects native words -- see above */ + // bss //8008C720 #ifdef PORT @@ -924,6 +958,20 @@ s32 texInflateNonZlib(u8 *src, u8 *dst, s32 arg2, s32 forcenumimages, struct tex return 0; } +#ifdef PORT + /* D219 (docs/dev/findings.md): diagnostic-only, inert unless + * GE_D219RAW is set. Log the bitstream header for every non-zlib + * wide-pixel image (RGBA32/RGBA16/RGB24/RGB15/IA16) so a session can + * cross-reference any suspect texture's format/compmethod against the + * offline ROM census (scratch/d219_census.txt) without a texnum-range + * filter. No behavior change -- print only. */ + if (getenv("GE_D219RAW") && format <= TEXFORMAT_IA16) + { + osSyncPrintf("D219RAW texnum=%d format=%d width=%d height=%d compmethod=%d i=%d\n", + (int)g_TexNumToLoad, (int)format, (int)width, (int)height, (int)compmethod, (int)i); + } +#endif + switch (compmethod) { case TEXCOMPMETHOD_UNCOMPRESSED0: @@ -1018,6 +1066,24 @@ s32 texInflateNonZlib(u8 *src, u8 *dst, s32 arg2, s32 forcenumimages, struct tex }; } +#ifdef PORT + /* D219: dump the first row of decoded texels exactly as stored in the + * pool (post-PORT_PIXEL16, i.e. byte-swapped on PORT for the 16-bit + * family -- the bytes import_texture_rgba16/ia16 read back). Covers + * every 16-bit wide-pixel format, no texnum filter. Diagnostic-only. */ + if (getenv("GE_D219RAW") && + (format == TEXFORMAT_RGBA16 || format == TEXFORMAT_RGB15 || format == TEXFORMAT_IA16)) + { + u16 *p16 = (u16 *)&dst[totalbytesout]; + s32 dbgk; + osSyncPrintf("D219RAW decoded texnum=%d format=%d compmethod=%d width=%d height=%d pixels:", + (int)g_TexNumToLoad, (int)format, (int)compmethod, (int)width, (int)height); + for (dbgk = 0; dbgk < 12 && dbgk < width * height; dbgk++) + osSyncPrintf(" %04x", (unsigned)p16[dbgk]); + osSyncPrintf("\n"); + } +#endif + if (arg2 == 1 && forcenumimages > 0) { texSwapAltRowBytes(&dst[totalbytesout], width, height, format); @@ -1683,8 +1749,9 @@ s32 texReadUncompressed(u8 *dst, s32 width, s32 height, s32 format) case TEXFORMAT_RGBA32: for (y = 0; y < height; y++) { for (x = 0; x < width; x++) { - dst32[x] = texReadBits(16) << 16; - dst32[x] |= texReadBits(16); + u32 px = texReadBits(16) << 16; + px |= texReadBits(16); + dst32[x] = PORT_PIXEL32(px); } dst32 += (width + 3) & 0xffc; @@ -1694,7 +1761,7 @@ s32 texReadUncompressed(u8 *dst, s32 width, s32 height, s32 format) case TEXFORMAT_RGB24: for (y = 0; y < height; y++) { for (x = 0; x < width; x++) { - dst32[x] = texReadBits(24) << 8 | 0xff; + dst32[x] = PORT_PIXEL32(texReadBits(24) << 8 | 0xff); } dst32 += (width + 3) & 0xffc; @@ -1705,7 +1772,7 @@ s32 texReadUncompressed(u8 *dst, s32 width, s32 height, s32 format) case TEXFORMAT_IA16: for (y = 0; y < height; y++) { for (x = 0; x < width; x++) { - dst16[x] = texReadBits(16); + dst16[x] = PORT_PIXEL16(texReadBits(16)); } dst16 += (width + 3) & 0xffc; @@ -1715,7 +1782,7 @@ s32 texReadUncompressed(u8 *dst, s32 width, s32 height, s32 format) case TEXFORMAT_RGB15: for (y = 0; y < height; y++) { for (x = 0; x < width; x++) { - dst16[x] = texReadBits(15) << 1 | 1; + dst16[x] = PORT_PIXEL16(texReadBits(15) << 1 | 1); } dst16 += (width + 3) & 0xffc; @@ -1776,7 +1843,7 @@ s32 texChannelsToPixels(u8 *src, s32 width, s32 height, u8 *dst, s32 format) { for (x = 0; x < width; x++) { - dst32[x] = src[pos] << 24 | src[pos + mult] << 16 | src[pos + mult * 2] << 8 | src[pos + mult * 3]; + dst32[x] = PORT_PIXEL32(src[pos] << 24 | src[pos + mult] << 16 | src[pos + mult * 2] << 8 | src[pos + mult * 3]); pos++; } @@ -1790,7 +1857,7 @@ s32 texChannelsToPixels(u8 *src, s32 width, s32 height, u8 *dst, s32 format) { for (x = 0; x < width; x++) { - dst32[x] = src[pos] << 24 | src[pos + mult] << 16 | src[pos + mult * 2] << 8 | 0xff; + dst32[x] = PORT_PIXEL32(src[pos] << 24 | src[pos + mult] << 16 | src[pos + mult * 2] << 8 | 0xff); pos++; } @@ -1804,7 +1871,7 @@ s32 texChannelsToPixels(u8 *src, s32 width, s32 height, u8 *dst, s32 format) { for (x = 0; x < width; x++) { - dst16[x] = src[pos] << 11 | src[pos + mult] << 6 | src[pos + mult * 2] << 1 | src[pos + mult * 3]; + dst16[x] = PORT_PIXEL16(src[pos] << 11 | src[pos + mult] << 6 | src[pos + mult * 2] << 1 | src[pos + mult * 3]); pos++; } @@ -1818,7 +1885,7 @@ s32 texChannelsToPixels(u8 *src, s32 width, s32 height, u8 *dst, s32 format) { for (x = 0; x < width; x++) { - dst16[x] = src[pos] << 8 | src[pos + mult]; + dst16[x] = PORT_PIXEL16(src[pos] << 8 | src[pos + mult]); pos++; } @@ -1832,7 +1899,7 @@ s32 texChannelsToPixels(u8 *src, s32 width, s32 height, u8 *dst, s32 format) { for (x = 0; x < width; x++) { - dst16[x] = src[pos] << 11 | src[pos + mult] << 6 | src[pos + mult * 2] << 1 | 1; + dst16[x] = PORT_PIXEL16(src[pos] << 11 | src[pos + mult] << 6 | src[pos + mult * 2] << 1 | 1); pos++; } @@ -1940,7 +2007,7 @@ s32 texInflateLookup(s32 width, s32 height, u8 *dst, u8 *lookup, s32 numcolours, case TEXFORMAT_RGBA32: for (y = 0; y < height; y++) { for (x = 0; x < width; x++) { - dst32[x] = lookup32[texReadBits(bitspercolour)]; + dst32[x] = PORT_PIXEL32(lookup32[texReadBits(bitspercolour)]); } dst32 += (width + 3) & 0xffc; @@ -1950,7 +2017,7 @@ s32 texInflateLookup(s32 width, s32 height, u8 *dst, u8 *lookup, s32 numcolours, case TEXFORMAT_RGB24: for (y = 0; y < height; y++) { for (x = 0; x < width; x++) { - dst32[x] = lookup32[texReadBits(bitspercolour)] << 8; + dst32[x] = PORT_PIXEL32(lookup32[texReadBits(bitspercolour)] << 8); } dst32 += (width + 3) & 0xffc; @@ -1961,7 +2028,7 @@ s32 texInflateLookup(s32 width, s32 height, u8 *dst, u8 *lookup, s32 numcolours, case TEXFORMAT_IA16: for (y = 0; y < height; y++) { for (x = 0; x < width; x++) { - dst16[x] = lookup16[texReadBits(bitspercolour)]; + dst16[x] = PORT_PIXEL16(lookup16[texReadBits(bitspercolour)]); } dst16 += (width + 3) & 0xffc; @@ -1971,7 +2038,7 @@ s32 texInflateLookup(s32 width, s32 height, u8 *dst, u8 *lookup, s32 numcolours, case TEXFORMAT_RGB15: for (y = 0; y < height; y++) { for (x = 0; x < width; x++) { - dst16[x] = lookup16[texReadBits(bitspercolour)] << 1 | 1; + dst16[x] = PORT_PIXEL16(lookup16[texReadBits(bitspercolour)] << 1 | 1); } dst16 += (width + 3) & 0xffc; @@ -2060,11 +2127,11 @@ s32 texInflateLookupFromBuffer(u8 *src, s32 width, s32 height, u8 *dst, u8 *look { if (numcolours <= 256) { - dst32[x] = lookup32[src8[x]]; + dst32[x] = PORT_PIXEL32(lookup32[src8[x]]); } else { - dst32[x] = lookup32[src16[x]]; + dst32[x] = PORT_PIXEL32(lookup32[src16[x]]); } } @@ -2082,11 +2149,11 @@ s32 texInflateLookupFromBuffer(u8 *src, s32 width, s32 height, u8 *dst, u8 *look { if (numcolours <= 256) { - dst32[x] = lookup32[src8[x]] << 8 | 0xff; + dst32[x] = PORT_PIXEL32(lookup32[src8[x]] << 8 | 0xff); } else { - dst32[x] = lookup32[src16[x]] << 8 | 0xff; + dst32[x] = PORT_PIXEL32(lookup32[src16[x]] << 8 | 0xff); } } @@ -2105,11 +2172,11 @@ s32 texInflateLookupFromBuffer(u8 *src, s32 width, s32 height, u8 *dst, u8 *look { if (numcolours <= 256) { - dst16[x] = lookup16[src8[x]]; + dst16[x] = PORT_PIXEL16(lookup16[src8[x]]); } else { - dst16[x] = lookup16[src16[x]]; + dst16[x] = PORT_PIXEL16(lookup16[src16[x]]); } } @@ -2127,11 +2194,11 @@ s32 texInflateLookupFromBuffer(u8 *src, s32 width, s32 height, u8 *dst, u8 *look { if (numcolours <= 256) { - dst16[x] = lookup16[src8[x]] << 1 | 1; + dst16[x] = PORT_PIXEL16(lookup16[src8[x]] << 1 | 1); } else { - dst16[x] = lookup16[src16[x]] << 1 | 1; + dst16[x] = PORT_PIXEL16(lookup16[src16[x]] << 1 | 1); } }