From a3d67cc639fa2e4ea9c9e7b1e9283850ecbc0f3c Mon Sep 17 00:00:00 2001 From: arcattack Date: Wed, 8 Jul 2026 14:43:32 -0500 Subject: [PATCH] Combat visible + killable: Wword root-cause fix, .PFX effect layer, RemakeEntity swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- context/combat-damage.md | 34 +- context/open-questions.md | 40 ++- context/reconstruction-gotchas.md | 23 +- engine/MUNGA_L4/L4VIDEO.cpp | 561 +++++++++++++++++++++++++++++- engine/MUNGA_L4/L4VIDRND.h | 5 + game/reconstructed/btl4vid.cpp | 167 ++++++++- game/reconstructed/btl4vid.hpp | 43 +++ game/reconstructed/mech.cpp | 16 +- game/reconstructed/mech.hpp | 9 +- game/reconstructed/mech4.cpp | 179 ++++++++-- game/reconstructed/mechdmg.cpp | 48 ++- scratchpad/checkres.py | 18 + scratchpad/cyl_ground.py | 65 ++++ scratchpad/findcall.py | 27 ++ scratchpad/listani.py | 15 + scratchpad/scanres.py | 17 + 16 files changed, 1205 insertions(+), 62 deletions(-) create mode 100644 scratchpad/checkres.py create mode 100644 scratchpad/cyl_ground.py create mode 100644 scratchpad/findcall.py create mode 100644 scratchpad/listani.py create mode 100644 scratchpad/scanres.py diff --git a/context/combat-damage.md b/context/combat-damage.md index b2d5dcc..5d78377 100644 --- a/context/combat-damage.md +++ b/context/combat-damage.md @@ -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]`) — diff --git a/context/open-questions.md b/context/open-questions.md index 328cc43..fb5e4c4 100644 --- a/context/open-questions.md +++ b/context/open-questions.md @@ -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 diff --git a/context/reconstruction-gotchas.md b/context/reconstruction-gotchas.md index 91f4956..129dbf9 100644 --- a/context/reconstruction-gotchas.md +++ b/context/reconstruction-gotchas.md @@ -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 diff --git a/engine/MUNGA_L4/L4VIDEO.cpp b/engine/MUNGA_L4/L4VIDEO.cpp index 6ba45f4..06b9eae 100644 --- a/engine/MUNGA_L4/L4VIDEO.cpp +++ b/engine/MUNGA_L4/L4VIDEO.cpp @@ -25,6 +25,7 @@ using namespace std; #include #include #include +#include // std::sort (the .PFX particle depth sort) LPDIRECT3D9 gD3D = NULL; @@ -58,6 +59,11 @@ struct BTBeamFx }; static std::vector 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 gBTPfxEmitters; +static std::vector 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\) 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 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 order; + static std::vector 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; diff --git a/engine/MUNGA_L4/L4VIDRND.h b/engine/MUNGA_L4/L4VIDRND.h index a054b7f..44a4f65 100644 --- a/engine/MUNGA_L4/L4VIDRND.h +++ b/engine/MUNGA_L4/L4VIDRND.h @@ -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(); diff --git a/game/reconstructed/btl4vid.cpp b/game/reconstructed/btl4vid.cpp index 88623b7..fcf397c 100644 --- a/game/reconstructed/btl4vid.cpp +++ b/game/reconstructed/btl4vid.cpp @@ -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::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::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 diff --git a/game/reconstructed/btl4vid.hpp b/game/reconstructed/btl4vid.hpp index b62bae7..9635921 100644 --- a/game/reconstructed/btl4vid.hpp +++ b/game/reconstructed/btl4vid.hpp @@ -78,6 +78,7 @@ class DPLRenderer; #if !defined(NAMELIST_HPP) # include // NameList / NameList::Entry #endif +#include // 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 segRenderable; // slot -> joint renderable + std::map segGState; // slot -> last applied graphic state + }; + std::map 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 //===========================================================================// diff --git a/game/reconstructed/mech.cpp b/game/reconstructed/mech.cpp index afad437..b71bbd7 100644 --- a/game/reconstructed/mech.cpp +++ b/game/reconstructed/mech.cpp @@ -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 } // diff --git a/game/reconstructed/mech.hpp b/game/reconstructed/mech.hpp index 9bc7bc0..3a7494c 100644 --- a/game/reconstructed/mech.hpp +++ b/game/reconstructed/mech.hpp @@ -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). diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 71215b7..bd14732 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -140,6 +140,7 @@ #include // BoundingBoxTreeNode::FindBoundingBoxUnder / ...ContainingColumn #include // BoxedSolid / BoxedSolidCollision / BoxedSolidCollisionList #include // CulturalIcon::IsStoppingCollisionVolume / GetClassDerivations +#include // 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. } } } diff --git a/game/reconstructed/mechdmg.cpp b/game/reconstructed/mechdmg.cpp index f3b98e7..80468c4 100644 --- a/game/reconstructed/mechdmg.cpp +++ b/game/reconstructed/mechdmg.cpp @@ -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 diff --git a/scratchpad/checkres.py b/scratchpad/checkres.py new file mode 100644 index 0000000..f809c1a --- /dev/null +++ b/scratchpad/checkres.py @@ -0,0 +1,18 @@ +import struct +data = open(r'C:\git\bt411\content\BTL4.RES', 'rb').read() +labOnly, maxID = struct.unpack_from(' 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(" %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) diff --git a/scratchpad/findcall.py b/scratchpad/findcall.py new file mode 100644 index 0000000..d346b24 --- /dev/null +++ b/scratchpad/findcall.py @@ -0,0 +1,27 @@ +import struct +data = open(r'C:\git\bt411\content\BTL4OPT.EXE', 'rb').read() +e = struct.unpack_from('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(' %s' % (site, targets[tgt])) diff --git a/scratchpad/listani.py b/scratchpad/listani.py new file mode 100644 index 0000000..b157b6a --- /dev/null +++ b/scratchpad/listani.py @@ -0,0 +1,15 @@ +import struct +data = open(r'C:\git\bt411\content\BTL4.RES', 'rb').read() +labOnly, maxID = struct.unpack_from('= 0: + print(repr(data[i-160:i+40]))