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>
13 KiB
BTPLAYER.CPP / .HPP — reconstruction notes
Status: constructor / dtor / Make / TestInstance / GetExperienceLevel + message-handler table + DropZoneReply dispatch RECONSTRUCTED; the scoring/death handlers, PlayerSimulation, and CreatePlayerVehicle/InitializePlayerLink (the Mech-subsystem frontier) still staged.
Message-handler table (wired 2026-07-19)
DefaultData now uses BTPlayer::MessageHandlers (chained onto
Player::MessageHandlers) instead of reusing the base set — the earlier reuse
left BTPlayer's own handlers unwired, so a DropZoneReply reached the base
Player::DropZoneReplyMessageHandler stub that Fails "should not be handled by
base player class!" (PLAYER.CPP:179). Table entries (BT411 .data @005130c0):
DropZoneReply, VehicleDead, Score, ScoreInflicted, ScoreUpdate. ScoreInflicted/
ScoreUpdate add two BT message IDs (Player::NextMessageID+0/+1). MissionStarting/
MissionEnding are inherited from Player unchanged (not re-listed — BT411 lists
them but they point at the same base handlers).
DropZoneReplyMessageHandler is the real spawn/respawn dispatch skeleton; its
mech-placement tail (vehicle-class performance select, Mech::Reset respawn) is
deferred with the Mech subsystem. On the first fresh-drop reply it calls
CreatePlayerVehicle → the current boot frontier.
Sources
- BT411
game/reconstructed/btplayer.cpp(Ghidra-recovered ctor @004c0bc8, Make @004c0ecc, dtor @004c0efc, TestInstance @004c0f28) — byte-offset annotated body. - Surviving 1995 headers pin every base API: PLAYER.HPP (base members
currentScore/scenarioRole/deathCount/playerMission, GetMission), SIMULATE.HPP
(lastPerformance/lastUpdate/simulationFlags/activePerformance —
lastPerformanceis a BASE member, not BT-own), ENTITY.HPP (GetInstance/ReplicantInstance), NTTMGR.HPP (EntityManager::FindGroup, EntityGroup::groupMembers = ChainOf<Node*>), TEAM.HPP (Team::teamName[64], public — BTTeam inherits it), BTMSSN.HPP (BTMission::ExperienceLevel()/AdvancedDamageOn(), enum Novice/Standard/ Veteran/Expert = 0..3), SCNROLE.HPP.
Member layout note
The staged header's int reserved[38] placeholder was replaced with the real
BT-own members (btMission, teamName[64], teamEntity, freeForAll, consoleAttached,
suppressConsole, the experience-flag block, killCount/padScore, objectiveMech,
lastConsoleUpdate, deathPending). Byte offsets do NOT need to match the shipped
0x294-byte object — this is a fresh self-consistent build; only wire-format
structs (MakeMessage) must match the binary, and those are unchanged.
Experience-level flag mapping — the [T1] correction
The ctor derives the game-mode flags from btMission->ExperienceLevel()
(mission+0xe4, the egg's per-pilot "experience" key), NOT from the scenario
role's returnFromDeath — see bt-experience-levels. BT411's ported ctor uses
scenarioRole->GetReturnFromDeath() as a stand-in and marks it [T3]; this 1995
reconstruction encodes the binary-accurate reading instead.
Binary switch rows (member ← value per level), from the [T1] disassembly:
| level | showDamageReceived (0x25c) | showKills (0x260) | roleReturnDelay2 (0x26c) | showScore (0x270) |
|---|---|---|---|---|
| novice | 0 | 0 | 0 | 0 |
| standard | 1 | 0 | 1 | 1 |
| veteran | 1 | 1 | 1 | 1 |
| expert | 1 | 1 | 0 | 1 |
Plus showDamageInflicted (0x264) = roleReturnDelay (0x268) = advancedDamageOn.
Member NAMES are wrong-but-kept (they date from the earlier scoring work):
showKills is really the HEAT-MODEL master switch (FUN_004ad7d4), showDamageReceived
is the "sim live / novice lockout" gate, showScore/roleReturnDelay2 consumers
not fully located ([T4]). Rename is a follow-up wiring task; the VALUES are correct.
Deliberate differences from BT411's port
- Make: the 1995
return new BTPlayer(creation_message);— BT411's replicantSetValidFlag()is a P6 network-bring-up addition, dropped. - Replicant-mission caching uses
application->GetCurrentMission()(the real 1995 accessor, PLAYER.CPP:689) instead of BT411's rawapplication+0xc8.
Reconstructed 2026-07-20 (boot reaches the live game loop)
- InitializePlayerLink (btplayer.cpp, was @004bfee0) — DONE. Dispatches a
PlayerLinkMessagecarrying our ownGetEntityID()toplayerVehicleand its network replicants, binding the freshly-made mech to its owning player. Byte-for-byte the same tail the engine's ownCameraDirector::CreateCameraShipruns (DIRECTOR.CPP):BTPlayer/Player/CameraDirectorall derive fromEntity, soPlayerLinkMessage/PlayerLinkMessageID/GetEntityIDresolve through theEntitybase. No staging. - VehicleDeadMessageHandler (btplayer.cpp) — PARTIAL. The non-death
flavours are real: the engine's
-2"did the drop-zone reply arrive?" probe (posted byPlayer::HuntForDropZoneduring every spawn) and the>=0respawn re-post both delegate to the authentic basePlayer::VehicleDeadMessageHandler, which only re-hunts a drop zone when the death is still current and one has not been acquired — so a freshly-spawned player falls straight through (this is what unblocks bring-up boot). Only thedeathCount == -1immediate death notification is stillFail-staged: its full cycle (scoring-role life debit, +5 s respawn re-post, warp collapse) is downstream of the per-frame mech sim (mech4) + scoring roles.
With these two, the reconstructed tree boots into the running per-frame game
loop with zero Fail() reached: engine → app → mission → BTPlayer →
CreatePlayerVehicle → Mech ctor (33 subsystems from the real TEST.EGG model) →
InitializePlayerLink → DropZone acquire → the -2 probe returns harmlessly →
LBE4ControlsManager::Execute spins per-frame. The only thing it now waits on
is RIO cockpit hardware (absent in the headless smoke test).
Reconstructed 2026-07-20 (entity simulation goes LIVE)
- DropZoneReplyMessageHandler now, after the fresh spawn (CreatePlayerVehicle
- InitializePlayerLink), CHOOSES the per-vehicle Performance from the vehicle
class: a Mech gets
SetPerformance(&BTPlayer::PlayerSimulation)+SetScoringPlayerFlag(); anything else (camera ship) getsPlayer::CameraShipSimulation+ rank −1. This is load-bearing for mission flow (see below). The mech PLACEMENT tail (Mech::Resetheal/move to the drop-zone origin, translocation alarm, warp-sphere reveal) stays deferred with the mech per-frame sim; the fresh mech sits at its ctor origin.
- InitializePlayerLink), CHOOSES the per-vehicle Performance from the vehicle
class: a Mech gets
- PlayerSimulation (btplayer.cpp) — chains the authentic engine base
Player::PlayerSimulation(CalcRanking + ManageApplicationStatus + vehicle- position copy + StatusMessageUpdate). The BT console SCORE-delta flush (ConsolePlayerVTVScoreUpdateMessage, only meaningful with a live console host) is deferred to the scoring wave.
Why this is the piece that starts the world executing
Entity::Execute only calls PerformAndWatch (i.e. simulates entities) when
Application::GetApplicationState() == RunningMission. The app is a state
machine advanced one step per RunMissionMessage:
WaitingForLaunch → LaunchingMission → RunningMission. With no console,
CheckLoadMessageHandler posts the FIRST RunMission (→ LaunchingMission, which
dispatches MissionStarting → the player starts a 3 s fade). The SECOND
RunMission (→ RunningMission) is dispatched by Player::ManageApplicationStatus
when that fade expires — but ManageApplicationStatus only runs inside
PlayerSimulation/CameraShipSimulation, NOT inside the launch-phase
HuntForDropZone. So the player must switch its Performance onto PlayerSimulation
after it spawns, or the fade never counts down and the world never runs.
That switch (above) + a non-Fail PlayerSimulation is what takes the boot from
"controls loop only" to a live per-frame entity simulation: verified — the
mech is executed each frame and its subsystem roster ticks (Sensor first-frame
marker), zero Fail. The mech BODY is still DoNothingOnce (its real
Mech::Simulate is Phase 5.3).
Still staged (next bricks)
Mech::Simulate— the mech's real per-frame Performance (Phase 5.3): now the frontier, since the entity tick path is live and calls the mech's Performance each frame.- VehicleDeadMessageHandler(
-1) death cycle — with mech4 + scoring roles. - Score / ScoreInflicted / ScoreUpdate handlers (scoring wave); the console SCORE-delta flush inside PlayerSimulation.
- DropZoneReply respawn tail (
Mech::Resetin-place heal/move).
Experience flags renamed to TRUE semantics (2026-07-22, Phase 5.3.12)
The show* / roleReturn* block was the old "per-role display toggles"
misread (the memory note flagged it). Renamed + retyped to the binary truth:
simLive @0x25c (novice lockout: jams/lights/powersub short events),
heatModelOn @0x260 (THE heat-model master switch, FUN_004ad7d4),
advancedDamageOn/advancedDamageOn2 (the egg technician flag pair),
levelFlag26c (0/1/1/0, consumer unlocated), levelFlag270 (0/1/1/1),
experienceLevel (raw 0..3). The ctor rows were already binary-accurate.
Public accessors IsSimLive()/IsHeatModelOn()/IsAdvancedDamageOn(); a gated
[exp] sentinel prints the derived flags per master player. Consumers:
HeatSink::HeatModelActive() (HEAT.CPP), ProjectileWeapon::LiveFireEnabled()
(PROJWEAP.CPP) -- both walk mech->GetPlayerLink(), NULL-permissive.
5.3.16 — BT_SPAWN_ENEMY dev harness
DropZoneReplyMessageHandler, after the fresh-spawn block: BT_SPAWN_ENEMY=1
clones the CreatePlayerVehicle MakeMessage recipe (same game-model resource,
badge/color/patch, Mech::DefaultFlags) at dropZone+150 on Z, calls
Mech::Make() directly (the Entity ctor self-registers + uniquifies the ID)
and sets it as the player mech's target. The enemy has NO player link, so its
experience gates read dev-permissive; it never fires back (no target of its
own — set one for a mutual fight). DEV-ONLY: the real opponent path is the
network mesh / mission roster.
5.3.17 — BT_SPAWN_ENEMY is now a MUTUAL fight
The harness also sets the enemy's target back to the player: under BT_FORCE_FIRE the enemy returns fire (its gates read dev-permissive with no player link), so the damage cascade gets a live incoming side — used to verify the player's own weapons fall silent when destroyed.
5.3.24 — the VehicleDead(-1) cycle + respawn branch LIVE
The -1 immediate-death branch (binary @004c05c4): MissionEndingState swallow, deathPending dedup (one death one cycle), ++deathCount + stamp into the message (the base handler's identity gate), scenario-role life debit (NULL-safe), +5s re-post (RespawnDelay @004c0830) -> the engine base handler runs the drop-zone hunt -> reply -> the respawn branch resets the SAME mech (reset-based respawn; severing was the "two mechs" glitch family). The >=0 re-posts/probes are MOOT once the mech lives again (clear deathPending, return -- ends the 2s probe loop). Out-of-lives -> mission review post = scoring wave. The fresh-spawn branch does NOT yet run the shared Reset/translocation-alarm tail (mech already placed by the MakeMessage; 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=Ngoes 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).