Fire VISUALS wave: the authored firesmoke sheet, vertex-alpha effect cards, the case-4 wreck dressing

The "fireballs like the demo vids" arc, decomp/content-grounded end to end,
plus the live-play UX batch verified over the same sessions:

FIRESMOKE SHEET (the PFX fireball fix): every firesmokeN_scr_tex in BTFX.VMF
maps the SAME 64x64 tileable noise image bintA (variants differ only in SCROLL
rate) and firesmoke1_mtl colours it through the "fiery" ramp (0.3,0.1,0.1)->
(0.9,0.7,0.3).  The particle layer now bakes ramp(lum(bintA)) as its sprite
colour (noise detail in alpha) and SCROLLS it at firesmoke1's authored rate
via a texture-transform; the port's radial soft-edge mask moved to a second
CLAMPed stage so the WRAPPED scroll rolls flame through the sprite without
scrolling the edge away.  Old grit x radial bake kept as the no-BINTA fallback.
Impact hits, damage-band smoke and death booms all ride this layer.

AUTHORED TEXTURE SCROLL in the model path: the BMF TEXTURE records carry
SPECIAL " SCROLL u0 v0 du dv" (tag 0x2037); the draw path always supported
per-op scrolling (SetTextureScrolling) but the BGF loader never parsed it, so
every scrolling material rendered frozen.  Wired TexRef -> MatInfo -> batch ->
L4TEXOP.doScroll: the flame cards (flamebig/fire5) now roll fire noise.

VERTEX-ALPHA EFFECT CARDS (the "twisted drill bit of fire" fix): FLAMEBIG's
verts carry authored float RGBA -- white-hot base (1.0,0.99,0.97) -> dark-red
tip fading to alpha -0.2 (the DPL clamp convention).  The loader kept a flat
batch colour and drew it OPAQUE = a solid orange spike.  Corpus sweep: exactly
14 shipped BGFs carry vertex alpha, ALL effect cards (flames, MUZFLASH,
EXDISK_A/B/C, TMST_A/B/C, beam models, DECLOUDS).  Such batches now keep the
authored per-vertex gradient and route to the alpha-blend pass, unlit,
colour = texture x gradient, alpha = the vertex fade; sky objects excluded
(drawAsSky + alphaTest passes NEITHER pass filter -- DECLOUDS stays in the
sky pass).  MUZFLASH/EXDISK render correctly for free when the muzzle-model
work lands.

WRECK DRESSING (the 1996 ExplosionScripts case-4 transcription): pieces spawn
HIDDEN and reveal 0.25s after the boom (the InstanceSwitch delay, behind the
dnboom flash); flamebig hangs over the pile, Y-BILLBOARDED at the camera
(SetOffsetYaw + a camera-pos getter -- the dpl_SetDCSReorientAxes analog);
the MakeDCSFall settle arms at the reveal with the two authored rates (hulk/
debris -0.025 t^2, fires -0.01 t^2 -- the flames ride above the sinking pile
and die with it at burial).  EMPTY-PLACEHOLDER hulk guard: THRDBR.BGF is a
153-byte zero-geometry stub that "loads fine" -- vertex-count check now routes
it to the gendbr fallback (a Thor wreck was invisible).  Hulk content census
recorded: AVADBR==MADDBR==VULDBR geometry (palette-only prefix diffs),
RAPDBR==SNDDBR==STIDBR byte-identical -- wreck variety is materials + the
dressing, not unique piles.

LIVE-PLAY BATCH: muzzle resolve uses the named segmentIndex (raw +0xdc read
was layout garbage); forward launch frame (authored MuzzleVelocity +Z vs the
mech's -Z facing); dock-bottom single window (gauge strip appended below the
world viewport, 1100x600 default, BT_DEV_GAUGES_WINDOW=1 restores the separate
window); portrait sec surface unrotated CW; ammo counters live via typed
bridges (BTAmmoBinCountPtr/BTAmmoBinFeeding/BTWeaponAmmoBin -- raw bin+0x180
and a hand-rolled link walk were garbage); fourth fire key ('4' = Pinky);
panel/arc probes de-aliased (%61 prime).

