Files
BT411/game/reconstructed/btplayer.cpp
T
arcattackandClaude Opus 5 2518e43719 Gitea #59: the first death permanently disabled ExecuteWatchers -- simulationFlags |= 0x1 should be ForceUpdate()
Verified the claim in the binary myself with capstone before changing behaviour:
  0x4c0155:  or word ptr [ebx + 0x18], 1
+0x18 is Simulation::updateModel, a **Word** -- and the 16-bit 'or word ptr'
settles it, since simulationFlags is an LWord at +0x28 and would assemble as
'or dword ptr'.  So the authentic op is updateModel |= DefaultUpdateModelFlag,
which is exactly Simulation::ForceUpdate() (SIMULATE.h:146-147).

The old transcription wrote simulationFlags |= 0x1 instead.  Bit 0 there is
DelayWatchersFlag (SIMULATE.h:170); Simulation::Simulate then skips
ExecuteWatchers() forever (SIMULATE.cpp:461), and NOTHING clears it -- the only
ClearWatcherDelay() in the tree is in the encore path (UPDATE.cpp:215), which a
Player never takes.  So every pilot's first death permanently disabled watcher
execution on their simulation, and the replication mark the binary intended was
never set at all.  context/reconstruction-gotchas.md had flagged this exact line
as the un-audited sibling of the #12 dirty-bit class, asking for the disasm
first; that is now on the record.

LIVE VERIFICATION (scratchpad/sim3.py, 3 mechs + BT_MP_FORCE_DMG, 180s):
4 consecutive death/respawn cycles on pod3, every one completing --
  death cycle START (death #N) -> RESET at drop zone [COMPLETE]
with 'player watchersDelayed=0' after every death, and no crash.  Also
re-confirms the #57 latch fix under repeated deaths (the interleaved SWALLOWED
lines are the authentic dedup of a second notification, each followed by a
completed RESET).

Leaves a silent regression guard: the death path now logs only if it ever finds
the watcher-delay flag set again.

Rigs: sim3.py (3-mech combat + force damage), destest.py (2-node designation
proof: 18 designations, deathPending clean on all of them).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg
2026-07-25 09:04:07 -05:00

2010 lines
79 KiB
C++

