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
+206 -51
View File
@@ -20,6 +20,7 @@
#include "l4particles.h"
#include "DXUtils.h"
#include "bgfload.h" // BT: SetVideoPathPriority (day/night path priority)
#include "image.h" // BT: decodeImage (the authored firesmoke sheet bintA)
using namespace std;
#include <conio.h>
@@ -936,6 +937,9 @@ void BTDrawPfx(LPDIRECT3DDEVICE9 dev, const D3DXMATRIX *view, float dt)
dev->GetTextureStageState(0, D3DTSS_ALPHAOP, &sAop);
dev->GetTextureStageState(0, D3DTSS_ALPHAARG1, &sAa1);
dev->GetTextureStageState(0, D3DTSS_ALPHAARG2, &sAa2);
DWORD sAdrU, sAdrV; // the scroll WRAPs sampler 0
dev->GetSamplerState(0, D3DSAMP_ADDRESSU, &sAdrU);
dev->GetSamplerState(0, D3DSAMP_ADDRESSV, &sAdrV);
// PREMULTIPLIED blending (ONE, INVSRCALPHA): the one model that renders BOTH
// authored particle families correctly. FIRE (bright colour, the additive
@@ -959,43 +963,62 @@ void BTDrawPfx(LPDIRECT3DDEVICE9 dev, const D3DXMATRIX *view, float dt)
dev->SetRenderState(D3DRS_DESTBLEND, s_pfxAdditive ? D3DBLEND_ONE : D3DBLEND_INVSRCALPHA);
dev->SetRenderState(D3DRS_CULLMODE, D3DCULL_NONE);
// Particle sprite texture: the beams' authentic grit sheet MASKED by a
// radial falloff -> soft round fiery blobs (an unmasked square sheet on an
// additive quad reads as a hard BOX, which the 1995 sprites never did).
// Baked once from the decoded grit; pure radial gradient if grit is absent.
static LPDIRECT3DTEXTURE9 s_pfxTex = 0;
// Particle sprite texture -- the AUTHORED firesmoke sheet. Every
// firesmokeN_scr_tex in BTFX.VMF maps the SAME 64x64 tileable noise image
// "bintA" (the variants differ only in SCROLL rate: firesmoke1 = 0.05,-0.331
// uv/s, firesmoke2 = 0,0.131, firesmoke4 = 0.05,-0.131), and firesmoke1_mtl
// colours it through the "fiery" ramp (0.3,0.1,0.1)->(0.9,0.7,0.3) -- the
// rolling orange fireball of the original. Bake ramp(lum) as the sprite
// colour with the noise detail in alpha; the RADIAL falloff lives in a
// SECOND (CLAMPed) texture stage so the WRAPPED scroll can roll flame
// through the sprite without scrolling the soft edge away. (The mask is
// port-shaped: we draw billboards where the original drew flame geometry,
// and a square scrolled sheet on a quad reads as a hard box.)
// Fallback when BINTA.VTX is absent: the old grit x radial single-tex bake.
static LPDIRECT3DTEXTURE9 s_pfxTex = 0; // fallback single-texture
static LPDIRECT3DTEXTURE9 s_pfxNoise = 0; // bintA through the "fiery" ramp
static LPDIRECT3DTEXTURE9 s_pfxMask = 0; // radial falloff (stage 1, CLAMP)
static int s_pfxTexTried = 0;
if (!s_pfxTexTried)
{
s_pfxTexTried = 1;
const int TW = 64, TH = 64;
LPDIRECT3DTEXTURE9 grit = BTGetBeamGritTexture();
unsigned char gritLum[128 * 64]; // grit is 128x64 when present
int gw = 0, gh = 0;
if (grit)
{
D3DSURFACE_DESC gd;
if (SUCCEEDED(grit->GetLevelDesc(0, &gd)) && gd.Width <= 128 && gd.Height <= 64)
{
D3DLOCKED_RECT glr;
if (SUCCEEDED(grit->LockRect(0, &glr, NULL, D3DLOCK_READONLY)))
{
gw = gd.Width; gh = gd.Height;
for (int y = 0; y < gh; ++y)
{
const DWORD *src = (const DWORD *)((const char *)glr.pBits + y * glr.Pitch);
for (int x = 0; x < gw; ++x)
gritLum[y * gw + x] = (unsigned char)(src[x] & 0xFF);
}
grit->UnlockRect(0);
}
}
}
if (SUCCEEDED(dev->CreateTexture(TW, TH, 1, 0, D3DFMT_A8R8G8B8,
D3DPOOL_MANAGED, &s_pfxTex, NULL)))
Image fireImg = decodeImage("VIDEO\\TEX\\BINTA.VTX");
if (fireImg.ok && fireImg.w > 0 && fireImg.h > 0 &&
SUCCEEDED(dev->CreateTexture(fireImg.w, fireImg.h, 1, 0, D3DFMT_A8R8G8B8,
D3DPOOL_MANAGED, &s_pfxNoise, NULL)))
{
D3DLOCKED_RECT lr;
if (SUCCEEDED(s_pfxTex->LockRect(0, &lr, NULL, 0)))
if (SUCCEEDED(s_pfxNoise->LockRect(0, &lr, NULL, 0)))
{
const float lo[3] = { 0.3f, 0.1f, 0.1f }; // RAMP "fiery" (BTFX.VMF)
const float hi[3] = { 0.9f, 0.7f, 0.3f };
for (int y = 0; y < fireImg.h; ++y)
{
DWORD *dst = (DWORD *)((char *)lr.pBits + y * lr.Pitch);
for (int x = 0; x < fireImg.w; ++x)
{
uint32_t s = fireImg.argb[(size_t)y * fireImg.w + x];
float lum = (((s >> 16) & 0xFF) + ((s >> 8) & 0xFF) + (s & 0xFF)) / (3.0f * 255.0f);
int r = (int)((lo[0] + (hi[0] - lo[0]) * lum) * 255.0f);
int g = (int)((lo[1] + (hi[1] - lo[1]) * lum) * 255.0f);
int b = (int)((lo[2] + (hi[2] - lo[2]) * lum) * 255.0f);
// alpha carries the noise detail (same 0.35 floor the grit
// bake used) -> the occlusion edge breaks up like flame
int a = (int)((0.35f + 0.65f * lum) * 255.0f);
dst[x] = ((DWORD)a << 24) | (r << 16) | (g << 8) | b;
}
}
s_pfxNoise->UnlockRect(0);
}
else { s_pfxNoise->Release(); s_pfxNoise = 0; }
}
if (s_pfxNoise &&
SUCCEEDED(dev->CreateTexture(TW, TH, 1, 0, D3DFMT_A8R8G8B8,
D3DPOOL_MANAGED, &s_pfxMask, NULL)))
{
D3DLOCKED_RECT lr;
if (SUCCEEDED(s_pfxMask->LockRect(0, &lr, NULL, 0)))
{
for (int y = 0; y < TH; ++y)
{
@@ -1008,30 +1031,125 @@ void BTDrawPfx(LPDIRECT3DDEVICE9 dev, const D3DXMATRIX *view, float dt)
float f = 1.0f - r; // radial falloff
if (f < 0.0f) f = 0.0f;
f = f * f * (3.0f - 2.0f * f); // smoothstep edge
float n = 1.0f;
if (gw > 0) // authentic grit detail
n = 0.35f + 0.65f * (gritLum[(y % gh) * gw + (x % gw)] / 255.0f);
int v = (int)(f * n * 255.0f);
if (v > 255) v = 255;
// the falloff mask lives in ALPHA too: the premultiplied
// draw (ONE, INVSRCALPHA) needs soft-edged OCCLUSION for
// smoke, not just soft-edged colour for fire
dst[x] = (v << 24) | (v << 16) | (v << 8) | v;
int v = (int)(f * 255.0f);
dst[x] = ((DWORD)v << 24) | (v << 16) | (v << 8) | v;
}
}
s_pfxTex->UnlockRect(0);
s_pfxMask->UnlockRect(0);
}
else { s_pfxMask->Release(); s_pfxMask = 0; }
}
if (s_pfxNoise && !s_pfxMask) { s_pfxNoise->Release(); s_pfxNoise = 0; }
if (!s_pfxNoise)
{
LPDIRECT3DTEXTURE9 grit = BTGetBeamGritTexture();
unsigned char gritLum[128 * 64]; // grit is 128x64 when present
int gw = 0, gh = 0;
if (grit)
{
D3DSURFACE_DESC gd;
if (SUCCEEDED(grit->GetLevelDesc(0, &gd)) && gd.Width <= 128 && gd.Height <= 64)
{
D3DLOCKED_RECT glr;
if (SUCCEEDED(grit->LockRect(0, &glr, NULL, D3DLOCK_READONLY)))
{
gw = gd.Width; gh = gd.Height;
for (int y = 0; y < gh; ++y)
{
const DWORD *src = (const DWORD *)((const char *)glr.pBits + y * glr.Pitch);
for (int x = 0; x < gw; ++x)
gritLum[y * gw + x] = (unsigned char)(src[x] & 0xFF);
}
grit->UnlockRect(0);
}
}
}
if (SUCCEEDED(dev->CreateTexture(TW, TH, 1, 0, D3DFMT_A8R8G8B8,
D3DPOOL_MANAGED, &s_pfxTex, NULL)))
{
D3DLOCKED_RECT lr;
if (SUCCEEDED(s_pfxTex->LockRect(0, &lr, NULL, 0)))
{
for (int y = 0; y < TH; ++y)
{
DWORD *dst = (DWORD *)((char *)lr.pBits + y * lr.Pitch);
for (int x = 0; x < TW; ++x)
{
float dx = (x + 0.5f) / TW * 2.0f - 1.0f;
float dy = (y + 0.5f) / TH * 2.0f - 1.0f;
float r = sqrtf(dx * dx + dy * dy);
float f = 1.0f - r; // radial falloff
if (f < 0.0f) f = 0.0f;
f = f * f * (3.0f - 2.0f * f); // smoothstep edge
float n = 1.0f;
if (gw > 0) // authentic grit detail
n = 0.35f + 0.65f * (gritLum[(y % gh) * gw + (x % gw)] / 255.0f);
int v = (int)(f * n * 255.0f);
if (v > 255) v = 255;
// the falloff mask lives in ALPHA too: the premultiplied
// draw (ONE, INVSRCALPHA) needs soft-edged OCCLUSION for
// smoke, not just soft-edged colour for fire
dst[x] = (v << 24) | (v << 16) | (v << 8) | v;
}
}
s_pfxTex->UnlockRect(0);
}
else { s_pfxTex->Release(); s_pfxTex = 0; }
}
else { s_pfxTex->Release(); s_pfxTex = 0; }
}
}
dev->SetTexture(0, s_pfxTex);
dev->SetTextureStageState(0, D3DTSS_COLOROP, s_pfxTex ? D3DTOP_MODULATE : D3DTOP_SELECTARG1);
dev->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_DIFFUSE);
dev->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_TEXTURE);
// occlusion = vertex alpha x the texture's falloff mask (soft-edged smoke)
dev->SetTextureStageState(0, D3DTSS_ALPHAOP, s_pfxTex ? D3DTOP_MODULATE : D3DTOP_SELECTARG1);
dev->SetTextureStageState(0, D3DTSS_ALPHAARG1, D3DTA_DIFFUSE);
dev->SetTextureStageState(0, D3DTSS_ALPHAARG2, D3DTA_TEXTURE);
// The authored SCROLL clock (firesmoke1's rate; the shipped variants share
// the sheet and differ only in rate, so the port rolls every sprite at the
// dominant one). Accumulate the OFFSETS and wrap each into [0,1) -- an
// unbounded time * rate loses float precision over a long session (the
// tsphere scroll-precision lesson).
static float s_pfxScrollU = 0.0f, s_pfxScrollV = 0.0f;
s_pfxScrollU += 0.05f * dt; s_pfxScrollU -= floorf(s_pfxScrollU);
s_pfxScrollV += -0.331f * dt; s_pfxScrollV -= floorf(s_pfxScrollV);
if (s_pfxNoise)
{
// stage 0: diffuse x the scrolled fire noise (WRAPped)
dev->SetTexture(0, s_pfxNoise);
dev->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_MODULATE);
dev->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_DIFFUSE);
dev->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_TEXTURE);
dev->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_MODULATE);
dev->SetTextureStageState(0, D3DTSS_ALPHAARG1, D3DTA_DIFFUSE);
dev->SetTextureStageState(0, D3DTSS_ALPHAARG2, D3DTA_TEXTURE);
D3DXMATRIX tm; D3DXMatrixIdentity(&tm);
tm._31 = s_pfxScrollU; tm._32 = s_pfxScrollV; // (u,v) translation
dev->SetTransform(D3DTS_TEXTURE0, &tm);
dev->SetTextureStageState(0, D3DTSS_TEXTURETRANSFORMFLAGS, D3DTTFF_COUNT2);
dev->SetSamplerState(0, D3DSAMP_ADDRESSU, D3DTADDRESS_WRAP);
dev->SetSamplerState(0, D3DSAMP_ADDRESSV, D3DTADDRESS_WRAP);
// stage 1: x the CLAMPed radial mask (same UV set, UNscrolled) -- both
// colour and alpha, so the premultiplied occlusion stays soft-edged
dev->SetTexture(1, s_pfxMask);
dev->SetTextureStageState(1, D3DTSS_COLOROP, D3DTOP_MODULATE);
dev->SetTextureStageState(1, D3DTSS_COLORARG1, D3DTA_CURRENT);
dev->SetTextureStageState(1, D3DTSS_COLORARG2, D3DTA_TEXTURE);
dev->SetTextureStageState(1, D3DTSS_ALPHAOP, D3DTOP_MODULATE);
dev->SetTextureStageState(1, D3DTSS_ALPHAARG1, D3DTA_CURRENT);
dev->SetTextureStageState(1, D3DTSS_ALPHAARG2, D3DTA_TEXTURE);
dev->SetTextureStageState(1, D3DTSS_TEXCOORDINDEX, 0);
dev->SetSamplerState(1, D3DSAMP_ADDRESSU, D3DTADDRESS_CLAMP);
dev->SetSamplerState(1, D3DSAMP_ADDRESSV, D3DTADDRESS_CLAMP);
dev->SetSamplerState(1, D3DSAMP_MAGFILTER, D3DTEXF_LINEAR);
dev->SetSamplerState(1, D3DSAMP_MINFILTER, D3DTEXF_LINEAR);
}
else
{
dev->SetTexture(0, s_pfxTex);
dev->SetTextureStageState(0, D3DTSS_COLOROP, s_pfxTex ? D3DTOP_MODULATE : D3DTOP_SELECTARG1);
dev->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_DIFFUSE);
dev->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_TEXTURE);
// occlusion = vertex alpha x the texture's falloff mask (soft-edged smoke)
dev->SetTextureStageState(0, D3DTSS_ALPHAOP, s_pfxTex ? D3DTOP_MODULATE : D3DTOP_SELECTARG1);
dev->SetTextureStageState(0, D3DTSS_ALPHAARG1, D3DTA_DIFFUSE);
dev->SetTextureStageState(0, D3DTSS_ALPHAARG2, D3DTA_TEXTURE);
}
D3DXMATRIX ident; D3DXMatrixIdentity(&ident);
dev->SetTransform(D3DTS_WORLD, &ident);
@@ -1108,6 +1226,17 @@ void BTDrawPfx(LPDIRECT3DDEVICE9 dev, const D3DXMATRIX *view, float dt)
&verts[0], sizeof(V));
dev->SetTexture(0, NULL);
if (s_pfxNoise)
{
// tear down the two-stage fire pipe: stage 1 back to the D3D default
// (DISABLE), the scroll transform off, sampler 0 addressing restored
dev->SetTexture(1, NULL);
dev->SetTextureStageState(1, D3DTSS_COLOROP, D3DTOP_DISABLE);
dev->SetTextureStageState(1, D3DTSS_ALPHAOP, D3DTOP_DISABLE);
dev->SetTextureStageState(0, D3DTSS_TEXTURETRANSFORMFLAGS, D3DTTFF_DISABLE);
}
dev->SetSamplerState(0, D3DSAMP_ADDRESSU, sAdrU);
dev->SetSamplerState(0, D3DSAMP_ADDRESSV, sAdrV);
dev->SetRenderState(D3DRS_LIGHTING, sLight);
dev->SetRenderState(D3DRS_FOGENABLE, sFog);
dev->SetRenderState(D3DRS_ZWRITEENABLE, sZW);
@@ -7445,6 +7574,14 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
hr = mDevice->BeginScene();
// DOCK-BOTTOM (single-window gauges): keep the world in the top region;
// the gauge strip owns the bottom band (drawn by BTDrawGaugeInset with a
// full-target viewport before EndScene). No-op unless the mode is on.
{
extern void BTApplyWorldViewport(LPDIRECT3DDEVICE9 device);
BTApplyWorldViewport(mDevice);
}
mDevice->SetFVF(L4VERTEX_FVF);
D3DXMATRIX viewTransform;
@@ -8558,11 +8695,29 @@ void
// backbuffer into the client area, so rendering with the CLIENT aspect cancels
// the stretch.
//
// DOCK-BOTTOM (single-window gauges): the world occupies the client MINUS the
// gauge strip (strip height tracks width at the panel's 480/1320 design ratio),
// so the projection aspect must come from the WORLD region or the scene
// stretches vertically by the strip's share.
static float BTWorldAspectOf(int client_w, int client_h)
{
extern int gBTGaugeDockBottom;
extern int BTGaugeStripHeightFor(int width);
float world_h = (float)client_h;
if (gBTGaugeDockBottom)
{
float strip = (float)BTGaugeStripHeightFor(client_w);
if (strip > 0.0f && strip < world_h - 8.0f)
world_h -= strip;
}
return (float)client_w / world_h;
}
void L4NotifyWindowResized(int client_w, int client_h)
{
if (client_w <= 0 || client_h <= 0)
return;
gWindowAspect = (float)client_w / (float)client_h;
gWindowAspect = BTWorldAspectOf(client_w, client_h);
DEBUG_STREAM << "[resize] client " << client_w << "x" << client_h
<< " aspect=" << gWindowAspect
<< " app=" << (void *)l4_application << "\n" << std::flush;
@@ -8579,7 +8734,7 @@ void
{
if (client_w <= 0 || client_h <= 0)
return;
gWindowAspect = (float)client_w / (float)client_h;
gWindowAspect = BTWorldAspectOf(client_w, client_h);
if (viewAngle <= 0.0f || clipFar <= clipNear)
{
DEBUG_STREAM << "[resize] projection not built yet (viewAngle="