From e82f54c957540059f2887f137348579a9bc5864c Mon Sep 17 00:00:00 2001 From: Joe DiPrima Date: Fri, 7 Aug 2026 08:41:59 -0500 Subject: [PATCH] scoring: the score AUTHORITY is the operator console -- and our port has none Follow-up to 2772175 (type-0 interceptor restored). Benching the restored credit exposed the next layer, and two of my attempts at it were wrong; both are recorded so they are not retried. FINDING [T1]: the binary flushes ConsolePlayerVTVScoreUpdate(ownerID, currentScore) then does `param_1[0x9e] = 0` -- UNGATED. So +0x278 is a console DELTA, never a running total, and it does not matter which NODE computed one: every delta is stamped with the scoring player's ownerID and the CONSOLE accumulates. That is almost certainly where the manual chart's "+1000 starting the game" was seeded, which is why no game-side code grants it. Our port has no console as score authority. GetScore() (SCORE gauge), CalcRanking() and the replicated Player__UpdateRecord all read +0x278 on the OWNING node. Damage is applied on the VICTIM's node, so block B dispatches the inflicted report to the SHOOTER's player object there -- a REPLICANT -- and the master's next update record overwrites it. Benched: totals climb to ~35 and snap back every few seconds. THAT is the "scoring went screwy" report. WRONG TURN 1 (reverted in spirit, kept only where harmless): blamed the console flush and added a last-sent watermark so the console still gets deltas while +0x278 keeps a total. The resets were 2s apart, not on the 10s console interval -- the timing was already in the data. The watermark stays because it does stop the FLUSH from zeroing a master's own total, but it was not the bug. WRONG TURN 2 (reverted): gated the interception to MasterInstance so a replicant would reroute to the master. The message arrives, but the BT extension fields (damageAmount@+0x24, senderMechID@+0x34) do NOT survive the wire -- only the base scoreAward -- so every award computed 0.00. That failure is the clue to the answer: the kill report (type 2) credits cross-node correctly precisely because its value rides scoreAward. FIX SHAPE (not implemented -- landing it deliberately rather than guessing a third time): compute the award on the victim's node, where the damage data lives, and ship the RESULT in scoreAward the way the kill report already does, instead of shipping the basis and recomputing on a machine that cannot see it. State now = binary-faithful unconditional interception. Re-benched: 79 inflicted rows, awards 0.98..25.00 all positive and tracking damage, 0 type-0 Verify rejections. Cross-node banking still open. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ --- context/combat-damage.md | 20 ++++++ game/reconstructed/btplayer.cpp | 120 +++++++++++++++++++++++++++++--- 2 files changed, 131 insertions(+), 9 deletions(-) diff --git a/context/combat-damage.md b/context/combat-damage.md index d42d56c..a145631 100644 --- a/context/combat-damage.md +++ b/context/combat-damage.md @@ -646,6 +646,26 @@ is the cross-check for every row and should be consulted before touching this pa | −500 | Destroying your own 'Mech by an ammo explosion | | −1000 | Destroying your own 'Mech by ejecting | +⚠ **THE SCORE AUTHORITY IS THE OPERATOR CONSOLE, not the player object** [T1, 2026-08-07]. The +binary sends `ConsolePlayerVTVScoreUpdate(ownerID, currentScore)` every `CONSOLE_UPDATE_INTERVAL` +and then does `param_1[0x9e] = 0` — **ungated**. So `+0x278` is a *console DELTA*, never a running +total, and it does not matter which NODE computed a delta: every node's contribution is flushed +stamped with the scoring player's `ownerID` and the console accumulates. This is almost certainly +where the chart's **+1000 starting the game** was seeded, which is why no game-side code grants it. + +**Consequence for the port** (no console as score authority): `GetScore()` (SCORE gauge), +`Player::CalcRanking()` and the replicated `Player__UpdateRecord` all read `+0x278` **on the owning +node**. Damage is applied on the VICTIM's node, so block B dispatches the inflicted report to the +SHOOTER's player object there — a **REPLICANT** — and the credit is banked on the wrong machine, +where the master's next update record overwrites it (benched: totals climb to ~35, snap back every +few seconds = the field "scoring is screwy"). ⚠ OPEN. **Tried and rejected:** gating the type-0 +interception to `MasterInstance` so a replicant reroutes — the message arrives but the BT extension +fields (`damageAmount`@+0x24, `senderMechID`@+0x34) do NOT survive the wire, only the base +`scoreAward`, so every award computes 0.00. That is also WHY the kill report (type 2) already +credits cross-node correctly: its value rides `scoreAward`. **Fix shape:** compute the award on the +victim's node (where the damage data is) and ship the RESULT in `scoreAward`, as the kill report +does — do not ship the basis and recompute where it cannot be seen. + ⚠ **Three chart rows are NOT yet reconciled with the reconstruction** — treat as open [T4]: (a) a kill benches at `award=4.88`, two orders off the chart's flat **+500**; (b) **+1000 at game start** has no known implementation; (c) **−1000 eject / −500 ammo** would live in diff --git a/game/reconstructed/btplayer.cpp b/game/reconstructed/btplayer.cpp index 06e9fb4..63fec7a 100644 --- a/game/reconstructed/btplayer.cpp +++ b/game/reconstructed/btplayer.cpp @@ -312,6 +312,9 @@ static const Scalar TicksPerSecond = 1.0f; // (see note in PlayerSimulation) //############################### BTPlayer ############################## //############################################################################# +Scalar BTScoreWatermarkOf(int owner); +void BTScoreWatermarkSet(int owner, Scalar sent); + //############################################################################# // Message Support // @@ -731,6 +734,49 @@ void suppressConsole = 0; // this+0x258 } +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +// Console score watermark (port-side, 2026-08-07) +// +// How much of a player's running score has already been reported to the +// operator console. Keyed by ownerID and kept OUTSIDE BTPlayer: sizeof +// (BTPlayer) is static_assert-locked at 652 against the binary, so a new data +// member is not available (the console timer above is a file static for the +// same reason). A pod round is a handful of players; linear scan is free. +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +namespace { + struct ScoreWatermark { int owner; Scalar sent; }; + ScoreWatermark gScoreWatermarks[16]; + int gScoreWatermarkCount = 0; +} + +Scalar + BTScoreWatermarkOf(int owner) +{ + for (int i = 0; i < gScoreWatermarkCount; ++i) + if (gScoreWatermarks[i].owner == owner) + return gScoreWatermarks[i].sent; + return 0.0f; +} + +void + BTScoreWatermarkSet(int owner, Scalar sent) +{ + for (int i = 0; i < gScoreWatermarkCount; ++i) + if (gScoreWatermarks[i].owner == owner) + { + gScoreWatermarks[i].sent = sent; + return; + } + if (gScoreWatermarkCount + < (int)(sizeof(gScoreWatermarks) / sizeof(gScoreWatermarks[0]))) + { + gScoreWatermarks[gScoreWatermarkCount].owner = owner; + gScoreWatermarks[gScoreWatermarkCount].sent = sent; + ++gScoreWatermarkCount; + } +} + + //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // Dispatch (@004bffa0, vtable @00513300 slot 3) // @@ -763,7 +809,8 @@ void if (what != 0 && what->messageID == Player::ScoreMessageID && ((BTPlayer::ScoreMessage *)what)->scoreType - == BTPlayer::ScoreMessage::DamageInflictedScore) + == BTPlayer::ScoreMessage::DamageInflictedScore + ) { ScoreInflictedMessageHandler((BTPlayer::ScoreMessage *)what); return; @@ -771,6 +818,40 @@ void Player::Dispatch(what); } +// +// ⚠ OPEN, cross-node credit routing (2026-08-07). The interception above is +// binary-faithful and restores per-hit inflicted credit -- benched, awards +// track damage. What it does NOT yet solve is WHICH MACHINE banks it. +// +// Damage is applied on the VICTIM's node, so block B dispatches the inflicted +// report to the SHOOTER's player object THERE, which on that node is a +// REPLICANT. In 1995 that was fine: +0x278 is only ever a console DELTA, and +// it is flushed to the operator console stamped with the shooter's ownerID -- +// the CONSOLE holds the authoritative total, so it does not matter which node +// computed a delta. (That is almost certainly where the manual chart's "+1000 +// starting the game" was seeded, which is why no game-side code grants it.) +// +// Our port has no console as score authority: GetScore() (the SCORE gauge), +// CalcRanking() and the replicated Player__UpdateRecord all read +0x278 on the +// OWNING node. So a credit banked on the victim's replicant copy is +// overwritten by the master's next update record -- benched as totals climbing +// to ~35 and snapping back every few seconds. +// +// TRIED AND REJECTED: gating the interception to MasterInstance so a replicant +// falls through and reroutes to the master. The message arrives, but the BT +// extension fields (damageAmount@+0x24, senderMechID@+0x34) do NOT survive the +// wire -- only the base Player::ScoreMessage `scoreAward` does -- so every +// award computed to 0.00. That is also WHY the kill path (type 2) already +// credits cross-node correctly: its value rides `scoreAward`. +// +// THE FIX SHAPE, therefore: compute the award on the victim's node (where the +// damage data lives, exactly as now) and ship the RESULT to the owner in +// `scoreAward`, the way the kill report already does -- rather than shipping +// the basis and recomputing on a machine that cannot see it. +// + + + //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // ScoreInflictedMessageHandler @@ -1184,20 +1265,38 @@ void // // Only bother if our score actually changed since last time. // - if ((Scalar)currentScore != 0.0f) // this[0x9e] != _DAT_004c0900 (0.0f) + // DELTA vs RUNNING TOTAL (2026-08-07). The binary sends currentScore + // and then ZEROES it, ungated (@FUN_..., `param_1[0x9e] = 0` right after + // the send) -- so in 1995 +0x278 was a CONSOLE DELTA and the running + // total lived on the operator console, which is also where the manual's + // "+1000 starting the game" would have been seeded. + // + // Our port cannot copy that literally: THREE port-side consumers read + // +0x278 as a running total -- GetScore() (the SCORE gauge), + // Player::CalcRanking(), and Player__UpdateRecord (the only score field + // we replicate to peers). Zeroing it made all three reset every + // CONSOLE_UPDATE_INTERVAL, which is what players saw as "scoring went + // screwy" (bench: totals climbed to ~35 and dropped back). It was + // mostly invisible until the type-0 interceptor was restored, because + // before that currentScore barely moved. + // + // So: keep the WIRE authentic (the console still receives a DELTA) and + // keep the OBJECT sane (currentScore stays a true running total). The + // last-sent watermark is a file static keyed by player -- a new data + // member would change sizeof(BTPlayer) and break the offset locks (same + // reason the console timer above is a static). + // + const Scalar sent_already = BTScoreWatermarkOf(ownerID); + const Scalar delta = (Scalar)currentScore - sent_already; + if (delta != 0.0f) { - int score = (int)currentScore; + int score = (int)delta; ConsolePlayerVTVScoreUpdateMessage score_message( ownerID, score ); // FUN_00420ea4(0x20, 0x1a, 1, ...) - // gauge scoring wave: the binary's currentScore is a console DELTA that is - // flushed to the operator console then zeroed. Our SCORE/RANK gauges read - // currentScore as the RUNNING total, so only flush+zero when a console host - // is actually present (MP / pod); in solo there is no console -> keep the - // running score so the SCORE gauge + CalcRanking don't reset every 10s. Host *console_host = application->GetHostManager()->GetConsoleHost(); // FUN_00429078 if (console_host) @@ -1216,7 +1315,10 @@ void NetworkClient::ConsoleClientID, // 5 &score_message ); - currentScore = 0; // this[0x9e] = 0 + // The binary does `currentScore = 0` here. We advance the + // watermark by the amount actually SENT instead, so the console + // sees the same deltas while the object keeps the total. + BTScoreWatermarkSet(ownerID, sent_already + (Scalar)score); } } }