#162 K/D chain fixed at the authority model, once and for all: death tail master-gated (the 1995 sender one-shot restored -- the binary has NO receiver dedup, +0x290 never set [T1 byte-scan]); the #81 latch-clear misattribution corrected (binary site = DropZoneReply tail); steam eggs stamp the host's advancedDamage mission-wide (penalty was silently off for every steam match); #38 badge/patch ride the lobby (bd/pt keys). Bench: kd_bench.sh, five hard invariants, 4/4 deaths+costs+credits, zero dupes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
40c35946cd
commit
3545056395
@@ -1254,3 +1254,49 @@ code involved; grammar = the binary ctors @0x49ea48/@0x49e5e4). Results:
|
||||
## Key Relationships
|
||||
- Weapons/roster: [[subsystems]]. Aim source: [[locomotion]] (drive/facing). Effects: [[rendering]].
|
||||
- P5 forensics: `docs/HARD_PROBLEMS.md`. Data: [[decomp-reference]] §4-5.
|
||||
|
||||
|
||||
## The K/D-score AUTHORITY MODEL -- settled once and for all (2026-08-11, #162) [T1]
|
||||
|
||||
The 1995 chain has ONE emitter and NO receiver dedup; every counter is single-owner.
|
||||
Fixed twice piecemeal (#150 kills, #162 deaths) before the full audit settled it -- do
|
||||
not touch any single limb of this chain again without re-reading this section.
|
||||
|
||||
**Emitter (Mech::TakeDamageMessageHandler @0x4a0230 -- the victim's MASTER only;
|
||||
replicant-asserted part_012.c:14613):** entry latch `local_14` = destroyed-at-entry
|
||||
(modes 2|9, @0x49fb54). Three id-0x16 reports: **A** type-2 KILL -> shooter's player,
|
||||
alive->dead edge only, exclusive with B (basis = victim role's killBonus, @0x4a0506);
|
||||
**B** type-0 Inflicted -> shooter, non-lethal AND corpse hits (tally != 0); **C** type-1
|
||||
Received -> victim's own player, latch-INDEPENDENT (corpse hits included; basis =
|
||||
intended damage = bursts x amount). Death tail (gate `local_14==0 && mode 9|10`,
|
||||
@0x4a07b5): exactly ONE **VehicleDead** (id 0x17, deathCount=-1, killer player id) ->
|
||||
victim's own player, + explosion + delayed burn + splash (fresh TakeDamages, gate
|
||||
advancedDamageOn && !suppressConsole) + ForceUpdate. Collision (type-0 damage) diverts
|
||||
past ALL reports straight to the tail: a ram death counts + costs but credits no one.
|
||||
|
||||
**Consumer (BTPlayer):** 0x16 type-2 @0x4c02e4: CalcKillScore, killCount(+0x27c)++ dual
|
||||
(the phantom wrong-column partner -- keep byte-faithful), suicide fchs negation skips
|
||||
both increments. 0x17 @0x4c05c4 deathCount==-1 arm: deaths(+0x280)++ then STRAIGHT-LINE
|
||||
the -500 cost (gate advancedDamageOn(+0x264) ONLY; -role->specialCaseDeathPenalty
|
||||
(+0x20) via DIRECT Player::ScoreMessageHandler base call -- never on the wire, never in
|
||||
a matchlog; the [deathcost] DEBUG line is its only receipt). **The receiver dedup gate
|
||||
+0x290 is NEVER written non-zero anywhere in BTL4OPT.EXE [T1 image byte-scan]** -- the
|
||||
binary's single-increment guarantee is purely the SENDER's one-shot edge. Its clear
|
||||
pair (+0x290=0, +0x258=0) lives in the DropZoneReply tail (FUN_004c012c,
|
||||
part_013.c:10519) -- the #81-era port put it in the VehicleDead arm by misattribution,
|
||||
disarming the port's defensive latch (corrected 2026-08-11).
|
||||
|
||||
**Replication:** the player update record carries currentScore(+0x1c8) +
|
||||
dropZoneLocation ONLY. killCount/deaths never cross the wire in 1995; each node's
|
||||
scoreboard columns are locally accumulated (the port's SBMIRROR is a deliberate
|
||||
deviation that makes boards consistent).
|
||||
|
||||
**Port rules that keep it correct:** (1) the report block AND the death tail are both
|
||||
master-gated + entry-latched (mech.cpp) -- replicant-side damage application is a
|
||||
visual-only deviation and must never emit; (2) advancedDamageOn comes from the mission
|
||||
egg per-pilot page -- every stock egg stamps `advancedDamage=1` on every page, and the
|
||||
FE egg writer now stamps the host's menu choice mission-wide (a memset-zero pilot
|
||||
record shipped penalty-off for entire steam nights, #162); (3) the bench for ANY change
|
||||
here is scratchpad/night15/kd_bench.sh -- five HARD invariants (deaths==increments,
|
||||
cost-per-death, credit-per-kill, zero swallowed, arithmetic witness); its predecessor's
|
||||
assertions were greps that matched nothing, which is how both halves shipped broken.
|
||||
|
||||
@@ -1200,6 +1200,7 @@ int
|
||||
{
|
||||
BTLobbyRoster roster;
|
||||
if (BTLobby_HostAndRoom(self.name, self.vehicle, self.color, self.experience,
|
||||
self.badge, self.patch,
|
||||
&roster, spec->steamMyToken, sizeof(spec->steamMyToken),
|
||||
spec->steamMap, sizeof(spec->steamMap)) != 0 ||
|
||||
roster.memberCount == 0)
|
||||
@@ -1207,6 +1208,15 @@ int
|
||||
return 1; // cancelled / Steam unavailable
|
||||
}
|
||||
spec->mode = BTFeLaunchHostSteam;
|
||||
// #162: capture the host's ADV. DAMAGE menu choice BEFORE the roster
|
||||
// loop -- `self` aliases mission.pilots[0], which the loop overwrites
|
||||
// with the first lobby member. advancedDamage is a MISSION-level
|
||||
// setting (every stock 1995 egg stamps the same value on every pilot
|
||||
// page); leaving it unset here shipped advancedDamage=0 for EVERY
|
||||
// pilot of EVERY steam match, which silently disabled the -500 death
|
||||
// cost and the death splash all night (night-15 [exp] receipts:
|
||||
// advDamage=0 on 24 of 26 group-mission spawns).
|
||||
const int host_adv_damage = self.advancedDamage;
|
||||
mission.pilotCount = 0;
|
||||
sprintf(spec->podList, "127.0.0.1:%d", console_port);
|
||||
for (int m = 0; m < roster.memberCount && m < 8; ++m)
|
||||
@@ -1226,6 +1236,12 @@ int
|
||||
// but now only for clients that never published a choice.
|
||||
strncpy(pilot.experience, member.experience,
|
||||
sizeof(pilot.experience) - 1);
|
||||
// #38: badge + patch ride the lobby the same way (absent = the
|
||||
// writer's VGL/Red fallback, which is exactly what every non-host
|
||||
// player got all night while these fields were never carried).
|
||||
strncpy(pilot.badge, member.badge, sizeof(pilot.badge) - 1);
|
||||
strncpy(pilot.patch, member.patch, sizeof(pilot.patch) - 1);
|
||||
pilot.advancedDamage = host_adv_damage; // #162: mission-level
|
||||
sprintf(pilot.address, "%s:%d",
|
||||
member.fakeAddress, member.gamePort);
|
||||
if (!member.isSelf)
|
||||
@@ -1245,6 +1261,7 @@ int
|
||||
if (menu.steamAction == 2)
|
||||
{
|
||||
if (BTLobby_JoinAndWait(self.name, self.vehicle, self.color, self.experience,
|
||||
self.badge, self.patch,
|
||||
spec->steamMyToken, sizeof(spec->steamMyToken),
|
||||
spec->steamMap, sizeof(spec->steamMap)) != 0)
|
||||
{
|
||||
|
||||
@@ -129,7 +129,7 @@ static CSteamID currentLobby;
|
||||
|
||||
static void
|
||||
PublishSelf(const char *pilot_name, const char *vehicle, const char *color,
|
||||
const char *experience)
|
||||
const char *experience, const char *badge, const char *patch)
|
||||
{
|
||||
//
|
||||
// Identity is implicit (the member's SteamID); the roster TOKENS are
|
||||
@@ -140,6 +140,10 @@ static void
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "vh", vehicle);
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "cl", color);
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "xp", experience);
|
||||
// #38: badge + patch complete the paint identity (the egg's fourth and
|
||||
// fifth pilot fields) -- without them every joiner rendered VGL/Red.
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "bd", badge);
|
||||
matchmaking->SetLobbyMemberData(currentLobby, "pt", patch);
|
||||
// BUILD GATE, member half: publish our exact build so the HOST can
|
||||
// reject mismatches at GO time. This is what catches OLD exes -- they
|
||||
// predate the joiner-side lobby filter, but they can't fake a "bv" key
|
||||
@@ -178,6 +182,16 @@ static int
|
||||
if (xp != NULL)
|
||||
strncpy(out->experience, xp, sizeof(out->experience) - 1);
|
||||
}
|
||||
{
|
||||
// #38: badge + patch, same tolerance -- an older client that never
|
||||
// published them keeps the old VGL/Red fallback, nobody else does.
|
||||
const char *bd = matchmaking->GetLobbyMemberData(lobby, member, "bd");
|
||||
if (bd != NULL)
|
||||
strncpy(out->badge, bd, sizeof(out->badge) - 1);
|
||||
const char *pt = matchmaking->GetLobbyMemberData(lobby, member, "pt");
|
||||
if (pt != NULL)
|
||||
strncpy(out->patch, pt, sizeof(out->patch) - 1);
|
||||
}
|
||||
out->isSelf = (member == SteamUser()->GetSteamID());
|
||||
if (out->name[0] == 0)
|
||||
{
|
||||
@@ -379,7 +393,7 @@ static int
|
||||
int
|
||||
BTLobby_HostAndRoom(
|
||||
const char *pilot_name, const char *vehicle, const char *color,
|
||||
const char *experience,
|
||||
const char *experience, const char *badge, const char *patch,
|
||||
BTLobbyRoster *roster_out,
|
||||
char *my_token_out, int my_token_capacity,
|
||||
char *steam_map_out, int steam_map_capacity)
|
||||
@@ -409,7 +423,7 @@ int
|
||||
// one stale-zip player desyncs one connection's stream). Same build or
|
||||
// no entry.
|
||||
SteamMatchmaking()->SetLobbyData(currentLobby, "btl4ver", BT_VERSION_STRING);
|
||||
PublishSelf(pilot_name, vehicle, color, experience);
|
||||
PublishSelf(pilot_name, vehicle, color, experience, badge, patch);
|
||||
LobbyLog("host: lobby up (%llu)", (unsigned long long)created.m_ulSteamIDLobby);
|
||||
|
||||
if (RunRoom(1) != 0)
|
||||
@@ -496,7 +510,7 @@ int
|
||||
int
|
||||
BTLobby_JoinAndWait(
|
||||
const char *pilot_name, const char *vehicle, const char *color,
|
||||
const char *experience,
|
||||
const char *experience, const char *badge, const char *patch,
|
||||
char *my_token_out, int my_token_capacity,
|
||||
char *steam_map_out, int steam_map_capacity)
|
||||
{
|
||||
@@ -586,7 +600,7 @@ int
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
PublishSelf(pilot_name, vehicle, color, experience);
|
||||
PublishSelf(pilot_name, vehicle, color, experience, badge, patch);
|
||||
LobbyLog("join: in lobby, waiting for GO");
|
||||
|
||||
int result = RunRoom(0);
|
||||
|
||||
@@ -34,6 +34,9 @@ struct BTLobbyMember
|
||||
// without it on the wire, the host's
|
||||
// egg authored every JOINER as the
|
||||
// WriteEgg "veteran" fallback
|
||||
char badge[16]; // #38: same class as experience --
|
||||
char patch[16]; // absent on the wire, every joiner
|
||||
// spawned VGL-badged with a Red patch
|
||||
int isSelf;
|
||||
unsigned long long steamID;
|
||||
};
|
||||
@@ -51,7 +54,7 @@ struct BTLobbyRoster
|
||||
int
|
||||
BTLobby_HostAndRoom(
|
||||
const char *pilot_name, const char *vehicle, const char *color,
|
||||
const char *experience,
|
||||
const char *experience, const char *badge, const char *patch,
|
||||
BTLobbyRoster *roster_out,
|
||||
char *my_token_out, int my_token_capacity,
|
||||
char *steam_map_out, int steam_map_capacity);
|
||||
@@ -63,6 +66,6 @@ int
|
||||
int
|
||||
BTLobby_JoinAndWait(
|
||||
const char *pilot_name, const char *vehicle, const char *color,
|
||||
const char *experience,
|
||||
const char *experience, const char *badge, const char *patch,
|
||||
char *my_token_out, int my_token_capacity,
|
||||
char *steam_map_out, int steam_map_capacity);
|
||||
|
||||
@@ -736,38 +736,25 @@ void
|
||||
}
|
||||
|
||||
//
|
||||
// RELEASE THE DEATH LATCH (#81, the GHOST MECH fix -- 2026-07-30).
|
||||
// #162 CORRECTION (2026-08-11): the #81 clear that used to sit HERE was a
|
||||
// MISATTRIBUTION. FUN_004c012c -- whose tail is `Post(...); *(+0x290)=0;
|
||||
// *(+0x258)=0` (part_013.c:10519-10523) -- is the DROP-ZONE REPLY handler,
|
||||
// not this arm. Clearing the latch at the end of THIS arm disarmed the
|
||||
// port's own dedup within the same invocation, so the duplicate
|
||||
// VehicleDeads from the replicant-side death tails (the #162 multi-count:
|
||||
// deaths=1,2 one ms apart) sailed straight through the :546 gate. The
|
||||
// latch now clears where the binary clears it: the DropZoneReply paths
|
||||
// (fresh spawn :1799, abandoned cycle :1810, already-alive probe :1829,
|
||||
// respawn complete :1900) -- which also keep the #81 ghost fix intact:
|
||||
// every terminal outcome of a cycle releases it. suppressConsole rides
|
||||
// the same binary tail, so its clear moves to DropZoneReply with it
|
||||
// (post-eject corpse hits keep their console posts suppressed until the
|
||||
// respawn -- authentic). The binary itself never SETS +0x290 [T1 image
|
||||
// byte-scan: three writes, all zero-stores] -- its guarantee is the
|
||||
// sender's one-shot edge, restored in mech.cpp's death tail the same day;
|
||||
// our set at :559 stays as a defensive tripwire (a duplicate that still
|
||||
// arrives is SWALLOWED and logged loudly instead of counted).
|
||||
//
|
||||
// The binary clears it right here, between the re-post and suppressConsole:
|
||||
// FUN_004c012c's tail is Post(...) ; *(this+0x290) = 0 ; *(this+0x258) = 0
|
||||
// (part_013.c:10519-10523). We had the Post and the suppressConsole and
|
||||
// were missing the middle instruction, so `deathPending` -- which we DO set
|
||||
// (:505, and the dedup gate that reads it IS authentic; the binary's own
|
||||
// @004c05c4 does `mov edx,[ebx+0x290]; test edx,edx; jne ret`) -- was only
|
||||
// ever cleared on the SUCCESS paths. One failed respawn therefore latched
|
||||
// the pilot dead for the rest of the mission: every later death hit the
|
||||
// dedup and was SWALLOWED, so the cycle could never restart. That is what
|
||||
// turned a transient respawn hiccup into a PERMANENT ghost (dead,
|
||||
// un-Reset, still driveable, a burning wreck on every peer that sinks out
|
||||
// of the world after ~18 s and can never be drawn again).
|
||||
//
|
||||
// Field signature it explains exactly: 2026-07-29, 8 death cycles, 6
|
||||
// stranded, NONE of them ever recovering.
|
||||
//
|
||||
// Binary evidence that the latch must not persist: `+0x290` is written in
|
||||
// exactly THREE places in the whole of BTL4OPT.EXE (file offsets 0x0b75fb,
|
||||
// 0x0bffe3, 0x0c0a05) and ALL THREE store a zeroed register (`xor` on the
|
||||
// preceding instruction); there is no write of 1 -- or of any non-zero
|
||||
// value, in any instruction form -- anywhere in the executable. So in 1995
|
||||
// the gate exists but can never block. [T1]
|
||||
//
|
||||
// Ordering matters and is preserved: the re-entrant death that arrives
|
||||
// while the death EFFECTS are being dispatched still lands while the latch
|
||||
// is up, so the "one death, one cycle" dedup is untouched.
|
||||
//
|
||||
deathPending = 0; // this+0x290 (binary: FUN_004c012c tail)
|
||||
|
||||
suppressConsole = 0; // this+0x258
|
||||
}
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
@@ -1799,6 +1786,8 @@ void
|
||||
deathPending = 0; // #55 step 1: a latch carried into a
|
||||
// fresh spawn would permanently kill
|
||||
// every later respawn (the #57 class)
|
||||
suppressConsole = 0; // this+0x258 (binary: FUN_004c012c
|
||||
// tail pairs both clears -- #162)
|
||||
// (warp fired in the shared placement below -- initial drop-in + respawn)
|
||||
}
|
||||
else if (deathCount == message->deathCount) // param_2[0xe] == param_1[0x80]
|
||||
@@ -1898,6 +1887,11 @@ void
|
||||
// the same process. The cycle's completion is HERE, so clear it here.
|
||||
//
|
||||
deathPending = 0; // this+0x290
|
||||
suppressConsole = 0; // this+0x258 -- the binary pairs
|
||||
// both clears in FUN_004c012c's
|
||||
// tail (part_013.c:10519-10523);
|
||||
// moved here from the VehicleDead
|
||||
// arm with the #162 correction
|
||||
DEBUG_STREAM << "[respawn] player " << BTMatchHostOf(GetEntityID())
|
||||
<< ":" << (int)GetEntityID() << " RESET at drop zone (death #"
|
||||
<< deathCount << ") -- death cycle complete, latch cleared\n"
|
||||
|
||||
@@ -1333,7 +1333,26 @@ void
|
||||
// on every path through here, so the edges coincide; this exact predicate
|
||||
// is the one the #89 blast benches verified both ways.
|
||||
death_tail:
|
||||
if (deathBlastArmed && IsMechDestroyed())
|
||||
// #162 (2026-08-11) -- MASTER ONLY: the discipline #150 restored for the
|
||||
// score reports, now applied to the death tail it forgot. In 1995 this
|
||||
// ENTIRE handler was replicant-asserted (part_012.c:14613), so the tail
|
||||
// could only run on the victim's master: ONE VehicleDead per death -- and
|
||||
// the receiver needs no dedup, because the binary's deathPending gate
|
||||
// (+0x290) is never written non-zero anywhere in BTL4OPT.EXE [T1 image
|
||||
// byte-scan]; the sender's one-shot edge IS the 1995 guarantee. Our port
|
||||
// deliberately applies damage on replicant copies for visual response,
|
||||
// and on those copies movementMode does not flip to 9 until the owner's
|
||||
// type-6 death record round-trips -- so deathBlastArmed stayed armed
|
||||
// there, and EVERY salvo of the killing volley re-fired this tail on
|
||||
// EVERY peer: a duplicate VehicleDead per salvo per node, rerouted to the
|
||||
// victim's master (night-15 receipts: 5 real deaths -> 8 PLAYER_DEAD,
|
||||
// deaths=1,2 one ms apart, x3 same-millisecond; multiplicity tracks the
|
||||
// killing-window ROUNDS, not shooters or peers), plus a duplicate death
|
||||
// splash per peer. Peers' death visuals ride the type-6 record path
|
||||
// (UpdateDeathState wreck/explosion), never this tail -- gating it here
|
||||
// removes only the phantom emissions the binary never made.
|
||||
if (deathBlastArmed && IsMechDestroyed()
|
||||
&& GetInstance() != ReplicantInstance)
|
||||
{
|
||||
extern void BTMechPostVehicleDead(void *, void *, int);
|
||||
BTMechPostVehicleDead((void *)this, (void *)message, reportZone);
|
||||
|
||||
Reference in New Issue
Block a user