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); } } }