diff --git a/context/combat-damage.md b/context/combat-damage.md index c27ee31..e47370c 100644 --- a/context/combat-damage.md +++ b/context/combat-damage.md @@ -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. diff --git a/game/glass/btl4fe.cpp b/game/glass/btl4fe.cpp index 2075fb5..c733ebd 100644 --- a/game/glass/btl4fe.cpp +++ b/game/glass/btl4fe.cpp @@ -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) { diff --git a/game/glass/btl4lobby.cpp b/game/glass/btl4lobby.cpp index b86242d..b61fb47 100644 --- a/game/glass/btl4lobby.cpp +++ b/game/glass/btl4lobby.cpp @@ -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); diff --git a/game/glass/btl4lobby.hpp b/game/glass/btl4lobby.hpp index 8ed4640..f15ec99 100644 --- a/game/glass/btl4lobby.hpp +++ b/game/glass/btl4lobby.hpp @@ -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); diff --git a/game/reconstructed/btplayer.cpp b/game/reconstructed/btplayer.cpp index e55fc3d..9bc411c 100644 --- a/game/reconstructed/btplayer.cpp +++ b/game/reconstructed/btplayer.cpp @@ -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" diff --git a/game/reconstructed/mech.cpp b/game/reconstructed/mech.cpp index 88b42a4..41680f4 100644 --- a/game/reconstructed/mech.cpp +++ b/game/reconstructed/mech.cpp @@ -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);