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:
co-authored by
Claude Fable 5
parent
48c9c8444f
commit
065c114590
+449
-50
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user