#45 scoreboard: reclaim the binary's DEATHS field, add a heartbeat, sweep the false KB claims

Follow-up to 4fa7eee, driven by an adversarial review pass and three rig cycles.
Each item below is a real defect that pass found, not polish.

DEATHS now uses the binary's own column.  PilotList::Execute @0x4cabd0 draws
`fild [edi+0x27c]` (KILLS) and `fild [edi+0x280]` (DEATHS); +0x280 has exactly
two runtime writers image-wide plus the ctor zero.  Our port had declared it
`pad_0x280`, never written, and displayed the ENGINE's Player::deathCount
(+0x200) instead -- which is the respawn-handshake identity, seeded -2, and is
why it needed a clamp to pass as a count.  It is now BTPlayer::deathTally (our
offset 0x274, offsetof-locked), incremented beside ++deathCount, read by the
gauge, and replicated.  deathCount is left to the engine's handshake.  So this
half moves TOWARD the binary; it also closes the plan's Headline-1.
(`deathTally`, NOT `deaths`/`deathCount` -- those would shadow the base.)

The mirror was not self-healing: update records are UNRELIABLE by construction
(Entity::UpdateMessage clears ReliableFlag, ENTITY3.h:112; the relay's UDP path
also drops stale/reordered datagrams), and the pair was dirtied only on an
event.  One lost datagram left every peer stale until that pilot's next kill or
death -- forever for the last kill of a round.  Added a 2s heartbeat that
re-dirties the record, which also bounds how long the binary's phantom
partner-increment stays visible.  The timer is a function static deliberately: a
data member would change sizeof(BTPlayer) and break the offset locks.

Also fixed: the SBMIRROR row AND its change-detect both still read deathCount (a
constant -2 here), so the "log only the edge" guard could never be false and
every row printed deaths=-2.  That is what made the first rig runs look like
DEATHS was broken when a WRITE/READ trace proved the transport correct.  Row
count per node fell from ~20 to 3, one per real change.

Guarded the record against per-bit layouts: update_model is a BIT INDEX and
Entity::WriteUpdateRecord switches on it (ENTITY.cpp:329-352) -- the DamageZone
bit emits a variable-length packed stream whose length it computes itself, so
appending two ints and re-stamping recordLength over that would corrupt it.

Deleted BTPlayerCountObservedDeath -- definition, call site, extern and friend
together, since /FORCE hides stragglers.  It never executed (its call site sat
inside the once-per-death transition, which a replicant never enters) and could
not have worked (0 of 8800 corpus DMG rows target a replicant, which is why every
DEATH inst=R row reads killer=0:0).  Under replication it would have been a
second writer of a replicated counter.

New forensics: NOCREDIT names the failing link when a kill credit is skipped (it
used to be completely silent -- the counter simply never moved), and PLAYER_LINK
records whether the one-shot link resolved.  Both retire the NULL-playerLink
theory: every rig shows `PLAYER_LINK inst=R resolved=1` and no NOCREDIT rows.
PLAYER_DEAD now logs both counters (deaths=handshake, tally=scoreboard).

