//===========================================================================//
// 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>
#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");

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(); }
		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(); }
				if (getenv("BT_SCORE_LOG"))
					DEBUG_STREAM << "[score] VehicleDead: no DropZones group -- "
						"respawn unavailable in this mission\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)
	{
		Check_Fpu();
		return;
	}
	deathPending = 1;							// this+0x290

	// 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
	// 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);
	}

	simulationFlags |= 0x1;						// request a forced update
	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
}

//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//					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
	if (playerVehicle == 0)
	{
		currentScore += message->scoreAward;		// the base's award apply
		return;
	}
	Player::ScoreMessageHandler(message);			// FUN_0042da20
}

//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//					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
			);
		Check(mech_res);
		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);
	}

	// --- 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);
	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
		{
			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()))
		{
			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)

		// 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]

	//
	// 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, cache the application's mission/registry pointer
	// (application+0xc8 in the binary; no GetMissionRegistry accessor in the
	// WinTesla Application, so read through the documented offset).
	//
	if ((simulationFlags & 0xc) == 4 && btMission == 0)		// this+0x28, this[0x7e]
	{
		btMission = (Mission *)*(void **)((char *)application + 0xc8);	// 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 = btMission->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: choose the HUD display toggles from the role class index.
		//
		// TODO(bring-up): scenarioRole is NULL here because the role lookup above
		// (btMission->GetRoleRegistry()->Lookup) is still stubbed out. Guard the deref
		// so mission load proceeds; default index 2 = show all stats (freeforall).
		roleClassIndex = scenarioRole ? scenarioRole->GetReturnFromDeath() : 2;	// this[0x9d] <- role+0xe4
		switch (roleClassIndex)
		{
		case 0:
			showKills = showDamageReceived = showDamageInflicted = showScore = 0;
			break;
		case 1:
			showKills = 0;
			showDamageReceived = showDamageInflicted = showScore = 1;
			break;
		case 2:
			showKills = showDamageReceived = showDamageInflicted = showScore = 1;
			break;
		case 3:
			showKills = showDamageReceived = showDamageInflicted = 1;
			showScore = 0;
			break;
		}

		// role+0xf0 ("returnDelay") has no field in the WinTesla ScenarioRole;
		// initialised to 0 here.  CROSS-FAMILY -- see report.  BEST-EFFORT.
		roleReturnDelay = roleReturnDelay2 = 0.0f;		// this[0x99],[0x9a] <- role+0xf0
	}

	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;
}

// OBSERVED-DEATH tally (MP DEATHS fix): each node maintains every player's
// score from LOCALLY OBSERVED events -- exactly how the KILLS credit already
// works (the victim's ScoreMessage handler ++s the SHOOTER's local Player
// copy).  This is the symmetric half: when a REPLICANT mech dies here (the
// once-per-death transition runs on every node), tally the death onto its
// owning player's LOCAL copy.  The owner's own node counts through its real
// VehicleDead(-1) path, so the caller gates this to replicant mechs -- each
// node then counts every pilot self-consistently.  Normalize the -2/-1
// pre-acquire seed first so death #1 reads 1.
void BTPlayerCountObservedDeath(void *player)
{
	if (player == 0)
		return;
	BTPlayer *p = (BTPlayer *)player;
	if (p->deathCount < 0)
		p->deathCount = 0;
	++p->deathCount;
	if (getenv("BT_SCORE_LOG"))
		DEBUG_STREAM << "[score] observed death -> pilot " << player
			<< " deaths=" << p->deathCount << "\n" << std::flush;
}

