Impact-FX FORENSICS wave: the i860 specialfx engine, per-round detonations, the ram economy closed

The "we're off the rails" reinvestigation -- a 5-thread evidence workflow
(logs / PFX data / RES model lists / decomp / port audit) + the i860 firmware
decode, then surgical fixes.  Every claim tiered; the wrong turns are on the
record in the KB alongside the corrections.

THE OLD-STYLE SPECIALFX ENGINE [T1, firmware-decoded from VREND.MNG]:
rebuilt scratchpad/i860dis.py (binutils opcode table); mapped the dispatch
(data+0xdd0c; sfx trigger 0xf040cda0, install 0xf040cdc0, step ~0xf0413698,
instance init 0xf04128d8); decoded the heat model EXACTLY: per 30Hz board
frame h *= cool_a; RGB_ch = h_old*(h_new*cook_ch - 0.25) + 0.25 (K=0.25 =
the ember floor @VA 0xF080; kill at h <= 1e-4 @0xF0A0); alpha fades cool_b
x RAW dt (PER-SECOND -- the x30 scaling made laser hits invisible); 7s cap;
y_off = the kill plane; "variance" is DEAD DATA (binary reads "variance",
INI authors "varience").  The 13 descriptors (PPCHit/LaserHit/MissileHit/
Chunks/Sparks/Fireball...) parse from BTDPL.INI and render via the BTPfx
layer -- heat bursts draw the FIERY sheet (brightness over fire), .PFX keeps
GRAYSCALE (authored colours: DNBOOM orange, DDAM gray).

HIT-PACKAGE CENSUS [T1, RES byte-verified]: ppchit=[8] lzrhit=[9] mghit=[7,11]
canhit=[11] mslhit=[10,12,1023] explode=[6]; all 8 mech death lists identical.
Effect routing corrected in BOTH consumers: <100 = specialfx, >=1000 = psfx.

"SILVER MIST x5" ROOT CAUSE: SHKWAVE.PFX (mslhit's 1023) authors maxIssue=5
relPeriod=0.2 rate=1 -- the ONE file where rate contradicts the window; the
emitter trusted rate -> 5 shells at exactly 1Hz.  Emission rate now always
maxIssue/releasePeriod.

PER-ROUND DETONATIONS [T1 @004bef78]: every missile round spawns its own
ExplosionModelFile at ITS impact (hull + terrain) -- a volley ripples 12
fireballs like the demos; rack-tube launch spread [T3] (GUIDED rounds only --
the slot-0 deflection sent every AFC100 shell 3.4deg left/2.5deg down: the
phantom "4th gold beam"); replicant salvos detonate too (launcher index rides
the visual push).  Missile damage bundles through the shooter's messmgr with
the launcher's subsystemID (mslhit fires at the consolidated point; the
binary's dedup CONFIRMED [T1 @0049b784] -- a workflow agent's per-record
claim REFUTED by direct decomp read).

