diff --git a/context/open-questions.md b/context/open-questions.md index 049ba9f..a3221ce 100644 --- a/context/open-questions.md +++ b/context/open-questions.md @@ -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` (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=` 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. diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 0dbb4b4..40454d4 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -2750,6 +2750,43 @@ void << " zones=" << m->damageZoneCount << "\n" << std::flush; } + // CRIT-PROPAGATION PROBE (BT_CRIT_PROBE=, 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 diff --git a/game/reconstructed/mechdmg.cpp b/game/reconstructed/mechdmg.cpp index 2fa3ee0..7fae94e 100644 --- a/game/reconstructed/mechdmg.cpp +++ b/game/reconstructed/mechdmg.cpp @@ -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; } } } diff --git a/game/reconstructed/mechdmg.hpp b/game/reconstructed/mechdmg.hpp index 6a94f42..0397b55 100644 --- a/game/reconstructed/mechdmg.hpp +++ b/game/reconstructed/mechdmg.hpp @@ -80,15 +80,12 @@ DZIntegerPlug *operator[](int) { return 0; } void DeleteContents() {} }; - template struct DZSlot // SlotOf stand-in - { - DZSlot() {} - DZSlot(const void *) {} // ctor(owner) - void Construct(int) {} - T Resolve() { return T(); } - DZSlot &operator=(const DZSlot &) { return *this; } - template DZSlot &operator=(const A &) { return *this; } - }; + // (task #2: the DZSlot stand-in is RETIRED -- the engine SlotOf + // (engine/MUNGA/SLOT.h) is the real type; the binary vtables 0050bb84 / + // 0050bb7c ARE its / 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 + SlotOf 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 parentArtifactZone; // @0x188 ctor @0049ddc9 (vtable @0050bb7c) + SlotOf parentArtifactZone; // @0x188 ctor @0049ddc9 (vtable @0050bb7c) // --- reconstruction-named state (decomp spellings; real DamageZone // base uses damageLevel/damageZoneState/etc.) ------------------ diff --git a/game/reconstructed/mechsub.cpp b/game/reconstructed/mechsub.cpp index a645deb..b4e054c 100644 --- a/game/reconstructed/mechsub.cpp +++ b/game/reconstructed/mechsub.cpp @@ -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) } diff --git a/game/reconstructed/mechsub.hpp b/game/reconstructed/mechsub.hpp index 9d0f119..8dbac82 100644 --- a/game/reconstructed/mechsub.hpp +++ b/game/reconstructed/mechsub.hpp @@ -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]