8.5 KiB
D172 — particle/flares wrong color (magenta/cyan blood): ROOT CAUSE FOUND + FIXED (M-82)
Symptom (user-reported, GRAPHICS-BACKLOG line 31): bullet impacts / flares / blood spatter render magenta/cyan instead of dark red.
TL;DR (M-82)
Root cause: gfx_lod_tile_offset() (gfx_pc.cpp) returns 0 unconditionally on
the !gfx_detail_textures_enabled path (a D107 blurry-ceiling fix — and
port/src/video.c:225 sets that flag false at startup). Particle draws use
G_CC_INTERFERENCE (TEXEL0 * TEXEL1) with tile 0 = IA8 smoke @ TMEM 0 and
tile 1 = RGBA16 fire @ TMEM 0x188 — but with the offset pinned to 0, texunit 1
samples the smoke texture too. smoke * smoke through the rest of the combine
= the magenta/cyan. Confirmed by the GE_D172 probe on a user Silo firefight:
40/40 particle tris logged cfg1[tile=1 tmem=392 fmt=RGBA] but
SAMPLED1=tile0(tmem=0 fmt=IA).
Fix (gfx_pc.cpp gfx_lod_tile_offset): return rdp.tex_lod ? 0 : i; —
fold to the base tile only when LOD is actually active (D107's mip case,
tex_lod=1, unchanged); a genuine non-LOD 2-texture combine now gets tile i.
bunker1 verify PASS (the D107 repro level). Committed on PR #34. Owed: an
in-game eyeball that blood/sparks now read dark red.
The 0xB9 / cycle-type false trails and the probe design are kept below for the record.
Original note (superseded): Static audit done; one strong root-cause candidate with a cheap in-game verification. No code changed.
1. How particles get their color state (mechanism)
Particle triangles (explosion.c, gSPVertex + G_TRI_FILL/G_TRI_SHADE_TXTR) carry
no per-primitive material command. Their CC/tile state comes from replaying ROM-resident
state records via gSPDisplayList(gdl++, g_ExplosionDisplayLists[var_s2])
(explosion.c:894, 15-record loop, one record + its particles per iteration).
- Records:
globalDL_0x078 … globalDL_0x9a8, a 17-entry table at image-bank offset 0x28 (g_pc_gimg_off_globalDL_*), bank base cart 0x1029D160 (seeimage_bank.c:322). - Each record is also scanned at load by
texLoadFromDisplayList(src/game/image.c:475) for itsG_SETTIMG+ ABCD marker word — that part works (textures upload fine).
2. Record layout (decoded from ROM)
+0x00 E7 C3 FF FE G_RDPPIPESYNC
+0x08 B9 00 03 1D ??? <-- UNHANDLED by fast3d (see §4)
+0x10 <varies> second word of the ??? command
+0x18 FC ... G_SETCOMBINE w0 (rgb c0 | alpha c0 <<16)
+0x20 FF ... G_SETCOMBINE w1 (rgb c1 | alpha c1 <<16)
+0x28 FD ... G_SETTIMG w0 (timg pointer)
+0x30 .. 50 50 50 50 G_SETTIMG w1 (ABCD marker)
Dominant particle combine mode (14 of 17 records), decoded with the standard libultra bit layout:
- cycle 0 RGB:
TEXEL0, 0, TEXEL1, COMB_A - cycle 0 ALP:
TEXEL0A, COMB_A, SHADE, COMB_A - cycle 1 RGB:
COMBINED, 0, SHADE, COMB_A← 2-cycle modulate - cycle 1 ALP: same as cycle 0
3. fast3d audit (what is right)
G_SETCOMBINEbit extraction (gfx_pc.cpp:2786) matches the standard libultra layout exactly (verified field-by-field against the ROM words). Not the bug.- Store/decode of
rdp.combine_mode(gfx_dp_set_combine_modevsgfx_generate_cckey shifts) are consistent. Not the bug. - The generated GLSL combiner implements the RDP formula with the usual special cases;
SHADER_COMBINEDcorrectly resolves to the previous cycle's running value.
4. The 0xB9 root-cause candidate is WITHDRAWN (M-82, 2026-09-08)
The earlier audit claimed fast3d has "no case for opcode 0xB9" and that the D146
default: guard therefore aborts every particle sub-DL. That is wrong.
This build does not define F3DEX_GBI_2 (verified: no -DF3DEX_GBI_2 in
CMakeLists.txt, no #define in include/PR/gbi.h or anywhere in tree). In the
non-F3DEX2 branch of gbi.h, G_SETOTHERMODE_L = G_IMMFIRST-6 = -71, and
(uint8_t)(-71) == 0xB9. So gfx_pc.cpp:2731 case (uint8_t)G_SETOTHERMODE_L:
is case 0xB9:.
Decode of the record word w0 = 0xB900031D, w1 = <varies>:
gfx_sp_set_other_mode(C0(8,8)=3, C0(0,8)=29, w1) → sets other_mode_l bits
3..31. sft=3 == G_MDSFT_RENDERMODE, len=29 — this is exactly
gsDPSetRenderMode(w1). w1 values (0x0C184B50, 0x00504B50, …) are
render-mode / blender words. fast3d applies it correctly.
So the particle sub-DL runs to completion on PC: E7 RDPPIPESYNC (handled),
B9 SetRenderMode (handled), FC SETCOMBINE (handled), FD SETTIMG (handled).
No D146 abort. No stale-state fallthrough. The wrong-colour bug is elsewhere.
5. Cycle-type is ALSO ruled out — the real record, and where the bug is (M-82)
The actual particle records are in assets/oddtextures.c
(globalDL_0x078 … , 17 of them). One record verbatim:
gsDPPipeSync(),
gsDPSetCycleType(G_CYC_2CYCLE), // <-- record DOES set 2-cycle
gsDPSetRenderMode(G_RM_PASS, G_RM_ZB_CLD_SURF2),
gsDPSetTextureLOD(G_TL_TILE),
gsDPSetCombineMode(G_CC_INTERFERENCE, G_CC_MODULATEIA2),
gsSPTexture(..., G_TX_RENDERTILE, G_ON),
// tile 0: IMAGE_SMOKE_0 IA16 image -> loaded/rendered as IA8, 56x56
// tile 1: IMAGE_FIRE_0 RGBA16, 16x14, loaded to tmem 0x188
gsDPSetTextureLUT(G_TT_NONE),
gsDPPipeSync(),
gsSPEndDisplayList(),
So the record explicitly sets G_CYC_2CYCLE (opcode 0xBA =
G_SETOTHERMODE_H in this build), and fast3d handles 0xBA correctly:
gfx_pc.cpp:2734 → gfx_sp_set_other_mode(52, 2, w1<<32) sets other_mode_h
cycletype = 2CYC. Confirmed by probe (M-82): the GE_D172=1 SETCOMBINE
probe logged the particle combine w0=fc26a004 (= G_CC_INTERFERENCE/
G_CC_MODULATEIA2) with cycletype=1 (2CYC) every time. The cycle-type
hypothesis is dead too.
The bug is the G_CC_INTERFERENCE two-tile combine / TEXEL1 path.
G_CC_INTERFERENCE cycle-0 = TEXEL0 * TEXEL1; the record binds two
different tiles of two different formats (tile 0 IA8 smoke, tile 1 RGBA16
fire at tmem 0x188). Magenta/cyan = a TEXEL1 sampling/decode fault: wrong tmem
offset for tile 1, RGBA16 (5551) channel/endian misread, or fast3d treating
tile 1 as an LOD mip of tile 0 under G_TL_TILE. This needs a visual
render-iterate pass (headless can't see particle colour) — see §6.
Probes shipped (M-82, #ifdef PORT, GE_D172=1, inert when unset)
gfx_pc.cppG_SETCOMBINE case — logs each distinct combine + active cycletype (dedup, 64 max). Confirmed the particle combinefc26a004runs atcycletype=2CYC.gfx_pc.cppgfx_sp_tri1(after the texunit loop) — whenever a 2-cycle triangle actually consumes TEXEL1, dumpscombine_mode+ both texunits'tile / tmem / fmt / siz. This is the decisive probe: a Silo close-range guard kill withGE_D172=1should logT0[... fmt=3(IA) siz=1(8b)] T1[tmem=392 fmt=0(RGBA) siz=2(16b)]. IfT1.tmem != 392, orT1.fmt/sizare wrong, the TEXEL1 bind is the bug. If they're all correct, the fault is inimport_texture_rgba16for the second texunit or theG_CC_INTERFERENCECC generation — pair withGE_DTEX=1/GE_TEXRAW=1to dump the fire tile's bytes.
6. Fix path (next session — needs a display)
- Get a
-level_XXcapture with visible particle spray (Silo guard kill at close range per the original report, or any wall-impact spark). Note the colour. - Env-gated probe in
gfx_pc.cppimport_texture/ the TEXEL1 bind path: for tile 1 when combine == the particle key, log format/size/tmem-addr/ line_size and the first few decoded texels. Compare against the RGBA16IMAGE_FIRE_0bytes in the extracted asset. - Likely fixes, in order of suspicion: (a) tile-1 tmem address 0x188 not
honoured → TEXEL1 reads tile-0 bytes; (b) RGBA16→RGBA32 decode for the
second texunit; (c)
G_TL_TILELOD path picking the wrong tile for TEXEL1. - Re-decode
G_CC_INTERFERENCE/G_CC_MODULATEIA2as fast3d generates them (gfx_cc.cpp) and confirm the GLSL matchesTEXEL0*TEXEL1thenCOMBINED*SHADE.
Files
src/game/explosion.c— record table (165), replay loop (894)src/game/image_bank.c:322,src/game/image.c:475— record offsets / load-time scanport/fast3d/gfx_pc.cpp— SP walker cases (~2600–2930), D146 default (2920), SETCOMBINE decode (2786), combine store (2270), cc generation (312+), 2CYCLE flag (1523)rsp/graphics/gmain.s— N64 ucode ground truth for 0xB9 (dispatch table at dmem 0xbc; ginit.s missing from repo)
Note (M-82): 0xB9 is G_SETOTHERMODE_L in this non-F3DEX2 build and is already
handled by fast3d — gmain.s / ginit.s are NOT needed to resolve D172.