Combat visible + killable: Wword root-cause fix, .PFX effect layer, RemakeEntity swap

The 'can't kill the enemy / no visible damage' cluster, root-caused and fixed
faithfully:

- STEP-6 unaimed path was INERT: the cylinder table was 'cached' at Wword(0x111)
  -- the recon ABSORBER bank (stores nothing, reads 0) -- so every unaimed hit
  silently no-op'd.  Promoted to the named member Mech::damageLookupTable
  (binary this[0x111], was mislabeled ammoExpended).  New gotcha class recorded
  (reconstruction-gotchas §2) + sweep; 2 dead multiplayer branches logged.

- Fire path migrated off the stale vital-zone aim onto the completed STEP-6
  unaimed dispatch (zone=-1 + beam entry point -> cylinder resolves the
  exterior zone).  No more invisible 1-shot kills; death via the authentic
  cascade (~14 center-mass hits).  Wreck stays TARGETED on kill (beams stop on
  it); scoring latches off.

- SendSubsystemDamage AV fixed: unbound critical-subsystem plug guard (43
  unbound plugs/mech logged as an open question -- the binding itself is a gap).

- RemakeEntity (render damage swap): the 1996 render state machine's missing
  Remake state, reconstructed as an in-place SetDrawObj mesh swap keyed by each
  segment's damage-zone graphic state (tree dtor doesn't cascade -> never
  rebuild).  Destroyed arms/guns visibly wreck (the only variants the RES
  registers).

