ROOT CAUSE: target designation wrote a Mech* into deathPending, disabling respawn (#57/#48)

MEASURED our compiled BTPlayer layout (new one-shot [layout] ctor diagnostic):
  sizeof=652 (0x28c)  killCount@0x270  pad_0x280@0x274
  objectiveMech@0x278  deathPending@0x284

The three target-designation sites wrote RAW at pilotArray[0]+0x284 intending the
BINARY's objectiveMech.  On our object 0x284 is deathPending -- the death-cycle
latch.  So EVERY target designation (the Comm bank 0x30-0x37, or the typed
t/y/u/i/o keys) stored a Mech pointer into deathPending, and a non-zero
deathPending makes VehicleDeadMessageHandler take its dedup early-return, which
skips the warp, ++deathCount, the PLAYER_DEAD record AND the drop-zone hunt.

=> designating a target silently disabled your respawn for the rest of the
   process.  That is #57's missing first cause: it explains David's very first
   death being swallowed with no prior death, and the 3-of-3 swallowed deaths in
   the final round.  It also explains the operator's persistent observation that
   the trouble began when the comms panel went live -- before the pilot list
   populated, there was nothing to designate.
   And the designation never worked either: the value never reached
   objectiveMech (input-audit finding #1).

FIX: all three sites now use named-member bridges in the complete-BTPlayer TU --
BTPilotSetObjectiveMech / BTPilotObjectiveMech / BTPilotVehicle / BTPilotPosition
/ BTVehicleDestroyed.  Also retires the raw +0x1fc (playerVehicle) reads and the
raw +0x100 position reads in ChooseNearestPilot, and routes its destroyed-check
through the real Mech predicate instead of the Is_Destroyed stub.

LAYOUT LOCKS: static_asserts on objectiveMech@0x278, deathPending@0x284,
killCount@0x270 and sizeof==0x28c, in the friend fn BTPlayerLayoutSelfCheck, so
a member shift fails the BUILD instead of silently clobbering the latch again.

Rigs: scratchpad/commstest.py (drives designation + captures panels),
valvetest.py, lampgallery.py (428-bitmap gallery for #48 visual search).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
This commit is contained in:
arcattack
2026-07-25 08:33:22 -05:00
co-authored by Claude Opus 5
parent cb50a1d491
commit 2dd4ee3910
6 changed files with 478 additions and 34 deletions
+139
View File
@@ -203,6 +203,39 @@ static_assert(offsetof(BTPlayer::MakeMessage, roleName) == 0x90,
static_assert(sizeof(BTPlayer::MakeMessage) == 0xD0,
"BTPlayer::MakeMessage wire size must be 0xD0");
//
// LAYOUT LOCK (Gitea #48/#57). Our compiled BTPlayer is NOT the binary's -- the
// Entity base differs -- so the 1995 offsets must never be used as raw byte
// addresses on this object. These are the MEASURED offsets (printed by the ctor
// 2026-07-25); they exist to make the difference explicit and to fail the BUILD
// if a member shifts under code that assumes it.
//
// The bug they memorialise: the target-designation path wrote raw at byte 0x284
// intending `objectiveMech` (the binary's offset). Here 0x284 is
// **`deathPending`** -- so designating a target silently set the death-cycle
// latch and disabled the pilot's respawn for the rest of the process, while the
// designation itself never reached `objectiveMech` at all.
//
// If one of these fires, do NOT "fix" it by changing the number: find whoever is
// using a raw offset and give them a named-member bridge instead (this file's
// BTPilot* functions are the pattern).
//
void BTPlayerLayoutSelfCheck() // never called -- a compile-time lock only
{
static_assert(offsetof(BTPlayer, objectiveMech) == 0x278,
"BTPlayer::objectiveMech moved -- the target-designation bridge "
"(BTPilotSetObjectiveMech) and any raw 0x284-era offset must be re-checked");
static_assert(offsetof(BTPlayer, deathPending) == 0x284,
"BTPlayer::deathPending moved -- it is what the old raw 0x284 designation "
"write actually clobbered (Gitea #57); re-verify nothing raw-writes it");
static_assert(offsetof(BTPlayer, killCount) == 0x270,
"BTPlayer::killCount moved -- the scoreboard bridges (BTPilotKills) and the "
"binary's 0x27c-era offsets must be re-checked");
static_assert(sizeof(BTPlayer) == 0x28c,
"BTPlayer size changed -- re-measure the offsets above before trusting any "
"raw-offset code that touches this class");
}
static const char *SelfDestructName = "self destruct"; // &DAT_00524b38
static const Scalar RespawnDelay = 5.0f; // death -> drop-zone hunt (@004c0830)
static const Scalar TicksPerSecond = 1.0f; // (see note in PlayerSimulation)
@@ -1354,6 +1387,32 @@ BTPlayer::BTPlayer(
currentScore = 0; // this[0x9e]
deathPending = 0; // this[0xa4]
//
// LAYOUT GROUND TRUTH (Gitea #48/#57 investigation, one-shot). The target
// designation path writes RAW at `pilotArray[0] + 0x284` (mechmppr.cpp:1344,
// :1364, :1420) intending the binary's `objectiveMech`. Our compiled layout
// is NOT the binary's (our Entity base is ~0x1BC vs the binary's 0x300), so
// that write lands on whatever member actually occupies byte 0x284 HERE.
// Print the real offsets once so we know what it clobbers -- if 0x284 falls
// on `deathPending` this single line explains BOTH the #48 artifacts AND
// #57's mysterious first-respawn failures.
//
{
static int s_once = 0;
if (!s_once)
{
s_once = 1;
DEBUG_STREAM << "[layout] sizeof(BTPlayer)=" << (int)sizeof(BTPlayer)
<< " (0x" << std::hex << (int)sizeof(BTPlayer) << std::dec << ")"
<< " killCount@0x" << std::hex << (int)offsetof(BTPlayer, killCount)
<< " pad_0x280@0x" << (int)offsetof(BTPlayer, pad_0x280)
<< " objectiveMech@0x" << (int)offsetof(BTPlayer, objectiveMech)
<< " deathPending@0x" << (int)offsetof(BTPlayer, deathPending)
<< std::dec << " <-- the raw write targets byte 0x284\n"
<< std::flush;
}
}
//
// Remember our team name from the creation message.
//
@@ -1558,6 +1617,86 @@ int BTPilotDeaths(void *pilot)
// roster row was a blank box + blanked-zero numerals: invisible until the
// pilot scored. Camera players carry index -1 (PLAYER.cpp:744) -> NULL ->
// DrawMechIcon's authentic cache-miss box branch.
//
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// TARGET DESIGNATION bridges (Gitea #48/#57 -- THE `+0x284` BUG).
//
// `MechControlsMapper`'s three designation sites wrote the chosen target RAW at
// `pilotArray[0] + 0x284`, intending the binary's `BTPlayer::objectiveMech`, and
// read the target's vehicle RAW at `chosen + 0x1fc` (`Player::playerVehicle`) and
// its position at `+0x100`. Those are the 1995 offsets. MEASURED ground truth
// for OUR compiled object (printed by the ctor, 2026-07-25):
//
// sizeof(BTPlayer) = 652 (0x28c)
// killCount @ 0x270 pad_0x280 @ 0x274
// objectiveMech @ 0x278 deathPending @ 0x284 <-- what 0x284 really is
//
// So every target designation wrote a Mech pointer into **`deathPending`**, the
// death-cycle latch. A non-zero `deathPending` makes
// `VehicleDeadMessageHandler` take its dedup early-return (btplayer.cpp:~404),
// which skips the warp, `++deathCount`, the `PLAYER_DEAD` record AND the
// drop-zone hunt -- i.e. **designating a target silently disabled your respawn
// for the rest of the process.** That is the missing first cause in #57 (and
// why David's very first death was already swallowed with no prior death to
// explain it). The designation itself also never worked, because the value
// never reached `objectiveMech` (input-audit finding #1).
//
// Fixed the only correct way: named members, resolved in this complete-type TU.
//
void BTPilotSetObjectiveMech(void *pilot, void *target_vehicle)
{
if (pilot == 0)
return;
((BTPlayer *)pilot)->objectiveMech = (Mech *)target_vehicle;
}
void *BTPilotObjectiveMech(void *pilot)
{
return (pilot != 0) ? (void *)((BTPlayer *)pilot)->objectiveMech : 0;
}
//
// The designated target's VEHICLE (the binary's raw `chosen + 0x1fc`) --
// `Player::playerVehicle`, via the engine accessor.
//
void *BTPilotVehicle(void *pilot)
{
return (pilot != 0) ? (void *)((BTPlayer *)pilot)->GetPlayerVehicle() : 0;
}
//
// The pilot's world position (the binary's raw `+ 0x100`). Reads it off the
// pilot's VEHICLE, which is where a Player's position actually lives in our
// layout -- a Player is not a positioned entity. Returns 0 if the pilot has no
// vehicle (dead / not yet acquired), so callers can skip it in a distance test.
//
int BTPilotPosition(void *pilot, float *out_xyz)
{
if (pilot == 0 || out_xyz == 0)
return 0;
Entity *veh = (Entity *)((BTPlayer *)pilot)->GetPlayerVehicle();
if (veh == 0)
return 0;
const Point3D &p = ((Mover *)veh)->localOrigin.linearPosition;
out_xyz[0] = p.x; out_xyz[1] = p.y; out_xyz[2] = p.z;
return 1;
}
//
// Is this pilot's vehicle destroyed? (the binary's `Is_Destroyed(entity)` on the
// raw `+0x1fc` handle -- our `Is_Destroyed` is still a stub, btstubs.cpp:76, so
// resolve it through the real Mech predicate in this complete-type TU).
//
int BTVehicleDestroyed(void *vehicle)
{
if (vehicle == 0)
return 1; // no vehicle == not targetable
Entity *e = (Entity *)vehicle;
if (e->GetClassID() != Mech::MechClassID)
return 0;
return ((Mech *)e)->IsMechDestroyed() ? 1 : 0;
}
BitMap *BTPilotNameBitmap(void *pilot)
{
if (pilot == 0 || application == 0)