diff --git a/context/combat-damage.md b/context/combat-damage.md
index 2e36383..2bf1278 100644
--- a/context/combat-damage.md
+++ b/context/combat-damage.md
@@ -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`
diff --git a/context/decomp-reference.md b/context/decomp-reference.md
index 381be70..e0cedda 100644
--- a/context/decomp-reference.md
+++ b/context/decomp-reference.md
@@ -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):
diff --git a/context/multiplayer.md b/context/multiplayer.md
index 717a219..4d351b9 100644
--- a/context/multiplayer.md
+++ b/context/multiplayer.md
@@ -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]]
diff --git a/context/open-questions.md b/context/open-questions.md
index c781591..b6f5dd4 100644
--- a/context/open-questions.md
+++ b/context/open-questions.md
@@ -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
diff --git a/docs/KD_SCOREBOARD_PLAN.md b/docs/KD_SCOREBOARD_PLAN.md
index 87503f9..b126322 100644
--- a/docs/KD_SCOREBOARD_PLAN.md
+++ b/docs/KD_SCOREBOARD_PLAN.md
@@ -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).
diff --git a/game/reconstructed/btplayer.cpp b/game/reconstructed/btplayer.cpp
index a642be1..a81e074 100644
--- a/game/reconstructed/btplayer.cpp
+++ b/game/reconstructed/btplayer.cpp
@@ -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)
diff --git a/game/reconstructed/btplayer.hpp b/game/reconstructed/btplayer.hpp
index c3242eb..1b97a90 100644
--- a/game/reconstructed/btplayer.hpp
+++ b/game/reconstructed/btplayer.hpp
@@ -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
diff --git a/game/reconstructed/mech.cpp b/game/reconstructed/mech.cpp
index 06d3fa3..c2e2519 100644
--- a/game/reconstructed/mech.cpp
+++ b/game/reconstructed/mech.cpp
@@ -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);
}
diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp
index 5eb34e3..1726211 100644
--- a/game/reconstructed/mech4.cpp
+++ b/game/reconstructed/mech4.cpp
@@ -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).
diff --git a/game/reconstructed/mechweap.cpp b/game/reconstructed/mechweap.cpp
index 64f6f64..422ceac 100644
--- a/game/reconstructed/mechweap.cpp
+++ b/game/reconstructed/mechweap.cpp
@@ -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
diff --git a/scratchpad/night12/scorekill.sh b/scratchpad/night12/scorekill.sh
new file mode 100644
index 0000000..5a13910
--- /dev/null
+++ b/scratchpad/night12/scorekill.sh
@@ -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="; "[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
diff --git a/scratchpad/night12/scoreself.sh b/scratchpad/night12/scoreself.sh
new file mode 100644
index 0000000..1610766
--- /dev/null
+++ b/scratchpad/night12/scoreself.sh
@@ -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=; 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