Kills/deaths only ever appeared on ONE machine. The cause is not a missing
tally -- it is Entity::Dispatch:
if (GetInstance() == ReplicantInstance) // ENTITY.cpp:244-251
application->SendMessage(ownerID, EntityManagerClientID, message);
BTPostKillScore runs on the VICTIM's node (the only node whose mech carries a
populated lastInflictingID), resolves the killer's Player -- a REPLICANT there --
and Dispatch()es to it. The engine reroutes that to the owning host, so
++killCount lands on the killer's own PC and nowhere else, and nothing carried it
back out: Player__UpdateRecord is currentScore + dropZoneLocation only. Every
other pod's copy therefore read 0 all mission. playerLink was never NULL for
these kills -- the dispatch proves it resolved.
Field proof over the whole corpus (255 node-logs): 125 of 125 SCORE type=2 rows
credit the LOGGING node's own player, not one credits a remote pilot; and 0 of
8800 DMG rows target a replicant, so no other node can even know the killer.
This means the owner's counter is already the single authoritative copy,
incremented exactly once per kill. So the "design decision" the plan was blocked
on collapses: there is no second writer to reconcile, only a one-way
owner->replicant mirror to add.
* BTPlayer__UpdateRecord = Player::UpdateRecord + killTally + deathTally, with
Read/WriteUpdateRecord overrides. No new data member, no new virtual ->
sizeof(BTPlayer)==0x28c and every existing offset lock still holds.
* Both counter writes already ForceUpdate() (:508 death, :829/:840 score), so
no dirty-bit edit was needed (plan risk 6 avoided).
* recordLength guard in ReadUpdateRecord: a pod on a build without the
extension degrades to "remote counters don't move" instead of reading a
neighbouring record as a kill count.
* Size locks added per plan risk 2 (no false offsetof assert).
Rig-verified, 2-node loopback (scratchpad/rig45_up.ps1, BT_MP_FORCE_DMG):
owner 3:1 finished kills 3/deaths 2 and the peer read kills=3 deaths=2; owner 2:1
finished kills 2/deaths 3 and the peer read kills=2 deaths=3. Exact convergence
where a remote column previously never left 0. 3 respawns per side with intact
death sequences (deathCount is only overwritten on replicant copies; the
handshake runs on masters), no crash, no GLITCH rows.
Kept byte-faithful: the kill handler's partner increment (the binary's
inc [ebx+0x27c] / inc [edx+0x27c] wrong-column slip) is still reproduced. It
always lands on a replicant copy, so the owner's record now overwrites it -- the
rig caught the correction repeatedly (wasKills=3 -> kills=2). The phantom kill
stops being visible without silently deciding the fidelity question.
Still open and deliberately untouched: which field the LOCAL pod's DEATHS column
should read (+0x280 vs deathCount), last-hitter-takes-all/ram kills, and the
slip's fidelity. Awaiting live multi-pod verification by a human.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2104 lines
83 KiB
C++
2104 lines
83 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");
|
|
|
|
//
|
|
// 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)
|
|
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;
|
|
}
|
|
|
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
// 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).
|
|
//
|
|
// Side effect worth knowing: the binary's kill handler also bumps killCount on
|
|
// the VICTIM's player (the @0x4c0397/@0x4c03a3 wrong-column slip we reproduce
|
|
// faithfully). That partner increment always lands on a REPLICANT copy of the
|
|
// victim, so the owner's authoritative value now overwrites it on the next
|
|
// record -- the phantom kill stops being visible without touching the
|
|
// reconstructed handler.
|
|
//
|
|
void
|
|
BTPlayer::WriteUpdateRecord(Simulation__UpdateRecord *message, int update_model)
|
|
{
|
|
Player::WriteUpdateRecord(message, update_model);
|
|
|
|
UpdateRecord *rec = (UpdateRecord *)message;
|
|
rec->recordLength = sizeof(UpdateRecord);
|
|
rec->killTally = killCount;
|
|
rec->deathTally = deathCount;
|
|
}
|
|
|
|
void
|
|
BTPlayer::ReadUpdateRecord(Simulation__UpdateRecord *message)
|
|
{
|
|
Player::ReadUpdateRecord(message);
|
|
|
|
UpdateRecord *rec = (UpdateRecord *)message;
|
|
|
|
//
|
|
// MIXED-VERSION GUARD: a peer on a build without this extension writes a
|
|
// plain Player-sized record, and reading the two fields off the end would
|
|
// pick up whatever record was packed after it in the update stream and
|
|
// display it as a kill count. Trust the length the writer stamped.
|
|
//
|
|
if (rec->recordLength < sizeof(UpdateRecord))
|
|
{
|
|
return;
|
|
}
|
|
|
|
int was_kills = killCount;
|
|
int was_deaths = deathCount;
|
|
|
|
killCount = rec->killTally;
|
|
deathCount = 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 || deathCount != was_deaths)
|
|
{
|
|
if (BTMatchLogActive())
|
|
BTMatchLog("SBMIRROR",
|
|
"player=%d:%d kills=%d deaths=%d wasKills=%d wasDeaths=%d",
|
|
BTMatchHostOf(GetEntityID()), (int)GetEntityID(),
|
|
killCount, deathCount, was_kills, was_deaths);
|
|
if (getenv("BT_SCORE_LOG"))
|
|
DEBUG_STREAM << "[score] scoreboard mirror: pilot "
|
|
<< (int)GetEntityID() << " kills " << was_kills << "->"
|
|
<< killCount << " deaths " << was_deaths << "->" << 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;
|
|
// * 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
|
|
}
|