From a52207d77919837744a9f660e05dcc9a70ee57d9 Mon Sep 17 00:00:00 2001 From: arcattack Date: Sat, 25 Jul 2026 22:09:00 -0500 Subject: [PATCH] #45 scoreboard: reclaim the binary's DEATHS field, add a heartbeat, sweep the false KB claims Follow-up to 4fa7eee, driven by an adversarial review pass and three rig cycles. Each item below is a real defect that pass found, not polish. DEATHS now uses the binary's own column. PilotList::Execute @0x4cabd0 draws `fild [edi+0x27c]` (KILLS) and `fild [edi+0x280]` (DEATHS); +0x280 has exactly two runtime writers image-wide plus the ctor zero. Our port had declared it `pad_0x280`, never written, and displayed the ENGINE's Player::deathCount (+0x200) instead -- which is the respawn-handshake identity, seeded -2, and is why it needed a clamp to pass as a count. It is now BTPlayer::deathTally (our offset 0x274, offsetof-locked), incremented beside ++deathCount, read by the gauge, and replicated. deathCount is left to the engine's handshake. So this half moves TOWARD the binary; it also closes the plan's Headline-1. (`deathTally`, NOT `deaths`/`deathCount` -- those would shadow the base.) The mirror was not self-healing: update records are UNRELIABLE by construction (Entity::UpdateMessage clears ReliableFlag, ENTITY3.h:112; the relay's UDP path also drops stale/reordered datagrams), and the pair was dirtied only on an event. One lost datagram left every peer stale until that pilot's next kill or death -- forever for the last kill of a round. Added a 2s heartbeat that re-dirties the record, which also bounds how long the binary's phantom partner-increment stays visible. The timer is a function static deliberately: a data member would change sizeof(BTPlayer) and break the offset locks. Also fixed: the SBMIRROR row AND its change-detect both still read deathCount (a constant -2 here), so the "log only the edge" guard could never be false and every row printed deaths=-2. That is what made the first rig runs look like DEATHS was broken when a WRITE/READ trace proved the transport correct. Row count per node fell from ~20 to 3, one per real change. Guarded the record against per-bit layouts: update_model is a BIT INDEX and Entity::WriteUpdateRecord switches on it (ENTITY.cpp:329-352) -- the DamageZone bit emits a variable-length packed stream whose length it computes itself, so appending two ints and re-stamping recordLength over that would corrupt it. Deleted BTPlayerCountObservedDeath -- definition, call site, extern and friend together, since /FORCE hides stragglers. It never executed (its call site sat inside the once-per-death transition, which a replicant never enters) and could not have worked (0 of 8800 corpus DMG rows target a replicant, which is why every DEATH inst=R row reads killer=0:0). Under replication it would have been a second writer of a replicated counter. New forensics: NOCREDIT names the failing link when a kill credit is skipped (it used to be completely silent -- the counter simply never moved), and PLAYER_LINK records whether the one-shot link resolved. Both retire the NULL-playerLink theory: every rig shows `PLAYER_LINK inst=R resolved=1` and no NOCREDIT rows. PLAYER_DEAD now logs both counters (deaths=handshake, tally=scoreboard). KB sweep of the claims that hid this bug for so long: * context/gauges-hud.md's "RESOLVED -- deaths tally per node from locally observed events" was FALSE; corrected with the measured evidence. * docs/GAUGE_COMPOSITE.md + btl4gau3.cpp "the dead pad_0x280" -- never dead, merely unwritten. * btplayer.hpp attributed VehicleDeadMessageHandler to @004c012c; it is @004c05c4 (absent from the decomp export -- the #60 gap). * docs/RESPAWN_REARM_PLAN.md's "#45 SUBSUMED" -- the PLAYER_DEAD symptom was subsumed, the scoreboard defect was not. * The corpus figure in the record banner now states its method so it is reproducible. Rig-verified (2-node loopback, several cycles): owner 3:1 kills 2/tally 1 read `kills=2 deaths=1` on the peer; owner 2:1 kills 1/tally 2 read `kills=1 deaths=2` on the peer. Respawn unaffected, no crash, no GLITCH rows. Still awaiting live multi-pod verification by a human; all pods must run the same build. Co-Authored-By: Claude Opus 5 (1M context) --- context/gauges-hud.md | 23 +++- docs/GAUGE_COMPOSITE.md | 6 +- docs/KD_SCOREBOARD_PLAN.md | 59 +++++++++- docs/RECONCILE.md | 29 ++++- docs/RESPAWN_REARM_PLAN.md | 2 +- game/reconstructed/btl4gau3.cpp | 9 +- game/reconstructed/btplayer.cpp | 188 ++++++++++++++++++++++++-------- game/reconstructed/btplayer.hpp | 35 ++++-- game/reconstructed/mech.cpp | 15 +++ game/reconstructed/mech4.cpp | 25 ++--- scratchpad/rig45_down.ps1 | 11 +- 11 files changed, 316 insertions(+), 86 deletions(-) diff --git a/context/gauges-hud.md b/context/gauges-hud.md index d8fdd8c..ab03f1c 100644 --- a/context/gauges-hud.md +++ b/context/gauges-hud.md @@ -332,11 +332,24 @@ One ROW PER PILOT in the mission (2-player MP = 2 rows — not duplicate display rows); DEATHS = `Player::deathCount`: engine-inits to **−2** (PLAYER.cpp:759), the LOCAL vehicle-acquire branch zeroes it (btplayer.cpp:1118), then VehicleDead(-1) ++s per death. A REMOTE player's Player object never runs the local acquire → −2 +1 spawn increment = **−1 -locked**. **RESOLVED 2026-07-12 [T2]:** deaths now tally per node from LOCALLY OBSERVED events -(the same model as the cross-pod KILLS credit) — a replicant's once-per-death transition calls -`BTPlayerCountObservedDeath` on its owning player's local copy (replicant-gated: the master's -own VehicleDead path counts its node), and `BTPilotDeaths` clamps the −2/−1 pre-acquire seed -to 0 for display. Own row counts correctly as before. +locked**. **SUPERSEDED -- that "RESOLVED" claim was FALSE (corrected 2026-07-25, Gitea #45).** The +observed-tally design never ran: `BTPlayerCountObservedDeath`'s only call site is +replicant-gated but sits INSIDE `Mech::UpdateDeathState`'s once-per-death transition, which a +replicant never enters (it takes the mode-9 early return) -- and it could not have worked +anyway, because a replicant victim carries no attribution at all (**0 of 8800** `DMG` rows in +the corpus target a replicant, so every `DEATH inst=R` row reads `killer=0:0`). What actually +happened: BOTH counters lived only on the OWNING pod, so every REMOTE row read 0/0 all +mission -- KILLS because `Entity::Dispatch` reroutes a replicant's ScoreMessage to the master +(ENTITY.cpp:244-251), so `++killCount` lands on the killer's own machine; DEATHS because the +`VehicleDead(-1)` handler runs on the victim's own master. Neither counter rode an update +record. Measured: **125 of 125** `SCORE type=2` rows across 255 node-logs credit the LOGGING +node's own player; not one credits a remote pilot. [T1] +**FIXED 2026-07-25 [T2, rig-verified]:** DEATHS now reads the binary's OWN `+0x280` column +(`BTPlayer::deathTally`, our offset 0x274 -- previously declared `pad_0x280` and "dead"), not +`Player::deathCount` (which is the respawn-handshake identity, seeded -2 -- exactly what the +old display clamp was hiding). Both counters replicate owner->replicant via a +`BTPlayer__UpdateRecord` extension, so every pod shows the same numbers. See +`docs/KD_SCOREBOARD_PLAN.md` (addendum) + `docs/RECONCILE.md`. ## Launcher-panel recharge dial — CORRECTION 2026-07-19: it IS live (slot 17 @004b9c9c) [T1] The weapon panels' SegmentArc270 tick ring reads MechWeapon::rechargeLevel (+0x320). diff --git a/docs/GAUGE_COMPOSITE.md b/docs/GAUGE_COMPOSITE.md index 5707257..91f7bf4 100644 --- a/docs/GAUGE_COMPOSITE.md +++ b/docs/GAUGE_COMPOSITE.md @@ -528,8 +528,10 @@ a prose-only vtable slot AVs on first call). ValveSetting→coolantFlowScale@0x15C. The SET valve indicator renders (row=Round(span*value)). - ✅ **PilotList** (Comm KILLS/DEATHS roster; part_014.c:3156-3434, DAT_0051af88 8×3 layout PE-parsed) — the Comm surface now renders the live local-pilot row (KILLS 0 DEATHS 0 + name box). Feed = new - `BTResolveRosterPilot` bridge (mechmppr.cpp) → mapper roster. DEATHS redirected from the dead pad_0x280 to - the real deathCount@0x200. The scoring feed that moves KILLS/DEATHS is P3. + `BTResolveRosterPilot` bridge (mechmppr.cpp) → mapper roster. DEATHS now reads the binary's own + `+0x280` column (`BTPlayer::deathTally`, our offset 0x274) -- **corrected 2026-07-25 (#45)**: it was + briefly redirected to `Player::deathCount@0x200`, but that field is the respawn-handshake identity + (seeded -2), which is why it needed a display clamp. `+0x280` was never dead, merely unwritten. The scoring feed that moves KILLS/DEATHS is P3. - ✅ **GeneratorCluster** (4 generator panels, buttons 9-12) — the 4 panels (A/B/C/D) render with blue OutputVoltage bars (the @004c72ac ScalarBarGauge Scalar* variant + the Generator OutputVoltage table), labels, and status lamps. **Root cause of the initial abort (cdb-attach to the frozen dialog):** the engine diff --git a/docs/KD_SCOREBOARD_PLAN.md b/docs/KD_SCOREBOARD_PLAN.md index 0dfa421..6753d87 100644 --- a/docs/KD_SCOREBOARD_PLAN.md +++ b/docs/KD_SCOREBOARD_PLAN.md @@ -313,10 +313,19 @@ recording only its own PIDs so teardown never kills by name. Logs: - **Cross-node convergence exact.** Owner 3:1 finished kills 3 / deaths 2 and the peer read `kills=3 deaths=2`; owner 2:1 finished kills 2 / deaths 3 and the peer read `kills=2 deaths=3`. Before the fix a remote pilot's column never left 0. -- **The `-2` seed is gone off-node**: the first mirror on each side shows - `wasDeaths=-2 -> deaths=`, so a remote pilot's DEATHS no longer displays the - clamped fake 0 that §Headline-1 describes. (§Headline-1's *own-pod* field-choice - question is untouched and still open.) +- **DEATHS now uses the binary's own column, and §Headline-1 is CLOSED.** The port displayed + `Player::deathCount` (+0x200) -- the respawn-handshake identity, seeded -2, which is exactly why it + needed a display clamp. DEATHS is now `BTPlayer::deathTally` (our offset 0x274 = the binary's + `+0x280`, what `PilotList::Execute` @0x4cabd0 actually draws with `fild [edi+0x280]`, two runtime + writers image-wide). `deathCount` is left to the engine's handshake. So this half of the change + moves TOWARD the binary. `PLAYER_DEAD` now logs both (`deaths=` handshake, `tally=` scoreboard) so + a future divergence between them is visible. +- **`NOCREDIT` and `PLAYER_LINK` records added, and they retire the "barrier 1" hypothesis.** A + skipped credit used to be completely silent -- the counter just never moved. `NOCREDIT` now names + the failing link (missing killer entity / self-kill / unregistered mech / NULL link) and + `PLAYER_LINK` records whether the one-shot link resolved. Both rigs: `NOCREDIT` empty, and + `PLAYER_LINK ... inst=R ... resolved=1` -- a replicant's `playerLink` DOES resolve, so a NULL link + was never the cause. [T2] - **Respawn unaffected** (the §5 risk-8 worry): 3 respawns per side, `deaths=` sequences intact, no drop-zone or latch anomaly. `deathCount` is only ever overwritten on a REPLICANT copy, and the handshake runs on masters. @@ -326,6 +335,48 @@ recording only its own PIDs so teardown never kills by name. Logs: - No crash, no `GLITCH` (plane-audit) rows, both nodes alive through 3 death/respawn cycles. +## Review-driven refinements (adversarial pass, same day) + +A 4-dimension review with a refutation pass ran against the first cut. What survived, and +what shipped because of it: + +1. **The mirror is NOT self-healing on its own — update records are UNRELIABLE.** + `Entity::UpdateMessage` clears `ReliableFlag` (`ENTITY3.h:112`) and the relay's UDP path drops + stale/reordered datagrams behind a per-sender sequence gate. Since the pair was dirtied only on a + score/death EVENT, one lost datagram would leave every peer stale until that pilot's next kill or + death — forever, for the last kill of a round. **Fixed:** a 2 s scoreboard heartbeat re-dirties the + record (`BTPlayer::PlayerSimulation`), so the pair reconverges within a beat. Cheap — a Player has + no 60 Hz stream of its own and the record is 68 bytes. The timer is a function static on purpose: + a data member would change `sizeof(BTPlayer)` and break the offset locks. [T0 on the unreliability] +2. **The phantom kill's correction was a race.** Same fix: the visibility window for the binary's + wrong-column partner increment is now bounded by one heartbeat instead of "whenever that pilot + next scores". The increment itself stays byte-faithful. +3. **The edge filter could never be false, and the log lied.** The `SBMIRROR` row and its + change-detect both still read the engine's `deathCount` (a constant −2 here) instead of the + replicated `deathTally`. That is what made the first rig runs *look* like DEATHS was broken + (`deaths=-2` on every row) when the transport was provably correct — a `WRITE`/`READ` trace showed + the right values on both sides. **Fixed**, and confirmed by the row count dropping from ~20 to 3 + per node (one per real change). +4. **Per-bit record layouts.** `update_model` is a BIT INDEX and `Entity::WriteUpdateRecord` switches + on it (`ENTITY.cpp:329-352`); the DamageZone bit emits a variable-length packed stream whose length + it computes itself. Appending two ints and re-stamping `recordLength` over that would corrupt it. + A pilot only ever sets the default bit, which is why the engine's own `Player::WriteUpdateRecord` + is bit-agnostic — **guarded anyway**. +5. **The dead observed-death tally is DELETED** — definition, call site, `extern` and `friend` + together (`/FORCE` hides stragglers). Under replication it would have been a second writer of a + replicated counter, and it incremented a field the scoreboard no longer reads. +6. **A skipped credit is no longer silent.** New `NOCREDIT` record names the failing link, and + `PLAYER_LINK` records whether the one-shot link resolved. Both rigs: `NOCREDIT` empty and + `PLAYER_LINK ... inst=R ... resolved=1` — which **retires the NULL-playerLink hypothesis + outright**: a replicant's link does resolve, so that was never the cause. + +**Not acted on, recorded instead:** the review measured kill credits landing while the application is +not in RunningMission (`st=` on every matchlog row is the APPLICATION state). In this rig those are an +artifact of `BT_MP_FORCE_DMG` firing during load; the corpus instances are a pre-existing separate +concern that replication makes more visible, and gating or resetting the counters per round is a +design change for its own pass. `killCount`/`deathTally` are still zeroed only in the ctor, exactly as +`killCount` always was. + ## Still to do 1. **Live multi-pod verification by a human** — the memory rule: this stays "fix landed, diff --git a/docs/RECONCILE.md b/docs/RECONCILE.md index c4e9baa..f591350 100644 --- a/docs/RECONCILE.md +++ b/docs/RECONCILE.md @@ -1579,6 +1579,23 @@ therefore point at BTPlayer's versions. No new data member and no new virtual, s `sizeof(BTPlayer) == 0x28c` is unchanged and the existing offset locks still hold. **[T3 on the divergence being *necessary*; T0 on the mechanism that makes it necessary.]** +The record only appends to the DEFAULT update model. `Entity::WriteUpdateRecord` switches on the +bit (`ENTITY.cpp:329-352`) and the DamageZone bit emits a variable-length packed stream whose length +it computes itself, so re-stamping `recordLength` over that would corrupt it. A pilot only ever sets +the default bit (plain `ForceUpdate()`), which is why the engine's own `Player::WriteUpdateRecord` +gets away with being bit-agnostic; we guard anyway. + +**Second, smaller divergence in the same change: `+0x280` put back into service.** The DEATHS column +is now `BTPlayer::deathTally` (our offset 0x274) -- the binary's own `+0x280`, which +`PilotList::Execute` @0x4cabd0 draws with `fild [edi+0x280]` and which has exactly two runtime +writers image-wide (@0x4c0674/@0x4c067a) plus the ctor zero. Our port had declared it `pad_0x280`, +never written, and displayed the ENGINE's `Player::deathCount` (+0x200) instead -- which is really +the respawn-handshake identity (seeded -2 at `PLAYER.cpp:777`, matched at `:230`) and needed a +display clamp to pass as a count. So this half is a move TOWARD the binary, not away from it: the +gauge reads the field the arcade read, and `deathCount` is left to the engine's handshake. Naming +note: it is `deathTally`, NOT `deaths`/`deathCount`, which would shadow the base +(reconstruction-gotchas #1). + **Why it is necessary.** A kill is credited by `BTPostKillScore` on the VICTIM's node, which resolves the killer's `Player` — a REPLICANT there — and calls `Dispatch()`. `Entity::Dispatch` reroutes a replicant's message to the owning host (`ENTITY.cpp:244-251`), so `++killCount` lands @@ -1589,10 +1606,14 @@ no other node can even know the killer. The pods' closed LAN made the one-way en sufficient in 1995 only because nothing ever needed the counters off-node — the arcade cabinet scoreboard each pod drew was its own. -**Verified live** (2-node loopback, Release, `BT_MP_FORCE_DMG`, 2026-07-25): both nodes converged -exactly — owner 3:1 (kills 3 / deaths 2) read `kills=3 deaths=2` on the peer, owner 2:1 -(kills 2 / deaths 3) read `kills=2 deaths=3` on the peer. 3 respawns per side, death sequences -intact, no crash. +**Verified live** (2-node loopback, Release, `BT_MP_FORCE_DMG`, 2026-07-25, several runs): both +nodes converge exactly. Final run -- owner 3:1 (kills 1, `tally=1`) read `kills=1 deaths=1` on the +peer, and owner 2:1 (kills 1, `tally=1`) read `kills=1 deaths=1` on the peer; an earlier longer run +converged at 3 kills/2 deaths and 2 kills/3 deaths. Respawn unaffected (3 cycles per side, `deaths=` +sequences intact -- the handshake `deathCount` is only ever overwritten on a replicant copy, and the +handshake itself runs on masters). `NOCREDIT` empty on both nodes (no silently skipped credit) and +`PLAYER_LINK ... inst=R ... resolved=1`, which retires the "NULL playerLink" hypothesis outright. +No crash, no `GLITCH` rows. **What we did NOT change (kept byte-faithful).** The kill handler's partner increment `++sender_owner->killCount` (btplayer.cpp, the binary's `inc [ebx+0x27c]` / `inc [edx+0x27c]` diff --git a/docs/RESPAWN_REARM_PLAN.md b/docs/RESPAWN_REARM_PLAN.md index 5fef73b..d7f3de2 100644 --- a/docs/RESPAWN_REARM_PLAN.md +++ b/docs/RESPAWN_REARM_PLAN.md @@ -194,7 +194,7 @@ Restore `collisionVolumeCount` and un-park `collisionVolume` (the burial at `mec | issue | disposition | |---|---| | **#22** "ammo and destroyed weapons do not reset on respawn" | **Retitle, don't reopen as "the reset is missing".** The sweep shipped in 4.11.524 (commit 5f115ec is an ancestor of 6c3fca2, `rev-list --count` = 524) and provably works. Residual real gaps that #22 should now carry: `MissileLauncher` burstCount, the `AmmoBin` refill being in the wrong function, the missing chain terminus, and the illegal `RespawnRepair` cast. New title: *"the death→respawn cycle can be skipped entirely (no PLAYER_DEAD, no Mech::Reset)"*. | -| **#45** "no PLAYER_DEAD record" | **SUBSUMED — same defect, not two.** `PLAYER_DEAD` is written in the same straight-line block that starts the respawn. Merge into this investigation. | +| **#45** "no PLAYER_DEAD record" | **CORRECTED 2026-07-25: NOT subsumed.** The missing `PLAYER_DEAD` record was one symptom and it is fixed, but #45's substance is the SCOREBOARD: both counters lived only on the owning pod (KILLS because `Entity::Dispatch` reroutes a replicant's ScoreMessage to the master, ENTITY.cpp:244-251; DEATHS because `VehicleDead(-1)` runs on the victim's own master) and neither rode an update record, so every REMOTE row read 0/0 all mission. Fixed separately by replicating them (`docs/KD_SCOREBOARD_PLAN.md` addendum). Original note kept for history: **SUBSUMED — same defect, not two.** `PLAYER_DEAD` is written in the same straight-line block that starts the respawn. Merge into this investigation. | | **#21** "recharge arc + fire-ready dot go dark" | **STAYS SEPARATE and is still live.** Its fix (the overcharge rescue, `emitter.cpp:513-538`) is *unreachable* from a dead mech (it sits inside `case 3`, after the `return` at :437) and never covered the projectile family at all (no charge accumulator, no seek voltage; their only pin is `recoil == rechargeRate`). Independent evidence it is unrelated: single-weapon permanent silence with ammo left appears in six logs that night, **five with no death at all**, plus David's own wpn 22 (−123 s) and wpn 29 (−66 s). Tightest repro in the corpus: David's wpn 27 vs wpn 29 — two byte-identical LRM 15s on identical 16-round bins, in lockstep for four volleys, then one fires four more times alone. That rules out anything global (heat, bus voltage, target, trigger). | | **#46** ammo-bay fire does not kill · **#28** vital-crit death path unconfirmed | **RELATED ROOT, separate fix.** `MechSubsystem::TakeDamage` is an **empty stub** (`btstubs.cpp:178-181`), so the authentic slot-9 body `@004ac0bc` — which on the destroy edge sets `statusAlarm 1`, pins `zone[0x158] = 1.0`, and **if `[0xE4]` (vital) sets the owning mech's `graphicAlarm` to 9** (verified at `0x4ac112`/`0x4ac12c`) — never runs. Compounded by `ApplyDamageAndMeasure` (`mechsub.cpp:409-415`) returning a deterministic 0.0 through the proxy alias, so the crit budget never saturates. That is why 164 crits were all `vital=0` and every row stayed green. Not a respawn bug; do not bundle. | | **KB corrections to land** | (a) **`DamageZone + 0x158` is accumulated DAMAGE: 0 = pristine, 1.0 = destroyed** — settled this pass from `@004ac22c` (reset writes 0), `@004ac0bc` (destroy writes 1.0 alongside `statusAlarm 1`), `@004ac144` (`>= 1.0` ⇒ tier 1). So `mechdmg.hpp:24`'s `structureLevel` naming and `MechSubsystem::GetStatusFlags`'s ">= 1.0 → intact" comment are **inverted**; `damageLevel` is right. (b) `CLASSMAP` slot labels: slot 8 = `GenerateFault(int)`, slot 9 = `TakeDamage(Damage&)`, slot 10 = `DeathReset(int)` (== the BT source's `ResetToInitialState`), slot 11 = `DeathShutdown(int)` = the `@004ad10e` forwarder — anchored to T0 `SUBSYSTM.h:146-167`. (c) `CLASSMAP`'s "AmmoBin s10 empty per AMMOBIN.TCP" is **wrong**; the `.TCP` shards are a **stale pre-MechWeapon revision** (EMITTER.TCP chains `PoweredSubsystem`, the shipped Emitter chains `MechWeapon`) and must never be used as body evidence against the binary. (d) Delete "the binary's respawn severed the vehicle and built a NEW mech" (`context/multiplayer.md:~810`, echoed in `mech4.cpp:1770-1780` and `ammobin.cpp:372`) — `@004c05c4` provably never clears `playerVehicle`; the arcade re-armed in place, so ammo refill on respawn is **authentic, not a port divergence**. (e) `docs/GAUGE_COMPOSITE.md:677` / `context/gauges-hud.md:531-533` "the port sim never calls slot 17" is **stale**. (f) Record in `gauges-hud.md` that the fire-ready **disc is a `percentDone > 0.99` lamp** (`btl4gau2.cpp:1655`), not a `weaponAlarm == Loaded` lamp — that single fact collapses six failures into one. (g) `btplayer.cpp:508-511` still asserts "during the 5-second dead window playerVehicle is severed" — the opposite of what the same file implements. | diff --git a/game/reconstructed/btl4gau3.cpp b/game/reconstructed/btl4gau3.cpp index 10fea2e..bb74bda 100644 --- a/game/reconstructed/btl4gau3.cpp +++ b/game/reconstructed/btl4gau3.cpp @@ -542,10 +542,13 @@ void // BTPlayer/Player layout differs (engine base != binary), so those offsets read a // stale/garbage slot -> a silent 0 even after the handlers increment the members. // The bridge reads the SAME members the ScoreMessageHandler/VehicleDead handlers - // write (killCount / Player::deathCount). (DEATHS uses the real deathCount, not - // the binary's dead pad_0x280.) + // write (killCount / deathTally). DEATHS reads the binary's OWN +0x280 column + // (BTPlayer::deathTally, our offset 0x274) -- corrected 2026-07-25 (#45). It was + // briefly redirected to Player::deathCount@0x200, but that is the respawn-handshake + // identity (seeded -2), which is why it needed a display clamp; +0x280 was never + // dead, only unwritten. Both counters now replicate owner->replicant. e.nameDisplay->Draw(&localView, (Scalar)BTPilotKills(pilot)); // KILLS (killCount) - e.mechDisplay->Draw(&localView, (Scalar)BTPilotDeaths(pilot)); // DEATHS (Player::deathCount) + e.mechDisplay->Draw(&localView, (Scalar)BTPilotDeaths(pilot)); // DEATHS (deathTally @0x280) } ++currentSlot; diff --git a/game/reconstructed/btplayer.cpp b/game/reconstructed/btplayer.cpp index c61aa29..61bc7a6 100644 --- a/game/reconstructed/btplayer.cpp +++ b/game/reconstructed/btplayer.cpp @@ -231,6 +231,10 @@ void BTPlayerLayoutSelfCheck() // never called -- a compile-time lock only static_assert(offsetof(BTPlayer, killCount) == 0x270, "BTPlayer::killCount moved -- the scoreboard bridges (BTPilotKills) and the " "binary's 0x27c-era offsets must be re-checked"); + static_assert(offsetof(BTPlayer, deathTally) == 0x274, + "BTPlayer::deathTally moved -- it is the binary's DEATHS column (@0x280) that " + "Gitea #45 put back into service; re-check GetDeaths/BTPilotDeaths and the " + "update-record mirror"); static_assert(sizeof(BTPlayer) == 0x28c, "BTPlayer size changed -- re-measure the offsets above before trusting any " "raw-offset code that touches this class"); @@ -254,6 +258,9 @@ void BTPlayerLayoutSelfCheck() // never called -- a compile-time lock only static const char *SelfDestructName = "self destruct"; // &DAT_00524b38 static const Scalar RespawnDelay = 5.0f; // death -> drop-zone hunt (@004c0830) +// #45: how often a master re-dirties its update record so the replicated +// KILLS/DEATHS pair reconverges after a dropped (unreliable) update datagram. +static const Scalar ScoreboardHeartbeat = 2.0f; static const Scalar TicksPerSecond = 1.0f; // (see note in PlayerSimulation) // @@ -485,11 +492,24 @@ void ++deathCount; // this+0x200 message->deathCount = deathCount; // message+0x38 + // + // #45: the SCOREBOARD deaths tally is the binary's own +0x280, not the engine's + // deathCount above. deathCount stays what the engine made it -- the respawn + // handshake identity (seeded -2, matched at PLAYER.cpp:230) -- so it must not be + // the number we display or replicate. This is the one the PilotList draws and + // the one that rides the update record to every other pod. + // + ++deathTally; // this+0x280 + // MP MATCH FORENSICS (matchlog.hpp): the owning player's death // notification (fires exactly once per death, deduped above). if (BTMatchLogActive()) - BTMatchLog("PLAYER_DEAD", "player=%d:%d deaths=%d", - BTMatchHostOf(GetEntityID()), (int)GetEntityID(), deathCount); + // #45: log BOTH counters. `deaths` is the engine's respawn-handshake + // deathCount (seeded -2); `tally` is the binary's +0x280 scoreboard column + // that the gauge draws and the update record replicates. Having them side + // by side is what makes a divergence between them visible at all. + BTMatchLog("PLAYER_DEAD", "player=%d:%d deaths=%d tally=%d", + BTMatchHostOf(GetEntityID()), (int)GetEntityID(), deathCount, deathTally); // TODO(bring-up): scenarioRole is the scoring role looked up from the mission's // role registry (btplayer.cpp:815, currently deferred), so it is NULL for the // minimal dev missions. Only debit a life when a role is present. @@ -919,6 +939,36 @@ void Player::PlayerSimulation(time_slice); // FUN_0042e168 + // + // SCOREBOARD HEARTBEAT (#45). The KILLS/DEATHS mirror rides the update record, + // and update records are UNRELIABLE BY CONSTRUCTION: `Entity::UpdateMessage` + // clears `ReliableFlag` (ENTITY3.h:112) and the relay's UDP path additionally + // drops stale/reordered datagrams behind a per-sender sequence gate. The pair is + // only dirtied on a score/death EVENT, so one lost datagram would leave every + // peer's copy of this pilot's columns stale until their NEXT kill or death -- + // which, for the last kill of a round, is forever. Re-dirty the record at a low + // fixed rate so the pair reconverges within a beat. This is also what bounds the + // visibility of the binary's phantom partner increment (the wrong-column slip we + // still reproduce faithfully): the owner's authoritative value overwrites it + // within one beat instead of whenever the pilot next scores. + // + // Cheap: a Player has no 60Hz stream of its own -- it emits a record only when + // dirtied -- and the record is 68 bytes. The timer is a function static ON + // PURPOSE: a new data member would change `sizeof(BTPlayer)` and break the offset + // locks. A node pilots exactly one master player, so a shared timer is exact + // here; with several it would simply beat in turn. + // + if (GetInstance() != Entity::ReplicantInstance) + { + static Scalar s_scoreboard_beat = 0.0f; + s_scoreboard_beat += time_slice; + if (s_scoreboard_beat >= ScoreboardHeartbeat) + { + s_scoreboard_beat = 0.0f; + ForceUpdate(); // updateModel |= DefaultUpdateModelFlag + } + } + if ( (lastPerformance - lastConsoleUpdate) / TicksPerSecond >= CONSOLE_UPDATE_INTERVAL // _DAT_004c08fc || application->GetApplicationState() == Application::EndingMission // app+0x88 == 6 @@ -1452,7 +1502,7 @@ BTPlayer::BTPlayer( DEBUG_STREAM << "[layout] sizeof(BTPlayer)=" << (int)sizeof(BTPlayer) << " (0x" << std::hex << (int)sizeof(BTPlayer) << std::dec << ")" << " killCount@0x" << std::hex << (int)offsetof(BTPlayer, killCount) - << " pad_0x280@0x" << (int)offsetof(BTPlayer, pad_0x280) + << " deathTally@0x" << (int)offsetof(BTPlayer, deathTally) << " objectiveMech@0x" << (int)offsetof(BTPlayer, objectiveMech) << " deathPending@0x" << (int)offsetof(BTPlayer, deathPending) << std::dec << " <-- the raw write targets byte 0x284\n" @@ -1581,7 +1631,7 @@ BTPlayer::BTPlayer( } killCount = 0; // this[0x9f] - pad_0x280 = 0; // this[0xa0] + deathTally = 0; // this[0xa0] (#45: the binary's DEATHS column @0x280) objectiveMech = 0; // this[0xa1] lastPerformance = lastConsoleUpdate = lastUpdate; // this[0xa2],[0xa3] = this[4] @@ -1654,6 +1704,10 @@ int BTPilotDeaths(void *pilot) { if (pilot == 0) return 0; + // #45: GetDeaths() is now the binary's own +0x280 tally, which starts at 0 and + // only ever counts up, so the clamp no longer has a -2 engine seed to hide (it + // used to read Player::deathCount, the respawn handshake identity). Kept as a + // cheap floor in case a record ever arrives before the owner's first write. int deaths = ((BTPlayer *)pilot)->GetDeaths(); return (deaths < 0) ? 0 : deaths; } @@ -1675,7 +1729,7 @@ int BTPilotDeaths(void *pilot) // for OUR compiled object (printed by the ctor, 2026-07-25): // // sizeof(BTPlayer) = 652 (0x28c) -// killCount @ 0x270 pad_0x280 @ 0x274 +// killCount @ 0x270 deathTally @ 0x274 // objectiveMech @ 0x278 deathPending @ 0x284 <-- what 0x284 really is // // So every target designation wrote a Mech pointer into **`deathPending`**, the @@ -1764,27 +1818,14 @@ BitMap *BTPilotNameBitmap(void *pilot) return mission->GetSmallNameBitmap(index); } -// OBSERVED-DEATH tally (MP DEATHS fix): each node maintains every player's -// score from LOCALLY OBSERVED events -- exactly how the KILLS credit already -// works (the victim's ScoreMessage handler ++s the SHOOTER's local Player -// copy). This is the symmetric half: when a REPLICANT mech dies here (the -// once-per-death transition runs on every node), tally the death onto its -// owning player's LOCAL copy. The owner's own node counts through its real -// VehicleDead(-1) path, so the caller gates this to replicant mechs -- each -// node then counts every pilot self-consistently. Normalize the -2/-1 -// pre-acquire seed first so death #1 reads 1. -void BTPlayerCountObservedDeath(void *player) -{ - if (player == 0) - return; - BTPlayer *p = (BTPlayer *)player; - if (p->deathCount < 0) - p->deathCount = 0; - ++p->deathCount; - if (getenv("BT_SCORE_LOG")) - DEBUG_STREAM << "[score] observed death -> pilot " << player - << " deaths=" << p->deathCount << "\n" << std::flush; -} +// OBSERVED-DEATH tally: DELETED 2026-07-25 (Gitea #45) together with its call site +// (mech4.cpp) and its `friend` (btplayer.hpp) -- all three at once, because /FORCE +// would otherwise hide a straggler. It never executed (its call site sat inside the +// once-per-death TRANSITION, which a replicant never enters) and it could not have +// worked (a replicant victim carries no attribution: 0 of 8800 corpus DMG rows target +// a replicant). DEATHS is now replicated from the owning pod instead. Do not +// resurrect it: under replication it would be a SECOND writer of a replicated counter, +// and it incremented `Player::deathCount`, which the scoreboard no longer even reads. //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // WriteUpdateRecord / ReadUpdateRecord (Gitea #45) @@ -1813,10 +1854,27 @@ void { Player::WriteUpdateRecord(message, update_model); + // + // Append the counters ONLY to the default-model record. `update_model` is a + // BIT INDEX, and Entity::WriteUpdateRecord switches on it (ENTITY.cpp:329-352): + // the DamageZone bit emits a variable-length packed stream of DamageZone + // records whose length it computes itself, so appending two ints and + // re-stamping recordLength over THAT would corrupt the record. A pilot only + // ever sets the default bit today (plain `ForceUpdate()`), which is why the + // engine's own Player::WriteUpdateRecord gets away with being bit-agnostic -- + // but a future `ForceUpdate(mask)` on a player would make this silently wrong, + // and skipping here degrades safely: the reader's recordLength guard simply + // leaves the counters alone. + // + if (update_model != Simulation::DefaultUpdateModelBit) + { + return; + } + UpdateRecord *rec = (UpdateRecord *)message; rec->recordLength = sizeof(UpdateRecord); - rec->killTally = killCount; - rec->deathTally = deathCount; + rec->killTally = killCount; // @0x27c + rec->deathTally = deathTally; // @0x280 -- NOT the engine's deathCount } void @@ -1830,34 +1888,47 @@ void // MIXED-VERSION GUARD: a peer on a build without this extension writes a // plain Player-sized record, and reading the two fields off the end would // pick up whatever record was packed after it in the update stream and - // display it as a kill count. Trust the length the writer stamped. + // display it as a kill count. Trust the length the writer stamped. (The + // stream itself stays in sync either way -- both sides advance by the + // WRITER's recordLength, ENTITY.cpp:390 / SIMULATE.cpp:323.) // if (rec->recordLength < sizeof(UpdateRecord)) { return; } + // + // Only ever mirror onto a REPLICANT. The owning master holds the sole + // authoritative copy of both counters -- it is the only node the engine ever + // increments them on -- so a record must never be allowed to write back over + // it. ReadUpdateRecord is a replicant-side path today, but this is the + // invariant the whole design rests on, so state it rather than assume it. + // + if (GetInstance() != Entity::ReplicantInstance) + { + return; + } int was_kills = killCount; - int was_deaths = deathCount; + int was_deaths = deathTally; killCount = rec->killTally; - deathCount = rec->deathTally; + deathTally = rec->deathTally; // // Log only the EDGE -- this runs at update rate, so an unconditional line // would drown the log (and the matchlog) in per-frame noise. // - if (killCount != was_kills || deathCount != was_deaths) + if (killCount != was_kills || deathTally != was_deaths) { if (BTMatchLogActive()) BTMatchLog("SBMIRROR", "player=%d:%d kills=%d deaths=%d wasKills=%d wasDeaths=%d", BTMatchHostOf(GetEntityID()), (int)GetEntityID(), - killCount, deathCount, was_kills, was_deaths); + killCount, deathTally, was_kills, was_deaths); if (getenv("BT_SCORE_LOG")) DEBUG_STREAM << "[score] scoreboard mirror: pilot " << (int)GetEntityID() << " kills " << was_kills << "->" - << killCount << " deaths " << was_deaths << "->" << deathCount + << killCount << " deaths " << was_deaths << "->" << deathTally << "\n" << std::flush; } } @@ -1960,17 +2031,21 @@ void BTPostKillScore(Entity *victim, Scalar damage) // Step 7: KILL (+ MP deat { return; } - // OBSERVED-KILL credit (MP KILLS fix, 2026-07-13; the symmetric twin of - // BTPlayerCountObservedDeath): every node maintains LOCAL score copies, - // so the kill must land on the KILLER's player-link object -- the same - // object the PilotList gauge reads (the roster resolves pilots via the - // mechs' GetPlayerLink()). The old GetMissionPlayer() dispatch credited - // a copy the scoreboard never displays (kills stuck at 0 while deaths -- - // which already flow through the player link -- counted; user-reported). - // Resolving the killer from the victim's lastInflictingID also credits - // the REMOTE player's local copy when THEY score ("target's kills - // display 0") -- each node witnesses the death transition and tallies - // its own copies self-consistently. + // KILL credit. Runs on the VICTIM's node -- the only node whose mech carries a + // populated `lastInflictingID` (damage is applied master-side only: 0 of 8800 + // corpus DMG rows target a replicant, which is why every `DEATH inst=R` row reads + // `killer=0:0`). The credit is dispatched to the killer's Player, which HERE is a + // REPLICANT, so `Entity::Dispatch` reroutes it to the owning host + // (ENTITY.cpp:244-251) and `++killCount` lands on the killer's OWN machine. + // + // CORRECTED 2026-07-25 (Gitea #45). The previous banner claimed "every node + // maintains LOCAL score copies ... each node witnesses the death transition and + // tallies its own copies self-consistently". That was FALSE and it is what hid + // this bug: the reroute means exactly ONE node increments, no other node can + // observe the killer at all, and nothing carried the value back out -- so every + // remote pilot's KILLS read 0 on every other pod, all mission (measured: 125 of + // 125 `SCORE type=2` rows credit the logging node's own player). The counters are + // now replicated owner->replicant instead; see Read/WriteUpdateRecord above. BTPlayer *killer_player = 0; if (application->GetHostManager() != 0) { @@ -1999,6 +2074,29 @@ void BTPostKillScore(Entity *victim, Scalar damage) // Step 7: KILL (+ MP deat << " deaths=" << killer_player->GetDeaths() << " score=" << (Scalar)killer_player->GetScore() << std::endl; } + else if (BTMatchLogActive()) + { + // + // #45 observability: the credit was SKIPPED. This used to be completely + // silent, which is why the bug survived so long -- the counter simply never + // moved and no log said why. Record which link in the chain broke so a + // single matchlog convicts it: a missing killer entity, an unregistered + // mech, a self-kill, or the NULL playerLink hazard. + // + Entity *k = (application->GetHostManager() != 0) + ? application->GetHostManager()->GetEntityPointer( + ((Mech *)victim)->lastInflictingID) + : 0; + extern int BTIsRegisteredMech(Entity *e); + BTMatchLog("NOCREDIT", + "victim=%d:%d killerID=%d:%d found=%d self=%d registered=%d link=%d", + BTMatchHostOf(victim->GetEntityID()), (int)victim->GetEntityID(), + BTMatchHostOf(((Mech *)victim)->lastInflictingID), + (int)((Mech *)victim)->lastInflictingID, + (int)(k != 0), (int)(k == victim), + (int)(k != 0 && BTIsRegisteredMech(k)), + (int)(k != 0 && ((Mech *)k)->GetPlayerLink() != 0)); + } // MP DEATH: credit a death to the VICTIM's own player. NULL for the solo // BT_SPAWN_ENEMY dummy (GetPlayerLink()==0) -> skipped, so DEATHS stays 0 in diff --git a/game/reconstructed/btplayer.hpp b/game/reconstructed/btplayer.hpp index 8636538..da8e5de 100644 --- a/game/reconstructed/btplayer.hpp +++ b/game/reconstructed/btplayer.hpp @@ -228,7 +228,9 @@ class DropZone__ReplyMessage; static MessageHandlerSet& GetMessageHandlers(); void - VehicleDeadMessageHandler(VehicleDeadMessage *message); // @004c012c + VehicleDeadMessageHandler(VehicleDeadMessage *message); // @004c05c4 (NOT @004c012c -- that address was a + // misattribution; the real handler is absent from + // functions_index.tsv, Gitea #60 export gap) void ScoreInflictedMessageHandler(ScoreMessage *message); // @004c0200 void @@ -309,9 +311,16 @@ class DropZone__ReplyMessage; // `Player__UpdateRecord` is currentScore + dropZoneLocation only // (PLAYER.h:73-80) -- so every OTHER node's copy stayed 0 for the whole // mission, which is exactly why the comm panel never showed a pilot's - // kills. [T0 the reroute + the record contents; T1 the field evidence: - // across 255 node-logs, 125 of 125 type=2 KILL records credit the LOGGING - // node's own player and not one credits a remote pilot.] + // kills. [T0 the reroute + the record contents; T1 the field evidence.] + // + // The field measurement, stated so it is REPRODUCIBLE (a bare figure nobody + // can re-derive is worse than none): over `matchlogs/**/*matchlog*.txt` plus + // `content/matchlog*.txt`, taking each log's own host as the modal host in its + // VEHICLE/RESPAWN/PLAYER_DEAD rows -- 255 logs had an identifiable local pilot + // and carried 125 `SCORE ... type=2` rows, of which 125 credited the LOGGING + // node's own player and 0 credited a remote pilot. An independent pass that + // de-duplicated the corpus by file content instead counted 113/113 the same + // way; the ratio, not the absolute count, is the claim. // // The owner's value is already the single authoritative copy -- incremented // exactly once per kill, through the engine's own reroute -- so completing @@ -355,7 +364,7 @@ class DropZone__ReplyMessage; // silently read 0. currentScore/playerRanking are inherited Player fields. // int GetKillCount() const { return killCount; } // @0x27c - int GetDeaths() const { return deathCount; } // @0x200 (Player) + int GetDeaths() const { return deathTally; } // @0x280 (#45: was the engine's deathCount @0x200) Scalar GetScore() const { return currentScore; } // @0x278 (Player) int GetRanking() const { return playerRanking; } // (Player) Mech *GetObjectiveMech() const { return objectiveMech; } // @0x284 (current target) @@ -421,7 +430,6 @@ class DropZone__ReplyMessage; friend int BTPlayerRoleLocksAdvanced(void *); // the FUN_004ac9c8 bridge (task #12): experienceLevel == 0 friend int BTPlayerExperienceSimLive(void *); // +0x25c reader (issue #2): jams / lights / powersub short path friend int BTPlayerExperienceHeatModelOn(void *); // +0x260 reader (issue #2): FUN_004ad7d4 heat-model gate - friend void BTPlayerCountObservedDeath(void *); // observed-death tally (MP DEATHS fix) // TARGET DESIGNATION (Gitea #48/#57): the mapper used to write the target // RAW at `pilot + 0x284` -- the BINARY's objectiveMech offset, which on our // compiled object is `deathPending` (the respawn latch). These bridges @@ -432,8 +440,21 @@ class DropZone__ReplyMessage; int killCount; // @0x27c kills credited to this player + // + // @0x280 -- the binary's DEATHS column. `PilotList::Execute` @0x4cabd0 + // draws `fild [edi+0x27c]` (KILLS) and `fild [edi+0x280]` (DEATHS), and + // image-wide +0x280 has exactly two runtime writers (@0x4c0674/@0x4c067a) + // plus the ctor zero. This was declared `pad_0x280` and never written -- + // the port displayed the ENGINE's `Player::deathCount` (+0x200) instead, + // which is really the respawn-handshake identity number (seeded -2, + // PLAYER.cpp:777, compared at :230) and needed a clamp to look like a + // count. Gitea #45 gives it its real job back: it is the deaths tally, + // it is what the gauge reads, and it is what replicates. Named + // `deathTally` deliberately -- `deaths`/`deathCount` would SHADOW the + // engine base (reconstruction-gotchas #1). + // int - pad_0x280; // @0x280 + deathTally; // @0x280 deaths (our offset 0x274) Mech *objectiveMech; // @0x284 designated target / objective mech Time diff --git a/game/reconstructed/mech.cpp b/game/reconstructed/mech.cpp index 4b17027..43f6dad 100644 --- a/game/reconstructed/mech.cpp +++ b/game/reconstructed/mech.cpp @@ -682,6 +682,21 @@ void DEBUG_STREAM << "[cam] Mech::PlayerLink inst=" << (int)(GetInstance() == ReplicantInstance) << " owner=" << (void*)owner << "\n" << std::flush; + + // + // #45 observability: did the one-shot PlayerLink actually RESOLVE on this node? + // `Entity::playerLink` is written only here and rides no update record, so a + // replicant with resolved=0 is the "barrier 1" hazard -- it would silently skip + // that pilot's kill credit and leave no other trace. One row per link message, + // so this cannot spam the matchlog. + // + if (BTMatchLogActive()) + BTMatchLog("PLAYER_LINK", + "mech=%d:%d inst=%c playerID=%d:%d resolved=%d", + BTMatchHostOf(GetEntityID()), (int)GetEntityID(), + (GetInstance() == ReplicantInstance) ? 'R' : 'M', + BTMatchHostOf(message->playerID), (int)message->playerID, + (int)(owner != 0)); if (owner != NULL) { owner->SetPlayerVehicle(this); // player+0x1fc = this diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 8fe3567..9782057 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -2022,20 +2022,17 @@ void << " DESTROYED (death effects dispatched from the death transition) ***\n" << std::flush; - // OBSERVED-DEATH tally (MP DEATHS fix): a REPLICANT's death observed - // here counts onto its owning player's LOCAL score copy -- the - // symmetric half of the cross-pod KILLS credit (see btplayer.cpp - // BTPlayerCountObservedDeath). Masters count through their own - // VehicleDead(-1) path; counting them here too would double-tally. - if (GetInstance() == ReplicantInstance) - { - Player *owning_player = GetOwningPlayer(); // entity+0x190 - if (owning_player != 0) - { - extern void BTPlayerCountObservedDeath(void *); - BTPlayerCountObservedDeath(owning_player); - } - } + // OBSERVED-DEATH tally: DELETED 2026-07-25 (Gitea #45). It never ran and + // could not have worked. Never ran: this block is the once-per-death + // TRANSITION, and a replicant reaches destroyed state via the replicated + // simulationState 9, which routes it to the mode-9 early return above -- so + // the ReplicantInstance gate here was unreachable by construction. Could not + // have worked: a replicant victim carries no attribution at all (0 of 8800 + // corpus DMG rows target a replicant, which is why every DEATH inst=R row + // reads killer=0:0), so no bystander can know who to credit. DEATHS is now + // replicated from the owning pod instead (BTPlayer::Read/WriteUpdateRecord), + // which is also why this must not come back: it would be a SECOND writer of a + // replicated counter. Do not re-arm it. } // diff --git a/scratchpad/rig45_down.ps1 b/scratchpad/rig45_down.ps1 index 4bb985c..0a7a5c5 100644 --- a/scratchpad/rig45_down.ps1 +++ b/scratchpad/rig45_down.ps1 @@ -9,13 +9,22 @@ $pidfile = Join-Path $scratch 'rig45_pids.txt' if (-not (Test-Path $pidfile)) { "no pidfile -- nothing recorded, nothing killed"; exit 0 } +# The console is launched with output redirection, so Start-Process wraps it in a +# cmd.exe shim (-> python.bat -> python). Kill the recorded PID *and its children*, +# still strictly by PID and only for the images this rig is known to launch. +$targets = @() foreach ($line in Get-Content $pidfile) { $procId = 0 if (-not [int]::TryParse($line.Trim(), [ref]$procId)) { continue } + $targets += @(Get-CimInstance Win32_Process -Filter "ParentProcessId=$procId" -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty ProcessId) + $targets += $procId +} +foreach ($procId in ($targets | Select-Object -Unique)) { $p = Get-Process -Id $procId -ErrorAction SilentlyContinue if (-not $p) { "pid $procId already gone"; continue } # Only kill it if it is still one of the images this rig launches. - if ($p.ProcessName -notin @('btl4','python')) { + if ($p.ProcessName -notin @('btl4','python','cmd','conhost')) { "pid $procId is now '$($p.ProcessName)' -- PID recycled, SKIPPING" continue }