Whirlwind respawn: authentic death->drop-zone->recreate cycle (task #52)

Death transition (mech4) now dispatches VehicleDead(-1) to the owning
player; BTPlayer::VehicleDeadMessageHandler restructured to the authentic
@004c05c4 three-way branch: -1 = death bookkeeping + sever playerVehicle
(wreck stays) + 5s re-post; >=0 = engine drop-zone hunt -> DropZoneReply
-> CreatePlayerVehicle (new mech); -2 = the acquire probe. Guarded on a
DropZones group so a zone-less mission stays dead instead of aborting.

Three latent bugs the respawn path exposed, all fixed:
- IsMechDestroyed latched on graphicAlarm>=9 alone; a later leg hit on the
  wreck rewrites the alarm to 4/3, un-latching -> the death transition
  re-ran (double kill/score, abort). Now latches on movementMode 2||9.
- Score handlers dereferenced the severed playerVehicle during the dead
  window; guarded.
- The console score flush routed a NetworkClient::Message through the
  player's Entity::Dispatch, which stamps entityID past the smaller struct
  -> /RTC1 stack overflow. Sent via application->SendMessage instead.
- Renderer LoadMission re-entry (per viewpoint-make) re-read the env INI;
  its light block Fail'd on stale sceneLightCount. Reset it in
  DPLReadEnvironment so the respawn's second read is clean.

Verified 2-node self-drive: B killed repeatedly by A respawns each time
(wreck stays), ticks + reloads + takes fresh damage on the new mech, no
abort/hang across multiple death->respawn cycles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-09 20:26:55 -05:00
co-authored by Claude Opus 4.8
parent e430e474cb
commit 83b3f31957
6 changed files with 283 additions and 54 deletions
+149 -44
View File
@@ -203,7 +203,7 @@ static_assert(sizeof(BTPlayer::MakeMessage) == 0xD0,
"BTPlayer::MakeMessage wire size must be 0xD0");
static const char *SelfDestructName = "self destruct"; // &DAT_00524b38
static const Scalar ResetDelay = 1.0f; // re-post delay
static const Scalar RespawnDelay = 5.0f; // death -> drop-zone hunt (@004c0830)
static const Scalar TicksPerSecond = 1.0f; // (see note in PlayerSimulation)
//
@@ -281,12 +281,27 @@ BTPlayer::SharedData
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// VehicleDeadMessageHandler
//
// @004c012c (file=? in the index, but operates on the BTPlayer object and
// is reached through the "VehicleDead" handler slot). Records the death,
// stamps the outgoing message with our death count, debits one "return from
// death" against the role, raises the console alarm, remembers the killer's
// name and re-posts the message a short time later so we can hunt for a new
// drop zone.
// @004c05c4 The BT death/respawn cycle (task #52). Three message flavors
// arrive here, keyed on message->deathCount:
//
// -1 The immediate DEATH NOTIFICATION (the ctor default), dispatched
// by the vehicle's death transition (mech4.cpp). Bookkeeping,
// sever playerVehicle (the wreck entity STAYS in the world) and
// re-post this message to ourselves at +5 seconds (5.0f
// @004c0830) stamped with the new death count.
// >= 0 Our own 5-second re-post: hand it to the engine base handler
// (@0042db80, PLAYER.cpp:214) which hunts the closest drop zone,
// dispatches AssignDropZone and posts the 2s -2 probe. The
// DropZoneReply then CREATEs the replacement mech.
// -2 The engine's 2s "did the reply arrive?" probe. If the player
// has a vehicle again the drop zone WAS acquired -> moot.
//
// The binary also runs killer/victim score bumps (+0x280 via @0042e580) here
// -- our kill scoring already flows through BTPostKillScore at the death
// transition, so it is not duplicated -- and, when the role is out of lives,
// posts the mission-review message (id 0x18) at +10 s instead of respawning
// (deferred with the mission-flow work). @004c012c is a sibling retry
// helper reached from DropZoneReply.
//
void
BTPlayer::VehicleDeadMessageHandler(VehicleDeadMessage *message)
@@ -295,36 +310,87 @@ void
Check(message);
//
// scoring wave (DEATHS fix): @004c012c is the drop-zone RESPAWN-RETRY helper --
// the engine's RequestDropZone (PLAYER.cpp:400) posts a 2s-delayed VehicleDead
// (deathCount=-2) as a "did the drop zone reply?" timer. If the player already
// has a vehicle, the drop zone WAS acquired (the DropZoneReply created it) -> the
// retry is moot: don't count a death and don't re-post. The prior unconditional
// ++deathCount + re-post was an INFINITE loop in solo (the drop-zone handshake
// never "completes" in the bring-up), climbing deathCount ~1/s. (A real MP death
// -- vehicle destroyed -> playerVehicle cleared/the @004c05c4 path -- is deferred.)
// Mission teardown swallows the whole cycle. ([this+0x40] == 4 skip.)
//
if (playerVehicle != 0)
if (GetSimulationState() == MissionEndingState)
{
deathPending = 0; // this+0x290
suppressConsole = 0; // this+0x258
Check_Fpu();
return;
}
if (getenv("BT_SCORE_LOG"))
DEBUG_STREAM << "[score] VehicleDeadMessageHandler: deathCount " << deathCount
<< " -> " << (deathCount + 1) << "\n" << std::flush;
if (message->deathCount != -1)
{
//
// The 5s respawn re-post (>=0) or the engine's 2s probe (-2). A
// vehicle in hand means the drop zone was acquired -> nothing to do.
// (This branch also kept the old solo bring-up honest: the initial
// spawn's -2 probes land here and are eaten.)
//
if (playerVehicle != 0)
{
deathPending = 0; // this+0x290
suppressConsole = 0; // this+0x258
Check_Fpu();
return;
}
if (getenv("BT_SCORE_LOG"))
DEBUG_STREAM << "[score] VehicleDead: drop-zone hunt (msgDeath="
<< message->deathCount << " deathCount=" << deathCount
<< ")\n" << std::flush;
//
// Respawning needs a drop zone. A dev mission without a "DropZones"
// group would hit the engine base's ACTIVE Check(dropzones) -> an
// abort() dialog; no zones -> no respawn (stay dead), honestly logged.
//
{
EntityManager *entity_manager = application->GetEntityManager();
if (entity_manager == 0
|| entity_manager->FindGroup("DropZones") == 0)
{
if (getenv("BT_SCORE_LOG"))
DEBUG_STREAM << "[score] VehicleDead: no DropZones group -- "
"respawn unavailable in this mission\n" << std::flush;
Check_Fpu();
return;
}
}
//
// No vehicle: find/ask a drop zone for a spot. The base gates on
// message->deathCount == deathCount, so stale re-posts from an
// earlier death fall through harmlessly.
//
Player::VehicleDeadMessageHandler(message); // engine base @0042db80
return;
}
//
// One more death; tell the message (so replicants stay in sync) and
// debit a life against the scoring role.
// ======== deathCount == -1: the immediate death notification ==========
// Dedup on deathPending -- one death, one cycle (cleared when a vehicle
// is back in hand, above).
//
if (deathPending != 0)
{
Check_Fpu();
return;
}
deathPending = 1; // this+0x290
if (getenv("BT_SCORE_LOG"))
DEBUG_STREAM << "[score] VehicleDead(-1): death #" << (deathCount + 1)
<< ", respawn hunt in 5s\n" << std::flush;
//
// One more death; stamp the message (the base handler's identity gate)
// and debit a life against the scoring role.
//
++deathCount; // this+0x200
message->deathCount = deathCount; // message+0x38
// TODO(bring-up): scenarioRole is the scoring role looked up from the mission's
// role registry (btplayer.cpp:815, currently deferred), so it is NULL for the
// minimal TEST.EGG dev mission. Only debit a life when a role is present.
// minimal dev missions. Only debit a life when a role is present.
if (scenarioRole != 0) // this[0x208]
{
scenarioRole->SetReturnFromDeath( // --*(this[0x208]+0x28)
@@ -335,30 +401,36 @@ void
Set_Alarm_Level((char *)this + 0x2c, 1); // FUN_0041bbd8(this+0x2c, 1)
//
// Remember who killed us (for the pilot HUD). If the kill came from a
// real attacker the message carries its name (+0x1c); otherwise fall
// back to the canned "self destruct" string (@00524b38).
// Remember who killed us (for the pilot HUD). The 1995 message is an
// extended VehicleDeadMessage carrying the killer's name (the binary
// reads message+0x1c); our compiled Entity::Message header is a
// different size, so that raw offset lands inside the header -- a
// garbage pointer. Until the pilot-roster wave defines the extended
// message, every death reads as the canned string (@00524b38).
//
if (showDamageInflicted == 0) // this+0x264
{
// The BT VehicleDead message carried the killer's name at +0x1c; the
// engine base Player__VehicleDeadMessage has no such field, so it is
// read through the documented byte offset. BEST-EFFORT (message gap).
killerName = *(const char *const *)((const char *)message + 0x1c);
}
else
{
killerName = SelfDestructName; // &DAT_00524b38
}
killerName = SelfDestructName; // this+0x1d0
//
// Re-post the dead message to ourselves after a short delay.
// Sever the vehicle link. @004c05c4 powers the roster down through
// @0049fe0c (the slot-11 DeathShutdown broadcast -- our mech's own death
// transition already runs that pass) and clears this+0x1fc. The wreck
// stays registered in the world; only the player's claim on it ends --
// which is what lets DropZoneReply create the replacement.
//
Time when = Now(); // FUN_00414b60 + FUN_004dcd94
when += ResetDelay;
application->Post(HighEventPriority, this, message, when); // app+0x60
playerVehicle = 0; // this+0x1fc
//
// If lives remain (solo/dev: no role -> always), re-post to ourselves at
// now + 5 seconds: the pilot watches the wreck, then the recovery drop.
//
if (scenarioRole == 0 || scenarioRole->GetReturnFromDeath() > 0)
{
Time when = Now(); // FUN_00414b60 + FUN_004dcd94
when += RespawnDelay; // 5.0f @004c0830
application->Post(HighEventPriority, this, message, when); // app+0x60
}
// else: out of lives -> the +10s mission-review post (id 0x18). Deferred.
deathPending = 0; // this+0x290
suppressConsole = 0; // this+0x258
}
@@ -397,6 +469,17 @@ void
(Mech *)application->GetHostManager()->GetEntityPointer(message->senderMechID); // app+0x2c, msg+0x34
Mech *our_mech = (Mech *)playerVehicle; // this+0x1fc
//
// Respawn-cycle guard (task #52): during the 5-second dead window
// playerVehicle is severed; the tonnage ratio below is undefined without
// our own mech, so a straggler inflicted-score is dropped.
//
if (our_mech == 0)
{
Check_Fpu();
return;
}
Scalar award = CalcInflictedScore(message->damageAmount, target_mech, 0.0f);
if (target_mech == our_mech)
{
@@ -594,8 +677,17 @@ void
//
// Hand the (now-scored) message to the base class to apply the award.
// Respawn-cycle guard (task #52): during the 5-second dead window
// playerVehicle is severed, and the engine base does
// playerVehicle->RespondToScoreMessage unguarded (Check aborts) -- so a
// straggler score lands the award without the vehicle response.
//
message->scoreAward = award; // msg+0x1c
if (playerVehicle == 0)
{
currentScore += message->scoreAward; // the base's award apply
return;
}
Player::ScoreMessageHandler(message); // FUN_0042da20
}
@@ -690,7 +782,20 @@ void
application->GetHostManager()->GetConsoleHost(); // FUN_00429078
if (console_host)
{
Dispatch(&score_message); // (**(*this+0xc))(this, &msg)
// A console message is a NetworkClient::Message, NOT an
// Entity::Message -- it must reach the console over the
// NETWORK stream (as the VTV-damaged push above does),
// not through the player's Entity::Dispatch. Entity::Dispatch
// stamps entityID/interestZoneID at the (larger) Entity::Message
// offsets, writing PAST this small stack message -> an RTC stack
// overflow (caught live on the respawned player's first score
// flush, task #52). Route it through SendMessage like the
// damaged-message push.
application->SendMessage( // app+0x20, slot+0x18
console_host->GetHostID(), // console_host+0xc
NetworkClient::ConsoleClientID, // 5
&score_message
);
currentScore = 0; // this[0x9e] = 0
}
}