#55 steps 0/1/2/4: the 1995 DEATH pass restored, the arg gate made real, and 5 subsystems the respawn sweep never reached

THE HEADLINE: the port's death sweep was a total no-op, and fixing it required
fixing the arg gate in the same commit -- otherwise corpses refill their ammo.

STEP 2 -- the authentic dispatch shape:
  * engine/MUNGA/SUBSYSTM.h: Subsystem::DeathShutdown gains the binary's
    universal base body { DeathReset(c) } (@004ad10e).  It was empty, and NO
    port class overrode it, so everything the 1995 game does at death was
    skipped entirely.
  * mech4.cpp death sweep now passes 0, not 1 (the binary's @0049fe0c arg) --
    the wreck shape: alarms/state settle, nothing refills.
  * ARG PROPAGATION (mandatory once the sweep runs): AmmoBin::DeathReset is now
    arg-gated exactly as @004bd26c -- refill only when arg != 0, ammoAlarm
    unconditional.  Ignoring the arg was harmless only while the sweep was
    dead; with it restored, every corpse would have re-armed.  Also forwarded
    in PoweredSubsystem / HeatSink / Condenser / HeatableSubsystem / Generator
    (each binary body forwards it -- verified addresses in the plan).

STEP 4 -- coverage for 5 classes that had an authentic slot-10 body but no
DeathReset, so the respawn sweep fell through to the empty base:
  Torso (this is Gitea #70 -- "loosing torso twist function after a death"),
  Gyroscope, Myomers, HUD, Seeker.

STEPS 0/1 -- observability + the David chain:
  * Three unconditional matchlog rows: DEAD_NOTIFY (mech + resolved link),
    PLAYER_LINK (player <-> vehicle), RESPAWN (mode/alive/zones/subsys/pos).
    A respawn was previously INVISIBLE in the matchlog -- night 3's analysis
    had to infer them from ammo arithmetic.
  * Mech::PlayerLinkMessageHandler + the death dispatch site: when the engine's
    one-shot registry lookup misses (no null check, no retry -> the whole
    death/respawn cycle silently swallowed), recover the SAME object via the
    reverse link the binary's own respawn branch walks (player+0x1FC ==
    playerVehicle).  Complete-type TU, no raw offsets.
  * deathPending cleared in the first-spawn branch (a latch carried in would
    permanently kill every later respawn -- the #57 class).

VERIFIED on the 2-pod rig (madcat vs thor, forced kills, 5 death/respawn cycles
per pod): build clean, ZERO new /FORCE unresolved externs from the 5 new
overrides; refill lines appear ONLY immediately before a Mech::Reset and NEVER
between a death and the next respawn (the corpse-refill regression this commit
had to pre-empt); all three probe rows present in both pods' matchlogs; every
DEAD_NOTIFY carried a non-null link.

DELIBERATELY DEFERRED (documented, not forgotten): the mechsub.cpp rename pair
(ResetToInitialState -> GenerateFault, ClearStatus -> the root reset @004ac22c),
deleting HeatableSubsystem::ResetToInitialState, and deleting RespawnRepair.
Those need the HeatableSubsystem vtable-slot-10 pre-flight the plan calls for,
and we have no binary image here to dump the vtable from -- guessing at it
risks the vptr-alias trap.  Next session with the decomp shards open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-27 08:09:31 -05:00
co-authored by Claude Opus 5
parent 26e678e570
commit ef8e449a17
17 changed files with 187 additions and 21 deletions
+9 -2
View File
@@ -163,8 +163,15 @@ virtual void
{}
virtual void
DeathShutdown(int /*shutdown_command*/)
{}
DeathShutdown(int shutdown_command)
{
// #55: the binary's universal base @004ad10e forwards slot 11 to
// slot 10 -- { this->vtbl[0x28](this, arg); } -- so a death sweep
// with arg 0 runs every subsystem's DeathReset in its wreck shape
// (no refill). The empty body here made the whole 1995 death
// pass a no-op in the port.
DeathReset(shutdown_command);
}
};
//##########################################################################
+22 -8
View File
@@ -455,15 +455,29 @@ void AmmoBin::ReadUpdateRecord(UpdateRecord *update)
// respawn restores exactly those ctor values. (ResetToInitialState below
// stays authentically EMPTY per AMMOBIN.TCP.)
//
void AmmoBin::DeathReset(int /*reset_command*/)
void AmmoBin::DeathReset(int reset_command)
{
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[respawn] ammo bin " << subsystemID << " refilled "
<< ammoCount << " -> " << initialAmmoCount << "\n" << std::flush;
ammoCount = initialAmmoCount; // @0x180 <- @0x220 (ctor value)
feedTimer = 0; // @0x184
cookOffArmed = 0; // @0x18C
ammoAlarm.SetLevel(Loaded); // @0x1A8 (ctor: 1)
//
// THE ARG GATE IS AUTHENTIC AND LOAD-BEARING (@004bd26c, #55 step 2):
// if (arg) { feedTimer = 0; [0x228] = 0; ammoCount = initialAmmoCount;
// cookOffArmed = 0; }
// ammoAlarm.SetLevel(Loaded); // UNCONDITIONAL
// The refill runs on a RESPAWN (arg 1) and must NOT run at DEATH (arg 0) --
// a wreck keeps its counts. This body used to ignore the arg, which was
// harmless only while the death sweep was a no-op; the moment that sweep
// was restored (SUBSYSTM.h DeathShutdown -> DeathReset with arg 0) an
// unguarded refill would have re-armed every corpse.
//
if (reset_command != 0)
{
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[respawn] ammo bin " << subsystemID << " refilled "
<< ammoCount << " -> " << initialAmmoCount << "\n" << std::flush;
ammoCount = initialAmmoCount; // @0x180 <- @0x220 (ctor value)
feedTimer = 0; // @0x184
cookOffArmed = 0; // @0x18C
}
ammoAlarm.SetLevel(Loaded); // @0x1A8 -- unconditional per the binary
}
//#############################################################################
+9
View File
@@ -1292,6 +1292,12 @@ void
DEBUG_STREAM << "[cam] InitializePlayerLink dispatched (player "
<< GetEntityID() << " -> vehicle "
<< playerVehicle->GetEntityID() << ")\n" << std::flush;
// #55 step 0: prove the link in the matchlog -- a lost PlayerLink race
// (the David chain) was previously invisible.
BTMatchLog("PLAYER_LINK", "player=%d:%d vehicle=%d:%d",
BTMatchHostOf(GetEntityID()), (int)GetEntityID(),
BTMatchHostOf(playerVehicle->GetEntityID()),
(int)playerVehicle->GetEntityID());
Check_Fpu();
}
@@ -1367,6 +1373,9 @@ void
AlwaysExecute(); // param_1[10] &= ~2 (run every frame)
deathCount = 0; // param_1[0x80]
deathPending = 0; // #55 step 1: a latch carried into a
// fresh spawn would permanently kill
// every later respawn (the #57 class)
// (warp fired in the shared placement below -- initial drop-in + respawn)
}
else if (deathCount == message->deathCount) // param_2[0xe] == param_1[0x80]
+9
View File
@@ -1083,3 +1083,12 @@ void GyroApplyDamageTorque(Subsystem *gyro, Scalar x, Scalar y, Scalar z, Scalar
if (gyro != 0)
static_cast<Gyroscope *>(gyro)->ApplyDamageTorque(x, y, z, magnitude);
}
//
// DeathReset (#55 step 4) -- the respawn sweep's entry for the Gyroscope.
//
void
Gyroscope::DeathReset(int /*reset_command*/)
{
ResetToInitialState(); // @004b2678 (takes no arg)
}
+6
View File
@@ -241,6 +241,12 @@ class Damage; // engine damage record (DAMAGE.h); ApplyDamageResponse input
public:
Logical HandleDeathMessage(Message &message); // slot 9, @004b2660 -> @004b179c
void ResetToInitialState(); // slot 10, @004b2678
// #55 step 4: the respawn sweep (Mech::Reset -> DeathReset) never
// reached this class -- it fell through to the empty Subsystem base, so
// this subsystem kept its previous life's state. Chains our own
// authentic slot-10 body.
void DeathReset(int reset_command);
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Construction and Destruction
+6 -6
View File
@@ -195,9 +195,9 @@ void
// the heat family was reset on respawn at all.
//
void
HeatableSubsystem::DeathReset(int /*reset_command*/)
HeatableSubsystem::DeathReset(int reset_command)
{
ResetToInitialState(True);
ResetToInitialState(reset_command != 0); // #55: arg forwarded
}
//###########################################################################
@@ -377,9 +377,9 @@ void
// [T3 -- do NOT claim valves are fixed; tracked on the issue.]
//
void
Condenser::DeathReset(int /*reset_command*/)
Condenser::DeathReset(int reset_command)
{
ResetToInitialState(True);
ResetToInitialState(reset_command != 0); // #55: arg forwarded
}
//###########################################################################
@@ -723,9 +723,9 @@ void
// coolant").
//
void
HeatSink::DeathReset(int /*reset_command*/)
HeatSink::DeathReset(int reset_command)
{
ResetToInitialState(True); // @004ad760
ResetToInitialState(reset_command != 0); // @004ad760 (#55: arg forwarded)
// Gitea #55 regression guard: the respawn refill must actually land. Verified
// live 2026-07-25 -- coolant == capacity on every heat sink across 4 respawn
// cycles, and the temperature restored to startingTemperature (77) instead of
+9
View File
@@ -466,3 +466,12 @@ Subsystem *CreateHUDSubsystem(Mech *owner, int id, void *seg)
return (Subsystem *) new (Memory::Allocate(0x2a4))
HUD(owner, id, (HUD::SubsystemResource *)seg, HUD::DefaultData);
}
//
// DeathReset (#55 step 4) -- the respawn sweep's entry for the HUD.
//
void
HUD::DeathReset(int reset_command)
{
ResetToInitialState(reset_command != 0); // @004b77bc
}
+6
View File
@@ -225,6 +225,12 @@
HandleMessage(int message); // slot 9, @004b7780 (body not captured)
void
ResetToInitialState(Logical powered = True); // slot 10, @004b77bc
// #55 step 4: the respawn sweep (Mech::Reset -> DeathReset) never
// reached this class -- it fell through to the empty Subsystem base, so
// this subsystem kept its previous life's state. Chains our own
// authentic slot-10 body.
void DeathReset(int reset_command);
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Test Class Support
+22
View File
@@ -724,6 +724,28 @@ void
(GetInstance() == ReplicantInstance) ? 'R' : 'M',
BTMatchHostOf(message->playerID), (int)message->playerID,
(int)(owner != 0));
//
// #55 step 1 (the David chain): the engine base wrote playerLink =
// host->GetEntityPointer(playerID) with NO null check and NO retry -- a
// lost registry race left this mech ownerless forever, silently swallowing
// its whole death/respawn cycle. Recover the SAME object via the reverse
// link the binary's own respawn branch walks (player+0x1FC ==
// playerVehicle): the local mission player whose vehicle is this mech.
// Complete-type TU (Mech + Player both visible) -- no raw offsets.
//
if (owner == 0 && application != 0)
{
Player *mission_player = (Player *)application->GetMissionPlayer();
if (mission_player != 0
&& mission_player->GetPlayerVehicle() == (Entity *)this)
{
playerLink = mission_player; // public engine member
owner = mission_player;
DEBUG_STREAM << "[respawn] PlayerLink registry miss RECOVERED "
"via the reverse link (mech " << GetEntityID() << ")\n"
<< std::flush;
}
}
if (owner != NULL)
{
owner->SetPlayerVehicle(this); // player+0x1fc = this
+34 -1
View File
@@ -1829,6 +1829,16 @@ void
<< ") alive=" << (int)(!IsMechDestroyed())
<< " zones=" << damageZoneCount
<< " subsys=" << GetSubsystemCount() << "\n" << std::flush;
// #55 step 0: a respawn used to be INVISIBLE in the matchlog (VEHICLE only
// logs on the CreatePlayerVehicle path, which reset-based respawn never
// takes) -- night 3's analysis had to infer every respawn from ammo
// arithmetic. One unconditional row per reset.
BTMatchLog("RESPAWN", "mech=%d:%d mode=%d alive=%d zones=%d subsys=%d "
"pos=%.1f,%.1f,%.1f",
BTMatchHostOf(GetEntityID()), (int)GetEntityID(), mode,
(int)(!IsMechDestroyed()), damageZoneCount, GetSubsystemCount(),
localOrigin.linearPosition.x, localOrigin.linearPosition.y,
localOrigin.linearPosition.z);
Check_Fpu();
}
@@ -1940,7 +1950,9 @@ void
{
Subsystem *s = GetSubsystem(i);
if (s != 0)
s->DeathShutdown(1);
s->DeathShutdown(0); // #55: the binary sweep @0049fe0c passes 0
// (wreck shape: alarms/state settle, ammo
// bins do NOT refill the corpse)
}
// Request the DEATH record BEFORE entering the disabled state (the
// ForceUpdate filter masks types 2..8 once IsDisabled) -- the binary
@@ -2052,6 +2064,27 @@ void
//
{
Player *owner = GetPlayerLink();
// #55 step 1 (the David fix): the death notification used to hinge on
// this single never-retried pointer -- playerLink is written once by
// Entity::PlayerLinkMessageHandler with no null check, so a lost race
// silently swallowed the whole respawn cycle. Resolve the SAME object
// by the reverse link the binary's own respawn branch uses
// (player+0x1FC == playerVehicle): the mission player whose vehicle is
// this mech. Not a stand-in -- the identical entity, second index.
if (owner == 0 && application != 0)
{
Player *mission_player = (Player *)application->GetMissionPlayer();
if (mission_player != 0
&& mission_player->GetPlayerVehicle() == (Entity *)this)
{
owner = mission_player;
}
}
// #55 step 0: a NULL link was a SILENT swallow -- record the
// resolution unconditionally so the matchlog can prove it either way.
BTMatchLog("DEAD_NOTIFY", "mech=%d:%d link=%p",
BTMatchHostOf(GetEntityID()), (int)GetEntityID(),
(void *)owner);
if (owner != 0)
{
Player::VehicleDeadMessage
+8
View File
@@ -634,3 +634,11 @@ Subsystem *CreateMyomersSubsystem(Mech *owner, int id, void *seg)
return (Subsystem *) new (Memory::Allocate(0x358))
Myomers(owner, id, (Myomers::SubsystemResource *)seg);
}
//
// DeathReset (#55 step 4) -- the respawn sweep's entry for the Myomers.
//
void Myomers::DeathReset(int reset_command)
{
ResetToInitialState(reset_command != 0); // @004b8aa4
}
+6
View File
@@ -245,6 +245,12 @@ class Mech;
HandleMessage(int message); // slot 9, @004b8a8c (-> PoweredSubsystem @004b0efc)
void
ResetToInitialState(Logical powered = True); // slot 10, @004b8aa4 (-> PoweredSubsystem @004b0e6c)
// #55 step 4: the respawn sweep (Mech::Reset -> DeathReset) never
// reached this class -- it fell through to the empty Subsystem base, so
// this subsystem kept its previous life's state. Chains our own
// authentic slot-10 body.
void DeathReset(int reset_command);
// slot 15 (PoweredSubsystem base @004b1780) -- the SeekVoltageGraph's
// voltage-response sampler (issue #11: the shared subsystem
// vtbl+0x3C slot the graph's Execute @004c6934 calls; Emitter's
+10 -4
View File
@@ -427,9 +427,12 @@ Scalar
// timer reset, but only while the source is actually Ready (state 2).
//
void
PoweredSubsystem::DeathReset(int /*reset_command*/)
PoweredSubsystem::DeathReset(int reset_command)
{
ResetToInitialState(True); // @004b0e6c
// #55 step 2: forward the arg (every binary slot-11 body does). At DEATH
// (0) the !powered branch idles the electrical alarm -- the authentic wreck
// shape; on RESPAWN (1) it returns to Ready with the start timer seeded.
ResetToInitialState(reset_command != 0); // @004b0e6c
}
void
@@ -1228,9 +1231,12 @@ void
// its previous life's generator state.
//
void
Generator::DeathReset(int /*reset_command*/)
Generator::DeathReset(int reset_command)
{
ResetToInitialState(True); // @004b215c
// #55 step 2: forward the arg (@004b2164 does). The body brings the
// generator ON LINE (outputVoltage = ratedVoltage) either way -- the
// binary's shape, and what un-bricks a respawned pilot's energy weapons.
ResetToInitialState(reset_command != 0); // @004b215c
}
//
+8
View File
@@ -235,3 +235,11 @@ Logical Seeker::TestInstance() const
{
return Subsystem::TestInstance();
}
//
// DeathReset (#55 step 4) -- the respawn sweep's entry for the Seeker.
//
void Seeker::DeathReset(int /*reset_command*/)
{
ResetToInitialState();
}
+6
View File
@@ -154,6 +154,12 @@
TestClass(Missile &);
void
ResetToInitialState();
// #55 step 4: the respawn sweep (Mech::Reset -> DeathReset) never
// reached this class -- it fell through to the empty Subsystem base, so
// this subsystem kept its previous life's state. Chains our own
// authentic slot-10 body.
void DeathReset(int reset_command);
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Construction and Destruction
+11
View File
@@ -1090,3 +1090,14 @@ Scalar *BTGetTorsoTwistAddr(Subsystem *torso)
{
return torso ? ((Torso *)torso)->CurrentTwistAddr() : 0;
}
//
// DeathReset (#55 step 4) -- the respawn sweep's entry for the Torso. Without
// it a respawned mech kept its previous life's twist state and watcher latches
// (playtest night 4: "loosing torso twist function after a death", Gitea #70).
//
void
Torso::DeathReset(int /*reset_command*/)
{
ResetToInitialState(); // @004b5bf8 (takes no arg)
}
+6
View File
@@ -285,6 +285,12 @@ class Joint; // engine skeleton node (JOINT.h); the twist target
void WriteUpdateRecord(UpdateRecord *message, int update_model); // slot 7, @004b6a1c (serialize)
Logical HandleDeathMessage(Message &message); // slot 9, @004b5be0 -> @004b179c
void ResetToInitialState(); // slot 10, @004b5bf8
// #55 step 4: the respawn sweep (Mech::Reset -> DeathReset) never
// reached this class -- it fell through to the empty Subsystem base, so
// this subsystem kept its previous life's state. Chains our own
// authentic slot-10 body.
void DeathReset(int reset_command);
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Construction and Destruction