KB sweep of the claims that hid this bug for so long:
  * context/gauges-hud.md's "RESOLVED -- deaths tally per node from locally
    observed events" was FALSE; corrected with the measured evidence.
  * docs/GAUGE_COMPOSITE.md + btl4gau3.cpp "the dead pad_0x280" -- never dead,
    merely unwritten.
  * btplayer.hpp attributed VehicleDeadMessageHandler to @004c012c; it is
    @004c05c4 (absent from the decomp export -- the #60 gap).
  * docs/RESPAWN_REARM_PLAN.md's "#45 SUBSUMED" -- the PLAYER_DEAD symptom was
    subsumed, the scoreboard defect was not.
  * The corpus figure in the record banner now states its method so it is
    reproducible.

Rig-verified (2-node loopback, several cycles): owner 3:1 kills 2/tally 1 read
`kills=2 deaths=1` on the peer; owner 2:1 kills 1/tally 2 read `kills=1 deaths=2`
on the peer.  Respawn unaffected, no crash, no GLITCH rows.  Still awaiting live
multi-pod verification by a human; all pods must run the same build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-25 22:09:00 -05:00
co-authored by Claude Opus 5
parent 4fa7eee54f
commit a52207d779
11 changed files with 316 additions and 86 deletions
+4 -2
View File
@@ -528,8 +528,10 @@ a prose-only vtable slot AVs on first call).
ValveSetting→coolantFlowScale@0x15C. The SET valve indicator renders (row=Round(span*value)).
-**PilotList** (Comm KILLS/DEATHS roster; part_014.c:3156-3434, DAT_0051af88 8×3 layout PE-parsed) —
the Comm surface now renders the live local-pilot row (KILLS 0 DEATHS 0 + name box). Feed = new
`BTResolveRosterPilot` bridge (mechmppr.cpp) → mapper roster. DEATHS redirected from the dead pad_0x280 to
the real deathCount@0x200. The scoring feed that moves KILLS/DEATHS is P3.
`BTResolveRosterPilot` bridge (mechmppr.cpp) → mapper roster. DEATHS now reads the binary's own
`+0x280` column (`BTPlayer::deathTally`, our offset 0x274) -- **corrected 2026-07-25 (#45)**: it was
briefly redirected to `Player::deathCount@0x200`, but that field is the respawn-handshake identity
(seeded -2), which is why it needed a display clamp. `+0x280` was never dead, merely unwritten. The scoring feed that moves KILLS/DEATHS is P3.
-**GeneratorCluster** (4 generator panels, buttons 9-12) — the 4 panels (A/B/C/D) render with blue
OutputVoltage bars (the @004c72ac ScalarBarGauge Scalar* variant + the Generator OutputVoltage table),
labels, and status lamps. **Root cause of the initial abort (cdb-attach to the frozen dialog):** the engine
+55 -4
View File
@@ -313,10 +313,19 @@ recording only its own PIDs so teardown never kills by name. Logs:
- **Cross-node convergence exact.** Owner 3:1 finished kills 3 / deaths 2 and the peer
read `kills=3 deaths=2`; owner 2:1 finished kills 2 / deaths 3 and the peer read
`kills=2 deaths=3`. Before the fix a remote pilot's column never left 0.
- **The `-2` seed is gone off-node**: the first mirror on each side shows
`wasDeaths=-2 -> deaths=<real>`, so a remote pilot's DEATHS no longer displays the
clamped fake 0 that §Headline-1 describes. (§Headline-1's *own-pod* field-choice
question is untouched and still open.)
- **DEATHS now uses the binary's own column, and §Headline-1 is CLOSED.** The port displayed
`Player::deathCount` (+0x200) -- the respawn-handshake identity, seeded -2, which is exactly why it
needed a display clamp. DEATHS is now `BTPlayer::deathTally` (our offset 0x274 = the binary's
`+0x280`, what `PilotList::Execute` @0x4cabd0 actually draws with `fild [edi+0x280]`, two runtime
writers image-wide). `deathCount` is left to the engine's handshake. So this half of the change
moves TOWARD the binary. `PLAYER_DEAD` now logs both (`deaths=` handshake, `tally=` scoreboard) so
a future divergence between them is visible.
- **`NOCREDIT` and `PLAYER_LINK` records added, and they retire the "barrier 1" hypothesis.** A
skipped credit used to be completely silent -- the counter just never moved. `NOCREDIT` now names
the failing link (missing killer entity / self-kill / unregistered mech / NULL link) and
`PLAYER_LINK` records whether the one-shot link resolved. Both rigs: `NOCREDIT` empty, and
`PLAYER_LINK ... inst=R ... resolved=1` -- a replicant's `playerLink` DOES resolve, so a NULL link
was never the cause. [T2]
- **Respawn unaffected** (the §5 risk-8 worry): 3 respawns per side, `deaths=` sequences
intact, no drop-zone or latch anomaly. `deathCount` is only ever overwritten on a
REPLICANT copy, and the handshake runs on masters.
@@ -326,6 +335,48 @@ recording only its own PIDs so teardown never kills by name. Logs:
- No crash, no `GLITCH` (plane-audit) rows, both nodes alive through 3 death/respawn
cycles.
## Review-driven refinements (adversarial pass, same day)
A 4-dimension review with a refutation pass ran against the first cut. What survived, and
what shipped because of it:
1. **The mirror is NOT self-healing on its own — update records are UNRELIABLE.**
`Entity::UpdateMessage` clears `ReliableFlag` (`ENTITY3.h:112`) and the relay's UDP path drops
stale/reordered datagrams behind a per-sender sequence gate. Since the pair was dirtied only on a
score/death EVENT, one lost datagram would leave every peer stale until that pilot's next kill or
death — forever, for the last kill of a round. **Fixed:** a 2 s scoreboard heartbeat re-dirties the
record (`BTPlayer::PlayerSimulation`), so the pair reconverges within a beat. Cheap — a Player has
no 60 Hz stream of its own and the record is 68 bytes. The timer is a function static on purpose:
a data member would change `sizeof(BTPlayer)` and break the offset locks. [T0 on the unreliability]
2. **The phantom kill's correction was a race.** Same fix: the visibility window for the binary's
wrong-column partner increment is now bounded by one heartbeat instead of "whenever that pilot
next scores". The increment itself stays byte-faithful.
3. **The edge filter could never be false, and the log lied.** The `SBMIRROR` row and its
change-detect both still read the engine's `deathCount` (a constant 2 here) instead of the
replicated `deathTally`. That is what made the first rig runs *look* like DEATHS was broken
(`deaths=-2` on every row) when the transport was provably correct — a `WRITE`/`READ` trace showed
the right values on both sides. **Fixed**, and confirmed by the row count dropping from ~20 to 3
per node (one per real change).
4. **Per-bit record layouts.** `update_model` is a BIT INDEX and `Entity::WriteUpdateRecord` switches
on it (`ENTITY.cpp:329-352`); the DamageZone bit emits a variable-length packed stream whose length
it computes itself. Appending two ints and re-stamping `recordLength` over that would corrupt it.
A pilot only ever sets the default bit, which is why the engine's own `Player::WriteUpdateRecord`
is bit-agnostic — **guarded anyway**.
5. **The dead observed-death tally is DELETED** — definition, call site, `extern` and `friend`
together (`/FORCE` hides stragglers). Under replication it would have been a second writer of a
replicated counter, and it incremented a field the scoreboard no longer reads.
6. **A skipped credit is no longer silent.** New `NOCREDIT` record names the failing link, and
`PLAYER_LINK` records whether the one-shot link resolved. Both rigs: `NOCREDIT` empty and
`PLAYER_LINK ... inst=R ... resolved=1` — which **retires the NULL-playerLink hypothesis
outright**: a replicant's link does resolve, so that was never the cause.
**Not acted on, recorded instead:** the review measured kill credits landing while the application is
not in RunningMission (`st=` on every matchlog row is the APPLICATION state). In this rig those are an
artifact of `BT_MP_FORCE_DMG` firing during load; the corpus instances are a pre-existing separate
concern that replication makes more visible, and gating or resetting the counters per round is a
design change for its own pass. `killCount`/`deathTally` are still zeroed only in the ctor, exactly as
`killCount` always was.
## Still to do
1. **Live multi-pod verification by a human** — the memory rule: this stays "fix landed,
+25 -4
View File
@@ -1579,6 +1579,23 @@ therefore point at BTPlayer's versions. No new data member and no new virtual, s
`sizeof(BTPlayer) == 0x28c` is unchanged and the existing offset locks still hold. **[T3 on the
divergence being *necessary*; T0 on the mechanism that makes it necessary.]**
The record only appends to the DEFAULT update model. `Entity::WriteUpdateRecord` switches on the
bit (`ENTITY.cpp:329-352`) and the DamageZone bit emits a variable-length packed stream whose length
it computes itself, so re-stamping `recordLength` over that would corrupt it. A pilot only ever sets
the default bit (plain `ForceUpdate()`), which is why the engine's own `Player::WriteUpdateRecord`
gets away with being bit-agnostic; we guard anyway.
**Second, smaller divergence in the same change: `+0x280` put back into service.** The DEATHS column
is now `BTPlayer::deathTally` (our offset 0x274) -- the binary's own `+0x280`, which
`PilotList::Execute` @0x4cabd0 draws with `fild [edi+0x280]` and which has exactly two runtime
writers image-wide (@0x4c0674/@0x4c067a) plus the ctor zero. Our port had declared it `pad_0x280`,
never written, and displayed the ENGINE's `Player::deathCount` (+0x200) instead -- which is really
the respawn-handshake identity (seeded -2 at `PLAYER.cpp:777`, matched at `:230`) and needed a
display clamp to pass as a count. So this half is a move TOWARD the binary, not away from it: the
gauge reads the field the arcade read, and `deathCount` is left to the engine's handshake. Naming
note: it is `deathTally`, NOT `deaths`/`deathCount`, which would shadow the base
(reconstruction-gotchas #1).
**Why it is necessary.** A kill is credited by `BTPostKillScore` on the VICTIM's node, which
resolves the killer's `Player` — a REPLICANT there — and calls `Dispatch()`. `Entity::Dispatch`
reroutes a replicant's message to the owning host (`ENTITY.cpp:244-251`), so `++killCount` lands
@@ -1589,10 +1606,14 @@ no other node can even know the killer. The pods' closed LAN made the one-way en
sufficient in 1995 only because nothing ever needed the counters off-node — the arcade cabinet
scoreboard each pod drew was its own.
**Verified live** (2-node loopback, Release, `BT_MP_FORCE_DMG`, 2026-07-25): both nodes converged
exactly — owner 3:1 (kills 3 / deaths 2) read `kills=3 deaths=2` on the peer, owner 2:1
(kills 2 / deaths 3) read `kills=2 deaths=3` on the peer. 3 respawns per side, death sequences
intact, no crash.
**Verified live** (2-node loopback, Release, `BT_MP_FORCE_DMG`, 2026-07-25, several runs): both
nodes converge exactly. Final run -- owner 3:1 (kills 1, `tally=1`) read `kills=1 deaths=1` on the
peer, and owner 2:1 (kills 1, `tally=1`) read `kills=1 deaths=1` on the peer; an earlier longer run
converged at 3 kills/2 deaths and 2 kills/3 deaths. Respawn unaffected (3 cycles per side, `deaths=`
sequences intact -- the handshake `deathCount` is only ever overwritten on a replicant copy, and the
handshake itself runs on masters). `NOCREDIT` empty on both nodes (no silently skipped credit) and
`PLAYER_LINK ... inst=R ... resolved=1`, which retires the "NULL playerLink" hypothesis outright.
No crash, no `GLITCH` rows.
**What we did NOT change (kept byte-faithful).** The kill handler's partner increment
`++sender_owner->killCount` (btplayer.cpp, the binary's `inc [ebx+0x27c]` / `inc [edx+0x27c]`
+1 -1
View File
@@ -194,7 +194,7 @@ Restore `collisionVolumeCount` and un-park `collisionVolume` (the burial at `mec
| issue | disposition |
|---|---|
| **#22** "ammo and destroyed weapons do not reset on respawn" | **Retitle, don't reopen as "the reset is missing".** The sweep shipped in 4.11.524 (commit 5f115ec is an ancestor of 6c3fca2, `rev-list --count` = 524) and provably works. Residual real gaps that #22 should now carry: `MissileLauncher` burstCount, the `AmmoBin` refill being in the wrong function, the missing chain terminus, and the illegal `RespawnRepair` cast. New title: *"the death→respawn cycle can be skipped entirely (no PLAYER_DEAD, no Mech::Reset)"*. |
| **#45** "no PLAYER_DEAD record" | **SUBSUMED — same defect, not two.** `PLAYER_DEAD` is written in the same straight-line block that starts the respawn. Merge into this investigation. |
| **#45** "no PLAYER_DEAD record" | **CORRECTED 2026-07-25: NOT subsumed.** The missing `PLAYER_DEAD` record was one symptom and it is fixed, but #45's substance is the SCOREBOARD: both counters lived only on the owning pod (KILLS because `Entity::Dispatch` reroutes a replicant's ScoreMessage to the master, ENTITY.cpp:244-251; DEATHS because `VehicleDead(-1)` runs on the victim's own master) and neither rode an update record, so every REMOTE row read 0/0 all mission. Fixed separately by replicating them (`docs/KD_SCOREBOARD_PLAN.md` addendum). Original note kept for history: **SUBSUMED — same defect, not two.** `PLAYER_DEAD` is written in the same straight-line block that starts the respawn. Merge into this investigation. |
| **#21** "recharge arc + fire-ready dot go dark" | **STAYS SEPARATE and is still live.** Its fix (the overcharge rescue, `emitter.cpp:513-538`) is *unreachable* from a dead mech (it sits inside `case 3`, after the `return` at :437) and never covered the projectile family at all (no charge accumulator, no seek voltage; their only pin is `recoil == rechargeRate`). Independent evidence it is unrelated: single-weapon permanent silence with ammo left appears in six logs that night, **five with no death at all**, plus David's own wpn 22 (123 s) and wpn 29 (66 s). Tightest repro in the corpus: David's wpn 27 vs wpn 29 — two byte-identical LRM 15s on identical 16-round bins, in lockstep for four volleys, then one fires four more times alone. That rules out anything global (heat, bus voltage, target, trigger). |
| **#46** ammo-bay fire does not kill · **#28** vital-crit death path unconfirmed | **RELATED ROOT, separate fix.** `MechSubsystem::TakeDamage` is an **empty stub** (`btstubs.cpp:178-181`), so the authentic slot-9 body `@004ac0bc` — which on the destroy edge sets `statusAlarm 1`, pins `zone[0x158] = 1.0`, and **if `[0xE4]` (vital) sets the owning mech's `graphicAlarm` to 9** (verified at `0x4ac112`/`0x4ac12c`) — never runs. Compounded by `ApplyDamageAndMeasure` (`mechsub.cpp:409-415`) returning a deterministic 0.0 through the proxy alias, so the crit budget never saturates. That is why 164 crits were all `vital=0` and every row stayed green. Not a respawn bug; do not bundle. |
| **KB corrections to land** | (a) **`DamageZone + 0x158` is accumulated DAMAGE: 0 = pristine, 1.0 = destroyed** — settled this pass from `@004ac22c` (reset writes 0), `@004ac0bc` (destroy writes 1.0 alongside `statusAlarm 1`), `@004ac144` (`>= 1.0` ⇒ tier 1). So `mechdmg.hpp:24`'s `structureLevel` naming and `MechSubsystem::GetStatusFlags`'s ">= 1.0 → intact" comment are **inverted**; `damageLevel` is right. (b) `CLASSMAP` slot labels: slot 8 = `GenerateFault(int)`, slot 9 = `TakeDamage(Damage&)`, slot 10 = `DeathReset(int)` (== the BT source's `ResetToInitialState`), slot 11 = `DeathShutdown(int)` = the `@004ad10e` forwarder — anchored to T0 `SUBSYSTM.h:146-167`. (c) `CLASSMAP`'s "AmmoBin s10 empty per AMMOBIN.TCP" is **wrong**; the `.TCP` shards are a **stale pre-MechWeapon revision** (EMITTER.TCP chains `PoweredSubsystem`, the shipped Emitter chains `MechWeapon`) and must never be used as body evidence against the binary. (d) Delete "the binary's respawn severed the vehicle and built a NEW mech" (`context/multiplayer.md:~810`, echoed in `mech4.cpp:1770-1780` and `ammobin.cpp:372`) — `@004c05c4` provably never clears `playerVehicle`; the arcade re-armed in place, so ammo refill on respawn is **authentic, not a port divergence**. (e) `docs/GAUGE_COMPOSITE.md:677` / `context/gauges-hud.md:531-533` "the port sim never calls slot 17" is **stale**. (f) Record in `gauges-hud.md` that the fire-ready **disc is a `percentDone > 0.99` lamp** (`btl4gau2.cpp:1655`), not a `weaponAlarm == Loaded` lamp — that single fact collapses six failures into one. (g) `btplayer.cpp:508-511` still asserts "during the 5-second dead window playerVehicle is severed" — the opposite of what the same file implements. |