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
+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