#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 **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 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; 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 **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 manual-trained to purge ([[pod-hardware]] §Manual), testers are not. Novice sim gates also
suppress the whole path ([[experience-levels]]). 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 ## layers are now fixed and live-verified
The recovery: the un-exported gap held `Mech::TakeDamageMessageHandler @0x4a0230` (found via the 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, 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`**: 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 `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 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 @0x4ac0bc` is real (zone damage → destroyed alarms → **vital-subsystem kill**: owner
`graphicAlarm` level 9, the #28 machinery — CLASSMAP's "HandleMessage@4ac0bc" was a mislabel). `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` 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` — a full chain crit destroying a subsystem. The `damageType==0` COLLISION divert (@0x4a0368
COLLISION divert (@0x4a0368`0x49ffcc`, its own distribution) and the **id-0x16 damage/kill `0x49ffcc`) and the id-0x16 report tail are BOTH reconstructed — see the next section. Diags:
reports** to shooter+victim players (@0x4a04da-0x4a07b2 — the authentic stats plumbing, feeds #45: `BT_CRIT_LOG` (`[subarmor]` + `[critroll]`), `BT_DMG_LOG`.
`{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]` + ## ✅ THE AUTHENTIC SCORE/DEATH REPORT TAIL (2026-08-05, #45/#134) [T1 raw disasm, T2 both benches]
`[critroll]`), `BT_DMG_LOG`. 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] ## (HISTORICAL — the gap as found 2026-07-29, superseded above) [T1]
The authored crit machinery exists and is reconstructed — `Mech__DamageZone::CriticalHit @0049ccc4` 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] ### 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. 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 - **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 ClearBurningState@0049f700, **0x19 EjectPilot@0049f854**, **0x1a DuckRequest@0049fa00** (the
manual's CROUCH; streamed button 0x13 sends it — unreconstructed). 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** - **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 (`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): 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. REMAINING note is closed.
5.**2-window driving — RESOLVED (tasks #48/#51).** Keyboard MP turning was restored at the root 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 (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 harnesses. DEATHS scoring lights via the kill/death report flow (2026-08-05: now the AUTHENTIC
reconstructed (task #52 — see item 3b). 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 ## Key Relationships
- Base: [[wintesla-port]] (L4NET). Depends on: [[locomotion]] (update writer), [[combat-damage]] - 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 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. 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) ## Rendering follow-ups (non-blocking)
- ~~Per-pilot mech PAINT (color/badge/patch)~~ — **DONE 2026-07-17, verified live** (crimson MadCat + - ~~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 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 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 ram kills), and §Headline-6's fidelity question (the wrong-column slip is still
reproduced; it is merely no longer visible). 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, static_assert(sizeof(BTPlayer::MakeMessage) == 0xD0,
"BTPlayer::MakeMessage wire size must be 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 // 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 // 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). // 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 = Scalar received =
scenarioRole->CalcDamageReceivedScore(message->damageAmount); // FUN_00429b94 scenarioRole->CalcDamageReceivedScore(message->damageAmount); // FUN_00429b94
award = (MECH_TONNAGE(our_mech) / MECH_TONNAGE(sender_mech)) * received; 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 // 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 = Host *console_host =
application->GetHostManager()->GetConsoleHost(); // FUN_00429078 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( ConsolePlayerVTVDamagedMessage damaged_message(
ownerID, // this+0x18c ownerID, // this+0x18c
MECH_OWNING_PLAYER(sender_mech)->ownerID, // *(sender+0x190)+0x18c damager_player->ownerID, // *(sender+0x190)+0x18c
Round(message->damageAmount), // FUN_004078fc Round(message->damageAmount), // FUN_004078fc
message->pointSenderLo, // msg+0x2c message->zoneIndex, // msg+0x2c
message->pointSenderHi, // msg+0x28 message->vitalHit, // msg+0x28
(int)Now().ticks, // FUN_004dcd94 Round(award), // FUN_004dcd94(ST0)
message->auxID // msg+0x30 message->subsysID // msg+0x30
); );
application->SendMessage( // app+0x20, slot+0x18 application->SendMessage( // app+0x20, slot+0x18
@@ -888,6 +932,13 @@ void
case BTPlayer::ScoreMessage::KillScore: // 2 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 // gauge scoring wave: the attacking mech's owner (NULL for the ownerless
// BT_SPAWN_ENEMY dummy). Resolved once, guarded everywhere below. // BT_SPAWN_ENEMY dummy). Resolved once, guarded everywhere below.
BTPlayer *sender_owner = MECH_OWNING_PLAYER(sender_mech); // *(sender+0x190), NULL for the dummy 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 AUTHENTIC COMBAT REPORT SENDER (#45/#134 -- 2026-08-05)
// the scoreboard. The inflictor is always the viewpoint mech in bring-up, so the
// local (crediting) player is application->GetMissionPlayer(). Bridges (not inline
// in mech4.cpp) so the message construction lives in this complete-BTPlayer TU.
// //
void BTPostDamageScore(Entity *victim, Scalar damage, 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; 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) BTPlayer *shooter_player = 0;
{ Entity *shooter = 0;
if (application == 0 || victim == 0) extern int BTIsRegisteredMech(Entity *e);
{
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;
if (application->GetHostManager() != 0) if (application->GetHostManager() != 0)
{ {
Entity *killer = application->GetHostManager()->GetEntityPointer( shooter = application->GetHostManager()->GetEntityPointer(
((Mech *)victim)->lastInflictingID); msg->inflictingEntity);
extern int BTIsRegisteredMech(Entity *e); if (shooter != 0 && BTIsRegisteredMech(shooter))
if (killer != 0 && killer != victim && BTIsRegisteredMech(killer)) shooter_player = (BTPlayer *)((Mech *)shooter)->GetPlayerLink();
killer_player = (BTPlayer *)((Mech *)killer)->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 // Block A: the KILL report, to the SHOOTER's player. On the victim's
// (killer == victim) never reaches here. // node that player is a replicant, so Entity::Dispatch reroutes to the
BTPlayer::ScoreMessage kill( // owning host (ENTITY.cpp:244) and the credit lands on the killer's own
Player::ScoreMessageID, // 0x12 // 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), sizeof(BTPlayer::ScoreMessage),
BTPlayer::ScoreMessage::KillScore, // 2 BTPlayer::ScoreMessage::DamageInflictedScore, // 0
0.0f, // scoreAward (killBonus; 0 for bring-up) damage_tally, // +0x1c
damage, // damageAmount (killing-blow) damage_tally, // +0x24 basis = tally
victim->GetEntityID()); // senderMechID = victim victim->GetEntityID(),
killer_player->Dispatch(&kill); vital_hit, report_zone, msg->inflictingSubsystemID);
if (getenv("BT_SCORE_LOG")) shooter_player->Dispatch(&inflicted);
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));
} }
// MP DEATH: the victim's death tally. if (damage_tally > 0.0f && victim_player != 0)
// {
// REMOVED 2026-07-30 (#81): this used to dispatch a second //
// `Player::VehicleDeadMessage` to the victim's own player "to credit a // Block C: the RECEIVED report, to the victim's own player -- kills
// death". That message is the RESPAWN-CYCLE TRIGGER, not a scoreboard // included (the binary runs this after block A as well). Carries the
// increment, and the victim already receives one from the mech's death // INTENDED damage (burstCount x amount, @0x4a06d2 fild/fmul) as the
// transition (mech4.cpp:2110) -- which is the hardened, authentic notify // penalty basis and the INFLICTOR as senderMechID; feeds the score
// (it carries the #55 NULL-playerLink fallback). Both fire inside the SAME // penalty and the operator-console VTVDamaged line.
// death transition (BTPostKillScore is called at mech4.cpp:2006, the notify //
// at :2110), so they were always paired: every death dispatched the message int bursts = msg->damageData.burstCount;
// TWICE. The tally itself is credited by the handler's `++deathTally` if (bursts < 1)
// (:538) on the first one, so this dispatch never added anything. bursts = 1; // port guard (mirrors the loop)
// BTPlayer::ScoreMessage received(
// It was invisible because the `deathPending` latch silently deduped it -- Player::ScoreMessageID,
// that is what EVERY "death ... SWALLOWED" warning in the field logs sizeof(BTPlayer::ScoreMessage),
// actually was (8 of 8 deaths, a 100% base rate, which is exactly why it BTPlayer::ScoreMessage::DamageReceivedScore, // 1
// correlated with nothing). Once the latch was released to match the damage_tally, // +0x1c
// binary (see the death handler's tail), the duplicate stopped being (Scalar)bursts * msg->damageData.damageAmount, // +0x24 intended
// masked and started a SECOND death cycle: deathCount double-incremented, msg->inflictingEntity, // +0x34 the INFLICTOR
// the first cycle's re-post went stale and tripped the drop-zone vital_hit, report_zone, msg->inflictingSubsystemID);
// `*** MISMATCH ***`, and one respawn burned two cycles. Caught on the victim_player->Dispatch(&received);
// first solo bench of the latch fix. }
//
// The kill credit above is unaffected -- it dispatches a ScoreMessage, the
// correct message for a scoreboard change.
} }
//
// 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) // 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 // generated the points. Parallels RPPlayer__ScoreMessage, but the BT
// score types describe mech combat instead of score zones. // score types describe mech combat instead of score zones.
// //
// Observed message layout (param_2 in the handlers): // Message layout, sizeof 0x3C (field meanings CORRECTED 2026-08-05 from the
// +0x1c scoreAward (Scalar, base Player::ScoreMessage) // 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) // +0x20 scoreType (int)
// +0x24 damageAmount (Scalar) raw damage / point quantity // +0x24 damageAmount (Scalar) the scoring BASIS: type 0 = tally,
// +0x28 pointSenderHi (EntityID word) // type 1 = intended (burstCount x amount),
// +0x2c pointSenderLo (EntityID word) // type 2 = the VICTIM role's killBonus (role+0x1c)
// +0x30 auxID (EntityID / host word) // +0x28 vitalHit (int) a zone reached BurningState during
// +0x34 senderMechID (EntityID -- resolved to the inflicting Mech) // 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: class BTPlayer__ScoreMessage:
public Player::ScoreMessage public Player::ScoreMessage
@@ -72,11 +84,16 @@ class DropZone__ReplyMessage;
public: public:
// //
// Kind of scoring event. Recovered from the branch selector at // Kind of scoring event. Recovered from the branch selector at
// @004c02e4 (this->scoreType, message+0x20) and the dedicated // @004c02e4 (this->scoreType, message+0x20). NOTE: type 0 has NO
// inflicted-damage handler at @004c0200. // 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 { enum ScoreType {
DamageInflictedScore = 0, // to ScoreInflictedMessageHandler DamageInflictedScore = 0, // sent, but scores nothing (see above)
DamageReceivedScore = 1, // I took damage DamageReceivedScore = 1, // I took damage
KillScore = 2 // I destroyed / was destroyed KillScore = 2 // I destroyed / was destroyed
}; };
@@ -85,21 +102,19 @@ class DropZone__ReplyMessage;
scoreType; // +0x20 scoreType; // +0x20
Scalar 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 int
pointSenderHi; // +0x28 vitalHit; // +0x28 zone entered BurningState this message
int int
pointSenderLo; // +0x2c zoneIndex; // +0x2c struck damage zone
int int
auxID; // +0x30 subsysID; // +0x30 inflicting weapon subsystem
EntityID EntityID
senderMechID; // +0x34 inflicting mech (point sender) senderMechID; // +0x34 victim (types 0/2) / inflictor (type 1)
BTPlayer__ScoreMessage( BTPlayer__ScoreMessage(
Receiver::MessageID message_ID, Receiver::MessageID message_ID,
@@ -107,15 +122,63 @@ class DropZone__ReplyMessage;
int score_type, int score_type,
Scalar score_award, Scalar score_award,
Scalar damage_amount, 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), Player::ScoreMessage(message_ID, length, score_award),
scoreType(score_type), scoreType(score_type),
damageAmount(damage_amount), damageAmount(damage_amount),
vitalHit(vital_hit),
zoneIndex(zone_index),
subsysID(subsys_ID),
senderMechID(sender_mech_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 ######################### //##################### BTPlayer::MakeMessage #########################
//########################################################################### //###########################################################################
@@ -202,8 +265,9 @@ class DropZone__ReplyMessage;
// Player::ScoreMessage.) // Player::ScoreMessage.)
// //
public: public:
typedef BTPlayer__ScoreMessage ScoreMessage; typedef BTPlayer__ScoreMessage ScoreMessage;
typedef BTPlayer__MakeMessage MakeMessage; typedef BTPlayer__MakeMessage MakeMessage;
typedef BTPlayer__VehicleDeadMessage VehicleDeadMessage; // BT 0x38 ext (killed-by)
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Message Support // Message Support
+70 -41
View File
@@ -1063,24 +1063,40 @@ void
(float)(message->damageData.impactPoint.z - localOrigin.linearPosition.z)); (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 // @0x4a0368 -- the COLLISION DIVERT (#83, closing the #82 chain). Damage
// type 0 == the mover's collision damage (what the crash response policy // type 0 == the mover's collision damage (what the crash response policy
// forwards). It NEVER reaches the zone/armor loop below: it is priced + // forwards). It NEVER reaches the zone/armor loop below: it is priced +
// distributed as INTERNAL RATTLE by DistributeCollisionDamage (@0049ffcc) // distributed as INTERNAL RATTLE by DistributeCollisionDamage (@0049ffcc).
// and the handler is done. This was the missing half of the crash // CORRECTED 2026-08-05: the binary does NOT return here -- it JUMPS TO THE
// self-damage reconstruction -- without the divert, the raw kinetic-energy // DEATH TAIL (raw disasm @0x4a0375: jmp 0x4a07b5), so a mech that dies of
// amounts (a 60-ton mech prices ~1000+ per wall tap) fell through into the // the rattle still posts VehicleDead (the respawn trigger) and still
// WEAPON loop and a single bump killed the mech (run-14 field report). // 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) if (message->damageData.damageType == 0)
{ {
DistributeCollisionDamage(&message->damageData); DistributeCollisionDamage(&message->damageData);
Check_Fpu(); goto death_tail;
return;
} }
DamageLookupTable *table = (DamageLookupTable *)damageLookupTable; // named member (Wword absorbs!)
// #92: when this gate fails the zone keeps whatever the message carried -- // #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. // which for an unaimed weapon hit is 0, i.e. EVERY shot lands on zone 0.
if (getenv("BT_DMGTABLE_LOG")) if (getenv("BT_DMGTABLE_LOG"))
@@ -1105,6 +1121,7 @@ void
<< message->damageData.impactPoint.z << ")\n" << std::flush; << message->damageData.impactPoint.z << ")\n" << std::flush;
} }
} }
reportZone = message->damageZone; // post-resolve (@0x4a0396 write)
// //
// #80 -- the faithful application loop (binary @0x4a0423-0x4a04d8), which // #80 -- the faithful application loop (binary @0x4a0423-0x4a04d8), which
// REPLACES the engine-base single application. Three things the base // 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 // nothing (matches ENTITY.cpp:878; the binary would deref -1 -- it can't
// happen there because every mech ships a lookup table). // 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 if (damageZones != 0 && message->damageZone >= 0
&& message->damageZone < damageZoneCount) && message->damageZone < damageZoneCount)
{ {
@@ -1181,14 +1193,20 @@ void
} }
message->damageZone = zoneIndex; // matchlog sees the LAST zone 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, // THE REPORT TAIL (@0x4a04da-0x4a07b2), reconstructed 2026-08-05 [T1]:
// inflicting subsystem, victim name} -- to the shooter's player (with a // the kill / inflicted / received score reports to the shooter's and
// kill-flagged variant when this damage NEWLY disabled the mech) and to // victim's players. Sender lives in btplayer.cpp (complete-BTPlayer
// the victim's player. That is the authentic stats plumbing (#45); the // TU, per the databinding rule); it receives the APPLIED tally, the
// port's matchlog + BTPostDamageScore cover the bookkeeping today [T3]. // vital-wreck flag, and the loop-ENTRY zone. This retires
(void)zoneDestroyed; // BTPostDamageScore/BTPostKillScore -- see the btplayer.cpp tombstone.
(void)damageTally; //
{
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 // MP MATCH FORENSICS (matchlog.hpp): the victim-side authoritative damage
@@ -1218,30 +1236,41 @@ void
(float)message->damageData.impactPoint.z); (float)message->damageData.impactPoint.z);
} }
// #89 DEATH BLAST (raw disasm @0x4a07b8-0x4a0bda -- the un-exported tail // THE DEATH TAIL (@0x4a07b5-0x4a0bda, raw disasm -- the un-exported tail
// of THIS handler; found by call-scanning Explosion::SplashDamage // of THIS handler). Gate [T1]: mech was ALIVE at entry ([ebp-0x10]) and
// @0042fad0, which has exactly TWO callers: Missile::Perform and here). // is dead(9)/eject(10) now. Sequence:
// The binary, when the victim ENTERS dead(9)/eject(10) during the // 1. Player::VehicleDeadMessage (id 0x17; the BT 0x38-byte extension
// applications above: // carrying killed-by player + kill zone) -> the mech's OWN player:
// 1. self-dispatches SetBurningState (id 0x17, Damage payload, zone -1) // the respawn-cycle trigger. CORRECTED 2026-08-05: the #89 banner
// -- the burning wreck. DEFERRED here: our 0x17 handler is not yet // previously read this build as "SetBurningState (mech id 0x17)" --
// reconstructed; the port's wreck-fire visuals come from the death // it is a PLAYER-table id: the binary's BTPlayer handler table (file
// effect layer meanwhile. // 0x112dxx) binds 0x17 -> @0x4c05c4 VehicleDeadMessageHandler; the
// 2. spawns the death Explosion (Explosion::Make model 0x31 at the mech // mech-table 0x17 (@0x49f674) is unrelated. Dispatched from HERE --
// origin) -- the port's kill path already fires the authored death // this is the decoded sender the mech4 transition site's
// list visuals; not double-spawned. // [T3 sender-undecoded] flag was waiting for.
// 3. calls SplashDamage on it, gated on the owning player's // 2. the death Explosion (id 3 MakeMessage, 0x5C bytes, model 0x31 at
// advancedDamageOn (+0x264) AND NOT suppressConsole (+0x258 -- set // the mech origin, @0x4a08bd) -- the port fires the authored per-mech
// by the eject bookkeeping: an EJECTED pilot's mech never blasts): // 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, // Damage{type=2 Explosive, amount=deathSplashDamage@0x520,
// impact=mech origin, // impact=mech origin,
// burstCount=round(0.001 * moverMass@0x20c * 15.0)} // burstCount=round(0.001 * moverMass@0x20c * 15.0)}
// radius=deathSplashRadius@0x524, excluded=the dying mech. // radius=deathSplashRadius@0x524, excluded=the dying mech;
// Splash falloff (bursts / dist^1.25) applies inside per victim. // falloff bursts/dist^1.25 per victim inside the shared core.
// 4. ForceUpdate(1). (A score/console post also rides this tail -- // 4. ForceUpdate(1).
// the deferred id-0x16 family, tracked with #134.) // 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()) if (deathBlastArmed && IsMechDestroyed())
{ {
extern void BTMechPostVehicleDead(void *, void *, int);
BTMechPostVehicleDead((void *)this, (void *)message, reportZone);
extern void BTApplyDeathSplash(void *mech_v); extern void BTApplyDeathSplash(void *mech_v);
BTApplyDeathSplash((void *)this); 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 // 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). // mech locks onto it as its current target each frame (see targeting step below).
extern Entity *gEnemyMech; extern Entity *gEnemyMech;
// gauge scoring wave: producers (btplayer.cpp) -- feed the scoreboard from combat. // RETIRED 2026-08-05: the BTPostDamageScore / BTPostKillScore producers.
extern void BTPostDamageScore(Entity *victim, Scalar damage, Entity *shooter); // per-hit SCORE (ScoreInflicted) // 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) 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 target slots (verified vs the binary's weapon/fire path, part_013.c):
// mech+0x37c Point3D current target world position (range/aim source) // mech+0x37c Point3D current target world position (range/aim source)
@@ -1311,12 +1313,11 @@ static void
shooter->GetEntityID(), -1 /*unaimed -> cylinder resolves*/, dmg); shooter->GetEntityID(), -1 /*unaimed -> cylinder resolves*/, dmg);
e->Dispatch(&td); e->Dispatch(&td);
// SCORE (#95 follow-up): the binary's victim handler tallies EVERY // SCORE: handled by the victim's own TakeDamage handler now -- the
// TakeDamage -- splash included -- and reports it to the INFLICTING // dispatch above lands in the authentic report tail (#45), which
// player (id-0x16, deferred #45), so a bystander blast is score too. // posts the kill/received reports itself. The shooter-side
// The bridge itself refuses a shooter that is not the local vehicle // BTPostDamageScore call that lived here was RETIRED 2026-08-05
// (this function's death-blast caller passes the DYING mech). // (per-hit inflicted credit never existed in the 1995 pod).
BTPostDamageScore(e, dmg.damageAmount * (Scalar)bursts, shooter);
// MP MATCH FORENSICS (matchlog.hpp): shooter-side splash delivery // MP MATCH FORENSICS (matchlog.hpp): shooter-side splash delivery
// to a bystander (pairs with the victim's DMG burst>1 line). // 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, (p.shooter != 0) ? p.shooter->GetEntityID() : EntityID::Null,
-1 /*unaimed -> cylinder resolves*/, dmg, p.weaponSubsys); -1 /*unaimed -> cylinder resolves*/, dmg, p.weaponSubsys);
tgt->Dispatch(&take_damage); tgt->Dispatch(&take_damage);
// gauge scoring wave (Step 6): a projectile hit credits SCORE with // SCORE: handled by the victim's own TakeDamage handler now --
// the DELIVERED amount -- per-missile damage x the rolled cluster // the dispatch above lands in the authentic report tail (#45),
// count, the same figure the dispatch above hands the victim's // which tallies every burst (crit bonuses included) itself.
// handler (which applies it burstCount times, tallying the amount // The shooter-side BTPostDamageScore call that lived here (and
// per burst @0x4a04da). Posting the bare per-missile amount here // its #95 salvo-credit fix) was RETIRED 2026-08-05: per-hit
// was the "LRM10 registered ~3 points to score" defect (#95): the // inflicted credit never existed in the 1995 pod.
// 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);
// MP MATCH FORENSICS (matchlog.hpp): shooter-side // MP MATCH FORENSICS (matchlog.hpp): shooter-side
// projectile impact delivery (pairs with the victim's // 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 // boresight pick skips a dead mech, so that block never ran against
// it again: internally dead, smoking, standing, invulnerable. // it again: internally dead, smoking, standing, invulnerable.
{ {
// KILL score (once; the ownerless dummy yields no death for us). // KILL score: RETIRED here 2026-08-05 -- the authentic kill report now
// task #60: pass the REAL killing-blow magnitude (latched in the damage // posts from the TakeDamage report tail (@0x4a04da, block A) with the
// handler), not the flat bring-up kShotDamage -- the score handler // REAL applied tally, which supersedes the lastInflictingDamage relay
// @0x4c02e4 derives the whole kill award from this damageAmount, so a // this site needed (#60). Suicides dispatch too (the handler negates
// flat 12 made every kill score identically regardless of weapon. // the award -- the #134 panic-penalty path this site's filter blocked).
BTPostKillScore((Entity *)this, lastInflictingDamage);
if ((Entity *)this == gEnemyMech) if ((Entity *)this == gEnemyMech)
gEnemyDestroyed = 1; // latches off damage score gEnemyDestroyed = 1; // latches off damage score
@@ -2496,55 +2491,19 @@ void
} }
// //
// --- RESPAWN CYCLE (task #52): notify the owning player of the death. --- // --- RESPAWN CYCLE (task #52): the VehicleDead notify MOVED 2026-08-05. ---
// The BTPlayer VehicleDead handler @004c05c4 receives this with // This site carried [T3 -- the binary's exact sender is undecoded]. The
// deathCount == -1 (the ctor default = "immediate death notification"), // sender is now decoded [T1]: the death tail of the TakeDamage handler
// does the death bookkeeping, severs playerVehicle (this wreck entity // that killed the mech (@0x4a07d4-0x4a0890, raw disasm) builds the BT
// STAYS in the world) and re-posts the message to itself at +5 seconds // 0x38-byte VehicleDeadMessage extension {killed-by player, kill zone}
// (5.0f @004c0830) -> the engine drop-zone hunt -> DropZoneReply -> // and dispatches it to the owning player. BTMechPostVehicleDead
// CreatePlayerVehicle (a NEW mech). Only the owner pod's master has a // (btplayer.cpp) is that sender; the #55 NULL-playerLink fallback and the
// live playerLink, which is exactly where the cycle must run; replicant // DEAD_NOTIFY forensics moved into it intact. Every path that reaches
// wrecks just mirror the master's death. [T1 the handler @004c05c4; // this transition does so inside the killing TakeDamage (weapon zones,
// T3 this dispatch site -- the binary's exact sender is undecoded, but // collision rattle via the divert fallthrough, the eject charge), so the
// the once-per-death transition is the only death edge and the message // tail covers every death this site covered -- with one dispatch, keeping
// ctor's deathCount=-1 default exists for precisely this notification.] // 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). // 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) target->Dispatch(&message); // no manager: direct (bring-up)
} }
// PORT bookkeeping (score lived in the retired mech4 fire block): credit // SCORE: handled by the victim's own TakeDamage handler now -- the
// the delivered amount to the shooter-side score exactly as before. // dispatch above lands in the authentic report tail (#45), which posts
extern void BTPostDamageScore(Entity *victim, Scalar damage, Entity *shooter); // the kill/received reports itself. The shooter-side BTPostDamageScore
extern Logical BTIsRegisteredMech(Entity *e); // call that lived here was RETIRED 2026-08-05 (per-hit inflicted credit
if (BTIsRegisteredMech(target) // never existed in the 1995 pod; kills + received-damage penalties only).
&& !((Mech *)target)->IsMechDestroyed()) extern Logical BTIsRegisteredMech(Entity *e); // still read by the matchlog below
{
// 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);
}
// MP MATCH FORENSICS (matchlog.hpp): the shooter-side per-shot damage // MP MATCH FORENSICS (matchlog.hpp): the shooter-side per-shot damage
// submission -- pairs with the victim's DMG application line. mech=1 // 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