- BT .PFX particle layer (L4VIDEO.cpp): the 1995 explosion/damage effect layer,
  unported since 2007 (DPLIndependantEffect/ReadPSFX/ExplosionScripts all
  stubs).  Parses the authentic VIDEO/*.PFX definitions via the [pfx_day]
  psfxN mapping; premultiplied blending renders BOTH families from the same
  data (additive-style fire + occluding smoke -- DDAM2 is 30% grey, DDTHSMK
  ramps negative: impossible additively); depth-sorted billboards with a
  radial-masked grit sprite; impact-frame orientation (.PFX offsets are
  authored mech-local, -Z = out of the struck armor toward the shooter) for
  weapon hits AND damage bands (via lastInflictingID, now maintained -- was
  declared but never written).  Both effect-number encodings route (raw dpl
  <100 + WinTesla 1000+slot carried by the band resources).  Death fires the
  authentic dnboom (7) + ddthsmk smoke plume (1).

- Effects anchor at the impact point / damaged zone's segment, not the mech
  origin (no more fire at the feet).

- Dev force-input gates BT_AUTOFIRE / BT_AUTODRIVE for headless fire-chain
  verification; BT_PFX_ADD=1 flips the particle blend for A/B.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-08 14:43:32 -05:00
co-authored by Claude Fable 5
parent 7c455303bd
commit a3d67cc639
16 changed files with 1205 additions and 62 deletions
+27 -7
View File
@@ -139,7 +139,10 @@ Built + runtime-verified. Changes:
the real `MemoryStream` (`ReadBytes`).
- **`mech.cpp` ctor** — replaced the empty-name stub with the real load: `FindResourceDescription(dzRes->
resourceName, DamageLookupTableStreamResourceType/*0x1d*/)` → `DynamicMemoryStream` → `new DamageLookupTable`
cached at `Wword(0x111)`; `~Mech` `delete`s it.
cached in the **named member `Mech::damageLookupTable`** (binary `this[0x111]`/byte 0x444; was mislabeled
`ammoExpended` in the offset map); `~Mech` `delete`s it. ⚠ It was ORIGINALLY "cached" at `Wword(0x111)` —
the absorber bank — which stores NOTHING, so the whole unaimed path was inert until 2026-07-08 (the
"can't kill the enemy" bug). See [[reconstruction-gotchas]] §2. [T2]
- **`Mech::TakeDamageMessageHandler`** override registered (`MESSAGE_ENTRY(Mech, TakeDamage)`, overlays
Entity's by ID): on `invalidDamageZone`, `msg->damageZone = table->ResolveHit(msg->damageData.impactPoint)`,
clear the flag, then base-route. Aimed hits pass straight through.
@@ -147,12 +150,22 @@ Built + runtime-verified. Changes:
`CylinderReferenceHeight` (`standingTemplateMaxY` = `collisionTemplate->maxY` = the binary `mech+0x2ec[+0xc]`),
`TorsoHeading` (via the `BTGetTorsoTwist` bridge in `torso.cpp` → `Torso::CurrentTwist` = torso+0x1d8;
torso.hpp can't be `#include`d into mech.cpp — subsystem-stub collision).
- **Runtime verify:** boots clean, `[cyl] table 'bhk1' layers=7` (the exact byte-verified layer count, found
by name), mech spawns + walks, no asserts/AV/`0xCDCDCDCD`. The live `[cyl] unaimed hit → zone N` path fires
on COLLISION damage (zone==1); not exercised in the solo DEV.EGG (no combat) but the resolver is live +
byte-verified. Env gate `BT_CYL_LOG=1`. The recon's empty-name stub (mech.cpp:1219) must be fixed to the real
name (the binary copies it from a sibling resource, `local_130+0xc` @part_012.c:10361 — reuse the mech's
DamageZoneStream/model name). RE phase COMPLETE; the build (containers + handler + load + wire) is next. `Mech__DamageZone::TakeDamage` → engine armor model (`damageLevel
- **Runtime verify (2026-07-08, POST-Wword-fix — the earlier "verified" claim was PARTIAL: the handler
wiring was live but the table cache was inert):** with `BT_AUTOFIRE=1 BT_AUTODRIVE=0.5 BT_SPAWN_ENEMY=1`,
the full chain runs end-to-end: `[cyl] unaimed hit → zone N` resolves across **19 distinct zones**
(front-facing zones 0/6/17/15/4 dominate for head-on fire, as the geometry predicts), structure climbs
gradually per hit, the enemy dies after **14** hits (not 1) via the authentic cascade, 4+ zones reach
`DestroyedGraphicState`, and the destroyed segment meshes visibly swap (RemakeEntity). Env gates
`BT_CYL_LOG=1`, `BT_AUTOFIRE`/`BT_AUTODRIVE` (dev force-inputs, mech4.cpp). [T2]
- **Fire path now UNAIMED (2026-07-08):** both bring-up hit paths (the SHOT block + the projectile
impact, mech4.cpp) dispatch `zone=1` + a world impact point (beam entry point = enemy origin shifted
~3u toward the shooter at muzzle height; the projectile uses its own impact position) → the cylinder
resolves the struck EXTERIOR zone. The old code aimed the internal VITAL zone directly →
invisible 1-shot kills (internal zones are soft; the binary never aims them — they die only via the
destruction cascade `RecurseSegmentTable`/`SendSubsystemDamage`). On kill the wreck STAYS TARGETED
(`gEnemyMech` is NOT nulled) so beams keep terminating on it — nulling it made every later beam a
"free" ray that visibly passed through the wreck; scoring latches off via `gEnemyDestroyed`. [T2]
`Mech__DamageZone::TakeDamage` → engine armor model (`damageLevel
+= damageAmount*damageScale[type]`) → 1.0 = zone destroyed → death. The Mech ctor populates the
inherited `damageZones[]` from the DamageZoneStream (type 0x14) resource. `class Damage{ damageType
(Collision/Ballistic/Explosive/Laser/Energy), damageAmount, damageForce, surfaceNormal, impactPoint,
@@ -178,6 +191,13 @@ Full decomp map of the death path. Two tiers:
`SetLegAnimation`/`SetBodyAnimation` = `FUN_004a7fc4`/`FUN_004a800c`), one-shot via `deathAnimationLatched`
(mech+0x650). Already reconstructed (mech2.cpp:550-557/817-822). The active drive path DOES call
`AdvanceBodyAnimation` (mech4.cpp:317) → reachable.
⚠ **CONFLICT with the shipped clip inventory (2026-07-08) [T4 until re-read]:** BTL4.RES holds exactly
**27 named clip codes per mech** (`swr wwl wwr wsl wsr wrl wrr rrl rrr rwl rwr bmp bbl bbr bsl bsr sbl
sbr ggl ggr gsl gsr wgl wgr sqd squ trn`, + `*i` inside-skeleton variants) — NO explicit death/fall clip
name, and indexes 0x1c-0x1f (28-31) exceed a 27-entry table unless the table interleaves the i-variants
or carries non-resource entries. The "clips 0x1c-0x1f" claim needs the FUN_004a5028 latch region re-read
against the real StandingAnimation table before the collapse is built (task #32). Candidate authentic
visuals: the `bmp` (knockdown) family or `sqd` (squat/crumple).
- `Mech::IsDestroyed()` (`FUN_0049fb54`) = `movementMode==2||9` (reconstructed as `IsDisabled`, btstubs.cpp);
`FUN_004ab1c8` freezes locomotion (zeros mech+0x298) when destroyed.
- **`MechDeathHandler`** (ctor `FUN_0042a984` + Performance `FUN_0042aa2c`, cached mech+0x850 / `mech[0x214]`) —
+31 -9
View File
@@ -82,11 +82,14 @@ authentic path scoped.
and composed of runtime-verified pieces. **Note:** validated STEP 6's height ref along the way —
`collisionTemplate->maxY ≈ 7.1` (a real mech height), confirming `CylinderReferenceHeight` reads a height,
not the heat value the `mech+0x2ec` dual-labeling (heat-gauge sink vs groundRef, mech4.cpp:2697) hinted at.
- **✅ Cylinder hit-location (STEP 6) — DONE 2026-07-08 [T2].** Built + runtime-verified: the `dmgtable.cpp`
- **✅ Cylinder hit-location (STEP 6) — DONE + LIVE 2026-07-08 [T2].** Built + runtime-verified: the `dmgtable.cpp`
classes now have real storage + a working `ResolveHit→zone`, the mech ctor loads the type-0x1d table by the
DamageZoneStream name (`[cyl] table 'bhk1' layers=7`), and `Mech::TakeDamageMessageHandler` resolves
unaimed (zone==1) hits before base-routing. Full change list in [[combat-damage]] "STEP 6 COMPLETE". The
live collision-damage path (zone==1 → resolve) is now UNBLOCKED. Historical investigation notes retained
unaimed (zone==1) hits before base-routing. ⚠ The FIRST "verified" claim was PARTIAL: the table was
cached via `Wword(0x111)` (the ABSORBER — stores nothing) so every unaimed hit silently no-op'd; fixed
2026-07-08 by promoting the cache to the named member `Mech::damageLookupTable`
([[reconstruction-gotchas]] §2). Now verified end-to-end (19 zones resolving, `BT_AUTOFIRE` harness).
Full change list in [[combat-damage]] "STEP 6 COMPLETE". Historical investigation notes retained
below for provenance.
**Re-investigated 2026-07 (see [[combat-damage]] for the full algorithm):** the table (height×angle
grid, resource type 0x1d) IS built (recon `StandingAnimation` @Mech[0x111], but with an EMPTY name → 0 rows,
@@ -101,12 +104,22 @@ authentic path scoped.
- **Death sequence — CORE DONE 2026-07-08 [T2]; effects+anim deferred.** `Mech::UpdateDeathState()`
(mech4.cpp) reconstructs the death STATE machine (collapse `movementMode` → RP `VTV::DeathShutdown`
subsystem loop → settle to disabled/frozen; wreck stays, no crash) — runtime-verified (`BT_DEATH_LOG`).
Full map + citations in [[combat-damage]] "Death SEQUENCE". **Remaining** (all in the un-exported master-perf
gap 0x4a9770-0x4ab188 OR a big exported reconstruction): (a) the visible **collapse ANIMATION** latch
through the active path (+ spawned mechs don't advance body anim); (b) reconstruct **`MechDeathHandler`**
(`FUN_0042a984`/`FUN_0042aa2c`) — the EXPORTED per-subsystem destroyed-skin + explosion engine (still a
stub, mech.cpp:221) — the biggest visible payoff; (c) the whole-mech **DeathSplash** radius damage. Do NOT
issue DestroyEntityMessage on death.
Full map + citations in [[combat-damage]] "Death SEQUENCE". ✅ (b) `MechDeathHandler` DONE (mechdmg.cpp) +
✅ the render-side **RemakeEntity** destroyed-mesh swap DONE 2026-07-08 (btl4vid.cpp
`RemakeEntityRenderables` + `BTRemakeMechModel` bridge; in-place `SetDrawObj` swap — the tree dtor does
NOT cascade so never rebuild). **Remaining**: (a) the visible **collapse ANIMATION** latch
through the active path (+ spawned mechs don't advance body anim); (c) the whole-mech **DeathSplash**
radius damage. Do NOT issue DestroyEntityMessage on death.
- **Critical-subsystem plugs UNBOUND (43 skips/mech) [T3].** `MechCriticalSubsystem::subsystemPlug`
never gets WIRED to its live subsystem (the ctor's Resolve() at mechdmg.cpp:276 only READS; the
binding write is elsewhere/unreconstructed), so `SendSubsystemDamage` skips every entry via the
unbound-plug guard (mechdmg.cpp, added 2026-07-08 after a live NULL-deref AV in
`SendSubsystemDamage+0x93` — the 1995 binary derefs unchecked because there every plug is bound).
Consequence: zone destruction does NOT yet propagate damage into carried subsystems (generator/
gyro failures etc.). Also latent there: `SubProxy2` raw-offset reads (databinding trap) once s≠0.
- **Two dead Wword comparison branches (multiplayer)**: `mech.cpp:1511` + `:1613` (replicant
leg-state/stability sync in ReadUpdateRecord) — always-false BTVal comparisons ([[reconstruction-gotchas]] §2);
needs slots 0xf/0x10 promoted to named members when P6 replication work resumes.
## Multiplayer (Phase 7 / P6)
- Cross-pod COMBAT (target a replicant + route damage to the owning master).
@@ -115,6 +128,15 @@ authentic path scoped.
reconstructed + smoke-tested on one box (two instances share a world, movement replicates).
## Rendering follow-ups (non-blocking)
- **.PFX effect-layer polish (deferred 2026-07-08; the layer itself is LIVE + verified).** The
reconstructed BT particle layer (L4VIDEO.cpp, see its banner) renders the authentic .PFX content
(fire + smoke, premultiplied blend, impact-frame oriented — weapon hits AND damage bands via
`lastInflictingID`, now written by `Mech::TakeDamageMessageHandler`). Remaining nuances, all
cosmetic: (a) `atten/attenv` distance attenuation not honoured; (b) the per-file texture name
(`btfx:firesmoke1_scr_tex`) — all effects share the radial-masked grit sheet; decoding FIRE.BSL
would give the authored sheet; (c) beams draw before particles, so a beam through thick smoke
reads too bright (pass-ordering); (d) `colorWarp/alphaWarp` applied as `t^(1/warp)` [T4 convention];
(e) emission uses rate×releasePeriod batching [T4 convention vs the exact 1995 emitter].
- Replacement-LOD selection for NON-additive multi-LOD models (needs the board's hot-spot/
reference-point semantics; leads: `s_dplobject.lod_ranges[16]/lod_hot_spot`, 0x2047/0x2048).
- Day/night MATERIAL path priority (BMF/IMG) — infrastructure done, gated `BT_MATPRI` (over-applies
+19 -4
View File
@@ -39,11 +39,26 @@ copy; (b) it lands at a **different offset** than the binary assumed.
(`statusFlags``simulationFlags`, `destroyed``simulationState`, `statusBits``ForceUpdate()`).
[T2]
## 2. Wword(N) — a global scratch bank, NOT this+N
## 2. Wword(N) — an ABSORBER, not storage (state cached there VANISHES)
`mechrecon.hpp:192` defines `Wword(int i)` as `static BTVal bank[0x400]; return bank[i&0x3ff]`.
Any `Wword(N)` used for OBJECT access reads/writes a shared global, not `this+i*4`. e.g.
`(Mech*)Wword(3)` for a zone's owner → garbage; use `GetOwningSimulation()`. [T2]
`mechrecon.hpp:226` defines `Wword(int i)` as `static BTVal bank[0x400]; return bank[i&0x3ff]`,
and `BTVal` is the recon **absorber** type: `operator=` stores NOTHING, every read converts to
`T()` (zero), and ALL comparisons (`==` and `!=`, vs BTVal or int) return **false**. Consequences: [T2]
- Any state CACHED via `Wword(N) = x` silently vanishes; the later read is always 0/null.
**Archetype:** the STEP-6 cylinder table was "cached" at `Wword(0x111)` → the unaimed
TakeDamage path was totally inert (every hit no-op'd, "can't kill the enemy") while the
ctor log looked fine. Fix = a real named member (`Mech::damageLookupTable`). If a Wword
slot must hold real state, PROMOTE it to a named member mapped to that binary offset —
check `mech.hpp`'s offset map first (the slot may already exist under a best-effort
mislabel; 0x111 was mislabeled `ammoExpended`).
- `if (Wword(a) != Wword(b))` and `if (Wword(a) == 2)` are BOTH always-false → the guarded
branch is dead code. Known dead sites: `mech.cpp:1511` + `mech.cpp:1613` (replicant
leg-state / stability-alarm sync in ReadUpdateRecord — multiplayer-only, deferred).
- Any `Wword(N)` used for OBJECT access reads a shared global, not `this+i*4`; e.g.
`(Mech*)Wword(3)` for a zone's owner → garbage; use `GetOwningSimulation()`.
Sweep recipe: `grep -nE "\((int|void|[A-Z]\w+) ?\*\)\s*Wword\(|if \(Wword\(|Wword\([^)]+\)\s*(!=|==)"`
every hit is either dead code or a vanished cache.
## 3. Databinding trap — raw offsets read garbage
+556 -5
View File
@@ -25,6 +25,7 @@ using namespace std;
#include <vector>
#include <hash_map>
#include <map>
#include <algorithm> // std::sort (the .PFX particle depth sort)
LPDIRECT3D9 gD3D = NULL;
@@ -58,6 +59,11 @@ struct BTBeamFx
};
static std::vector<BTBeamFx> gBTBeams;
// the decoded beam grit sheet (built lazily in BTDrawBeams; shared with the
// .PFX particle layer below)
LPDIRECT3DTEXTURE9 gBTBeamGritTex = 0;
LPDIRECT3DTEXTURE9 BTGetBeamGritTexture() { return gBTBeamGritTex; }
// Called from the game (mech4.cpp) -- external linkage, matched by an extern decl there.
// LOD eyepoint feed (game -> renderer): the viewpoint mech's position, the
// authentic LOD reference (see the ExecuteImplementation banner). mech4 calls
@@ -121,8 +127,10 @@ void BTDrawBeams(LPDIRECT3DDEVICE9 dev, const D3DXMATRIX *view, float dt)
// once, ramp its luminance to a grayscale grit texture, and MODULATE the beam
// colour by it with a scrolling UV -> the streaming "gritty" look, not a clean
// gradient. BT_BEAM_TEX=0 falls back to the plain additive beam.
// (file-scope so the .PFX particle layer below can share the decoded sheet)
static int s_beamTexTried = 0;
static LPDIRECT3DTEXTURE9 s_beamTex = 0;
extern LPDIRECT3DTEXTURE9 gBTBeamGritTex;
LPDIRECT3DTEXTURE9 &s_beamTex = gBTBeamGritTex;
if (!s_beamTexTried)
{
s_beamTexTried = 1;
@@ -361,6 +369,484 @@ void BTDrawBeams(LPDIRECT3DDEVICE9 dev, const D3DXMATRIX *view, float dt)
dev->SetSamplerState(0, D3DSAMP_ADDRESSV, sAddrV);
}
//===========================================================================//
// BT PARTICLE EFFECTS (.PFX) -- the 1995 explosion/damage effect layer.
//
// The pod's damage/explosion visuals are DATA: VIDEO/*.PFX text files (format
// documented inside each file), mapped to dpl effect NUMBERS by BTDPL.INI's
// [pfx_day]/[pfx_night] pages ("psfxN=file.pfx" -- N is the number embedded in
// resources: 2..5 = the mech damage bands, 6 = projectile-gun hit, 7 = the
// mech-death explosion, 8 = zone destroyed...). The 1995 board consumed these
// through dpl particle calls that were never ported: DPLIndependantEffect's
// (<100) arm and ReadPSFX are both stubbed, so every weapon-hit / damage-band /
// death explosion rendered NOTHING. Like the weapon-beam layer above, this is
// the self-contained D3D9 port of that layer:
// BTLoadPfxFile -- parses a .PFX into BTPfxDef (the documented format)
// BTStartPfx -- starts an emitter instance at a world position
// (called by DPLIndependantEffect for effect_number < 100)
// BTDrawPfx -- per-frame sim + camera-facing additive-quad billboards
// (called beside BTDrawBeams in the render loop)
// Variance convention (decoded from DNBOOM.PFX: vel 150 + var -300 = a
// symmetric +-150 burst): sampled = value + variance * rand01().
// NOT yet honoured (noted, low-visibility): atten/attenv (distance
// attenuation), colorWarp/alphaWarp exponents are applied as t^(1/warp),
// the per-file texture name (all BT effects use the firesmoke sheet; we use
// the same decoded grit texture as the beams).
//===========================================================================//
struct BTPfxDef
{
int valid;
unsigned identifier;
int maxIssue; // total particles an instance may issue
float releasePeriod; // batch interval (s)
float rate; // particles per second while releasing
float px, py, pz, pv; // spawn offset + positional variance
float velx, vely, velz, velxv, velyv, velzv;
float rad, radv, exp, expv, dexp, dexpv; // radius, expansion rate, expansion decay
float accelx, accely, accelz, accelxv, accelyv, accelzv;
float atten, attenv; // (not yet honoured)
float sI[4], sIv[4]; // start colour inner RGBA + variance
float sO[4], sOv[4]; // start colour outer RGBA + variance
float eI[4], eIv[4]; // end colour inner RGBA + variance
float eO[4], eOv[4]; // end colour outer RGBA + variance
float colorWarp, alphaWarp;
float duration, durationv; // particle lifetime
};
#define BT_PFX_SLOTS 32
static BTPfxDef gBTPfxDefs[BT_PFX_SLOTS];
struct BTPfxEmitter
{
const BTPfxDef *def;
D3DXVECTOR3 pos;
// The effect's LOCAL FRAME (.PFX offsets/velocities are authored in the
// victim's mech-local space -- e.g. DAFC.PFX sprays -Z, out through the
// front armor toward the shooter). Identity when no frame is known.
D3DXVECTOR3 ax, ay, az;
float sinceRelease;
int issued;
int active;
};
struct BTPfxParticle
{
D3DXVECTOR3 pos, vel, accel;
float age, life;
float rad, exp, dexp;
float colorWarp, alphaWarp; // def-level warps, carried per particle
float sI[4], sO[4], eI[4], eO[4];
};
static std::vector<BTPfxEmitter> gBTPfxEmitters;
static std::vector<BTPfxParticle> gBTPfxParticles;
static float BTPfxRand01() // cheap deterministic LCG
{
static unsigned s = 0x2545F491u;
s = s * 1664525u + 1013904223u;
return (float)((s >> 8) & 0xFFFFFF) / 16777215.0f;
}
// Parse one .PFX text file (VIDEO\<name>) into a def slot. Format per the
// spec block carried inside every .PFX (and ReadPSFX's comment).
static int BTLoadPfxFile(const char *file_name, BTPfxDef &d)
{
char path[256];
strcpy(path, "VIDEO\\");
strcat(path, file_name);
FILE *fp = fopen(path, "rt");
if (!fp)
{
DEBUG_STREAM << "[pfx] could not open " << path << " -- its effects will not draw\n" << std::flush;
return 0;
}
memset(&d, 0, sizeof(d));
char line[256];
int ok = 1;
// line 1: texture name (recorded in the banner note; the shared sheet is used)
if (!fgets(line, sizeof(line), fp)) ok = 0;
if (ok && fgets(line, sizeof(line), fp))
ok = (sscanf(line, "%x %d %f %f", &d.identifier, &d.maxIssue, &d.releasePeriod, &d.rate) == 4);
else ok = 0;
if (ok && fgets(line, sizeof(line), fp))
ok = (sscanf(line, "%f %f %f %f", &d.px, &d.py, &d.pz, &d.pv) == 4);
else ok = 0;
if (ok && fgets(line, sizeof(line), fp))
ok = (sscanf(line, "%f %f %f %f %f %f", &d.velx, &d.vely, &d.velz, &d.velxv, &d.velyv, &d.velzv) == 6);
else ok = 0;
if (ok && fgets(line, sizeof(line), fp))
ok = (sscanf(line, "%f %f %f %f %f %f", &d.rad, &d.radv, &d.exp, &d.expv, &d.dexp, &d.dexpv) == 6);
else ok = 0;
if (ok && fgets(line, sizeof(line), fp))
ok = (sscanf(line, "%f %f %f %f %f %f", &d.accelx, &d.accely, &d.accelz, &d.accelxv, &d.accelyv, &d.accelzv) == 6);
else ok = 0;
if (ok && fgets(line, sizeof(line), fp))
ok = (sscanf(line, "%f %f", &d.atten, &d.attenv) == 2);
else ok = 0;
float *quads[8] = { d.sI, d.sIv, d.sO, d.sOv, d.eI, d.eIv, d.eO, d.eOv };
for (int q = 0; ok && q < 4; ++q) // 4 lines: sI+var, sO+var, eI+var, eO+var
{
if (fgets(line, sizeof(line), fp))
ok = (sscanf(line, "%f %f %f %f %f %f %f %f",
&quads[q*2][0], &quads[q*2][1], &quads[q*2][2], &quads[q*2][3],
&quads[q*2+1][0], &quads[q*2+1][1], &quads[q*2+1][2], &quads[q*2+1][3]) == 8);
else ok = 0;
}
if (ok && fgets(line, sizeof(line), fp))
ok = (sscanf(line, "%f %f", &d.colorWarp, &d.alphaWarp) == 2);
else ok = 0;
if (ok && fgets(line, sizeof(line), fp))
ok = (sscanf(line, "%f %f", &d.duration, &d.durationv) == 2);
else ok = 0;
fclose(fp);
if (!ok)
{
DEBUG_STREAM << "[pfx] " << path << " did not parse -- effect disabled\n" << std::flush;
return 0;
}
d.valid = 1;
return 1;
}
// Slot-checked load entry used by the psfx page loader (LoadMission walk).
int BTLoadPfxFile_slot(const char *file_name, int slot)
{
if (slot < 0 || slot >= BT_PFX_SLOTS)
return 0;
return BTLoadPfxFile(file_name, gBTPfxDefs[slot]);
}
// Start one effect instance at a world position (the DPLIndependantEffect
// contract: renderer-owned, runs to termination on its own). The optional
// frame rows orient the .PFX's mech-local offsets/velocities in the world
// (pass the victim entity's localToWorld X/Y/Z rows); identity when absent.
void BTStartPfxFrame(int effect_number, float x, float y, float z,
const float *xrow, const float *yrow, const float *zrow)
{
if (effect_number < 0 || effect_number >= BT_PFX_SLOTS)
return;
const BTPfxDef &d = gBTPfxDefs[effect_number];
if (!d.valid)
return;
if (gBTPfxEmitters.size() > 64) // runaway guard
return;
BTPfxEmitter e;
e.def = &d;
e.pos = D3DXVECTOR3(x, y, z);
e.ax = xrow ? D3DXVECTOR3(xrow[0], xrow[1], xrow[2]) : D3DXVECTOR3(1, 0, 0);
e.ay = yrow ? D3DXVECTOR3(yrow[0], yrow[1], yrow[2]) : D3DXVECTOR3(0, 1, 0);
e.az = zrow ? D3DXVECTOR3(zrow[0], zrow[1], zrow[2]) : D3DXVECTOR3(0, 0, 1);
e.sinceRelease = 1e9f; // first batch releases immediately
e.issued = 0;
e.active = 1;
gBTPfxEmitters.push_back(e);
}
void BTStartPfx(int effect_number, float x, float y, float z)
{
BTStartPfxFrame(effect_number, x, y, z, 0, 0, 0);
}
static void BTPfxSpawn(const BTPfxEmitter &e)
{
const BTPfxDef &d = *e.def;
if (gBTPfxParticles.size() > 2048) // global particle cap
return;
BTPfxParticle p;
// Sample in the effect's LOCAL frame, then orient into the world through
// the emitter's basis (the victim's localToWorld rows). The position
// jitter pv is an isotropic scatter about the base offset -> symmetric
// (rand +-pv); the paired variances stay value + var*rand01 (DNBOOM's
// vel 150 / var -300 decodes to the symmetric +-150 burst).
D3DXVECTOR3 lp(
d.px + d.pv * (BTPfxRand01() * 2.0f - 1.0f),
d.py + d.pv * (BTPfxRand01() * 2.0f - 1.0f),
d.pz + d.pv * (BTPfxRand01() * 2.0f - 1.0f));
D3DXVECTOR3 lv(
d.velx + d.velxv * BTPfxRand01(),
d.vely + d.velyv * BTPfxRand01(),
d.velz + d.velzv * BTPfxRand01());
D3DXVECTOR3 la(
d.accelx + d.accelxv * BTPfxRand01(),
d.accely + d.accelyv * BTPfxRand01(),
d.accelz + d.accelzv * BTPfxRand01());
p.pos = e.pos + e.ax * lp.x + e.ay * lp.y + e.az * lp.z;
p.vel = e.ax * lv.x + e.ay * lv.y + e.az * lv.z;
p.accel = e.ax * la.x + e.ay * la.y + e.az * la.z;
p.age = 0.0f;
p.life = d.duration + d.durationv * BTPfxRand01();
if (p.life < 0.05f) p.life = 0.05f;
p.rad = d.rad + d.radv * BTPfxRand01();
p.exp = d.exp + d.expv * BTPfxRand01();
p.dexp = d.dexp + d.dexpv * BTPfxRand01();
p.colorWarp = (d.colorWarp > 1e-3f) ? d.colorWarp : 1.0f;
p.alphaWarp = (d.alphaWarp > 1e-3f) ? d.alphaWarp : 1.0f;
for (int i = 0; i < 4; ++i)
{
p.sI[i] = d.sI[i] + d.sIv[i] * BTPfxRand01();
p.sO[i] = d.sO[i] + d.sOv[i] * BTPfxRand01();
p.eI[i] = d.eI[i] + d.eIv[i] * BTPfxRand01();
p.eO[i] = d.eO[i] + d.eOv[i] * BTPfxRand01();
}
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();
void BTDrawPfx(LPDIRECT3DDEVICE9 dev, const D3DXMATRIX *view, float dt)
{
// ---- sim ----
if (dt > 0.1f) dt = 0.1f; // stall guard
for (size_t ei = 0; ei < gBTPfxEmitters.size(); ++ei)
{
BTPfxEmitter &e = gBTPfxEmitters[ei];
if (!e.active) continue;
const BTPfxDef &d = *e.def;
e.sinceRelease += dt;
if (e.sinceRelease >= d.releasePeriod)
{
// one batch: rate particles/sec over the release period, total-capped
int batch = (int)(d.rate * (d.releasePeriod > 1e-4f ? d.releasePeriod : dt));
if (batch < 1) batch = 1;
if (e.issued + batch > d.maxIssue) batch = d.maxIssue - e.issued;
for (int b = 0; b < batch; ++b)
BTPfxSpawn(e);
e.issued += batch;
e.sinceRelease = 0.0f;
if (e.issued >= d.maxIssue)
e.active = 0; // done issuing -> instance ends
}
}
{ // compact finished emitters
size_t w = 0;
for (size_t i = 0; i < gBTPfxEmitters.size(); ++i)
if (gBTPfxEmitters[i].active) gBTPfxEmitters[w++] = gBTPfxEmitters[i];
gBTPfxEmitters.resize(w);
}
{ // advance + expire particles
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;
p.vel += p.accel * dt;
p.pos += p.vel * dt;
p.rad += p.exp * dt;
p.exp += p.dexp * dt;
if (p.rad < 0.05f) p.rad = 0.05f;
gBTPfxParticles[w++] = p;
}
gBTPfxParticles.resize(w);
}
if (gBTPfxParticles.empty())
return;
// ---- draw: camera-facing additive quads (inner core + outer glow) ----
const D3DXVECTOR3 right(view->_11, view->_21, view->_31);
const D3DXVECTOR3 up (view->_12, view->_22, view->_32);
DWORD sLight, sFog, sZW, sBlend, sSrc, sDst, sCull, sCop, sCa1, sCa2, sAop, sAa1, sAa2;
dev->GetRenderState(D3DRS_LIGHTING, &sLight);
dev->GetRenderState(D3DRS_FOGENABLE, &sFog);
dev->GetRenderState(D3DRS_ZWRITEENABLE, &sZW);
dev->GetRenderState(D3DRS_ALPHABLENDENABLE, &sBlend);
dev->GetRenderState(D3DRS_SRCBLEND, &sSrc);
dev->GetRenderState(D3DRS_DESTBLEND, &sDst);
dev->GetRenderState(D3DRS_CULLMODE, &sCull);
dev->GetTextureStageState(0, D3DTSS_COLOROP, &sCop);
dev->GetTextureStageState(0, D3DTSS_COLORARG1, &sCa1);
dev->GetTextureStageState(0, D3DTSS_COLORARG2, &sCa2);
dev->GetTextureStageState(0, D3DTSS_ALPHAOP, &sAop);
dev->GetTextureStageState(0, D3DTSS_ALPHAARG1, &sAa1);
dev->GetTextureStageState(0, D3DTSS_ALPHAARG2, &sAa2);
// PREMULTIPLIED blending (ONE, INVSRCALPHA): the one model that renders BOTH
// authored particle families correctly. FIRE (bright colour, the additive
// glow) and SMOKE (dark/negative-ramp colour with high alpha -> OCCLUDES the
// scene behind it -- DDAM2 is 30% grey, DDTHSMK ramps to negative: both are
// invisible under pure additive, which is why damaged mechs never smoked).
// Vertex colour carries colour*alpha (premultiplied), vertex alpha carries
// the occlusion; the texture's falloff mask modulates both.
// BT_PFX_ADD=1 flips back to pure additive for A/B comparison.
static int s_pfxAdditive = -1;
if (s_pfxAdditive < 0)
{
const char *av = getenv("BT_PFX_ADD");
s_pfxAdditive = (av != 0 && av[0] == '1') ? 1 : 0;
}
dev->SetRenderState(D3DRS_LIGHTING, FALSE);
dev->SetRenderState(D3DRS_FOGENABLE, FALSE);
dev->SetRenderState(D3DRS_ZWRITEENABLE, FALSE); // test Z, don't write it
dev->SetRenderState(D3DRS_ALPHABLENDENABLE, TRUE);
dev->SetRenderState(D3DRS_SRCBLEND, D3DBLEND_ONE);
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;
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)))
{
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; }
}
}
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);
dev->SetFVF(D3DFVF_XYZ | D3DFVF_DIFFUSE | D3DFVF_TEX1);
struct V { float x, y, z; DWORD c; float u, v; };
static std::vector<V> verts;
verts.clear();
// BACK-TO-FRONT order: premultiplied occlusion (smoke) composites in depth
// order; unsorted draws pop when a near puff renders before a far one.
static std::vector<size_t> order;
static std::vector<float> depth;
order.resize(gBTPfxParticles.size());
depth.resize(gBTPfxParticles.size());
for (size_t i = 0; i < gBTPfxParticles.size(); ++i)
{
const D3DXVECTOR3 &pp = gBTPfxParticles[i].pos;
order[i] = i;
depth[i] = pp.x * view->_13 + pp.y * view->_23 + pp.z * view->_33; // view-space z
}
std::sort(order.begin(), order.end(),
[](size_t a, size_t b) { return depth[a] > depth[b]; });
for (size_t oi = 0; oi < order.size(); ++oi)
{
const BTPfxParticle &p = gBTPfxParticles[order[oi]];
float t = p.age / p.life;
if (t < 0.0f) t = 0.0f; if (t > 1.0f) t = 1.0f;
// colour/alpha interpolation start->end, warped (t^(1/warp): a large
// warp shifts to the end colour early -- the fast orange->smoke shift)
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)
{
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;
if (a <= 0.0f) continue;
if (a > 1.0f) a = 1.0f;
// PREMULTIPLIED: vertex rgb = colour x alpha (the framebuffer
// contribution), vertex a = alpha (the occlusion). A colour ramped
// NEGATIVE (DDTHSMK's smoke tail) clamps to 0 -> pure occluding
// black smoke; a bright fire colour with fading alpha dims out.
int ir = (int)(r * a * 255.0f); if (ir > 255) ir = 255; if (ir < 0) ir = 0;
int ig = (int)(g * a * 255.0f); if (ig > 255) ig = 255; if (ig < 0) ig = 0;
int ib = (int)(b * a * 255.0f); if (ib > 255) ib = 255; if (ib < 0) ib = 0;
int ia = (int)(a * 255.0f); if (ia > 255) ia = 255; if (ia < 0) ia = 0;
if (ia == 0 && ir == 0 && ig == 0 && ib == 0) continue;
DWORD c = ((DWORD)ia << 24) | (ir << 16) | (ig << 8) | ib;
float rad = p.rad * scale;
D3DXVECTOR3 rv = right * rad, uv = up * rad;
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[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]); }
}
}
if (!verts.empty())
dev->DrawPrimitiveUP(D3DPT_TRIANGLELIST, (UINT)verts.size() / 3,
&verts[0], sizeof(V));
dev->SetTexture(0, NULL);
dev->SetRenderState(D3DRS_LIGHTING, sLight);
dev->SetRenderState(D3DRS_FOGENABLE, sFog);
dev->SetRenderState(D3DRS_ZWRITEENABLE, sZW);
dev->SetRenderState(D3DRS_ALPHABLENDENABLE, sBlend);
dev->SetRenderState(D3DRS_SRCBLEND, sSrc);
dev->SetRenderState(D3DRS_DESTBLEND, sDst);
dev->SetRenderState(D3DRS_CULLMODE, sCull);
dev->SetTextureStageState(0, D3DTSS_COLOROP, sCop);
dev->SetTextureStageState(0, D3DTSS_COLORARG1, sCa1);
dev->SetTextureStageState(0, D3DTSS_COLORARG2, sCa2);
dev->SetTextureStageState(0, D3DTSS_ALPHAOP, sAop);
dev->SetTextureStageState(0, D3DTSS_ALPHAARG1, sAa1);
dev->SetTextureStageState(0, D3DTSS_ALPHAARG2, sAa2);
}
// BT (task #20): the live window client aspect (0 = never resized -> the
// projection builders fall back to the configured x_size/y_size). Set by
// L4NotifyWindowResized (WM_SIZE) so a user-resized window doesn't stretch
@@ -3575,8 +4061,32 @@ void
Unregister_Object(light_namelist);
delete light_namelist;
}
//
// Get the list of PSFX effects we should load into RAM. RECONSTRUCTED
// (was stubbed with ReadPSFX): the "psfxN=file.pfx" entries on the visited
// pages ([pfx_day]/[pfx_night], include-reached from the mission's arena
// page) bind each dpl effect NUMBER to its authentic .PFX definition,
// loaded into the BT particle layer (BTLoadPfxFile / BTStartPfx / BTDrawPfx
// at the top of this file) that DPLIndependantEffect's <100 arm consumes.
//
if ((psfx_namelist = master_notation_file->MakeEntryList(starting_page_name, "psfx")) != NULL)
{
extern int BTLoadPfxFile_slot(const char *file_name, int slot);
int pfx_loaded = 0;
for (entry = psfx_namelist->GetFirstEntry();
entry;
entry = entry->GetNextEntry())
{
int psfx_number = atoi(entry->GetName() + 4); // "psfxN"
const char *psfx_file_name = entry->GetChar();
pfx_loaded += BTLoadPfxFile_slot(psfx_file_name, psfx_number);
}
DEBUG_STREAM << "[pfx] page '" << starting_page_name << "': "
<< pfx_loaded << " effect definitions loaded\n" << std::flush;
delete psfx_namelist;
}
////
//// Get a the list of PSFX effects we should load into RAM
//// (original stubbed loader retained for provenance)
////
//if ((psfx_namelist = master_notation_file->MakeEntryList(starting_page_name, "psfx")) != NULL)
//{
@@ -5894,9 +6404,29 @@ void
<< entity->localOrigin.linearPosition.x
<< ", " << entity->localOrigin.linearPosition.y
<< ", " << entity->localOrigin.linearPosition.z << "], " << effect_number << ");" << std::endl << std::flush;
DPLIndependantEffect(
entity->localOrigin.linearPosition,
effect_number);
// 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.
{
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,
(float)entity->localOrigin.linearPosition.x,
(float)entity->localOrigin.linearPosition.y,
(float)entity->localOrigin.linearPosition.z,
xr, yr, zr);
}
}
else
{
@@ -6928,6 +7458,13 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
// beam is occluded where it enters terrain). See BTDrawBeams above.
BTDrawBeams(mDevice, &viewTransform, (float)dT);
// BT .PFX particle effects (explosions / damage bands / mech death) -- the
// same pass as the beams: world proj+view set, Z-test on, additive quads.
{
extern void BTDrawPfx(LPDIRECT3DDEVICE9 dev, const D3DXMATRIX *view, float dt);
BTDrawPfx(mDevice, &viewTransform, (float)dT);
}
//
// And don't forget particles too
//
@@ -7565,6 +8102,20 @@ void
return;
}
//
// 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.
//
{
extern void BTStartPfx(int effect_number, float x, float y, float z);
BTStartPfx(effect_number, location.x, location.y, location.z);
}
//dpl_EXPLOSION_EFFECT_INFO my_explosion;
//my_explosion.type = effect_number;
//my_explosion.x = location.x;
+5
View File
@@ -70,6 +70,11 @@ public:
virtual bool IsStatic() { return false; }
d3d_OBJECT *GetDrawObj() { return this->graphicalObject; }
// Swap this component's drawable in place. Execute() re-reads graphicalObject
// every frame, so changing it makes the segment draw a different mesh next
// frame -- the mechanism the BT damage-model "RemakeEntity" swap uses to show
// a destroyed segment without tearing down and rebuilding the whole tree.
void SetDrawObj(d3d_OBJECT *obj) { this->graphicalObject = obj; }
protected:
void ExecuteChildren();
+166 -1
View File
@@ -294,6 +294,19 @@ HierarchicalDrawComponent*
entity, VideoRenderable::Dynamic, NULL,
inDeathZone, intersect_mode, intersect_mask);
//
// Start (or reset) this mech's RemakeEntity bookkeeping: record the skeleton
// variant now; the per-segment renderables + initial graphic states are filled
// in as the tree is built below (see RemakeEntityRenderables).
//
MechRenderTree &render_tree = mMechRenderTrees[entity];
render_tree = MechRenderTree();
render_tree.skeletonType = (int)skeletonType;
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[BTrender] tracking mech tree for entity " << (void*)entity
<< " classID=" << entity->GetClassID() << " ("
<< mMechRenderTrees.size() << " tracked)\n" << std::flush;
//
// Per-segment renderable array (the parent for each segment's children).
//
@@ -368,7 +381,20 @@ HierarchicalDrawComponent*
// Load this segment's geometry (skeleton-variant .bgf), if any.
//
d3d_OBJECT *this_object = NULL;
CString *object_name = segment->GetVideoObjectName(skeletonType); // FUN_00424084
// Select the segment's model VARIANT by its damage zone's graphic state.
// The engine keys video-object names by {skeleton, damage_graphic_state}
// (SEGMENT.h:172): a Destroyed zone (GetGraphicState()==1) returns the
// destroyed/damaged model, so a wrecked segment visibly comes apart. The
// recon previously passed ONLY the skeleton type, leaving the state at its
// default 0 (Exists) -> always the intact model = no visible damage.
Enumeration seg_gstate = 0; // ExistsGraphicState
{
int zone_index = segment->GetPrimaryDamageZone(); // SEGMENT.h:107 (a zone INDEX)
if (zone_index >= 0 && zone_index < entity->damageZoneCount
&& entity->damageZones[zone_index] != 0)
seg_gstate = entity->damageZones[zone_index]->GetGraphicState(); // DAMAGE.h:196
}
CString *object_name = segment->GetVideoObjectName(skeletonType, seg_gstate); // FUN_00424084
if (object_name != NULL)
{
char filename[44];
@@ -454,6 +480,11 @@ HierarchicalDrawComponent*
}
}
dcs_array[segment_slot] = child;
// Record this segment's renderable + the graphic state it was built with,
// so a later damage-state change can swap its mesh in place (RemakeEntity).
render_tree.segRenderable[segment_slot] = child;
render_tree.segGState[segment_slot] = (int)seg_gstate;
}
delete [] dcs_array;
@@ -539,6 +570,140 @@ HierarchicalDrawComponent*
}
//
//#############################################################################
// RemakeEntityRenderables (the render "RemakeEntity" state -- damage swap)
//#############################################################################
//
// A damage zone's graphic state changed (a segment became Destroyed or Gone).
// Walk this mech's segments and, for any whose graphic state now differs from
// what its renderable was built with, re-pick the video-object variant by the
// new graphic state and swap it onto the joint renderable IN PLACE. Execute()
// re-reads graphicalObject each frame, so the wrecked mesh shows next frame.
// No teardown: the component dtor does not cascade to children (L4VIDRND.cpp:104),
// so a rebuild would leak -- the authentic behaviour is an in-place mesh swap.
//
void
BTL4VideoRenderer::RemakeEntityRenderables(Entity *entity)
{
std::map<Entity*, MechRenderTree>::iterator tree_it =
mMechRenderTrees.find(entity);
if (tree_it == mMechRenderTrees.end())
{
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[BTrender] RemakeEntity: no render tree for entity "
<< (void*)entity << " (" << mMechRenderTrees.size()
<< " tracked)\n" << std::flush;
return; // tree not built yet -- Make will read the state
}
MechRenderTree &render_tree = tree_it->second;
JointedMover *jointed_mover = (JointedMover *)entity;
EntitySegment::SkeletonType skeletonType =
(EntitySegment::SkeletonType)render_tree.skeletonType;
EntitySegment::SegmentTableIterator segment_iterator(jointed_mover->segmentTable);
EntitySegment *segment;
int swapped = 0, checked = 0, mapped = 0;
while ((segment = segment_iterator.ReadAndNext()) != NULL)
{
if (segment->IsSiteSegment() != 0)
continue;
++checked;
int segment_slot = segment->GetIndex();
std::map<int, HierarchicalDrawComponent*>::iterator r =
render_tree.segRenderable.find(segment_slot);
if (r == render_tree.segRenderable.end() || r->second == NULL)
continue;
++mapped;
//
// Current graphic state for this segment (from its damage zone).
//
Enumeration seg_gstate = 0; // ExistsGraphicState
int zone_index = segment->GetPrimaryDamageZone();
if (zone_index >= 0 && zone_index < entity->damageZoneCount
&& entity->damageZones[zone_index] != 0)
seg_gstate = entity->damageZones[zone_index]->GetGraphicState();
if ((int)seg_gstate == render_tree.segGState[segment_slot])
continue; // unchanged -- nothing to swap
render_tree.segGState[segment_slot] = (int)seg_gstate;
//
// Re-pick + load the segment's video-object variant for the new graphic
// state (same construction as the initial build in MakeMechRenderables).
//
CString *object_name = segment->GetVideoObjectName(skeletonType, seg_gstate);
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[BTrender] seg '" << (const char *)segment->GetName()
<< "' slot " << segment_slot << " -> gstate " << (int)seg_gstate
<< " variant=" << (object_name ? (const char *)*object_name : "(none)")
<< "\n" << std::flush;
d3d_OBJECT *new_object = NULL;
if (object_name != NULL)
{
char filename[44];
strcpy(filename, (const char *)*object_name);
int len = (int)strlen(filename);
if (len >= 4)
filename[len - 4] = '\0'; // strip ".bgf"
strcat(filename, ".bgf");
new_object = d3d_OBJECT::LoadObject(GetDevice(), filename);
if (new_object == NULL && getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[BTrender] damaged variant '" << filename
<< "' FAILED to load (expects VIDEO\\*.x)\n" << std::flush;
if (new_object != NULL && strstr(filename, "tshd") != NULL)
{
new_object->SetIsShadow(1);
for (int op = 0; op < new_object->GetDrawOpCount(); ++op)
new_object->GetDrawOp(op)->alphaTest = true;
}
}
//
// GoneGraphicState (blown off): no mesh -> hide the segment. Destroyed/
// Exists: swap to the variant if it loaded; otherwise keep the current
// mesh (don't blank a segment merely because a damaged .bgf is missing).
//
if (new_object != NULL)
r->second->SetDrawObj(new_object);
else if ((int)seg_gstate == DamageZone::GoneGraphicState)
r->second->SetDrawObj(NULL);
++swapped;
}
if (swapped != 0 || getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[BTrender] RemakeEntity: " << swapped
<< " mesh(es) swapped (" << mapped << " body segs mapped of "
<< checked << " checked)\n" << std::flush;
}
//
//#############################################################################
// BTRemakeMechModel (sim-side bridge -- see btl4vid.hpp)
//#############################################################################
//
// Reaches the live renderer and refreshes a mech's visible model after its
// damage graphic state changed. Called from MechDeathHandler (sim TU). The
// frame loop is single-threaded (sim + render share the main thread; only the
// network RX socket runs on its own thread), so loading geometry here is safe.
//
void BTRemakeMechModel(Entity *entity)
{
if (entity == NULL || application == NULL)
return;
BTL4VideoRenderer *renderer =
(BTL4VideoRenderer *)application->GetVideoRenderer();
if (renderer != NULL)
renderer->RemakeEntityRenderables(entity);
}
//
//#############################################################################
// BTReticleRenderable::AddWeapon
+43
View File
@@ -78,6 +78,7 @@ class DPLRenderer;
#if !defined(NAMELIST_HPP)
# include <namelist.hpp> // NameList / NameList::Entry
#endif
#include <map> // per-mech render-tree bookkeeping (RemakeEntity)
class Entity;
class Mission;
@@ -534,6 +535,40 @@ class BTReticleRenderable:
void
TearDownMaterialSubstitutionList(); // @004d11e8
//
// RemakeEntity (damage-model swap). The 1996 binary drives a per-entity
// render state machine (Make / RemakeEntity / DestroyEntity -- the state
// name strings survive at .rdata 0x4e3f20). Only "Make" was ported (the
// tree is built ONCE at entity creation, with fixed d3d_OBJECTs).
// RemakeEntity is the model REFRESH: when a damage zone's graphic state
// changes (a segment becomes Destroyed/Gone), re-pick each segment's video-
// object variant by its zone graphic state (EntitySegment::GetVideoObjectName
// is keyed by {skeleton, graphic_state}) and swap it onto the already-built
// joint renderable IN PLACE (HierarchicalDrawComponent::Execute re-reads
// graphicalObject each frame). No teardown: the component dtor does not
// cascade to children (L4VIDRND.cpp:104) so a rebuild would leak -- the
// authentic behaviour is an in-place mesh swap.
//
void
RemakeEntityRenderables(Entity *entity);
protected:
//
// Per-mech render-tree bookkeeping so RemakeEntityRenderables can find each
// segment's joint renderable (the dcs_array in MakeMechRenderables is local
// and freed). Keyed by Entity*; one entry per built mech. segRenderable
// maps a segment SLOT (EntitySegment::GetIndex) to its draw component;
// segGState is the graphic state last applied to that slot so a swap only
// reloads geometry on an actual state change.
//
struct MechRenderTree
{
int skeletonType; // EntitySegment::SkeletonType used at build
std::map<int, HierarchicalDrawComponent*> segRenderable; // slot -> joint renderable
std::map<int, int> segGState; // slot -> last applied graphic state
};
std::map<Entity*, MechRenderTree> mMechRenderTrees;
protected:
//
// Renderer-manager overrides
@@ -598,6 +633,14 @@ class BTReticleRenderable:
*tracer_zone; // [0x2e4] projectile tracers
};
//
// Sim-side bridge to RemakeEntityRenderables. MechDeathHandler (sim TU) calls
// this when a mech's damage graphic state changes; defined in btl4vid.cpp, it
// reaches the live renderer via l4_application->GetVideoRenderer(). A free
// function so the sim TU needs no renderer header (just this extern).
//
extern void BTRemakeMechModel(Entity *entity);
#endif // BTL4VID_HPP
//===========================================================================//
+11 -5
View File
@@ -486,7 +486,13 @@ void
Mech::TakeDamageMessageHandler(TakeDamageMessage *message)
{
Check(message);
DamageLookupTable *table = (DamageLookupTable *)Wword(0x111);
// Maintain the last-attacker bookkeeping (mech[0x43c]): read by the
// DamageZone LOD router (same-attacker redirect reuse, mechdmg.cpp:374)
// and by the damage-band effect orientation. Was declared but never
// WRITTEN -- a reconstruction gap; the natural authentic write site is
// this handler (every damage message carries the inflictor). [T3]
lastInflictingID = message->inflictingEntity;
DamageLookupTable *table = (DamageLookupTable *)damageLookupTable; // named member (Wword absorbs!)
if (message->invalidDamageZone && table != 0)
{
int zone = table->ResolveHit(message->damageData.impactPoint);
@@ -1323,7 +1329,7 @@ Mech::Mech(
// cached at mech[0x111] (byte 0x444). (Was an empty-name StandingAnimation
// stub -> 0 rows; the real class is dmgtable.cpp.)
//
Wword(0x111) = 0;
damageLookupTable = 0; // named member (Wword absorbs!)
ResourceDescription *dzForName =
MechFindResource(creation_message->resourceID,
ResourceDescription::DamageZoneStreamResourceType); // type 0x14 (for its name)
@@ -1339,7 +1345,7 @@ Mech::Mech(
DynamicMemoryStream cylStream( // FUN_004032dc, offset 0
cylRes->resourceAddress, cylRes->resourceSize, 0);
DamageLookupTable *table = new DamageLookupTable(this, &cylStream); // FUN_0049ea48
Wword(0x111) = (int)table;
damageLookupTable = (int)table; // named member (Wword absorbs!)
DEBUG_STREAM << "[cyl] table '" << dzForName->resourceName
<< "' layers=" << table->LayerCount() << "\n" << std::flush;
}
@@ -1407,9 +1413,9 @@ Mech::~Mech()
<< "\n" << std::flush;
}
if (Wword(0x111) != 0) // cylinder hit-location table (STEP 6)
if (damageLookupTable != 0) // cylinder hit-location table (STEP 6)
{
delete (DamageLookupTable *)Wword(0x111); // frees layers/slices/entries
delete (DamageLookupTable *)damageLookupTable; // frees layers/slices/entries
}
//
+8 -1
View File
@@ -488,7 +488,14 @@ protected:
int heatLevel; // @0x518 this[0x146]
int heatCapacity; // @0x51c this[0x147] = 0.6 * heatLevel
Time creationTime; // @0x778 this[0x1de]
int ammoExpended; // @0x444 this[0x111] (FUN_0049ea48)
// Cylinder hit-location table (STEP 6) -- DamageLookupTable* cached by the
// ctor from the type-0x1d resource (FUN_0049ea48 = the table ctor), read by
// Mech::TakeDamageMessageHandler to resolve an unaimed (-1) hit's zone from
// its impact point. MUST be a real named member: the old home Wword(0x111)
// is the recon ABSORBER bank (BTVal stores nothing, reads 0) -> the cached
// table silently vanished and every unaimed hit no-op'd. (Was mislabeled
// "ammoExpended"; binary slot this[0x111] is this pointer.)
int damageLookupTable; // @0x444 this[0x111] (FUN_0049ea48)
int deathHandler; // @0x850 this[0x214] (FUN_0042a984)
// Three ref-counted creation-name objects (badge/color/insignia).
+152 -27
View File
@@ -140,6 +140,7 @@
#include <BOXTREE.hpp> // BoundingBoxTreeNode::FindBoundingBoxUnder / ...ContainingColumn
#include <BOXSOLID.hpp> // BoxedSolid / BoxedSolidCollision / BoxedSolidCollisionList
#include <cultural.hpp> // CulturalIcon::IsStoppingCollisionVolume / GetClassDerivations
#include <hostmgr.hpp> // HostManager::GetEntityPointer (band-effect attacker resolve)
static const Scalar kBehindCull = -1.0e-4f; // _DAT_004ac044
@@ -822,7 +823,10 @@ static void
Mech *m = (Mech *)tgt;
if (m->damageZoneCount > 0)
{
int zone = m->FirstVitalZone(); // concentrated fire -> a kill
// UNAIMED (STEP 6): the projectile's world impact position IS the
// hit point; Mech::TakeDamageMessageHandler resolves the struck
// zone from the cylinder hit-location table. (Previously this
// aimed the internal vital zone directly -> invisible insta-kill.)
Damage dmg;
dmg.damageType = Damage::ExplosiveDamageType;
dmg.damageAmount = p.damage;
@@ -830,12 +834,13 @@ static void
dmg.impactPoint = p.pos;
Entity::TakeDamageMessage take_damage(
Entity::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage),
0 /*inflictor id: bring-up*/, zone, dmg);
0 /*inflictor id: bring-up*/, -1 /*unaimed -> cylinder resolves*/, dmg);
tgt->Dispatch(&take_damage);
// gauge scoring wave (Step 6): a projectile hit credits SCORE too
// (tgt == gEnemyMech here; local player is the viewpoint shooter).
BTPostDamageScore((Entity *)tgt, p.damage);
DEBUG_STREAM << "[projectile] IMPACT damage=" << p.damage << " zone=" << zone << "\n" << std::flush;
DEBUG_STREAM << "[projectile] IMPACT damage=" << p.damage
<< " zone=" << take_damage.damageZone << " (cyl-resolved)\n" << std::flush;
}
}
p.active = 0;
@@ -853,7 +858,7 @@ static void
// derives the position from the subsystem (mech+0x184); we use the mech origin.
//###########################################################################
void
BTSpawnDamageEffect(Mech *mech, int effect_resource)
BTSpawnDamageEffect(Mech *mech, int effect_resource, int segment_index)
{
if (mech == 0)
return;
@@ -862,7 +867,55 @@ void
res = gExplodeRes; // fall back to the resolved generic explosion
if (res <= 0)
return; // nothing to spawn yet
Origin o = mech->localOrigin; // at the mech (binary: per-subsystem)
//
// Effect position: the damaged zone's SEGMENT, in world space (the binary
// derives the effect position from the damaged subsystem/zone, not the mech
// origin -- an origin-anchored effect burns at ground level between the
// feet). Resolve segment_index through the segment table exactly as the
// gun-port muzzles do (GetSegmentToEntity x localToWorld); fall back to
// torso height over the origin when the zone has no segment.
//
Origin o = mech->localOrigin;
Point3D fxPos = o.linearPosition;
fxPos.y += kMuzzleHeight; // default: torso height
if (segment_index >= 0)
{
EntitySegment::SegmentTableIterator it(mech->segmentTable);
EntitySegment *seg;
while ((seg = it.ReadAndNext()) != NULL)
{
if (seg->GetIndex() == segment_index)
{
AffineMatrix mw;
mw.Multiply(seg->GetSegmentToEntity(), mech->localToWorld);
fxPos = mw; // Point3D = matrix translation
break;
}
}
}
o.linearPosition = fxPos;
// IMPACT FRAME for the band effect: orient local -Z from the victim toward
// the LAST ATTACKER (lastInflictingID, maintained by TakeDamageMessageHandler)
// -- the .PFX offsets are authored "out of the struck armor". Falls back to
// the victim's own frame when the attacker can't be resolved.
if (application != 0 && application->GetHostManager() != 0)
{
Entity *attacker =
application->GetHostManager()->GetEntityPointer(mech->lastInflictingID);
if (attacker != 0 && attacker != (Entity *)mech)
{
float adx = (float)(mech->localOrigin.linearPosition.x
- attacker->localOrigin.linearPosition.x);
float adz = (float)(mech->localOrigin.linearPosition.z
- attacker->localOrigin.linearPosition.z);
if (adx * adx + adz * adz > 1e-6f)
o.angularPosition = // -Z at the attacker
EulerAngles(0.0f, (Scalar)atan2((double)adx, (double)adz), 0.0f);
}
}
Explosion::MakeMessage m(
Explosion::MakeMessageID, sizeof(Explosion::MakeMessage),
(Entity::ClassID)RegisteredClass::ExplosionClassID, EntityID::Null,
@@ -1088,6 +1141,27 @@ void
}
}
// DEV: BT_AUTOFIRE=1 holds the trigger (drives the fireForced hook) and
// BT_AUTODRIVE=<0..1> holds the throttle (drives the forced hook) so the
// full walk->fire->damage->death chain can be exercised headlessly.
{
static int sAutoFire = -1;
static float sAutoDrive = -1.0f;
if (sAutoFire < 0)
{
const char *af = getenv("BT_AUTOFIRE");
sAutoFire = (af && *af == '1') ? 1 : 0;
const char *ad = getenv("BT_AUTODRIVE");
sAutoDrive = ad ? (float)atof(ad) : 0.0f;
}
gBTDrive.fireForced = sAutoFire;
if (sAutoDrive > 0.0f)
{
gBTDrive.forced = 1;
gBTDrive.forcedThrottle = sAutoDrive;
}
}
if (gBTDrive.allStop) { sLever = 0.0f; sDetent = 0; gBTDrive.allStop = 0; }
if (getenv("BT_KEY_LOG"))
@@ -2216,7 +2290,32 @@ void
gFireCooldown = kFireCooldown;
++gShotCount;
Origin exp_origin = ((Mech *)gEnemyMech)->localOrigin; // at the target
// Beam entry point: on the enemy's surface toward the shooter, at
// the beam's convergence height. (Exactly the axis point would be
// angularly degenerate for the cylinder sector lookup.) Used for
// BOTH the hit-explosion position and the unaimed damage dispatch --
// the effect fires where the beam lands (chest height, facing side),
// not at the mech origin (ground level between the feet).
Point3D impact = enemyPos;
if (range > 1e-3f)
{
impact.x -= (ddx / range) * 3.0f; // ~torso radius toward shooter
impact.z -= (ddz / range) * 3.0f;
}
impact.y += kMuzzleHeight; // chest height (beam aim height)
Origin exp_origin = ((Mech *)gEnemyMech)->localOrigin;
exp_origin.linearPosition = impact; // at the hit point
// IMPACT FRAME: the .PFX hit effects are authored with local -Z =
// "out of the struck armor" (DAFC offsets/velocities spray -Z).
// That is the IMPACT normal -- toward the SHOOTER -- not the
// victim's body front: with the victim's own quat a rear/side hit
// flashed on the FAR (front) side of the mech. Build the frame as
// a yaw with -Z aimed from the victim at the shooter. Engine yaw
// convention (MATRIX.cpp:196-209): forward -Z = (-sin y, 0, -cos y)
// -> yaw = atan2(ddx, ddz) points -Z at the shooter. [T0]
exp_origin.angularPosition =
EulerAngles(0.0f, (Scalar)atan2((double)ddx, (double)ddz), 0.0f);
Explosion::MakeMessage exp_message(
Explosion::MakeMessageID,
@@ -2241,21 +2340,19 @@ void
DEBUG_STREAM << "[fire] Explosion::Make returned NULL\n" << std::flush;
}
// --- DAMAGE (real): dispatch a TakeDamage message to a VALID zone; the
// engine base handler routes it to Mech__DamageZone::TakeDamage (the real
// armor/structure model). Aim a rotating zone so the whole mech degrades;
// read back structureLevel (now valid -- friend access) to show it climb
// toward 1.0 (destroyed).
// --- DAMAGE (real, STEP 6): dispatch UNAIMED (zone == -1) with the
// beam's world entry point; Mech::TakeDamageMessageHandler resolves
// the zone from the cylinder hit-location table (the STEP-6
// reconstruction) and the base handler routes it to
// Mech__DamageZone::TakeDamage (the real armor/structure model).
// Hits therefore land on the EXTERIOR zone facing the shooter
// (arm/leg/torso -- zones with visible segments that wreck), and
// internal vitals die only through the authentic destruction
// cascade (RecurseSegmentTable / SendSubsystemDamage) -- no more
// invisible one-shot kills on the soft internal vital zone.
if (gEnemyMech->damageZoneCount > 0)
{
int zc = gEnemyMech->damageZoneCount;
// Aim the first VITAL zone so concentrated fire destroys it (-> mech
// death). The faithful per-impact aim (cylinder lookup from the hit
// point) is STEP 6; until then we target a vital zone directly.
int zone = 0;
for (int k = 0; k < zc; ++k)
if (((Mech *)gEnemyMech)->Zone(k)->vitalDamageZone) { zone = k; break; }
// (impact computed above -- shared with the hit-explosion origin)
Damage dmg; // default-constructed
// Explosive: the weapon effect is an Explosion (explosive), not an
// energy beam. Also the correct type to exercise the zone armour/
@@ -2265,23 +2362,32 @@ void
dmg.damageType = Damage::ExplosiveDamageType;
dmg.damageAmount = kShotDamage;
dmg.burstCount = 1;
dmg.impactPoint = enemyPos; // world impact point
dmg.impactPoint = impact; // world impact point
Entity::TakeDamageMessage take_damage(
Entity::TakeDamageMessageID,
sizeof(Entity::TakeDamageMessage),
GetEntityID(), // inflicting = this (shooter)
zone, // valid zone -> base handler applies
-1, // UNAIMED -> receiver's cylinder resolves
dmg);
gEnemyMech->Dispatch(&take_damage);
// gauge scoring wave (Step 6): credit the local player for damage
// dealt -> SCORE climbs per hit (currentScore += tonnageRatio*award).
BTPostDamageScore(gEnemyMech, kShotDamage);
// No score for pounding the wreck (damage still applies -- zones
// clamp at 1.0, further segments wreck visibly).
if (!gEnemyDestroyed)
BTPostDamageScore(gEnemyMech, kShotDamage);
Scalar s = ((Mech *)gEnemyMech)->Zone(zone)->damageLevel; // [0,1], 1.0=destroyed (engine base field)
DEBUG_STREAM << "[damage] hit zone " << zone << "/" << zc
<< " structure=" << s << "\n" << std::flush;
// The handler wrote the resolved zone back into the message.
int zone = take_damage.damageZone;
if (zone >= 0 && zone < gEnemyMech->damageZoneCount)
{
Scalar s = ((Mech *)gEnemyMech)->Zone(zone)->damageLevel; // [0,1], 1.0=destroyed
DEBUG_STREAM << "[damage] hit zone " << zone << "/"
<< gEnemyMech->damageZoneCount
<< " structure=" << s << "\n" << std::flush;
}
}
// Death via the REAL damage model: a mech with a destroyed VITAL zone is
@@ -2304,8 +2410,22 @@ void
// fires; the ownerless dummy yields no death, DEATHS stays 0).
BTPostKillScore(gEnemyMech, kShotDamage);
// Death explosion at the target.
// Death effects, per the authentic BTDPL.INI effect-number map:
// 7 = "the big explosion used as part of mech death" (dnboom)
// 1 = "the mech death/rubble smoke plume" (ddthsmk)
// Fired directly into the render effect layer at the wreck (the
// unexported death sequence's effect chain dispatched these
// numbers through the 0xBD3 manager; the numbers are the data).
{
extern void BTStartPfx(int effect_number, float x, float y, float z);
Point3D wreck = ((Mech *)gEnemyMech)->localOrigin.linearPosition;
BTStartPfx(7, wreck.x, wreck.y + kMuzzleHeight, wreck.z); // the death boom
BTStartPfx(1, wreck.x, wreck.y + kMuzzleHeight, wreck.z); // the smoke plume
}
// Death explosion at the target (torso height, not ground level).
Origin death_origin = ((Mech *)gEnemyMech)->localOrigin;
death_origin.linearPosition.y += kMuzzleHeight;
Explosion::MakeMessage death_exp(
Explosion::MakeMessageID,
sizeof(Explosion::MakeMessage),
@@ -2358,7 +2478,12 @@ void
DEBUG_STREAM << "[damage] *** TARGET DESTROYED after "
<< gShotCount << " hits ***\n" << std::flush;
gEnemyMech = 0; // stop targeting/firing the dead entity
// KEEP the wreck targeted (do NOT null gEnemyMech): the wreck
// STAYS in the world (removal = the P5 teardown crash), so the
// beam convergence must keep terminating ON it -- nulling the
// lock here made every later beam a "free" ray that visibly
// passed through the standing wreck. Damage + kill scoring are
// latched off above via gEnemyDestroyed.
}
}
}
+45 -3
View File
@@ -575,6 +575,24 @@ void
{
Subsystem *s = criticalSubsystems[i]->subsystemPlug.Resolve(); // +4
MechCriticalSubsystem *cs = criticalSubsystems[i];
// UNBOUND-PLUG GUARD. Resolve() (FUN_00417ab4) returns the subsystem bound
// to the DZSlot, or 0 if plug+8 was never wired. In the 1995 binary every
// critical subsystem exists and is bound, so it dereferences s with no check.
// In this reconstruction a critical subsystem can be UNBOUND (its type is not
// yet built, or its slot never connected), leaving s == 0 -> the original
// code AV'd here (mov [edx+0xc], edx=0) as soon as a zone with such a plug was
// destroyed. Skip the unbound entry rather than crash; log it so the missing
// binding can be tracked. NOT a behavioural stand-in: a bound plug takes the
// authentic path below unchanged. [T3 -- see open-questions: crit-subsys binding]
if (s == 0)
{
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[deathfx] crit-subsys " << i << "/" << criticalSubsystemCount
<< " UNBOUND (plug not wired) -- skipped\n" << std::flush;
continue;
}
Mech__DamageZone *zone = (Mech__DamageZone *)((SubProxy2 *)s)->damageZone; // subsystem[0x38] @0xE0
zone->damageLevel += (cs->damagePercentage - cs->damagePercentageUsed);
@@ -911,7 +929,12 @@ void
// live): spawns the descriptor's explosion resource at the mech. The authentic
// path dispatches a class-5 message to the effect manager (app+0x38 -> the 0xBD3
// SubsystemMessageManager, unreconstructed); we use the established Explosion port.
extern void BTSpawnDamageEffect(Mech *mech, int effect_resource);
extern void BTSpawnDamageEffect(Mech *mech, int effect_resource, int segment_index);
// Render bridge (btl4vid.cpp): swap a mech's wrecked segment meshes onto its
// already-built render tree when a damage zone's graphic state changes (the
// unported "RemakeEntity" render state). Entity* param -> Mech* binds fine.
extern void BTRemakeMechModel(Entity *entity);
MechDeathHandler::MechDeathHandler(Mech *mech) // @0042a984
: owner(mech)
@@ -960,8 +983,27 @@ void
}
if (d != 0)
{
BTSpawnDamageEffect(owner, d->effectResource); // explosion at the zone
zone->ApplyDamageGraphicState(d->graphicState); // destroyed skin
BTSpawnDamageEffect(owner, d->effectResource, // explosion AT the zone's
zone->segmentIndex); // segment (world position)
zone->ApplyDamageGraphicState(d->graphicState); // destroyed skin (graphic state)
// A graphic-state change means the segment's MODEL changed (intact ->
// destroyed variant, keyed by GetVideoObjectName(skl, gstate)). Fire
// the engine's model-rebuild flag so the renderer re-runs
// MakeMechRenderables and loads the destroyed segment geometry -- the
// same mechanism CulturalIcons use to visibly deform on damage
// (CULTURAL.cpp:91; EXPTBL.cpp:549 does this per zone in the engine's
// ExplosionTable). Without this the tree stays as built at spawn (intact).
// The render's "RemakeEntity" state (which consumes this flag) was
// NOT ported -- the mech tree is built once at spawn -- so we drive the
// equivalent directly: set the flag (authentic signal; also flags the
// zone for net damage replication) AND call the render bridge, which
// swaps the wrecked segment mesh onto the already-built tree in place.
// The single-threaded frame loop makes the immediate swap safe.
if (d->graphicState != DamageZone::ExistsGraphicState)
{
owner->ForceUpdate(Entity::DamageZoneUpdateModelFlag);
BTRemakeMechModel(owner); // RemakeEntity: swap in the destroyed mesh
}
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[deathfx] zone " << i << " level " << level
<< " -> effect " << d->effectResource << " gstate " << d->graphicState
+18
View File
@@ -0,0 +1,18 @@
import struct
data = open(r'C:\git\bt411\content\BTL4.RES', 'rb').read()
labOnly, maxID = struct.unpack_from('<ii', data, 4)
offs = struct.unpack_from('<%dI' % maxID, data, 12)
TN = {1: 'ModelList', 9: 'BoxedSolidStream', 10: 'VideoModel', 11: 'StaticAudioStream',
15: 'GameModel', 16: 'Animation', 18: 'GaugeImageStream', 30: 'ExplosionTableStream'}
res = {}
for o in offs:
if o == 0:
continue
rid, rt = struct.unpack_from('<ii', data, o)
nm = data[o + 8:o + 40].split(b'\0')[0].decode('ascii', 'replace')
res[rid] = (rt, nm)
print('total resources:', len(res), 'maxID:', maxID)
print('=== descriptor effect ids (26-31) + weapon explode (13) ===')
for i in [13, 26, 27, 28, 29, 30, 31]:
r = res.get(i)
print(' id=%3d' % i, ('type=%d(%s) name=%r' % (r[0], TN.get(r[0], '?'), r[1])) if r else 'ABSENT')
+65
View File
@@ -0,0 +1,65 @@
import struct, collections
RES = r"C:\git\bt411\content\BTL4.RES"
data = open(RES, "rb").read()
ver = data[0:4]
labOnly, maxID = struct.unpack_from("<ii", data, 4)
offsets = struct.unpack_from("<%dI" % maxID, data, 12)
Res = collections.namedtuple("Res", "rid rtype name prio flags off length")
resources = {}
for o in offsets:
if o == 0: continue
rid, rtype = struct.unpack_from("<ii", data, o)
name = data[o+8:o+40].split(b"\0")[0].decode("ascii","replace")
prio, flags, roff, rlen = struct.unpack_from("<iIII", data, o+40)
resources[rid] = Res(rid, rtype, name, prio, flags, roff, rlen)
t29 = sorted([r for r in resources.values() if r.rtype == 29], key=lambda x: x.name)
t20 = {r.name: r for r in resources.values() if r.rtype == 20}
print("version", list(ver), "type29 (DamageLookupTableStream) count =", len(t29))
print("type20 (DamageZoneStream) count =", len(t20))
print("name-match: every t29 name also a t20 name?",
all(r.name in t20 for r in t29), "\n")
def hexdump(b, base=0, n=64):
out=[]
for i in range(0, min(n,len(b)), 16):
chunk=b[i:i+16]
hexs=" ".join("%02x"%c for c in chunk)
out.append(" %04x %-47s" % (base+i, hexs))
return "\n".join(out)
def parse_table(b, name):
print("="*70)
print("TABLE '%s' (%d bytes)" % (name, len(b)))
print(hexdump(b, 0, 48))
p = 0
rowCount = struct.unpack_from("<i", b, p)[0]; p += 4
print(" rowCount =", rowCount)
for r in range(rowCount):
rotate = struct.unpack_from("<i", b, p)[0]; p += 4
cellCount = struct.unpack_from("<i", b, p)[0]; p += 4
print(" row[%d] rotateWithTorso=%d cellCount=%d" % (r, rotate, cellCount))
for c in range(cellCount):
# cell: [int slen][slen chars]["\0"][int cnt][cnt*(float threshold, int zone)]
slen = struct.unpack_from("<i", b, p)[0]; p += 4
if slen < 0 or slen > 64 or p+slen > len(b):
print(" cell[%d] *** slen sanity fail slen=%d @%d ***" % (c, slen, p-4)); return
s = b[p:p+slen].decode("ascii","replace"); p += slen + 1 # +1 = trailing null
cnt = struct.unpack_from("<i", b, p)[0]; p += 4
entries=[]
for _ in range(cnt):
thr = struct.unpack_from("<f", b, p)[0]
zone = struct.unpack_from("<i", b, p+4)[0]; p += 8
entries.append((round(thr,4), zone))
if r == 0:
print(" cell[%d] str='%s' cnt=%d entries=%s" % (c, s, cnt, entries))
status = "EXACT" if p == len(b) else "MISMATCH (off by %d)" % (len(b)-p)
print(" consumed %d / %d bytes -> %s" % (p, len(b), status))
# ground on the first 2 tables
for r in t29[:2]:
b = data[r.off:r.off+r.length]
parse_table(b, r.name)
+27
View File
@@ -0,0 +1,27 @@
import struct
data = open(r'C:\git\bt411\content\BTL4OPT.EXE', 'rb').read()
e = struct.unpack_from('<I', data, 0x3C)[0]; coff = e+4
num = struct.unpack_from('<H', data, coff+2)[0]; osz = struct.unpack_from('<H', data, coff+16)[0]
opt = coff+20; base = struct.unpack_from('<I', data, opt+28)[0]; sect = opt+osz
secs = []
for i in range(num):
o = sect+i*40
vs, va, rs, rp = struct.unpack_from('<IIII', data, o+8)
secs.append((va, vs, rp, rs))
def off2va(off):
for va, vs, rp, rs in secs:
if rp <= off < rp+rs:
return base+va+(off-rp)
return None
targets = {0x49ed0c: 'FUN_0049ed0c (glue: table->zone) == the handler',
0x49de14: 'FUN_0049de14 (cell->zone roll)'}
cva, cvs, crp, crs = secs[0]
for i in range(crp, crp+crs-5):
if data[i] == 0xE8:
rel = struct.unpack_from('<i', data, i+1)[0]
site = off2va(i)
tgt = site+5+rel
if tgt in targets:
print('call site VA %08x -> %s' % (site, targets[tgt]))
+15
View File
@@ -0,0 +1,15 @@
import struct
data = open(r'C:\git\bt411\content\BTL4.RES', 'rb').read()
labOnly, maxID = struct.unpack_from('<ii', data, 4)
offs = struct.unpack_from('<%dI' % maxID, data, 12)
anims = []
for o in offs:
if o == 0:
continue
rid, rt = struct.unpack_from('<ii', data, o)
nm = data[o + 8:o + 40].split(b'\0')[0].decode('ascii', 'replace')
if rt == 16:
anims.append((rid, nm))
print('type-16 Animation resources:', len(anims))
for rid, nm in sorted(anims, key=lambda x: x[1]):
print(' id=%4d %s' % (rid, nm))
+17
View File
@@ -0,0 +1,17 @@
import sys
data = open(r'C:\git\bt411\content\BTL4.RES', 'rb').read()
low = data.lower()
for name in [b'blhd.bgf', b'blhdbr', b'blh_tor', b'blhd_', b'blhdlarm', b'blhdlule', b'blhdldle', b'blhdlfot']:
i = 0
hits = []
while True:
i = low.find(name.lower(), i)
if i < 0:
break
hits.append(i)
i += 1
print(name.decode(), 'x', len(hits), hits[:6])
# context around blhd.bgf hits
i = low.find(b'blhd.bgf')
if i >= 0:
print(repr(data[i-160:i+40]))