#45 review pass: correct three claims the fix's own comments got wrong
An adversarial review of the change returned NO BLOCKERS but caught three
statements that were wrong or unverifiable. All three are corrections to my own
comments/docs, not behaviour changes -- the verified binary is unchanged.
1. The phantom-kill note had the ORDERING backwards. It claimed the owner's
record "overwrites it on the next record -- the phantom stops being visible".
The engine's event priorities run the other way: an inbound update record is
posted at UpdateEventPriority == MaxEventPriority and drained by
ExecuteBackgroundTask, while the rerouted score message sits at
EntityManagerEventPriority == DefaultEventPriority and is popped later -- so
the correction usually lands FIRST and the phantom AFTER it. The artifact is
BOUNDED, not masked: on the killer's pod only, that victim's KILLS reads +1
until the next record, i.e. <= one 2s heartbeat instead of "until the victim
next scores or dies". Swept in btplayer.cpp, KD_SCOREBOARD_PLAN.md and
RECONCILE.md.
2. The corpus figures were not reproducible, and were mis-tiered. I cited
"125 of 125 SCORE type=2 over 255 node-logs" and "0 of 8800 DMG rows" -- true
when measured, but the corpus is append-only and this fix's OWN verification
runs grew it from ~255 to 425 files, so nobody can re-derive them. Replaced
everywhere with invariant RATIOS that hold at any corpus size, re-measured
here:
* 0 of 18818 DMG rows are inst=R (damage is applied master-side only)
* 439 of 439 DEATH inst=R rows read killer=0:0 killdmg=0.000, against
0 of 225 inst=M rows (sole lastInflictingID writer: mech.cpp:746)
Also re-tagged T1 -> T2: log-corpus field evidence is T2 per the CLAUDE.md
tier table. Lesson recorded in the banner: cite ratios, not counts, for a
corpus your own rigs append to.
3. The recordLength guard's justification was wrong (and the plan's risk 5 with
it). A short legacy record does NOT desync the stream -- both sides advance
by the WRITER's stamped recordLength and no reader asserts a length. The real
hazard is a read PAST THE ALLOCATION: an inbound update message lives in a
per-event heap block sized from messageLength, so on a TRAILING legacy record
the two new fields would be read off the end of it.
Plus: the record struct's own field comment still said "deathCount -- the DEATHS
column", the exact wrong-field belief #45 removed, sitting on the wire struct.
Filed the review's other finding on #60: a SECOND decomp export gap, index
jumping FUN_0049fe80 (ends 0x49ffc8) -> FUN_004a1232, 4714 unexported bytes
containing Mech::TakeDamageMessageHandler @0x4a0230 -- the producer of every
score and death message in the game. Verified independently against
functions_index.tsv before posting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a52207d779
commit
48d47ef806
+28
-12
@@ -262,11 +262,18 @@ resolved. [T0 on the reroute and the record contents.]
|
||||
|
||||
## Field proof
|
||||
|
||||
- **125 of 125** `SCORE type=2` records across **255 node-logs** credit the LOGGING
|
||||
node's OWN player; **not one** credits a remote pilot.
|
||||
- **0 of 8800** `DMG` rows target a replicant — so a non-victim node genuinely
|
||||
cannot know the killer, which is why every `DEATH inst=R` row reads `killer=0:0`
|
||||
and why §4's "unreachable observed tally" could never have worked anyway.
|
||||
**Field proof, as INVARIANT RATIOS.** (An earlier draft cited "125 of 125 over 255 node-logs".
|
||||
That was true when measured but is not reproducible: every rig run appends to the same corpus, and
|
||||
the verification runs for this fix alone grew it from ~255 to 425 files. Cite ratios, not counts.)
|
||||
- **0 of 18818** `DMG` rows are `inst=R` — damage is applied MASTER-SIDE ONLY, so a non-victim node
|
||||
cannot attribute a kill even in principle. This is also why §4's "unreachable observed tally"
|
||||
could never have worked.
|
||||
- **439 of 439** `DEATH inst=R` rows read `killer=0:0 killdmg=0.000`, against **0 of 225** `inst=M`
|
||||
rows — a replicant victim's `lastInflictingID` is never populated locally (sole writer
|
||||
`mech.cpp:746`).
|
||||
Both re-derive with a one-line scan over `matchlogs/**` + `content/matchlog*.txt` +
|
||||
`content/matchlogs/**` and neither drifts as the corpus grows. [T2 — log-corpus field evidence is
|
||||
T2 per the CLAUDE.md tier table, not T1.]
|
||||
- Tonight's 3 nodes, one kill: host 3 logged `player=3:1 type=2 award=5.88`; the
|
||||
award equals the `killdmg=5.881` recorded on **host 4** — the value crossed the
|
||||
wire. Host 4 (the victim, the user's pod) logged the death and no kill at all.
|
||||
@@ -290,14 +297,23 @@ ships without a new dirty-bit edit — §5 risk 6 is avoided entirely.
|
||||
|
||||
Satisfies §5: risk 1 avoided (no new member → no shadow), risk 2 locks added
|
||||
(`sizeof(UpdateRecord) > sizeof(Player::UpdateRecord)` and `== base + 2*sizeof(int)`),
|
||||
risk 4 respected (exactly two ints), risk 5 **improved** — a `recordLength` guard in
|
||||
`ReadUpdateRecord` makes a mixed-build session degrade to "counters don't update"
|
||||
instead of reading a neighbouring record as a kill count.
|
||||
risk 4 respected (exactly two ints), risk 5 **corrected and improved** — that risk's premise is wrong. A mixed-build
|
||||
session does NOT desync the stream: both sides advance by the WRITER's stamped `recordLength`
|
||||
(`ENTITY.cpp:390` / `SIMULATE.cpp:323`) and no reader asserts a length. The real hazard the
|
||||
`recordLength` guard prevents is a READ PAST THE ALLOCATION — an inbound update message lives in
|
||||
a per-event heap block sized from `messageLength`, so on a TRAILING legacy record the two new
|
||||
fields would be read off the end of it. With the guard a mixed session degrades to "remote
|
||||
counters don't move".
|
||||
|
||||
**Bonus:** the §Headline-6 phantom kill (the binary's partner `inc [+0x27c]` on the
|
||||
VICTIM's player) always lands on a replicant copy, so the owner's authoritative
|
||||
value now overwrites it on the next record — the phantom stops being visible
|
||||
without touching the faithfully-reconstructed handler.
|
||||
**§Headline-6 phantom kill — BOUNDED, not masked (corrected).** The binary's partner
|
||||
`inc [+0x27c]` on the VICTIM's player always lands on a replicant copy, so the owner's record
|
||||
corrects it. But it does NOT arrive after it: an inbound update record is posted at
|
||||
`UpdateEventPriority == MaxEventPriority` and drained by `ExecuteBackgroundTask`, while the
|
||||
rerouted score message is posted at `EntityManagerEventPriority == DefaultEventPriority` and
|
||||
popped later — so the correction usually lands FIRST and the phantom after it. Net effect: on
|
||||
the killer's pod only, that victim's KILLS reads +1 until the next record, i.e. at most one
|
||||
heartbeat (2 s) instead of "until the victim next scores or dies". The increment itself stays
|
||||
byte-faithful; whether to drop it is the open fidelity question.
|
||||
|
||||
## Verification (2026-07-25, 2-node loopback, Release, `BT_MP_FORCE_DMG=1`)
|
||||
|
||||
|
||||
+11
-7
@@ -1600,9 +1600,9 @@ note: it is `deathTally`, NOT `deaths`/`deathCount`, which would shadow the base
|
||||
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
|
||||
on the killer's own machine and nowhere else, and nothing ever carried it back out. Field proof
|
||||
before the fix: across 255 node-logs, **125 of 125** `SCORE type=2` rows credit the LOGGING node's
|
||||
own player and **not one** credits a remote pilot; **0 of 8800** `DMG` rows target a replicant, so
|
||||
no other node can even know the killer. The pods' closed LAN made the one-way engine record
|
||||
before the fix, as invariant ratios (counts drift — every rig run appends to the same corpus):
|
||||
**0 of 18818** `DMG` rows are `inst=R`, so no other node can attribute the kill at all, and
|
||||
**439 of 439** `DEATH inst=R` rows read `killer=0:0` against **0 of 225** `inst=M` rows. [T2] The pods' closed LAN made the one-way engine record
|
||||
sufficient in 1995 only because nothing ever needed the counters off-node — the arcade cabinet
|
||||
scoreboard each pod drew was its own.
|
||||
|
||||
@@ -1618,10 +1618,14 @@ 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]`
|
||||
@0x4c0397/@0x4c03a3 wrong-column slip) is still reproduced as shipped. It always lands on a
|
||||
REPLICANT copy of the victim, so the owner's authoritative record now overwrites it on the next
|
||||
update — the rig captured the correction repeatedly (`wasKills=3 -> kills=2`,
|
||||
`wasKills=2 -> kills=1`). The phantom kill stops being *visible* without editing the
|
||||
reconstructed handler, so the fidelity question stays open rather than being silently decided.
|
||||
REPLICANT copy of the victim, so the owner's authoritative record corrects it — the rig captured
|
||||
that repeatedly (`wasKills=3 -> kills=2`, `wasKills=2 -> kills=1`). It is **bounded, not
|
||||
masked**: the engine's event priorities run the correction FIRST (an inbound update record is
|
||||
posted at `UpdateEventPriority == MaxEventPriority` and drained by `ExecuteBackgroundTask`,
|
||||
while the rerouted score message sits at `DefaultEventPriority` and is popped later), so the
|
||||
phantom usually lands after it. On the killer's pod only, that victim's KILLS reads +1 until
|
||||
the next record — at most one 2 s heartbeat. The increment stays byte-faithful, so the fidelity
|
||||
question stays open rather than being silently decided.
|
||||
|
||||
**Mixed-build note.** `ReadUpdateRecord` early-returns when `recordLength < sizeof(UpdateRecord)`,
|
||||
so a pod on a build without the extension degrades to "remote counters don't move" instead of
|
||||
|
||||
Reference in New Issue
Block a user