DAMAGE-BAND SEMANTICS [T1]: MechDeathHandler fires the CURRENT band effect on
any damage rise (the binary's changed-flag coalescing) -- a mauled mech under
fire smokes/burns per hit; bands 3/4 are authored fire plumes.

THE RAM ECONOMY -- CLOSED (3 layers, measured live):
 1. armor = POINTS (every zone: damageScale = 1/armorPoints, armor 50-140;
    the [zone-armor] BT_DMG_LOG dump);
 2. StaticBounce prices rams with authored moverMass ~1.3e6 -> ~59,000 pts
    @10m/s; the binary dispatches it RAW but pod MP dropped it on the local
    replicant (MECH.CPP:986 warns) -- ram damage was NETWORK-INERT; our
    task-#47 replicant forwarding surfaced it as a one-shot.  Port
    normalizes x1e-3 to the point economy [T3];
 3. contact EDGE (ramLastVictim/ramContactLinger): the binary's bounce made
    separation implicit; our gait-derived velocity re-priced full rams at
    60Hz (the respawn explode-loop).  One bump = one hit; pressed = BLOCK.

PLUS: sfx size x0.5 + 1.25m visibility floor + hot-phase occlusion [T3];
particle pool 2048->8192 (missile traffic starved laser bursts -- the
"intermittent effects"); replicant beam aging (a lost beam-END record pinned
a stale beam on forever); UV-variant noise stamps (mask-safe mirror/swap);
no scroll on particle stamps (material-path only); BT_FX_TEST/fxshot.py
self-verification harness ([beam-draw]/[zone-armor]/[collide-tx] telemetry).

KB: rendering.md (specialfx engine + census + closeout), combat-damage.md
(ram economy + band semantics).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-13 08:14:12 -05:00
co-authored by Claude Fable 5
parent 48c9c8444f
commit 065c114590
14 changed files with 4404 additions and 82 deletions
+449 -50
View File
@@ -677,6 +677,24 @@ struct BTPfxParticle
float rad, exp, dexp;
float colorWarp, alphaWarp; // def-level warps, carried per particle
float sI[4], sO[4], eI[4], eO[4];
// OLD-STYLE SPECIALFX mode (the VREND.MNG heat model, firmware-decoded
// 2026-07-12): when sfxMode != 0 the colour comes from the board's heat
// equation instead of the .PFX colour quads:
// h *= coolMul per board frame (30 Hz);
// channel = h_old*(h_new*cook_ch - 0.25) + 0.25 (0.25 = the ember floor,
// firmware constant @VA 0xF080); kill at h <= 1e-4 (@0xF0A0), at
// fade <= 0 (fade -= coolSub per board frame), or below the kill plane
// (the descriptor's y_off -- Chunks die 1m under the burst: the floor).
int sfxMode;
float heat, coolMul, coolSub, fade;
float cookR, cookG, cookB;
float killY;
int sfxType; // descriptor 'type' (draw weight)
int uvVariant; // 0..7 mirror/swap of the noise UVs
// (de-correlates the stamps like the
// board's random burst spin; mirror/
// swap keeps UVs in 0..1 so the
// CLAMPed radial mask stays valid)
};
static std::vector<BTPfxEmitter> gBTPfxEmitters;
static std::vector<BTPfxParticle> gBTPfxParticles;
@@ -769,7 +787,7 @@ void BTStartPfxFrame(int effect_number, float x, float y, float z,
const BTPfxDef &d = gBTPfxDefs[effect_number];
if (!d.valid)
return;
if (gBTPfxEmitters.size() > 64) // runaway guard
if (gBTPfxEmitters.size() > 256) // runaway guard
return;
BTPfxEmitter e;
e.def = &d;
@@ -821,7 +839,8 @@ void BTPfxTrailPuff(int effect_number, float x, float y, float z,
static void BTPfxSpawn(const BTPfxEmitter &e)
{
const BTPfxDef &d = *e.def;
if (gBTPfxParticles.size() > 2048) // global particle cap
if (gBTPfxParticles.size() > 8192) // global particle cap (2048 starved
// laser bursts during missile volleys)
return;
BTPfxParticle p;
// Sample in the effect's LOCAL frame, then orient into the world through
@@ -859,9 +878,126 @@ static void BTPfxSpawn(const BTPfxEmitter &e)
p.eI[i] = d.eI[i] + d.eIv[i] * BTPfxRand01();
p.eO[i] = d.eO[i] + d.eOv[i] * BTPfxRand01();
}
p.sfxMode = 0; // .PFX path: authored colour quads
p.uvVariant = (int)(BTPfxRand01() * 7.99f);
gBTPfxParticles.push_back(p);
}
//===========================================================================//
// OLD-STYLE SPECIALFX (the "more fire and 'splosions" layer) -- the 1995
// per-weapon impact effects: BTDPL.INI [effects_to_load] "specialfxN=Name" +
// [Name] descriptor sections (Chunks1/Sparks/PPCHit/LaserHit/MissileHit/
// Fireball/...). These are the dpl EFFECT-TABLE entries that effect numbers
// < 100 index (the 1996 ExplosionScripts' own comments match: 3=Chunks,
// 12=GroundHit, 15=Fireball) -- NOT psfx slots.
//
// SEMANTICS [T1, decoded from the i860 firmware VREND.MNG 2026-07-12]:
// - host installer FUN_00490210 ships the descriptor VERBATIM (cmd 0x1C,
// 0x44 bytes; trigger = cmd 0x1B {id, dcs, 4x4 matrix});
// - per board frame (30 Hz): h *= cool_a; RGB_ch = h_old*(h_new*cook_ch
// - 0.25) + 0.25 (superbright start -> 0.25 ember floor; constants pool
// @VA 0xF070: K=0.25 @0xF080, kill floor 1e-4 @0xF0A0);
// - alpha fades linearly (the step: out[0x40] -= dt * rate);
// - pos += vel; the burst frame is randomly SPUN per trigger (quadratic
// small-angle rotation, theta from the host-fed random table);
// - "variance" is DEAD DATA in the shipping pod: the binary looks up
// "variance" (@004f2604) but every INI section spells "varience" -- the
// lookup missed and the field stayed 0. Honoured (not parsed).
// - y_off reads as the particle KILL PLANE relative to the burst origin
// (Chunks -1 = die at the ground; hits -3) [T3 -- strongly data-indicated].
//===========================================================================//
struct BTSfxDef
{
int valid;
int type; // 0 = chunks (solid debris), 1/2 = fiery ball
int noTex; // texture=NOTEX(T): untextured sparks
float size, velocity, vBias, yOff;
float cook[3]; // RGB heat gains
float gravity;
float coolMul, coolSub;
int count, repeats;
};
#define BT_SFX_SLOTS 32
static BTSfxDef gBTSfxDefs[BT_SFX_SLOTS];
int BTInstallSfx(int slot, int type, int no_tex, float size, float velocity,
float v_bias, float y_off, const float cook[3], float gravity,
float cool_mul, float cool_sub, int count, int repeats)
{
if (slot < 0 || slot >= BT_SFX_SLOTS)
return 0;
BTSfxDef &d = gBTSfxDefs[slot];
d.type = type; d.noTex = no_tex;
d.size = size; d.velocity = velocity; d.vBias = v_bias; d.yOff = y_off;
d.cook[0] = cook[0]; d.cook[1] = cook[1]; d.cook[2] = cook[2];
d.gravity = gravity; d.coolMul = cool_mul; d.coolSub = cool_sub;
d.count = count > 0 ? count : 1;
d.repeats = repeats > 0 ? repeats : 1;
d.valid = 1;
return 1;
}
void BTStartSfx(int effect_number, float x, float y, float z)
{
if (effect_number < 0 || effect_number >= BT_SFX_SLOTS)
return;
const BTSfxDef &d = gBTSfxDefs[effect_number];
if (!d.valid)
return;
for (int i = 0; i < d.count; ++i)
{
if (gBTPfxParticles.size() > 8192)
return;
BTPfxParticle p;
memset(&p, 0, sizeof(p));
// Random burst direction; v_bias biases it upward (Sparks 1.4 =
// fountain, GroundHit 0.01 = flat ring, Fireball 0.99 = rising
// column -- the authored values read cleanly under this model).
// The firmware spins the whole burst frame by a random angle;
// per-particle isotropic randoms give the same coverage.
float ax = BTPfxRand01() * 2.0f - 1.0f;
float ay = BTPfxRand01() * 2.0f - 1.0f;
float az = BTPfxRand01() * 2.0f - 1.0f;
float dl = sqrtf(ax * ax + ay * ay + az * az);
if (dl < 1e-4f) { ax = 0; ay = 1; az = 0; dl = 1; }
ax /= dl; ay /= dl; az /= dl;
ay = fabsf(ay) * 0.3f + d.vBias; // upward bias
dl = sqrtf(ax * ax + ay * ay + az * az);
ax /= dl; ay /= dl; az /= dl;
float spd = d.velocity * (0.6f + 0.4f * BTPfxRand01());
// burst scatter: break the perfect overlap of count co-spawned
// sprites (the flash read as a single smooth ball) [T3]
float sc = d.size * 0.2f;
p.pos = D3DXVECTOR3(
x + sc * (BTPfxRand01() * 2.0f - 1.0f),
y + sc * (BTPfxRand01() * 2.0f - 1.0f),
z + sc * (BTPfxRand01() * 2.0f - 1.0f));
p.vel = D3DXVECTOR3(ax * spd, ay * spd, az * spd);
p.accel = D3DXVECTOR3(0.0f, -d.gravity, 0.0f);
p.age = 0.0f;
p.life = 10.0f; // real kills: heat/fade/plane
// size scale [T3]: x0.5 (size ~ diameter) with a 1.25m visibility
// floor for textured bursts -- combat ranges run 100m+; LaserHit
// (authored size 1.9) at <1m rendered ~8px = "practically nothing"
// (user-rejected). MissileHit 12 -> 6m radius core. Sparks/NOTEX
// keep their authored tininess (they read by count, not size).
p.rad = (d.size > 0.01f) ? d.size * 0.5f : 0.15f;
if (!d.noTex && d.size > 0.01f && p.rad < 1.25f) p.rad = 1.25f;
p.exp = 0.0f; p.dexp = 0.0f;
p.colorWarp = 1.0f; p.alphaWarp = 1.0f;
p.sfxMode = 1;
p.heat = 1.0f;
p.coolMul = (d.coolMul > 0.0f && d.coolMul < 1.0f) ? d.coolMul : 0.9f;
p.coolSub = d.coolSub > 0.0f ? d.coolSub : 0.02f;
p.fade = 1.0f;
p.cookR = d.cook[0]; p.cookG = d.cook[1]; p.cookB = d.cook[2];
p.killY = y + d.yOff;
p.sfxType = d.type;
p.uvVariant = (int)(BTPfxRand01() * 7.99f);
gBTPfxParticles.push_back(p);
}
}
// The beams' decoded grit texture (built in BTDrawBeams) -- shared with the
// particles so the fire quads carry the authentic firesmoke-family noise.
extern LPDIRECT3DTEXTURE9 BTGetBeamGritTexture();
@@ -870,6 +1006,60 @@ void BTDrawPfx(LPDIRECT3DDEVICE9 dev, const D3DXMATRIX *view, float dt)
{
// ---- sim ----
if (dt > 0.1f) dt = 0.1f; // stall guard
// DEV HARNESS: BT_FX_TEST="10" (or "10,1003,15") fires the listed effect
// numbers every 3s, 30m in front of the camera -- iterate the effect look
// via screenshots without a live fire loop. <100 = specialfx descriptor,
// >=1000 = psfx slot n-1000 (the real dispatch encodings).
{
static int s_fxTestN = -1;
static int s_fxTest[8];
static float s_fxTestTimer = 0.0f;
if (s_fxTestN < 0)
{
s_fxTestN = 0;
const char *tv = getenv("BT_FX_TEST");
if (tv != 0)
{
const char *p = tv;
while (*p && s_fxTestN < 8)
{
s_fxTest[s_fxTestN++] = atoi(p);
while (*p && *p != ',') ++p;
if (*p == ',') ++p;
}
}
}
if (s_fxTestN > 0)
{
s_fxTestTimer -= dt;
if (s_fxTestTimer <= 0.0f)
{
s_fxTestTimer = 3.0f;
float cx, cy, cz;
d3d_OBJECT::GetCameraPosition(&cx, &cy, &cz);
// fire on BOTH sides of the camera (view-matrix handedness
// varies; one of the two is dead-centre in frame)
for (int side = -1; side <= 1; side += 2)
{
float fx = cx + view->_13 * 30.0f * side;
float fy = cy + view->_23 * 30.0f * side + 4.0f;
float fz = cz + view->_33 * 30.0f * side;
for (int i = 0; i < s_fxTestN; ++i)
{
extern void BTStartSfx(int, float, float, float);
if (s_fxTest[i] >= 1000)
BTStartPfx(s_fxTest[i] - 1000, fx, fy, fz);
else
BTStartSfx(s_fxTest[i], fx, fy, fz);
}
}
DEBUG_STREAM << "[fxtest] fired +-30m of cam (" << cx << "," << cy << "," << cz
<< ") particles=" << (int)gBTPfxParticles.size()
<< " emitters=" << (int)gBTPfxEmitters.size() << "\n" << std::flush;
}
}
}
for (size_t ei = 0; ei < gBTPfxEmitters.size(); ++ei)
{
BTPfxEmitter &e = gBTPfxEmitters[ei];
@@ -881,7 +1071,17 @@ void BTDrawPfx(LPDIRECT3DDEVICE9 dev, const D3DXMATRIX *view, float dt)
// DDAM2 35/16~=2, DDTHSMK 30/3=10) -- release_period IS the emission
// WINDOW, not a batch interval. (The old one-burst batching dumped a
// 10-second smoke plume in a single frame's puff.)
e.emitAccum += d.rate * dt;
// EMISSION-WINDOW RULE (hardened 2026-07-12): release_period IS the
// emission window -- derive the rate from maxIssue/releasePeriod
// instead of trusting the authored 'rate' field. SHKWAVE.PFX (the
// missile-hit shockwave, mslhit's 1023) authors maxIssue=5,
// releasePeriod=0.2, rate=1 -- the one shipped file where they
// disagree; trusting 'rate' stretched the single 0.2s layered
// 5-shell blast into 5 metronomic 1Hz pulses ("silver mist exactly
// 5 times at perfectly timed intervals" -- user-reported).
float erate = (d.releasePeriod > 1e-3f)
? (float)d.maxIssue / d.releasePeriod : d.rate;
e.emitAccum += erate * dt;
int n = (int)e.emitAccum;
if (n > 0)
{
@@ -901,12 +1101,28 @@ void BTDrawPfx(LPDIRECT3DDEVICE9 dev, const D3DXMATRIX *view, float dt)
gBTPfxEmitters.resize(w);
}
{ // advance + expire particles
const float boardSteps = dt * 30.0f; // the pod stepped the sfx sim per 30Hz frame
size_t w = 0;
for (size_t i = 0; i < gBTPfxParticles.size(); ++i)
{
BTPfxParticle &p = gBTPfxParticles[i];
p.age += dt;
if (p.age >= p.life) continue;
if (p.sfxMode)
{
// the firmware kills (heat floor 1e-4 @0xF0A0; the y_off kill
// plane). TIMEBASE [T1, re-read from the step disasm]: heat
// decays per BOARD FRAME (f18 = f17*f16, no dt -- hence the
// 30Hz boardSteps exponent) but the alpha fade is multiplied
// by RAW dt (@f0413754-64: out[0x40] -= dt * inst[0x1a4]) --
// cool_b is PER-SECOND. The earlier x30 scaling killed every
// burst in ~0.1s: fireballs read as a subliminal pop and
// laser hits as "nothing happens" (user-reported).
p.heat *= powf(p.coolMul, boardSteps);
p.fade -= p.coolSub * dt;
if (p.heat <= 1e-4f || p.fade <= 0.0f) continue;
if (p.pos.y < p.killY) continue;
}
p.vel += p.accel * dt;
p.pos += p.vel * dt;
p.rad += p.exp * dt;
@@ -977,6 +1193,10 @@ void BTDrawPfx(LPDIRECT3DDEVICE9 dev, const D3DXMATRIX *view, float dt)
// 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_pfxNoiseGray = 0; // RAW bintA (grayscale) -- the
// old-style specialfx sample the
// TEXTURE, not the ramped material:
// the heat model supplies ALL colour
static LPDIRECT3DTEXTURE9 s_pfxMask = 0; // radial falloff (stage 1, CLAMP)
static int s_pfxTexTried = 0;
if (!s_pfxTexTried)
@@ -1013,6 +1233,28 @@ void BTDrawPfx(LPDIRECT3DDEVICE9 dev, const D3DXMATRIX *view, float dt)
}
else { s_pfxNoise->Release(); s_pfxNoise = 0; }
}
if (s_pfxNoise && fireImg.ok &&
SUCCEEDED(dev->CreateTexture(fireImg.w, fireImg.h, 1, 0, D3DFMT_A8R8G8B8,
D3DPOOL_MANAGED, &s_pfxNoiseGray, NULL)))
{
D3DLOCKED_RECT lr;
if (SUCCEEDED(s_pfxNoiseGray->LockRect(0, &lr, NULL, 0)))
{
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];
int l = (((s >> 16) & 0xFF) + ((s >> 8) & 0xFF) + (s & 0xFF)) / 3;
int a = (int)((0.35f + 0.65f * (l / 255.0f)) * 255.0f);
dst[x] = ((DWORD)a << 24) | (l << 16) | (l << 8) | l;
}
}
s_pfxNoiseGray->UnlockRect(0);
}
else { s_pfxNoiseGray->Release(); s_pfxNoiseGray = 0; }
}
if (s_pfxNoise &&
SUCCEEDED(dev->CreateTexture(TW, TH, 1, 0, D3DFMT_A8R8G8B8,
D3DPOOL_MANAGED, &s_pfxMask, NULL)))
@@ -1099,29 +1341,32 @@ void BTDrawPfx(LPDIRECT3DDEVICE9 dev, const D3DXMATRIX *view, float dt)
}
}
// 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);
// stage 0: diffuse x the scrolled noise (WRAPped). GRAYSCALE: every
// .PFX and specialfx descriptor references the TEXTURE
// btfx:firesmokeN_scr_tex (the raw bintA noise), NOT the fiery-ramped
// material -- the AUTHORED per-particle colours are the sole colour
// source (DNBOOM's 0.8,0.3,0 = the orange fire; DDAM's 0.5-gray = the
// smoke). The earlier ramp-baked sheet gilded the gray smoke bands
// into "gold blasts of light" (user-rejected). The fiery ramp
// belongs to the MATERIAL path only (flamebig etc. via bgfload).
dev->SetTexture(0, s_pfxNoiseGray ? s_pfxNoiseGray : 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);
// NO SCROLL on particle stamps: the authored SCROLL belongs to the
// TEXTURE record for the MATERIAL path (flame meshes); sliding the
// noise through short-lived particle sprites reads as a shimmering
// crawl ("not sure what I'm looking at" -- user-rejected). Each
// particle instead gets a FIXED random window into the tileable
// sheet (u0/v0, set at spawn; the WRAP addressing makes any window
// valid) -- the board's per-burst random spin de-correlation.
dev->SetTextureStageState(0, D3DTSS_TEXTURETRANSFORMFLAGS, D3DTTFF_DISABLE);
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
@@ -1158,6 +1403,15 @@ void BTDrawPfx(LPDIRECT3DDEVICE9 dev, const D3DXMATRIX *view, float dt)
struct V { float x, y, z; DWORD c; float u, v; };
static std::vector<V> verts;
verts.clear();
// SPECIALFX batch: drawn with the FIERY-RAMPED sheet. On the IG board the
// heat channels are BRIGHTNESS multipliers over an inherently fire-
// coloured texture (firesmoke through its material ramp): white-hot flash
// -> ember-orange dissipating cloud (the demo fireballs). The .PFX batch
// keeps the GRAYSCALE sheet (its authored colours decide: DNBOOM orange
// fire, DDAM gray smoke). Drawing sfx ember clouds grayscale made them
// "explosive silver mist" (user-rejected).
static std::vector<V> sfxVerts;
sfxVerts.clear();
// BACK-TO-FRONT order: premultiplied occlusion (smoke) composites in depth
// order; unsorted draws pop when a near puff renders before a far one.
@@ -1185,16 +1439,54 @@ void BTDrawPfx(LPDIRECT3DDEVICE9 dev, const D3DXMATRIX *view, float dt)
float tc = powf(t, 1.0f / p.colorWarp);
float ta = powf(t, 1.0f / p.alphaWarp);
// two quads: outer glow (2.2x radius) then inner core
for (int layer = 0; layer < 2; ++layer)
// The .PFX sI/sO are the INTERIOR/EXTERIOR colours of ONE sprite (the
// dpl_PARTICLE model) -- approximated as an exterior quad at the
// AUTHORED radius + an interior core at 0.45x, with the exterior's
// occlusion halved (it is the sprite's soft skirt, not a second
// particle). The old 2.2x "outer glow" inflated every particle and
// doubled its occlusion -- 20 overlapping damage-smoke particles
// merged into one giant black blob (seen in the BT_FX_TEST captures).
// Old-style specialfx draw ONE sprite per particle (the firmware
// draw emits a single projected sprite).
for (int layer = (p.sfxMode ? 1 : 0); layer < 2; ++layer)
{
const float *cs = layer ? p.sI : p.sO;
const float *ce = layer ? p.eI : p.eO;
float scale = layer ? 1.0f : 2.2f;
float r = cs[0] + (ce[0] - cs[0]) * tc;
float g = cs[1] + (ce[1] - cs[1]) * tc;
float b = cs[2] + (ce[2] - cs[2]) * tc;
float a = cs[3] + (ce[3] - cs[3]) * ta;
float scale = layer ? 0.45f : 1.0f;
if (p.sfxMode) scale = 1.0f;
float r, g, b, a;
if (p.sfxMode)
{
// THE FIRMWARE HEAT EQUATION [T1]: channel = h_old*(h_new*
// cook_ch - 0.25) + 0.25. h was already stepped in the sim
// above; approximate h_old over this frame's step from the
// per-frame factor (h_old = h / coolMul^steps was h before
// the step -- one step back is close enough at 30Hz).
float hNew = p.heat;
float hOld = p.heat / p.coolMul; // one board step back
if (hOld > 1.0f) hOld = 1.0f;
r = hOld * (hNew * p.cookR - 0.25f) + 0.25f;
g = hOld * (hNew * p.cookG - 0.25f) + 0.25f;
b = hOld * (hNew * p.cookB - 0.25f) + 0.25f;
if (r < 0) r = 0; if (r > 1) r = 1;
if (g < 0) g = 0; if (g > 1) g = 1;
if (b < 0) b = 0; if (b > 1) b = 1;
// occlusion weight: solid debris chunks occlude; fiery balls
// mostly-occlude while HOT (bright fire body), thinning as
// the heat dies (0.5 halved the flash into a faint wisp at
// combat range -- "practically nothing", user-rejected)
float occl = (p.sfxType == 0) ? 0.85f
: (0.45f + 0.45f * (p.heat > 1.0f ? 1.0f : p.heat));
a = p.fade * occl;
}
else
{
r = cs[0] + (ce[0] - cs[0]) * tc;
g = cs[1] + (ce[1] - cs[1]) * tc;
b = cs[2] + (ce[2] - cs[2]) * tc;
a = cs[3] + (ce[3] - cs[3]) * ta;
if (layer == 0) a *= 0.5f; // exterior skirt: half occlusion
}
if (a <= 0.0f) continue;
if (a > 1.0f) a = 1.0f;
// PREMULTIPLIED: vertex rgb = colour x alpha (the framebuffer
@@ -1210,20 +1502,62 @@ void BTDrawPfx(LPDIRECT3DDEVICE9 dev, const D3DXMATRIX *view, float dt)
float rad = p.rad * scale;
D3DXVECTOR3 rv = right * rad, uv = up * rad;
// noise-stamp orientation: mirror/swap the 0..1 UVs by the
// particle's variant (stays in 0..1 -> the CLAMPed mask holds)
float cu[4] = { 0, 0, 1, 1 }; // corners: BL, TL, TR, BR
float cv[4] = { 1, 0, 0, 1 };
for (int k = 0; k < 4; ++k)
{
float uu2 = cu[k], vv2 = cv[k];
if (p.uvVariant & 1) uu2 = 1.0f - uu2;
if (p.uvVariant & 2) vv2 = 1.0f - vv2;
if (p.uvVariant & 4) { float t = uu2; uu2 = vv2; vv2 = t; }
cu[k] = uu2; cv[k] = vv2;
}
V q[6];
q[0].x = p.pos.x - rv.x - uv.x; q[0].y = p.pos.y - rv.y - uv.y; q[0].z = p.pos.z - rv.z - uv.z; q[0].u = 0; q[0].v = 1;
q[1].x = p.pos.x - rv.x + uv.x; q[1].y = p.pos.y - rv.y + uv.y; q[1].z = p.pos.z - rv.z + uv.z; q[1].u = 0; q[1].v = 0;
q[2].x = p.pos.x + rv.x + uv.x; q[2].y = p.pos.y + rv.y + uv.y; q[2].z = p.pos.z + rv.z + uv.z; q[2].u = 1; q[2].v = 0;
q[0].x = p.pos.x - rv.x - uv.x; q[0].y = p.pos.y - rv.y - uv.y; q[0].z = p.pos.z - rv.z - uv.z; q[0].u = cu[0]; q[0].v = cv[0];
q[1].x = p.pos.x - rv.x + uv.x; q[1].y = p.pos.y - rv.y + uv.y; q[1].z = p.pos.z - rv.z + uv.z; q[1].u = cu[1]; q[1].v = cv[1];
q[2].x = p.pos.x + rv.x + uv.x; q[2].y = p.pos.y + rv.y + uv.y; q[2].z = p.pos.z + rv.z + uv.z; q[2].u = cu[2]; q[2].v = cv[2];
q[3] = q[0];
q[4] = q[2];
q[5].x = p.pos.x + rv.x - uv.x; q[5].y = p.pos.y + rv.y - uv.y; q[5].z = p.pos.z + rv.z - uv.z; q[5].u = 1; q[5].v = 1;
for (int k = 0; k < 6; ++k) { q[k].c = c; verts.push_back(q[k]); }
q[5].x = p.pos.x + rv.x - uv.x; q[5].y = p.pos.y + rv.y - uv.y; q[5].z = p.pos.z + rv.z - uv.z; q[5].u = cu[3]; q[5].v = cv[3];
std::vector<V> &sink = p.sfxMode ? sfxVerts : verts;
for (int k = 0; k < 6; ++k) { q[k].c = c; sink.push_back(q[k]); }
}
}
// DIAG (BT_FX_TEST): prove the draw reaches pixels -- particle count, the
// vertex count, the first particle's colour/alpha/pos, texture presence.
if (getenv("BT_FX_TEST") != 0 && !gBTPfxParticles.empty())
{
static float s_drawLog = 0.0f;
s_drawLog += dt;
if (s_drawLog >= 2.0f)
{
s_drawLog = 0.0f;
const BTPfxParticle &p0 = gBTPfxParticles[0];
DEBUG_STREAM << "[pfx-draw] n=" << (int)gBTPfxParticles.size()
<< " verts=" << (int)verts.size()
<< " tex=" << (s_pfxNoiseGray ? "gray" : (s_pfxNoise ? "fiery" : (s_pfxTex ? "grit" : "NONE")))
<< " p0(sfx=" << p0.sfxMode << " pos=" << p0.pos.x << "," << p0.pos.y << "," << p0.pos.z
<< " rad=" << p0.rad << " heat=" << p0.heat << " fade=" << p0.fade
<< ")\n" << std::flush;
if (!verts.empty())
DEBUG_STREAM << "[pfx-draw] v0=(" << verts[0].x << "," << verts[0].y << "," << verts[0].z
<< ") c=" << std::hex << (unsigned)verts[0].c << std::dec << "\n" << std::flush;
}
}
if (!verts.empty())
dev->DrawPrimitiveUP(D3DPT_TRIANGLELIST, (UINT)verts.size() / 3,
&verts[0], sizeof(V));
if (!sfxVerts.empty())
{
// the fiery-ramped sheet for the heat-model bursts (see above)
if (s_pfxNoise)
dev->SetTexture(0, s_pfxNoise);
dev->DrawPrimitiveUP(D3DPT_TRIANGLELIST, (UINT)sfxVerts.size() / 3,
&sfxVerts[0], sizeof(V));
}
dev->SetTexture(0, NULL);
if (s_pfxNoise)
@@ -4503,6 +4837,63 @@ void
<< pfx_loaded << " effect definitions loaded\n" << std::flush;
delete psfx_namelist;
}
//
// OLD-STYLE SPECIALFX ("specialfxN=Name" + [Name] descriptor pages) -- the
// per-weapon impact effects (PPCHit/LaserHit/MissileHit/Chunks/Sparks/
// Fireball...). Field names per the 1995 binary's parser (part_008.c:1546:
// type/size/velocity/v_bias/y_off/variance/gravity/count/repeats +
// "cook" "%f %f %f" + "cool" "%f %f" + texture). NOTE the binary reads
// "variance" but the INI authors "varience" -- the lookup MISSED in the
// shipping pod, so variance is authentically dead (not parsed here).
// The 2007 loader's version-2-only walk below skips all of these
// (version < 2 -> continue), which is why the layer never rendered.
//
{
NameList *sfx_namelist =
master_notation_file->MakeEntryList(starting_page_name, "specialfx");
if (sfx_namelist != NULL)
{
extern int BTInstallSfx(int slot, int type, int no_tex, float size,
float velocity, float v_bias, float y_off, const float cook[3],
float gravity, float cool_mul, float cool_sub, int count, int repeats);
int sfx_loaded = 0;
for (entry = sfx_namelist->GetFirstEntry();
entry;
entry = entry->GetNextEntry())
{
int sfx_number = atoi(entry->GetName() + 9); // "specialfxN"
const char *page = entry->GetChar();
if (page == NULL || page[0] == '\0')
continue;
int type = 0, count = 1, repeats = 1;
float size = 0, velocity = 0, v_bias = 0, y_off = 0, gravity = 0;
float cook[3] = { 1, 1, 1 };
float cool_mul = 0.9f, cool_sub = 0.02f;
const char *sp = NULL;
master_notation_file->GetEntry(page, "type", &type);
master_notation_file->GetEntry(page, "size", &size);
master_notation_file->GetEntry(page, "velocity", &velocity);
master_notation_file->GetEntry(page, "v_bias", &v_bias);
master_notation_file->GetEntry(page, "y_off", &y_off);
master_notation_file->GetEntry(page, "gravity", &gravity);
master_notation_file->GetEntry(page, "count", &count);
master_notation_file->GetEntry(page, "repeats", &repeats);
if (master_notation_file->GetEntry(page, "cook", &sp) && sp != NULL)
sscanf(sp, "%f %f %f", &cook[0], &cook[1], &cook[2]);
if (master_notation_file->GetEntry(page, "cool", &sp) && sp != NULL)
sscanf(sp, "%f %f", &cool_mul, &cool_sub);
int no_tex = 0;
if (master_notation_file->GetEntry(page, "texture", &sp) && sp != NULL)
no_tex = (strncmp(sp, "NOTEX", 5) == 0); // NOTEX / NOTEXT
sfx_loaded += BTInstallSfx(sfx_number, type, no_tex, size, velocity,
v_bias, y_off, cook, gravity, cool_mul, cool_sub, count, repeats);
}
DEBUG_STREAM << "[sfx] page '" << starting_page_name << "': "
<< sfx_loaded << " old-style specialfx installed\n" << std::flush;
delete sfx_namelist;
}
}
////
//// (original stubbed loader retained for provenance)
////
@@ -6840,29 +7231,40 @@ void
<< entity->localOrigin.linearPosition.x
<< ", " << entity->localOrigin.linearPosition.y
<< ", " << entity->localOrigin.linearPosition.z << "], " << effect_number << ");" << std::endl << std::flush;
// Both effect-number ENCODINGS resolve to the same psfx slot:
// <100 = the raw dpl board number; >=1000 = the WinTesla-era
// "1000+slot" INDIE id carried by the damage-band resources
// (1002-1005 = ddam1-4 light..critical damage smoke, 1008 =
// ddam5 zone-destroyed -- the SAME [pfx_day] mapping). BT
// ships no INDIE descriptors (the version-2 specialfx pages
// don't exist), so all slots route to the BT .PFX layer, with
// the Explosion entity's ORIENTATION (the victim's frame) --
// the .PFX offsets/velocities are authored mech-local.
// TWO DISTINCT ENCODINGS (routing corrected 2026-07-12):
// < 100 = the dpl EFFECT-TABLE number = an OLD-STYLE
// specialfx descriptor (BTDPL.INI [effects_to_load]
// "specialfxN": 8=PPCHit, 9=LaserHit, 10=MissileHit,
// 3=Chunks1, 5=Sparks, 15=Fireball -- the 1996
// script's own comments match) -> the firmware-
// decoded heat-model burst (BTStartSfx);
// >= 1000 = the "1000+slot" psfx id (1002-1005 = ddam1-4,
// 1008 = ddam5, 1009-1013 = idam1-5) -> the .PFX
// layer, oriented in the victim's frame.
// (The old "<100 -> psfx slot" mapping rendered PPCHit as
// ddam5 SMOKE and Chunks as damage smoke -- the "hits are
// just a little smoke" report.)
if (effect_number >= 1000)
{
int pfx_slot = (effect_number >= 1000)
? effect_number - 1000 : effect_number;
extern void BTStartPfxFrame(int, float, float, float,
const float *, const float *, const float *);
float xr[3] = { (float)entity->localToWorld(0,0), (float)entity->localToWorld(0,1), (float)entity->localToWorld(0,2) };
float yr[3] = { (float)entity->localToWorld(1,0), (float)entity->localToWorld(1,1), (float)entity->localToWorld(1,2) };
float zr[3] = { (float)entity->localToWorld(2,0), (float)entity->localToWorld(2,1), (float)entity->localToWorld(2,2) };
BTStartPfxFrame(pfx_slot,
BTStartPfxFrame(effect_number - 1000,
(float)entity->localOrigin.linearPosition.x,
(float)entity->localOrigin.linearPosition.y,
(float)entity->localOrigin.linearPosition.z,
xr, yr, zr);
}
else
{
extern void BTStartSfx(int, float, float, float);
BTStartSfx(effect_number,
(float)entity->localOrigin.linearPosition.x,
(float)entity->localOrigin.linearPosition.y,
(float)entity->localOrigin.linearPosition.z);
}
}
else
{
@@ -8630,17 +9032,14 @@ void
}
//
// Board effect numbers (<100) -- the 1995 dpl explosion/damage effects.
// RECONSTRUCTED: routed to the BT .PFX particle layer (the [pfx_day]/
// [pfx_night] psfxN mapping loads each number's authentic .PFX definition;
// see the layer banner at the top of this file). This is what makes every
// weapon-hit / damage-band / mech-death explosion actually VISIBLE -- the
// original dpl_Effect(dpl_effect_type_explosion, ...) below was IG-board
// hardware and was never ported.
// Board effect numbers (<100) -- the 1995 dpl EFFECT TABLE = the old-style
// specialfx descriptors (BTDPL.INI [effects_to_load]), NOT psfx slots
// (routing corrected 2026-07-12; the psfx encoding is "1000+slot"). The
// firmware-decoded heat-model burst renders them (BTStartSfx).
//
{
extern void BTStartPfx(int effect_number, float x, float y, float z);
BTStartPfx(effect_number, location.x, location.y, location.z);
extern void BTStartSfx(int effect_number, float x, float y, float z);
BTStartSfx(effect_number, location.x, location.y, location.z);
}
//dpl_EXPLOSION_EFFECT_INFO my_explosion;