BT410 Phase 5.3.25: scoring + the COMPLETE mission lifecycle -- egg to GAME-RC

Scoring roles bound (binary ctor tail @004c0bc8): the mission role dict ->
Player::scenarioRole; TEST.EGG's Role::Default = the baked dfltrole
(lives=1000, killBonus=500) -- authored data flowing.  The three score
handlers are live per the decomp derivations (@004c02e4 / @004c0200 /
@004c02a8 + CalcInflictedScore @004c052c; tonnage/bias staged); the
producers post damage scores per delivered hit and the kill credit from
the death transition with the latched killing-blow magnitude.  First live
kill: award=3.43, kills=1, score=123.6.

Out of lives -> the mission END: the binary's +10s review post sits in the
one decomp gap, so the staged stand-in enters the engine's own cascade
(StopMissionMessage +10s) -- EndingMission -> the 3s fade -> the second
Stop -> Application::Stop -> RunMissions exits.  The BTL4/L4 stop layers
run authentically (plasma score display off, egress lamps).

VERIFIED END-TO-END: a 2-lives egg (role-page return=2 override) -- death
#1 debits and respawns healed; death #2 is OUT OF LIVES; +10s later the
mission ends and BTL4OPT.EXE exits CLEANLY to DOS (GAME-RC prints).  The
full 1995 pod mission loop -- egg, boot, spawn, fight, score, die,
respawn, die, mission end, exit -- now runs from reconstructed source.
Zero faults; fight/smoke/novice regressions green.  Dev knob:
BT_PLAYER_PASSIVE=1 (piloted mechs hold force-fire).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-24 17:18:41 -05:00
co-authored by Claude Fable 5
parent 623c872c92
commit f65a3259ce
9 changed files with 653 additions and 248 deletions
+205 -15
View File
@@ -55,6 +55,14 @@
# include <mission.hpp> # include <mission.hpp>
#endif #endif
#if !defined(APPMSG_HPP)
# include <appmsg.hpp>
#endif
#if !defined(SCNROLE_HPP)
# include <scnrole.hpp>
#endif
// //
//############################################################################# //#############################################################################
// Shared data support // Shared data support
@@ -207,6 +215,38 @@ BTPlayer::BTPlayer(
objectiveMech = NULL; objectiveMech = NULL;
lastPerformance = lastConsoleUpdate = lastUpdate; lastPerformance = lastConsoleUpdate = lastUpdate;
//
// Bind the SCORING ROLE (the binary ctor tail @004c0bc8: look the role
// up in the mission's role dictionary by the MakeMessage roleName and
// stash it at this+0x208). BTL4Mission::SetPlayerData populated the
// dictionary before any player exists; the role carries the score
// modifiers AND the lives counter (returnFromDeath). A missing page
// leaves the base ctor's NULL (dev shape: infinite lives, raw-damage
// scores).
//
if (btMission != NULL)
{
scenarioRole =
Cast_Object(BTMission *, btMission)->GetScenarioRole(
creation_message->roleName);
if (getenv("BT_MECH_LOG"))
{
DEBUG_STREAM << "[role] '" << creation_message->roleName << "'";
if (scenarioRole != NULL)
{
DEBUG_STREAM
<< " lives=" << scenarioRole->GetReturnFromDeath()
<< " killBonus=" << scenarioRole->GetKillBonus()
<< " dmgMod=" << scenarioRole->GetDamageInflictedModifier();
}
else
{
DEBUG_STREAM << " NOT FOUND (role-less: infinite lives)";
}
DEBUG_STREAM << endl << flush;
}
}
Check_Fpu(); Check_Fpu();
} }
@@ -576,43 +616,193 @@ void
++deathCount; ++deathCount;
message->deathCount = deathCount; message->deathCount = deathCount;
if (GetScenarioRole() != NULL) if (scenarioRole != NULL)
{ {
GetScenarioRole()->SetReturnFromDeath( scenarioRole->SetReturnFromDeath(
GetScenarioRole()->GetReturnFromDeath() - 1); scenarioRole->GetReturnFromDeath() - 1);
} }
if (GetScenarioRole() == NULL if (scenarioRole == NULL
|| GetScenarioRole()->GetReturnFromDeath() > 0) || scenarioRole->GetReturnFromDeath() > 0)
{ {
Time when = Now(); Time when = Now();
when += 5.0f; when += 5.0f;
application->Post(HighEventPriority, this, message, when); application->Post(HighEventPriority, this, message, when);
if (getenv("BT_MECH_LOG"))
{
DEBUG_STREAM << "[vdead] death #" << deathCount
<< " lives="
<< ((scenarioRole != NULL)
? scenarioRole->GetReturnFromDeath() : -1)
<< " -- respawn posted +5s" << endl << flush;
}
} }
else
if (getenv("BT_MECH_LOG"))
{ {
DEBUG_STREAM << "[vdead] death #" << deathCount //
<< " -- respawn posted +5s" << endl << flush; // OUT OF LIVES. The binary posts an id-0x18 message at +10s (the
// mission-review kick; its exact receiver sits in the one decomp
// gap 4c05c4..4c083c). STAGED stand-in with the engine's own
// end-of-mission entry: StopMissionMessage at +10s -- the full
// authentic cascade runs from there (EndingMission -> the player's
// 3s fade -> the second Stop -> Application::Stop -> RunMissions
// exits -> GAME-RC). The review/playback launch joins the
// mission-review wave.
//
Application::StopMissionMessage stop_mission(NullExitCodeID);
Time when = Now();
when += 10.0f;
application->Post(HighEventPriority, application, &stop_mission, when);
if (getenv("BT_MECH_LOG"))
{
DEBUG_STREAM << "[vdead] death #" << deathCount
<< " OUT OF LIVES -- mission end posted +10s"
<< endl << flush;
}
} }
} }
void void
BTPlayer::ScoreMessageHandler(ScoreMessage *) BTPlayer::ScoreMessageHandler(ScoreMessage *message)
{ {
Fail("BTPlayer::ScoreMessageHandler -- btplayer.cpp not yet reconstructed"); Check(this);
Check(message);
//
// Binary @004c02e4. Master-only; everything is ignored once the
// mission is ending. Type 0 has its own handler; type 1 = points LOST
// for damage taken; type 2 = the kill credit. The tonnage ratio
// (mech+0x4bc) and mech damage bias (+0x354) are STAGED at 1.0/0.0
// (members not reconstructed): awards degrade to the role-scaled
// damage figures, raw damage with no role (the dev shape).
//
if (GetSimulationState() == MissionEndingState)
{
return;
}
HostManager *host_manager = application->GetHostManager();
Entity *sender_mech =
(Entity *)host_manager->GetEntityPointer(message->senderMechID);
switch (message->scoreType)
{
case DamageReceivedScore:
if (sender_mech != NULL && sender_mech != playerVehicle
&& scenarioRole != NULL)
{
message->scoreAward =
scenarioRole->CalcDamageReceivedScore(message->damageAmount);
// (the console VTV-damaged feed joins the console wave)
Player::ScoreMessageHandler(message);
}
break;
case KillScore:
{
Scalar award = CalcInflictedScore(
message->damageAmount, sender_mech, message->scoreAward);
if (sender_mech != NULL && sender_mech == playerVehicle)
{
//
// Suicide: no credit, no kill count.
//
award = -award;
}
else
{
++killCount;
// (the victim-side status ticker joins the console wave)
}
message->scoreAward = award;
Player::ScoreMessageHandler(message);
if (getenv("BT_MECH_LOG"))
{
DEBUG_STREAM << "[score] KILL award=" << award
<< " kills=" << killCount
<< " score=" << currentScore << endl << flush;
}
}
break;
default:
//
// Type 0 belongs to the ScoreInflicted handler (the binary asserts
// here); drop it quietly in the opt shape.
//
break;
}
Check_Fpu();
} }
void void
BTPlayer::ScoreInflictedMessageHandler(ScoreMessage *) BTPlayer::ScoreInflictedMessageHandler(ScoreMessage *message)
{ {
Fail("BTPlayer::ScoreInflictedMessageHandler -- btplayer.cpp not yet reconstructed"); Check(this);
Check(message);
//
// Binary @004c0200 (type 0 only): points EARNED for damage delivered.
// Accumulates DIRECTLY into currentScore (not via the base handler);
// negated for self-hits. Tonnage ratio staged 1.0.
//
if (GetSimulationState() == MissionEndingState)
{
return;
}
HostManager *host_manager = application->GetHostManager();
Entity *target_mech =
(Entity *)host_manager->GetEntityPointer(message->senderMechID);
Scalar award = CalcInflictedScore(message->damageAmount, target_mech, 0.0f);
if (target_mech != NULL && target_mech == playerVehicle)
{
award = -award;
}
currentScore += award;
Check_Fpu();
} }
void void
BTPlayer::ScoreUpdateMessageHandler(ScoreMessage *) BTPlayer::ScoreUpdateMessageHandler(ScoreMessage *message)
{ {
Fail("BTPlayer::ScoreUpdateMessageHandler -- btplayer.cpp not yet reconstructed"); Check(this);
Check(message);
//
// Binary @004c02a8: the network score resync -- fold the carried award
// straight through the base handler.
//
Player::ScoreMessageHandler(message);
Check_Fpu();
}
//
//#############################################################################
// CalcInflictedScore (binary @004c052c) -- the shared damage -> points
// converter. factor = -friendlyFirePenalty on a team hit, else
// (mechDamageBias x roleDamageBias + 1); STAGED: mech damage bias (+0x354)
// unreconstructed = 0, so factor degrades to 1 (or the friendly penalty).
//#############################################################################
//
Scalar
BTPlayer::CalcInflictedScore(
Scalar damage_amount,
Entity * /*other_mech -- the team / bias source, staged*/,
Scalar bias
)
{
Check(this);
if (scenarioRole == NULL)
{
return damage_amount + bias;
}
return (damage_amount + bias)
* scenarioRole->GetDamageInflictedModifier();
} }
// //
+66
View File
@@ -64,6 +64,46 @@
}; };
//########################################################################### //###########################################################################
//###########################################################################
//###################### BTPlayer Score Message #########################
//###########################################################################
//
// The BT wire layout (extends the engine ScoreMessage's scoreAward
// @+0x1c): scoreType @+0x20, damageAmount @+0x24 (the input every award
// derives from), the point-source handles @+0x28..0x30 (console VTV
// feed -- headless zeros), senderMechID @+0x34 (the inflicting / victim
// mech, resolved via the host manager).
//
class BTPlayer__ScoreMessage:
public Player__ScoreMessage
{
public:
int scoreType;
Scalar damageAmount;
int pointSenderHi;
int pointSenderLo;
int auxID;
EntityID senderMechID;
BTPlayer__ScoreMessage(
Receiver::MessageID message_ID,
size_t length,
Scalar score,
int score_type,
Scalar damage_amount,
const EntityID &sender_mech_ID
):
Player__ScoreMessage(message_ID, length, score),
scoreType(score_type),
damageAmount(damage_amount),
pointSenderHi(0),
pointSenderLo(0),
auxID(0),
senderMechID(sender_mech_ID)
{}
};
//############################# BTPlayer ################################ //############################# BTPlayer ################################
//########################################################################### //###########################################################################
@@ -92,6 +132,32 @@
NextMessageID NextMessageID
}; };
//
// The score types the BT ScoreMessage carries (branch selector
// @004c02e4; type 0 has its own handler @004c0200).
//
enum {
DamageInflictedScore = 0,
DamageReceivedScore = 1,
KillScore = 2
};
public:
typedef BTPlayer__ScoreMessage ScoreMessage;
//
// The shared damage -> points converter (binary @004c052c):
// (damage + bias) x role damageInflictedModifier x factor, where a
// friendly hit flips to -friendlyFirePenalty. Role-less players
// degrade to raw damage (the dev-mission shape).
//
Scalar
CalcInflictedScore(
Scalar damage_amount,
Entity *other_mech,
Scalar bias
);
protected: protected:
void void
DropZoneReplyMessageHandler(DropZone__ReplyMessage *message); DropZoneReplyMessageHandler(DropZone__ReplyMessage *message);
@@ -193,3 +193,36 @@ return -- ends the 2s probe loop). Out-of-lives -> mission review post =
scoring wave. The fresh-spawn branch does NOT yet run the shared scoring wave. The fresh-spawn branch does NOT yet run the shared
Reset/translocation-alarm tail (mech already placed by the MakeMessage; Reset/translocation-alarm tail (mech already placed by the MakeMessage;
joins the cockpit wave). joins the cockpit wave).
## 5.3.25 — scoring roles + the COMPLETE mission lifecycle
- The ctor binds Player::scenarioRole from the mission role dictionary by
the MakeMessage roleName (binary @004c0bc8 tail; the dict was populated
by BTL4Mission::SetPlayerData before any player exists). TEST.EGG's
Role::Default -> baked 'dfltrole': lives=1000, killBonus=500, dmgMod=1
(authored data flowing). Egg override: `return=N` goes on the ROLE page
([Role::Default]), NOT the player page (BTL4MSSN.CPP:307 reads role_node).
- Score handlers live: Score @004c02e4 (type 1 DamageReceived via
role->CalcDamageReceivedScore; type 2 Kill via CalcInflictedScore + the
suicide negate + killCount, then the base applies scoreAward),
ScoreInflicted @004c0200 (type 0, accumulates DIRECTLY into currentScore,
self-hit negated), ScoreUpdate @004c02a8 (base fold-through).
CalcInflictedScore @004c052c staged: tonnage ratios (mech+0x4bc) and
mechDamageBias (+0x354) unreconstructed -> 1.0/0.0; friendly-fire factor
joins the team wave; console VTV feeds join the console wave.
- Producers (the authentic emitters sit in the unexported master-perf
writer 0x4a9770-0x4ab188; the port shape is used): SendDamage + the
missile fuse post type-0 damage scores to the shooter's pilot; the mech
death transition posts the type-2 kill to the killer's pilot with the
latched killing-blow magnitude (Mech::lastInflictingDamage).
- OUT OF LIVES: the binary's +10s id-0x18 review post lives in the one
decomp gap (4c05c4..4c083c); STAGED stand-in = StopMissionMessage at
+10s -- the ENGINE cascade is fully authentic from there: EndingMission
-> MissionEnding fade (3s) -> the second Stop -> Application::Stop ->
RunMissions exits -> GAME-RC. The BTL4/L4 layers run authentically
(plasma score display off; egress lamps).
- VERIFIED END-TO-END (lifecycle2.conf, KILLTEST.EGG return=2,
BT_PLAYER_PASSIVE=1): lives=2 -> death#1 debit+respawn -> death#2 OUT OF
LIVES -> plasma off -> clean GAME-RC exit. THE FULL 1995 POD MISSION
LOOP, egg to DOS exit, from reconstructed source. Dev knobs:
BT_PLAYER_PASSIVE (piloted mechs hold force-fire).
+12 -1
View File
@@ -482,7 +482,18 @@ void
{ {
forceFire = (getenv("BT_FORCE_FIRE") != NULL) ? 1 : 0; forceFire = (getenv("BT_FORCE_FIRE") != NULL) ? 1 : 0;
} }
if (forceFire) //
// BT_PLAYER_PASSIVE=1: the piloted mech holds fire (the lifecycle
// test -- let the enemy win).
//
static int playerPassive = -1;
if (playerPassive < 0)
{
playerPassive = (getenv("BT_PLAYER_PASSIVE") != NULL) ? 1 : 0;
}
if (forceFire
&& !(playerPassive && owner != NULL
&& owner->GetPlayerLink() != NULL))
{ {
fireImpulse = (GetWeaponState() == LoadedState) ? 1.0f : 0.0f; fireImpulse = (GetWeaponState() == LoadedState) ? 1.0f : 0.0f;
} }
+35 -1
View File
@@ -55,6 +55,8 @@
#include <mechdmg.hpp> // Mech::DamageZone -- the hull zone fill (Pass 3) #include <mechdmg.hpp> // Mech::DamageZone -- the hull zone fill (Pass 3)
#include <dmgtable.hpp> // DamageLookupTable -- the cylinder hit table #include <dmgtable.hpp> // DamageLookupTable -- the cylinder hit table
#include <player.hpp> // Player::VehicleDeadMessage -- the death notification #include <player.hpp> // Player::VehicleDeadMessage -- the death notification
#include <btplayer.hpp> // BTPlayer::ScoreMessage -- the kill credit
#include <hostmgr.hpp> // HostManager::GetEntityPointer -- killer resolve
// //
//############################################################################# //#############################################################################
@@ -191,6 +193,7 @@ Mech::Mech(
lastInflictingID = EntityID::Null; lastInflictingID = EntityID::Null;
damageLookupTable = NULL; damageLookupTable = NULL;
deathTransitionDone = 0; deathTransitionDone = 0;
lastInflictingDamage = 0.0f;
// //
// Look-view angles: defaults until the GameModel read below overrides them // Look-view angles: defaults until the GameModel read below overrides them
@@ -649,7 +652,8 @@ void
<< endl << flush; << endl << flush;
} }
lastInflictingID = message->inflictingEntity; lastInflictingID = message->inflictingEntity;
lastInflictingDamage = message->damageData.damageAmount;
// //
// The cylinder resolve (binary @0x4a0264 tail): an UNAIMED hit arrives // The cylinder resolve (binary @0x4a0264 tail): an UNAIMED hit arrives
@@ -1015,6 +1019,36 @@ void
pilot->Dispatch(&dead); pilot->Dispatch(&dead);
} }
//
// The KILL CREDIT: resolve the last attacker and post the
// type-2 ScoreMessage to the KILLER's pilot, carrying the
// killing-blow magnitude (the whole award derives from it --
// binary emitter in the unexported master-perf writer; the
// kill-bonus bias rides scoreAward, staged 0). MASTER-only,
// and an unpiloted killer (the dev enemy) earns nothing.
//
if (GetInstance() != Entity::ReplicantInstance
&& !(lastInflictingID == EntityID::Null))
{
Entity *killer = (Entity *)application->GetHostManager()->
GetEntityPointer(lastInflictingID);
if (killer != NULL && killer != (Entity *)this
&& killer->IsDerivedFrom(Mech::ClassDerivations)
&& killer->GetPlayerLink() != NULL)
{
BTPlayer::ScoreMessage
kill_score(
Player::ScoreMessageID,
sizeof(BTPlayer::ScoreMessage),
0.0f,
BTPlayer::KillScore,
lastInflictingDamage,
GetEntityID()
);
killer->GetPlayerLink()->Dispatch(&kill_score);
}
}
if (getenv("BT_MECH_LOG")) if (getenv("BT_MECH_LOG"))
{ {
DEBUG_STREAM << "[death] mech " << GetEntityID() DEBUG_STREAM << "[death] mech " << GetEntityID()
+3 -1
View File
@@ -455,6 +455,8 @@
Entity *targetEntity; Entity *targetEntity;
EntityID lastInflictingID; // binary mech+0x43c -- last EntityID lastInflictingID; // binary mech+0x43c -- last
// attacker (zone LOD router) // attacker (zone LOD router)
Scalar lastInflictingDamage; // the killing-blow
// magnitude (kill score)
int deathTransitionDone; // the once-per-death latch int deathTransitionDone; // the once-per-death latch
// (binary movementMode 2||9) // (binary movementMode 2||9)
DamageLookupTable *damageLookupTable; // binary mech+0x444 -- the DamageLookupTable *damageLookupTable; // binary mech+0x444 -- the
@@ -473,7 +475,7 @@
// mech2/mech3/mech4 simulation. Reserved until that path is // mech2/mech3/mech4 simulation. Reserved until that path is
// reconstructed with named fields. // reconstructed with named fields.
// //
int reservedState[197]; int reservedState[196];
}; };
#endif #endif
+24
View File
@@ -30,6 +30,10 @@
# include <random.hpp> # include <random.hpp>
#endif #endif
#if !defined(BTPLAYER_HPP)
# include <btplayer.hpp>
#endif
Derivation Derivation
MechWeapon::ClassDerivations( MechWeapon::ClassDerivations(
PoweredSubsystem::ClassDerivations, PoweredSubsystem::ClassDerivations,
@@ -452,6 +456,26 @@ void
); );
target->Dispatch(&hit); target->Dispatch(&hit);
//
// The DAMAGE SCORE: post the type-0 ScoreInflicted to OUR pilot (the
// authentic producer sits in the unexported master-perf writer; the
// port shape posts per delivered hit). senderMechID = the VICTIM.
//
if (owner != NULL && owner->GetInstance() != Entity::ReplicantInstance
&& owner->GetPlayerLink() != NULL)
{
BTPlayer::ScoreMessage
damage_score(
BTPlayer::ScoreInflictedMessageID,
sizeof(BTPlayer::ScoreMessage),
0.0f,
BTPlayer::DamageInflictedScore,
damage.damageAmount,
target->GetEntityID()
);
owner->GetPlayerLink()->Dispatch(&damage_score);
}
if (getenv("BT_MECH_LOG")) if (getenv("BT_MECH_LOG"))
{ {
DEBUG_STREAM << "[dmg] '" << GetName() DEBUG_STREAM << "[dmg] '" << GetName()
+34
View File
@@ -42,6 +42,14 @@
# include <app.hpp> # include <app.hpp>
#endif #endif
#if !defined(HOSTMGR_HPP)
# include <hostmgr.hpp>
#endif
#if !defined(BTPLAYER_HPP)
# include <btplayer.hpp>
#endif
// //
// Guidance tuning (binary .rdata @004bf594..@004bf5b0, BT411-decoded). // Guidance tuning (binary .rdata @004bf594..@004bf5b0, BT411-decoded).
// STAGED-DEFAULT flight envelope: lifetime / thrust come from the missile // STAGED-DEFAULT flight envelope: lifetime / thrust come from the missile
@@ -347,6 +355,32 @@ void
); );
victim->Dispatch(&hit); victim->Dispatch(&hit);
//
// The DAMAGE SCORE to the launching pilot (type 0, victim as
// sender), resolved through the latched launcher id.
//
{
Entity *launcher_entity =
(Entity *)application->GetHostManager()->
GetEntityPointer(launcherEntityID);
if (launcher_entity != NULL
&& launcher_entity->GetInstance()
!= Entity::ReplicantInstance
&& launcher_entity->GetPlayerLink() != NULL)
{
BTPlayer::ScoreMessage
damage_score(
BTPlayer::ScoreInflictedMessageID,
sizeof(BTPlayer::ScoreMessage),
0.0f,
BTPlayer::DamageInflictedScore,
damageData.damageAmount,
victim->GetEntityID()
);
launcher_entity->GetPlayerLink()->Dispatch(&damage_score);
}
}
if (getenv("BT_MECH_LOG")) if (getenv("BT_MECH_LOG"))
{ {
DEBUG_STREAM << "[msl] DETONATE on zone " << zone DEBUG_STREAM << "[msl] DETONATE on zone " << zone
+12 -1
View File
@@ -278,7 +278,18 @@ void
{ {
forceFire = (getenv("BT_FORCE_FIRE") != NULL) ? 1 : 0; forceFire = (getenv("BT_FORCE_FIRE") != NULL) ? 1 : 0;
} }
if (forceFire) //
// BT_PLAYER_PASSIVE=1: the piloted mech holds fire (the lifecycle
// test -- let the enemy win).
//
static int playerPassive = -1;
if (playerPassive < 0)
{
playerPassive = (getenv("BT_PLAYER_PASSIVE") != NULL) ? 1 : 0;
}
if (forceFire
&& !(playerPassive && owner != NULL
&& owner->GetPlayerLink() != NULL))
{ {
fireImpulse = (GetWeaponState() == LoadedState) ? 1.0f : 0.0f; fireImpulse = (GetWeaponState() == LoadedState) ? 1.0f : 0.0f;
} }