//===========================================================================// // 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::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 #include // MP match forensics (VEHICLE/PLAYER_DEAD/SCORE) #pragma hdrstop #if !defined(BTPLAYER_HPP) # include #endif #if !defined(BTTEAM_HPP) # include #endif #if !defined(BTMSSN_HPP) # include #endif #if !defined(APP_HPP) # include #endif #if !defined(HOSTMGR_HPP) # include #endif #if !defined(BTCNSL_HPP) # include #endif #if !defined(NTTMGR_HPP) # include // EntityManager / EntityGroup / FindGroup #endif #if !defined(SCALAR_HPP) # include // Round() #endif #if !defined(DROPZONE_HPP) # include // DropZone__ReplyMessage (spawn handshake) #endif #if !defined(MISSION_HPP) # include // Mission::GetGameModel / GetBadgeName / GetColorName #endif #if !defined(RESOURCE_HPP) # include // ResourceFile / ResourceDescription / ModelListResourceType #endif #if !defined(MECH_HPP) # include // 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(offsetof(BTPlayer, deathTally) == 0x274, "BTPlayer::deathTally moved -- it is the binary's DEATHS column (@0x280) that " "Gitea #45 put back into service; re-check GetDeaths/BTPilotDeaths and the " "update-record mirror"); static_assert(sizeof(BTPlayer) == 0x28c, "BTPlayer size changed -- re-measure the offsets above before trusting any " "raw-offset code that touches this class"); // // Scoreboard replication (Gitea #45). There is no offsetof lock to make here // -- our BTPlayer is not offset-faithful -- so lock what IS real: the record // must stay strictly larger than the base (or the two counters are not being // carried at all) and must stay exactly two ints wider (the update stream is // a single fixed 1400-byte buffer, ENTITY.cpp:472-474, and this record must // not become the precedent for a fat player record). // static_assert(sizeof(BTPlayer::UpdateRecord) > sizeof(Player::UpdateRecord), "BTPlayer::UpdateRecord no longer extends Player::UpdateRecord -- the " "KILLS/DEATHS columns would silently stop replicating (Gitea #45)"); static_assert(sizeof(BTPlayer::UpdateRecord) == sizeof(Player::UpdateRecord) + 2 * sizeof(int), "BTPlayer::UpdateRecord grew beyond the two scoreboard counters -- " "re-check the 1400-byte update buffer before widening the player record"); } static const char *SelfDestructName = "self destruct"; // &DAT_00524b38 static const Scalar RespawnDelay = 5.0f; // death -> drop-zone hunt (@004c0830) // #45: how often a master re-dirties its update record so the replicated // KILLS/DEATHS pair reconverges after a dropped (unreliable) update datagram. static const Scalar ScoreboardHeartbeat = 2.0f; 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 // // #45: the SCOREBOARD deaths tally is the binary's own +0x280, not the engine's // deathCount above. deathCount stays what the engine made it -- the respawn // handshake identity (seeded -2, matched at PLAYER.cpp:230) -- so it must not be // the number we display or replicate. This is the one the PilotList draws and // the one that rides the update record to every other pod. // ++deathTally; // this+0x280 // MP MATCH FORENSICS (matchlog.hpp): the owning player's death // notification (fires exactly once per death, deduped above). if (BTMatchLogActive()) // #45: log BOTH counters. `deaths` is the engine's respawn-handshake // deathCount (seeded -2); `tally` is the binary's +0x280 scoreboard column // that the gauge draws and the update record replicates. Having them side // by side is what makes a divergence between them visible at all. BTMatchLog("PLAYER_DEAD", "player=%d:%d deaths=%d tally=%d", BTMatchHostOf(GetEntityID()), (int)GetEntityID(), deathCount, deathTally); // 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 // // SCOREBOARD HEARTBEAT (#45). The KILLS/DEATHS mirror rides the update record, // and update records are UNRELIABLE BY CONSTRUCTION: `Entity::UpdateMessage` // clears `ReliableFlag` (ENTITY3.h:112) and the relay's UDP path additionally // drops stale/reordered datagrams behind a per-sender sequence gate. The pair is // only dirtied on a score/death EVENT, so one lost datagram would leave every // peer's copy of this pilot's columns stale until their NEXT kill or death -- // which, for the last kill of a round, is forever. Re-dirty the record at a low // fixed rate so the pair reconverges within a beat. This is also what bounds the // visibility of the binary's phantom partner increment (the wrong-column slip we // still reproduce faithfully): the owner's authoritative value overwrites it // within one beat instead of whenever the pilot next scores. // // Cheap: a Player has no 60Hz stream of its own -- it emits a record only when // dirtied -- and the record is 68 bytes. The timer is a function static ON // PURPOSE: a new data member would change `sizeof(BTPlayer)` and break the offset // locks. A node pilots exactly one master player, so a shared timer is exact // here; with several it would simply beat in turn. // if (GetInstance() != Entity::ReplicantInstance) { static Scalar s_scoreboard_beat = 0.0f; s_scoreboard_beat += time_slice; if (s_scoreboard_beat >= ScoreboardHeartbeat) { s_scoreboard_beat = 0.0f; ForceUpdate(); // updateModel |= DefaultUpdateModelFlag } } 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() : "") << "' 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; // #55 step 0: prove the link in the matchlog -- a lost PlayerLink race // (the David chain) was previously invisible. BTMatchLog("PLAYER_LINK", "player=%d:%d vehicle=%d:%d", BTMatchHostOf(GetEntityID()), (int)GetEntityID(), BTMatchHostOf(playerVehicle->GetEntityID()), (int)playerVehicle->GetEntityID()); Check_Fpu(); } //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // 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] deathPending = 0; // #55 step 1: a latch carried into a // fresh spawn would permanently kill // every later respawn (the #57 class) // (warp fired in the shared placement below -- initial drop-in + respawn) } else if (deathCount == message->deathCount) // param_2[0xe] == param_1[0x80] { 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) << " deathTally@0x" << (int)offsetof(BTPlayer, deathTally) << " 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 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] deathTally = 0; // this[0xa0] (#45: the binary's DEATHS column @0x280) 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; // #45: GetDeaths() is now the binary's own +0x280 tally, which starts at 0 and // only ever counts up, so the clamp no longer has a -2 engine seed to hide (it // used to read Player::deathCount, the respawn handshake identity). Kept as a // cheap floor in case a record ever arrives before the owner's first write. 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 deathTally @ 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: DELETED 2026-07-25 (Gitea #45) together with its call site // (mech4.cpp) and its `friend` (btplayer.hpp) -- all three at once, because /FORCE // would otherwise hide a straggler. It never executed (its call site sat inside the // once-per-death TRANSITION, which a replicant never enters) and it could not have // worked (a replicant victim carries no attribution: 0 of 18818 corpus DMG rows are // inst=R, and 439 of 439 DEATH inst=R rows read killer=0:0). DEATHS is now replicated from the owning pod instead. Do not // resurrect it: under replication it would be a SECOND writer of a replicated counter, // and it incremented `Player::deathCount`, which the scoreboard no longer even reads. //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // WriteUpdateRecord / ReadUpdateRecord (Gitea #45) // // SCOREBOARD REPLICATION -- port record extension; the full root-cause banner is // on the record struct in btplayer.hpp. In one line: a kill is credited by // `BTPostKillScore` on the VICTIM's node, which Dispatch()es to the killer's // Player -- a replicant there -- and `Entity::Dispatch` reroutes a replicant's // message to the master (ENTITY.cpp:244-251), so `++killCount` lands on the // killer's OWN machine only. Nothing carried it back out, so every other pod's // copy read 0 all mission. The owner's counter is therefore already the single // authoritative value; this is a plain one-way owner->replicant mirror. // // Sends for free: both counter writes already dirty the record -- // `ForceUpdate()` on the death path (:508) and on every score award (:829/:840). // // The binary's phantom partner increment, stated ACCURATELY. Its kill handler // also bumps killCount on the VICTIM's player (the @0x4c0397/@0x4c03a3 // wrong-column slip, which we still reproduce byte-faithfully). That increment // always lands on a REPLICANT copy of the victim, so the owner's record corrects // it -- but NOT, as an earlier draft of this comment claimed, by arriving after // it. The engine's event priorities run the other way: an inbound update record // is posted at UpdateEventPriority == MaxEventPriority (INTEREST.cpp) and drained // by ExecuteBackgroundTask, while the rerouted score message is posted at // EntityManagerEventPriority == DefaultEventPriority (NTTMGR.cpp) and popped // later, so the correction usually lands FIRST and the phantom lands after it. // The artifact is therefore BOUNDED, not masked: on the killer's pod only, that // victim's KILLS reads +1 until the next record -- at most one heartbeat (2s) // rather than "until the victim next scores or dies". Deciding whether to drop // the increment outright is the open fidelity question; see docs/RECONCILE.md. // void BTPlayer::WriteUpdateRecord(Simulation__UpdateRecord *message, int update_model) { Player::WriteUpdateRecord(message, update_model); // // Append the counters ONLY to the default-model record. `update_model` is a // BIT INDEX, and Entity::WriteUpdateRecord switches on it (ENTITY.cpp:329-352): // the DamageZone bit emits a variable-length packed stream of DamageZone // records whose length it computes itself, so appending two ints and // re-stamping recordLength over THAT would corrupt the record. A pilot only // ever sets the default bit today (plain `ForceUpdate()`), which is why the // engine's own Player::WriteUpdateRecord gets away with being bit-agnostic -- // but a future `ForceUpdate(mask)` on a player would make this silently wrong, // and skipping here degrades safely: the reader's recordLength guard simply // leaves the counters alone. // if (update_model != Simulation::DefaultUpdateModelBit) { return; } UpdateRecord *rec = (UpdateRecord *)message; rec->recordLength = sizeof(UpdateRecord); rec->killTally = killCount; // @0x27c rec->deathTally = deathTally; // @0x280 -- NOT the engine's deathCount } void BTPlayer::ReadUpdateRecord(Simulation__UpdateRecord *message) { Player::ReadUpdateRecord(message); UpdateRecord *rec = (UpdateRecord *)message; // // MIXED-VERSION GUARD -- and the reason is NOT stream desync. Both sides // advance by the WRITER's stamped recordLength (ENTITY.cpp:390 / // SIMULATE.cpp:323) and no reader asserts a length, so an old 60-byte record // read by this 68-byte reader keeps the stream perfectly in sync. The real // hazard is a READ PAST THE ALLOCATION: an inbound update message is copied // into a per-event heap block sized from messageLength (EntityManager's // ReceiveNetworkPacket -> application->Post -> new long[(len+3)>>2]), so on a // TRAILING legacy record these two fields would be read off the end of that // block -- garbage displayed as a kill count, or worse. Trust the length the // writer stamped. // if (rec->recordLength < sizeof(UpdateRecord)) { return; } // // Only ever mirror onto a REPLICANT. The owning master holds the sole // authoritative copy of both counters -- it is the only node the engine ever // increments them on -- so a record must never be allowed to write back over // it. ReadUpdateRecord is a replicant-side path today, but this is the // invariant the whole design rests on, so state it rather than assume it. // if (GetInstance() != Entity::ReplicantInstance) { return; } int was_kills = killCount; int was_deaths = deathTally; killCount = rec->killTally; deathTally = rec->deathTally; // // Log only the EDGE -- this runs at update rate, so an unconditional line // would drown the log (and the matchlog) in per-frame noise. // if (killCount != was_kills || deathTally != was_deaths) { if (BTMatchLogActive()) BTMatchLog("SBMIRROR", "player=%d:%d kills=%d deaths=%d wasKills=%d wasDeaths=%d", BTMatchHostOf(GetEntityID()), (int)GetEntityID(), killCount, deathTally, was_kills, was_deaths); if (getenv("BT_SCORE_LOG")) DEBUG_STREAM << "[score] scoreboard mirror: pilot " << (int)GetEntityID() << " kills " << was_kills << "->" << killCount << " deaths " << was_deaths << "->" << deathTally << "\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; } // KILL credit. Runs on the VICTIM's node -- the only node whose mech carries a // populated `lastInflictingID` (damage is applied master-side only: 0 of 8800 // corpus DMG rows target a replicant, which is why every `DEATH inst=R` row reads // `killer=0:0`). The credit is dispatched to the killer's Player, which HERE is a // REPLICANT, so `Entity::Dispatch` reroutes it to the owning host // (ENTITY.cpp:244-251) and `++killCount` lands on the killer's OWN machine. // // CORRECTED 2026-07-25 (Gitea #45). The previous banner claimed "every node // maintains LOCAL score copies ... each node witnesses the death transition and // tallies its own copies self-consistently". That was FALSE and it is what hid // this bug: the reroute means exactly ONE node increments, no other node can // observe the killer at all, and nothing carried the value back out -- so every // remote pilot's KILLS read 0 on every other pod, all mission (corpus invariant: // 0 of 18818 DMG rows are inst=R, so no other node can even attribute the kill; // every DEATH inst=R row reads killer=0:0). The counters are // now replicated owner->replicant instead; see Read/WriteUpdateRecord above. 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; } else if (BTMatchLogActive()) { // // #45 observability: the credit was SKIPPED. This used to be completely // silent, which is why the bug survived so long -- the counter simply never // moved and no log said why. Record which link in the chain broke so a // single matchlog convicts it: a missing killer entity, an unregistered // mech, a self-kill, or the NULL playerLink hazard. // Entity *k = (application->GetHostManager() != 0) ? application->GetHostManager()->GetEntityPointer( ((Mech *)victim)->lastInflictingID) : 0; extern int BTIsRegisteredMech(Entity *e); BTMatchLog("NOCREDIT", "victim=%d:%d killerID=%d:%d found=%d self=%d registered=%d link=%d", BTMatchHostOf(victim->GetEntityID()), (int)victim->GetEntityID(), BTMatchHostOf(((Mech *)victim)->lastInflictingID), (int)((Mech *)victim)->lastInflictingID, (int)(k != 0), (int)(k == victim), (int)(k != 0 && BTIsRegisteredMech(k)), (int)(k != 0 && ((Mech *)k)->GetPlayerLink() != 0)); } // 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; // * ThermalSight::ToggleLamp @004b860c (thermalsight.hpp ControlsAllowLights) // -- and NOT Searchlight, whose own handler @004b838c has no such gate // (raw disasm, #61: it tests the press only), so novices keep the lamp; // * 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 }