KB: rendering.md (vertex-alpha card family + scroll), combat-damage.md (hulk
census + THRDBR stub), gauges-hud.md (ammo bridges).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-12 20:04:29 -05:00
co-authored by Claude Fable 5
parent bb795e2805
commit 48c9c8444f
23 changed files with 701 additions and 109 deletions
+67 -3
View File
@@ -295,6 +295,9 @@ struct MatInfo {
bool hasDiffuse = false; // material carried an explicit DIFFUSE/AMBIENT tag
std::string texPath;
int texChannel = 0; // BSL bit-slice (BMF TEXTURE tag 0x18; absent = 0)
bool texScroll = false; // TEXTURE SPECIAL " SCROLL" (tag 0x2037): UV animation
float texScrollU = 0.0f; // cycles/second
float texScrollV = 0.0f;
// RAMP (task #20): the material's 2-endpoint colour ramp (dpl_SetMaterialRamp).
// The grayscale texture's luminance indexes a low->high colour gradient -- this
// is how the IG board coloured terrain (rock 0.25,0.21,0.16->0.8,0.5,0.4=warm
@@ -316,6 +319,11 @@ struct MatInfo {
struct TexRef {
std::string map;
int channel = 0;
// TEXTURE-record SPECIAL " SCROLL u0 v0 du dv" (tag 0x2037): authored UV
// scroll rates in cycles/second (the firesmoke fire-noise roll).
bool scroll = false;
float scrollU = 0.0f;
float scrollV = 0.0f;
};
struct MaterialResolver {
@@ -333,6 +341,22 @@ struct MaterialResolver {
if (ch.id == TAG_NAME) name = chunkStr(ch);
else if (ch.id == TAG_TEXTURE_MAP) ref.map = chunkStr(ch);
else if (ch.id == TAG_BITSLICE && ch.len >= 1) ref.channel = ch.data[0];
else if (ch.id == TAG_SV_SPECIAL && ch.len > 8) {
// " SCROLL u0 v0 du dv" -- authored UV scroll (BTFX's
// firesmoke family). u0/v0 initial offsets are 0 in every
// shipped record; only the rates matter.
std::string sp = chunkStr(ch);
size_t at = sp.find("SCROLL");
if (at != std::string::npos) {
float u0 = 0, v0 = 0, du = 0, dv = 0;
if (sscanf(sp.c_str() + at + 6, "%f %f %f %f", &u0, &v0, &du, &dv) == 4
&& (du != 0.0f || dv != 0.0f)) {
ref.scroll = true;
ref.scrollU = du;
ref.scrollV = dv;
}
}
}
}
if (!name.empty() && !ref.map.empty()) out[name] = ref;
}
@@ -442,7 +466,13 @@ struct MaterialResolver {
if (tm != texMaps.end()) {
const auto& imgs = imageIndex();
auto f = imgs.find(stemLower(tm->second.map));
if (f != imgs.end()) { info.texPath = f->second; info.texChannel = tm->second.channel; }
if (f != imgs.end()) {
info.texPath = f->second;
info.texChannel = tm->second.channel;
info.texScroll = tm->second.scroll;
info.texScrollU = tm->second.scrollU;
info.texScrollV = tm->second.scrollV;
}
}
out[name] = info;
}
@@ -501,6 +531,8 @@ struct Builder {
bool currentHasDiffuse = false; // material carried an explicit diffuse tag
std::string currentTex;
int currentTexChannel = 0; // BSL bit-slice (BMF tag 0x18)
bool currentTexScroll = false; // TEXTURE SPECIAL " SCROLL" (tag 0x2037)
float currentTexScrollU = 0.0f, currentTexScrollV = 0.0f;
bool currentHasRamp = false;
float currentRampLo[3] = {0,0,0};
float currentRampHi[3] = {1,1,1};
@@ -569,18 +601,32 @@ struct Builder {
void buildPmesh(const Chunk& pmesh) {
std::vector<float> lx, ly, lz, lu, lv;
// Authored per-vertex RGBA (tags 0x0082/0x008A: 4 floats at offset 12).
// Values run OUTSIDE [0,1] (the flame card FLAMEBIG.BGF fades its tip
// to alpha -0.2 and over-brightens its base to 1.2 -- the DPL clamp
// convention, same as the .PFX negative colour ramps).
std::vector<uint32_t> lrgba;
bool vertexAlpha = false; // any authored alpha < 1 -> BLENDED batch
uint16_t vtag = 0;
for (const Chunk& ch : pmesh.children) {
int stride = vertexStride(ch.id);
if (!stride) continue;
vtag = ch.id;
int uvOff = uvOffset(ch.id);
const bool hasRGBA = (ch.id == TAG_VERTEX_XYZ_RGBA || ch.id == TAG_VERTEX_XYZ_RGBA_UV);
size_t count = ch.len / (size_t)stride;
for (size_t i = 0; i < count; ++i) {
const uint8_t* v = ch.data + i * stride;
lx.push_back(rdF32(v)); ly.push_back(rdF32(v + 4)); lz.push_back(rdF32(v + 8));
if (uvOff >= 0) { lu.push_back(rdF32(v + uvOff)); lv.push_back(rdF32(v + uvOff + 4)); }
else { lu.push_back(0.0f); lv.push_back(0.0f); }
if (hasRGBA) {
float cr = rdF32(v + 12), cg = rdF32(v + 16), cb = rdF32(v + 20), ca = rdF32(v + 24);
if (ca < 0.999f) vertexAlpha = true;
auto C8 = [](float f) { int n = (int)(f * 255.0f + 0.5f); return (uint32_t)(n < 0 ? 0 : n > 255 ? 255 : n); };
lrgba.push_back((C8(ca) << 24) | (C8(cr) << 16) | (C8(cg) << 8) | C8(cb));
} else
lrgba.push_back(0xFFFFFFFFu);
}
break;
}
@@ -670,7 +716,13 @@ struct Builder {
px.push_back(lx[i]); py.push_back(ly[i]); pz.push_back(lz[i]);
nx.push_back(0); ny.push_back(0); nz.push_back(0);
uu.push_back(lu[i] * tileU + twist * lv[i]); vv.push_back(lv[i] * tileV);
col.push_back(vcol);
// VERTEX-ALPHA batches (the flame cards) keep their AUTHORED
// per-vertex gradient (white-hot base -> dark faded tip) -- it IS
// the flame's shading, modulated by the (ramped, scrolling)
// texture in the blend pass. Opaque batches keep the flat
// tint/diffuse rule unchanged (alpha==1 skins carry no gradient
// the verified look depends on).
col.push_back(vertexAlpha ? lrgba[i] : vcol);
}
const uint32_t idxStart = (uint32_t)mesh->indices.size();
@@ -729,6 +781,10 @@ struct Builder {
: (useRamp ? rampTint : currentColor); // pure-emissive test keys on it
batch.texPath = currentTex;
batch.texChannel = currentTexChannel;
batch.texScroll = currentTexScroll;
batch.texScrollU = currentTexScrollU;
batch.texScrollV = currentTexScrollV;
batch.vertexAlpha = vertexAlpha;
batch.hasRamp = useRamp;
// tsphere warp flag: warpBands>0 signals L4D3D's ramp bake to CONTRAST-
// COMPRESS the bintA cloud toward mid-grey (BT_WARP_CONTRAST) before applying
@@ -966,6 +1022,8 @@ struct Builder {
bool savedHasDiffuse = currentHasDiffuse;
std::string savedTex = currentTex;
int savedTexChannel = currentTexChannel;
bool savedTexScroll = currentTexScroll;
float savedTexScrollU = currentTexScrollU, savedTexScrollV = currentTexScrollV;
bool savedHasRamp = currentHasRamp;
bool savedPunch = currentPunch;
bool savedShadowMat = currentShadowMat;
@@ -1048,12 +1106,15 @@ struct Builder {
currentColor = info.color; currentTex = info.texPath;
currentHasDiffuse = info.hasDiffuse;
currentTexChannel = info.texChannel;
currentTexScroll = info.texScroll;
currentTexScrollU = info.texScrollU;
currentTexScrollV = info.texScrollV;
currentHasRamp = info.hasRamp;
for (int i = 0; i < 3; ++i) { currentRampLo[i] = info.rampLo[i]; currentRampHi[i] = info.rampHi[i]; }
currentHasEmissive = info.hasEmissive;
for (int i = 0; i < 3; ++i) currentEmissive[i] = info.emissive[i];
}
else { currentColor = colorForMaterial(full); currentTex.clear(); currentHasDiffuse = false; currentTexChannel = 0; }
else { currentColor = colorForMaterial(full); currentTex.clear(); currentHasDiffuse = false; currentTexChannel = 0; currentTexScroll = false; currentTexScrollU = currentTexScrollV = 0.0f; }
// TRANSLOCATION WARP: override the material's coarse "sky" ramp with the
// NARROW LAVENDER ramp that matched the original (capture.png). The swirl
// is the bintA cloud coloured lo->hi by luminance; a wide sky ramp
@@ -1085,6 +1146,9 @@ struct Builder {
currentHasDiffuse = savedHasDiffuse;
currentTex = savedTex;
currentTexChannel = savedTexChannel;
currentTexScroll = savedTexScroll;
currentTexScrollU = savedTexScrollU;
currentTexScrollV = savedTexScrollV;
currentHasRamp = savedHasRamp;
currentPunch = savedPunch;
currentShadowMat = savedShadowMat;