#45/#134 authentic score/death report tail -- replaces the scoring stand-ins

Reconstructs the dark-gap tail of Mech::TakeDamageMessageHandler
(@0x4a02f4-0x4a0890, raw disasm): the three id-0x16 score reports (kill to
the shooter's player / type-0 wire-fidelity / received to the victim's
player) and the BT 0x38-byte VehicleDeadMessage extension {killed-by player,
kill zone} dispatched from the death tail.  Retires BTPostDamageScore /
BTPostKillScore and the per-hit inflicted credit (never existed in 1995:
@0x4c0200 is bound in no handler-table entry -- byte-scan receipt in
decomp-reference).  Suicides now dispatch and the handler negates the award
(the #134 panic penalty).  Collision divert falls through to the death tail
per @0x4a0375 (wall deaths respawn + blast; no score).  ScoreMessage fields
renamed to decoded truth (vitalHit/zoneIndex/subsysID) + wire asserts;
console VTVDamaged points_transfered corrected (Round(award), not Now()).

Benches: scorekill.sh cross-node kill (kills=1 award=4.88, killedBy=2:1
zone=3, single death cycle), scoreself.sh suicide (type=2 award=-39.00
kills=0), deathblast2.sh re-verified (72 bursts at ~9u).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Joe DiPrima
2026-08-05 18:36:58 -05:00
co-authored by Claude Fable 5
parent 06a8edbff2
commit 91bd28669e
12 changed files with 706 additions and 296 deletions
+56 -8
View File
@@ -391,7 +391,7 @@ zones — mid-fight darkening DOES accumulate when mechs live long enough.
**Field logs confirm dominance**: Rajel's night-9 log alone has 12 bay-fire events (LRM10
455-490 pts); ConnMan 10; the "salvo barely dented my armor" reporter was dying to their OWN
cook-offs. **The real lever is HEAT** (bay fire arms at the bin heat-watcher's FAILURE level;
the [[heat-and-coolant]]/#96 too-hot economy arms it constantly) plus player training: EJECT
the [[subsystems]] heat/#96 too-hot economy arms it constantly) plus player training: EJECT
**hold** purges the burning bay (`DumpAmmo` → "bay fire EXTINGUISHED"); pod players were
manual-trained to purge ([[pod-hardware]] §Manual), testers are not. Novice sim gates also
suppress the whole path ([[experience-levels]]).
@@ -569,7 +569,9 @@ percent tables, MadCat) — see #73 on the tracker.
## layers are now fixed and live-verified
The recovery: the un-exported gap held `Mech::TakeDamageMessageHandler @0x4a0230` (found via the
Mech **message table @0x50bdf8** — rows `{id, name, handler}`: 0x12 TakeDamage, 0x14 PlayerLink,
0x15 RealMaxSpeed, 0x16 BalanceCoolant, 0x17/0x18 Set/ClearBurningState, 0x19 EjectPilot @0x49f854,
0x15 RealMaxSpeed, 0x16 BalanceCoolant, 0x17/0x18 Set/ClearBurningState (⚠ label/body mismatch:
the 0x17 handler @0x49f674 randomizes position + graphicAlarm(2) + re-enables sim — not burning;
possibly a shifted name row, cf. the factory ClassID mislabels — open-questions), 0x19 EjectPilot @0x49f854,
0x1a DuckRequest @0x49fa00) and the **crit-chance roll `@0x4a0164`**:
`p = clamp(0.7·damageLevel² + 0.01, 0..1)`, gated on the player's `simLive` flag (+0x25c — novice
never crits), rolled per BURST on the current zone (skip if the zone is already burning). The
@@ -582,12 +584,58 @@ entirely — multi-burst under-applied (burst-1)×) is superseded. `MechSubsyste
@0x4ac0bc` is real (zone damage → destroyed alarms → **vital-subsystem kill**: owner
`graphicAlarm` level 9, the #28 machinery — CLASSMAP's "HandleMessage@4ac0bc" was a mislabel).
Live-verified: `[subarmor]` shows parsed scales at spawn; `[critroll] zone=3 -> Myomers subLvl=1`
— a full chain crit destroying a subsystem. Still deferred [T4→documented]: the `damageType==0`
COLLISION divert (@0x4a0368`0x49ffcc`, its own distribution) and the **id-0x16 damage/kill
reports** to shooter+victim players (@0x4a04da-0x4a07b2 — the authentic stats plumbing, feeds #45:
`{tally, zone, destroyed-flag, inflicting subsystem, victim name}`, kill-flagged variant on newly
disabled, plus a killed-by block gated on movementMode 9/10). Diags: `BT_CRIT_LOG` (`[subarmor]` +
`[critroll]`), `BT_DMG_LOG`.
— a full chain crit destroying a subsystem. The `damageType==0` COLLISION divert (@0x4a0368
`0x49ffcc`) and the id-0x16 report tail are BOTH reconstructed — see the next section. Diags:
`BT_CRIT_LOG` (`[subarmor]` + `[critroll]`), `BT_DMG_LOG`.
## ✅ THE AUTHENTIC SCORE/DEATH REPORT TAIL (2026-08-05, #45/#134) [T1 raw disasm, T2 both benches]
The rest of `Mech::TakeDamageMessageHandler` (@0x4a02f4-0x4a0890, dark-gap raw disasm) is now
reconstructed: `BTMechPostCombatReports` + `BTMechPostVehicleDead` (btplayer.cpp), called from the
handler at the binary's exact positions (mech.cpp). Retires `BTPostDamageScore`/`BTPostKillScore`.
**Entry sequence** [T1]: gyro bounce → death-edge latch `[ebp-0x10]` (was-destroyed at ENTRY —
before the divert!) → `lastInflictingID` stamp (mech+0x43c ← msg inflictor) → shooter resolve
(registry find → `shooter+0x190` = shooter's PLAYER; victim's = `mech+0x190`) → **collision divert
JUMPS TO THE DEATH TAIL** (@0x4a0375 `jmp 0x4a07b5`, NOT a return — a wall/fall death still posts
VehicleDead + blasts, but skips the score blocks: collision deaths credit no one). Our old early
`return` here was a latent "wall-death strands the pilot" hazard, fixed with the move.
**The three id-0x16 reports** (all `Player::ScoreMessageID` 0x16, sizeof 0x3C — layout in
btplayer.hpp, static_assert-locked; `scoreAward`=APPLIED tally in all three):
- **A @0x4a04da, newly destroyed** → type 2 KillScore to the SHOOTER's player. Basis (+0x24) =
the VICTIM role's `killBonus` (role+0x1c). senderMechID = the victim. Suicide (eject charge,
self-damage) IS dispatched — the handler negates the award (@0x4c03ab `fchs`) and skips
`killCount++`: **the #134 panic penalty, live** (bench: `type=2 award=-39.00 kills=0`).
- **B @0x4a05d9, not newly killed && tally≠0** → type 0 to the shooter. The ONLY registered 0x16
receiver Verify-rejects type 0 → 1995 folded an UNINITIALIZED stack float into the shooter's
score on every non-lethal hit (real 1995 bug — @0x4c0200, the handler that accepts type 0, is
in NO table entry: dead code). Port sends it for wire fidelity, banks award 0.
- **C @0x4a06c0, tally>0 (kills included)** → type 1 DamageReceivedScore to the VICTIM's player.
Basis = INTENDED damage (burstCount×amount). senderMechID = the INFLICTOR. Feeds the received
penalty (`CalcDamageReceivedScore` returns the NEGATIVE) + the operator-console VTVDamaged line
(gated `GetConsoleHost()` && !`suppressConsole` — which is why that field is named that).
Reports carry the LOOP-ENTRY zone (msg+0x24, never rewritten mid-loop), the vital-wreck flag, and
`inflictingSubsystemID` (msg+0x5c, engine T0 name).
**Score model consequence** [T1]: 1995 pod scoring = **kill awards + received-damage penalties +
death costs. No per-hit inflicted credit** (the port's old per-hit crediting — and the #95 salvo
fix on top of it — were inventions riding the dead @0x4c0200 channel; both retired).
Kill award = `(victimKillBonus + tally) × killerRole.damageInflictedModifier ×
(victimAvgZoneDamage@0x354 × damageBias + 1.0) × (victimTonnage/killerTonnage)`; same-team kill in
a non-FFA game = `-friendlyFirePenalty` basis (inline strcmp of `teamName@0x20c`, gate
`freeForAll@0x250==0`); self-kill negates the whole award.
**The death tail** (@0x4a07b5, shared by the divert): gate = was-alive-at-entry && movementMode
9|10 → **`Player::VehicleDeadMessage` id 0x17 to the mech's OWN player** — the respawn trigger,
in the BT 0x38-byte extension `{deathCount -1, dropZone Null, +0x2c killed-by PLAYER EntityID,
+0x34 kill zone}` (bench: `killedBy=2:1 zone=3` cross-node) — then the death Explosion (id 3,
0x5C, model 0x31; port fires the authored death list from the transition instead, #42) and the
#89 SplashDamage (re-verified after the reorder: 72 bursts at ~9u). The old mech4
death-transition dispatch site (which was flagged [T3 sender-undecoded]) is retired — this IS
that sender. The #55 NULL-playerLink fallback + DEAD_NOTIFY forensics moved into the bridge.
Benches: `scratchpad/night12/scorekill.sh` (cross-node kill: killer `kills=1 award=4.88`,
victim respawns, death #1 single-cycle) + `scoreself.sh` (#134 negation). Collision-death tail
fallthrough is inspection-tier [T3] — shares the benched tail code; field wall-deaths exercise it.
## (HISTORICAL — the gap as found 2026-07-29, superseded above) [T1]
The authored crit machinery exists and is reconstructed — `Mech__DamageZone::CriticalHit @0049ccc4`
+28 -1
View File
@@ -247,9 +247,36 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
### Binary message tables decoded 2026-07-20 (glass input audit) [T1]
16-byte HandlerEntry {id, namePtr, fnPtr, 0} rows in section_dump. Per-receiver-class id spaces.
- **Mech (entity)** @0x50BDF8..: 0x12 TakeDamage@004a0230, 0x14 PlayerLink@0049f624, 0x15
RealMaxSpeed@0049f604, **0x16 BalanceCoolant@0049f728 (WIRED 2026-07-21, issue #20**: press-only, NO novice guard; sets every condenser valveState@0x1D0=1 then the shared redistribute @0049f788 == BTRecomputeCondenserValves; verify BT_BALTEST=1 + BT_VALVE_LOG=1**)**, 0x17 SetBurningState@0049f674, 0x18
RealMaxSpeed@0049f604, **0x16 BalanceCoolant@0049f728 (WIRED 2026-07-21, issue #20**: press-only, NO novice guard; sets every condenser valveState@0x1D0=1 then the shared redistribute @0049f788 == BTRecomputeCondenserValves; verify BT_BALTEST=1 + BT_VALVE_LOG=1**)**, 0x17 SetBurningState@0049f674 (⚠ body mismatch — randomizes position, graphicAlarm 2, sim re-arm; see open-questions), 0x18
ClearBurningState@0049f700, **0x19 EjectPilot@0049f854**, **0x1a DuckRequest@0049fa00** (the
manual's CROUCH; streamed button 0x13 sends it — unreconstructed).
- **BTPlayer** (byte-scanned 2026-08-05, file 0x112dxx; 20-byte rows {id, namePtr, fn, 0, 0} — 6
entries EXACTLY, matching the T0 PLAYER.h enum): **0x15 DropZoneReply@0x4bffd0, 0x16
Score@0x4c02e4** (the type-1/2 switch — BT shadows the engine id with its override), **0x17
VehicleDead@0x4c05c4, 0x18 MissionStarting@0x4bfbe8, 0x19 MissionEnding@0x4bfc20, 0x1a
ScoreUpdate@0x4c02a8** (= Player::NextMessageID). **@0x4c0200 ("ScoreInflicted", type-0-only,
Verify line 0x18b) is in NO entry — dead code in 1995.** The 1995 type-0 path therefore lands in
@0x4c02e4's Verify arm and folds an UNINITIALIZED [ebp-0xc] into currentScore (real bug; port
banks 0). @0x4c02e4 internals [T1 raw]: `+0x40==4` MissionEnding gate; registry-find msg+0x34;
case 1 = `(ownTonnage/senderTonnage) × role->CalcDamageReceivedScore(basis)` + the
console VTVDamaged post (ConsoleHost && !suppressConsole@0x258; **arg 6 = Round(AWARD)** — an
ST0-arg __ftol @0x4dcd94 the decompiler rendered argless; the old "Now()" read was wrong); case
2 = kill: `CalcKillScore@0x4c052c` × (senderTonnage/ownTonnage), self-kill `fchs`, dual
`killCount@0x27c++` (shooter + victim's player — the phantom partner increment, masked by
replication), StatusMessage{type 0 Destroyed, victim player, 6.0s} from pool @0x512f6c with
vtable @0x513344, clear-target @0x4b04d8 via `playerVehicle+0x128` roster head when the dead
mech == `objectiveMech@0x284`. `CalcKillScore@0x4c052c` [T1]: same-team (strcmp
`teamName@0x20c`, gate `freeForAll@0x250==0`) → `-friendlyFirePenalty(role+0x14)`; else
`victimMech->avgZoneDamage@0x354 × damageBias(role+0x18) + 1.0` (const @0x4c05c0); result ×
`(basis + tally) × damageInflictedModifier(role+0xC)`. **ScenarioRole offsets** (Node base):
+0xC inflictedMod, +0x10 receivedMod, +0x14 ffPenalty, +0x18 damageBias, +0x1C killBonus,
+0x20 specialCaseDeathPenalty. **mech+0x354** = average zone damageLevel (computed with the
subsystem average @0x358 by the telemetry refresher, part_012.c:9515). **Wire layouts**
(static_assert-locked, btplayer.hpp): ScoreMessage 0x3C {+0x1c scoreAward=tally, +0x20 type,
+0x24 basis, +0x28 vitalHit, +0x2c zone, +0x30 subsysID(=TakeDamageMessage+0x5c,
inflictingSubsystemID [T0]), +0x34 senderMechID}; BT VehicleDeadMessage 0x38 {engine 0x28 +
0x28=0, +0x2c killed-by PLAYER EntityID, +0x34 kill zone}. Senders: the TakeDamage report tail
— see [[combat-damage]].
- **HeatableSubsystem** @0x50E41C: {3, "ToggleCooling"→@004ad6f8}. **Disassembled 2026-07-20**
(`tools/disas2.py 0x4ad6f8`): a per-subsystem coolant on/off TOGGLE (NOT a multi-level "cycle
priority" — that's the emergent effect, not the mechanism):
+4 -2
View File
@@ -881,8 +881,10 @@ transition, HUD all landed since P6): console egg → mesh → RunningMission on
REMAINING note is closed.
5. ✅ **2-window driving — RESOLVED (tasks #48/#51).** Keyboard MP turning was restored at the root
(the `pilotArray[1]` shrunk-span overrun fix, task #51); `BT_KEY_NOFOCUS` exists for automated
harnesses. DEATHS scoring lights via the existing BTPostKillScore MP branch. Respawn is
reconstructed (task #52 — see item 3b).
harnesses. DEATHS scoring lights via the kill/death report flow (2026-08-05: now the AUTHENTIC
report tail in the victim's TakeDamage handler — BTMechPostCombatReports, replacing
BTPostKillScore; same cross-node reroute, see [[combat-damage]] §report tail). Respawn is
reconstructed (task #52 — see item 3b; the VehicleDead sender moved to the same tail).
## Key Relationships
- Base: [[wintesla-port]] (L4NET). Depends on: [[locomotion]] (update writer), [[combat-damage]]
+22
View File
@@ -931,6 +931,28 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
but is never CALLED, like the salvo mirror was thought to be, or a missing ForceUpdate). A
LIVENESS audit (does a live call path reach each reconstructed fn?) would catch this class.
## Score/death report tail residuals (2026-08-05, #45/#134 reconstruction — the tail itself is DONE + benched, [[combat-damage]])
- **Mech-table 0x17 "SetBurningState" label/body MISMATCH.** The recovered name row says
Set/ClearBurningState for 0x17/0x18, but @0x49f674's body randomizes the mech's position from
two scaled randoms, rebuilds localToWorld, sets graphicAlarm level 2, ForceUpdates and clears
the not-simulated flag — nothing burns. Possibly a shifted name row (cf. the factory ClassID
mislabels). Neither handler is reconstructed (nothing in the port sends mech-0x17/0x18);
identify the real bodies before wiring. The DEATH-tail id-0x17 message that was once labeled
"SetBurningState dispatch" is RESOLVED: it is Player::VehicleDeadMessage (player table, not mech).
- **Does @0x4c05c4 (VehicleDead handler) CONSUME the BT extension fields** (+0x2c killed-by
player, +0x34 kill zone)? The function is not in the export; our #52 reconstruction predates
the fields. If it reads them, the likely consumer is a killed-by console post
(ConsolePlayerMechDeathWithoutHonor / a killed-by line) + maybe an RP-style "DestroyedBy"
status banner on the victim. The port SENDS them faithfully; raw-disasm the handler to close.
- **Collision-death tail fallthrough is inspection-tier [T3]:** the divert now falls to the death
tail (VehicleDead + blast) per @0x4a0375, but no live wall-death bench has run; field
wall-deaths exercise it nightly (watch for DEAD_NOTIFY on collision deaths).
- **The 1995 type-0 uninit-award bug** (documented in [[decomp-reference]] §BTPlayer table): the
shipped binary folds an uninitialized stack float into the shooter's score on every non-lethal
hit's type-0 report. Era scoreboards looked sane — worth understanding WHY (dispatch path may
deterministically leave ~0 at [ebp-0xc]; or replicant-player reroute delivery differs). Port
deviates safely (award=0). Curiosity, not a blocker.
## Rendering follow-ups (non-blocking)
- ~~Per-pilot mech PAINT (color/badge/patch)~~**DONE 2026-07-17, verified live** (crimson MadCat +
yellow VGL emblems + hip hazard stripes). Mechanics + the vehicletable color/badge/patch name
+18
View File
@@ -404,3 +404,21 @@ design change for its own pass. `killCount`/`deathTally` are still zeroed only i
column should read, `+0x280` vs `deathCount`), §Headline-5 (last-hitter-takes-all and
ram kills), and §Headline-6's fidelity question (the wrong-column slip is still
reproduced; it is merely no longer visible).
## Addendum 2026-08-05 — the AUTHENTIC report tail lands (#45/#134)
The kill/damage report sender is no longer the BTPostDamageScore/BTPostKillScore stand-in pair:
`Mech::TakeDamageMessageHandler`'s dark-gap tail (@0x4a02f4-0x4a0890) is reconstructed as
`BTMechPostCombatReports` + `BTMechPostVehicleDead` (btplayer.cpp), dispatching the binary's three
id-0x16 reports (kill to shooter / type-0 wire-fidelity / received to victim) and the BT-extended
0x38 VehicleDead {killed-by player, kill zone} from the victim's handler at the binary's exact
positions. Consequences for this plan:
- KILLS credit path unchanged in shape (victim node -> replicant-player Dispatch reroute ->
killer's master ++killCount) — re-benched cross-node (scorekill.sh: kills=1, award=4.88).
- Suicides now dispatch and the handler negates the award (#134 panic penalty live;
scoreself.sh: type=2 award=-39.00 kills=0).
- Per-hit INFLICTED credit is REMOVED — it never existed in 1995 (@0x4c0200 is bound in no
handler-table entry; scoring = kill awards + received penalties + death costs). SCORE column
behavior changes accordingly; KILLS/DEATHS columns unaffected.
- §Headline-6's wrong-column slip (dual +0x27c increment) remains faithfully reproduced and
replication-masked. The type-1 received report also fixes the console VTVDamaged
points_transfered field (was a Now() misread; it is Round(award), raw disasm @0x4c04a7).
+285 -136
View File
@@ -203,6 +203,35 @@ static_assert(offsetof(BTPlayer::MakeMessage, roleName) == 0x90,
static_assert(sizeof(BTPlayer::MakeMessage) == 0xD0,
"BTPlayer::MakeMessage wire size must be 0xD0");
// Wire-format lock (2026-08-05): the combat score reports and the extended
// VehicleDead cross nodes raw (replicant-player Dispatch reroute), and the
// sender/receiver field map was transcribed from the binary's builds
// (@0x4a04da/@0x4a05d9/@0x4a06c0 and @0x4a07d4) -- lock every offset.
static_assert(offsetof(BTPlayer::ScoreMessage, scoreAward) == 0x1c,
"ScoreMessage::scoreAward must be at wire offset 0x1c");
static_assert(offsetof(BTPlayer::ScoreMessage, scoreType) == 0x20,
"ScoreMessage::scoreType must be at wire offset 0x20");
static_assert(offsetof(BTPlayer::ScoreMessage, damageAmount) == 0x24,
"ScoreMessage::damageAmount must be at wire offset 0x24");
static_assert(offsetof(BTPlayer::ScoreMessage, vitalHit) == 0x28,
"ScoreMessage::vitalHit must be at wire offset 0x28");
static_assert(offsetof(BTPlayer::ScoreMessage, zoneIndex) == 0x2c,
"ScoreMessage::zoneIndex must be at wire offset 0x2c");
static_assert(offsetof(BTPlayer::ScoreMessage, subsysID) == 0x30,
"ScoreMessage::subsysID must be at wire offset 0x30");
static_assert(offsetof(BTPlayer::ScoreMessage, senderMechID) == 0x34,
"ScoreMessage::senderMechID must be at wire offset 0x34");
static_assert(sizeof(BTPlayer::ScoreMessage) == 0x3c,
"ScoreMessage wire size must be 0x3c (binary build @0x4a052b)");
static_assert(offsetof(BTPlayer::VehicleDeadMessage, reserved28) == 0x28,
"VehicleDeadMessage::reserved28 must be at wire offset 0x28");
static_assert(offsetof(BTPlayer::VehicleDeadMessage, killedByPlayerID) == 0x2c,
"VehicleDeadMessage::killedByPlayerID must be at wire offset 0x2c");
static_assert(offsetof(BTPlayer::VehicleDeadMessage, killZone) == 0x34,
"VehicleDeadMessage::killZone must be at wire offset 0x34");
static_assert(sizeof(BTPlayer::VehicleDeadMessage) == 0x38,
"VehicleDeadMessage wire size must be 0x38 (binary build @0x4a07f6)");
//
// LAYOUT LOCK (Gitea #48/#57). Our compiled BTPlayer is NOT the binary's -- the
// Entity base differs -- so the 1995 offsets must never be used as raw byte
@@ -851,30 +880,45 @@ void
{
//
// Points lost for damage we took (unless we hit ourselves).
// sender null guard is PORT SAFETY: the binary derefs the registry
// lookup unguarded (@0x4c0453) -- an inflictor that died between
// shot and impact would have crashed the 1995 pod.
//
if (sender_mech != our_mech && scenarioRole != 0) // scoring wave: guard the NULL bring-up role
if (sender_mech != 0 && our_mech != 0 // port guards (dead-window straggler)
&& 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;
}
// BINARY NUANCE [T1 raw disasm @0x4c0433]: on SELF-inflicted damage
// (sender == own vehicle) the 1995 arm skips the math and the common
// tail folds its UNINITIALIZED award local into currentScore --
// stack garbage, every fall/ram self-hit. The port keeps award=0.0,
// a deliberate safe deviation.
//
// Tell the console (if any, and not suppressed) that our VTV
// was damaged.
// was damaged. points_transfered CORRECTED 2026-08-05: the binary
// rounds THE COMPUTED AWARD here (raw disasm @0x4c04a7: fld
// [ebp-0xc]; call 0x4dcd94 -- an ST0-arg __ftol the decompiler
// rendered argless, which an earlier pass misread as Now()).
//
Host *console_host =
application->GetHostManager()->GetConsoleHost(); // FUN_00429078
if (console_host && suppressConsole == 0) // this+0x258
BTPlayer *damager_player =
(sender_mech != 0) ? MECH_OWNING_PLAYER(sender_mech) : 0; // port guard (dummy shooter)
if (console_host && suppressConsole == 0 // this+0x258
&& damager_player != 0)
{
ConsolePlayerVTVDamagedMessage damaged_message(
ownerID, // this+0x18c
MECH_OWNING_PLAYER(sender_mech)->ownerID, // *(sender+0x190)+0x18c
damager_player->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
message->zoneIndex, // msg+0x2c
message->vitalHit, // msg+0x28
Round(award), // FUN_004dcd94(ST0)
message->subsysID // msg+0x30
);
application->SendMessage( // app+0x20, slot+0x18
@@ -888,6 +932,13 @@ void
case BTPlayer::ScoreMessage::KillScore: // 2
{
// PORT SAFETY: an unresolvable victim mech (removed wreck racing the
// cross-node dispatch) or a severed own vehicle skips the arm; the
// binary derefs both unguarded (@0x4c037e).
if (sender_mech == 0 || our_mech == 0)
{
break;
}
// 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
@@ -2191,149 +2242,247 @@ Logical BTResolveMessageBoard(Entity * /*tracked_mech*/, int *messageId, BitMap
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// 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.
// THE AUTHENTIC COMBAT REPORT SENDER (#45/#134 -- 2026-08-05)
//
void BTPostDamageScore(Entity *victim, Scalar damage, Entity *shooter) // Step 6: per-hit SCORE
// Reconstructed from the raw disasm of the Mech::TakeDamageMessageHandler
// report tail @0x4a02f4-0x4a0890 (dark-gap region, not in the export). This
// RETIRES the bring-up producers BTPostDamageScore / BTPostKillScore (see the
// tombstone below). The binary's flow, transcribed:
//
// resolve: shooterEntity = registry find(msg->inflictingEntity) @0x4a033c
// shooterPlayer = shooterEntity+0x190 @0x4a034f
// victimPlayer = mech+0x190 @0x4a0358
// block A @0x4a04da (was ALIVE at entry, destroyed now):
// ScoreMessage{id 0x16, type 2 KillScore,
// scoreAward = the APPLIED damage tally,
// damageAmount = victim role's killBonus (role+0x1c),
// vitalHit, zone, subsysID,
// senderMechID = the VICTIM} -> the SHOOTER's player
// block B @0x4a05d9 (not newly killed, tally != 0):
// same shape, type 0 -- which the only registered 0x16 receiver
// (@0x4c02e4) VERIFY-rejects; 1995 then folded an UNINITIALIZED
// award. Sent for wire fidelity; our handler banks 0.
// block C @0x4a06c0 (tally > 0; runs after A too -- kills included):
// ScoreMessage{type 1 DamageReceivedScore,
// scoreAward = tally,
// damageAmount = INTENDED (burstCount x amount),
// senderMechID = the INFLICTOR} -> the victim's player
//
// Score-model consequence [T1]: shooters are credited ONLY for kills; victims
// are PENALIZED for damage received (CalcDamageReceivedScore returns the
// negative). Per-hit inflicted credit never existed in the 1995 pod.
//
// Port accommodations, all guarded: registry/player lookups null-checked (the
// binary derefs them raw); killBonus basis 0 for the ownerless dummy victim;
// a skipped kill credit keeps the #45 NOCREDIT matchlog forensics.
//
void BTMechPostCombatReports(
void *mech_v, // the VICTIM mech (the handler's this)
void *msg_v, // the TakeDamageMessage being handled
float damage_tally, // loop local_24: applied damage + crit bonuses
int vital_hit, // loop local_2c: a zone entered BurningState
int report_zone, // msg->damageZone at loop ENTRY (the binary
// reads msg+0x24, never rewritten mid-loop)
int newly_destroyed) // alive at entry && IsMechDestroyed() now
{
if (application == 0 || victim == 0)
Mech *victim = (Mech *)mech_v;
Entity::TakeDamageMessage *msg = (Entity::TakeDamageMessage *)msg_v;
if (application == 0 || victim == 0 || msg == 0)
{
return;
}
BTPlayer *local_player = (BTPlayer *)application->GetMissionPlayer();
if (local_player == 0)
{
return;
}
// The credit belongs to the SHOOTER, not the viewer: the binary reports the
// victim handler's damage tally to the INFLICTING player (the id-0x16 report
// tail @0x4a04da, deferred #45). This stand-in can only bank score on the
// LOCAL player, so refuse any post whose shooter is not the local vehicle --
// an AI master's fire on the player (SP), or a dying mech's death-blast
// splash, must not credit the viewer. MP is unaffected: only local fire
// carries live damage on this node.
if (shooter != (Entity *)local_player->GetPlayerVehicle())
{
return;
}
// ScoreInflicted (scoreType 0): senderMechID = the mech that TOOK the damage.
// -> ScoreInflictedMessageHandler: currentScore += tonnageRatio*CalcInflictedScore.
BTPlayer::ScoreMessage message(
BTPlayer::ScoreInflictedMessageID, // 0x16
sizeof(BTPlayer::ScoreMessage),
BTPlayer::ScoreMessage::DamageInflictedScore, // 0
0.0f, // scoreAward (unused for inflicted)
damage, // damageAmount
victim->GetEntityID()); // senderMechID = victim
local_player->Dispatch(&message);
if (getenv("BT_SCORE_LOG"))
DEBUG_STREAM << "[score] +damage " << damage << " -> currentScore="
<< (Scalar)local_player->GetScore() << "\n" << std::flush;
}
void BTPostKillScore(Entity *victim, Scalar damage) // Step 7: KILL (+ MP death)
{
if (application == 0 || victim == 0)
{
return;
}
// KILL credit. Runs on the VICTIM's node -- the only node whose mech carries a
// populated `lastInflictingID` (damage is applied master-side only: 0 of 8800
// corpus DMG rows target a replicant, which is why every `DEATH inst=R` row reads
// `killer=0:0`). The credit is dispatched to the killer's Player, which HERE is a
// REPLICANT, so `Entity::Dispatch` reroutes it to the owning host
// (ENTITY.cpp:244-251) and `++killCount` lands on the killer's OWN machine.
//
// CORRECTED 2026-07-25 (Gitea #45). The previous banner claimed "every node
// maintains LOCAL score copies ... each node witnesses the death transition and
// tallies its own copies self-consistently". That was FALSE and it is what hid
// this bug: the reroute means exactly ONE node increments, no other node can
// observe the killer at all, and nothing carried the value back out -- so every
// remote pilot's KILLS read 0 on every other pod, all mission (corpus invariant:
// 0 of 18818 DMG rows are inst=R, so no other node can even attribute the kill;
// every DEATH inst=R row reads killer=0:0). The counters are
// now replicated owner->replicant instead; see Read/WriteUpdateRecord above.
BTPlayer *killer_player = 0;
BTPlayer *shooter_player = 0;
Entity *shooter = 0;
extern int BTIsRegisteredMech(Entity *e);
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();
shooter = application->GetHostManager()->GetEntityPointer(
msg->inflictingEntity);
if (shooter != 0 && BTIsRegisteredMech(shooter))
shooter_player = (BTPlayer *)((Mech *)shooter)->GetPlayerLink();
}
if (killer_player != 0)
BTPlayer *victim_player = (BTPlayer *)victim->GetPlayerLink();
if (newly_destroyed)
{
// 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
//
// Block A: the KILL report, to the SHOOTER's player. On the victim's
// node that player is a replicant, so Entity::Dispatch reroutes to the
// owning host (ENTITY.cpp:244) and the credit lands on the killer's own
// machine -- the #45 flow, unchanged. killer == victim (the eject
// charge) IS dispatched: the handler negates the award. That is the
// #134 panic-penalty path the old BTPostKillScore filtered out.
//
Scalar kill_bonus = 0.0f; // basis: victim role+0x1c
if (victim_player != 0 && victim_player->GetScenarioRole() != 0)
kill_bonus = victim_player->GetScenarioRole()->GetKillBonus();
if (shooter_player != 0)
{
BTPlayer::ScoreMessage kill(
Player::ScoreMessageID, // 0x16
sizeof(BTPlayer::ScoreMessage), // 0x3c
BTPlayer::ScoreMessage::KillScore, // 2
damage_tally, // +0x1c
kill_bonus, // +0x24
victim->GetEntityID(), // +0x34 the VICTIM
vital_hit, // +0x28
report_zone, // +0x2c
msg->inflictingSubsystemID); // +0x30
shooter_player->Dispatch(&kill);
if (getenv("BT_SCORE_LOG"))
DEBUG_STREAM << "[score] *** KILL report *** -> shooterPlayer="
<< (void *)shooter_player << " tally=" << damage_tally
<< " bonus=" << kill_bonus << "\n" << std::flush;
}
else if (BTMatchLogActive())
{
// #45 forensics, carried from BTPostKillScore: WHY the credit was
// skipped -- missing killer entity, unregistered, or NULL link.
BTMatchLog("NOCREDIT",
"victim=%d:%d killerID=%d:%d found=%d registered=%d link=%d",
BTMatchHostOf(victim->GetEntityID()), (int)victim->GetEntityID(),
BTMatchHostOf(msg->inflictingEntity), (int)msg->inflictingEntity,
(int)(shooter != 0),
(int)(shooter != 0 && BTIsRegisteredMech(shooter)),
(int)(shooter != 0 && ((Mech *)shooter)->GetPlayerLink() != 0));
}
}
else if (damage_tally != 0.0f && shooter_player != 0)
{
//
// Block B: the plain inflicted report. Wire fidelity only -- the 0x16
// handler Verify-rejects type 0 and banks award 0 (1995 banked an
// uninitialized stack float; see the handler's type-0 arm note).
//
BTPlayer::ScoreMessage inflicted(
Player::ScoreMessageID,
sizeof(BTPlayer::ScoreMessage),
BTPlayer::ScoreMessage::KillScore, // 2
0.0f, // scoreAward (killBonus; 0 for bring-up)
damage, // damageAmount (killing-blow)
victim->GetEntityID()); // senderMechID = victim
killer_player->Dispatch(&kill);
if (getenv("BT_SCORE_LOG"))
DEBUG_STREAM << "[score] *** KILL *** killerPlayer=" << (void *)killer_player
<< " killCount=" << killer_player->GetKillCount()
<< " deaths=" << killer_player->GetDeaths()
<< " score=" << (Scalar)killer_player->GetScore() << std::endl;
}
else if (BTMatchLogActive())
{
//
// #45 observability: the credit was SKIPPED. This used to be completely
// silent, which is why the bug survived so long -- the counter simply never
// moved and no log said why. Record which link in the chain broke so a
// single matchlog convicts it: a missing killer entity, an unregistered
// mech, a self-kill, or the NULL playerLink hazard.
//
Entity *k = (application->GetHostManager() != 0)
? application->GetHostManager()->GetEntityPointer(
((Mech *)victim)->lastInflictingID)
: 0;
extern int BTIsRegisteredMech(Entity *e);
BTMatchLog("NOCREDIT",
"victim=%d:%d killerID=%d:%d found=%d self=%d registered=%d link=%d",
BTMatchHostOf(victim->GetEntityID()), (int)victim->GetEntityID(),
BTMatchHostOf(((Mech *)victim)->lastInflictingID),
(int)((Mech *)victim)->lastInflictingID,
(int)(k != 0), (int)(k == victim),
(int)(k != 0 && BTIsRegisteredMech(k)),
(int)(k != 0 && ((Mech *)k)->GetPlayerLink() != 0));
BTPlayer::ScoreMessage::DamageInflictedScore, // 0
damage_tally, // +0x1c
damage_tally, // +0x24 basis = tally
victim->GetEntityID(),
vital_hit, report_zone, msg->inflictingSubsystemID);
shooter_player->Dispatch(&inflicted);
}
// MP DEATH: the victim's death tally.
//
// REMOVED 2026-07-30 (#81): this used to dispatch a second
// `Player::VehicleDeadMessage` to the victim's own player "to credit a
// death". That message is the RESPAWN-CYCLE TRIGGER, not a scoreboard
// increment, and the victim already receives one from the mech's death
// transition (mech4.cpp:2110) -- which is the hardened, authentic notify
// (it carries the #55 NULL-playerLink fallback). Both fire inside the SAME
// death transition (BTPostKillScore is called at mech4.cpp:2006, the notify
// at :2110), so they were always paired: every death dispatched the message
// TWICE. The tally itself is credited by the handler's `++deathTally`
// (:538) on the first one, so this dispatch never added anything.
//
// It was invisible because the `deathPending` latch silently deduped it --
// that is what EVERY "death ... SWALLOWED" warning in the field logs
// actually was (8 of 8 deaths, a 100% base rate, which is exactly why it
// correlated with nothing). Once the latch was released to match the
// binary (see the death handler's tail), the duplicate stopped being
// masked and started a SECOND death cycle: deathCount double-incremented,
// the first cycle's re-post went stale and tripped the drop-zone
// `*** MISMATCH ***`, and one respawn burned two cycles. Caught on the
// first solo bench of the latch fix.
//
// The kill credit above is unaffected -- it dispatches a ScoreMessage, the
// correct message for a scoreboard change.
if (damage_tally > 0.0f && victim_player != 0)
{
//
// Block C: the RECEIVED report, to the victim's own player -- kills
// included (the binary runs this after block A as well). Carries the
// INTENDED damage (burstCount x amount, @0x4a06d2 fild/fmul) as the
// penalty basis and the INFLICTOR as senderMechID; feeds the score
// penalty and the operator-console VTVDamaged line.
//
int bursts = msg->damageData.burstCount;
if (bursts < 1)
bursts = 1; // port guard (mirrors the loop)
BTPlayer::ScoreMessage received(
Player::ScoreMessageID,
sizeof(BTPlayer::ScoreMessage),
BTPlayer::ScoreMessage::DamageReceivedScore, // 1
damage_tally, // +0x1c
(Scalar)bursts * msg->damageData.damageAmount, // +0x24 intended
msg->inflictingEntity, // +0x34 the INFLICTOR
vital_hit, report_zone, msg->inflictingSubsystemID);
victim_player->Dispatch(&received);
}
}
//
// The authentic VehicleDead sender (@0x4a07d4-0x4a0890): the respawn-cycle
// trigger, dispatched from the death tail of the SAME TakeDamage that killed
// the mech, to the mech's own player -- now carrying the killed-by player and
// the killing zone (the BT 0x38-byte message extension). Replaces the mech4
// death-transition dispatch site, which carried a [T3 -- the binary's exact
// sender is undecoded] flag; this IS that sender, decoded. The #55
// NULL-playerLink fallback and the DEAD_NOTIFY forensics move here intact.
//
void BTMechPostVehicleDead(void *mech_v, void *msg_v, int report_zone)
{
Mech *victim = (Mech *)mech_v;
Entity::TakeDamageMessage *msg = (Entity::TakeDamageMessage *)msg_v;
if (application == 0 || victim == 0)
{
return;
}
Player *owner = victim->GetPlayerLink();
// #55 (the David fix), moved intact: resolve the SAME object by the
// reverse link the binary's own respawn branch uses when the once-written
// playerLink was lost.
if (owner == 0)
{
Player *mission_player = (Player *)application->GetMissionPlayer();
if (mission_player != 0
&& mission_player->GetPlayerVehicle() == (Entity *)victim)
{
owner = mission_player;
}
}
BTMatchLog("DEAD_NOTIFY", "mech=%d:%d link=%p",
BTMatchHostOf(victim->GetEntityID()), (int)victim->GetEntityID(),
(void *)owner);
if (owner == 0)
{
return;
}
// killed-by: the shooter PLAYER's own EntityID (@0x4a07d4 copies
// shooterPlayer+0x184); Null when the inflictor or its player is gone.
EntityID killed_by = EntityID::Null;
if (msg != 0 && application->GetHostManager() != 0)
{
Entity *shooter = application->GetHostManager()->GetEntityPointer(
msg->inflictingEntity);
extern int BTIsRegisteredMech(Entity *e);
if (shooter != 0 && BTIsRegisteredMech(shooter))
{
Player *shooter_player = ((Mech *)shooter)->GetPlayerLink();
if (shooter_player != 0)
killed_by = shooter_player->GetEntityID();
}
}
BTPlayer::VehicleDeadMessage vehicle_dead(
Player::VehicleDeadMessageID, // 0x17
sizeof(BTPlayer::VehicleDeadMessage), // 0x38
killed_by,
report_zone);
owner->Dispatch(&vehicle_dead);
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[death] VehicleDead(-1) dispatched to the owning player"
<< " (killedBy=" << BTMatchHostOf(killed_by) << ":" << (int)killed_by
<< " zone=" << report_zone << ")\n" << std::flush;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// TOMBSTONE (2026-08-05): BTPostDamageScore / BTPostKillScore -- the gauge
// scoring wave's bring-up producers -- are RETIRED, replaced by the authentic
// report tail above (BTMechPostCombatReports / BTMechPostVehicleDead, called
// from Mech::TakeDamageMessageHandler at the binary's exact positions).
// What changed for the scoreboard:
// - per-hit INFLICTED credit is GONE: it was a port invention riding the
// never-registered @0x4c0200 handler (the binary's BTPlayer table, byte-
// scanned at file 0x112dxx, has 6 entries and none binds it). 1995
// scoring = kill awards + received-damage penalties, nothing per-hit.
// This also retires the #95 salvo-credit fix that rode it.
// - suicides now DISPATCH the kill report and the handler NEGATES the
// award -- the #134 panic-eject penalty path the old filter blocked.
// - kill credit resolves the shooter from msg->inflictingEntity of the
// killing TakeDamage itself; lastInflictingID (stamped from the same
// field at handler entry) stays for the LOD router + effect orientation.
// - the cross-node reroute story is unchanged: the kill report still
// Dispatches to a replicant player and lands on the owner host
// (ENTITY.cpp:244-251); KILLS/DEATHS still replicate owner->replicant
// (Read/WriteUpdateRecord + the scoreboard heartbeat).
// - the #81 single-VehicleDead rule is unchanged: exactly one dispatch per
// death, now from the authentic tail instead of the death transition.
// The ScoreInflictedMessageID channel and its handler remain registered but
// idle (port infra; the binary's analog was dead code in the table).
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//#############################################################################
// BTPlayerRoleLocksAdvanced -- complete-type bridge (task #12)
+86 -22
View File
@@ -57,14 +57,26 @@ class DropZone__ReplyMessage;
// generated the points. Parallels RPPlayer__ScoreMessage, but the BT
// score types describe mech combat instead of score zones.
//
// Observed message layout (param_2 in the handlers):
// +0x1c scoreAward (Scalar, base Player::ScoreMessage)
// Message layout, sizeof 0x3C (field meanings CORRECTED 2026-08-05 from the
// raw disasm of the three sender builds in the Mech::TakeDamageMessageHandler
// report tail @0x4a04da/@0x4a05d9/@0x4a06c0 -- the old "pointSender" names
// were guesses from RP's smaller message, which stops at +0x28):
// +0x1c scoreAward (Scalar, base) -- the sender puts the APPLIED
// damage tally here; the receiving handler
// overwrites it with the computed award before
// delegating to Player::ScoreMessageHandler
// +0x20 scoreType (int)
// +0x24 damageAmount (Scalar) raw damage / point quantity
// +0x28 pointSenderHi (EntityID word)
// +0x2c pointSenderLo (EntityID word)
// +0x30 auxID (EntityID / host word)
// +0x34 senderMechID (EntityID -- resolved to the inflicting Mech)
// +0x24 damageAmount (Scalar) the scoring BASIS: type 0 = tally,
// type 1 = intended (burstCount x amount),
// type 2 = the VICTIM role's killBonus (role+0x1c)
// +0x28 vitalHit (int) a zone reached BurningState during
// this message's applications (loop local_2c)
// +0x2c zoneIndex (int) the struck damage zone (msg+0x24,
// the INITIAL resolve -- not the last burst's)
// +0x30 subsysID (int) inflictingSubsystemID passed through
// from the TakeDamageMessage (+0x5c)
// +0x34 senderMechID (EntityID) types 0/2: the VICTIM mech;
// type 1: the INFLICTING mech
//
class BTPlayer__ScoreMessage:
public Player::ScoreMessage
@@ -72,11 +84,16 @@ class DropZone__ReplyMessage;
public:
//
// Kind of scoring event. Recovered from the branch selector at
// @004c02e4 (this->scoreType, message+0x20) and the dedicated
// inflicted-damage handler at @004c0200.
// @004c02e4 (this->scoreType, message+0x20). NOTE: type 0 has NO
// scoring arm in the binary -- @004c02e4 Verify-rejects it (line 662)
// and @004c0200, the only function that accepts it, appears in no
// handler-table entry (byte-scan 2026-08-05: the BTPlayer table at
// file 0x112dxx has exactly 6 entries, none binding it). 1995 pod
// scoring = kills + received-damage penalties; per-hit inflicted
// credit never existed.
//
enum ScoreType {
DamageInflictedScore = 0, // to ScoreInflictedMessageHandler
DamageInflictedScore = 0, // sent, but scores nothing (see above)
DamageReceivedScore = 1, // I took damage
KillScore = 2 // I destroyed / was destroyed
};
@@ -85,21 +102,19 @@ class DropZone__ReplyMessage;
scoreType; // +0x20
Scalar
damageAmount; // +0x24
damageAmount; // +0x24 scoring basis (see layout table)
//
// Point-source / auxiliary handles carried alongside the damage record
// (read by ScoreMessageHandler when building the console feed message).
//
int
pointSenderHi; // +0x28
vitalHit; // +0x28 zone entered BurningState this message
int
pointSenderLo; // +0x2c
zoneIndex; // +0x2c struck damage zone
int
auxID; // +0x30
subsysID; // +0x30 inflicting weapon subsystem
EntityID
senderMechID; // +0x34 inflicting mech (point sender)
senderMechID; // +0x34 victim (types 0/2) / inflictor (type 1)
BTPlayer__ScoreMessage(
Receiver::MessageID message_ID,
@@ -107,15 +122,63 @@ class DropZone__ReplyMessage;
int score_type,
Scalar score_award,
Scalar damage_amount,
const EntityID &sender_mech_ID
const EntityID &sender_mech_ID,
int vital_hit = 0,
int zone_index = 0,
int subsys_ID = 0
):
Player::ScoreMessage(message_ID, length, score_award),
scoreType(score_type),
damageAmount(damage_amount),
vitalHit(vital_hit),
zoneIndex(zone_index),
subsysID(subsys_ID),
senderMechID(sender_mech_ID)
{}
};
//###########################################################################
//################# BTPlayer::VehicleDeadMessage ######################
//###########################################################################
//
// BT extension of the engine Player__VehicleDeadMessage (PLAYER.h:
// deathCount @0x1c, dropZoneID @0x20). Decoded 2026-08-05 from the death
// tail of Mech::TakeDamageMessageHandler (@0x4a07d4-0x4a0890, raw disasm):
// the binary builds a 0x38-byte message {size 0x38, id 0x17 ==
// Player::VehicleDeadMessageID, pri 1, deathCount -1 (the engine ctor
// default), dropZoneID Null, +0x28 = 0, +0x2c = the KILLER PLAYER's own
// EntityID (shooterPlayer+0x184), +0x34 = the killing zone (msg+0x24)} and
// dispatches it to the dying mech's own player -- the respawn-cycle
// trigger, now carrying the killed-by attribution. Whether the @004c05c4
// handler consumes the two extension fields is still undecoded (the
// function is not in the export); the sender carries them faithfully.
//
class BTPlayer__VehicleDeadMessage:
public Player::VehicleDeadMessage
{
public:
int
reserved28; // +0x28 zeroed by the binary (@0x4a0856)
EntityID
killedByPlayerID; // +0x2c the killer PLAYER's EntityID
int
killZone; // +0x34 zone the killing damage struck
BTPlayer__VehicleDeadMessage(
Receiver::MessageID message_ID,
size_t length,
const EntityID &killed_by_player_ID,
int kill_zone
):
Player::VehicleDeadMessage(message_ID, length), // deathCount -1, dropZone Null
reserved28(0),
killedByPlayerID(killed_by_player_ID),
killZone(kill_zone)
{}
};
//###########################################################################
//##################### BTPlayer::MakeMessage #########################
//###########################################################################
@@ -202,8 +265,9 @@ class DropZone__ReplyMessage;
// Player::ScoreMessage.)
//
public:
typedef BTPlayer__ScoreMessage ScoreMessage;
typedef BTPlayer__MakeMessage MakeMessage;
typedef BTPlayer__ScoreMessage ScoreMessage;
typedef BTPlayer__MakeMessage MakeMessage;
typedef BTPlayer__VehicleDeadMessage VehicleDeadMessage; // BT 0x38 ext (killed-by)
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Message Support
+70 -41
View File
@@ -1063,24 +1063,40 @@ void
(float)(message->damageData.impactPoint.z - localOrigin.linearPosition.z));
}
//
// Death-edge latch, MOVED 2026-08-05 to the authentic position (binary
// @0x4a0303: captured at handler ENTRY, before the collision divert). It
// arms the whole death tail -- kill report, VehicleDead, death blast.
// Capturing it after the divert (where #89 first placed it) meant a
// COLLISION death could never arm the tail.
//
const int deathBlastArmed = !IsMechDestroyed(); // [ebp-0x10], inverted
//
// The zone the reports + VehicleDead carry: msg+0x24 as of loop entry.
// The binary never rewrites msg+0x24 after the initial cylinder resolve;
// our loop-exit matchlog write (damageZone = LAST burst's zone) must not
// leak into them.
//
int reportZone = message->damageZone;
DamageLookupTable *table = (DamageLookupTable *)damageLookupTable; // named member (Wword absorbs!)
//
// @0x4a0368 -- the COLLISION DIVERT (#83, closing the #82 chain). Damage
// type 0 == the mover's collision damage (what the crash response policy
// forwards). It NEVER reaches the zone/armor loop below: it is priced +
// distributed as INTERNAL RATTLE by DistributeCollisionDamage (@0049ffcc)
// and the handler is done. This was the missing half of the crash
// self-damage reconstruction -- without the divert, the raw kinetic-energy
// amounts (a 60-ton mech prices ~1000+ per wall tap) fell through into the
// WEAPON loop and a single bump killed the mech (run-14 field report).
// distributed as INTERNAL RATTLE by DistributeCollisionDamage (@0049ffcc).
// CORRECTED 2026-08-05: the binary does NOT return here -- it JUMPS TO THE
// DEATH TAIL (raw disasm @0x4a0375: jmp 0x4a07b5), so a mech that dies of
// the rattle still posts VehicleDead (the respawn trigger) and still
// blasts. The early `return` this block shipped with was a latent
// "wall-death strands the pilot" hazard. No score reports on this path --
// the jump bypasses them; a collision death credits no one. Authentic.
//
if (message->damageData.damageType == 0)
{
DistributeCollisionDamage(&message->damageData);
Check_Fpu();
return;
goto death_tail;
}
DamageLookupTable *table = (DamageLookupTable *)damageLookupTable; // named member (Wword absorbs!)
// #92: when this gate fails the zone keeps whatever the message carried --
// which for an unaimed weapon hit is 0, i.e. EVERY shot lands on zone 0.
if (getenv("BT_DMGTABLE_LOG"))
@@ -1105,6 +1121,7 @@ void
<< message->damageData.impactPoint.z << ")\n" << std::flush;
}
}
reportZone = message->damageZone; // post-resolve (@0x4a0396 write)
//
// #80 -- the faithful application loop (binary @0x4a0423-0x4a04d8), which
// REPLACES the engine-base single application. Three things the base
@@ -1126,11 +1143,6 @@ void
// nothing (matches ENTITY.cpp:878; the binary would deref -1 -- it can't
// happen there because every mech ships a lookup table).
//
// #89 DEATH-BLAST edge arm: the binary's tail (@0x4a07b8) gates on the
// victim ENTERING dead/eject during THIS message (entry test edx = the
// pre-application disabled state). Capture the pre-state here.
const int deathBlastArmed = !IsMechDestroyed();
if (damageZones != 0 && message->damageZone >= 0
&& message->damageZone < damageZoneCount)
{
@@ -1181,14 +1193,20 @@ void
}
message->damageZone = zoneIndex; // matchlog sees the LAST zone
// Binary tail (@0x4a04da-0x4a07b2, decoded + deferred): builds id-0x16
// damage/kill REPORT messages -- {tally, zone, zoneDestroyed flag,
// inflicting subsystem, victim name} -- to the shooter's player (with a
// kill-flagged variant when this damage NEWLY disabled the mech) and to
// the victim's player. That is the authentic stats plumbing (#45); the
// port's matchlog + BTPostDamageScore cover the bookkeeping today [T3].
(void)zoneDestroyed;
(void)damageTally;
//
// THE REPORT TAIL (@0x4a04da-0x4a07b2), reconstructed 2026-08-05 [T1]:
// the kill / inflicted / received score reports to the shooter's and
// victim's players. Sender lives in btplayer.cpp (complete-BTPlayer
// TU, per the databinding rule); it receives the APPLIED tally, the
// vital-wreck flag, and the loop-ENTRY zone. This retires
// BTPostDamageScore/BTPostKillScore -- see the btplayer.cpp tombstone.
//
{
extern void BTMechPostCombatReports(void *, void *, float, int, int, int);
BTMechPostCombatReports((void *)this, (void *)message,
(float)damageTally, zoneDestroyed, reportZone,
deathBlastArmed && IsMechDestroyed());
}
}
// MP MATCH FORENSICS (matchlog.hpp): the victim-side authoritative damage
@@ -1218,30 +1236,41 @@ void
(float)message->damageData.impactPoint.z);
}
// #89 DEATH BLAST (raw disasm @0x4a07b8-0x4a0bda -- the un-exported tail
// of THIS handler; found by call-scanning Explosion::SplashDamage
// @0042fad0, which has exactly TWO callers: Missile::Perform and here).
// The binary, when the victim ENTERS dead(9)/eject(10) during the
// applications above:
// 1. self-dispatches SetBurningState (id 0x17, Damage payload, zone -1)
// -- the burning wreck. DEFERRED here: our 0x17 handler is not yet
// reconstructed; the port's wreck-fire visuals come from the death
// effect layer meanwhile.
// 2. spawns the death Explosion (Explosion::Make model 0x31 at the mech
// origin) -- the port's kill path already fires the authored death
// list visuals; not double-spawned.
// 3. calls SplashDamage on it, gated on the owning player's
// advancedDamageOn (+0x264) AND NOT suppressConsole (+0x258 -- set
// by the eject bookkeeping: an EJECTED pilot's mech never blasts):
// THE DEATH TAIL (@0x4a07b5-0x4a0bda, raw disasm -- the un-exported tail
// of THIS handler). Gate [T1]: mech was ALIVE at entry ([ebp-0x10]) and
// is dead(9)/eject(10) now. Sequence:
// 1. Player::VehicleDeadMessage (id 0x17; the BT 0x38-byte extension
// carrying killed-by player + kill zone) -> the mech's OWN player:
// the respawn-cycle trigger. CORRECTED 2026-08-05: the #89 banner
// previously read this build as "SetBurningState (mech id 0x17)" --
// it is a PLAYER-table id: the binary's BTPlayer handler table (file
// 0x112dxx) binds 0x17 -> @0x4c05c4 VehicleDeadMessageHandler; the
// mech-table 0x17 (@0x49f674) is unrelated. Dispatched from HERE --
// this is the decoded sender the mech4 transition site's
// [T3 sender-undecoded] flag was waiting for.
// 2. the death Explosion (id 3 MakeMessage, 0x5C bytes, model 0x31 at
// the mech origin, @0x4a08bd) -- the port fires the authored per-mech
// death list from the death transition instead (#42 history): same
// once-per-death edge, not double-spawned.
// 3. SplashDamage (#89), gated advancedDamageOn (+0x264) AND NOT
// suppressConsole (+0x258 -- ejected pilots' mechs never blast):
// Damage{type=2 Explosive, amount=deathSplashDamage@0x520,
// impact=mech origin,
// burstCount=round(0.001 * moverMass@0x20c * 15.0)}
// radius=deathSplashRadius@0x524, excluded=the dying mech.
// Splash falloff (bursts / dist^1.25) applies inside per victim.
// 4. ForceUpdate(1). (A score/console post also rides this tail --
// the deferred id-0x16 family, tracked with #134.)
// radius=deathSplashRadius@0x524, excluded=the dying mech;
// falloff bursts/dist^1.25 per victim inside the shared core.
// 4. ForceUpdate(1).
// The gate predicate here is IsMechDestroyed() (graphicAlarm >= 9, the
// structural flag @0x49fb54) -- the binary tests movementMode 9|10, but
// the death transition sets mode 9 synchronously with the structural flag
// on every path through here, so the edges coincide; this exact predicate
// is the one the #89 blast benches verified both ways.
death_tail:
if (deathBlastArmed && IsMechDestroyed())
{
extern void BTMechPostVehicleDead(void *, void *, int);
BTMechPostVehicleDead((void *)this, (void *)message, reportZone);
extern void BTApplyDeathSplash(void *mech_v);
BTApplyDeathSplash((void *)this);
}
+33 -74
View File
@@ -705,10 +705,12 @@ static int gEnemyDestroyed = 0;
// BRING-UP: the spawned target/enemy mech (defined in btplayer.cpp). The player
// mech locks onto it as its current target each frame (see targeting step below).
extern Entity *gEnemyMech;
// gauge scoring wave: producers (btplayer.cpp) -- feed the scoreboard from combat.
extern void BTPostDamageScore(Entity *victim, Scalar damage, Entity *shooter); // per-hit SCORE (ScoreInflicted)
// RETIRED 2026-08-05: the BTPostDamageScore / BTPostKillScore producers.
// Scoring now flows through the authentic report tail in the victim's
// TakeDamageMessageHandler (BTMechPostCombatReports, btplayer.cpp) -- the
// shooter-side posts here double-credited against it. See the tombstone in
// btplayer.cpp.
float gBTWalkElev = 0.0f; // #124 zone walker: servo-driven aim elevation (rad)
extern void BTPostKillScore(Entity *victim, Scalar damage); // KILL (+ MP death)
// Mech target slots (verified vs the binary's weapon/fire path, part_013.c):
// mech+0x37c Point3D current target world position (range/aim source)
@@ -1311,12 +1313,11 @@ static void
shooter->GetEntityID(), -1 /*unaimed -> cylinder resolves*/, dmg);
e->Dispatch(&td);
// SCORE (#95 follow-up): the binary's victim handler tallies EVERY
// TakeDamage -- splash included -- and reports it to the INFLICTING
// player (id-0x16, deferred #45), so a bystander blast is score too.
// The bridge itself refuses a shooter that is not the local vehicle
// (this function's death-blast caller passes the DYING mech).
BTPostDamageScore(e, dmg.damageAmount * (Scalar)bursts, shooter);
// SCORE: handled by the victim's own TakeDamage handler now -- the
// dispatch above lands in the authentic report tail (#45), which
// posts the kill/received reports itself. The shooter-side
// BTPostDamageScore call that lived here was RETIRED 2026-08-05
// (per-hit inflicted credit never existed in the 1995 pod).
// MP MATCH FORENSICS (matchlog.hpp): shooter-side splash delivery
// to a bystander (pairs with the victim's DMG burst>1 line).
@@ -1864,17 +1865,12 @@ static void
(p.shooter != 0) ? p.shooter->GetEntityID() : EntityID::Null,
-1 /*unaimed -> cylinder resolves*/, dmg, p.weaponSubsys);
tgt->Dispatch(&take_damage);
// gauge scoring wave (Step 6): a projectile hit credits SCORE with
// the DELIVERED amount -- per-missile damage x the rolled cluster
// count, the same figure the dispatch above hands the victim's
// handler (which applies it burstCount times, tallying the amount
// per burst @0x4a04da). Posting the bare per-missile amount here
// was the "LRM10 registered ~3 points to score" defect (#95): the
// armour took the full salvo while the scoreboard banked one
// missile. (Crit bonuses in the tally remain victim-side
// knowledge -- the deferred id-0x16 report, #45.)
BTPostDamageScore((Entity *)tgt,
p.damage * (Scalar)dmg.burstCount, p.shooter);
// SCORE: handled by the victim's own TakeDamage handler now --
// the dispatch above lands in the authentic report tail (#45),
// which tallies every burst (crit bonuses included) itself.
// The shooter-side BTPostDamageScore call that lived here (and
// its #95 salvo-credit fix) was RETIRED 2026-08-05: per-hit
// inflicted credit never existed in the 1995 pod.
// MP MATCH FORENSICS (matchlog.hpp): shooter-side
// projectile impact delivery (pairs with the victim's
@@ -2425,12 +2421,11 @@ void
// boresight pick skips a dead mech, so that block never ran against
// it again: internally dead, smoking, standing, invulnerable.
{
// KILL score (once; the ownerless dummy yields no death for us).
// task #60: pass the REAL killing-blow magnitude (latched in the damage
// handler), not the flat bring-up kShotDamage -- the score handler
// @0x4c02e4 derives the whole kill award from this damageAmount, so a
// flat 12 made every kill score identically regardless of weapon.
BTPostKillScore((Entity *)this, lastInflictingDamage);
// KILL score: RETIRED here 2026-08-05 -- the authentic kill report now
// posts from the TakeDamage report tail (@0x4a04da, block A) with the
// REAL applied tally, which supersedes the lastInflictingDamage relay
// this site needed (#60). Suicides dispatch too (the handler negates
// the award -- the #134 panic-penalty path this site's filter blocked).
if ((Entity *)this == gEnemyMech)
gEnemyDestroyed = 1; // latches off damage score
@@ -2496,55 +2491,19 @@ void
}
//
// --- RESPAWN CYCLE (task #52): notify the owning player of the death. ---
// The BTPlayer VehicleDead handler @004c05c4 receives this with
// deathCount == -1 (the ctor default = "immediate death notification"),
// does the death bookkeeping, severs playerVehicle (this wreck entity
// STAYS in the world) and re-posts the message to itself at +5 seconds
// (5.0f @004c0830) -> the engine drop-zone hunt -> DropZoneReply ->
// CreatePlayerVehicle (a NEW mech). Only the owner pod's master has a
// live playerLink, which is exactly where the cycle must run; replicant
// wrecks just mirror the master's death. [T1 the handler @004c05c4;
// T3 this dispatch site -- the binary's exact sender is undecoded, but
// the once-per-death transition is the only death edge and the message
// ctor's deathCount=-1 default exists for precisely this notification.]
// --- RESPAWN CYCLE (task #52): the VehicleDead notify MOVED 2026-08-05. ---
// This site carried [T3 -- the binary's exact sender is undecoded]. The
// sender is now decoded [T1]: the death tail of the TakeDamage handler
// that killed the mech (@0x4a07d4-0x4a0890, raw disasm) builds the BT
// 0x38-byte VehicleDeadMessage extension {killed-by player, kill zone}
// and dispatches it to the owning player. BTMechPostVehicleDead
// (btplayer.cpp) is that sender; the #55 NULL-playerLink fallback and the
// DEAD_NOTIFY forensics moved into it intact. Every path that reaches
// this transition does so inside the killing TakeDamage (weapon zones,
// collision rattle via the divert fallthrough, the eject charge), so the
// tail covers every death this site covered -- with one dispatch, keeping
// the #81 single-VehicleDead rule.
//
{
Player *owner = GetPlayerLink();
// #55 step 1 (the David fix): the death notification used to hinge on
// this single never-retried pointer -- playerLink is written once by
// Entity::PlayerLinkMessageHandler with no null check, so a lost race
// silently swallowed the whole respawn cycle. Resolve the SAME object
// by the reverse link the binary's own respawn branch uses
// (player+0x1FC == playerVehicle): the mission player whose vehicle is
// this mech. Not a stand-in -- the identical entity, second index.
if (owner == 0 && application != 0)
{
Player *mission_player = (Player *)application->GetMissionPlayer();
if (mission_player != 0
&& mission_player->GetPlayerVehicle() == (Entity *)this)
{
owner = mission_player;
}
}
// #55 step 0: a NULL link was a SILENT swallow -- record the
// resolution unconditionally so the matchlog can prove it either way.
BTMatchLog("DEAD_NOTIFY", "mech=%d:%d link=%p",
BTMatchHostOf(GetEntityID()), (int)GetEntityID(),
(void *)owner);
if (owner != 0)
{
Player::VehicleDeadMessage
vehicle_dead(
Player::VehicleDeadMessageID,
sizeof(Player::VehicleDeadMessage)
);
owner->Dispatch(&vehicle_dead);
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[death] VehicleDead(-1) dispatched to the owning player\n"
<< std::flush;
}
}
}
// Peer heading published each sim update, sampled by the render loop (BT_RENDHDG).
+6 -12
View File
@@ -740,18 +740,12 @@ void
target->Dispatch(&message); // no manager: direct (bring-up)
}
// PORT bookkeeping (score lived in the retired mech4 fire block): credit
// the delivered amount to the shooter-side score exactly as before.
extern void BTPostDamageScore(Entity *victim, Scalar damage, Entity *shooter);
extern Logical BTIsRegisteredMech(Entity *e);
if (BTIsRegisteredMech(target)
&& !((Mech *)target)->IsMechDestroyed())
{
// Direct-fire damageData always authors burstCount = 1 (emitter.cpp:355),
// so the plain amount IS the delivered amount here. The shooter param
// lets the bridge refuse AI-master fire (score is the shooter's, #45).
BTPostDamageScore(target, damageData.damageAmount, (Entity *)mech);
}
// SCORE: handled by the victim's own TakeDamage handler now -- the
// dispatch above lands in the authentic report tail (#45), which posts
// the kill/received reports itself. The shooter-side BTPostDamageScore
// call that lived here was RETIRED 2026-08-05 (per-hit inflicted credit
// never existed in the 1995 pod; kills + received-damage penalties only).
extern Logical BTIsRegisteredMech(Entity *e); // still read by the matchlog below
// MP MATCH FORENSICS (matchlog.hpp): the shooter-side per-shot damage
// submission -- pairs with the victim's DMG application line. mech=1
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# #45/#134 AUTHENTIC SCORE SENDER verify, bench 1: CROSS-NODE KILL.
# A (madcat, shooter, node 1501) zone-walk-hammers ONLY dz_ldleg on B (loki,
# spinner target, node 1601) at 90u until the vital leg dies -> B dies.
# PASS:
# B log (victim master): DMG rows; DEAD_NOTIFY link!=0; "[death]
# VehicleDead(-1) ... killedBy=<A player>"; "[score] *** KILL report ***";
# death cycle START (respawn un-regressed).
# A log (killer): matchlog SCORE type=2 award>0 kills=1 (rerouted credit);
# per-hit type-0 Verify prints prove the wire-fidelity block B.
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
bt_assert_player_env
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f sk_a.log sk_b.log sk_relay.log matchlog_*.txt
bt_expert_egg MP.EGG SK.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/" SK.EGG
python - << 'EOF'
lines = open('SK.EGG').read().splitlines(True)
n = 0
for i, l in enumerate(lines):
if l.startswith('vehicle='):
n += 1
lines[i] = 'vehicle=madcat\n' if n == 1 else 'vehicle=loki\n'
open('SK.EGG', 'w').writelines(lines)
print('vehicles set:', n)
EOF
(
export BT_DMG_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1
export BT_MATCHLOG=1 BT_SCORE_LOG=1
bt_launch sk_b.log SK.EGG 0x0C -net 1601
)
sleep 2
(
export BT_ZONE_WALK=8 BT_WALK_ZONES=dz_ldleg
export BT_GOTO=enemy BT_GOTO_STOP=90 BT_KEY_NOFOCUS=1
export BT_DMG_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 BT_SCORE_LOG=1
bt_launch sk_a.log SK.EGG 0x03 -net 1501
)
sleep 5
python ../tools/btconsole.py SK.EGG 127.0.0.1:1501 127.0.0.1:1601 > sk_relay.log 2>&1 &
RELAY=$!
sleep 340
kill $RELAY 2>/dev/null
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 3
echo "=== B (victim) death + notify + kill report sent:"
grep -aE "DEAD_NOTIFY|VehicleDead|KILL report|death cycle START" sk_b.log | head -8
echo "=== B matchlog SCORE lines:"
grep -a "SCORE" matchlog_*.txt 2>/dev/null | tail -6
echo "=== A (killer) score handler receipts:"
grep -aE "\[score\]|ScoreMessageHandler" sk_a.log | head -8
echo "=== A kill credit (matchlog):"
grep -a "type=2" matchlog_*.txt 2>/dev/null | tail -3
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# #134 SELF-KILL PENALTY verify: A self-destructs (BT_SELF_DAMAGE, inflictor =
# SELF) with B standing off. The kill report now DISPATCHES for killer==victim
# (the old BTPostKillScore filtered it) and the handler NEGATES the award.
# PASS (A log/matchlog): "[score] *** KILL report ***" to A's OWN player;
# SCORE type=2 with award < 0 and kills UNCHANGED (0); VehicleDead
# killedBy=<A's own player>; death cycle START (respawn un-regressed).
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
bt_assert_player_env
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f ss_a.log ss_b.log ss_relay.log matchlog_*.txt
bt_expert_egg MP.EGG SG.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" SG.EGG
(
export BT_SELF_DAMAGE=40
export BT_DMG_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 BT_SCORE_LOG=1
bt_launch ss_a.log SG.EGG 0x0C -net 1601
)
sleep 2
(
export BT_GOTO=enemy BT_GOTO_STOP=60 BT_KEY_NOFOCUS=1
export BT_DMG_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 BT_SCORE_LOG=1
bt_launch ss_b.log SG.EGG 0x03 -net 1501
)
sleep 5
python ../tools/btconsole.py SG.EGG 127.0.0.1:1501 127.0.0.1:1601 > ss_relay.log 2>&1 &
RELAY=$!
sleep 100
kill $RELAY 2>/dev/null
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 3
echo "=== A self-kill report + death:"
grep -aE "KILL report|VehicleDead\(-1\) dispatched|death cycle START" ss_a.log | head -6
echo "=== A SCORE lines (want type=2 award<0 kills=0):"
grep -a "SCORE" matchlog_*.txt 2>/dev/null | grep "type=2" | head -4
echo "=== all SCORE traffic:"
grep -a "SCORE" matchlog_*.txt 2>/dev/null | tail -8