//===========================================================================//
// File: btplayer.cpp //
// Project: BattleTech Brick: Player / Scoring //
// Contents: BattleTech player & scoring entity (BT analog of RPPLAYER) //
//---------------------------------------------------------------------------//
// Date Who Modification //
// -------- --- ---------------------------------------------------------- //
// --/--/96 JM Initial coding. //
//---------------------------------------------------------------------------//
// Copyright (C) 1995, Virtual World Entertainment, Inc. All Rights reserved //
// PROPRIETARY AND CONFIDENTIAL //
//===========================================================================//
//
// RECONSTRUCTED from the shipped binary. Behaviour follows the Ghidra
// pseudo-C for the bt/btplayer.cpp cluster (FUN_004c012c .. FUN_004c0f28);
// method/member names follow the surviving sibling RPPLAYER.cpp/.h, the
// MUNGA PLAYER.HPP base, and SCNROLE.HPP (the scoring "role" object).
// Each non-trivial method cites its originating @ADDR.
//
// d:\tesla_bt\bt\btplayer.cpp is the original path baked into the asserts.
//
// Hex float constants converted to decimal:
// 0x3f800000 = 1.0f 0x40c00000 = 6.0f 0x41200000 = 10.0f
//
// Engine helper-function name mapping (internals referenced by the decomp):
// FUN_0040385c Verify()/assert(msg,file,line)
// FUN_0041a1a4 IsDerivedFrom(classDerivations)
// FUN_00402298 operator new (raw alloc) FUN_004022d0 operator delete
// FUN_004024d8 CString refcount free
// FUN_00402460 CString::CString(const char*)
// FUN_00402f74 MemoryBlock::New (status-message pool @00512f6c)
// FUN_004078fc Round_To_Int(Scalar)
// FUN_00408440 CString::operator=(const char*)
// FUN_00414b60 Now() (clock) FUN_004dcd94 Now() (Time)
// FUN_0041acbc Application::Post(priority,receiver,message,when)
// FUN_0041bbd8 AlarmIndicator::SetLevel(n)
// FUN_004212b0 EntityManager::FindGroup(name)
// FUN_00421414 ChainIteratorOf<Node*>::ctor FUN_00421452 ::dtor
// FUN_004323c7 Registry iterator ctor FUN_00432405 ::dtor
// FUN_004d4b58 strcmp()
// FUN_00429078 HostManager::GetConsoleHost()
// FUN_00429b94 ScenarioRole::CalcDamageReceivedScore(&dmg)
// FUN_0042d990 Player__StatusMessage::StatusMessage(player,type,time)
// FUN_0042da20 Player::ScoreMessageHandler(message) [base]
// FUN_0042e168 Player::PlayerSimulation(time_slice) [base]
// FUN_0042e328 Player::Player(make,shared) [base ctor]
// FUN_0042e524 Player::~Player() [base dtor]
// FUN_0042e580 Player::AddStatusMessage(status_message)
// FUN_004b04d8 Mech objective-reached notify (bt subsystem)
// FUN_004c052c BTPlayer::CalcInflictedScore (this file)
// FUN_004c1944 ConsolePlayerVTVDamagedMessage ctor (8 args)
//
// Global app singleton: DAT_004efc94 == application
// application+0x20 -> network sender (SendMessage slot @+0x18)
// application+0x24 -> EntityManager
// application+0x2c -> HostManager (GetEntityPointer slot @+0xc,
// GetConsoleHost via host table +0x18)
// application+0x60 -> event queue (Post)
// application+0x88 -> application state application+0xc8 -> mission/registry
//
// ScenarioRole (the scoring "role", inherited Player::scenarioRole @0x208):
// +0x0c damageInflictedModifier +0x10 damageReceivedModifier
// +0x14 friendlyFirePenalty +0x18 damageBias
// +0x1c killBonus +0x20 specialCaseDeathPenalty
// +0x24 roleName (CString) +0x28 returnFromDeath
//
// Mech fields touched here:
// +0x190 (400) owningPlayer (BTPlayer*) +0x4bc tonnage / score scale
// +0x354 per-target damage-bias factor +0x128 (objective link)
//
#include <bt.hpp>
#include <matchlog.hpp> // MP match forensics (VEHICLE/PLAYER_DEAD/SCORE)
#pragma hdrstop
#if !defined(BTPLAYER_HPP)
# include <btplayer.hpp>
#endif
#if !defined(BTTEAM_HPP)
# include <btteam.hpp>
#endif
#if !defined(BTMSSN_HPP)
# include <btmssn.hpp>
#endif
#if !defined(APP_HPP)
# include <app.hpp>
#endif
#if !defined(HOSTMGR_HPP)
# include <hostmgr.hpp>
#endif
#if !defined(BTCNSL_HPP)
# include <btcnsl.hpp>
#endif
#if !defined(NTTMGR_HPP)
# include <nttmgr.hpp> // EntityManager / EntityGroup / FindGroup
#endif
#if !defined(SCALAR_HPP)
# include <scalar.hpp> // Round()
#endif
#if !defined(DROPZONE_HPP)
# include <dropzone.hpp> // DropZone__ReplyMessage (spawn handshake)
#endif
#if !defined(MISSION_HPP)
# include <mission.hpp> // Mission::GetGameModel / GetBadgeName / GetColorName
#endif
#if !defined(RESOURCE_HPP)
# include <resource.hpp> // ResourceFile / ResourceDescription / ModelListResourceType
#endif
#if !defined(MECH_HPP)
# include <mech.hpp> // Mech::MakeMessage / MechClassID / Reset
#endif
#define CONSOLE_UPDATE_INTERVAL 10.0f // _DAT_004c08fc
#define STATUS_DISPLAY_TIME 6.0f // 0x40c00000
// BRING-UP: the spawned target/enemy mech (see CreatePlayerVehicle below). The
// player mech's per-frame targeting step (mech4.cpp) locks onto this as its
// current target. NULL when no enemy was spawned (BT_SPAWN_ENEMY unset).
Entity *gEnemyMech = 0;
//
// LIVE-MECH REGISTRY (task #46): every Mech (player, dummy, peer replicants)
// (de)registers here from its ctor/dtor. The boresight world-pick walks it to
// find target candidates -- generalising the solo gEnemyMech to MP peers. A
// plain fixed array (mech counts are tiny -- <= a few per arena) avoids any
// allocator interaction in the hot ctor path.
//
static Entity *gBTMechRegistry[32];
static int gBTMechCount = 0;
void BTRegisterMech(Entity *m)
{
if (m == 0) return;
for (int i = 0; i < gBTMechCount; ++i) // idempotent
if (gBTMechRegistry[i] == m) return;
if (gBTMechCount < 32)
gBTMechRegistry[gBTMechCount++] = m;
}
void BTDeregisterMech(Entity *m)
{
for (int i = 0; i < gBTMechCount; ++i)
{
if (gBTMechRegistry[i] == m)
{
gBTMechRegistry[i] = gBTMechRegistry[--gBTMechCount];
gBTMechRegistry[gBTMechCount] = 0;
return;
}
}
}
// The target-candidate list for a given shooter: every registered mech that is
// NOT the shooter itself. (Alive/pick filtering is the caller's -- mech4's
// world-pick already tests IsMechDestroyed + the collision-box ray.)
int BTGetTargetCandidates(Entity *shooter, Entity **out, int maxOut)
{
int n = 0;
for (int i = 0; i < gBTMechCount && n < maxOut; ++i)
{
Entity *m = gBTMechRegistry[i];
if (m != 0 && m != shooter)
out[n++] = m;
}
return n;
}
// Is this entity a live registered mech? (Projectile-impact target validation
// -- replaces the solo `== gEnemyMech` guard.)
int BTIsRegisteredMech(Entity *e)
{
for (int i = 0; i < gBTMechCount; ++i)
if (gBTMechRegistry[i] == e)
return 1;
return 0;
}
//
// Reconstruction helpers / data referenced by the decomp that have no direct
// analog in the surviving WinTesla headers (CROSS-FAMILY / engine gap -- see
// report). Declared here so the recovered bodies compile; the linker binds
// them to the real engine/BT symbols.
//
// FUN_0041bbd8 AlarmIndicator::SetLevel (the engine GaugeAlarm exposes no
// SetLevel in WinTesla) -- raise an alarm to a given level.
// FUN_004b04d8 notify the objective subsystem that its mech was destroyed.
// StatusMessagePool the BT status-message MemoryBlock pool (@00512f6c).
// BT*VTable the BT vtable symbols installed by ctor/dtor.
// SelfDestructName / ResetDelay canned strings / timing constants.
//
extern void Set_Alarm_Level(void *alarm_indicator, int level);
extern void Notify_Objective_Reached(int *objective_subsystem, Mech *mech);
extern MemoryBlock *StatusMessagePool;
extern void *BTPlayerVTable;
extern void *BTStatusMessageVTable;
// Wire-format lock: BTPlayer::MakeMessage travels RAW over the network (entity
// replication), so the inline string fields must sit at the binary's offsets.
static_assert(offsetof(BTPlayer::MakeMessage, teamName) == 0x50,
"BTPlayer::MakeMessage::teamName must be at wire offset 0x50");
static_assert(offsetof(BTPlayer::MakeMessage, roleName) == 0x90,
"BTPlayer::MakeMessage::roleName must be at wire offset 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)
//
// Mech fields touched by the scoring code but not exposed by the reconstructed
// mech.hpp (owned by the mech family). Accessed through the documented byte
// offsets. CROSS-FAMILY -- ideally Mech would expose these accessors.
//
// gauge scoring wave: the raw binary offsets are garbage in our compiled Mech
// (engine Entity base ~0x1BC vs the binary's 0x300; 0x638 Mech vs 0x854), and the
// scoring handlers deref them -> a fault the moment a ScoreMessage is dispatched.
// Reconcile: the owning player via the engine playerLink accessor (NULL for the
// uncontrolled BT_SPAWN_ENEMY dummy -- exactly what the null-guards below rely on);
// tonnage ratio stubbed 1.0 + damage bias stubbed 0.0 for bring-up (SCORE == raw
// damage, un-tonnage-scaled; the real per-mech tonnage/bias accessors are a follow-up).
#define MECH_TONNAGE(m) (1.0f) // bring-up: ratio == 1
#define MECH_DAMAGE_BIAS(m) (0.0f) // bring-up: factor = 0*bias+1 = 1
#define MECH_OWNING_PLAYER(m) ((BTPlayer *)((Mech *)(m))->GetPlayerLink()) // ENTITY.h:430 (NULL for the dummy)
//#############################################################################
//############################### BTPlayer ##############################
//#############################################################################
//#############################################################################
// Message Support
//
// MESSAGE_ENTRY names recovered from the .data string block @005130c0.
// (ScoreInflicted is reconstructed best-effort -- see header note.)
//
const Receiver::HandlerEntry
BTPlayer::MessageHandlerEntries[] =
{
MESSAGE_ENTRY(BTPlayer, DropZoneReply),
MESSAGE_ENTRY(BTPlayer, VehicleDead),
MESSAGE_ENTRY(BTPlayer, Score),
MESSAGE_ENTRY(BTPlayer, ScoreInflicted),
MESSAGE_ENTRY(BTPlayer, ScoreUpdate),
MESSAGE_ENTRY(BTPlayer, MissionStarting),
MESSAGE_ENTRY(BTPlayer, MissionEnding)
};
Receiver::MessageHandlerSet&
BTPlayer::GetMessageHandlers()
{
static Receiver::MessageHandlerSet messageHandlers(
ELEMENTS(BTPlayer::MessageHandlerEntries),
BTPlayer::MessageHandlerEntries,
Player::GetMessageHandlers()
);
return messageHandlers;
}
//#############################################################################
// Shared Data Support
//
Derivation*
BTPlayer::GetClassDerivations()
{
static Derivation classDerivations(
Player::GetClassDerivations(),
"BTPlayer" // @005130cb
);
return &classDerivations;
}
BTPlayer::SharedData
BTPlayer::DefaultData(
BTPlayer::GetClassDerivations(),
BTPlayer::GetMessageHandlers(),
Player::GetAttributeIndex(),
BTPlayer::StateCount,
(BTPlayer::MakeHandler)BTPlayer::Make
);
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// VehicleDeadMessageHandler
//
// @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)
{
Check(this);
Check(message);
//
// Mission teardown swallows the whole cycle. ([this+0x40] == 4 skip.)
//
if (GetSimulationState() == MissionEndingState)
{
// If our death raised the warp world-mask but the mission is tearing down
// 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;
}
if (message->deathCount != -1)
{
//
// The 5s respawn re-post (>=0) or the engine's 2s probe (-2). RESET-BASED
// respawn: the mech STAYS as playerVehicle through death (we no longer
// sever it), so "has a vehicle" no longer means "acquired a drop zone".
// Gate on whether the mech is still DEAD -- if it's alive again (already
// reset by a prior reply), this re-post is moot; if still dead, fall
// through to the drop-zone hunt so it gets a spot to reset into.
//
{
Mech *mech = (Mech *)playerVehicle;
Logical mech_dead = (mech != 0
&& mech->GetClassID() == Mech::MechClassID
&& mech->IsMechDestroyed());
if (!mech_dead)
{
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)
{
// No respawn possible -> the expand will never fire -> drop the warp
// world-mask now so we don't stay black waiting for a reincarnation.
if (this == (BTPlayer *)application->GetMissionPlayer())
{ extern void BTWarpForceUnmask(); BTWarpForceUnmask(); }
// 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;
}
}
//
// 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;
}
//
// ======== 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)
{
// 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
// sphere 100x -> 1x, then it raises the SetIsDead world mask and throbs until the
// respawn (DropZoneReply) releases the expand-reveal. LOCAL player only (POV).
if (this == (BTPlayer *)application->GetMissionPlayer())
{
extern void BTStartWarpCollapsePOV();
BTStartWarpCollapsePOV();
}
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
// MP MATCH FORENSICS (matchlog.hpp): the owning player's death
// notification (fires exactly once per death, deduped above).
if (BTMatchLogActive())
BTMatchLog("PLAYER_DEAD", "player=%d:%d deaths=%d",
BTMatchHostOf(GetEntityID()), (int)GetEntityID(), deathCount);
// 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 dev missions. Only debit a life when a role is present.
if (scenarioRole != 0) // this[0x208]
{
scenarioRole->SetReturnFromDeath( // --*(this[0x208]+0x28)
scenarioRole->GetReturnFromDeath() - 1);
}
//
// MARK FOR REPLICATION (Gitea #59). The binary's instruction here is
// `or word ptr [ebx + 0x18], 1` (verified with capstone at 0x4c0155). Offset
// +0x18 is `Simulation::updateModel`, a **Word**, and the 16-bit `or word ptr`
// confirms it -- `simulationFlags` is an LWord at +0x28 and would assemble as
// `or dword ptr`. So the authentic operation is
// `updateModel |= DefaultUpdateModelFlag`, which IS exactly
// `Simulation::ForceUpdate()` (SIMULATE.h:146-147).
//
// The old transcription wrote `simulationFlags |= 0x1` instead. Bit 0 of
// simulationFlags is **`DelayWatchersFlag`** (SIMULATE.h:170), and
// `Simulation::Simulate` skips `ExecuteWatchers()` for good whenever it is set
// (SIMULATE.cpp:461). Nothing clears it -- the only `ClearWatcherDelay()` in
// the tree is inside the encore path (UPDATE.cpp:215), which a Player never
// takes. Net effect: **the first death of any pilot permanently disabled
// watcher execution on that player's simulation**, and the replication mark
// the binary intended was never set at all.
//
// (`context/reconstruction-gotchas.md` flagged this exact line as the one
// un-audited sibling of the #12 dirty-bit mis-mapping class, asking for the
// disasm before changing it. That is the disasm.)
//
ForceUpdate(); // updateModel |= DefaultUpdateModelFlag
// Gitea #59 regression guard. The PLAYER's watcher-delay flag must never be
// set by the death path again -- the old `simulationFlags |= 0x1` set it and
// nothing in the tree clears it, permanently killing ExecuteWatchers for this
// Player. Silent unless the bug returns. Verified live 2026-07-25:
// watchersDelayed=0 across 4 consecutive death/respawn cycles.
if (AreWatchersDelayed())
DEBUG_STREAM << "[respawn] BUG (Gitea #59): the death path left this "
"player's watchers DELAYED -- ExecuteWatchers is dead for it now\n"
<< std::flush;
Set_Alarm_Level((char *)this + 0x2c, 1); // FUN_0041bbd8(this+0x2c, 1)
//
// 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).
//
killerName = SelfDestructName; // this+0x1d0
//
// RESET-BASED respawn: do NOT sever playerVehicle. The authentic respawn
// REUSES the same mech entity -- it stays our vehicle (a dead wreck) and is
// later healed + moved in place by Mech::Reset (DropZoneReply). Severing it
// (the old model) orphaned the wreck as a second entity and made the reply
// build a NEW viewpoint mech, which is what produced the "2 mechs / camera-
// inside / on-fire respawn" glitches. The ghost-drive is already gated by
// the input zero on IsMechDestroyed (mech4.cpp), so keeping the pointer is
// safe. (The dead mech keeps ticking as a frozen wreck until reset.)
//
//
// 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.
suppressConsole = 0; // this+0x258
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// ScoreInflictedMessageHandler
//
// @004c0200 Awards points for damage *this* player inflicted on another
// mech. The score is the tonnage-scaled inflicted value; if the target mech
// turns out to be our own vehicle the award is negated (you don't score for
// shooting yourself).
//
void
BTPlayer::ScoreInflictedMessageHandler(ScoreMessage *message)
{
Check(this);
Check(message);
//
// This handler is only ever fed DamageInflictedScore (type 0) messages.
//
if (message->scoreType != BTPlayer::ScoreMessage::DamageInflictedScore)
{
Verify(
False,
"BTPlayer::ScoreInflictedMessageHandler should only be "
"given DamageInflictedScoreMessages!", // @00513110
"d:\\tesla_bt\\bt\\btplayer.cpp", // @0051316a
0x18b
);
}
//
// Resolve the mech that took the damage and our own vehicle.
//
Mech *target_mech =
(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)
{
award = -award;
}
//
// Scale by the tonnage ratio (target / self) and accumulate. (tonnage is
// a Mech field at +0x4bc; not exposed by the reconstructed mech.hpp, so it
// is read through the documented offset. CROSS-FAMILY -- see report.)
//
currentScore +=
(MECH_TONNAGE(target_mech) / MECH_TONNAGE(our_mech)) * award; // (+0x4bc / +0x4bc), this+0x278
ForceUpdate(); // replicate the score (rank fix)
// MP MATCH FORENSICS (matchlog.hpp): inflicted-damage score (type=0),
// logged with the post-accumulate total.
if (BTMatchLogActive())
BTMatchLog("SCORE",
"player=%d:%d type=0 award=%.2f total=%.2f kills=%d",
BTMatchHostOf(GetEntityID()), (int)GetEntityID(),
(float)award, (float)currentScore, killCount);
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// ScoreUpdateMessageHandler
//
// @004c02a8 A network score-sync message. Only the master instance owns the
// authoritative score, so this must never run on a replicant; it then defers
// to the base score handler which folds the carried scoreAward in.
//
void
BTPlayer::ScoreUpdateMessageHandler(ScoreMessage *message)
{
if ((simulationFlags & 0xc) == 4) // this+0x28 -- replicant copy
{
Verify(
False,
"BTPlayer::ScoreUpdateMessageHandler should only "
"run on Master instances!", // @00513186
"d:\\tesla_bt\\bt\\btplayer.cpp", // @005131cf
0x1d5
);
}
Player::ScoreMessageHandler(message); // FUN_0042da20
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// ScoreMessageHandler
//
// @004c02e4 The main combat scoring handler. Runs only on the master, and
// only while not mission-ending. Handles damage received (type 1) and kills
// (type 2); the computed award is written back into the message before the
// base Player::ScoreMessageHandler applies it to currentScore.
//
void
BTPlayer::ScoreMessageHandler(ScoreMessage *message)
{
if ((simulationFlags & 0xc) == 4) // replicant -- must not score
{
Verify(
False,
"BTPlayer::ScoreMessageHandler should only run on "
"Master instances!", // @005131eb
"d:\\tesla_bt\\bt\\btplayer.cpp", // @0051322e
0x1e9
);
}
//
// Ignore everything once the mission is ending (state 4).
//
if (GetSimulationState() == MissionEndingState) // this+0x40 == 4
{
return;
}
Mech *sender_mech =
(Mech *)application->GetHostManager()->GetEntityPointer(message->senderMechID); // msg+0x34
Mech *our_mech = (Mech *)playerVehicle; // this+0x1fc
Scalar award = 0.0f;
switch (message->scoreType) // msg+0x20
{
case BTPlayer::ScoreMessage::DamageInflictedScore: // 0
//
// Inflicted-damage messages belong to ScoreInflictedMessageHandler.
//
Verify(
False,
"BTPlayer::ScoreMessageHandler should not be "
"given DamageInflictedScoreMessages!", // @0051324a
"d:\\tesla_bt\\bt\\btplayer.cpp", // @0051329a
0x296
);
break;
case BTPlayer::ScoreMessage::DamageReceivedScore: // 1
{
//
// Points lost for damage we took (unless we hit ourselves).
//
if (sender_mech != our_mech && scenarioRole != 0) // scoring wave: guard the NULL bring-up role
{
Scalar received =
scenarioRole->CalcDamageReceivedScore(message->damageAmount); // FUN_00429b94
award = (MECH_TONNAGE(our_mech) / MECH_TONNAGE(sender_mech)) * received;
}
//
// Tell the console (if any, and not suppressed) that our VTV
// was damaged.
//
Host *console_host =
application->GetHostManager()->GetConsoleHost(); // FUN_00429078
if (console_host && suppressConsole == 0) // this+0x258
{
ConsolePlayerVTVDamagedMessage damaged_message(
ownerID, // this+0x18c
MECH_OWNING_PLAYER(sender_mech)->ownerID, // *(sender+0x190)+0x18c
Round(message->damageAmount), // FUN_004078fc
message->pointSenderLo, // msg+0x2c
message->pointSenderHi, // msg+0x28
(int)Now().ticks, // FUN_004dcd94
message->auxID // msg+0x30
);
application->SendMessage( // app+0x20, slot+0x18
console_host->GetHostID(), // console_host+0xc
NetworkClient::ConsoleClientID, // 5
&damaged_message
);
}
}
break;
case BTPlayer::ScoreMessage::KillScore: // 2
{
// gauge scoring wave: the attacking mech's owner (NULL for the ownerless
// BT_SPAWN_ENEMY dummy). Resolved once, guarded everywhere below.
BTPlayer *sender_owner = MECH_OWNING_PLAYER(sender_mech); // *(sender+0x190), NULL for the dummy
//
// A kill (or a self-kill). Inflicted value scaled by the
// sender/self tonnage ratio.
//
award =
(MECH_TONNAGE(sender_mech) / MECH_TONNAGE(our_mech))
* CalcInflictedScore(message->damageAmount, sender_mech, message->scoreAward);
if (sender_mech == our_mech)
{
award = -award; // suicide -- no credit
}
else
{
//
// Credit a kill to us and (if it has one) to the attacking player.
// The solo demo posts this to the LOCAL player with senderMechID=the
// victim, so `this->killCount++` fires (KILLS -> 1); the ownerless
// dummy's sender_owner is NULL -> the second increment is guarded away.
//
++killCount; // this+0x27c
if (sender_owner)
++sender_owner->killCount; // *(sender+0x190)+0x27c
}
//
// Pop a "destroyed" status message naming the victim onto the comm
// ticker (MessageBoard feed, LIVE 2026-07-12). ALLOCATION NOTE: the
// binary drew from the MemoryBlock pool @00512f6c and spliced the BT
// vtable; our engine's StatusMessageUpdate reclaims each expired
// message with Unregister_Object + plain delete (PLAYER.cpp:864), so
// the matching allocation here is the engine ctor + Register_Object
// -- same record {playerInvolved, messageType 0, 6.0s}, same queue.
// Gated on a real named player (the ownerless dummy has none).
//
if (sender_owner)
{
Player__StatusMessage *status = new Player__StatusMessage(
sender_owner, // *(sender+0x190) -- the victim's player
0, // message strip 0 (the kill cell)
STATUS_DISPLAY_TIME // 0x40c00000 == 6.0f
);
Register_Object(status);
AddStatusMessage(status); // FUN_0042e580
}
//
// If we just killed our designated objective mech, notify it.
// (playerVehicle's objective subsystem link is at +0x128.)
//
if (sender_mech == objectiveMech) // this+0x284
{
Notify_Objective_Reached( // FUN_004b04d8
*(int **)((char *)playerVehicle + 0x128), // *(this[0x1fc]+0x128)
sender_mech
);
}
}
break;
}
//
// 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
// MP MATCH FORENSICS (matchlog.hpp): every folded score award. total is
// the score BEFORE this award lands (the base applies it right after) --
// matchcheck sums the awards; kills counts ride along.
if (BTMatchLogActive())
BTMatchLog("SCORE",
"player=%d:%d type=%d award=%.2f total=%.2f kills=%d",
BTMatchHostOf(GetEntityID()), (int)GetEntityID(),
(int)message->scoreType, (float)award, (float)currentScore,
killCount);
if (playerVehicle == 0)
{
currentScore += message->scoreAward; // the base's award apply
ForceUpdate(); // replicate the score (rank fix)
return;
}
Player::ScoreMessageHandler(message); // FUN_0042da20
// SCORE REPLICATION (2026-07-23, the "everyone sees themselves 1st"
// field report): Player::WriteUpdateRecord SHIPS currentScore and the
// replicant's ReadUpdateRecord APPLIES it -- but nothing ever marked the
// player's record dirty on a score change (the engine only ForceUpdates
// at mission end), so every peer's replicant player stayed at 0 and
// CalcRanking put the local player first on every machine.
ForceUpdate();
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// CalcInflictedScore
//
// @004c052c Shared damage->points routine. Same-team hits (in a non-free-
// for-all game) cost a fixed friendly-fire penalty; otherwise the points
// scale with the target's damage-bias factor. The result is multiplied by
// the role's damageInflictedModifier.
//
Scalar
BTPlayer::CalcInflictedScore(
const Scalar &damage_amount,
Mech *other_mech,
Scalar bias
)
{
// gauge scoring wave: scenarioRole is NULL in bring-up (the BTMission role
// registry has no WinTesla analog -- ctor lookup stubbed), and every modifier
// deref below would fault the moment a score message is dispatched. A NULL
// role == the neutral role (all modifiers 1, no penalties, bias 0), so the
// award is just (damage + bias). Real per-role modifiers are a follow-up.
if (scenarioRole == 0)
{
return damage_amount + bias;
}
Scalar factor;
//
// Team game: a hit on a team-mate is friendly fire.
//
if (freeForAll == 0 // this+0x250
&& strcmp(MECH_OWNING_PLAYER(other_mech)->teamName, teamName) == 0) // (other+0x190)+0x20c vs this+0x20c
{
factor = -scenarioRole->GetFriendlyFirePenalty(); // -*(this[0x208]+0x14)
}
else
{
factor =
MECH_DAMAGE_BIAS(other_mech) // other+0x354
* scenarioRole->GetDamageBias() // *(this[0x208]+0x18)
+ 1.0f; // _DAT_004c05c0 == 1.0f
}
return (damage_amount + bias)
* scenarioRole->GetDamageInflictedModifier() // *(this[0x208]+0x0c)
* factor;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// PlayerSimulation
//
// @004c083c Registered as the Performance for a console/replicant copy
// (set in the ctor when (simulationFlags & 0xc) == 4). Runs the base sim,
// then pushes our score to the console once every CONSOLE_UPDATE_INTERVAL
// seconds (or immediately if the application is shutting the mission down).
//
void
BTPlayer::PlayerSimulation(Scalar time_slice)
{
Check(this);
Player::PlayerSimulation(time_slice); // FUN_0042e168
if (
(lastPerformance - lastConsoleUpdate) / TicksPerSecond >= CONSOLE_UPDATE_INTERVAL // _DAT_004c08fc
|| application->GetApplicationState() == Application::EndingMission // app+0x88 == 6
)
{
lastConsoleUpdate = lastPerformance; // this+0x28c = this+0x10
//
// Only bother if our score actually changed since last time.
//
if ((Scalar)currentScore != 0.0f) // this[0x9e] != _DAT_004c0900 (0.0f)
{
int score = (int)currentScore;
ConsolePlayerVTVScoreUpdateMessage score_message(
ownerID,
score
); // FUN_00420ea4(0x20, 0x1a, 1, ...)
// gauge scoring wave: the binary's currentScore is a console DELTA that is
// flushed to the operator console then zeroed. Our SCORE/RANK gauges read
// currentScore as the RUNNING total, so only flush+zero when a console host
// is actually present (MP / pod); in solo there is no console -> keep the
// running score so the SCORE gauge + CalcRanking don't reset every 10s.
Host *console_host =
application->GetHostManager()->GetConsoleHost(); // FUN_00429078
if (console_host)
{
// 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
}
}
}
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// CreatePlayerVehicle
//
// @004bfcac (BTPlayer vtable slot +0x40). Makes the player's BattleMech at
// the supplied drop-zone origin. Lets the base build any MUNGA-level vehicle
// first (camera-ship observers); if no vehicle resulted, instantiates a Mech
// from the mission's game-model resource and links it as our viewpoint.
//
// Structural model: RPPLAYER.cpp RPPlayer::CreatePlayerVehicle (which makes a
// VTV the same way). BT substitutes Mech for VTV and carries the three mech
// resource-name strings (badge / colour / patch) into the make message.
//
void
BTPlayer::CreatePlayerVehicle(Origin mech_location)
{
Check(this);
Check(application);
ResourceFile *resources = application->GetResourceFile();
Check(resources);
Check_Pointer(playerMission->GetGameModel());
HostManager *host_manager = application->GetHostManager();
Check(host_manager);
// TEST HOOK (BT_SPAWN_XZ="x,z"): force the spawn/respawn to a fixed ground
// position (X/Z; Y stays the drop-zone ground height, the ground model
// snaps it). The map's drop zones are picked ~randomly and land the two
// -net instances ~1500u apart -- hard to find each other. Set the same
// base on both pods with a small offset so they spawn side by side.
{
const char *xz = getenv("BT_SPAWN_XZ");
if (xz != 0)
{
float sx = 0.0f, sz = 0.0f;
if (sscanf(xz, "%f,%f", &sx, &sz) == 2)
{
mech_location.linearPosition.x = sx;
mech_location.linearPosition.z = sz;
if (getenv("BT_MP_LOG"))
DEBUG_STREAM << "[spawn] BT_SPAWN_XZ override -> ("
<< sx << ", " << mech_location.linearPosition.y
<< ", " << sz << ")\n" << std::flush;
}
}
}
//
// Make any MUNGA-level vehicle (camera ship for an observer player).
//
Player::CreatePlayerVehicle(mech_location);
//
// If the player still has no vehicle, build him a mech and link to it.
//
if (!playerVehicle)
{
ResourceDescription *mech_res =
resources->FindResourceDescription(
playerMission->GetGameModel(),
ResourceDescription::ModelListResourceType
);
//
// HARDENING (found during the #35 hunt): an unknown vehicle= name in
// the egg (a typo, or a corrupted relay rewrite) returned NULL here
// and SEGFAULTED on Lock() in release (Check compiles out). A bad
// tag in a served egg would crash EVERY pod -- fail loud instead.
//
if (mech_res == 0)
{
DEBUG_STREAM << "[spawn] FATAL: unknown vehicle '"
<< (playerMission->GetGameModel()
? playerMission->GetGameModel() : "<null>")
<< "' in the egg (no such ModelList resource)\n" << std::flush;
Fail("unknown vehicle= name in the egg -- check the mission file\n");
return;
}
mech_res->Lock();
Mech::MakeMessage
create_player(
Mech::MakeMessageID,
sizeof(Mech::MakeMessage),
EntityID(host_manager->GetLocalHostID()),
(Entity::ClassID)Mech::MechClassID,
EntityID::Null,
mech_res->resourceID,
Mech::DefaultFlags,
mech_location,
Motion::Identity,
Motion::Identity,
playerMission->GetBadgeName(), // vehicle badge (+0x7c)
playerMission->GetColorName(), // vehicle colour (+0x90)
((BTMission *)playerMission)->GetPatchName() // vehicle patch (+0xa4)
);
mech_res->Unlock();
playerVehicle = application->MakeAndLinkViewpointEntity(&create_player);
Register_Object(playerVehicle);
}
// MP MATCH FORENSICS (matchlog.hpp): the player has a vehicle (initial
// spawn AND every drop-zone respawn route here) -- the line that binds a
// player id to a mech id for the whole analysis.
if (BTMatchLogActive() && playerVehicle != 0)
BTMatchLog("VEHICLE",
"player=%d:%d mech=%d:%d deaths=%d pos=%.1f,%.1f,%.1f",
BTMatchHostOf(GetEntityID()), (int)GetEntityID(),
BTMatchHostOf(playerVehicle->GetEntityID()),
(int)playerVehicle->GetEntityID(),
deathCount,
(float)mech_location.linearPosition.x,
(float)mech_location.linearPosition.y,
(float)mech_location.linearPosition.z);
// --- BRING-UP: spawn a stationary target/enemy mech (gameplay scaffolding) ---
// The mission system is networked (one pilot per host) and BT's bot/AI code
// is absent, so a solo mission has nothing to fight. When BT_SPAWN_ENEMY is
// set we drop a SECOND mech into the world via the SAME factory the registry
// uses for remote/AI entities (Mech::Make on a MakeMessage) -- a non-viewpoint,
// self-registering, rendering, ticking entity placed ahead of the player. It
// has no AI yet (mech4.cpp gates the player-drive path to the viewpoint mech),
// so it stands as a target dummy. First step toward actual combat.
if (getenv("BT_SPAWN_ENEMY"))
{
ResourceDescription *enemy_res =
resources->FindResourceDescription(
playerMission->GetGameModel(),
ResourceDescription::ModelListResourceType);
if (enemy_res)
{
enemy_res->Lock();
const ResourceDescription::ResourceID enemyResID = enemy_res->resourceID;
// COUNT: BT_SPAWN_ENEMY is read as a count -- "1" (or any truthy value)
// drops one dummy; "2"+ CLUSTERS extra dummies laterally so a bystander
// sits within a missile's SplashRadius (30) of the primary -- the rig for
// eyeballing splash damage (task #62): shoot ONE, watch the OTHER take
// collateral. Clamped to a sane range.
int enemyCount = atoi(getenv("BT_SPAWN_ENEMY"));
if (enemyCount < 1) enemyCount = 1; // non-numeric truthy -> 1
if (enemyCount > 8) enemyCount = 8;
// Base origin: 120 ahead of the player ALONG THE SPAWN FACING (the mech
// faces local -Z; rotate that axis into world by the spawn orientation).
// The old fixed "z -= 120" assumed the bring-up drive's forced heading=0;
// the real-controls drive keeps the authentic spawn orientation, so the
// dummy must follow the actual facing or the auto-walk leaves it behind.
Origin base_origin = mech_location;
UnitVector spawnZ;
{
AffineMatrix facing;
facing.BuildIdentity();
facing = mech_location.angularPosition; // rotation from the spawn pose
facing.GetFromAxis(Z_Axis, &spawnZ); // local Z basis in world
base_origin.linearPosition.x -= spawnZ.x * 120.0f; // forward = -Z
base_origin.linearPosition.y -= spawnZ.y * 120.0f;
base_origin.linearPosition.z -= spawnZ.z * 120.0f;
// FACE THE PLAYER: copying the spawn pose left BOTH mechs facing
// the same way (the player stared at the enemy's back). Flip the
// enemy's yaw 180 deg about Y so the two face each other. Engine
// yaw convention (MATRIX.cpp:196-209): the Z basis = (sin y, 0,
// cos y) -> yaw = atan2(z.x, z.z); enemy yaw = that + pi.
Scalar spawnYaw = (Scalar)atan2((double)spawnZ.x, (double)spawnZ.z);
base_origin.angularPosition =
EulerAngles(0.0f, spawnYaw + 3.14159265f, 0.0f);
}
enemy_res->Unlock();
// Right vector in the ground plane (perp to the forward XZ): cluster the
// extra dummies sideways so a hit on the primary catches the bystander.
Vector3D rightv(spawnZ.z, 0.0f, -spawnZ.x);
{
Scalar rl = (Scalar)sqrt((double)(rightv.x*rightv.x + rightv.z*rightv.z));
if (rl < 1e-4f) { rightv.x = 1.0f; rightv.z = 0.0f; }
else { rightv.x /= rl; rightv.z /= rl; }
}
for (int ei = 0; ei < enemyCount; ++ei)
{
Origin enemy_origin = base_origin;
// #0 at the primary spot; extras step +/-25u (inside SplashRadius 30).
Scalar off = (ei == 0) ? 0.0f
: 25.0f * (Scalar)(((ei + 1) / 2)) * ((ei & 1) ? 1.0f : -1.0f);
enemy_origin.linearPosition.x += rightv.x * off;
enemy_origin.linearPosition.z += rightv.z * off;
Mech::MakeMessage
create_enemy(
Mech::MakeMessageID,
sizeof(Mech::MakeMessage),
host_manager->MakeUniqueEntityID(),
(Entity::ClassID)Mech::MechClassID,
EntityID::Null,
enemyResID,
Mech::DefaultFlags,
enemy_origin,
Motion::Identity,
Motion::Identity,
playerMission->GetBadgeName(),
playerMission->GetColorName(),
((BTMission *)playerMission)->GetPatchName());
Mech *enemy = Mech::Make(&create_enemy);
if (enemy)
{
Register_Object(enemy);
// Mark it a VALID master so Entity::Dispatch delivers messages
// SYNCHRONOUSLY (Receiver::Receive) instead of posting them as deferred
// "entity invalid" events that never fire -- otherwise TakeDamage never
// lands. (The player gets validated via MakeViewpointEntity's CheckLoad
// handshake; a manually-spawned entity must set it itself. Its resources
// are already loaded -- it renders + its zones are built.) Also force
// pre-run + interest so it ticks like a live entity.
enemy->SetValidFlag();
enemy->SetPreRunFlag();
if (enemy->interestCount == 0) enemy->interestCount = 1;
// GROUND MODEL (task #15): a MASTER mech needs a CollisionAssistant
// for GetCurrentCollisions (the engine iterates it unchecked,
// MOVER.cpp:894). The player gets one in MakeViewpointEntity
// (btl4app.cpp:591); this dummy is a master too, so start its own
// -- without it the authentic ground block skips its collision half.
enemy->StartCollisionAssistant();
if (ei == 0)
gEnemyMech = enemy; // the player's targeting step locks onto the primary
DEBUG_STREAM << "[enemy] spawned dummy #" << ei
<< " id=" << enemy->GetEntityID()
<< (ei == 0 ? " (PRIMARY/direct-target)" : " (bystander/splash)")
<< " at ("
<< enemy_origin.linearPosition.x << ", "
<< enemy_origin.linearPosition.y << ", "
<< enemy_origin.linearPosition.z << ")\n" << std::flush;
}
else
{
DEBUG_STREAM << "[enemy] Mech::Make returned NULL (#" << ei << ")\n" << std::flush;
}
}
}
else
{
DEBUG_STREAM << "[enemy] model resource not found\n" << std::flush;
}
}
Check_Fpu();
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// InitializePlayerLink
//
// @004bfee0 Tells our freshly-made vehicle (and its replicants) which player
// owns it, by dispatching a PlayerLink message carrying our EntityID.
//
void
BTPlayer::InitializePlayerLink()
{
Check(this);
Check(playerVehicle);
PlayerLinkMessage
player_link_message(
PlayerLinkMessageID,
sizeof(PlayerLinkMessage),
GetEntityID()
);
playerVehicle->Dispatch(&player_link_message);
playerVehicle->DispatchToReplicants(&player_link_message);
if (getenv("BT_CAM_LOG"))
DEBUG_STREAM << "[cam] InitializePlayerLink dispatched (player "
<< GetEntityID() << " -> vehicle "
<< playerVehicle->GetEntityID() << ")\n" << std::flush;
Check_Fpu();
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// DropZoneReplyMessageHandler
//
// @004bffd0 The drop zone has answered our spawn request with a location.
//
// * No vehicle yet -> make the mech there, link it, choose the per-vehicle
// simulation Performance from its class, start running.
// * Same death -> if the mission is ending, ignore; if we have not yet
// acquired this drop zone, fold into the death/reset
// path (VehicleDeadMessageHandler, FUN_004c012c).
// * Stale message -> ignore.
//
// Then (re)place the mech at the drop-zone origin. Reconstructed faithfully
// from FUN_004bffd0; structural cross-check = Blocker::DropZoneReplyMessageHandler.
//
void
BTPlayer::DropZoneReplyMessageHandler(DropZone__ReplyMessage *message)
{
Check(this);
Check(message);
if (getenv("BT_SCORE_LOG"))
DEBUG_STREAM << "[score] DropZoneReply: hasVehicle=" << (playerVehicle != 0)
<< " deathCount=" << deathCount << " msgDeath=" << message->deathCount
<< " state=" << (int)GetSimulationState() << "\n" << std::flush;
// TEST HOOK (BT_SPAWN_XZ="x,z"): force the drop-zone X/Z so two -net instances
// land in the same area (map zones are ~random, ~1500u apart -- hard to find
// each other). Applied to the MESSAGE so BOTH the CreatePlayerVehicle make
// AND the Mech::Reset placement below use it (Reset re-places at
// message->dropZoneLocation, which would otherwise undo the override). Y
// stays the drop-zone ground height; the ground model snaps it.
{
const char *xz = getenv("BT_SPAWN_XZ");
if (xz != 0)
{
float sx = 0.0f, sz = 0.0f;
if (sscanf(xz, "%f,%f", &sx, &sz) == 2)
{
message->dropZoneLocation.linearPosition.x = sx;
message->dropZoneLocation.linearPosition.z = sz;
}
}
}
if (!playerVehicle) // param_1[0x7f] == 0
{
CreatePlayerVehicle(message->dropZoneLocation); // (**vtable+0x40)(origin)
InitializePlayerLink(); // FUN_004bfee0
//
// Drive the right per-vehicle simulation. A mech runs the combat
// PlayerSimulation; a camera-ship observer runs CameraShipSimulation
// and never ranks. (classID @ playerVehicle+4.)
//
if (playerVehicle->GetClassID() == Mech::MechClassID) // 0xbb9
{
SetPerformance(&BTPlayer::PlayerSimulation); // perf @00513058
// scoring wave (RANK fix): an active mech combatant is a SCORING player.
// Player::DefaultFlags creates every player NonScoring (rank -1 until
// activated); CalcRanking only ranks IsScoringPlayer()==true. The camera
// ship (below) stays non-scoring; a piloted mech becomes scoring here.
SetScoringPlayerFlag(); // clear NonScoringPlayerFlag
}
else // camera ship (0x45)
{
SetPerformance((Performance)&BTPlayer::CameraShipSimulation); // perf @00513064
playerRanking = -1; // this+0x1cc
}
AlwaysExecute(); // param_1[10] &= ~2 (run every frame)
deathCount = 0; // param_1[0x80]
// (warp fired in the shared placement below -- initial drop-in + respawn)
}
else if (deathCount == message->deathCount) // param_2[0xe] == param_1[0x80]
{
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;
}
//
// RESPAWN (reset-based): the mech STAYS ours through death; this reply
// (deathCount matches) resets the EXISTING dead mech in place -- fall
// through to the placement below (Mech::Reset heals + moves it). Guard
// against a duplicate reply / the 2s probe: if the mech is already alive
// again, there is nothing to reset. (The old path pulsed
// DropZoneAcquiredState and looped through the dead handler to build a
// NEW mech -- removed; that state drives our camera/targeting.)
//
Mech *mech = (Mech *)playerVehicle;
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;
}
// else: still dead -> fall through to placement (reset + warp)
}
else // stale / old message
{
return;
}
//
// Place the mech at the drop-zone origin: heal + move the SAME entity
// (Mech::Reset), raise the "translocated" cockpit alarm, and fire the
// translocation-sphere warp. Runs for the initial drop-in (fresh mech) and
// for a respawn (dead mech healed) alike.
//
Set_Alarm_Level((char *)this + 0x2c, 2); // FUN_0041bbd8(this+0x2c, 2)
if (playerVehicle->GetClassID() == Mech::MechClassID) // 0xbb9
{
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
// POV effect); a self-contained render one-shot that does NOT touch
// SimulationState (that dial drives the camera/POV + targeting -- pulsing it
// regressed all of those; SetIsDead is a separate pure-render flag).
if (this == (BTPlayer *)application->GetMissionPlayer())
{
extern void BTStartWarpExpandPOV();
BTStartWarpExpandPOV();
}
}
Check_Fpu();
}
//#############################################################################
// Construction and Destruction
//
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// BTPlayer (constructor)
//
// @004c0bc8
//
BTPlayer::BTPlayer(
BTPlayer::MakeMessage *creation_message,
SharedData &shared_data
):
Player(creation_message, shared_data) // FUN_0042e328
{
/* removed decompiler vtable artifact -- the C++ ctor already sets the real BTPlayer vtable;
the original `*(void**)this = &BTPlayerVTable` overwrote it with a zeroed stub -> crash. */ // PTR_FUN_00513300
consoleAttached = 0; // this[0x95]
suppressConsole = 0; // this[0x96]
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.
//
strcpy(teamName, creation_message->teamName); // this+0x20c <- make+0x50
//
// Resolve the BTTeam entity that owns this team name.
//
teamEntity = 0; // this[0x93]
EntityGroup *teams =
application->GetEntityManager()->FindGroup("Teams"); // app+0x24, @005132ee
if (teams)
{
ChainIteratorOf<Node*> iterator(teams->groupMembers);
BTTeam *team;
while ((team = (BTTeam *)iterator.ReadAndNext()) != 0)
{
if (strcmp(team->teamName, teamName) == 0) // team+0x1c4 vs this+0x20c
{
teamEntity = team;
break;
}
}
}
//
// Game-type flag. (Both arms of the shipped code set this to 1; the
// "freeforall" string compare result is effectively ignored -- looks
// like a left-over from a half-finished edit.)
//
freeForAll = 1; // this[0x94]
// The shipped code compared the mission's game model to "freeforall" but
// discarded the result (both arms set freeForAll=1 -- a half-finished
// edit). Application::GetMission()/Mission::GetGameModel() have no
// WinTesla analog, so the dead compare is dropped. @005132f4
//
// On a replicant copy, back-fill the mission pointer (the base Player ctor
// leaves playerMission NULL for replicants; the binary's @0x1f8 slot IS
// Player::playerMission -- app+0xc8 == Application::GetCurrentMission()).
// The old separate `btMission` phantom member is gone (issue #2).
//
if ((simulationFlags & 0xc) == 4 && playerMission == 0) // this+0x28, this[0x7e]
{
playerMission = application->GetCurrentMission(); // app+0xc8
}
//
// Look the scoring role up in the role registry (keyed by the role name
// in the creation message, +0x90) and stash it as our scenarioRole. The
// BT role registry (BTMission::GetRoleRegistry()->Lookup) has no WinTesla
// analog, so the scenarioRole set by the base Player ctor stands.
// CROSS-FAMILY: needs BTMission role-registry access. BEST-EFFORT.
//
CString role_key(creation_message->roleName); // make+0x90
(void)role_key;
// scenarioRole = playerMission->GetRoleRegistry()->Lookup(&role_key); // this[0x7e]+0x50, this[0x82]
if ((simulationFlags & 0xc) == 4)
{
//
// Replicant: drive the console-update Performance.
//
SetPerformance(&BTPlayer::PlayerSimulation); // this[7..9] = PTR_FUN_00513070
}
else
{
//
// Master: derive the game-mode flags from the EXPERIENCE level.
//
// WIRED (issue #2, 2026-07-20 [T1]): the binary @004c0bc8
// (part_013.c:10843-10870) reads btMission->experienceLevel
// (mission+0xe4, the egg's per-pilot "experience" 0..3 =
// novice/standard/veteran/expert) — NOT the role's returnFromDeath (the
// old stand-in, which NULL-defaulted every pilot to veteran). The
// mission pointer is the inherited Player::playerMission (= the
// binary's this+0x1f8), set by the base ctor on the master branch; the
// concrete mission is a BTL4Mission (is-a BTMission). Binary rows for
// (0x25c,0x260,0x26c,0x270): nov 0,0,0,0 / std 1,0,1,1 / vet 1,1,1,1 /
// exp 1,1,0,1. Note the two old slot errors, now fixed: the binary
// switch never touches advancedDamageOn(0x264) (set below), and
// expert's 0 lands on levelFlag26c(0x26c), not levelFlag270(0x270).
// See context/experience-levels.md.
//
BTMission *bt_mission = (BTMission *)playerMission; // this[0x7e]
experienceLevel = bt_mission != 0
? bt_mission->ExperienceLevel() // mission+0xe4
: (int)BTMission::VeteranMode; // [T3] dev-permissive; the binary derefs unguarded (master always has a mission)
switch (experienceLevel) // this[0x9d]
{
case BTMission::NoviceMode: // 0: everything off
heatModelOn = simLive = levelFlag26c = levelFlag270 = 0;
break;
case BTMission::StandardMode: // 1: live, no heat model
heatModelOn = 0;
simLive = levelFlag26c = levelFlag270 = 1;
break;
case BTMission::VeteranMode: // 2: everything on
heatModelOn = simLive = levelFlag26c = levelFlag270 = 1;
break;
case BTMission::ExpertMode: // 3: on, but 0x26c off
heatModelOn = simLive = levelFlag270 = 1;
levelFlag26c = 0;
break;
}
// The binary then unconditionally copies this[0x99]/[0x9a]
// (0x264/0x268) = btMission->advancedDamageOn (mission+0xf0, the egg
// "advancedDamage" technician flag; an int pair). [T1]
advancedDamageOn = advancedDamageOn2 = // this[0x99],[0x9a]
bt_mission != 0 ? bt_mission->AdvancedDamageOn() : False;
// Verification sentinel (issue #2): one line per master player.
DEBUG_STREAM << "[exp] master player experience=" << experienceLevel
<< " simLive=" << (int)simLive
<< " heatModelOn=" << (int)heatModelOn
<< " advDamage=" << (int)advancedDamageOn
<< "\n" << std::flush;
}
killCount = 0; // this[0x9f]
pad_0x280 = 0; // this[0xa0]
objectiveMech = 0; // this[0xa1]
lastPerformance = lastConsoleUpdate = lastUpdate; // this[0xa2],[0xa3] = this[4]
Check_Fpu();
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Make
//
// @004c0ecc Allocate a 0x294-byte BTPlayer and construct it with the
// default shared data (@00512fc4).
//
BTPlayer*
BTPlayer::Make(BTPlayer::MakeMessage *creation_message)
{
BTPlayer *player = new BTPlayer(creation_message);
// P6 bring-up: validate replicant players at creation (see Mech::Make -- an
// invalid replicant defers every network message forever).
if (player != 0 && player->GetInstance() == Entity::ReplicantInstance)
{
player->SetValidFlag();
}
return player;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// ~BTPlayer
//
// @004c0efc (scalar-deleting destructor thunk). Chains to ~Player.
//
BTPlayer::~BTPlayer()
{
/* removed decompiler vtable artifact -- the C++ ctor already sets the real BTPlayer vtable;
the original `*(void**)this = &BTPlayerVTable` overwrote it with a zeroed stub -> crash. */ // PTR_FUN_00513300
// (no BT-specific owned resources to release; base chain handles teardown)
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// TestInstance
//
// @004c0f28
//
Logical
BTPlayer::TestInstance() const
{
return IsDerivedFrom(*GetClassDerivations()); // FUN_0041a1a4(**this[3], 0x512f94)
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// gauge scoring wave: scoreboard bridges for the PilotList gauge (btl4gau3.cpp).
// The pilot roster entries are BTPlayer* delivered as void* (BTResolveRosterPilot);
// these read the COMPILED named members the scoring handlers write, replacing the
// earlier raw-offset reads (pilot+0x27c / +0x200) that don't match our layout.
//
int BTPilotKills(void *pilot)
{
int k = pilot ? ((BTPlayer *)pilot)->GetKillCount() : 0;
static int s_n = 0;
if (getenv("BT_SCORE_LOG") && (s_n++ % 120) == 0) // ~throttled
DEBUG_STREAM << "[score] gauge read: pilot=" << pilot << " kills=" << k << "\n" << std::flush;
return k;
}
// DEATHS display (MP DEATHS fix): the engine seeds Player::deathCount at -2
// (PLAYER.cpp:759) and only the LOCAL vehicle-acquire path zeroes it -- a REMOTE
// player's copy on this node keeps the raw pre-acquire value (-2/-1) forever,
// which the scoreboard showed verbatim ("DEATHS -1", user-reported). Those
// negatives are the engine's internal spawn-cycle convention, not real deaths;
// clamp them to the observable truth: no deaths yet = 0.
int BTPilotDeaths(void *pilot)
{
if (pilot == 0)
return 0;
int deaths = ((BTPlayer *)pilot)->GetDeaths();
return (deaths < 0) ? 0 : deaths;
}
// Gitea #43 completion: the pilot-list NAME icon. The binary blits the
// pilot's SMALL callsign raster -- the same 64x16 egg bitmaps the radar
// labels use (Mission's small-bitmap chain, keyed 1-based by the egg
// 'bitmapindex') -- but the port's PilotList had a NULL-stub lookup, so a
// 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 *p = (BTPlayer *)pilot;
p->objectiveMech = (Mech *)target_vehicle;
// PROOF LINE for the 0x284 bug (Gitea #48/#57): the designation must land on
// objectiveMech and must leave deathPending ALONE. Before the fix this wrote
// a Mech* into deathPending and killed the pilot's respawn for the session.
if (getenv("BT_SCORE_LOG"))
DEBUG_STREAM << "[score] designate: objectiveMech=" << (void *)p->objectiveMech
<< " deathPending=" << p->deathPending
<< (p->deathPending == 0 ? " (clean)" : " *** LATCH SET -- BUG ***")
<< "\n" << std::flush;
}
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)
return 0;
Mission *mission = application->GetCurrentMission();
int index = ((BTPlayer *)pilot)->playerBitmapIndex;
if (mission == 0 || index <= 0)
return 0;
return mission->GetSmallNameBitmap(index);
}
// OBSERVED-DEATH tally (MP DEATHS fix): each node maintains every player's
// score from LOCALLY OBSERVED events -- exactly how the KILLS credit already
// works (the victim's ScoreMessage handler ++s the SHOOTER's local Player
// copy). This is the symmetric half: when a REPLICANT mech dies here (the
// once-per-death transition runs on every node), tally the death onto its
// owning player's LOCAL copy. The owner's own node counts through its real
// VehicleDead(-1) path, so the caller gates this to replicant mechs -- each
// node then counts every pilot self-consistently. Normalize the -2/-1
// pre-acquire seed first so death #1 reads 1.
void BTPlayerCountObservedDeath(void *player)
{
if (player == 0)
return;
BTPlayer *p = (BTPlayer *)player;
if (p->deathCount < 0)
p->deathCount = 0;
++p->deathCount;
if (getenv("BT_SCORE_LOG"))
DEBUG_STREAM << "[score] observed death -> pilot " << player
<< " deaths=" << p->deathCount << "\n" << std::flush;
}
// Is `pilot` the owner of `local_player`'s current target? (the SELECT-TARGET row
// highlight). Computed here via accessors -- the PilotList's raw-offset version
// (local+0x284 objectiveMech, then deref target+0x190) reads garbage in our layout
// and AV'd inside the SEH-guarded Execute, silently aborting it BEFORE the KILLS/
// DEATHS draw. GetPlayerLink() is NULL for the ownerless dummy target -> not selected.
int BTPilotIsSelected(void *pilot, void *local_player)
{
if (pilot == 0 || local_player == 0)
{
return 0;
}
Mech *target = ((BTPlayer *)local_player)->GetObjectiveMech(); // @0x284
if (target == 0)
{
return 0;
}
return ((void *)((BTPlayer *)pilot) == (void *)target->GetPlayerLink()) ? 1 : 0;
}
// MessageBoard data bridge (consumed by btl4gau3.cpp MessageBoard::Execute). DEFERRED:
// the per-player status queue (StatusMessagePool, btstubs.cpp:62) is a NULL stub and
// BTPlayer+0x1dc is never populated, so there are no messages -> return False (empty
// board). Real impl (when StatusMessagePool is wired): owningPlayer (mech+0x190) ->
// status record (+0x1dc) -> {messageId@+0xc, nameEntity@+0x10 -> name bitmap} via the
// compiled BTPlayer/Player__StatusMessage members (decode AddStatusMessage @0042e580).
// A real (stub) definition is REQUIRED or the /FORCE link AVs MessageBoard::Execute's call.
class BitMap;
// MessageBoard feed (LIVE 2026-07-12): the board is the LOCAL cockpit's comm
// ticker -- read the mission player's engine status queue. statusMessagePointer
// is maintained per frame by Player::StatusMessageUpdate [T0 PLAYER.cpp:520/822]:
// it holds the newest queued Player__StatusMessage until its 6s displayTime runs
// out. The name bitmap resolves through the SAME small-raster chain the radar
// labels use (Mission::GetSmallNameBitmap keyed by the involved player's egg
// bitmapindex).
Logical BTResolveMessageBoard(Entity * /*tracked_mech*/, int *messageId, BitMap **nameBitmap)
{
*messageId = -1;
*nameBitmap = 0;
BTPlayer *local_player = (application != 0)
? (BTPlayer *)application->GetMissionPlayer() : 0;
if (local_player == 0)
{
return False;
}
Player::StatusMessage *m = local_player->statusMessagePointer;
if (m == 0)
{
return False;
}
*messageId = m->messageType;
Player *involved = m->playerInvolved;
if (involved != 0)
{
Mission *mission = application->GetCurrentMission();
if (mission != 0)
*nameBitmap = mission->GetSmallNameBitmap(involved->playerBitmapIndex);
}
return True;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// gauge scoring wave: PRODUCERS -- posted by the combat path (mech4.cpp) to feed
// the scoreboard. The inflictor is always the viewpoint mech in bring-up, so the
// local (crediting) player is application->GetMissionPlayer(). Bridges (not inline
// in mech4.cpp) so the message construction lives in this complete-BTPlayer TU.
//
void BTPostDamageScore(Entity *victim, Scalar damage) // Step 6: per-hit SCORE
{
if (application == 0 || victim == 0)
{
return;
}
BTPlayer *local_player = (BTPlayer *)application->GetMissionPlayer();
if (local_player == 0)
{
return;
}
// ScoreInflicted (scoreType 0): senderMechID = the mech that TOOK the damage.
// -> ScoreInflictedMessageHandler: currentScore += tonnageRatio*CalcInflictedScore.
BTPlayer::ScoreMessage message(
BTPlayer::ScoreInflictedMessageID, // 0x16
sizeof(BTPlayer::ScoreMessage),
BTPlayer::ScoreMessage::DamageInflictedScore, // 0
0.0f, // scoreAward (unused for inflicted)
damage, // damageAmount
victim->GetEntityID()); // senderMechID = victim
local_player->Dispatch(&message);
if (getenv("BT_SCORE_LOG"))
DEBUG_STREAM << "[score] +damage " << damage << " -> currentScore="
<< (Scalar)local_player->GetScore() << "\n" << std::flush;
}
void BTPostKillScore(Entity *victim, Scalar damage) // Step 7: KILL (+ MP death)
{
if (application == 0 || victim == 0)
{
return;
}
// OBSERVED-KILL credit (MP KILLS fix, 2026-07-13; the symmetric twin of
// BTPlayerCountObservedDeath): every node maintains LOCAL score copies,
// so the kill must land on the KILLER's player-link object -- the same
// object the PilotList gauge reads (the roster resolves pilots via the
// mechs' GetPlayerLink()). The old GetMissionPlayer() dispatch credited
// a copy the scoreboard never displays (kills stuck at 0 while deaths --
// which already flow through the player link -- counted; user-reported).
// Resolving the killer from the victim's lastInflictingID also credits
// the REMOTE player's local copy when THEY score ("target's kills
// display 0") -- each node witnesses the death transition and tallies
// its own copies self-consistently.
BTPlayer *killer_player = 0;
if (application->GetHostManager() != 0)
{
Entity *killer = application->GetHostManager()->GetEntityPointer(
((Mech *)victim)->lastInflictingID);
extern int BTIsRegisteredMech(Entity *e);
if (killer != 0 && killer != victim && BTIsRegisteredMech(killer))
killer_player = (BTPlayer *)((Mech *)killer)->GetPlayerLink();
}
if (killer_player != 0)
{
// KillScore (scoreType 2): senderMechID MUST be the VICTIM (!= the
// receiver's mech) so the handler credits `killCount++` -- a suicide
// (killer == victim) never reaches here.
BTPlayer::ScoreMessage kill(
Player::ScoreMessageID, // 0x12
sizeof(BTPlayer::ScoreMessage),
BTPlayer::ScoreMessage::KillScore, // 2
0.0f, // scoreAward (killBonus; 0 for bring-up)
damage, // damageAmount (killing-blow)
victim->GetEntityID()); // senderMechID = victim
killer_player->Dispatch(&kill);
if (getenv("BT_SCORE_LOG"))
DEBUG_STREAM << "[score] *** KILL *** killerPlayer=" << (void *)killer_player
<< " killCount=" << killer_player->GetKillCount()
<< " deaths=" << killer_player->GetDeaths()
<< " score=" << (Scalar)killer_player->GetScore() << std::endl;
}
// MP DEATH: credit a death to the VICTIM's own player. NULL for the solo
// BT_SPAWN_ENEMY dummy (GetPlayerLink()==0) -> skipped, so DEATHS stays 0 in
// solo (authentic -- DEATHS only lands on a real pilot in multiplayer).
BTPlayer *victim_player = (BTPlayer *)((Mech *)victim)->GetPlayerLink();
if (victim_player != 0)
{
Player::VehicleDeadMessage dead(
Player::VehicleDeadMessageID, // 0x13
sizeof(Player::VehicleDeadMessage));
victim_player->Dispatch(&dead);
}
}
//#############################################################################
// BTPlayerRoleLocksAdvanced -- complete-type bridge (task #12)
//
// FUN_004ac9c8 [T1]: `return *(*(*(sub+0xD0) + 0x190) + 0x274) == 0` -- the
// subsystem's owner Mech -> the owning BTPlayer (mech+0x190, GetPlayerLink)
// -> +0x274 = the raw EXPERIENCE level (egg "experience", mission+0xe4; the
// old "roleClassIndex / role resource" reading was wrong -- see
// context/experience-levels.md). TRUE = NOVICE experience LOCKS the advanced
// cockpit systems (generator routing, coolant valves); standard and above
// unlock them. The old reconstruction mislabeled this fn
// "Subsystem::IsDamaged" -- with healthy subsystems at simulationState==1,
// that stand-in gated the power-routing handlers OFF permanently. A NULL
// player (the target dummy / unbound solo mech) would AV in the binary (every
// pod mech has a player); the port reads NULL as UNLOCKED so dev rigs work [T3].
//
int BTPlayerRoleLocksAdvanced(void *owner_mech)
{
if (owner_mech == 0)
return 0;
BTPlayer *player = MECH_OWNING_PLAYER(owner_mech);
if (player == 0)
return 0; // dev-permissive [T3]
return (player->experienceLevel == 0) ? 1 : 0; // player+0x274 == 0
}
//#############################################################################
// BTPlayerExperienceSimLive -- complete-type bridge (issue #2)
//
// The player+0x25c "sim live" flag [T1]: 0 only for NOVICE experience. Binary
// consumers reached through the owner mech's playerLink (mech+0x190):
// * CheckForJam @004bbfcc (projweap.cpp LiveFireEnabled) -- no jam rolls;
// * Searchlight::ToggleLamp @004b860c (searchlight.hpp ControlsAllowLights);
// * PoweredSubsystem::HandleMessage @004b0efc short-event path (powersub.cpp).
// A NULL player (target dummy / unbound dev mech) reads as LIVE so dev rigs
// keep today's behavior [T3]; the binary derefs unguarded.
//
int BTPlayerExperienceSimLive(void *owner_mech)
{
if (owner_mech == 0)
return 1; // dev-permissive [T3]
BTPlayer *player = MECH_OWNING_PLAYER(owner_mech);
if (player == 0)
return 1; // dev-permissive [T3]
return player->simLive ? 1 : 0; // player+0x25c
}
//#############################################################################
// BTPlayerExperienceHeatModelOn -- complete-type bridge (issue #2)
//
// FUN_004ad7d4 [T1]: `return *(*(*(sub+0xD0)+0x190)+0x260)` -- the HEAT-MODEL
// master switch, ON only for VETERAN and EXPERT experience. The single
// most-called gate in the subsystem family: HeatSink::HeatModelActive()
// (heat.hpp -- weapon-fire heat, the heat.cpp simulation, coolant), missile
// launch heat (mislanch.cpp), projectile firing heat (projweap.cpp), and
// Myomers::OwnerAdvancedDamage() (movement heat, myomers.hpp). Standard mode
// has NO heat consequences -- authentic, not a port gap. A NULL player reads
// as ON (today's permissive stub behavior) so dev rigs are unchanged [T3].
//
int BTPlayerExperienceHeatModelOn(void *owner_mech)
{
if (owner_mech == 0)
return 1; // dev-permissive [T3]
BTPlayer *player = MECH_OWNING_PLAYER(owner_mech);
if (player == 0)
return 1; // dev-permissive [T3]
return player->heatModelOn ? 1 : 0; // player+0x260
}
//#############################################################################
// BTPlayerObjectiveMechOf -- complete-type bridge (task #17)
//
// The radar map's target resolve: *(*(owner+0x190)+0x284) = the owning
// BTPlayer's objectiveMech (the designated target; the same slot the
// power-routing valve gate reads past). btl4rdr.cpp can't include
// btplayer.hpp (TU type collisions), so it calls through this bridge.
//
Entity *BTPlayerObjectiveMechOf(void *player)
{
if (player == 0)
return 0;
return (Entity *)((BTPlayer *)player)->GetObjectiveMech(); // @0x284
}