// Is `pilot` the owner of `local_player`'s current target? (the SELECT-TARGET row
// highlight).  Computed here via accessors -- the PilotList's raw-offset version
// (local+0x284 objectiveMech, then deref target+0x190) reads garbage in our layout
// and AV'd inside the SEH-guarded Execute, silently aborting it BEFORE the KILLS/
// DEATHS draw.  GetPlayerLink() is NULL for the ownerless dummy target -> not selected.
int BTPilotIsSelected(void *pilot, void *local_player)
{
	if (pilot == 0 || local_player == 0)
	{
		return 0;
	}
	Mech *target = ((BTPlayer *)local_player)->GetObjectiveMech();		// @0x284
	if (target == 0)
	{
		return 0;
	}
	return ((void *)((BTPlayer *)pilot) == (void *)target->GetPlayerLink()) ? 1 : 0;
}

// MessageBoard data bridge (consumed by btl4gau3.cpp MessageBoard::Execute).  DEFERRED:
// the per-player status queue (StatusMessagePool, btstubs.cpp:62) is a NULL stub and
// BTPlayer+0x1dc is never populated, so there are no messages -> return False (empty
// board).  Real impl (when StatusMessagePool is wired): owningPlayer (mech+0x190) ->
// status record (+0x1dc) -> {messageId@+0xc, nameEntity@+0x10 -> name bitmap} via the
// compiled BTPlayer/Player__StatusMessage members (decode AddStatusMessage @0042e580).
// A real (stub) definition is REQUIRED or the /FORCE link AVs MessageBoard::Execute's call.
class BitMap;
// MessageBoard feed (LIVE 2026-07-12): the board is the LOCAL cockpit's comm
// ticker -- read the mission player's engine status queue.  statusMessagePointer
// is maintained per frame by Player::StatusMessageUpdate [T0 PLAYER.cpp:520/822]:
// it holds the newest queued Player__StatusMessage until its 6s displayTime runs
// out.  The name bitmap resolves through the SAME small-raster chain the radar
// labels use (Mission::GetSmallNameBitmap keyed by the involved player's egg
// bitmapindex).
Logical BTResolveMessageBoard(Entity * /*tracked_mech*/, int *messageId, BitMap **nameBitmap)
{
	*messageId  = -1;
	*nameBitmap = 0;
	BTPlayer *local_player = (application != 0)
		? (BTPlayer *)application->GetMissionPlayer() : 0;
	if (local_player == 0)
	{
		return False;
	}
	Player::StatusMessage *m = local_player->statusMessagePointer;
	if (m == 0)
	{
		return False;
	}
	*messageId = m->messageType;
	Player *involved = m->playerInvolved;
	if (involved != 0)
	{
		Mission *mission = application->GetCurrentMission();
		if (mission != 0)
			*nameBitmap = mission->GetSmallNameBitmap(involved->playerBitmapIndex);
	}
	return True;
}

//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// gauge scoring wave: PRODUCERS -- posted by the combat path (mech4.cpp) to feed
// the scoreboard.  The inflictor is always the viewpoint mech in bring-up, so the
// local (crediting) player is application->GetMissionPlayer().  Bridges (not inline
// in mech4.cpp) so the message construction lives in this complete-BTPlayer TU.
//
void BTPostDamageScore(Entity *victim, Scalar damage)		// Step 6: per-hit SCORE
{
	if (application == 0 || victim == 0)
	{
		return;
	}
	BTPlayer *local_player = (BTPlayer *)application->GetMissionPlayer();
	if (local_player == 0)
	{
		return;
	}
	// ScoreInflicted (scoreType 0): senderMechID = the mech that TOOK the damage.
	// -> ScoreInflictedMessageHandler: currentScore += tonnageRatio*CalcInflictedScore.
	BTPlayer::ScoreMessage message(
		BTPlayer::ScoreInflictedMessageID,				// 0x16
		sizeof(BTPlayer::ScoreMessage),
		BTPlayer::ScoreMessage::DamageInflictedScore,	// 0
		0.0f,											// scoreAward (unused for inflicted)
		damage,											// damageAmount
		victim->GetEntityID());							// senderMechID = victim
	local_player->Dispatch(&message);
	if (getenv("BT_SCORE_LOG"))
		DEBUG_STREAM << "[score] +damage " << damage << " -> currentScore="
			<< (Scalar)local_player->GetScore() << "\n" << std::flush;
}

