K/D scoreboard ROOT-CAUSED (#45) + respawn latch fix (#57) + plate stage ops (#58)

FIXES (compile clean; exe link pending -- a game client still holds btl4.exe):

#57 respawn latch: deathPending was released in ONE place (btplayer.cpp:343) and
only if a LATER re-post happened to find the mech alive -- so if the +5s re-post
landed before the drop-zone reply, or never arrived, the flag stayed set while
the pilot respawned and fought on, and their NEXT death hit the dedup at :391
and was swallowed whole (no warp, no tally, no hunt) for the rest of the
process.  Tonight 3 of 3 deaths in the final round were swallowed, each by a
player who had respawned successfully earlier in the same process.  Clear moved
to the actual completion point (after Mech::Reset at the drop zone) + the four
abandon paths that also stranded it.  Cycle logging promoted to always-on
(START / RESET / SWALLOWED warning) + a RESPAWN matchlog record.

#58 reticle callsign plate drew as a SOLID RECTANGLE on some maps: my
dpl2d_DrawTexturedRect bound a texture but never set COLOROP/ALPHAOP, and
L4D3D.cpp:1085 sets both to SELECTARG1 from TFACTOR (ignores the texture, fills
with a constant).  Which state was live depended on what the 3D pass drew last
-- hence 'depends on the level'.  Now sets MODULATE texture x diffuse for both
channels and restores.  Latent sibling noted (CameraHUDDrawQuad, same omission).

ROOT-CAUSE DOC (no code): docs/KD_SCOREBOARD_PLAN.md.  Headlines:
 - the port reads DEATHS from the WRONG FIELD: the binary's PilotList draws
   +0x27c/+0x280; we labelled +0x280 'pad_0x280', never write it, and
   substituted the engine's Player::deathCount (+0x200, the respawn identity
   number seeded to -2) -> remote rows read a clamped fake 0 by construction;
 - BTPlayer::VehicleDeadMessageHandler @0x4c05c4 is MISSING from our decomp
   export (functions_index.tsv jumps 4c052c -> 4c083c, verified) -- that silence
   is what produced the 'pad' belief.  An absent function is not evidence of an
   absent behaviour;
 - neither counter rides an update record, so divergence never self-heals and
   bystanders show 0/0 all mission;  BTPlayerCountObservedDeath is unreachable
   dead code;
 - 'a kill I didn't earn' = last-hitter-takes-all via lastInflictingID, and
   COLLISIONS count (5 of 47 deaths had a type=0 killing blow);
 - the phantom kill is authentic 1995 (the kill handler bumps the victim's
   killCount too);
 - btplayer.cpp:453 does simulationFlags |= 0x1 where the binary does
   ForceUpdate() -- bit 0 is DelayWatchersFlag and nothing clears it, so the
   first death of any pilot permanently disables ExecuteWatchers on that
   player's simulation.  Filing separately.

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 01:50:07 -05:00
co-authored by Claude Opus 5
parent 326023812a
commit 5174c638ce
4 changed files with 325 additions and 4 deletions
+57 -3
View File
@@ -319,6 +319,11 @@ void
// before we respawn, drop it now so the world can never stay black.
if (this == (BTPlayer *)application->GetMissionPlayer())
{ extern void BTWarpForceUnmask(); BTWarpForceUnmask(); }
// ABANDONED CYCLE -> release the latch (Gitea #57). Teardown swallows
// this death, so the pilot must not carry a set `deathPending` into the
// next round -- the pods live across rounds, so a flag stranded here
// silently swallowed every death of the following mission.
deathPending = 0; // this+0x290
Check_Fpu();
return;
}
@@ -366,9 +371,13 @@ void
// world-mask now so we don't stay black waiting for a reincarnation.
if (this == (BTPlayer *)application->GetMissionPlayer())
{ extern void BTWarpForceUnmask(); BTWarpForceUnmask(); }
if (getenv("BT_SCORE_LOG"))
DEBUG_STREAM << "[score] VehicleDead: no DropZones group -- "
"respawn unavailable in this mission\n" << std::flush;
// ABANDONED CYCLE -> release the latch (Gitea #57): this mission
// can never respawn anyone, so holding the flag would swallow
// every later death silently instead of just failing this one.
deathPending = 0; // this+0x290
DEBUG_STREAM << "[respawn] no DropZones group -- respawn "
"unavailable in this mission; death cycle abandoned\n"
<< std::flush;
Check_Fpu();
return;
}
@@ -390,10 +399,21 @@ void
//
if (deathPending != 0)
{
// A death arriving while a cycle is already in flight is authentically
// deduped -- but until Gitea #57 this was also the SILENT failure mode
// for a stranded latch, and it logged nothing at all. Always-on now: if
// this ever fires twice for one pilot, the latch is stuck again.
DEBUG_STREAM << "[respawn] WARNING: death for player "
<< BTMatchHostOf(GetEntityID()) << ":" << (int)GetEntityID()
<< " SWALLOWED -- a death cycle is already pending (deaths so far "
<< deathCount << ")\n" << std::flush;
Check_Fpu();
return;
}
deathPending = 1; // this+0x290
DEBUG_STREAM << "[respawn] player " << BTMatchHostOf(GetEntityID())
<< ":" << (int)GetEntityID() << " death cycle START (death #"
<< (deathCount + 1) << ") -- drop-zone hunt in 5s\n" << std::flush;
// WARP (task #52): our OWN death = the engine's Idle -> InitialCollapse (trigger
// becomes == control state, POVTranslocateRenderable). Collapse the eye-centred
@@ -1223,6 +1243,9 @@ void
{
if (GetSimulationState() == MissionEndingState) // state == 4
{
// ABANDONED CYCLE -> release the latch (Gitea #57): the reply arrived
// during teardown so no reset will happen; do not strand the flag.
deathPending = 0; // this+0x290
Check_Fpu();
return;
}
@@ -1239,6 +1262,9 @@ void
if (!(mech != 0 && mech->GetClassID() == Mech::MechClassID
&& mech->IsMechDestroyed()))
{
// Already alive (a duplicate reply, or the 2s probe after a completed
// reset) -> the cycle is DONE, so the latch must be down (Gitea #57).
deathPending = 0; // this+0x290
Check_Fpu();
return;
}
@@ -1261,6 +1287,34 @@ void
Mech *mech = (Mech *)playerVehicle;
mech->Reset(message->dropZoneLocation, 1 /* True */); // FUN_0049fb74 (heal+move)
//
// THE RESPAWN IS NOW COMPLETE -- release the death-cycle latch HERE
// (Gitea #57). Previously `deathPending` was cleared in exactly one
// place: the `deathCount != -1` re-post branch (:343), and only if that
// re-post happened to find the mech already alive. So the clear
// depended on a LATER message arriving after the reset:
// * if the +5s re-post landed BEFORE this reply (a respawn slower than
// 5s), it saw the mech still dead, did NOT clear, and fell through to
// another hunt -- and nothing cleared the flag afterwards;
// * if no further re-post/probe arrived at all, the flag simply stayed
// set while the pilot was alive and fighting.
// Either way the NEXT death hit the `deathPending != 0` dedup at :391 and
// was swallowed whole -- no warp, no PLAYER_DEAD, no drop-zone hunt --
// leaving the pilot driving a dead mech for the rest of the process.
// That is the field bug: tonight 3 of 3 deaths in the final round were
// swallowed, each by a player who had respawned successfully earlier in
// the same process. The cycle's completion is HERE, so clear it here.
//
deathPending = 0; // this+0x290
DEBUG_STREAM << "[respawn] player " << BTMatchHostOf(GetEntityID())
<< ":" << (int)GetEntityID() << " RESET at drop zone (death #"
<< deathCount << ") -- death cycle complete, latch cleared\n"
<< std::flush;
if (BTMatchLogActive())
BTMatchLog("RESPAWN", "player=%d:%d deaths=%d alive=%d",
BTMatchHostOf(GetEntityID()), (int)GetEntityID(), deathCount,
(mech->IsMechDestroyed() ? 0 : 1));
// WARP (task #52): our OWN respawn = the engine's WaitForReincarnate -> Expand
// release (POVTranslocateRenderable): drop the SetIsDead world mask and blast
// the eye-centred sphere open to reveal the reborn world. LOCAL player only (a