Combat: critical-subsystem plugs BOUND -- zone destruction damages carried subsystems (task #2)

The binding was in the zone ctor all along: Ghidra dropped the two arg
pushes @0049d0e1 (Slot::AddImplementation(subsystemArray[streamedIndex])),
making it read as a bare Resolve().  DZSlot stand-in -> engine SlotOf<T>;
SendSubsystemDamage rewritten to the recovered @0049c9a8 body (allotment
into the subsystem's OWN private zone; vital -> graphicAlarm 9); CriticalHit
-> real ApplyDamageAndMeasure; parentArtifactZone.Add revived (LOD damage
averaging); videoObjectFlag renamed vitalSubsystem (+0xE4).  BT_CRIT_PROBE
diag added.  Verified: 66 plugs bound/mech; probe-destroyed zone -> crits
damaged/DESTROYED, statusAlarm + destroyed-skin chain fire; MP kill + solo
un-regressed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-11 12:10:43 -05:00
co-authored by Claude Fable 5
parent a652ddcdbc
commit 87c25b9206
6 changed files with 172 additions and 63 deletions
+18 -7
View File
@@ -179,13 +179,24 @@ authentic path scoped.
(the authentic death-row removal — needs the mech render tree unhooked from the renderer first;
the inert wreck is the safe stand-in until then).
Do NOT issue DestroyEntityMessage on death (before the render-tree teardown lands).
- **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.
- **Critical-subsystem plugs BOUND — damage propagates (task #2, 2026-07-11) [T1/T2].**
The "binding write is elsewhere" reading was WRONG: the binding is IN the zone ctor loop
(@0049d0e1-0049d10d) — **Ghidra dropped the two argument pushes** ("type propagation not
settling"), making `Slot::AddImplementation(roster[streamedIndex])` read as a bare no-arg
call (mislabeled Resolve). The stream's third per-entry dword IS the roster index into
`subsystemArray` (mech+0x128). Port: the `DZSlot` stand-in (always-null Resolve) replaced
with the engine `SlotOf<T>` (binary vtables 0050bb84/0050bb7c ARE its instantiations); bind
gated MasterInstance+DynamicFlag (`simulationFlags@+0x28`); replicant plugs stay unbound
(master-authoritative). `SendSubsystemDamage` rewritten to the recovered @0049c9a8 logic:
the unused crit allotment lands in the subsystem's OWN private 0x160 DamageZone (the old
`SubProxy2` cast to a mech zone was wrong), ≥1.0 → statusAlarm Destroyed + gated PrintState
+ zone valve, vital → `graphicAlarm 9` (mech kill); `CriticalHit` now calls the real
`ApplyDamageAndMeasure` (@4ac07c); the artifact `parentArtifactZone.Add` back-pointer bind
also revived (LOD damage averaging). `videoObjectFlag` was `vitalSubsystem` (+0xE4,
res+0x48 "VitalSubsystem"). VERIFIED live (`BT_CRIT_PROBE=<zone>` diag): 66 plugs bound at
ctor per mech, zone destroyed → crits damaged/DESTROYED with authentic accumulation.
Residue: roster idx 0/1 (mapper installed post-ctor / voltage-bus stub) stay unbound with a
logged skip — none of the 8 mechs' streams crit-list them in practice.
- **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.
+37
View File
@@ -2750,6 +2750,43 @@ void
<< " zones=" << m->damageZoneCount << "\n" << std::flush;
}
// CRIT-PROPAGATION PROBE (BT_CRIT_PROBE=<zone>, task #2): every 4s
// hammer ONE named zone of the LOCAL mech with a heavy hit until the
// zone is destroyed -- drives Zone::TakeDamage -> RecurseSegmentTable
// -> SendSubsystemDamage over the now-BOUND critical plugs, without
// waiting for combat to randomly destroy a non-vital zone. Diag
// only, off by default.
{
static int s_critZone = -2;
static float s_critT = 0.0f;
if (s_critZone == -2)
{
const char *cz = getenv("BT_CRIT_PROBE");
s_critZone = cz ? atoi(cz) : -1;
}
if (s_critZone >= 0 && s_critZone < damageZoneCount
&& isPlayerMech && !IsMechDestroyed())
{
s_critT += dt;
if (s_critT >= 4.0f)
{
s_critT = 0.0f;
Mech__DamageZone *z = Zone(s_critZone);
if (z != 0)
{
Damage dmg;
dmg.damageType = Damage::ExplosiveDamageType;
dmg.damageAmount = 40.0f;
dmg.burstCount = 1;
dmg.impactPoint = localOrigin.linearPosition;
DEBUG_STREAM << "[crit-probe] hitting zone "
<< s_critZone << "\n" << std::flush;
z->TakeDamage(dmg);
}
}
}
}
// CROSS-POD DAMAGE TEST HOOK (BT_MP_FORCE_DMG, task #47): once a second,
// dispatch an unaimed TakeDamage at the first live REPLICANT through the
// SAME virtual Entity::Dispatch path a beam hit uses -- Entity::Dispatch
+72 -38
View File
@@ -40,7 +40,8 @@
// FUN_0042484f DamageZoneIndexTableIterator::~TableIteratorOf
// FUN_00424716 IntegerPlug::PlugOf(&int)
// FUN_00424811 ...IteratorOf(&list) (segment child iterators)
// FUN_00417ab4 SlotOf<>::Resolve() / SharedData::Resolve()
// FUN_00417ab4 Slot::GetCurrentPlug (READ; was mislabeled Resolve)
// FUN_00417a80 Slot::AddImplementation (BIND -- Ghidra dropped its args)
// FUN_00417858 SlotOf<>::Release
// FUN_0041a1a4 IsDerivedFrom(classDerivations)
// FUN_0041b9ec AlarmIndicator(levels) FUN_0041bbd8 AlarmIndicator::SetLevel(n)
@@ -199,7 +200,8 @@ Mech__DamageZone::Mech__DamageZone(
int damage_zone_index,
MemoryStream *stream
)
: DamageZone(mech, damage_zone_index, stream) // FUN_0041df5c
: DamageZone(mech, damage_zone_index, stream), // FUN_0041df5c
parentArtifactZone(0) // FUN_0049ddc9(,0) -- Socket(NULL) legal
{
// vtable @0050bb90
redirectTable.Construct(0, 1); // Wword(0x58) = FUN_00424767(,0,1)
@@ -207,7 +209,6 @@ Mech__DamageZone::Mech__DamageZone(
// last-redirect bookkeeping
lastDamageTime = (Scalar)TheTime.CurrentTick() / TicksPerSecond; // Wword(0x5f)
lastInflicting = mech->entityID; // Wword(0x60) = FUN_00420ea4(,mech+0x184)
parentArtifactZone.Construct(0); // Wword(0x62) = FUN_0049ddc9(,0)
criticalWeightSum = 0; // Wword(0x6c)
//
@@ -268,12 +269,38 @@ Mech__DamageZone::Mech__DamageZone(
stream->ReadBytes(&criticalSubsystems[i]->criticalWeight, 4); // +0x18
stream->ReadBytes(&criticalSubsystems[i]->damagePercentage, 4); // +0x10
criticalWeightSum += criticalSubsystems[i]->criticalWeight;
stream->ReadBytes(&subsystemIndex, 4); // subsystem-table index
stream->ReadBytes(&subsystemIndex, 4); // roster index (mech+0x128 order)
// Wire the subsystem plug to the live subsystem unless this is the
// pure-LOD / cosmetic build (mech flags 0x28 bits).
if ((mech->flags & 0xc) == 0 && (mech->flags & 0x100) != 0)
criticalSubsystems[i]->subsystemPlug.Resolve(); // FUN_00417ab4
// THE BINDING (task #2, disasm @0049d0e1-0049d10d -- Ghidra DROPPED
// the two arg pushes, which is why this read as a bare Resolve():
// the real call is Slot::AddImplementation(roster[index])). Gate =
// MasterInstance + DynamicFlag on the flags LWord @+0x28
// (simulationFlags). Replicant plugs stay UNBOUND on purpose:
// subsystem criticals are master-authoritative. [T1]
if ((mech->simulationFlags & Entity::InstanceMask) == Entity::MasterInstance
&& (mech->simulationFlags & Entity::DynamicFlag) != 0)
{
Subsystem *target =
(subsystemIndex >= 0 && subsystemIndex < mech->GetSubsystemCount())
? mech->GetSubsystem(subsystemIndex) : 0;
if (target != 0)
{
criticalSubsystems[i]->subsystemPlug.Add(target); // FUN_00417a80
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[crit] zone " << damage_zone_index
<< " entry " << i << " -> roster[" << subsystemIndex
<< "] = " << (void *)target << "\n" << std::flush;
}
else if (getenv("BT_DEATH_LOG"))
{
// roster slot NULL: idx 0 (mapper, installed post-ctor) or
// idx 1 (voltage bus, ReconSlot stub). The engine
// Link::AddToPlug derefs the plug, so a null bind crashes.
DEBUG_STREAM << "[crit] zone " << damage_zone_index
<< " entry " << i << " roster[" << subsystemIndex
<< "] is NULL -- left unbound\n" << std::flush;
}
}
}
}
@@ -307,7 +334,7 @@ void
for (IntegerPlug *plug; (plug = it.Next()) != 0; ) // slot 0x28
{
Mech__DamageZone *child = mech->Zone(plug->value); // inherited Entity::damageZones[idx], typed
child->parentArtifactZone = this; // slot @0x188 +4
child->parentArtifactZone.Add(this); // slot @0x188: FUN_00417a80 (NOT flag-gated)
}
}
@@ -380,7 +407,7 @@ void
// ---- real zone: apply to armour/structure ----
DamageZone::TakeDamage(damage); // FUN_0041e4e0
Mech__DamageZone *parent = parentArtifactZone.Resolve(); // FUN_00417ab4(this+0x62)
Mech__DamageZone *parent = parentArtifactZone.GetCurrent(); // FUN_00417ab4(this+0x62)
if (parent != 0)
parent->UpdateLODDamageLevel(); // FUN_0049c51c
@@ -388,8 +415,9 @@ void
if (damage.damageType == 4 && criticalSubsystemCount > 0)
{
int n = Random(criticalSubsystemCount); // FUN_0040807c
Subsystem *s = criticalSubsystems[n]->subsystemPlug.Resolve();
if (s->IsDerivedFrom(*Generator::GetClassDerivations())) // FUN_0041a1a4(,0x50f4bc)
Subsystem *s = criticalSubsystems[n]->subsystemPlug.GetCurrent();
if (s != 0 // unbound-plug guard (idx-0/1 roster residue)
&& s->IsDerivedFrom(*Generator::GetClassDerivations())) // FUN_0041a1a4(,0x50f4bc)
((Generator *)s)->ForceShortRecovery(); // FUN_004b11bc
}
@@ -546,11 +574,13 @@ Subsystem*
if (roll <= cum / criticalWeightSum)
{
Subsystem *s = criticalSubsystems[i]->subsystemPlug.Resolve();
Subsystem *s = criticalSubsystems[i]->subsystemPlug.GetCurrent();
MechCriticalSubsystem *cs = criticalSubsystems[i];
if (cs->damagePercentageUsed < cs->damagePercentage)
cs->damagePercentageUsed += ((SubProxy2 *)s)->ApplyDamage(damage_data); // FUN_004ac07c
return criticalSubsystems[i]->subsystemPlug.Resolve();
if (s != 0 // unbound-plug guard
&& cs->damagePercentageUsed < cs->damagePercentage)
cs->damagePercentageUsed +=
((MechSubsystem *)s)->ApplyDamageAndMeasure(damage_data); // FUN_004ac07c
return s;
}
}
}
@@ -573,39 +603,43 @@ void
for (int i = 0; i < criticalSubsystemCount; ++i)
{
Subsystem *s = criticalSubsystems[i]->subsystemPlug.Resolve(); // +4
MechSubsystem *s =
(MechSubsystem *)criticalSubsystems[i]->subsystemPlug.GetCurrent(); // +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]
// Unbound-plug guard: with the authentic binding live (the ctor loop)
// this is only the idx-0/1 roster residue (mapper installed post-ctor
// / voltage-bus stub), not the norm. The 1995 binary derefs with no
// check because every plug there is bound.
if (s == 0)
{
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[deathfx] crit-subsys " << i << "/" << criticalSubsystemCount
<< " UNBOUND (plug not wired) -- skipped\n" << std::flush;
<< " UNBOUND (roster slot residue) -- skipped\n" << std::flush;
continue;
}
Mech__DamageZone *zone = (Mech__DamageZone *)((SubProxy2 *)s)->damageZone; // subsystem[0x38] @0xE0
// Push the UNUSED crit allotment into the subsystem's OWN private
// DamageZone (the 0x160-byte zone its ctor built at +0xE0) -- NOT a
// mech dz_* zone (the old SubProxy2 cast misread this). [T1 @0049c9a8]
Scalar level = s->GetSubsystemDamageLevel()
+ (cs->damagePercentage - cs->damagePercentageUsed);
level = Clamp(level, StructureMin, StructureMax);
s->SetSubsystemDamageLevel(level);
zone->damageLevel += (cs->damagePercentage - cs->damagePercentageUsed);
zone->damageLevel = Clamp(zone->damageLevel, StructureMin, StructureMax);
if (zone->damageLevel >= StructureMax) // destroyed
if (level >= StructureMax) // subsystem destroyed
{
if (((SubProxy2 *)s)->isVital) // subsystem[0x39]
((Mech *)GetOwningSimulation())->graphicAlarm.SetLevel(9);
((SubProxy2 *)s)->failureAlarm.SetLevel(1); // subsystem+0xb
if (((SubProxy2 *)s)->hasDestructor) // subsystem[0x41]
((SubProxy2 *)s)->OnDestroyed(); // slot 0x34
zone->SetDamageZoneState(1); // zone+0x10
if (s->IsVitalSubsystem()) // +0xE4
((Mech *)GetOwningSimulation())->graphicAlarm.SetLevel(9); // mech kill
s->ForceCriticalFailure(); // status 1 + print + zone valve
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[deathfx] crit-subsys " << i
<< " DESTROYED (vital=" << s->IsVitalSubsystem() << ")\n" << std::flush;
}
else if (getenv("BT_DEATH_LOG"))
{
DEBUG_STREAM << "[deathfx] crit-subsys " << i << " damaged -> "
<< level << "\n" << std::flush;
}
}
}
+8 -11
View File
@@ -80,15 +80,12 @@
DZIntegerPlug *operator[](int) { return 0; }
void DeleteContents() {}
};
template<class T> struct DZSlot // SlotOf<T> stand-in
{
DZSlot() {}
DZSlot(const void *) {} // ctor(owner)
void Construct(int) {}
T Resolve() { return T(); }
DZSlot &operator=(const DZSlot &) { return *this; }
template<class A> DZSlot &operator=(const A &) { return *this; }
};
// (task #2: the DZSlot<T> stand-in is RETIRED -- the engine SlotOf<T>
// (engine/MUNGA/SLOT.h) is the real type; the binary vtables 0050bb84 /
// 0050bb7c ARE its <Subsystem*> / <Mech__DamageZone*> instantiations:
// FUN_004179d4 = Slot::Slot(Node*), FUN_00417a80 = AddImplementation
// (BIND), FUN_00417ab4 = GetCurrentPlug (READ). The stand-in's
// always-null Resolve() was why every critical plug read as unbound.)
//##########################################################################
//###################### MechCriticalSubsystem #################
@@ -108,7 +105,7 @@
virtual ~MechCriticalSubsystem(); // @0049dd44 (vtable slot 0)
DZSlot<Subsystem*>
SlotOf<Subsystem*>
subsystemPlug; // @0x04 ctor @0049dd7e / dtor @0049dd9d (vtable @0050bb84)
Scalar
@@ -175,7 +172,7 @@
// With the current art (1/30/96) no zone has both a parent and children,
// and many zones have neither.
DZSlot<Mech__DamageZone*> parentArtifactZone; // @0x188 ctor @0049ddc9 (vtable @0050bb7c)
SlotOf<Mech__DamageZone*> parentArtifactZone; // @0x188 ctor @0049ddc9 (vtable @0050bb7c)
// --- reconstruction-named state (decomp spellings; real DamageZone
// base uses damageLevel/damageZoneState/etc.) ------------------
+21 -3
View File
@@ -126,7 +126,7 @@ MechSubsystem::MechSubsystem(
this->owner = owner; // this[0x34] -- the owning Mech (canonical)
hostEntity = owner; // this[0x3a] = param_7
subsystemId2 = subsystem_ID; // this[0x3b] = param_8
videoObjectFlag = 0; // this[0x39]
vitalSubsystem = 0; // this[0x39]
BindName(refCount, "None"); // FUN_00402a98(.,this+0x3d,"None") @0x50df7a
alarmModel = 0; // identity ResourceID // FUN_00408440(this+0x3e, identity)
criticalReference = 0; // this[0x42]
@@ -163,7 +163,7 @@ MechSubsystem::MechSubsystem(
subsystemId2 = subsystem_ID; // this[0x3b]=param_7
resource = subsystem_resource; // this[0x3c]
vitalSubsystemIndex = -1; // this[0x44]
videoObjectFlag = (subsystem_resource->vitalSubsystemIndex == 1); // res+0x48==1
vitalSubsystem = (subsystem_resource->vitalSubsystemIndex == 1); // res+0x48==1
BindName(refCount, subsystem_resource->videoObjectName); // res+0x4c
alarmModel = subsystem_resource->alarmModel; // res+0xcc -> this[0x3e]
@@ -218,6 +218,24 @@ Scalar MechSubsystem::GetSubsystemDamageLevel() const
return damageZone ? ((DamageZone *)damageZone)->damageLevel : 0.0f;
}
//
// @0049c9a8 (zone-side inline) -- the destroyed-side effects applied when a
// crit drives this subsystem's own zone to 1.0: status Destroyed, the gated
// debug print, and the zone's state valve. (task #2)
//
void MechSubsystem::ForceCriticalFailure()
{
statusAlarm.SetLevel(1); // +0x2C: 1 = Destroyed
if (printSimulationState) // +0x104 gate
{
PrintState(); // vtable slot 0x34 @4ac8c0
}
if (damageZone != 0)
{
((DamageZone *)damageZone)->SetDamageZoneState(1); // zone+0x10
}
}
void MechSubsystem::SetSubsystemDamageLevel(Scalar level)
{
if (damageZone)
@@ -263,7 +281,7 @@ Logical
OnAlarmChanged(); // (*this.vtable+0x34)(this)
}
damageZone->structureLevel = 1.0f; // dz+0x158 = 1.0
if (videoObjectFlag != 0) // this[0x39]
if (vitalSubsystem != 0) // this[0x39]
{
((Mech *)owner)->RaiseStatusAlarm(9); // FUN_0041bbd8(owner+0x2c, 9)
}
+16 -4
View File
@@ -248,13 +248,22 @@ class Damage;
Scalar GetSubsystemDamageLevel() const;
void SetSubsystemDamageLevel(Scalar level);
// task #2 (crit propagation): the zone-side crit paths
// (Mech__DamageZone::CriticalHit @0049ccc4 / SendSubsystemDamage
// @0049c9a8) reach these subsystem facets.
int IsVitalSubsystem() const { return vitalSubsystem; }
// @0x4ac07c -- run the virtual TakeDamage and return the resulting
// rise in the subsystem's own damageLevel (the crit allotment used).
Scalar ApplyDamageAndMeasure(Damage &damage);
// The destroyed-side effects SendSubsystemDamage applies when the
// subsystem's own zone hits 1.0: statusAlarm Destroyed(1) + the
// PrintState debug gate + the zone's state valve. [T1 @0049c9a8]
void ForceCriticalFailure();
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Internal helpers
//
protected:
// @0x4ac07c -- run TakeDamage and return the resulting drop in
// structureLevel (DamageZone+0x158).
Scalar ApplyDamageAndMeasure(Damage &damage);
// @0x4ac22c -- clear structure & both alarms to the inactive level.
void ClearStatus();
@@ -276,7 +285,10 @@ class Damage;
AlarmIndicator statusAlarm; // @0x2C this+0xb (condition/status alarm)
int simulationState; // @0x40 DefaultState/Destroyed/Exploding
ReconDamageZone *damageZone; // @0xE0 this[0x38] (0x160-byte zone)
int videoObjectFlag; // @0xE4 this[0x39]
int vitalSubsystem; // @0xE4 this[0x39] -- destroying this
// subsystem KILLS the mech (ctor: res+0x48
// "VitalSubsystem"==1; was misnamed
// videoObjectFlag) [T1 part_012.c:15776]
Mech *hostEntity; // @0xE8 this[0x3a]
int subsystemId2; // @0xEC this[0x3b]
SubsystemResource *resource; // @0xF0 this[0x3c]