void BTPostKillScore(Entity *victim, Scalar damage)			// Step 7: KILL (+ MP death)
{
	if (application == 0 || victim == 0)
	{
		return;
	}
	// OBSERVED-KILL credit (MP KILLS fix, 2026-07-13; the symmetric twin of
	// BTPlayerCountObservedDeath): every node maintains LOCAL score copies,
	// so the kill must land on the KILLER's player-link object -- the same
	// object the PilotList gauge reads (the roster resolves pilots via the
	// mechs' GetPlayerLink()).  The old GetMissionPlayer() dispatch credited
	// a copy the scoreboard never displays (kills stuck at 0 while deaths --
	// which already flow through the player link -- counted; user-reported).
	// Resolving the killer from the victim's lastInflictingID also credits
	// the REMOTE player's local copy when THEY score ("target's kills
	// display 0") -- each node witnesses the death transition and tallies
	// its own copies self-consistently.
	BTPlayer *killer_player = 0;
	if (application->GetHostManager() != 0)
	{
		Entity *killer = application->GetHostManager()->GetEntityPointer(
			((Mech *)victim)->lastInflictingID);
		extern int BTIsRegisteredMech(Entity *e);
		if (killer != 0 && killer != victim && BTIsRegisteredMech(killer))
			killer_player = (BTPlayer *)((Mech *)killer)->GetPlayerLink();
	}
	if (killer_player != 0)
	{
		// KillScore (scoreType 2): senderMechID MUST be the VICTIM (!= the
		// receiver's mech) so the handler credits `killCount++` -- a suicide
		// (killer == victim) never reaches here.
		BTPlayer::ScoreMessage kill(
			Player::ScoreMessageID,						// 0x12
			sizeof(BTPlayer::ScoreMessage),
			BTPlayer::ScoreMessage::KillScore,			// 2
			0.0f,										// scoreAward (killBonus; 0 for bring-up)
			damage,										// damageAmount (killing-blow)
			victim->GetEntityID());						// senderMechID = victim
		killer_player->Dispatch(&kill);
		if (getenv("BT_SCORE_LOG"))
			DEBUG_STREAM << "[score] *** KILL *** killerPlayer=" << (void *)killer_player
				<< " killCount=" << killer_player->GetKillCount()
				<< " deaths=" << killer_player->GetDeaths()
				<< " score=" << (Scalar)killer_player->GetScore() << std::endl;
	}

	// MP DEATH: credit a death to the VICTIM's own player.  NULL for the solo
	// BT_SPAWN_ENEMY dummy (GetPlayerLink()==0) -> skipped, so DEATHS stays 0 in
	// solo (authentic -- DEATHS only lands on a real pilot in multiplayer).
	BTPlayer *victim_player = (BTPlayer *)((Mech *)victim)->GetPlayerLink();
	if (victim_player != 0)
	{
		Player::VehicleDeadMessage dead(
			Player::VehicleDeadMessageID,				// 0x13
			sizeof(Player::VehicleDeadMessage));
		victim_player->Dispatch(&dead);
	}
}


//#############################################################################
// BTPlayerRoleLocksAdvanced -- complete-type bridge (task #12)
//
// FUN_004ac9c8 [T1]: `return *(*(*(sub+0xD0) + 0x190) + 0x274) == 0` -- the
// subsystem's owner Mech -> the owning BTPlayer (mech+0x190, GetPlayerLink)
// -> roleClassIndex (+0x274, role resource +0xE4).  TRUE = the ROOKIE role
// (class 0) LOCKS the advanced cockpit systems (generator routing, coolant
// valves); nonzero role classes 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->roleClassIndex == 0) ? 1 : 0;		// player+0x274 == 0
}


//#############################################################################
// 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
}
