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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
This commit is contained in:
co-authored by
Claude Opus 5
parent
27721754da
commit
e82f54c957
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user