Author SHA1 Message Date
CydandClaude Opus 4.8 70ada2789f sec-surface phantoms: #48 wrap lit idx-254 art in live armor colors; fix + palette-aware glass token
Playtester report: (1) the CONTROL MODE stack showed boxes around the inactive
MID/ADV entries, (2) a phantom block between the HEADING dial and the ARMOR
rosette.  Root-caused [T1] by a 4-agent investigation + art decode:

  The #48 translation-table cycle-fill (6bb03ae, 2026-07-25) mapped
  out-of-range art indices in-plane as (index mod 2^bits) -- so art index 254
  landed on plane slot 62 = the LIVE colorMapperMultiArmor right-armor damage
  slot.  And idx-254 art EXISTS: every SMODE.PCC frame fills the INACTIVE
  mode-box interiors with 254, and BTSEC1.PCX carries a stray 52x13 idx-254
  bar at port (199-250,101-113) between the heading dial and the rosette (a
  scratch duplicate of the rosette quadrant bars).  Both lit up in the current
  right-armor color (adpal ramp green/orange/red) on EVERY render path.  On
  the shipped machine those regions rendered BLACK (the garbage entry's low
  plane bits were 0), which is why the 2026-07-19 smode audit -- run before
  the cycle-fill landed -- verified CORRECT.

FIX 1 (BuildSecondaryTranslation): map [2^bits..255] to translationTable[0]
  (plane BACKGROUND) -- the authentic on-screen result, same no-leak
  guarantee.  Verified on both paths: MID/ADV back to authored borders+text
  (idx 5/9), no interior fills; the phantom bar gone; BAS badge and the live
  armor rosette (in-range slots 60-63) untouched.

FIX 2 (glass dirty-skip palette blindness, latent in c9e25e5): the
  ColorMapper family animates by CLUT writes with zero pixel churn, which the
  pixel-checksum token cannot see -- the glass radar held stale armor tints
  between pixel repaints.  New SVGA16::paletteGeneration, bumped by the
  palette writers (BuildSecondaryColor only on a REAL entry change -- the
  adpal/adpal2 flash alternates every Execute writing identical RGB at zero
  damage, so the skip survives; full rebuilds bump unconditionally), folded
  into GlassWindowToken for palette-expanding windows.

KB: gauges-hud #48 REFINED addendum; glass-cockpit repaint-pump section
  (records f3d27f5 + c9e25e5 + this); GAUGE_COMPOSITE row 33 corrected
  (ControlsMapper/ControlMode, not DisplayMode) + re-verification note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 10:44:23 -05:00
127 changed files with 381 additions and 9695 deletions
+7 -15
View File
@@ -10,15 +10,12 @@
`BTL4OPT.EXE` binary on top of the working WinTesla engine. `BTL4OPT.EXE` binary on top of the working WinTesla engine.
**Repo of record:** the top-level `CMakeLists.txt` + `README.md` build `btl4.exe`. Layout: **Repo of record:** the top-level `CMakeLists.txt` + `README.md` build `btl4.exe`. Layout:
`engine/ game/ content/ docs/ reference/ tools/ context/`. `engine/ game/ content/ docs/ reference/ tools/ context/`.
**Current front (2026-08-11):** core gameplay reconstruction is COMPLETE and the K/D chain is **Current front (2026-08-06):** core gameplay reconstruction is COMPLETE — SP+MP loop, combat/
STRESS-PROVEN (kd4/kd8 benches: receipt-derived invariants exact at 4 and 8 nodes, gotcha §25 damage/scoring, locomotion (gait/gimp/CROUCH), night kit (searchlight), death/respawn, replication
closed the same-frame double-credit). This week's settled fronts: projectile flight clocks + all authentic + benched (builds 4.11.774→801). The decomp export was REBUILT 2026-08-06 (#60
live-target contact (#168), generator-out voice at the trip (the port's ONE content deviation, closed: coverage 93.5%, dark code 41 KB — cite `@ADDR`, NOT `part_0NN.c:LINE`; the old export is
#154), annunciator lamp table + stale-flash heal (#135), ordered exit (#163), ram eligibility archived). Active work: the POLISH list. Details + what's-next: `context/project-overview.md`,
(#128). The decomp export was REBUILT 2026-08-06 (#60 closed: coverage 93.5% — cite `@ADDR`, `context/open-questions.md`, recent git log.
NOT `part_0NN.c:LINE`). Active work: field verification (8-player night) + the remaining
POLISH list (#156 fade, weapon-state presentation). Details + what's-next:
`context/project-overview.md`, `context/open-questions.md`, recent git log.
--- ---
@@ -85,7 +82,6 @@ precise than anything you can infer.
| Multiplayer, replication, netcode | `context/multiplayer.md` | | Multiplayer, replication, netcode | `context/multiplayer.md` |
| **The OPERATOR CONSOLE + RELAY** — how to launch it, ports, seats, the round/launch lifecycle, re-arm | `context/operator-console.md` | | **The OPERATOR CONSOLE + RELAY** — how to launch it, ports, seats, the round/launch lifecycle, re-arm | `context/operator-console.md` |
| Running a session as sysop (operator-facing how-to) | `docs/OPERATOR_GUIDE.md` | | Running a session as sysop (operator-facing how-to) | `docs/OPERATOR_GUIDE.md` |
| The tester-facing OPEN-ISSUES handout (for Discord) | `docs/OPEN_ISSUES_FOR_TESTERS.txt` — regenerate from the tracker + update the header date before each playtest |
| Pod hardware, monitors, RIO, MFD surfaces, input remap (CONTROLS.MAP/XInput) | `context/pod-hardware.md` | | Pod hardware, monitors, RIO, MFD surfaces, input remap (CONTROLS.MAP/XInput) | `context/pod-hardware.md` |
| Glass cockpit desktop dev layer (BT_GLASS/BT_STEAM gates, PadRIO, miniconsole, Steam) | `context/glass-cockpit.md` | | Glass cockpit desktop dev layer (BT_GLASS/BT_STEAM gates, PadRIO, miniconsole, Steam) | `context/glass-cockpit.md` |
| Steam internet MP (the wire seam, identity tokens, lobby) | `context/steam-networking.md` | | Steam internet MP (the wire seam, identity tokens, lobby) | `context/steam-networking.md` |
@@ -95,11 +91,7 @@ precise than anything you can infer.
| Term / acronym definitions | `reference/glossary.yaml` | | Term / acronym definitions | `reference/glossary.yaml` |
| The ORIGINAL 1995 player manual (controls, per-mech stats, coolant loops) | `reference/manual/Tesla40_BT_manual.pdf` (+ alignment audit in `context/pod-hardware.md` §Manual) | | The ORIGINAL 1995 player manual (controls, per-mech stats, coolant loops) | `reference/manual/Tesla40_BT_manual.pdf` (+ alignment audit in `context/pod-hardware.md` §Manual) |
| The complete verbatim detail (fallback) | `docs/PROGRESS_LOG.md` (the old 2236-line CLAUDE.md) | | The complete verbatim detail (fallback) | `docs/PROGRESS_LOG.md` (the old 2236-line CLAUDE.md) |
| Detailed running ledgers | `docs/RECONCILE.md`, `docs/GAUGE_COMPOSITE.md`, `docs/HARD_PROBLEMS.md`, `docs/SUBSYS_PLAN.md`, `docs/P3_LOCOMOTION.md`, `docs/RESOURCE_AUDIT.md`, `docs/VEHICLE_SUBSYSTEMS.md`, `docs/BGF_FORMAT.md`, `docs/ASSET_PIPELINE.md`, `docs/BT_SOURCE_STATUS.md`, `docs/WAVE_PLAN.md`, `docs/GLASS_COCKPIT.md`, `docs/REVOLVING_DOOR_PLAN.md`, `docs/INPUT_PATH_AUDIT.md`, `docs/RESPAWN_REARM_PLAN.md`, `docs/KD_SCOREBOARD_PLAN.md`, `docs/DIST_LAYOUT_PLAN.md`, `docs/MOUSELOOK_PLAN.md`, `docs/GHOST_MECH_ANALYSIS.md`, `docs/AUDIO_FIDELITY.md`; multi-agent audit ledgers (2026-08-11): `docs/KD_CHAIN_AUDIT_20260811.md`, `docs/MISSION_END_AUDIT_20260811.md`, `docs/RAM_LEAK_AUDIT_20260811.md`, `docs/GEN_WARNING_AUDIT_20260811.md` | | Detailed running ledgers | `docs/RECONCILE.md`, `docs/GAUGE_COMPOSITE.md`, `docs/HARD_PROBLEMS.md`, `docs/SUBSYS_PLAN.md`, `docs/P3_LOCOMOTION.md`, `docs/RESOURCE_AUDIT.md`, `docs/VEHICLE_SUBSYSTEMS.md`, `docs/BGF_FORMAT.md`, `docs/ASSET_PIPELINE.md`, `docs/BT_SOURCE_STATUS.md`, `docs/WAVE_PLAN.md`, `docs/GLASS_COCKPIT.md`, `docs/REVOLVING_DOOR_PLAN.md`, `docs/INPUT_PATH_AUDIT.md`, `docs/RESPAWN_REARM_PLAN.md`, `docs/KD_SCOREBOARD_PLAN.md`, `docs/DIST_LAYOUT_PLAN.md`, `docs/MOUSELOOK_PLAN.md`, `docs/GHOST_MECH_ANALYSIS.md` |
**`docs/` split note:** most of `docs/` is AI-facing DETAIL (the ledgers above + `PROGRESS_LOG.md`);
the HUMAN-facing subset is exactly `OPERATOR_GUIDE.md`, `OPEN_ISSUES_FOR_TESTERS.txt`, and
`dist/RELEASE_NOTES_*` — write for the right audience per file.
--- ---
+1 -1
View File
@@ -1384,7 +1384,7 @@ cmHeat(K,ModeSecondaryHeat,58,heatpal.pcc,heatpal2.pcc,Myomers);
} }
#======================================================================= #=======================================================================
# Denkou (was: Sunder V1) # Sunder V1
#======================================================================= #=======================================================================
Snd1Init Snd1Init
{ {
-17
View File
@@ -69,23 +69,6 @@ run\run.cmd [EGG] # default DEV.EGG; cd's to content\ and runs btl4.exe -eg
**Keep the `if /I "%%P"=="btl4.exe"` guard** — parsing tasklist with `tokens=2` alone reads its **Keep the `if /I "%%P"=="btl4.exe"` guard** — parsing tasklist with `tokens=2` alone reads its
*"INFO: No tasks are running"* line as a PID and spins forever with nothing running. Full *"INFO: No tasks are running"* line as a PID and spins forever with nothing running. Full
investigation, including which windows exit vs merely hide: `phases/phase-12-orphan-processes.md`. investigation, including which windows exit vs merely hide: `phases/phase-12-orphan-processes.md`.
- **Field-log anatomy — reading a steam night (#157 forensics, 2026-08-10) [T2].** A night's log is
a CHAIN of `===== BT411 SESSION` blocks: menu stubs (`args=''`, "first process") alternating with
glass generations (`-net 1501 -platform glass`, "relaunched generation"); one glass block = one
drop, and boundaries are synchronized across every machine to ±2 s (mission-clock expiry relaunches
all peers' menus in the same second). **Stub anatomy:** the steam lobby is CLICK-driven
(`steamAction`, btl4fe.cpp), so a menu closed without a click logs exactly **4 boot lines** and
exits 0 — a 4-line stub at end-of-night is a player going to bed, NOT a crash (all four machines
ended night-14 with identical 4-line stubs at 00:31:2x). 78-line stubs = a lobby cycle
(`[steamnet] up` + `[lobby] join` + map). `BTCrashFilter` is armed before the first log line and
writes a `[crash]` block + module-relative stack on ANY unhandled exception — a silent log end
means quit/kill/hang, never an unlogged AV. **`lastrun_<stem>.txt` caveats:** appends stop silently
if the file gets locked (the `is_open` guard skips) — a lastrun ending mid-night while the log
continues means the file was captured/locked during live troubleshooting, not that the chain died.
And the bat sign-off ("The game has exited. If it closed unexpectedly, send…") prints after ANY
chain end including a normal quit — it primes players to report quits as crashes; #157 ("did not
exit cleanly") was exactly this echo. The arcade-authentic round-end (mission process exits →
menu relaunch) itself reads as a crash to first-night players.
- **Why this is a BT411-only hazard [T1].** The 1995 pod shipped ONE folder — `BTL4OPT.EXE` sits - **Why this is a BT411-only hazard [T1].** The 1995 pod shipped ONE folder — `BTL4OPT.EXE` sits
next to `BTL4.RES`/`VIDEO\`/`GAUGE\`/`AUDIO\` (it is still there in `content\`) — and RP411/RP412 next to `BTL4.RES`/`VIDEO\`/`GAUGE\`/`AUDIO\` (it is still there in `content\`) — and RP411/RP412
keep that shape (`pack-dist.ps1` copies the exe and every asset dir into one dist root). BT411's keep that shape (`pack-dist.ps1` copies the exe and every asset dir into one dist root). BT411's
+8 -145
View File
@@ -199,14 +199,6 @@ Fixed-torso mechs (the BLH: `TorsoHorizontalEnabled=0` — no jointtorso in the
boresight dead-ahead. Once locked, `Emitter::FireWeapon` converges with NO aim/arc test boresight dead-ahead. Once locked, `Emitter::FireWeapon` converges with NO aim/arc test
(part_013.c:7758). `MechWeapon::UpdateTargetState` (`FUN_004b9bdc` [T1]): `targetWithinRange = (part_013.c:7758). `MechWeapon::UpdateTargetState` (`FUN_004b9bdc` [T1]): `targetWithinRange =
dist < (1 hostZoneDamage) × weaponRange`. The 0x388 WRITER is in the same un-exported gap. dist < (1 hostZoneDamage) × weaponRange`. The 0x388 WRITER is in the same un-exported gap.
`weaponRange` is **fire-control data only** (#168 audit [T1]): projectile FLIGHT is never
range-capped — a plain shell (AFC) lives exactly 5.0 s (@4bddec) and a Missile lives
BurnTime+10 s with a y<1 kill-plane (@4bef78); FireWeapon @4bcc60 has no range gate, so an
AFC50 (authored range 750) landing hits at 2000+ u is AUTHENTIC arcade behavior. Constants +
the whole flight/expiry chain: [[decomp-reference]] §5 "Projectile/Missile flight & expiry".
The same audit killed the pool's stale-point auto-hit (unguided contact was tested against
the fire-time pick, so a locked AC shell could not miss a moving target — fixed 2026-08-11:
the contact test now tracks the target's live position on every round kind).
**`hostZoneDamage` is `Subsystem::damageZone->damageLevel` (weapon `@0xE0 → +0x158`), NOT **`hostZoneDamage` is `Subsystem::damageZone->damageLevel` (weapon `@0xE0 → +0x158`), NOT
heatLoad** — the port originally computed `effectiveRange = (1 heatLoad) × weaponRange` heatLoad** — the port originally computed `effectiveRange = (1 heatLoad) × weaponRange`
(mechweap.cpp), the SAME `@0xE0`-DamageZone-vs-heat misattribution corrected in (mechweap.cpp), the SAME `@0xE0`-DamageZone-vs-heat misattribution corrected in
@@ -614,21 +606,10 @@ btplayer.hpp, static_assert-locked; `scoreAward`=APPLIED tally in all three):
the VICTIM role's `killBonus` (role+0x1c). senderMechID = the victim. Suicide (eject charge, the VICTIM role's `killBonus` (role+0x1c). senderMechID = the victim. Suicide (eject charge,
self-damage) IS dispatched — the handler negates the award (@0x4c03ab `fchs`) and skips self-damage) IS dispatched — the handler negates the award (@0x4c03ab `fchs`) and skips
`killCount++`: **the #134 panic penalty, live** (bench: `type=2 award=-39.00 kills=0`). `killCount++`: **the #134 panic penalty, live** (bench: `type=2 award=-39.00 kills=0`).
- **B @0x4a05d9, not newly killed && tally≠0** → type 0 to the shooter: the **per-hit INFLICTED - **B @0x4a05d9, not newly killed && tally≠0** → type 0 to the shooter. The ONLY registered 0x16
credit**, and it is LIVE. ⚠ **CORRECTED 2026-08-07** — this entry previously read "the ONLY receiver Verify-rejects type 0 → 1995 folded an UNINITIALIZED stack float into the shooter's
registered 0x16 receiver Verify-rejects type 0 → 1995 folded an UNINITIALIZED stack float into score on every non-lethal hit (real 1995 bug — @0x4c0200, the handler that accepts type 0, is
the shooter's score on every non-lethal hit (real 1995 bug — @0x4c0200 … is in NO table entry: in NO table entry: dead code). Port sends it for wire fidelity, banks award 0.
dead code)". **That was wrong**, and build 787 retired the port's per-hit crediting on the
strength of it (the scoring regression players reported on 4.11.817). `BTPlayer` overrides
**`Dispatch` — vtable `@00513300` slot 3 = `FUN_004bffa0`** — and splits type 0 off BEFORE base
dispatch: `if (id==0x16 && type==0) FUN_004c0200(...); else base;`. `@004c0200` names itself in
its own Verify string (`BTPlayer::ScoreInflictedMessageHandler`) and computes
`CalcInflicted(basis) → negate if target==self → × (targetTonnage/ownTonnage) → += +0x278`.
`ScoreMessageHandler`'s type-0 arm Verify-rejects precisely BECAUSE the interceptor guarantees
type 0 never reaches it. Independently corroborated by the original manual's SCORING CHART
(`reference/manual/scoring_chart.webp`, via Lynx): "+1 each damage point scored on opponent's armor" and "-1 each
self-inflicted point of armor damage" — the negate-if-self arm exactly. Interceptor restored;
benched 83 inflicted rows, awards 0.9825.00, zero type-0 Verify rejections. [T1]
- **C @0x4a06c0, tally>0 (kills included)** → type 1 DamageReceivedScore to the VICTIM's player. - **C @0x4a06c0, tally>0 (kills included)** → type 1 DamageReceivedScore to the VICTIM's player.
Basis = INTENDED damage (burstCount×amount). senderMechID = the INFLICTOR. Feeds the received Basis = INTENDED damage (burstCount×amount). senderMechID = the INFLICTOR. Feeds the received
penalty (`CalcDamageReceivedScore` returns the NEGATIVE) + the operator-console VTVDamaged line penalty (`CalcDamageReceivedScore` returns the NEGATIVE) + the operator-console VTVDamaged line
@@ -636,61 +617,9 @@ btplayer.hpp, static_assert-locked; `scoreAward`=APPLIED tally in all three):
Reports carry the LOOP-ENTRY zone (msg+0x24, never rewritten mid-loop), the vital-wreck flag, and Reports carry the LOOP-ENTRY zone (msg+0x24, never rewritten mid-loop), the vital-wreck flag, and
`inflictingSubsystemID` (msg+0x5c, engine T0 name). `inflictingSubsystemID` (msg+0x5c, engine T0 name).
**Score model consequence** [T1, ⚠ REWRITTEN 2026-08-07]: 1995 pod scoring = **per-hit inflicted **Score model consequence** [T1]: 1995 pod scoring = **kill awards + received-damage penalties +
credit + kill awards + received-damage penalties + death costs**. The previous text here claimed death costs. No per-hit inflicted credit** (the port's old per-hit crediting — and the #95 salvo
"No per-hit inflicted credit … inventions riding the dead @0x4c0200 channel; both retired" — that fix on top of it — were inventions riding the dead @0x4c0200 channel; both retired).
followed from the dead-code misreading corrected in report B above, and retiring the credit IS the
scoring regression reported on 4.11.817. The **original manual's SCORING CHART** (`reference/manual/scoring_chart.webp`)
is the cross-check for every row and should be consulted before touching this path again:
| Points | Event |
|---|---|
| +1000 | Starting the game |
| +1 | Each damage point scored on opponent's armor |
| +10..+30 | Destroying an opponent's internal system |
| +500 | Destroying an opponent's 'Mech |
| 1 | Each self-inflicted point of armor damage |
| 10..30 | Knocking out one of your own internal systems |
| 500 | Destroying your own 'Mech by an ammo explosion |
| 1000 | Destroying your own 'Mech by ejecting |
**THE SCORE AUTHORITY IS THE OPERATOR CONSOLE, not the player object** [T1, 2026-08-07]. The
binary sends `ConsolePlayerVTVScoreUpdate(ownerID, currentScore)` every `CONSOLE_UPDATE_INTERVAL`
and then does `param_1[0x9e] = 0`**ungated**. So `+0x278` is a *console DELTA*, never a running
total, and it does not matter which NODE computed a delta: every node's contribution is flushed
stamped with the scoring player's `ownerID` and the console accumulates. This is almost certainly
where the chart's **+1000 starting the game** was seeded, which is why no game-side code grants it.
**Consequence for the port** (no console as score authority): `GetScore()` (SCORE gauge),
`Player::CalcRanking()` and the replicated `Player__UpdateRecord` all read `+0x278` **on the owning
node**. Damage is applied on the VICTIM's node, so block B dispatches the inflicted report to the
SHOOTER's player object there — a **REPLICANT** — and the credit is banked on the wrong machine,
where the master's next update record overwrites it (benched: totals climb to ~35, snap back every
few seconds = the field "scoring is screwy"). ⚠ OPEN. **Tried and rejected:** gating the type-0
interception to `MasterInstance` so a replicant reroutes — the message arrives but the BT extension
fields (`damageAmount`@+0x24, `senderMechID`@+0x34) do NOT survive the wire, only the base
`scoreAward`, so every award computes 0.00. That is also WHY the kill report (type 2) already
credits cross-node correctly: its value rides `scoreAward`. **Fix shape:** compute the award on the
victim's node (where the damage data is) and ship the RESULT in `scoreAward`, as the kill report
does — do not ship the basis and recompute where it cannot be seen.
**The damage-bias term is VESTIGIAL — do not "finish" it** [T1, audit 2026-08-08]. The kill/inflicted
formula's `(victimAvgZoneDamage@0x354 × damageBias + 1.0)` factor is **always 1.0** in the shipped
binary. `mech+0x354` has exactly one writer — `Mech::Reset` (@0049fb74, part_012.c:14340), which
computes `mean(zone+0x158)` across every damage zone *after* the zone heal has zeroed those cells —
and exactly one reader, `CalcInflictedScore` (@004c052c). Nothing recomputes it during play, so it
holds ~0 for the mech's whole life. `0x358`/`0x35c` are the same computation over subsystem zones
and have **no reader at all**. The port's `MECH_DAMAGE_BIAS(m) → 0.0f` therefore reproduces the
binary exactly; wiring it to live damage would look like completing a stub and would silently
inflate every award (the chart-verified +1/point and +500/kill both assume 1.0).
**Three chart rows are NOT yet reconciled with the reconstruction** — treat as open [T4]:
(a) a kill benches at `award=4.88`, two orders off the chart's flat **+500**; (b) **+1000 at
game start** has no known implementation; (c) **1000 eject / 500 ammo** would live in
`ScenarioRole::specialCaseDeathPenalty` (role+0x20), which the port reads
(`GetSpecialCaseDeathPenalty`, the death-cost block) but which **shipped content authors
nowhere**, so it is 0 in the field — the #134 symptom by another route. Do not "fix" these by
hard-coding chart numbers; find where the binary sources them.
Kill award = `(victimKillBonus + tally) × killerRole.damageInflictedModifier × Kill award = `(victimKillBonus + tally) × killerRole.damageInflictedModifier ×
(victimAvgZoneDamage@0x354 × damageBias + 1.0) × (victimTonnage/killerTonnage)`; same-team kill in (victimAvgZoneDamage@0x354 × damageBias + 1.0) × (victimTonnage/killerTonnage)`; same-team kill in
a non-FFA game = `-friendlyFirePenalty` basis (inline strcmp of `teamName@0x20c`, gate a non-FFA game = `-friendlyFirePenalty` basis (inline strcmp of `teamName@0x20c`, gate
@@ -1125,9 +1054,7 @@ player's advancedDamage copy (+0x268, `BTPlayerAdvancedDamageOn` bridge) — the
elasticity@+0x244 by Mover-layout walk); scaled <0.5 = FREE. Above: `n=Round(2×amount)` elasticity@+0x244 by Mover-layout walk); scaled <0.5 = FREE. Above: `n=Round(2×amount)`
sub-hits of amount/n, each landed on ONE roster subsystem drawn by cumulative sub-hits of amount/n, each landed on ONE roster subsystem drawn by cumulative
`collisionCriticalHitWeight` (@0x10C, MechSubsystem) vs a [0,1) roll, eligibility = `collisionCriticalHitWeight` (@0x10C, MechSubsystem) vs a [0,1) roll, eligibility =
IsDerivedFrom HeatSinkBANK/Gyroscope/Torso (GUIDs 0x50e590/0x50fdc0/0x510b08 -- ⚠ 0x50e590 IsDerivedFrom HeatSink-family/Gyroscope/Torso (GUIDs 0x50e590/0x50fdc0/0x510b08; all
is the AGGREGATE BANK's node, NOT the HeatSink base family: the family-wide reading was the
#128 instant-leak bug, corrected 2026-08-11, full record docs/RAM_LEAK_AUDIT_20260811.md; all
MechSubsystem-based, so +0x10C is valid), applied via `ApplyDamageAndMeasure` (@0x4ac07c MechSubsystem-based, so +0x10C is valid), applied via `ApplyDamageAndMeasure` (@0x4ac07c
the subsystem TakeDamage virtual into its PRIVATE crit zone). Un-won rolls land nowhere the subsystem TakeDamage virtual into its PRIVATE crit zone). Un-won rolls land nowhere
(weights un-normalized — faithful). Mech-vs-mech ram damage arriving as type 0 prices the (weights un-normalized — faithful). Mech-vs-mech ram damage arriving as type 0 prices the
@@ -1261,70 +1188,6 @@ code involved; grammar = the binary ctors @0x49ea48/@0x49e5e4). Results:
cylinder** -- diverted at the handler head (0x4a0361: test type; 0x4a036d: cylinder** -- diverted at the handler head (0x4a0361: test type; 0x4a036d:
call 0x49ffcc; jmp out) [T1 disasm]. call 0x49ffcc; jmp out) [T1 disasm].
## The death transition is SYNCHRONOUS within the damage pass (2026-08-11) [T1/T2]
The binary writes movementMode 9 INSIDE the lethal TakeDamage pass — its own death-tail gate
(part_012.c:14761) reads 9||10 already flipped in the same call. The port deferred the write to
the per-frame UpdateDeathState and a same-frame direct+splash lethal pair re-ran the whole death
block (duplicate kill credit + duplicate VehicleDead) — caught by the 4-node bench, fixed by
running the once-per-death transition inline at the death tail (1b6d247). Full class write-up:
[[reconstruction-gotchas]] §25; stress verification: kd4_bench 9/9/9/9 + kd8_stress 26/26/26/26,
SWALLOWED=0 both. The receiver-side dedup remains a TRIPWIRE only (1995 has none; +0x290 never
written) — if it ever fires again, a new duplicate source exists upstream.
## Key Relationships ## Key Relationships
- Weapons/roster: [[subsystems]]. Aim source: [[locomotion]] (drive/facing). Effects: [[rendering]]. - Weapons/roster: [[subsystems]]. Aim source: [[locomotion]] (drive/facing). Effects: [[rendering]].
- P5 forensics: `docs/HARD_PROBLEMS.md`. Data: [[decomp-reference]] §4-5. - 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).
**Full evidence record** (every dispatch/arm/layout with decomp line cites, the field
receipt A/B, and the fix archaeology): `docs/KD_CHAIN_AUDIT_20260811.md`.
**Port rules that keep it correct:** (0) **the entry latch is MODE-based (movementMode
2||9 == FUN_0049fb54), NEVER the alarm-based IsMechDestroyed()** -- EjectPilot raises
graphicAlarm to 10 BEFORE its killBonus self-damage, so an alarm-based latch reads a real
punch-out as "already destroyed" and silently eats the suicide type-2 (the -499 arithmetic)
while cook-off suicides still score; caught by the eject canary 2026-08-11, the same
alarm-vs-mode trap #108 fixed for deathBlastArmed; (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.
+1 -148
View File
@@ -253,34 +253,6 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
@004b0b18) + @004b0abc (auto/manual, modeAlarm@0x2CC); attach/detach @004b0dd8/@004b0e30 @004b0b18) + @004b0abc (auto/manual, modeAlarm@0x2CC); attach/detach @004b0dd8/@004b0e30
(generator client capacity @0x1e4/count @0x1e8). (generator client capacity @0x1e4/count @0x1e8).
### Projectile/Missile flight & expiry (#168 audit, 2026-08-11) [T1]
- **No range cap exists anywhere in the flight chain.** WeaponRange feeds fire-control only
(effectiveRange @0x328 = (1hostZoneDamage)×WeaponRange — targeting/reticle "in range" call).
FireWeapon @4bcc60 reads no +0x328: the trigger fires at any target distance.
- **Plain Projectile (AFC shell) live integrator @4bddec** (master); expiry: age since
spawn(+0x19c) > `_DAT_004be054` = **5.0 s** → FUN_0042061c kill. Dead-reckon divergence²
vs predicted pos(+0x260, predictor FUN_00422060 via +0x254) > `_DAT_004be050` = 0.1 →
updateModel(+0x18)|=1 (ForceUpdate, replication-dirty — NOT a kill).
- **Missile live integrator @4bef78** (master); expiry: age > burnTime(+0x340, model rec
+0x44) + `_DAT_004bf5ac` = **10.0 s** coast margin, OR pos.y < `_DAT_004bf5b0` = **1.0**
kill-plane → FUN_0042061c. Keepalive: no update for `_DAT_004bf594`=2.0 s → dirty bit.
**Proximity fuse**: seeker rangeToTarget(+0x10C) < `_DAT_004bf5a4` = **4.0** → detonate on
targetEntity without a geometry hit. Seeker drops a destroyed target (movementMode 2|9,
FUN_0049fb54).
- **Performance split** (Projectile ctor @4be1bc / Missile ctor @4bf5b4): instance
(flags&0xC)==ReplicantInstance(4) → FUN_004221c0 dead-reckoning smoother (no physics);
master → 4bddec/4bef78. (CLASSMAP's old authoritative/ghost labels were swapped —
corrected 2026-08-11.)
- **FUN_0042061c = Entity kill**: removes from world list + simulationFlags(+0x28) |= 0x2000|2.
- All tick→seconds via `DAT_0052140c` = 28.0 (the frame-rate global).
- Port consequences (both fixed in mech4.cpp, 2026-08-11): (a) the pool's old
`p.range = len×1.3+60` distance-to-target cap was an invention — replaced by the
authentic time caps; (b) the pool's contact sphere tested UNGUIDED rounds against
the FIRE-TIME pick point and dispatched damage to the entity wherever it moved —
a locked AC/Gauss could not miss a moving target (bench: 86/86 "hits" vs a
circling mech). The pool now tracks the target's live position for the contact
test on every round kind; only guided rounds steer.
--- ---
### The CONFIG-MODE session chain (task #6) [T1] ### The CONFIG-MODE session chain (task #6) [T1]
@@ -321,29 +293,6 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
inflictingSubsystemID [T0]), +0x34 senderMechID}; BT VehicleDeadMessage 0x38 {engine 0x28 + inflictingSubsystemID [T0]), +0x34 senderMechID}; BT VehicleDeadMessage 0x38 {engine 0x28 +
0x28=0, +0x2c killed-by PLAYER EntityID, +0x34 kill zone}. Senders: the TakeDamage report tail 0x28=0, +0x2c killed-by PLAYER EntityID, +0x34 kill zone}. Senders: the TakeDamage report tail
— see [[combat-damage]]. — see [[combat-damage]].
**#162 audit deltas (2026-08-11, full record docs/KD_CHAIN_AUDIT_20260811.md) [T1]:**
VehicleDead@0x4c05c4 deathCount==-1 arm: deaths(+0x280)++ dual — `inc [ebx+0x280]` victim
@0x4c067a unconditional + `inc [edi+0x280]` killer's copy @0x4c0674 (skipped on self-kill);
then STRAIGHT-LINE the death cost @0x4c07cd-0x4c0828: gate `advancedDamageOn(+0x264)` ONLY,
amount `-specialCaseDeathPenalty(role+0x20)` via `fld/fchs` @0x4c07dd, applied by a DIRECT
call to engine Player::ScoreMessageHandler @0x42da20 (never dispatched, never on the wire —
no matchlog receipt exists; the port's `[deathcost]` DEBUG line is the only witness).
**deathPending(+0x290) is NEVER written non-zero anywhere in BTL4OPT.EXE** (three writes,
all zero-stores: ctor @0x4c0c07, respawn helper @0x4c01e5, DropZoneReply tail) — 1995 has NO
receiver-side death dedup; the sender's one-shot edge is the whole guarantee. The clear pair
`{+0x290=0, +0x258=0}` lives in **FUN_004c012c = DropZoneReply**'s tail (part_013.c:10519-23)
— NOT the VehicleDead arm (the #81-era port misattribution, corrected 2026-08-11). Latch
asymmetry: the entry latch (FUN_0049fb54) tests modes **2|9** while the death-tail gate
accepts **9|10** (part_012.c:14761). **The mode-9 write is SYNCHRONOUS within the damage
pass** [T1 — 14761's gate reads it already flipped in the same call]: the sender one-shot
only holds because of this timing. The port deferred it to the per-frame UpdateDeathState
and a same-frame direct+splash double-lethal re-ran the death block (duplicate kill credit
— the 4-node retest catch, fixed 1b6d247 by running the transition inline at the death
tail; gotcha §25). `advancedDamageOn(+0x264)` ← the mission egg's
per-pilot `advancedDamage=` key (every stock egg stamps 1 on every page); Player update
record replicates ONLY currentScore(+0x1c8) + dropZoneLocation — killCount/deaths never
cross the 1995 wire (the port's #45 record extension mirrors them owner→replicant; benched
convergent at 4 and 8 nodes, kd4_bench/kd8_stress).
- **HeatableSubsystem** @0x50E41C: {3, "ToggleCooling"→@004ad6f8}. **Disassembled 2026-07-20** - **HeatableSubsystem** @0x50E41C: {3, "ToggleCooling"→@004ad6f8}. **Disassembled 2026-07-20**
(`tools/disas2.py 0x4ad6f8`): a per-subsystem coolant on/off TOGGLE (NOT a multi-level "cycle (`tools/disas2.py 0x4ad6f8`): a per-subsystem coolant on/off TOGGLE (NOT a multi-level "cycle
priority" — that's the emergent effect, not the mechanism): priority" — that's the emergent effect, not the mechanism):
@@ -481,78 +430,6 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
recomputes output via `(1 damage) × rated` and zeroes it. stateAlarm 4's recomputes output via `(1 damage) × rated` and zeroes it. stateAlarm 4's
producer = the THERMAL BREAKER in GeneratorSimulation itself; a 2026-08-03 producer = the THERMAL BREAKER in GeneratorSimulation itself; a 2026-08-03
destruction→state-4 bridge was removed as unfounded. destruction→state-4 bridge was removed as unfounded.
- **Generator trip/recovery + the warning edge (#154 audit, 2026-08-11, all [T1] unless
noted).** Trip: heatAlarm==FailureHeat(2) → stateAlarm 4, i.e. T > FailureTemperature;
recovery gate (case-4 body @4b1f7c) = heatAlarm back to **NormalHeat(0)**, i.e.
T ≤ DegradationTemperature — then 4→0 Starting → StartTime spin-up → 0→2 Ready.
Authored generator temps (raw BTL4.RES): start=77, degradation=**1000**,
failure=**2000** on all six vehicles — recovery at exactly HALF the trip line;
manual p23 prints the half-way rule verbatim, and the heat bar's colored fill
(VertTwoPartBar @4c48fc) exists exactly while T > 1000, so "red gone" == the
recovery gate to the tick. NO hysteresis in the heatAlarm ladder (@4ad924).
**The warning edge is authored backwards in the shipped content**: BTL4.RES
streams exactly TWO AudioStateTriggers per generator on GeneratorState —
(trigState=1 Idle, inv=0, Start) and (trigState=4 GeneratorOut, **inv=1**, Start,
= fire on LEAVING 4). No enter-4 trigger exists (RES-wide census: 144/144 rows,
triple-verified decode + live [audioedge] capture). Consequences, ALL live-benched
(`scratchpad/night15/gen_edge_bench.sh`, receipts BT_AUDIO_EDGE/[audioedge] +
BT_GEN_HEAT injection): thermal trip 2→4 SILENT; natural recovery 4→0 FIRES the
warning; respawn-with-tripped-gen RTIS (4,0) FIRES it (ghost warning during
respawn); crit-death fires enter-Idle one frame late; 4→1 (cooled while switched
off) double-fires. The generators' inv+Start is the ONLY inverse-Start fault
trigger in the game (census) — a 1995 authoring slip [T4 intent]. Port machinery
is faithful end-to-end. **DELIBERATE DEVIATION shipped 2026-08-11 (user-approved):**
the AudioStateTrigger ctor (AUDWTHR.cpp) flips the GeneratorState (trigState=4,
inv=1) record to inv=0 at stream time — warning now fires at the TRIP, silent at
recovery/respawn; `BT_GEN_WARN_SHIPPED=1` restores shipped behavior. This is the
port's ONLY authored-content override (precedent: the self-hit score deviation).
**The warning IS a VOICE — CORRECTED 2026-08-11 (a same-day wrong claim swept):**
the Warnings01/AllWarning zones are the project's recorded voice material (Yip,
original VWE) and the AudioControlSequences chain the zone-WORDS into spoken
phrases — established by the #119 leak-voice work (commit 42b0691: "the warning
is a phrase-sequenced voice patch (Warnings01 zones as notes)"). The generator
sequence (notes 29/16/26 = zones z4/z0/z3) is the spoken "generator out" phrase —
**ear-verified 2026-08-11** (zones played in sequence order, rate-patched copies) [T2]. An earlier revision of this entry claimed BT 4.10 shipped no speech and
attributed era memories to Firestorm — WRONG: the claim rested on preset NAMES
and was contradicted by our own git history. With the voice real, era memories
of hearing it at the trip may be genuine (possibly a later floor-content
revision fixing the same bit our deviation flips).
⚠ Process lesson: before asserting an asset ABSENT, grep the project's own git
log and prior tickets for the asset family (#119 was one `git log --grep=voice`
away; six audit agents and the synthesizer all missed it).
Voice-steal note: night-15 field logs show up to 932 channel steals/session — even
fired Starts can be eaten at combat peak (L4AUDRND weight-steal).
Diag hooks: BT_GEN_HEAT=<units/s> (+_DELAY/_SECS) injects into viewpoint
GeneratorA (thermalMass=726000 raw units — rate ~1.5e8 for ~18-25 s trips it);
BT_AUDIO_EDGE=1 prints every state-edge trigger delivery FIRED/no-fire.
Related corrections: AmmoState 3 = EJECTING (an earlier lane's "cook-off enter-3"
label was wrong — cook-off warning rides FireCountdownStarted matchers); manual
p23 also answers #133: the MFD X = generator destroyed by CRIT, not overheat.
- **THE CONDITION FLAGS ARE ROUTINE, NOT ALARMS — field-verified 2026-08-08 [T2]:** a
`[techstat] <sub> condition <n> SET` is an *operating* flag ("this component is above
nominal / browned out RIGHT NOW"), not a fault report. Census over a full Steam match
(`steam_20260806_c_michael_XIAOLONG.log`): cond 3 **Overheating** fires 33× on LLaser_2,
31× LLaser_1, 26× SRM4, 18× PPC_2 — every weapon trips it on each volley and clears it on
cooldown; cond 6 **BadPower** fires 8× on Myomers, 3× each on SLaser_1/PPC_1/MLaser_1 as
the bus browns out under simultaneous draw. **Every subsystem's SET/CLEARED counts are
balanced** (GeneratorD 5/4, GeneratorA 8/7, Myomers 5/4, Condenser5 1/1 — the odd extra SET
is only the log ending mid-heat). Nothing latches. **Never read a post-respawn `condition 3
SET` as a reset failure** — that inference cost a full two-sided reset audit (#137 below).
Diagnose a heat complaint from the SET/CLEARED *balance* and the throttle state, not from
the presence of a SET.
- **#137 "respawn came back with MYOMERS heat MAXED" = NOT A BUG, closed 2026-08-08 [T2]:**
`Mech::Reset` is clean on both sides — bench: every roster subsystem incl. all six
Condensers at `T=77 start=77`; field log: every live condition CLEARs 1216 lines after each
reset. The heat is real and immediate because **the mech respawns still under power**: at the
reset the log reads `thr=1`, `cycleSpeed=14.6`, gait state 12 (running), and the myomers →
Condenser5 → GeneratorD trip Overheating within ~12 s. `Mech::Reset`'s subsystem loop starts
at **index 2** and the ControlsMapper is **index 0**, so the throttle is never reset — and the
binary does the same, which is correct for a pod whose throttle is a PHYSICAL lever still
under the pilot's hand. **Desktop caveat (real, and separate):** the glass bridge emulates
that lever with a file-static ramp accumulator `sLever` (mech4.cpp:3250), zeroed ONLY by the
X all-stop and a direction-crossing snap — so a pad/keyboard pilot, who is physically holding
nothing, likewise respawns at speed with the lever state invisible. Port-layer question, not
a heat bug.
- **DEATH SCORE COST decoded 2026-08-02 (#118 tail) [T1]:** the death handler tail - **DEATH SCORE COST decoded 2026-08-02 (#118 tail) [T1]:** the death handler tail
`@004c07cd-0x4c0828` (inside the @004c05c4 export gap — missing from the #52 `@004c07cd-0x4c0828` (inside the @004c05c4 export gap — missing from the #52
reconstruction) gates on `advancedDamageOn`(+0x264) and hands the ENGINE base reconstruction) gates on `advancedDamageOn`(+0x264) and hands the ENGINE base
@@ -562,17 +439,7 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
Direct call, NOT dispatched — bypasses the BT handlers' type Verifies. NUANCE [T4]: Direct call, NOT dispatched — bypasses the BT handlers' type Verifies. NUANCE [T4]:
the 1995 engine adds at Player+0x1c8 while every BT scoreboard reads +0x278 — the the 1995 engine adds at Player+0x1c8 while every BT scoreboard reads +0x278 — the
pod's death cost may never have displayed; our single-cell port shows it. Shipped pod's death cost may never have displayed; our single-cell port shows it. Shipped
content authors NO role keys, so the cost is 0 in the field. **CORRECTED 2026-08-07 — content authors NO role keys, so the cost is 0 in the field. Other role+0x1c reader:
that sentence was wrong on both halves.** The role's scoring fields are not authored via
notation keys at all: the ctor `@00429a9c` loads them from the role MODEL's GameModel
record (type 0xf, 7 dwords — rec[0]=killBonus, rec[1]=specialCaseDeathPenalty,
rec[2]=dmgRcvd, rec[3]=dmgInflctd, rec[4]=bias, rec[5]=friendlyFire,
rec[6]=returnFromDeath); the notation keys are optional OVERRIDES. Shipped content
authors `Role::Default` (model `dfltrole`) with **killBonus=500, deathPenalty=500,
dmgInf=1, dmgRcv=0, bias=1, ff=1, return=1000** — the original manual's scoring chart
verbatim (`reference/manual/scoring_chart.webp`). The cost read 0 only because
`BTPlayer::scenarioRole` was never BOUND (the registry lookup sat commented out); wired
2026-08-07 and the cost is verified APPLYING at 500. Other role+0x1c reader:
`@0x4a0506` inside the deferred id-0x16 report tail (#45) reads killBonus. `@0x4a0506` inside the deferred id-0x16 report tail (#45) reads killBonus.
- **THE SIM TIME MODEL — CLOSED 2026-08-02 (issue #96) [T1]:** the arcade - **THE SIM TIME MODEL — CLOSED 2026-08-02 (issue #96) [T1]:** the arcade
`Simulation::PerformAndWatch` is **`FUN_0041c018`** (part_002.c:5101): `Simulation::PerformAndWatch` is **`FUN_0041c018`** (part_002.c:5101):
@@ -622,20 +489,6 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
@00444c80` (lamp map at renderer`+0x1c0c8`) → **`Lamp::SetAlertState @00444e64`** (the flash @00444c80` (lamp map at renderer`+0x1c0c8`) → **`Lamp::SetAlertState @00444e64`** (the flash
COUNTER at lamp+0x1C) → the L4 lamp flush `@00474e94` emits `0x37`/`0x13` = `flashFast` RIO COUNTER at lamp+0x1C) → the L4 lamp flush `@00474e94` emits `0x37`/`0x13` = `flashFast` RIO
states (== T0 L4LAMP.cpp:234-239) → `RIO::SetLamp` (binary `FUN_00476568`; cached wrapper states (== T0 L4LAMP.cpp:234-239) → `RIO::SetLamp` (binary `FUN_00476568`; cached wrapper
- **#135 (2026-08-11): the GENERATOR special @004cc27c is `table_0051d070[generatorNumber]`**
(sub+0x1E0, A=1..D=4, 1-based; bytes byte-read: {0x29, 0x1A,0x1B,0x1C,0x1D}; slot 5 is
already string data — bounds-guard 1..4) [T1] — the SAME shape as the condenser special
@004cc264 beside it. The port had mis-reconstructed it as an aux-screen placement probe
(fields generators never fill) → the field-logged `NO LAMP RESOLVED (generator=1)` silent
generator leak; benched fixed (`[lamp] 0x1a <- 0x37 (FLASHING)` on a GeneratorA leak).
- **#135 stale-flash gotcha [T0]:** `Lamp::SetAlertState/SetState` notify the panel ONLY when
the lamp's modeMask intersects the manager's current mask — an edge landing off-page (or in
the one-frame mask race of a page transition) is silently dropped, and the panel value
LATCHES the last-asserted flash (Oracle: "Loop 6 NEVER stopped flashing"; his log shows
Condenser6 SET/CLEARED balanced 3/3 — the manager was right, the lamp was stale). Port fix:
the skipped notify is remembered (`staleAlertNotify`) and delivered by the manager's
active-lamp loop at the next in-mode frame (LAMP.cpp). Field SET/CLEARED balance + a stuck
flash = this gotcha, not an alarm leak.
`@00474d54`). T0-compiled already: LAMP.cpp / L4LAMP.cpp / GAUGALRM.cpp / RENDERER messages. `@00474d54`). T0-compiled already: LAMP.cpp / L4LAMP.cpp / GAUGALRM.cpp / RENDERER messages.
**✅ ALL THREE PIECES BUILT same day (see [[open-questions]] for the full closure note).** **✅ ALL THREE PIECES BUILT same day (see [[open-questions]] for the full closure note).**
Corrections pinned while building: the SENDER is **`MechTech::TechnicalAssistance` @004ad33c** Corrections pinned while building: the SENDER is **`MechTech::TechnicalAssistance` @004ad33c**
+16 -40
View File
@@ -414,14 +414,6 @@ Verified live: bay fire → lamp 0xD (the LRM's select button) flashes 0x37 + en
on detonation/purge. Diagnostics: `BT_LAMP_LOG` → `[techstat]`/`[galarm]`/`[lamp]`. Details + on detonation/purge. Diagnostics: `BT_LAMP_LOG` → `[techstat]`/`[galarm]`/`[lamp]`. Details +
the four load-bearing fixes en route: [[open-questions]] + [[decomp-reference]] §GaugeAlarm. the four load-bearing fixes en route: [[open-questions]] + [[decomp-reference]] §GaugeAlarm.
**These conditions are ROUTINE and SELF-CLEARING [T2, field-verified 2026-08-08].** A `SET` is an
operating state, not a fault: every laser volley trips Overheating (cond 3) and clears it on
cooldown (33× on one LLaser in a single match), and BadPower (cond 6) flickers whenever
simultaneous draw browns the bus. Across a full match every subsystem's SET/CLEARED counts are
balanced — nothing latches. So a lamp flashing after a respawn is the mech *operating*, not a
failed reset; diagnose from the SET/CLEARED balance, never from a lone SET. Full census + the
#137 post-mortem it settled: [[decomp-reference]] §TechStatus.
## ConfigMapGauge (the weapon panel's trigger-config joystick) — LIVE via LinkToEntity (2026-07-21) ## ConfigMapGauge (the weapon panel's trigger-config joystick) — LIVE via LinkToEntity (2026-07-21)
The per-weapon btjoy.pcc joystick image + 4 cm_* state lamps (off/other/only/both) showing, The per-weapon btjoy.pcc joystick image + 4 cm_* state lamps (off/other/only/both) showing,
for each mappable fire button (Pinky/ThumbLow/Trigger/ThumbHigh), whether THIS panel's weapon for each mappable fire button (Pinky/ThumbLow/Trigger/ThumbHigh), whether THIS panel's weapon
@@ -557,38 +549,6 @@ and every instrument is now live [T2]:**
transcription color bug caught by a period reference screenshot, 2026-07-09; same for the transcription color bug caught by a period reference screenshot, 2026-07-09; same for the
bottom bowtie carets @4569-4570); pegs at 1200 with no target; the DISPLAYED range slides at bottom bowtie carets @4569-4570); pegs at 1200 with no target; the DISPLAYED range slides at
**500 m/s** toward the true pick range (HudSimulation :5652 [T1]). **500 m/s** toward the true pick range (HudSimulation :5652 [T1]).
**The HudSimulation tuning constants, read off .rdata 2026-08-08 [T1]** (`section_dump.txt`
rows ` 4b7ec0 8be55dc3 0000403f 0000803f 0000c842` / ` 4b7ed0 00000000`) —
`_DAT_004b7ec4` = **0.75f**, `_DAT_004b7ec8` = **1.0f**, `_DAT_004b7ecc` = **100.0f**,
`_DAT_004b7ed0` = **0.0f**, `_DAT_004b7f90` = **0.0f**. ec4/ec8 are the fire-control **LOCK**
limits (own HUD host zone < 0.75 damage, targeted zone < 1.0 — so a shot-up cockpit drops to
"target held, no lock", and a dead zone can't be re-locked); ed0 is the shared **zero** in the
range-slide `Abs()` idiom (the 500 is an immediate `0x43fa0000`, NOT a global). `hud.cpp` had
carried all five as 0.0f/500.0f stand-ins under guessed names ("SegmentTempLimit … heat
threshold for HUD page visibility" was neither heat nor page visibility) — corrected in place,
with the live lock/slide implementation staying in mech4.cpp's targeting step, which had both
thresholds right all along.
**THE CARET CAN DIE FOR THE WHOLE SESSION — #147, fixed 2026-08-08 [T2 code, T3 field link]:**
`sShownRange` (mech4's targeting step) is a **function-level static** — one cell per process,
shared by every mech, carried across drops. **NaN is absorbing** in `step = trueRange
sShownRange; sShownRange += step`, and neither the producer's clamps nor
`BTReticleRenderable::Draw`'s (`range < minRange` / `range > maxRange`) catch it — both
comparisons are **false for NaN**. A poisoned value therefore reaches `AddPoint`/`ConcatMatrix`,
and the caret + its bar become **degenerate geometry that stops rendering while the static tick
marks keep drawing** — the exact reported symptom ("no range finder on this drop", ticks there,
caret gone), sticky until relaunch. Fixed four ways: re-seed on mech change (not on respawn —
that reuses the entity, and the binary doesn't reset the readout either), a producer NaN trap, a
NaN-safe consumer clamp falling back to the 1200 peg, and — the reason no log could settle it —
**`BT_RANGE_LOG` now prints the caret's actual input** (`[range] caret input shown=`). It never
did before: `BT_RANGE_LOG` instrumented the PICK (#4) and `[target]`'s `range=` is a *separate*
locally-recomputed Sqrt in the weapon-range check, so grepping field logs for NaN found nothing
because the poisoned variable was never printed. Causal link to the field report is INFERENCE.
**GAP — the 100 m RANGE BIAS is NOT reconstructed [T1 read, unimplemented]:** HudSimulation
subtracts `_DAT_004b7ecc` (100.0f) from `RangeToTarget@0x1EC` **every frame while the timed
flag @0x22C is set**, accumulating @0x21C by `time_slice` until it reaches @0x1D8, then clearing
both. The port's targeting step (mech4.cpp) does the slide but never the bias, so whatever
in-game state sets @0x22C currently produces no range offset. Trigger for @0x22C not yet
identified — see [[open-questions]].
**VERDICT (Gitea #4, 2026-07-20): the "range slides in/out crazily while walking" report is **VERDICT (Gitea #4, 2026-07-20): the "range slides in/out crazily while walking" report is
AUTHENTIC behavior, not a bug [T2 measured].** Per-frame `BT_RANGE_LOG` traces (mech4.cpp, with AUTHENTIC behavior, not a bug [T2 measured].** Per-frame `BT_RANGE_LOG` traces (mech4.cpp, with
an independent Möller-Trumbore cross-check `BTGroundRayHitExact` in btvisgnd.cpp) on scripted an independent Möller-Trumbore cross-check `BTGroundRayHitExact` in btvisgnd.cpp) on scripted
@@ -823,6 +783,22 @@ pattern across entries [2^bits..255] (high-index art degrades to its
index-mod-2^bits colour IN-PLANE, can never leak). The 1995 binary ships the index-mod-2^bits colour IN-PLANE, can never leak). The 1995 binary ships the
SAME 64-entry fill and relied on 6-bit art discipline -- garbage is not a SAME 64-entry fill and relied on 6-bit art discipline -- garbage is not a
preservable behaviour, so the cycle-fill is a guarded PORT deviation. preservable behaviour, so the cycle-fill is a guarded PORT deviation.
**REFINED 2026-08-10 [T1] -- the cycle-fill itself made phantoms.** The
in-plane wrap mapped art index 254 onto plane slot 62 = the LIVE
`colorMapperMultiArmor` right-armor damage slot, and idx-254 art EXISTS:
every SMODE.PCC frame fills the INACTIVE control-mode box interiors with 254
(active border/text idx 9 yellow, inactive borders idx 5 orange-red), and
BTSEC1.PCX carries a stray 52x13 idx-254 bar at port (199-250, 101-113) --
between the heading dial and the armor rosette, geometrically a scratch
duplicate of the rosette quadrant bars. Both lit up in the current
right-armor colour (adpal ramp green/orange/red) on EVERY render path --
playtester-reported as "red boxes around MID/ADV" + "a block between Armor
and Heading". On the shipped machine those regions rendered BLACK (the
garbage entry's low plane bits were 0 -> in-plane index 0), so the fill now
maps [2^bits..255] to `translationTable[0]` (plane BACKGROUND) -- authentic
on-screen result, same no-leak guarantee. NB the 2026-07-19 audit verified
smode BEFORE the 07-25 cycle-fill landed, which is why the row said CORRECT.
Post-fix: **0 leaks over 60s** on the same probe; sim3 3-pod regression clean. Post-fix: **0 leaks over 60s** on the same probe; sim3 3-pod regression clean.
**The tripwires are DEFAULT-ON in every build** (BT_PLANE_AUDIT=0 opts out): the **The tripwires are DEFAULT-ON in every build** (BT_PLANE_AUDIT=0 opts out): the
plane-leak trap, plus an out-of-bounds draw-start trap in `buildDestPointer` plane-leak trap, plus an out-of-bounds draw-start trap in `buildDestPointer`
+25
View File
@@ -203,6 +203,31 @@ appears in the cfg like any panel and honours `,noframe`. Verified: a drag wrote
at 321,222. NB the plasma window blits directly every frame (`GetDC`+`StretchDIBits`), so unlike at 321,222. NB the plasma window blits directly every frame (`GetDC`+`StretchDIBits`), so unlike
the panels it has no `WM_TIMER` focus-throttle to worry about. the panels it has no `WM_TIMER` focus-throttle to worry about.
## Glass-panel repaint pump — perf + dirty-skip (2026-08-09/10) [T2 measured]
The exploded per-display windows are pure CPU/GDI (`ExpandPlaneToBGRA` +
`StretchDIBits`), repainted synchronously on the MAIN render thread by
`BTGlassPanels_Tick` (~16 Hz). Three layered changes (branch `glass-panel-perf`)
after a playtester reported ~20 fps in panels mode vs ~130 fps in the surround:
1. **HALFTONE → COLORONCOLOR** (`f3d27f5`): the `HALFTONE` stretch (GDI's
per-output-pixel resample) × 7 windows per pump was the primary sink; nearest
is crisper for the low-res MFD pixels anyway. `BT_GLASS_SMOOTH=1` restores.
2. **Per-window dirty-skip** (`c9e25e5`): each window carries a change token =
FNV over `SVGA16::PlaneChecksum(mask)` (the shared pixelBuffer masked to every
port that can feed the window) + each button's RENDERED lamp brightness +
held/latch. The pump re-blits only windows whose token moved: ~31 pumps/2s →
4-15 window repaints vs 217-224 always-on. `BT_GLASS_DIRTY=1` logs the tally.
3. **Palette generation in the token** (2026-08-10): the ColorMapper family
(armor rosette tints, adpal/adpal2 damage flash) animates by CLUT writes with
ZERO pixel churn — invisible to a pixel checksum. `SVGA16::paletteGeneration`
is bumped by the palette writers (`BuildSecondaryColor` only on a REAL entry
change, since the flash alternates palettes every Execute writing identical
RGB at zero damage; full rebuilds unconditionally) and folded into the token
for palette-expanding windows (`monoTint < 0`, or all under `BT_GLASS_MFD_PAL`).
Without it the glass radar held stale armor tints between pixel repaints while
the D3D surround (which re-expands every frame) tracked live.
**Turning it OFF: `L4PLASMA=NONE` (also `OFF`/`0`, 2026-08-06) [T2].** `L4GREND` creates a **Turning it OFF: `L4PLASMA=NONE` (also `OFF`/`0`, 2026-08-06) [T2].** `L4GREND` creates a
marquee whenever `L4PLASMA` is set at all (`SCREEN` → the desktop window, anything else → a real marquee whenever `L4PLASMA` is set at all (`SCREEN` → the desktop window, anything else → a real
`PlasmaDisplay` on that serial port), and the GLASS profile force-defaults it to `SCREEN` — so on `PlasmaDisplay` on that serial port), and the GLASS profile force-defaults it to `SCREEN` — so on
-37
View File
@@ -273,43 +273,6 @@ number the peer Standing case walks on). Verified: arena circle replicant 218×s
replicant, every `MechControlsMapper` demand cell except those explicitly re-derived replicant, every `MechControlsMapper` demand cell except those explicitly re-derived
(`turnDemand`) is DEAD — any peer-side state machine judging a local mapper read is judging 0.** (`turnDemand`) is DEAD — any peer-side state machine judging a local mapper read is judging 0.**
**The #52 SEQUEL: the peer body-channel STANDING-LOCK (root-caused + fixed 2026-08-07) [T2].**
Fixing (2) above closed an ACCIDENTAL escape hatch and the skate came back in a new shape. The
port's body case 4 is an INSERTION (the task-#64 lockstep twin) sitting between case 0 and the
advance group — but in the binary `case 4` is a **member of that advance group**
(`FUN_004a5678` @004a5678: `case 2,3,`**`4`**`,5,8,…`, no turn block, no speed exit) [T1], so
case 0's fallthrough is supposed to land on `Advance()`. The insertion intercepted it. On a
REPLICANT that is fatal and not a race: case 0 arms walk iff `standSpeed < bodyTargetSpeed`, and
the inserted block's exit tests `standSpeed < bspd` where `bspd` **IS** `bodyTargetSpeed` on a
replicant — the *same expression*. Arm and reset therefore fire on the same frame, every frame,
and a peer parked at Standing with a live replicated demand can never start cycling (reverse
likewise: both sides test `< ZeroSpeed`). Before e91d447 the replicant branch read the dead mapper
cell (0 forever) so the exit never fired and the fallthrough worked by accident. **Fix:** case 0
`goto advance_body_normally` — the leg twin's own idiom (`goto advance_normally`, mech2.cpp) —
restoring the binary's structure without touching the #64/#82 turn logic. `BT_NO_BODY_FALLTHRU=1`
reverts. Measured: legacy 336 consecutive locked seconds with `bspd=39.2324 bts=39.2324` identical
on every line; fixed 0 locks across every pass; the MASTER's body-Standing samples also fell 52→21
(it was locking too, invisibly — mj=0 writes no joints, and its two tests read *different* cells so
it only stalls in the window where they disagree). Turn-in-place re-verified under the fix (pivoter
reached body state 4 ×9 / leg state 4 ×8 — armed in lockstep).
**Why a peer must be able to self-arm walking at all** (the load-bearing bit behind
`mech4.cpp` "stand; case 0 walk-begins next tick"): the peer's body state is set directly from
`record->legState` only on **type-3 edges** (`ReadUpdateRecord`), and entering Standing emits one
while *leaving* it does not. So between gait-change records a replicant is REQUIRED to derive
walking itself from the replicated `bodyTargetSpeed`. That is why the lock needs a mech holding a
*steady* demand — a mech whose gait keeps changing keeps getting rescued by records, which is why
free-walking and wall-jammed benches each reproduce only half the symptom. [T2]
**The field symptom link is [T3], not T2.** The Standing-lock is proven and proven removed; that
it accounts for the night-13 episodes is inference (a locked peer has `bodyCycleSpeed==0` and never
advances its clip, so locked + translating *is* the `[skate]` signature by construction) — but no
bench caught the two together. The `[skate]` line now carries `bstate=`, so the next playtest
settles it: episodes gone → confirmed; any survivor names its own state. **NB the night-12
`skatebench` "reproductions" were a DETECTOR ARTIFACT** — the first detector build tested only
`legCycleSpeed==0`, which is normal on a peer (the body channel poses it), so it fired on every
healthy movement phase. Old-format lines (`legCycleSpeed=`, no `bodyCyc=`) are not evidence.
## Controls (`BT_REAL_CONTROLS`, default-on) ## Controls (`BT_REAL_CONTROLS`, default-on)
`MechControlsMapper` (mechmppr.cpp @004afbe0; btl4mppr.cpp mappers) interprets input → `speedDemand` `MechControlsMapper` (mechmppr.cpp @004afbe0; btl4mppr.cpp mappers) interprets input → `speedDemand`
/ `turnDemand`. ⚠ **WndProc NEVER receives WM_KEYUP** (the engine's per-frame reader `GetMessage`s / `turnDemand`. ⚠ **WndProc NEVER receives WM_KEYUP** (the engine's per-frame reader `GetMessage`s
-13
View File
@@ -908,16 +908,3 @@ RECONSTRUCTION STATE: coupled path is DEFAULT ON (mech4.cpp: s_drPos gait-couple
## Peer motion: the "random shakiness" is single-box packet jitter, NOT the game (task #50, 2026-07-15) [T2] ## Peer motion: the "random shakiness" is single-box packet jitter, NOT the game (task #50, 2026-07-15) [T2]
After the coupled body-channel peer landed (96a896a + turn-step f094d78 + cadence-mirror 23f1532), residual peer shakiness on accel/decel was RANDOM -- sometimes perfect, sometimes shaky. Root cause is the TEST RIG, proven with BT_RXJIT (record inter-arrival ms on the peer): two Debug btl4 nodes on ONE box contend for CPU, so Windows BATCHES their TCP delivery -- update records arrive in bursts (min ~0ms back-to-back, max 56-226ms gaps, burstiness max/avg 3-7x) instead of an even ~17ms. A peer dead-reckons across the long gaps then snaps when a record lands -> random shake. Pinning each node to a DISJOINT core set (ProcessorAffinity 0x00F / 0x3C0) restored even ~17ms delivery (burstiness ~1.0) and the shakiness vanished (user-confirmed). REAL pods are dedicated machines with no contention -- they never see this. DO NOT add an interpolation/jitter buffer or other un-authentic netcode to mask a single-box artifact. Use tools/mp_launch.sh (bakes in the affinity) for all 2-node tests. Diagnostic env: BT_RXJIT (arrival jitter), BT_SLIDE ([slide]/[mslide] slide-in-stand), BT_GAITEV, BT_MIRDIV, BT_NO_MIRROR_CAD / BT_PEER_LEGCH (revert the coupled-peer fixes). After the coupled body-channel peer landed (96a896a + turn-step f094d78 + cadence-mirror 23f1532), residual peer shakiness on accel/decel was RANDOM -- sometimes perfect, sometimes shaky. Root cause is the TEST RIG, proven with BT_RXJIT (record inter-arrival ms on the peer): two Debug btl4 nodes on ONE box contend for CPU, so Windows BATCHES their TCP delivery -- update records arrive in bursts (min ~0ms back-to-back, max 56-226ms gaps, burstiness max/avg 3-7x) instead of an even ~17ms. A peer dead-reckons across the long gaps then snaps when a record lands -> random shake. Pinning each node to a DISJOINT core set (ProcessorAffinity 0x00F / 0x3C0) restored even ~17ms delivery (burstiness ~1.0) and the shakiness vanished (user-confirmed). REAL pods are dedicated machines with no contention -- they never see this. DO NOT add an interpolation/jitter buffer or other un-authentic netcode to mask a single-box artifact. Use tools/mp_launch.sh (bakes in the affinity) for all 2-node tests. Diagnostic env: BT_RXJIT (arrival jitter), BT_SLIDE ([slide]/[mslide] slide-in-stand), BT_GAITEV, BT_MIRDIV, BT_NO_MIRROR_CAD / BT_PEER_LEGCH (revert the coupled-peer fixes).
## K/D authority + the scoreboard mirror — STRESS-PROVEN at 4 and 8 nodes (2026-08-11) [T2]
The scoring authority model (full map: [[combat-damage]] + `docs/KD_CHAIN_AUDIT_20260811.md`):
the VICTIM's master emits everything on its death edge (kill credit to the shooter via
replicant-Dispatch reroute, received penalty to itself, one VehicleDead); the killer's master
increments `killCount` when the rerouted credit lands; **killCount/deathTally never crossed the
1995 wire** — the port's #45 update-record extension mirrors them one-way owner→replicant
(read guarded replicant-only, mixed-version length guard, 2s heartbeat re-dirty). Bystander
nodes learn tallies ONLY through that mirror. Verified: `kd4_bench.sh` (bystander convergence +
no-mirror-exceeds-authority, receipt-derived per player) and `kd8_stress.sh` (8-node all-vs-all
melee, 26 deaths: 26/26/26/26 with zero duplicate-death swallows and zero `inst=R` deliveries).
The same-frame double-credit this closed (direct+splash lethal pair racing the deferred death
transition) is gotcha §25 — the death mode now flips synchronously inside the damage pass,
matching part_012.c:14761.
-32
View File
@@ -954,18 +954,6 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
deterministically leave ~0 at [ebp-0xc]; or replicant-player reroute delivery differs). Port deterministically leave ~0 at [ebp-0xc]; or replicant-player reroute delivery differs). Port
deviates safely (award=0). Curiosity, not a blocker. deviates safely (award=0). Curiosity, not a blocker.
## HUD range-bias @0x22C — read but NOT reconstructed (2026-08-08) [T1 read, unimplemented]
`HudSimulation` subtracts `_DAT_004b7ecc` = **100.0f** from `RangeToTarget@0x1EC` on **every frame
that the flag @0x22C is non-zero**, while accumulating a timer @0x21C by `time_slice` until it
reaches the limit @0x1D8, at which point BOTH the timer and the flag are cleared. So the authentic
HUD has a timed **100 m range offset** state that our targeting step (mech4.cpp — which does
reconstruct the 500 m/s slide correctly) never applies.
**Open:** what SETS @0x22C. Candidates not yet checked — a targeting-computer damage/jam effect, an
ECM/spoof, or a weapon-lock transient. Found while correcting hud.cpp's stand-in constant block
(the values are now byte-grounded: ec4 0.75 / ec8 1.0 / ecc 100.0 / ed0 0.0 / f90 0.0). Details:
[[gauges-hud]] §Right ladder. Worth resolving before trusting any field report about the range
readout being wrong — it is a real behavioural gap, not a cosmetic one.
## Rendering follow-ups (non-blocking) ## Rendering follow-ups (non-blocking)
- ~~Per-pilot mech PAINT (color/badge/patch)~~**DONE 2026-07-17, verified live** (crimson MadCat + - ~~Per-pilot mech PAINT (color/badge/patch)~~**DONE 2026-07-17, verified live** (crimson MadCat +
yellow VGL emblems + hip hazard stripes). Mechanics + the vehicletable color/badge/patch name yellow VGL emblems + hip hazard stripes). Mechanics + the vehicletable color/badge/patch name
@@ -1174,23 +1162,3 @@ Gitea #116 and `experience-levels.md`.
- **des_night-family pages have subtle fog deltas** (both sets near-black); arena/polar night - **des_night-family pages have subtle fog deltas** (both sets near-black); arena/polar night
pages carry the dramatic 5↔40 near-plane swing. If testers report "searchlight does nothing" pages carry the dramatic 5↔40 near-plane swing. If testers report "searchlight does nothing"
on some maps, it's page authoring, not the mechanism. on some maps, it's page authoring, not the mechanism.
## Replicant-side TakeDamage delivery -- mechanism uncharacterized (2026-08-11, from the #162 audit) [T4]
Replicant mech copies demonstrably EXECUTE `Mech::TakeDamageMessageHandler` (matchlog `DMG
inst=R` rows; the #150 bench observed replicant-side type-2 arms) -- yet every damage producer
uses `Entity::Dispatch`, and ENTITY.cpp:235-272 reroutes a replicant target's dispatch to the
wire WITHOUT local execution. Something delivers damage to the local replicant copy anyway
(wire-seam double-delivery? interest/watcher path? a port-side mirror?), and the 2026-07-25
corpus saw 0 of 18,818 DMG rows inst=R -- so the behavior APPEARED between then and 2026-08-09.
The #162 fix GATES every scoring/death consequence of it (reports + death tail are master-only),
so the scoreboard is safe regardless -- but the delivery mechanism itself is unmapped, and
replicant-side execution may have OTHER un-audited side effects (zone/subsystem state divergence
on peer copies beyond the intended visual application). To close: instrument the entry with the
delivery path (who posted it, from which socket/queue), find the first build where inst=R rows
appear, and decide whether the delivery is intended (visual-damage design) or a seam bug.
DATAPOINT (2026-08-11, the #162 retest wave): the kd4/kd8 benches audit this explicitly (I8)
and saw **0 inst=R DMG rows across three 4-node runs and one 8-node melee** (35 deaths total)
on build ~900 -- the behavior did not reproduce on the bench rig; whatever produced the
night-15 field rows needs field conditions (steam wire seam?) the LAN relay path lacks.
-167
View File
@@ -254,55 +254,6 @@ desktops. Two pieces were added for the cab:
once, and over CRD you cannot see the panels at all: the log IS the confirmation that a picture once, and over CRD you cannot see the panels at all: the log IS the confirmation that a picture
landed on the right glass. landed on the right glass.
### Boot-STABLE panel binding — `monitor:id:` (DEPLOYED + POD-VERIFIED 2026-08-10)
**Live on ALPHA-MR since 2026-08-10** — mapping eye-verified over livestream, then the master
`C:\bt411\glass_layout.cfg` flipped to the id form (old cfg saved as
`glass_layout.cfg.displaynbak`) and the relaunch bound all three windows to the SAME rects with
zero mismatch shouts [T2]. The shipped binding:
```
Secondary / Radar=monitor:id:Default_Monitor,bare # colour radar LCD, USB adapter
VGA Port A=monitor:id:UID224795,bare # RAR0005: Heat + Comm + Mfd2 CRTs
VGA Port B=monitor:id:UID200195,bare # RAR0005: Mfd1 + Mfd3 CRTs
```
**The radar LCD's USB display adapter presents NO EDID** — its only stable identity is the
`Default_Monitor` placeholder (`…#Default_Monitor#…&UID256#`). Unique today; a second EDID-less
panel would collide, and the per-boot `[glassid]` dump (BT_GLASS_IDS=1, now PERMANENT in
`podprofile.ini`) plus the mismatch shout are the diagnosis tools. The Dell (`DEL4025`) is the
PRIMARY main-view panel (BT_FIT owns it; not in the layout cfg) — the 08-08 note calling it a
"1908FP" service monitor mis-guessed its role.
**Windows renumbers displays.** Nick, after re-cabling + a reboot: *"the order changed … sometimes
they change when one gets turned off and back on, at least how windows SEEs them, even if the
visual desktop tool looks the same."* So the two original binding forms are both boot-fragile —
`monitor:2` is an enumeration index and `monitor:\.\DISPLAY4` is a GDI name Windows reassigns.
(Same trap next door in GameOS: its `-tmon` takes **DirectDraw device indices**, which are neither
Windows monitor numbers nor stable, and a NULL-device merge shifts every index down by one on top
— see Nick's `gos-displays.txt`.)
**The fix: bind to the panel's own hardware identity.** `EnumDisplayDevices` on a display's MONITOR
child returns a DeviceID embedding the **EDID manufacturer + product code** and the connector
instance — neither moves across a reboot or a power-cycle.
- **Discover:** run once on the pod with `BT_GLASS_IDS=1`. Every attached panel logs its
`stable-id` **and a ready-to-paste `cfg form`**:
```
[glassid] index=0 device=\\.\DISPLAY1 PRIMARY rect=0,0 1920x1080
stable-id = \\?\DISPLAY#AUO10ED#4&31323a6c&1&UID265988#{e6f07b5f-…}
cfg form = monitor:id:AUO10ED
```
The volatile identifiers print beside the stable one deliberately: run it twice across a
power-cycle and `index`/`device` move while `stable-id` does not — that IS the proof.
- **Bind:** `Heat MFD=monitor:id:AUO10ED,bare` in `glass_layout.cfg`.
- **Identical panels** (the pod's mono MFDs are likely one model, so EDID codes collide): use a
longer fragment from `stable-id` — the `UID…`/instance tail differs per connector, so
`monitor:id:UID265988` picks exactly one.
- **An `id:` that matches nothing WARNS and falls back** to computed placement:
`[glasswin] monitor id 'X' matched NO attached panel`. Silence there would put a picture on the
wrong glass and look exactly like the bug this form exists to prevent.
- **Nothing changes by default.** No env, no `id:` → identical behaviour; `monitor:<name|index>`
and raw `x,y` keep working. Playtester glass builds are untouched. [T2 verified on a 1-monitor
dev box — discovery, binding, centring and the mismatch warning; **T3 on the pod**, which was
offline: multi-panel disambiguation is unproven.]
**Runbook** (`tools/podprobe.ps1`, PowerShell, no install/admin — run it ON the pod PC): **Runbook** (`tools/podprobe.ps1`, PowerShell, no install/admin — run it ON the pod PC):
1. Probe: GPUs, every monitor's virtual-desktop rect, EDID make/model (identifies the original 1. Probe: GPUs, every monitor's virtual-desktop rect, EDID make/model (identifies the original
panels), serial ports (the RIO board), session type, and a PROPOSED `glass_layout.cfg` that panels), serial ports (the RIO board), session type, and a PROPOSED `glass_layout.cfg` that
@@ -439,124 +390,6 @@ returns nothing rather than failing. Kill `btl4.exe` BEFORE scp'ing a new exe or
locked. The receipts in `podrun.log` are the remote eyes; a missing `DEBUG_STREAM` line in an locked. The receipts in `podrun.log` are the remote eyes; a missing `DEBUG_STREAM` line in an
otherwise-logging run is real evidence that code path did not execute. otherwise-logging run is real evidence that code path did not execute.
### The remote runbook — READ THIS BEFORE IMPROVISING (2026-08-08)
Every line below cost real time to rediscover on a live stream. Follow it in order.
**1. Connect.** `ssh bt411-pod` (alias in `~/.ssh/config`) → `bt411-pod.tail840fa4.ts.net`, user
`user`, key `~/.ssh/bt411_pod`.
**The key has existed since 2026-08-06 but ssh will NOT offer it without the config entry** — it
only tries default names (`id_rsa`/`id_ed25519`). Without it you get
`Permission denied (publickey,password,keyboard-interactive)`, which reads exactly like "auth was
never set up". It was. Also: the Tailscale **node** KeyExpiry (2027-02-02) is what keeps the cab on
the tailnet — it is **not** an SSH credential, and Tailscale SSH is **not** enabled on the pod (the
peer advertises no `sshHostKeys`), so port 22 is the pod's own Windows OpenSSH.
**2. Absolute paths for System32 tools.** Over this SSH+cmd session, bare `taskkill` / `setx`
return `The system cannot find the path specified.` and silently do nothing. Use
`C:\Windows\System32\taskkill.exe`. (`tasklist`, `schtasks`, `dir`, `copy` happen to resolve.)
**3. Deploy — the SAME procedure a tester uses.** No pod-special exe drops.
```
python tools/mkdist.py # -> dist/BT411_4.11.NNN.zip
scp dist/BT411_4.11.NNN.zip bt411-pod:C:/bt411/
ssh bt411-pod "powershell -NoProfile -Command \"Expand-Archive C:\bt411\BT411_4.11.NNN.zip -DestinationPath C:\bt411 -Force\""
ssh bt411-pod "powershell -NoProfile -ExecutionPolicy Bypass -File C:\bt411\podkit.ps1 -Content C:\bt411\BT411_4.11.NNN\content"
```
`runpod.bat` auto-resolves the **newest** `BT411_*` by date, so nothing else needs pointing.
Local config is **never clobbered**: `mkdist.py` packs git-TRACKED content only, and
`bindings.txt`/`environ.ini`/`glass_layout.cfg` are all gitignored. `podkit.ps1` pushes the frozen
rig masters (`podprofile.ini`, `glass_layout.cfg`, `PODTEST.EGG`) from `C:\bt411\` into the install.
**`PODTEST.EGG` is not in the repo** — only podkit carries it. If podkit fails, the launcher runs
and *nothing appears*, with no error.
**4. Launch.** `schtasks /run /tn BT411Run` → runs `C:\bt411\runpod.bat` as `user`,
LogonType=Interactive, so it lands in **session 1** and is visible on the panels. Log:
`<install>\content\podrun.log`.
**5. ⚠ THE PODKIT INFINITE LOOP — the trap that ate an evening.** `podkit.ps1` line 30 was
```powershell
while ($keep.Count -gt 0 -and $keep[-1].Trim() -eq '') { $keep = $keep[0..($keep.Count-2)] }
```
When `$keep` trims to ONE blank line, `$keep.Count-2` is `-1` and PowerShell's `$keep[0..-1]`
returns **two** elements instead of shrinking — infinite loop, RSS climbing past 60 MB. It fires
whenever everything outside `environ.ini`'s marker block is blank, i.e. **any `environ.ini` that was
already kitted** — exactly what you get carrying it forward from the previous install. Patched to
`-gt 1` on the pod 2026-08-08 (`podkit.ps1.bak` is the original); **this fix is NOT in any repo**,
so a restored/replaced podkit brings the bug back.
*Signature:* a **blank cmd console** on the pod, task stuck `Status: Running`, **no `btl4.exe`, no
`podrun.log`**. *The cascade:* each hung run holds `environ.ini`, so every later attempt blocks too —
and over SSH your client times out while the REMOTE powershell keeps running, so "it returned
instantly and did nothing" actually means "it is still hung". Recover with
`taskkill /F /PID <pid>` (absolute path) on the session-1 `cmd`+`powershell` pair, then
`schtasks /end /tn BT411Run` before re-running — a task already Running refuses `/run` with
`2147946720` (`0x800710E0`, "operator refused the request").
**6. Panel identity (measured 2026-08-08, over SSH — WMI is session-independent so this works
without a GUI):** `DISPLAY\RAR0005\…UID224795`, `DISPLAY\DEL4025\…UID249395` (DELL 1908FP),
`DISPLAY\RAR0005\…UID200195`. **The two RAR panels share one EDID code and have blank serials**, so
`monitor:id:RAR0005` is ambiguous — they must be bound by the per-connector form
(`monitor:id:UID224795` / `monitor:id:UID200195`). **RESOLVED 2026-08-10:** the full map is
UID224795 = VGA Port A (Heat+Comm+Mfd2), UID200195 = VGA Port B (Mfd1+Mfd3), `Default_Monitor`
(EDID-less USB adapter, UID256) = the colour radar LCD, DEL4025 = the PRIMARY main-view panel
(not a service monitor). The master cfg now ships the id form (see the binding section above);
`glass_layout.cfg.displaynbak` preserves the old DISPLAYn cfg.
**7. ⚠ cmd redirect trap when scripting over SSH:** `echo VAR=1>> file` appends `VAR=` — cmd
parses the digit-adjacent `1>>` as a stream-handle redirect and eats the value (and MSVC putenv
treats an EMPTY value as UNSET, so the flag silently never arms). Space it (`echo VAR=1 >>file`,
trailing-space payload) or use PowerShell `Add-Content`. Bit us arming BT_GLASS_IDS 2026-08-10.
**8. Tester-procedure deploy VERIFIED end-to-end (2026-08-10, build 876):** scp zip →
`Expand-Archive` → podkit → `schtasks /run` came up clean on the first try — rig config survived,
newest-install auto-resolve worked, no hand-edits. The cab genuinely upgrades like a tester box.
**9a. ⚠ THE RIO's "COM1" IS A USB-SERIAL ADAPTER — the axis-dropout / frozen-boot fault
(2026-08-10, minidump-proven) [T1].** `\Device\VCP0` = an FTDI adapter (FTE7PGA5A); COM2 is a
second FTDI (A995JTOYA); **two ghost Prolific PL2303 entries also claim "COM1"** from earlier
plug-ins (clean them in Device Manager). When the adapter's **USB side** drops mid-operation:
brief drop = analog polls go mute (the field "encoder dropouts", revived by hand-jostle /
re-enumeration — NOT by button protocol); drop with a write in flight = the 1995 code's
**unbounded `GetOverlappedResult` wait in `PCSerialPacket::SendPacket` parked the whole game at
0 CPU forever** (three boots on 2026-08-10; stack: NtWaitForSingleObject ← GetOverlappedResult ←
SendPacket ← RIO ctor). **Hardened in 4.11.881**: 500 ms bounded write + CancelIo + 3 s sick
window (fast-drop, game keeps frame rate, keyboard fallback lives); `txTO=` in the `[rio]` health
line counts the events — proven the same evening (`txTO=1 rRetry=4` on a boot that SURVIVED).
USB **selective suspend disabled** system-wide on the cab the same day (powercfg) — prime suspect
for the random-period idle dropouts. Hardware to-do: FTDI adapter into a REAR motherboard USB
port, reseat its USB end. ⚠ A first "print-deadlock" diagnosis of the boot wedge was WRONG
(confounded A/B) and is retracted — the dump is the authority.
**Day-end state (2026-08-10 evening) + the pick-up plan.** The 881 soak measured the fault
precisely: the board answers for **~13 s after each fresh game start** (254 replies at 20 Hz),
then goes MUTE on analog for minutes while host→board writes keep completing (`req=3846 rep=254
age=240s`, txTO frozen at 1, abandon=0). Direct hand-poll (`tools/probe_rio.ps1`, also at
`C:\bt411\probe_rio.ps1` — raw COM1 CHECK/VERSION/ANALOG with correct [cmd][sum&0x7F] framing,
DTR/RTS raised): **zero bytes**. USB re-enumeration of BOTH FTDI converters + immediate re-poll:
**still zero** — which argues AGAINST the adapter (a wedged adapter RX clears on re-enum) and AT
the board. ⚠ [T4 hypothesis for tomorrow]: 13 s ≈ when the mission render load lights up — a
**power rail sagging under the cab's full load** would also explain the original mid-combat
dropouts (explosion/load transients) and the button "revival" (load dips + harness jostle).
Tomorrow, with hands: (1) loopback plug on the FTDI (TX→RX jumper, probe echoes = adapter fully
exonerated); (2) 5 V/12 V at the RIO board while a mission loads (the 13 s correlation is the
test); (3) swap in one of the spare Prolific adapters as cheap elimination; (4) rear-mobo USB
port + ghost-COM cleanup regardless. ⚠ My STALL/RECOVER edge detector in 881 keys on
request-age, which the 50 ms poll keeps resetting — it never fires; the `age=` field carries the
real signal (fix the keying to reply-age at the next build).
**Remote minidump procedure (worked over SSH, no tools install):** 32-bit dump of the wedged pid
via `C:\Windows\SysWOW64\rundll32.exe C:\Windows\SysWOW64\comsvcs.dll, MiniDump <pid>
C:\bt411\w.dmp full` → scp home → local x86 cdb (`where.exe cdb` resolves the bt411-tools copy)
with `-y C:\git\bt411\dist` (the archived per-build PDBs) → `~*k`. A 64-bit comsvcs dump of the
WOW64 process shows only 64-bit frames — use the SysWOW64 pair.
**9. ⚠ `schtasks /end` ORPHANS the game — the dead-RIO trap (2026-08-10):** `/end` kills the
batch, but `runpod.bat` `start ""`s the exe DETACHED, so the old btl4.exe survives, keeps COM1
open, and the NEXT launch boots with a dead cockpit board: `InitSerialPort() failed
[GetLastError() = 5]` (access denied) → `RIO initialization failed!` → buttons dead while the
banner still says "hardware RIO" (the banner is the PROFILE, not the init result). Diagnosis:
`tasklist /FI "IMAGENAME eq btl4.exe"` showing ≥2 instances. FIXED at the root the same day:
`runpod.bat` now `taskkill /F /IM btl4.exe`s FIRST (original saved as `runpod.bat.bak`), making
the BT411Run task an idempotent restart — but any hand-rolled launcher on a future cab needs the
same line, and a mid-mission `/run` now RESTARTS the game rather than failing quietly.
## The 1995 player manual — alignment audit (2026-07-18) [T1, primary source] ## The 1995 player manual — alignment audit (2026-07-18) [T1, primary source]
`reference/manual/Tesla40_BT_manual.pdf` (34pp, from Nick). CONFIRMS the reconstruction on `reference/manual/Tesla40_BT_manual.pdf` (34pp, from Nick). CONFIRMS the reconstruction on
every checked control behavior: every checked control behavior:
-151
View File
@@ -863,154 +863,3 @@ warning"), list what STARTS DRAWING at that event -- alarm-driven redraws,
state-change repaints -- before suspecting the event's logic; (d) the state-change repaints -- before suspecting the event's logic; (d) the
operator's screenshot is worth ten theories: the red-faces capture identified operator's screenshot is worth ten theories: the red-faces capture identified
in one frame what three log-side hypotheses missed. in one frame what three log-side hypotheses missed.
## 28. HAND-COMPOSING an engine-derived transform reads a STALE CACHE — and it only bites REPLICANTS (#141, 2026-08-08)
`EntitySegment::GetSegmentToEntity()` (`SEGMENT.cpp:262`) **recomputes only when
`segmentModified` is already set** — otherwise it hands back the cached matrix,
and if the segment has no parent it can never recompute at all. The thing that
sets that flag after a joint moves is `JointedMover::GetSegmentToWorld`
(`JMOVER.cpp:136-146`): it tests `AreJointsModified()` and, when set, walks the
whole segment table marking every entry dirty, then clears the joint flag.
The binary agrees exactly. `MechWeapon::GetMuzzlePoint @004b9948` ends in
`FUN_00424da8(owner, segment, out)`, which IS `GetSegmentToWorld`
instruction-for-instruction (`GetJointSubsystem``if (AreJointsModified())`
mark all → `ModifyJoints(False)``× localToWorld`). **So in the 1995 image
every muzzle query performs the joints→segments refresh.** [T1]
Four port sites had replaced that with `mw.Multiply(seg->GetSegmentToEntity(),
mech->localToWorld)` — including one commented "the faithful FUN_004b9948".
They skip the refresh and read whatever cache is present.
**Why it hid for a year:** the local mech is refreshed every frame anyway — the
renderer and cockpit camera call `GetSegmentToWorld` on it, *after* its torso
pushes the joint. So master-side output is correct and solo testing is clean.
A **replicant** gets no such pass: its cache stays at the BIND POSE. Measured on
a 2-node bench, peer missiles left along the LEG facing with `segYaw == bodyYaw`
EXACTLY (`twistDelta` 0.0000 over 165 salvos) while that same peer's copy torso
was demonstrably writing its joint (`PushTwist COPY twist=-1.49601`) from
correctly replicated records. Twist arrived, joint moved, segment never
re-derived.
Rules:
(a) **Never hand-compose `GetSegmentToEntity() × localToWorld`.** Call
`GetSegmentToWorld` — it is the binary's own path and it does the refresh.
(b) **Do NOT "fix" a stale transform by forcing the dirty flag.** Setting
`ModifyJoints(True)` at the read site made the symptom go away and scored
identically to the faithful fix — it was a stand-in that patched ONE
consumer and left every other peer segment reader stale. The binary only
ever *tests* that flag.
(c) A cached-transform bug is **master/replicant asymmetric by construction**.
If a geometry symptom is reported "peer POV only", suspect a cache that the
local render pass refreshes for free — before suspecting replication. Here
the replication was provably fine.
(d) When a fix lands at a partial percentage, **check whether the failures are
interleaved or a PREFIX** before calling it partial. These were a clean
prefix that ended the moment the peer first had a non-zero twist to carry —
i.e. the fix was complete and the remainder was correct behaviour. Reporting
it as "64% fixed" was wrong.
(e) Related probe trap: `Torso::PushTwist` sampled ONE shared static every 30th
call. With a master torso and a copy torso ticking 1:1, every 30th call is
always the SAME instance — the probe showed only the local untwisted torso
and hid the copy's writes entirely. Sample **per instance-kind** whenever
master and replicant objects share a diagnostic. (See also §gotcha on
process-wide statics serving the player's data as the replicant's.)
## 29. A peer mech does NOT tick before RunningMission — bench-only, and it fakes a replication bug (#148, 2026-08-08)
`Entity::Execute` (`ENTITY.cpp:556`, real engine source [T0]) calls
`PerformAndWatch` **only** when
```cpp
application->GetApplicationState() == Application::RunningMission
|| application->GetApplicationState() == Application::EndingMission
|| IsPreRunnable()
```
otherwise it just does `WriteSimulationUpdate`. `Entity::DefaultFlags` is
`DynamicFlag|MasterInstance`**no `PreRunFlag`**; only `Player` and `Director`
add it in their DefaultFlags, and `Mech::Reset` sets it for a reset MASTER
("a reset master must tick"). A **replicant mech never gets it.**
So during `LoadingMission` / `WaitingForLaunch` / `LaunchingMission` a peer mech
performs **zero** subsystem ticks, no matter how much correctly-replicated data
is arriving for it. Measured on the observer node:
```
235 [perf-first] mech 3:161 master <- own mech, immediately
402 [torso-rec-rx] <- peer's torso records start arriving
2754 [perf-first] mech 2:55 REPLICANT <- peer's FIRST performance
2758 [torso] PushTwist COPY <- its torso ticks 4 lines later
2761 [ent-exec] state=5 <- RunningMission
```
This is correct engine behaviour, **but it silently corrupts any bench that acts
before the round starts.** `BT_AUTOFIRE`/`BT_GOTO` begin immediately, so early
salvos measure a peer whose torso, gait and subsystems have never run — and the
result reads exactly like a replication failure. It cost a full investigation
(filed as #148) before the app-state trace showed the peer was simply not
executing yet.
Rules:
(a) **Judge a 2-node bench by PREFIX vs INTERLEAVED, never by raw percentage.**
A clean leading run of failures that stops for good is almost always the
pre-`RunningMission` window; interleaved failures are the real thing.
(b) When a peer looks inert, check `[ent-exec] state=` before suspecting
replication. States: `2` LoadingMission, `3` WaitingForLaunch,
`4` LaunchingMission, `5` RunningMission.
(c) Prefer benches that wait for `RunningMission` before acting — or slice the
log at the transition — otherwise every peer-side metric carries this bias.
(d) The receipts that make this legible: `BT_NET_TRACE` gives `[upd-repl]`
(offered to the performer), `[ent-exec]` (state / preRun / instance) and
`[perf-first]` (one-shot per mech: entity ID + instance at its FIRST
performance). Anonymous per-frame receipts are useless in a 2-node log —
**name the mech.**
## 30. A finite-difference cache the binary re-seeds at Reset — transcribe the WHOLE seed list (#137, 2026-08-09)
The binary `Mech::Reset @0049fb74` opens with more than the obvious origin writes: its SECOND
instruction is `FUN_00408440(mech+0x58c, param_2)` — re-seeding the **previous-position memory**
of the AccelerationLastFrame ring feed (+0x81c/0x824/0x828/0x82c) to the new origin. The port
reconstructed the ring itself faithfully (ctor `part_012.c:9836`, derivative `:15169`) but its
Reset never got that one line. Result: the first post-respawn sample computed
`|newPos prevPos| / dt` = **teleport distance / dt ≈ 1e5** into the velocity ring; the
ring-mean derivative turned it into an acceleration spike (with an opposite-sign ECHO ~15 frames
later as the sample rotated out of the mean); the myomer heat integrator's
`termAccel = (1accEff)·|v|·|a|·m·dt` turned THAT into a ~3e9 one-tick heat deposit; and the
freshly-reset myomers snapped from 77 to ~9000 against failT=2000 — the #137 respawn freeze.
Rules:
(a) **When the binary's Reset writes a cell you don't recognize, that write IS the spec.** The
+0x58c re-seed looked like bookkeeping and was silently dropped; it was the only thing
standing between a teleport and a position-derivative spike. Transcribe the whole zero/seed
list, then map each cell — never the recognizable subset.
(b) **Any prev-value cell backing a finite difference must be re-seeded at every discontinuity**
(teleport, warp, respawn). If you add such a cache port-side, grep the binary's reset for its
analog before assuming none exists.
(c) **Derived state hides stale backing state.** `currentTemperature` sampled AT the reset read
77 (clean) because RTIS wrote it — while the freeze arrived one frame later through
`heatEnergy += pendingHeat` from a live producer. Probing the derived cell at the reset
instant proves nothing about the producers; trace the WINDOW after, per producer.
(d) The diagnosis chain that worked, for reuse: roster-wide state snapshot (at-death / at-reset /
post-reset) → eliminate conduction by trapping flows with full operands → trap the remaining
producer's deposits with operands → cross-reference the operand SHAPE (pure local z,
magnitude = distance/dt, echo at ring-length) against the writers. Each trap eliminated a
theory the previous data had made plausible; three plausible theories died on operands.
## §25 — A same-pass edge latch sampling DEFERRED state: synchronous in the binary, next-tick in the port
**(2026-08-11, the #162 4-node retest double-credit.)** The binary's death processing writes
`movementMode 9` **inside the damage pass itself** — its own tail gate (part_012.c:14761) reads
the mode already flipped in the same call. The port put the mode write in the per-frame
`UpdateDeathState`, one tick later — and a comment at the death tail *asserted* the write was
synchronous, so nobody looked. Consequence: TWO lethal deliveries of one salvo (direct + splash,
2 ms apart, no sim tick between) each sampled `destroyedAtEntry = (mode==2||9)` as 0 and re-ran
the whole death block — duplicate kill credit + duplicate VehicleDead. Invisible at 2 nodes
(clean kills deliver ONE lethal hit); needs crowds + overlapping splash — the 8-player field
shape. **Rule:** when a binary function's OWN later code observes state it wrote earlier in the
same pass, the port's write must be equally synchronous — a per-frame deferral silently converts
an edge latch into a race. Sibling of the alarm-vs-mode trap (#108/#162): both are "the latch
sampled a cell whose write timing differs from the binary's". Fix shape: run the once-per-death
transition inline at the destruction edge (mech.cpp death_tail → `UpdateDeathState(0)`), keep the
per-frame caller for maintenance. Verified: kd4_bench 9/9/9/9 + kd8_stress 26/26/26/26, both
SWALLOWED=0 (the receiver dedup tripwire now never fires — no duplicate is ever GENERATED).
+1 -40
View File
@@ -102,10 +102,7 @@ Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER
(OwnerBaseSpeed reads the real base instead of the 1.0 stub). (OwnerBaseSpeed reads the real base instead of the 1.0 stub).
5. **Electrical sourcing — the dial's REAL stake is TORSO TWIST.** A gear's voltage must be 5. **Electrical sourcing — the dial's REAL stake is TORSO TWIST.** A gear's voltage must be
<= the generator's measured output (`(1 generator damage) × rated`, thermal breaker on <= the generator's measured output (`(1 generator damage) × rated`, thermal breaker on
FailureHeat with recovery gated on NormalHeat — trip at T>2000, re-arm at T≤1000, exactly FailureHeat; there is NO load model — demand never pulls the bus down), or the myomers leave
the manual p23 "cooled down half way" rule; full trip/recovery/warning-edge audit in
[[decomp-reference]] §Generator trip/recovery (#154); there is NO load model — demand
never pulls the bus down), or the myomers leave
Ready. The Torso is a PowerWatcher on the myomers: `TorsoSimulation` ZEROES the twist rate Ready. The Torso is a PowerWatcher on the myomers: `TorsoSimulation` ZEROES the twist rate
while the watched myomers are not Ready (halves it at DegradationHeat) — so an over-high gear while the watched myomers are not Ready (halves it at DegradationHeat) — so an over-high gear
on a damaged generator freezes the pilot's AIM until they downshift. On a healthy mech, on a damaged generator freezes the pilot's AIM until they downshift. On a healthy mech,
@@ -302,39 +299,3 @@ the `BTGetSubsystemAuxScreen` bridge. See `docs/VEHICLE_SUBSYSTEMS.md` + [[gauge
- Data: [[decomp-reference]] (ClassIDs/hierarchy). Bugs: [[reconstruction-gotchas]]. - Data: [[decomp-reference]] (ClassIDs/hierarchy). Bugs: [[reconstruction-gotchas]].
- Feeds: [[combat-damage]] (weapons/damage), [[gauges-hud]] (attribute state). - Feeds: [[combat-damage]] (weapons/damage), [[gauges-hud]] (attribute state).
- Plan: `docs/SUBSYS_PLAN.md`. - Plan: `docs/SUBSYS_PLAN.md`.
## Myomer drive-heat calibration — VERIFIED FAITHFUL (2026-08-09, #137) [T1]
The integrator `@004b8d18` accumulates into `pendingHeat@0x1C8`:
`gear² × (1 + X) × [ (1velEff)·|vy|·m·g·dt + (1velEff)·work + (1accEff)·|v|·|a|·m·dt ]`
with `work = mass · |v|² · 0.5`. Its constants, read byte-exact from `.rdata`
(`section_dump.txt` row ` 4b8ee0 5dc30000 0000003f 00000000 0000803f`):
**`_DAT_004b8ee4` = 0.5f** (the kinetic ½), **`_DAT_004b8ee8` = 0.0f** (the `Abs()` idiom),
**`_DAT_004b8eec` = 1.0f** (the `1 efficiency` complements and the gear-ratio clamp floor).
All three match what the port computes — the formula and its authored inputs
(VelocityEfficiency 0.995, AccelerationEfficiency 0.8, thermalMass 2.5e5, myomers linked
Condenser5) are reconstructed correctly.
**The one deliberate deviation, and why it is the faithful choice.** The binary applies **no
`time_slice`** to the kinetic term (`fVar5 * fVar1`) while the climb and accel terms both carry
`param_2` — it is a per-frame energy add at the pod's **fixed ~28 Hz**. The port uses
`work × (time_slice × 28)`, which is *identical* at 28 Hz (`dt·28 = 1.0`) but holds the same
heat-per-SECOND at any frame rate. A literal transcription would add the full term once per
frame, so at 170 fps it would inject ~6× the heat the pod ever did. `BT_MYO_HZ` overrides the
reference rate for bracketing.
**Consequence for #137 — CORRECTED 2026-08-09, the paragraph that stood here was wrong.** The
"players read 'respawned with heat maxed' as acceleration to top speed" claim did not survive
the data: the deposits were e9-scale within 30 frames of the reset, physically impossible from
motion input (~6.5e5/frame ceiling). The actual cause was a **dropped binary re-seed**: the
binary Reset's second instruction (`FUN_00408440(mech+0x58c, origin)`) re-seeds the
previous-position memory of the AccelerationLastFrame ring feed (+0x81c..+0x82c); the port
reconstructed the ring but not the re-seed, so the first post-respawn sample computed
TELEPORT-DISTANCE/dt (~1e5) into the velocity ring, the ring-mean derivative spiked
`AccelerationLastFrame`, and `termAccel = (1-accEff)·|v|·|a|·m·dt` deposited ~3e9 into
`pendingHeat` in one tick → myomers snapped from 77 to ~9000 (failT 2000) → speedEffect 0 →
frozen until cooled. Fixed by restoring the re-seed (`accelPrevPos = origin.linearPosition` in
Mech::Reset). The ~8% field rate was the |v| factor: only pilots whose throttle was still
forward at the respawn (physical lever / HOTAS — exactly who reported it) had gait-republished
speed in the spike frames. The calibration facts above (constants byte-exact, dt-normalised
kinetic term) all STAND; the in-life governor (running hot at sustained top speed derates the
myomers) is authentic and remains.
+1 -18
View File
@@ -204,24 +204,7 @@ sleep <duration>; kill $relay; bt_kill_ours
your own legs; pair screenshots with a state probe and READ a frame before concluding. your own legs; pair screenshots with a state probe and READ a frame before concluding.
- **Build ritual first** — see [[build-and-run]] §Build ritual (stale exe = phantom results). - **Build ritual first** — see [[build-and-run]] §Build ritual (stale exe = phantom results).
## The standing bench suite (scratchpad/night15/, 2026-08-11 wave — all hard PASS/FAIL)
| Bench | Asserts | Hooks it rides |
|---|---|---|
| `kd_bench.sh` (2-node) | K/D invariants I1-I5: deaths == PLAYER_DEAD == 500 costs == kill credits, no swallows | BT_AUTOFIRE + BT_AF_MISSILE, BT_GOTO, matchlog |
| `kd4_bench.sh` (4-node) | I1-I8 **receipt-derived per player** + bystander mirror convergence + no-mirror-exceeds-authority + zero `inst=R` | same + SBMIRROR records |
| `kd8_stress.sh` (8-node) | the same invariants under an all-vs-all melee (staggered salvo periods, one drop zone); `LOW STRESS` guard | same, 8 instances 1 core each |
| `proj_ttl_bench.sh` | #168 flight clocks: probe fizzles at exactly 5.0s / burn+10s; field phase: authentic ttl stamps, none early, no stale-point hits | BT_PROJ_TEST probe + BT_PROJ_LOG |
| `gen_edge_bench.sh` | #154 warning edge: 4/4 trigger fixups, trip edge FIRED, recovery + respawn silent | BT_GEN_HEAT injection + BT_AUDIO_EDGE |
| `xfire_bench.sh` | #165 destroyed-weapon gate: pre-kill FIRED, post-kill only REFUSED, survivor unaffected | BT_KILL_SUBSYS + named `[emitter] FIRED` |
| `eject_canary.sh` / `ram_bench.sh` / `relaunch_bench.sh` | eject suicide arithmetic / ram-rattle eligibility set / ordered-exit no-zombies | (night-15 fix wave) |
Multi-node rig notes: extend the egg by CLONING the 1602 pilot page (the kd4/kd8 scripts show
the python pattern); **only zone names the MAP defines are valid dropzones** (undefined names
pile everyone at a fallback — mechs spawn overlapping and the melee turns into splash-suicide
chaos, which the receipt-derived invariants still score exactly, but it isn't the scenario you
wrote); affinity masks 2-cores-each up to 4 nodes, 1-each at 8.
## Key Relationships ## Key Relationships
- Uses: [[build-and-run]] (parity, env gates, BT_SHOT capture) · [[experience-levels]] (expert vs novice gating) - Uses: [[build-and-run]] (parity, env gates, BT_SHOT capture) · [[experience-levels]] (expert vs novice gating)
- Informs: [[reconstruction-method]] (step 4 "verify honestly" — this file is the how) - Informs: [[reconstruction-method]] (step 4 "verify honestly" — this file is the how)
- Incident sources: [[reconstruction-gotchas]] §23 (pixels), §25 (same-pass latch timing — caught by the 4-node bench), §26 (silent stubs); [[multiplayer]] (replication layers) - Incident sources: [[reconstruction-gotchas]] §23 (pixels), §26 (silent stubs); [[multiplayer]] (replication layers)
+1 -1
View File
@@ -696,7 +696,7 @@ reconfigure/externalConfigure); parse-skip list EMPTY ([gskip]=0), all 50 attr b
| 30 | sec: schematic CRITICAL view (cmCrit) | subsystem simulationState/damage | LIVE this audit: N-cycle shows the full subsystem list (GEN A-D, LOOP 1-6, HUD, SENSORS, GYRO, TORSO, weapons) | T2 | CORRECT | | 30 | sec: schematic CRITICAL view (cmCrit) | subsystem simulationState/damage | LIVE this audit: N-cycle shows the full subsystem list (GEN A-D, LOOP 1-6, HUD, SENSORS, GYRO, TORSO, weapons) | T2 | CORRECT |
| 31 | sec: schematic HEAT view (cmHeat) | subsystem currentTemperature tint | #6 pixel-verified; re-cycled this audit (mask 0x450421→0x490421→0x510421) | T2 | CORRECT | | 31 | sec: schematic HEAT view (cmHeat) | subsystem currentTemperature tint | #6 pixel-verified; re-cycled this audit (mask 0x450421→0x490421→0x510421) | T2 | CORRECT |
| 32 | sec: view cycling (N / pod 0x15) | CycleDisplayMode → vtbl+0x4C @4d1ae4 | #6 resolution re-verified live ([mode] display notify 0/1/2) | T2 | CORRECT | | 32 | sec: view cycling (N / pod 0x15) | CycleDisplayMode → vtbl+0x4C @4d1ae4 | #6 resolution re-verified live ([mode] display notify 0/1/2) | T2 | CORRECT |
| 33 | sec: CONTROL MODE lamp (BAS/MID/ADV) | ControlsMapper/DisplayMode oneOfSeveralPixInt | attr wave incr.5; M-cycle verified #6 | T2 | CORRECT | | 33 | sec: CONTROL MODE lamp (BAS/MID/ADV) | ControlsMapper/**ControlMode** oneOfSeveralPixInt (row previously mislabeled DisplayMode — that is the sibling sdspmod) | attr wave incr.5; M-cycle verified #6. **2026-08-10:** this row's verification PREDATED the 07-25 #48 cycle-fill, which then lit the inactive box interiors (art idx 254 → live armor slot 62) — playtester-reported, re-fixed by mapping out-of-range indices to plane background (see context/gauges-hud.md §#48 REFINED); both render paths re-verified against the reference | T2 | CORRECT |
| 34 | sec: duck / searchlight button lamps | duckState (ctor-zeroed only) / Searchlight LightOn | duckState writer missing (P3 leftover) | T3 | DEFERRED-FEED (duck); CORRECT (light attr published) | | 34 | sec: duck / searchlight button lamps | duckState (ctor-zeroed only) / Searchlight LightOn | duckState writer missing (P3 leftover) | T3 | DEFERRED-FEED (duck); CORRECT (light attr published) |
| 35 | sec: messageBoard ticker | StatusMessagePool (NULL stub) + kill ticker strip 0 | 7fc4acb; kill ticker live 2026-07-12, other strips unsurveyed | T2/T3 | DEFERRED-FEED (partial) | | 35 | sec: messageBoard ticker | StatusMessagePool (NULL stub) + kill ticker strip 0 | 7fc4acb; kill ticker live 2026-07-12, other strips unsurveyed | T2/T3 | DEFERRED-FEED (partial) |
| 36 | MFD preset paging (J/K/L, pod RIO banks) | SetPresetMode table @0051dbf0 (little-endian re-decode) | #9; re-verified live this audit: cycles visit EXACTLY the populated set (MFD1: 1,2,4; MFD2: 1-4; MFD3: 1,2) | T2 | CORRECT | | 36 | MFD preset paging (J/K/L, pod RIO banks) | SetPresetMode table @0051dbf0 (little-endian re-decode) | #9; re-verified live this audit: cycles visit EXACTLY the populated set (MFD1: 1,2,4; MFD2: 1-4; MFD3: 1,2) | T2 | CORRECT |
-507
View File
@@ -1,507 +0,0 @@
# Generator-Out Warning Audit — #154 (2026-08-11)
> **Ledger** (full multi-agent evidence record; the curated digest lives in
> `context/decomp-reference.md` §Generator trip/recovery + the warning edge).
> Verdict chain: recovery threshold AUTHENTIC (trip 2000 / re-arm 1000 = the
> manual p23 half-cooled rule, heat-bar red == the gate); the warning was
> AUTHORED on the leave-GeneratorOut edge in shipped BTL4.RES (the game's only
> inverse-Start fault trigger, 144-row census) — fixed as the port's one
> deliberate content deviation (08fc87e, `BT_GEN_WARN_SHIPPED=1` reverts);
> live-benched trip-FIRED/recovery-silent (gen_edge_bench.sh); phrase
> ear-verified as Yip's spoken "generator out" (fef6fad).
>
> ⚠ RECORD CORRECTION (8d2f482): a same-day claim that BT 4.10 shipped no
> speech (and that era memories were Firestorm's) was WRONG — the Warnings01
> zones ARE the voice, phrase-sequenced (#119, commit 42b0691). The claim
> below in lane text predates the correction; read it with that lens. Process
> lesson recorded in memory: grep the project's own git log before asserting
> an asset absent.
---
## Phase 1 — four investigation lanes (raw returns)
########## LANE 0 ##########
## #154 — OUR PORT's generator trip/recovery reconstruction (raw findings)
### 1. Trip + recovery logic and thresholds
The generator's thermal trip lives in `Generator::GeneratorSimulation` — C:/git/bt411/game/reconstructed/powersub.cpp:1295-1372, transcribed from @004b1f7c (raw: reference/decomp/all/part_013.c:9049-9107, re-read this session).
TRIP (powersub.cpp:1304-1312):
```cpp
if (heatAlarm.GetLevel() == HeatSink::FailureHeat) // this+0x184 == 2
{
...
stateAlarm.SetLevel(GeneratorOut); // SetLevel 4
}
```
This is a HELD condition, not an edge: it re-asserts SetLevel(4) EVERY frame while heatAlarm==FailureHeat. Binary identical [T1]: part_013.c:9062-9064 `if (param_1[0x61] == 2) FUN_0041bbd8(param_1+0x7f, 4);`.
RECOVERY (powersub.cpp:1340-1357):
```cpp
case GeneratorOut: // 4
outputVoltage = 0.0f;
if (heatAlarm.GetLevel() == HeatSink::NormalHeat) // this+0x184 == 0
{
if (generatorOn == 0) { stateAlarm.SetLevel(GeneratorIdle); } // 1
else { startTimer = 0.0f; stateAlarm.SetLevel(GeneratorStarting); } // 0
}
```
Binary identical [T1]: part_013.c:9087-9097 (`param_1[0x61] == 0` gate, generatorOn at [0x75]). After the spin-up (`startTime <= startTimer`) it goes `SetLevel(GeneratorReady)` (2) and recomputes `outputVoltage = (1.0 - SourceLevel()) * ratedVoltage` (powersub.cpp:1316-1324).
The temperature thresholds behind heatAlarm (heat.cpp:851-862, @004ad924 [T1]): `T > failureTemperature → FailureHeat(2)`, `T > degradationTemperature → DegradationHeat(1)`, else `NormalHeat(0)`. Pure per-frame comparison, NO hysteresis. So: **trip at T > failureTemperature; recovery gate at T ≤ degradationTemperature** (all the way out of the yellow band, not just the red).
**The tester's era rule ("back online at HALF-cooled, when the red portion of the heat bar is gone") is SATISFIED by the port as built — no divergence.** The generator heat bar is a `VertTwoPartBar` fed `(currentTemp, degradeTemp, failTemp)` (btl4gau2.cpp:980-998); its Execute (btl4gaug.cpp:1321-1360, @004c48fc [T1]) tiles the bar up to the warn pixel `height*low/high` (low=degradationTemperature, high=failureTemperature) and paints `fillColor` only in the degradation..failure segment — i.e. the "red portion" IS exactly the T>degradationTemperature band, and it disappears exactly when the recovery gate opens. With the authored profile degradation=1000/failure=2000 (myomer record from BTL4.RES; generator values not separately confirmed [T3]) the warn line sits at literally HALF the bar. Oracle's 2nd/3rd tests ("came back after cooling halfway") behaviorally confirm the port already does this [T2-field].
### 2. stateAlarm writers, edges, and marks
Complete writer inventory of `Generator::stateAlarm` (@0x1FC, level cell @0x210):
- powersub.cpp:1311 `SetLevel(4)` — thermal breaker, HELD (per-frame re-assert while FailureHeat). The edge-ification happens inside the alarm: `GaugeAlarm54::SetLevel` (heat.hpp:106) `{ levelB = level; if (n != level) { level = n; NotifyWatchers(); } }` — notify ONLY on change, oldState (levelB@0x10) updated UNCONDITIONALLY. Byte-matches the binary FUN_0041bbd8 [T1] (part_002.c:5512-5546: `+0x10 = +0x14` before the equality check, then the three watcher sockets @+0x18/+0x2c/+0x40 fire only on change) and the T0 engine `StateIndicator::SetState` (engine/MUNGA/SIMULATE.cpp:155-217, including the "old state changes even on no-op set" NOTE at :176-179).
- powersub.cpp:1302 `SetLevel(1)` — held while `simulationState==1` (destroyed): a crit-destroyed generator is PINNED Idle forever (authentic).
- powersub.cpp:1321/1335 `SetLevel(2)` — spin-up / short-recovery completion.
- powersub.cpp:1346/1354 — the recovery arm above (4→1 or 4→0).
- powersub.cpp:1392 `SetLevel(3)` — HandleMessage msg 4 short (@004b21d0 [T1]).
- powersub.cpp:1212/1221 — ToggleGeneratorOnOff (@004b1ed0, raw re-read part_013.c:9016-9045 [T1]): OFF → SetLevel(1); ON → SetLevel(0) ONLY if heatAlarm==0 (hot ON-press changes NO state — see §4).
- powersub.cpp:1269-1270 — ResetToInitialState (@004b215c [T1]): SetLevel(0) then SetLevel(2) — every respawn passes through Starting then Ready.
- powersub.cpp:555 + :1564 — `ForceShortRecovery`/`PowerWatcher::HandleMessage` write `source->stateAlarm.SetLevel(3)` externally.
- `MarkGeneratorOut()` (powersub.hpp:483) via `BTGeneratorMarkOut` (powersub.cpp:1787-1791): **ZERO live callers** — the ForceCriticalFailure call was removed 2026-08-03 (mechsub.cpp:290-297).
**Edge delivered to audio:** the trip produces a synchronous `StateChanged(prev→4)` on the trip frame. Mechanism: `ATTRIBUTE_ENTRY(Generator, GeneratorState, stateAlarm)` (powersub.cpp:1051) publishes the alarm; `AudioStateWatcher` registers on the alarm's +0x18 audio socket (engine/MUNGA/AUDWTHR.cpp:918, with the T3 bring-up guard :908-917 that a constructed GaugeAlarm54 passes); `NotifyWatchers` (heat.cpp:96-111) Execute()s it inside SetLevel; `SendNotificationOfChange` (AUDWTHR.cpp:933-945) reads GetOldState()/GetState() at +0x10/+0x14 — layout verified compatible: StateIndicator : Node : Plug : RegisteredClass gives header 0xC then stateCount/oldState/currentState @0xC/0x10/0x14 + three sockets @0x18/0x2C/0x40 = 0x54, identical to GaugeAlarm54 (heat.hpp:126-138). State watchers are NOT on the per-frame poll socket (only `AudioTriggerOf`/`AudioMatchOf` call `simulation->AddAudioWatcher(this)` — AUDWTHR.h:293/:770; `AudioStateWatcher` registers only on the indicator socket), so no spurious held-state notifications.
**No ForceUpdate/updateModel/SetDirty mark exists at either edge** — grep of powersub.cpp finds none; the binary @004b1f7c has none either [T1]. The notification is purely the alarm-socket synchronous path.
**So the port's plumbing puts the notification on the TRIP edge (X→4), and also emits 4→0 then 0→2 at recovery.** Which of those the "Generator Out" SPEECH keys on is decided by the authored `AudioStateTrigger` fields (`triggerState/inverseTrigger/exclude_state`, streamed — AUDWTHR.cpp:968-990; firing rule :1046-1105 [T0]): non-inverse fires on `new_state==triggerState`, inverse on `old_state==triggerState` — an inverse trigger on state 4 fires EXACTLY on the recovery edge, which matches Oracle's symptom precisely. The port cannot flip this itself: the alarm mechanics are byte-faithful. The decisive probe is the load-time `[statecfg]` dump (AUDWTHR.cpp:981-987, env BT_ATTRBIND_LOG) — it prints every authored GeneratorState trigger's trigState/inv/exclude. Until that capture, whether the wrong edge is (a) authored-inverse consumed correctly (pod-era authoring quirk), (b) the trip-edge trigger authored against an attribute the port publishes differently, or (c) a dead START half of a start/stop pair, is undetermined FROM THIS LANE.
### 3. Divergence check vs the knowledge base
- context/decomp-reference.md:470-477 ("no Ready-case recompute"; stateAlarm 4's producer = the thermal breaker in GeneratorSimulation; the 2026-08-03 destruction→state-4 bridge removed as unfounded): **port conforms** — no Ready-case recompute exists in the port either, `BTGeneratorMarkOut` is dead code as documented.
- context/subsystems.md:103-105 ("(1 generator damage) × rated, thermal breaker on FailureHeat; NO load model"): consistent with powersub.cpp:1322/1336/1398.
- One STALE-ish note found: powersub.cpp:1052's comment on GeneratorOn ("static until a shutdown writer drives it to 0") — the shutdown writer EXISTS now (ToggleGeneratorOnOff, powersub.cpp:1214/1223); the comment predates #53. Cosmetic.
- No divergence found between the port and @004b1f7c/@004b1ed0/@004b215c/@0041bbd8 — instruction-shape faithful on every path I compared [T1].
### 4. FailureHeat "permanent brick" family (open-questions)
The generator does NOT share a recompute path with the weapon FailureHeat family — three distinct recovery disciplines all reading the SAME producer (own HeatSink heatAlarm @+0x184, thresholds heat.cpp:851-861):
1. **Emitters** (@004baa88, emitter.cpp:225-229, :426-434): continuous per-tick gate — ResetFiringState + currentLevel=0 while heatAlarm==2, auto-resumes as soon as heatAlarm drops to 1 (DegradationHeat). Weapons recover at "red gone"; the generator demands full NormalHeat — an intentional binary asymmetry [T1].
2. **Launchers** (open-questions.md:1086-1102, projweap gate-1): a LATCH (weaponAlarm 7 roach-motel, re-asserted unconditionally), recovery ONLY out-of-band via the EjectAmmo tap (@004bb9b8) or reset — never by cooling. This is the documented "missile spam brick".
3. **Generator**: a state-machine arm (case 4) that self-clears at NormalHeat — the OPPOSITE of a latch.
Family resemblance that could explain the once-seen "stuck offline forever" (did not repro): the generator DOES have latch-shaped states with no thermal recovery arm, and both are binary-authentic [T1]:
- **The Idle park**: press generator OFF then ON while heatAlarm==1 (degradation band). The ON branch (@004b1ed0, part_013.c:9027-9033) sets startTimer=0 but does NOT touch stateAlarm when hot → parked at Idle(1) with generatorOn=1; case 1 has no restart arm; the FailureHeat re-trip doesn't fire (heat is only level 1). Recoverable only by another OFF/ON cycle after full cooling, or reset. Requires eng-page button presses — plausible for Oracle's first test.
- **Crit-destroyed generator**: `simulationState==1` pins Idle every frame (powersub.cpp:1300-1303) — permanently out, authentically. If trip #1 coincided with a generator crit, "never came back" is correct behavior.
- Non-button path: T parked in the degradation band forever keeps the breaker latched — but then the bar still shows red, contradicting "cooled off completely"; the bar and the alarm read the same currentTemperature, so this one is self-consistent and unlikely.
Bench hooks already in place: `[gen] X TRIPPED (T=...)` / `[gen] X RESTARTING (T=...)` under BT_HEAT_LOG (powersub.cpp:1308-1310, 1350-1352) give the exact trip/recovery timeline to line up against the audio.
--- KEY FACTS ---
* Trip: powersub.cpp:1304-1312 — stateAlarm.SetLevel(GeneratorOut=4) re-asserted EVERY frame while own heatAlarm==FailureHeat(2), i.e. currentTemperature > failureTemperature; binary-identical @004b1f7c (part_013.c:9062-9064) [T1]
* Recovery: powersub.cpp:1340-1357 — requires heatAlarm==NormalHeat(0), i.e. T <= degradationTemperature; then GeneratorStarting(0) -> spin-up startTime -> GeneratorReady(2) with outputVoltage=(1-SourceLevel())*ratedVoltage; binary-identical (part_013.c:9087-9097) [T1]
* heatAlarm thresholds heat.cpp:851-862 (@004ad924): >failureT=FailureHeat(2), >degradationT=DegradationHeat(1), else NormalHeat(0); NO hysteresis [T1]
* Era rule already satisfied: the generator heat bar (VertTwoPartBar, btl4gau2.cpp:980-998; Execute btl4gaug.cpp:1321-1360 @004c48fc) paints its fill ONLY in the degradationT..failureT segment — 'red portion gone' == T<=degradationT == the recovery gate; with authored deg=1000/fail=2000 the warn line is at half the bar [T1 code, T3 for generator-specific authored values]
* Alarm edge semantics faithful: GaugeAlarm54::SetLevel (heat.hpp:106) == binary FUN_0041bbd8 (part_002.c:5512-5546) == engine StateIndicator::SetState (SIMULATE.cpp:155-217): oldState:=current unconditionally, notify watchers only on change [T0/T1]
* Trip edge DOES notify audio synchronously in the port: StateChanged(prev->4) on the trip frame via the alarm's +0x18 audio socket (AUDWTHR.cpp:918, 933-945); recovery emits 4->0 then 0->2; layout GaugeAlarm54==StateIndicator verified (header 0xC, oldState@0x10, currentState@0x14, sockets 0x18/0x2C/0x40) [T0+T1]
* No ForceUpdate/updateModel/SetDirty mark at either edge in port or binary; AudioStateWatcher/AudioStateTrigger are NOT on the per-frame poll socket (only AudioTriggerOf/AudioMatchOf call simulation->AddAudioWatcher — AUDWTHR.h:293/:770) [T0]
* Which edge plays the voice is decided by authored AudioStateTrigger fields (triggerState/inverseTrigger/exclude_state, AUDWTHR.cpp:1046-1105): inverse trigger on state 4 fires exactly on the RECOVERY edge — matching Oracle's symptom; the [statecfg] dump under BT_ATTRBIND_LOG (AUDWTHR.cpp:981-987) prints every authored GeneratorState trigger config at load [T0 mechanism, T4 on which config is authored]
* stateAlarm=4 writers: ONLY the thermal breaker; MarkGeneratorOut/BTGeneratorMarkOut (powersub.cpp:1787) has zero callers since 2026-08-03 (mechsub.cpp:290-297) [T2]
* Stuck-offline candidates, all binary-authentic [T1]: (a) Idle park — generator ON pressed while heatAlarm==1: @004b1ed0 (part_013.c:9027-9033) sets no state when hot -> parked at Idle(1), no restart arm, FailureHeat re-trip cannot fire at level 1; (b) crit-destroyed generator pinned Idle every frame (powersub.cpp:1300-1303, simulationState==1)
* FailureHeat family recompute paths are DISTINCT: emitter auto-resumes at heatAlarm<2 (@004baa88); launcher latches weaponAlarm 7, recovery only via EjectAmmo tap (@004bb9b8) or reset (open-questions.md:1086-1102); generator self-clears only at heatAlarm==0 — shared PRODUCER (own heatAlarm) but no shared recovery path [T1]
* Weapons recover at DegradationHeat (red gone) but the generator requires NormalHeat — an intentional binary asymmetry, not a port bug [T1]
* Bench hooks: BT_HEAT_LOG prints '[gen] X TRIPPED (T=..)' / '[gen] X RESTARTING (T=..)' (powersub.cpp:1308-1310, 1350-1352) for a trip/recovery-vs-audio timeline [T2]
--- UNCERTAINTIES ---
* The authored AudioStateTrigger config for Generator.GeneratorState (triggerState/inverseTrigger/exclude_state values) is unknown from this lane — it lives in the 1995 audio stream; the BT_ATTRBIND_LOG [statecfg] load-time dump would settle which edge the 'Generator Out' speech keys on. Until then, whether the wrong edge is authored-inverse (STOP half of a pair firing as the only live trigger) or a dead trip-side binding is T4.
* Whether the 'Generator Out' voice is even bound to Generator.GeneratorState vs GeneratorOn (a live x4 attr that does NOT change on thermal trip) vs a PoweredSubsystem electrical-state attribute the binary published but the port does not (port publishes only InputVoltage on PoweredSubsystem, powersub.cpp:100-104) — unverified.
* Generator-specific authored DegradationTemperature/FailureTemperature values: only the myomer profile (1000/2000, BTL4.RES) is confirmed; generators are authored per-subsystem in content and could differ, which would shift where 'half-cooled' sits on the bar.
* Whether Oracle's once-seen stuck generator was crit-destroyed (authentic permanent-out), the Idle park (requires OFF/ON presses while in the yellow band), or something else — not reproducible from code reading; needs the field log for that session.
* The AudioStateWatcher bring-up guard (AUDWTHR.cpp:908-917) is assumed to PASS for GaugeAlarm54 sockets (constructed SChainOf vptr at +0x18); AUDIO_FIDELITY F8's 'all 118 state watchers bind real StateIndicators' supports this, but a per-generator [audiostate] skip line under BT_AUDIO_LOG in the FIELD build has not been ruled out this session.
* Prime-time notification (PrimeWatcher -> SendNotificationOfChange at audio-page bind if the streamed sendNotificationOnChange flag is set) delivers StateChanged(0,2) at load — whether any authored trigger misfires on that is unchecked.
########## LANE 1 ##########
## The binary's Generator state machine (issue #154 lane: BTL4OPT.EXE simulation)
### Object map (Generator, classID 0xBC1, ctor @4b225c, vtable 0x50fd74)
The generator's operational state machine is a 0x54-byte StateIndicator at gen+0x1FC (ctor `FUN_0041b9ec(this+0x1FC, 5)` = 5 levels; current level @0x210 = the "stateAlarm", previous level @0x20C) [T1]. Fields set by the ctor @4b225c: ratedVoltage@0x1D8←res+0xFC, outputVoltage@0x1DC, maxTapCount@0x1E4←res+0x100, startTime@0x1EC←res+0x104, startTimer@0x1F0, shortRecoveryTime@0x1F4←res+0x108, shortTimer@0x1F8, generatorOn@0x1D4=1, percentAvailable@0x1D0=1.0, generatorNumber@0x1E0 = last char of name 0x40 (A=1..D=4). Initial state = SetState(2) Ready [T1]. States (2=Ready confirmed by HasVoltage @4b0b5c requiring source+0x210==2; 3=short-recovery and 1/4=off/out confirmed by the consumer-side dump @4b1224 which maps source 3→"Shorted", source 1||4→"GeneratorOff"): 0=Starting, 1=Off/Idle, 2=Ready, 3=ShortRecovery, 4=GeneratorOut.
The heat alarm is a SECOND, separate 3-level StateIndicator at gen+0x170 (level @0x184), inherited from HeatableSubsystem (ctor @4adda0: `FUN_0041b9ec(this+0x170, 3)`), with debug names @4ae050: 0="NormalHeat", 1="DegradationHeat", 2="FailureHeat" [T1].
### 1. THE THERMAL TRIP — exact write site
`GeneratorSimulation` = FUN_004b1f7c(this, dt) (part_013.c:9049-9107). Its preamble, every tick, BEFORE the state switch:
- base call FUN_004ad924 (HeatableSubsystem sim) recomputes temperature@0x114 = heatContent@0x158 / thermalMass@0x154 and drives the heat alarm with NO hysteresis: `if (T > FailureTemperature@0x11C) SetState(heatAlarm,2); else if (T > DegradationTemperature@0x118) SetState(heatAlarm,1); else SetState(heatAlarm,0)` (part_013.c:5638-5646) [T1]. The thresholds are authored content: HeatSink ctor @4adda0 loads 0x114←res+0xE4 StartingTemperature, 0x118←res+0xE8 DegradationTemperature, 0x11C←res+0xEC FailureTemperature (parse @4ae150) [T1].
- then `if (heatAlarm@0x184 == 2) SetState(genState@0x1FC, 4)` (part_013.c:9062-9064). **This is the ONLY producer of stateAlarm 4 in the binary** — I walked every SetState on the +0x1FC machine: ctor(2), ResetToInitialState @4b215c (0 then 2), toggle @4b1ed0 (0/1), sim @4b1f7c (1/4/2/2/1/0), HandleMessage msg-4 @4b21d0 (3), PoweredSubsystem-initiated short @4b11bc (3). No other site writes 4 [T1]. (The `SetState(+0x99,4)` calls in @4b0bd0/@4b0e6c are the CONSUMER PoweredSubsystem's own 5-state electrical machine @0x278 — 0=Starting,1=NoVoltage,2=Shorted,3=GeneratorOff,4=Ready per the string dump @4b1224 — do not confuse the two.)
- Also in the preamble: `if (this+0x40 == 1) SetState(genState, 1)` — pins a +0x40==1 generator to Off every tick.
- No gate on the trip: it fires at any experience level *mechanically*, but temperature can only rise when the heat model integrates (FUN_004ad7d4 gate = player+0x260, veteran+expert), so trips only occur when the heat model is on [T1].
### 2. THE RECOVERY — exact re-arm condition and threshold
Case 4 body (part_013.c:9087-9097): output forced 0 every tick; `if (heatAlarm@0x184 == 0)`: if generatorOn@0x1D4==0 → SetState(1) Off; else → startTimer@0x1F0=0, SetState(0) **Starting** — then case 0 counts startTimer up and at startTime seconds does SetState(2) Ready + `outputVoltage = (1.0 damageZone->damageLevel) × ratedVoltage` (constant @4b2154 byte-verified = 3F800000 = 1.0f in the exe's CODE section, file offset 0xB1F54).
**The re-arm threshold is NOT "half" of anything — there is no 0.5 multiplier anywhere in the generator path** (constants adjacent to the sim: @4b2154=1.0, @4b2158=0.0, @4b2258=1.0, @4b297c=0.0 — all byte-verified from BTL4OPT.EXE) [T1]. Recovery requires the heat alarm to return to **0 (NormalHeat), i.e. T ≤ DegradationTemperature** — alarm 1 (DegradationHeat) is NOT enough; the generator stays OUT through the whole degradation band. The hysteresis is structural: trip at T > FailureTemperature, re-arm at T ≤ DegradationTemperature, plus a full StartTime spin-up before voltage returns.
**The tester's "red portion of the heat bar gone" rule matches this EXACTLY in gauge terms**: the two-part temp bars normalize to FailureTemperature with the warn boundary at DegradationTemperature (warnPix = width·Degrade/Failure, value = CurrentTemperature — gauges-hud.md §TEMP/STATUS bar, @4c4170/@4c4724) [T1]. "Red gone" ⇔ T ≤ DegradationTemperature ⇔ the binary's re-arm condition. Whether that is numerically "half-cooled" depends on authored per-mech values; field notes (open-questions.md: generators equilibrate 11001500 K in the degradation band vs the ~2000 FailureHeat line) put Degrade/Failure ≈ 0.55 — approximately half [T3, authored temps not extracted from the archives].
### 3. WHICH EDGE the annunciator hears
The stateAlarm machine IS a StateIndicator; SetState = FUN_0041bbd8: first line unconditionally copies current→old (+0x10←+0x14), and ONLY on an actual value change it stores the new state and synchronously fires all three watcher chains (+0x18 audio / +0x2C video / +0x40 gauge), each watcher's vtbl+0x14 (part_002.c:5510-5546) [T1]. So:
- **stateAlarm 4 is written AT TRIP** (edge old=2→new=4 fires the audio chain the same tick T crosses FailureTemperature), **re-asserted every tick while out (edge-filtered no-ops, no re-fire), and REPLACED at recovery** by the edge 4→0 (Starting), followed StartTime seconds later by 0→2 (Ready). Nothing "clears" 4 separately; no write of 4 happens at recovery.
- What SOUND plays on which edge is decided by the AUTHORED AudioStateTrigger record streamed from content, engine code AUDWTHR.cpp `AudioStateTrigger::StateChanged` [T0]: fires when `!inverseTrigger && new_state==triggerState` (ENTER edge) or `inverseTrigger && old_state==triggerState` (LEAVE edge), with optional exclude_state suppression. **Both keying modes exist in the engine.** A "Generator Out" sample keyed non-inverse on state 4 speaks at TRIP; one keyed inverse on 4 (or non-inverse on 0/2) speaks at RECOVERY. The binary/engine machinery emits both edges faithfully — if the field build speaks only at recovery, the discriminator is the authored trigger record (trigger_state / inverse_trigger / exclude_state for `Generator.GeneratorState`), which has NOT been dumped ([statecfg] via BT_ATTRBIND_LOG would capture it; no capture exists in scratchpad/docs). Note the port's GaugeAlarm54::SetLevel (heat.hpp:106) and StateIndicator layout (STATE.h: oldState@0x10/currentState@0x14) reproduce the binary semantics exactly, including the unconditional old←current copy — the port sim (powersub.cpp:1296 GeneratorSimulation) is a faithful transcription of @4b1f7c, so the mis-edged audio is NOT a divergence in the generator sim itself [T1].
### 4. "No Ready-case recompute" — CONFIRMED, with one refinement
The switch in @4b1f7c has cases 0,1,3,4 and **NO case 2** — in Ready the sim touches nothing; outputVoltage is recomputed as `(1damage)×rated` ONLY on the two Ready-ENTRY transitions (case 0 timeout @9069-9073, case 3 timeout @9081-9085) [T1]. Refinement: `Generator::HandleMessage` @4b21d0 ALSO recomputes `(1damage)×rated` on ANY message id ≠ 4 (gated by player+0x274 ≠ 0, not-novice), and msg 4 (short) drives SetState(3)+shortTimer=0 when +0x40 ∈ {0,2} — so a message delivery is a third recompute path, but the crit distributor delivers damage without messaging the generator, so the decomp-reference claim stands for the destroyed-in-place case [T1].
### Bonus: a binary-authentic "stuck offline forever" candidate
ToggleGeneratorOnOff @4b1ed0 (not-novice gate FUN_004ac9c8, press>0): toggling ON while the heat alarm ≠ 0 sets generatorOn=1 and startTimer=0 but deliberately does NOT SetState — it leaves the machine in whatever state it is in. If the pilot toggles a TRIPPED (state 4) generator OFF (→ state 1, generatorOn=0) and back ON while still hot (state stays 1, generatorOn=1), the machine is now in state 1 with generatorOn=1 — and case 1 has NO exit logic and NO thermal re-arm check. When the heat later cools to normal, nothing transitions: the generator stays offline indefinitely, recoverable only by another full off/on toggle pair after cooling, or by re-tripping to state 4 (whose case body honors generatorOn) [T1, pure case-walk — matches the tester's non-reproducing "stuck offline forever" one-off]. This is the same defect family as the "no Ready-case recompute" latch and the open-questions FailureHeat weapon brick (weaponAlarm 7 — a different machine, same alarm-latch pattern).
--- KEY FACTS ---
* stateAlarm 4 (GeneratorOut) has exactly ONE producer in the binary: the thermal breaker at the top of GeneratorSimulation @004b1f7c`if (heatAlarm@0x184 == 2) SetState(genState@0x1FC, 4)` (part_013.c:9062-9064); it is written AT TRIP, held (edge-filtered) while out, and replaced 4→0→2 at recovery — no write of 4 occurs on the recovery edge [T1]
* Re-arm condition (case 4 body @004b1f7c, part_013.c:9087-9097): heat alarm must return to 0 = NormalHeat, i.e. currentTemperature ≤ DegradationTemperature (res+0xE8) — NOT half of FailureTemperature; no 0.5 constant exists in the path (sim constants @4b2154=1.0f, @4b2158=0.0f, @4b2258=1.0f byte-verified in BTL4OPT.EXE CODE section) [T1]
* Recovery is two-stage: 4→0 Starting (startTimer reset), then after StartTime (res+0x104) seconds 0→2 Ready with outputVoltage = (1.0 damageZone->damageLevel) × ratedVoltage; if generatorOn@0x1D4==0 at cool-down it goes 4→1 Off instead [T1]
* The heat alarm producer FUN_004ad924 (part_013.c:5638-5646) has NO hysteresis: >FailureTemperature→2, >DegradationTemperature→1, else 0, every tick; thresholds loaded by HeatSink ctor @004adda0 from res+0xE4/0xE8/0xEC (StartingTemperature/DegradationTemperature/FailureTemperature, parse @004ae150); alarm names @004ae050: Normal/Degradation/FailureHeat [T1]
* The tester's era rule maps exactly: the gauge temp bars place the warn/red boundary at DegradationTemperature over a 0..FailureTemperature scale (warnPix = width·low/high, gauges-hud.md, @004c4170/@004c4724), so 'red portion gone' ⇔ T ≤ DegradationTemperature ⇔ the binary's re-arm threshold [T1]; numerically ≈0.55·Failure from field equilibrium notes (11001500 K degradation band vs ~2000 FailureHeat) [T3]
* The audio edge is decided by AUTHORED content, not code: engine AudioStateTrigger::StateChanged (AUDWTHR.cpp) fires on ENTER (non-inverse: new_state==triggerState) or LEAVE (inverse: old_state==triggerState) with optional exclude_state; the state machine (FUN_0041bbd8, part_002.c:5510) fires its audio/video/gauge chains synchronously on every level CHANGE, old←current copied unconditionally first [T0/T1]
* The authored AudioStateTrigger record for Generator.GeneratorState (trigger_state/inverse_trigger/exclude_state) has never been dumped — no [statecfg] capture exists in scratchpad or docs; BT_ATTRBIND_LOG would print it at load [T2 gap]
* 'No Ready-case recompute' CONFIRMED: the @004b1f7c switch has cases 0,1,3,4 and no case 2; recompute happens only on Ready-entry (case 0/3 timeouts) and on Generator::HandleMessage @004b21d0 for any message id ≠ 4 when player+0x274 ≠ 0 (not-novice); msg 4 = short → SetState(3)+shortTimer=0 when this+0x40 ∈ {0,2} [T1]
* Binary-authentic stuck-offline latch found: ToggleGeneratorOnOff @004b1ed0 turning ON while heatAlarm ≠ 0 sets generatorOn=1 WITHOUT a state transition; from state 1 (Off) there is no thermal re-arm path, so off→on toggling a hot tripped generator strands it offline after cooling until another cold toggle pair or a fresh alarm-2 trip [T1 case-walk, matches the tester's one-off]
* Generator toggle and short handling are novice-locked: FUN_004ac9c8 = (player+0x274 == 0); the heat-model integration gate is player+0x260 (veteran+expert) via FUN_004ad7d4, but the alarm thresholding runs ungated [T1]
* Consumer view for cross-checks: PoweredSubsystem sim @004b0bd0 maps source stateAlarm 3→own state 2 'Shorted', 1||4→3 'GeneratorOff', 2→restart via 0 'Starting'; own electrical state names @004b1224: 0 Starting/1 NoVoltage/2 Shorted/3 GeneratorOff/4 Ready (machine @+0x264, level @0x278 — distinct from the generator's @0x1FC/0x210) [T1]
* The port's generator machinery is a faithful transcription: powersub.cpp:1296 GeneratorSimulation mirrors @004b1f7c line-for-line, GaugeAlarm54::SetLevel (heat.hpp:106) mirrors FUN_0041bbd8 including the unconditional old←current copy, and engine StateIndicator layout (oldState@0x10/currentState@0x14) matches — the generator sim is NOT the source of the mis-edged audio [T1]
--- UNCERTAINTIES ---
* The authored trigger_state/inverse_trigger/exclude_state for the Generator.GeneratorState AudioStateTrigger (and which SF2 preset/control it drives) is undumped content data — this alone determines whether 'Generator Out' speaks at trip or recovery in an authentic build; dump with BT_ATTRBIND_LOG=1 ([statecfg]/[trigcfg] lines) on a mech load
* Whether the pod's SPOKEN 'Generator Out' warning even rides the AudioStateTrigger/SF2 path at all, versus a separate speech/annunciator system — not established in this lane
* The semantics of gen+0x40 (port name simulationState): ==1 pins the machine to Off each tick and excludes the generator from eject-counting, but what writes 1 there is unlocated; decomp-reference's 'destroyed in place keeps stale Ready voltage' implies destruction does NOT set it — the two claims need reconciling
* Actual authored Generator StartingTemperature/DegradationTemperature/FailureTemperature/StartTime values per mech are inside the compiled content archives (only the exe and L4GAUGE.CFG reference the key names as text); the ≈0.55 Degrade/Failure ratio is inferred from field equilibrium notes, not extracted values
* Whether message id 4 ('short') is ever delivered to Generator::HandleMessage in normal play (vs the @004b11bc ForceShortRecovery direct-call path) — the message's producer was not traced
* @004b2164 (Generator::DeathReset thunk cited by the port) sits in an export gap between @4b215c and @4b21d0 — shape assumed from the port comment, not read from pseudocode
########## LANE 2 ##########
LANE: the port's generator reconstruction (game/reconstructed/powersub.cpp + heat.cpp/hpp, engine AUDWTHR.cpp watchers).
== 1. Trip + recovery logic and thresholds ==
The generator thermal breaker lives in Generator::GeneratorSimulation, C:/git/bt411/game/reconstructed/powersub.cpp:1296-1372, a line-for-line transcription of the binary @004b1f7c (reference/decomp/all/part_013.c:9049-9107).
TRIP (powersub.cpp:1304-1312):
```
if (heatAlarm.GetLevel() == HeatSink::FailureHeat) // this+0x184 == 2
{
... BT_HEAT_LOG "[gen] <name> TRIPPED (T=...)" on the transition only ...
stateAlarm.SetLevel(GeneratorOut); // SetLevel 4 -- called EVERY frame while FailureHeat holds
}
```
Binary: `if (param_1[0x61] == 2) FUN_0041bbd8(param_1+0x7f, 4);` (part_013.c:9062-9064) [T1 exact match].
RECOVERY (powersub.cpp:1340-1357):
```
case GeneratorOut: // 4
outputVoltage = 0.0f;
if (heatAlarm.GetLevel() == HeatSink::NormalHeat) // this+0x184 == 0
{
if (generatorOn == 0) stateAlarm.SetLevel(GeneratorIdle); // 1 -- STAYS DOWN until manual toggle
else { startTimer = 0.0f; stateAlarm.SetLevel(GeneratorStarting); } // 0 -> after StartTime secs -> SetLevel(2) Ready
}
```
Binary: `case 4: if (param_1[0x61] == 0) { if (param_1[0x75]==0) SetLevel 1; else { [0x7c]=0; SetLevel 0; } }` (part_013.c:9087-9098) [T1 exact match].
The heat thresholds are NOT in the generator — they are the HeatSink heatAlarm drive at the tail of HeatSinkSimulation, heat.cpp:851-862: `T > failureTemperature -> FailureHeat(2); else T > degradationTemperature -> DegradationHeat(1); else NormalHeat(0)`. Binary @004ad924 tail is identical (`param_1[0x47] < T -> 2; param_1[0x46] < T -> 1; else 0`, temps @0x11C/@0x118) — NO hysteresis on either side [T1]. Both temperatures are authored per-subsystem resource fields ("DegradationTemperature"/"FailureTemperature"); their generator values live in the streamed .RES model data and were not dumped here.
CONSEQUENCE FOR THE ERA RULE ("back online when the red bar portion is gone / half-cooled"): the recovery gate is heatAlarm==0, i.e. T must fall BELOW degradationTemperature — the LOWER of the two thresholds, well below the trip point. The heat gauges bind CurrentTemperature with DegradationTemperature + FailureTemperature as zone markers (content/GAUGE/L4GAUGE.CFG:4434-4436 pattern), so if the bar's red zone starts at DegradationTemperature, "red gone" == the exact recovery gate and the port is ALREADY era-correct — which matches the tester's own runs 2 and 3 ("it did come back online after cooling halfway", scratchpad/night14/housekeep14.py:224-240). This is the binary's behavior byte-for-byte; there is nothing to change in the sim [T1 code, T3 for the red-zone==degradation mapping, unverified against the palette].
== 2. Where stateAlarm is written, and on which edge ==
All stateAlarm (alarm @0x1FC, level @0x210) writers in the port:
- ctor powersub.cpp:1113 `SetLevel(GeneratorReady)` (2)
- GeneratorSimulation trip :1311 `SetLevel(4)` — TRIP edge, re-asserted every held frame
- GeneratorSimulation recovery :1346/:1354 `SetLevel(1)` or `SetLevel(0)`, then :1321 `SetLevel(2)` after StartTime — RECOVERY is TWO notify edges: 4->0, then 0->2
- ToggleGeneratorOnOff :1212/:1221 (0 on healthy re-arm, 1 on switch-off)
- ResetToInitialState :1269-1270 `SetLevel(0); SetLevel(2);` — every respawn fires a 0-then-2 notify pair (binary @004b215c does the same, part_013.c:9119-9120)
- Generator::HandleMessage msg 4 :1392 `SetLevel(GeneratorShorted)` (3)
- ForceShortRecovery (PoweredSubsystem) :555 and PowerWatcher::HandleMessage :1564 (both set 3)
- MarkGeneratorOut (powersub.hpp:483) — documented UNUSED; the destruction bridge was removed as unfounded (powersub.hpp:471-481).
The notify mechanism: GaugeAlarm54::SetLevel (heat.hpp:106) `levelB = level; if (n != level) { level = n; NotifyWatchers(); }` — oldState updated unconditionally FIRST, watchers fired ON CHANGE ONLY. Binary FUN_0041bbd8 (part_002.c:5512-5546) is semantically identical: `+0x10 = +0x14` unconditionally, then if-changed set +0x14 and Execute() every watcher on the three chains @+0x18/+0x2c/+0x40 [T1]. So the per-frame SetLevel(4) spam while tripped notifies exactly once, at the 2->4 trip edge.
THEREFORE: the port's sim DOES deliver a watcher notification on the TRIP edge (old=2, new=4), and delivers two more on recovery (4->0, 0->2). The generator sim is not the component putting the audio on the wrong edge. There is NO ForceUpdate/SetDirty/updateModel mark at either edge — grep of powersub.cpp + heat.cpp is empty, and the binary @004b1f7c contains nothing but the SetLevel calls and arithmetic; the watcher chains ARE the notification path [T1].
AUDIO WIRING (adjacent, for synthesis): GeneratorState is published as an attribute directly on stateAlarm (powersub.cpp:1051, ATTRIBUTE_ENTRY(Generator, GeneratorState, stateAlarm)); the audio renderer binds authored AudioStateTriggers to it by name (context/wintesla-port.md:94, "audiostate skips 85 -> 0"). Trigger evaluation is engine T0 (engine/MUNGA/AUDWTHR.cpp:1046-1105): non-inverse fires when new_state==triggerState, INVERSE fires when old_state==triggerState (i.e. on LEAVING the state), with optional exclude pairs. Given the sim edges above, a sound heard ONLY at recovery means the live trigger fired on 4->0 or 0->2 — i.e. either an inverse trigger on state 4 ("leaving GeneratorOut") or a trigger on entering 0/2. Which of those is authored is in the streamed audio pages, NOT in the port code — undumped. BT_ATTRBIND_LOG=1 prints every AudioStateTrigger's `[statecfg] trigState/inv/ctl/excl` at bind time (AUDWTHR.cpp:981-987), and BT_HEAT_LOG prints "[gen] X TRIPPED"/"[gen] X RESTARTING" (powersub.cpp:1308-1311, 1350-1352); one capture correlating those against the heard sample settles the edge question definitively. Related suspicion [T4]: docs/AUDIO_FIDELITY.md F5/F6 document whole blocks of authored warning audio bound to dead attributes (ReportLeak is dead on GeneratorA-D among 19 subsystems, AUDIO_FIDELITY.md:402-411) — if the pod's trip-side warning rides one of those dead bindings, the trip would be silent in the port while a different, live authored cue (e.g. a spin-up/state sound at 4->0 or 0->2) is what the tester heard at recovery and labeled "the generator out warning".
== 3. Divergence vs the context claims ==
None found. context/decomp-reference.md:470-477 ("the generator sim has NO Ready-case recompute — a generator destroyed in place keeps stale Ready voltage until any transition recomputes output") is confirmed in both the port and the binary: the GeneratorSimulation switch has cases 0/1/3/4 only, no case 2 — outputVoltage is untouched while Ready [T1]. context/subsystems.md:103-105 ("(1 generator damage) × rated, thermal breaker on FailureHeat, no load model") matches the transcription (the `(MaxOutputVoltage - SourceLevel()) * ratedVoltage` recomputes at :1322/:1336/:1398). The gnrator.cpp/gnrator.hpp pair is an alias note only — no second class, no code emitted (gnrator.cpp:10-24). One historical note: Generator::ResetToInitialState deliberately diverges from the surviving GNRATOR.TCP source (which zeroed outputVoltage) to follow the shipped binary @004b215c (outputVoltage = ratedVoltage) — documented at powersub.cpp:1238-1263 and docs/RESPAWN_REARM_PLAN.md:116 [T1].
== 4. FailureHeat "permanent brick" family (open-questions.md:1086-1102) ==
The generator does NOT share a recompute path with the weapon FailureHeat family — only the PREDICATE. All three families gate on the identical HeatSink heatAlarm level 2 at this+0x184 (open-questions.md:644-647, disasm-verified consumers), but their latch semantics differ:
- Emitters (@004baa88): FailureHeat -> ResetFiringState + currentLevel=0 "until it cools" — SELF-RECOVERING.
- Ballistic/missile launchers (@004bbd36 / projweap gate 1): FailureHeat -> weaponAlarm 7 (NoAmmo), re-asserted unconditionally inside the machine — a LATCH released only by an EjectAmmo tap or ResetToInitialState (the reproduced 19-rounds-left brick, open-questions.md:1086-1102).
- Generator (@004b1f7c case 4): SELF-RECOVERING when heatAlarm returns to 0.
So the missile roach-motel mechanism cannot produce the tester's once-seen "stuck offline forever" generator. Port-lane candidates for that non-reproducing report, both mechanisms [T1], scenario attribution [T4]: (a) the generatorOn==0 branch — if the pilot's generator toggle (RIO 0x1A-0x1D) had put generatorOn=0 (or was pressed while tripped: the OFF branch also zeroes coolantFlowScale, powersub.cpp:1219-1225, so the tripped generator loses its own coolant flow and cools only by conduction), a cooled generator lands in Idle(1) and NEVER self-restarts — authentic binary behavior requiring a manual toggle-ON; (b) a cooling stall — recovery needs T <= degradationTemperature (far below the trip point at failureTemperature); with coolant starved the last stretch can take a very long time, reading as "never" in one sitting while the heat BAR (which may visualize a different normalization) reads "cooled".
--- KEY FACTS ---
* Port trip gate: heatAlarm==FailureHeat(2) -> stateAlarm.SetLevel(GeneratorOut=4), powersub.cpp:1304-1312, re-asserted every frame while held; byte-matches binary @004b1f7c (part_013.c:9062-9064) [T1]
* Port recovery gate: case GeneratorOut requires heatAlarm==NormalHeat(0), i.e. T <= degradationTemperature (the LOWER threshold), then 4->0 (Starting) -> after authored StartTime -> 0->2 (Ready); powersub.cpp:1340-1357 + 1316-1324; byte-matches binary part_013.c:9087-9098 [T1]
* Heat alarm thresholds have NO hysteresis: T>failureTemperature->2, T>degradationTemperature->1, else 0; heat.cpp:851-862 == binary @004ad924 tail (temps @0x118/@0x11C) [T1]
* GaugeAlarm54::SetLevel (heat.hpp:106) notifies watchers ON CHANGE ONLY, oldState updated unconditionally first — semantically identical to binary FUN_0041bbd8 (part_002.c:5512-5546); the per-frame SetLevel(4) spam produces exactly ONE trip-edge notification (old=2,new=4) [T1]
* The port sim DOES fire a watcher notification on the trip edge; a sound heard only at recovery means the bound AudioStateTrigger fired on 4->0 or 0->2 — either an authored INVERSE trigger on state 4 (fires on old_state==triggerState, engine T0 AUDWTHR.cpp:1079-1082) or a trigger on entering Starting/Ready; the authored trigger config is in the streamed audio pages, undumped [T1 mechanics, config unknown]
* No ForceUpdate/SetDirty/updateModel mark at either edge in port (grep empty over powersub.cpp+heat.cpp) or in binary @004b1f7c — the alarm watcher chains are the only notification path [T1]
* GeneratorState attribute is published directly on stateAlarm (powersub.cpp:1051); audio binding verified live ('audiostate skips 85->0', context/wintesla-port.md:94-99) [T2]
* decomp-reference.md 'no Ready-case recompute' CONFIRMED: the GeneratorSimulation switch has no case 2 in port or binary; outputVoltage stays stale while Ready [T1]; no divergence found between port and context/decomp-reference.md or context/subsystems.md generator claims
* Generator does NOT share the weapon FailureHeat recompute/latch path — only the heatAlarm==2 predicate (this+0x184). Emitters @004baa88 self-recover, ballistic/missile @004bbd36 latch weaponAlarm 7 until EjectAmmo/reset (the reproduced brick, open-questions.md:1086-1102), generator self-recovers at heatAlarm==0 [T1]
* Stuck-offline-forever candidates (mechanisms T1, attribution T4): generatorOn==0 at cool-down lands Idle(1) which never self-restarts (manual toggle required, authentic); toggling a generator OFF also zeroes its coolantFlowScale (powersub.cpp:1221-1224) starving its own cooling; recovery needs cooling all the way below degradationTemperature, far under the trip point
* ResetToInitialState fires a 0-then-2 SetLevel pair on every respawn (powersub.cpp:1269-1270 == binary @004b215c) — any audio trigger keyed on entering state 0 or 2 also fires at every respawn [T1]
* Era-rule alignment: heat gauges bind DegradationTemperature+FailureTemperature as zone markers (L4GAUGE.CFG:4434-4436 pattern); if the red zone starts at DegradationTemperature, 'red gone' == the recovery gate exactly — matching the tester's runs 2-3 recovery at half-cooled [T3]
* Diagnostics that settle the audio edge: BT_ATTRBIND_LOG=1 dumps every AudioStateTrigger's trigState/inverse/exclude at bind (AUDWTHR.cpp:981-987); BT_HEAT_LOG prints [gen] TRIPPED/RESTARTING transition lines (powersub.cpp:1308-1311,1350-1352)
--- UNCERTAINTIES ---
* The authored AudioStateTrigger configuration for GeneratorState (trigger states, inverse flags, exclude pairs, control targets) is in the streamed audio pages of the mech model resources — not dumped; this single datum decides whether the recovery-edge sound is authored (inverse/state-0 trigger, pod would behave the same) or a port parsing/eval defect. Capture with BT_ATTRBIND_LOG=1.
* Which sample the tester actually heard ('generator out warning') is unidentified — no GeneratorOut-named wav exists in audiopresets.cpp; it may be a Warnings01 klaxon split or another state cue misattributed. The trip-side warning may ride a DEAD authored binding (the AUDIO_FIDELITY F5/F6 family; ReportLeak is dead on GeneratorA-D) [T4].
* Authored DegradationTemperature/FailureTemperature values for the generators (streamed .RES resources) not extracted; the 'red zone == degradation band' heat-bar mapping is inferred from the gauge attribute bindings, not verified against the palette rendering.
* Whether the 1995 pod played the warning at the trip edge is era testimony only [T4]; nothing in the decomp examined here identifies the trip-side audio path.
* The once-seen stuck-offline generator: neither candidate (generatorOn Idle landing, cooling stall) is confirmed against the tester's session — no log was captured for that run.
########## LANE 3 ##########
## #154 Generator Out warning — KB + field-evidence lane, raw findings
### 1. What the KB already claims (inventory)
**The generator state machine (binary-grounded):**
- `stateAlarm` levels: 0=Starting, 1=Idle, 2=Ready/OnLine, 3=Shorted, 4=GeneratorOut. stateAlarm 4's producer = "the THERMAL BREAKER in GeneratorSimulation itself; a 2026-08-03 destruction→state-4 bridge was removed as unfounded" — `context/decomp-reference.md:470-477` [T1].
- "The generator sim has NO Ready-case recompute — a generator destroyed IN PLACE keeps stale Ready voltage (no bit-6 invite) until any transition (thermal trip → stateAlarm 4 GeneratorOut, short, switch-off)" — `context/decomp-reference.md:472-475` [T1, @0049c9a8 + FUN_004b1f7c].
- Offsets: `stateAlarm@0x1FC` (the 0x54 GaugeAlarm54 object base — `docs/HARD_PROBLEMS.md:251`, `docs/RESPAWN_REARM_PLAN.md:250`) vs `stateAlarm@0x210` in the eject clause (`decomp-reference.md:425`). These are CONSISTENT, not a conflict: 0x210 = 0x1FC + 0x14 = the alarm's `level` field (GaugeAlarm54 level@+0x14, `context/subsystems.md:31`). Flagging because it reads like a contradiction.
- `Generator::ResetToInitialState @004b215c` does `stateAlarm.SetLevel(0)` then `SetLevel(2)` — "a forced *change*, so watchers re-latch" — `docs/RESPAWN_REARM_PLAN.md:172` [T1]. This 0→2 double edge fires at EVERY respawn/mission start.
- Condition-flag bit table (the `[techstat]` receipts): bit0 structure≥StatusThreshold, bit1 structure>StatusFloor, bit2 CoolantLeaking, bit3 heatAlarm≠0 (Overheating), bit6 !HasVoltage (BadPower; HasVoltage = source stateAlarm==2 && |V|>ε @004b0b5c) — `decomp-reference.md:454-489` [T1]. Conditions are ROUTINE operating flags, not alarms; never read a lone SET as a latch [T2, field-verified 2026-08-08].
- Audio binding: `Generator.GeneratorState`→stateAlarm registered as a real StateIndicator; "SetLevel fires them on change"; "generator/condenser/reservoir/ammo state sounds fire on transition" — `context/wintesla-port.md:87-99` [T2]. Port-side registration: `game/reconstructed/powersub.cpp:1051` `ATTRIBUTE_ENTRY(Generator, GeneratorState, stateAlarm)` with comment "SetLevel fires audio".
- `simulationFlags` bit 0 = engine DelayWatchersFlag — "setting it permanently skips the subsystem's ExecuteWatchers (audio watchers)" — `decomp-reference.md:248-250` [T0/T1]. (A watcher-suppression footgun adjacent to this family.)
- Experience gates: "generators take damage/go offline (reroute power)" is VETERAN+; standard mode = "no generator outages", MechTech audio limited to the leg-damage warning — `context/experience-levels.md:76-81` [T1 primary source].
**What the KB does NOT contain:** any claim about WHICH state edge the "Generator Out" audio is authored on, any decode of the authored AudioStateTrigger state→note records for GeneratorState, and any claim about the recovery threshold's relation to the heat bar. The recovery threshold itself was undocumented in `context/` prose — I read it from the decomp directly (below).
### 2. Binary + authored-data ground truth (read this session)
- **`FUN_004b1f7c` (GeneratorSimulation), `reference/decomp/all/part_013.c:9053-9107` [T1]:**
- Trip: `if (heatAlarm.level==2 /*FailureHeat*/) SetLevel(stateAlarm, 4)` — runs EVERY tick while at FailureHeat; edge-firing therefore depends entirely on GaugeAlarm54::SetLevel's fire-on-change contract.
- Recovery: `case 4:` exits ONLY when `heatAlarm.level == 0` (NormalHeat): generatorOn==0 → SetLevel(1) Idle; else `startTimer=0; SetLevel(0)` Starting → spin-up `startTime``SetLevel(2)` Ready + voltage restore. So a tripped generator stays out through the ENTIRE FailureHeat+DegradationHeat band and returns only when T ≤ degradationTemperature.
- **Authored thresholds, raw `content/BTL4.RES` dump [T1]:** GeneratorA-D (classID 0xBC1, 0x10C records): startingTemp=77, degradationTemp=1000, failureTemp=2000 (all six mech variants identical). heatAlarm banding has NO hysteresis: >2000→2, >1000→1, else 0 (`game/reconstructed/heat.cpp:851-862`, transcribed from @004ad924).
- **Therefore the binary's recovery point is T ≤ 1000 = exactly HALF of the 2000 trip line.** Oracle's era rule ("gens return at HALF-cooled / red bar gone") is not a missing feature — it is the shipped binary's arithmetic, and the port (`powersub.cpp:1296-1372`) transcribes it faithfully. The threshold audit flagged in the handout should come back "already authentic," CONTINGENT on one unverified visual claim: that the cockpit heat bar paints its red band as the ≥degradationTemp region (gauge-coloring not audited this pass) [T3 for the bar mapping].
- **Recovery emits TWO SetLevel edges (4→0, then 0→2 after spin-up); trip emits ONE (x→4). RTIS emits the same 0→2 pair at every respawn/mission start (@004b215c, part_013.c:9119-9120).** Any audio cue keyed to entering state 0 or state 2 will sound at recovery AND at every respawn/round start; a cue keyed to entering state 4 sounds at trip. This is the structural fingerprint the wrong-edge bug must sit on [T1 for the edge structure; the authored state→sound mapping itself is UNREAD — that is the code/data lane's job].
### 3. The 1995 manual (primary source, pages read this session)
- **p23 (GENERATORS / Generator Info Center), verbatim:** "If a generator overheats, it will shut down. That generator will not come back on-line until its heat gauge has cooled down half way, which can take some time. If a generator is destroyed by a critical hit, an 'X' will appear in that generator's info center." — Oracle's era rule is PRINTED, word for word the half-way rule. Also feeds open era question #133 (the X = destroyed-by-crit, per the manual — not shutdown/overheat).
- **p14 (MECHTECH / DAMAGE CONTROL):** the warning is listed among the spoken MechTech warnings: "*Warning, coolant leak … Warning, generator out … Warning, ammo bay fire*". MechTech audio warnings + smart buttons are veteran/expert-only; standard mode gets only the leg-damage message and "you are never required to switch generators".
- **p19:** "when a generator overheats, it simply shuts down"; auto gen-mode cascade warning (all-auto + two dead gens can lock out sensors/myomers).
- **p22:** "If your 'Mech stops moving, and the heat in your myomers is OK, check the generator; it may be the system that has overheated."
- The warning is a VOICE line in 1995. **The port's SF2 banks contain no speech presets at all** (241 presets enumerated from `game/reconstructed/audiopresets.cpp`; the only warning-family presets are the `Warnings01` 8-way klaxon split and `AllWarning`). Whatever the tester hears as "Generator Out" in the port is a klaxon zone, not the spoken phrase — and where the original spoken phrases lived (not in AUDIO1/2.RES) is an unrecorded open question.
### 4. Field evidence (night15, 2026-08-10 steam logs, build 4.11.883)
- **The trip/recovery edges are FORENSICALLY INVISIBLE in field logs.** The only generator state receipts (`[gen] TRIPPED/RESTARTING`, powersub.cpp:1308-1352) are `BT_HEAT_LOG`-gated and appear 0 times in all six logs. No audio-warning receipts exist at all. #154's wrong-edge claim currently rests solely on Oracle's testimony + the manual; no log can confirm or refute it.
- **Generator Overheating (cond 3) cycling is routine and balanced** — e.g. santo/MS-FIREFLY: ~30 GeneratorX cond-3 SETs with matching CLEAREDs; acaci/ZEUS GeneratorC 5 SET/4 CLEARED (log ends mid-heat). No latch anywhere = corroborates the stuck-forever non-repro.
- **Probable real thermal trip captured (rajel/GAMERSLAB):** GeneratorB cond 3 SET :99453 (still set) → :103610-103625 ERSLaser_2 + ERPPC_2 cond 6 BadPower SET → cond 6 CLEARED :104109/:104273 → GeneratorB cond 3 CLEARED :104815 → re-SET :105025. **Note the ordering: the weapons regained voltage ~500-700 lines BEFORE the generator's Overheating flag cleared.** Under the binary model a same-generator recovery is impossible before heatAlarm→0, so this is either (a) manual/auto power rerouting to another generator (manual p14 procedure; the auto-hunt is live per `BT_POWER_DETACH_TEST` bench), or (b) an early-recovery defect. Logs cannot disambiguate (no per-weapon source receipts). Every other cond-6 cluster in the six logs traces to crit damage (gen cond 1+2 SET pairs = structure+coolant-leak flags, e.g. santo GeneratorD :107400/:107412) or to the mission-start spin-up wave (Dave :51669-52166: 11 subsystems BadPower while all gens run their startTime spin-up after load — clears in ~500 lines).
- **The respawn reset clears gen conditions wholesale** (santo :89209-89210 GeneratorA cond 1+2 CLEARED inside the `[respawn] Mech::Reset` block) — and per @004b215c each respawn replays the 0→2 stateAlarm pair. If the port's warning cue is keyed to state 0 or 2, testers should ALSO hear it at every respawn/round start — a cheap discriminating question to put to Oracle.
- **#135 adjacency receipt:** `[galarm] condition 2 code 0x80/0x87 sub 'GeneratorA/D' -> NO LAMP RESOLVED (generator=1)` — the generator-special lamp resolution path (@004cc27c power-family special) returns nothing for a leaking generator, so a generator coolant leak flashes NO annunciator. That is exactly Oracle's #135 ("coolant leak with NO flashing annunciators") signature on generators, in the current build's logs.
### 5. Prior-ticket / adjacent-defect map
- `docs/OPEN_ISSUES_FOR_TESTERS.txt:62-64`#154 refined by Oracle: stuck-forever did NOT reproduce; era rule = half-cooled; "threshold audit due" (answer per §2: threshold already authentic).
- #133 (open era question): the MFD X — manual p23 answers it: X = destroyed by critical hit.
- The "missile spam trips FailureHeat permanent brick" (`context/open-questions.md:1086-1102`) is the WEAPON-side roach-motel: weaponAlarm 7 re-SetLevel(7) unconditionally (`decomp-reference.md:240-241`), a genuine latch. The GENERATOR breaker, by contrast, self-recovers by design. Different mechanism; only superficially the same family. If Oracle's once-seen "stuck offline forever" was real, the in-model candidates are: generatorOn==0 (state 4 → Idle requires a manual ToggleGeneratorOnOff; a pilot who punched the gen OFF while it was out stays dark forever — authentic), sustained load never letting T below 1000, or a coolant-leaked loop that can no longer shed heat at all.
- #55/RESPAWN_REARM history: `Generator::RTIS` was once transcribed with `outputVoltage = 0` (stale GNRATOR.TCP) vs the binary's `= ratedVoltage` (`docs/RESPAWN_REARM_PLAN.md:116`) — a documented past hard-brick vector, since fixed (powersub.cpp now matches @004b215c).
- git log has zero #154 commits — untouched territory.
### 6. Wrong-edge mechanism candidates (flagged for the code lane, all [T4] from this lane)
1. The authored AudioStateTrigger record for `Generator.GeneratorState` keys the warning cue at state 0 (Starting) or 2 (Ready) rather than 4 — or the port misreads the authored state value when binding.
2. The port's GaugeAlarm54 watcher passes the OLD level on change (off-by-one): a cue authored for level 4 then fires on the FIRST transition AFTER being at 4 — i.e. exactly at recovery. Symptom-exact.
3. A Start/Stop inversion on a looped alarm cue (authored: start-on-enter-4 / stop-on-exit-4).
Discriminator available without code: ask Oracle whether the same warning also sounds at respawn/round start (the RTIS 0→2 pair).
--- KEY FACTS ---
* Manual p23 verbatim: 'If a generator overheats, it will shut down. That generator will not come back on-line until its heat gauge has cooled down half way' — Oracle's era rule is printed 1995 doctrine (reference/manual/Tesla40_BT_manual.pdf p23) [T1 primary source]
* Binary recovery threshold: GeneratorSimulation @004b1f7c case 4 exits ONLY at heatAlarm==0 (NormalHeat), i.e. T <= degradationTemp (reference/decomp/all/part_013.c:9087-9097) [T1]
* Authored generator thresholds from raw BTL4.RES: startT=77, degradationTemp=1000, failureTemp=2000 for GeneratorA-D on all 6 variants — recovery at 1000 = exactly HALF the 2000 trip line, so the half-cooled rule is already the shipped arithmetic and the port transcribes it (powersub.cpp:1296-1372) [T1]
* Trip emits ONE stateAlarm edge (x->4); recovery emits TWO (4->0 Starting, then 0->2 Ready after startTime); Generator::ResetToInitialState @004b215c replays the same 0->2 pair at every respawn/mission start (part_013.c:9119-9120) [T1]
* The 'Warning, generator out' line is a spoken MechTech warning in 1995 (manual p14), veteran/expert only; the port's SF2 banks contain ZERO speech presets (241 enumerated, audiopresets.cpp) — whatever the port plays is a Warnings01 klaxon zone, and the original voice samples' home is unrecorded [T1/T2]
* Audio path on file: Generator.GeneratorState -> stateAlarm bound as a real StateIndicator, 'SetLevel fires them on change' (context/wintesla-port.md:87-99; powersub.cpp:1051) [T2]; the authored state->note mapping for GeneratorState has never been decoded — the KB is silent on which edge the warning is authored to
* Field logs (night15, all 6): zero receipts for generator stateAlarm transitions or audio warnings — [gen] TRIPPED/RESTARTING is BT_HEAT_LOG-gated and absent; #154's wrong-edge claim is currently unfalsifiable from logs [T2]
* Probable thermal trip in rajel/GAMERSLAB: GenB cond3 SET :99453 -> ERSLaser_2+ERPPC_2 cond6 BadPower :103610 -> cond6 CLEARED :104109/:104273 BEFORE GenB cond3 CLEARED :104815 — weapons regained power while the gen was still hot; auto/manual reroute vs early-recovery defect not distinguishable from the log [T2]
* Generator cond-3 SET/CLEARED counts are balanced in every night15 log (no latch) — corroborates Oracle's stuck-forever non-repro [T2]
* Stuck-forever in-model candidate: state 4 with generatorOn==0 drops to Idle(1) and stays until a manual ToggleGeneratorOnOff — a pilot who toggled the gen off while it was out stays dark forever, authentically (part_013.c:9089-9091) [T1]
* The missile-spam FailureHeat brick (open-questions.md:1086) is the WEAPON weaponAlarm-7 roach-motel (re-SetLevel(7) unconditionally, decomp-reference.md:240-241) — a real latch, mechanically distinct from the self-recovering generator breaker [T1]
* #135 adjacency captured live: [galarm] condition 2 sub 'GeneratorA/D' -> NO LAMP RESOLVED (generator=1) in night15 logs — a generator coolant leak flashes no annunciator [T2]
* stateAlarm@0x1FC vs @0x210 in the KB is NOT a contradiction: 0x1FC = GaugeAlarm54 base, 0x210 = its level field (+0x14) [T1]
* Manual p23 answers open era question #133: the MFD X = generator destroyed by critical hit (not shutdown/overheat) [T1 primary source]
* heatAlarm banding has no hysteresis (>2000->2, >1000->1, else 0; heat.cpp:851-862 from @004ad924), so recovery timing is purely the cooling curve crossing 1000 [T1]
--- UNCERTAINTIES ---
* Whether the cockpit heat bar's RED band is painted over the >=degradationTemp region (required for 'red gone' == the 1000 recovery line); gauge coloring was not audited this pass [T3]
* Which authored state value the 'Generator Out' audio cue is keyed to — the AudioStateTrigger state->note records for GeneratorState are undecoded; all three wrong-edge mechanism candidates (cue keyed to state 0/2, old-level off-by-one in the watcher, Start/Stop inversion of a looped cue) are [T4] pending the code/data lane
* Where the 1995 spoken warning phrases physically live (not in AUDIO1/2.RES SF2 banks) and what sample the port actually plays for the tester-heard 'Generator Out' warning — no audio receipt exists in any field log
* Whether rajel's :103610 BadPower window cleared via auto-hunt/manual reroute (authentic) or an early same-generator recovery (defect) — indistinguishable without per-weapon voltage-source receipts
* Which night15 log user is Oracle (handles: Dave/rajel/acaci/Michael/santo/eleng) — could not map the callsign, so the trip he described may not be in these six logs at all
* Whether Oracle also hears the warning at respawn/round start (the RTIS 0->2 SetLevel pair) — a cheap testimony discriminator between the state-0/2-keyed and old-level hypotheses that has not been asked
* Whether the binary's GaugeAlarm54 change-detection suppresses the per-tick SetLevel(4) re-fires during sustained FailureHeat exactly as the port's does (KB says fire-on-change [T1/T2]; not re-verified against disasm this pass)
########## LANE 4 ##########
## ANNUNCIATOR/AUDIO LANE — how stateAlarm 4 becomes (or fails to become) the audible "Generator Out" warning
### 1. The mechanism: state audio is EDGE-FIRED from SetLevel, not polled
Chain (all verified in code + binary):
1. `Generator::stateAlarm` is a `GaugeAlarm54` (the binary's 0x54 alarm, FUN_0041b9ec) at Generator+0x1FC; it IS a StateIndicator: stateCount@+0x0c, oldState@+0x10, currentState@+0x14, three watcher SChains @+0x18/0x2c/0x40 (C:\git\bt411\game\reconstructed\heat.hpp:93-139) [T1].
2. `GaugeAlarm54::SetLevel(n)` (heat.hpp:106): `levelB = level; if (n != level) { level = n; NotifyWatchers(); }` — oldState is updated UNCONDITIONALLY, watchers fire only on a change. Byte-identical to the binary's Set_Alarm_Level @0041bbd8 (`*(p+0x10)=*(p+0x14)` unconditionally; on change, walk the three sockets calling vtbl+0x14 Execute) and to engine `StateIndicator::SetState` (C:\git\bt411\engine\MUNGA\SIMULATE.cpp:156-230) [T1/T0].
3. `AudioStateWatcher` (C:\git\bt411\engine\MUNGA\AUDWTHR.cpp:886-919) registers DIRECTLY on the alarm's +0x18 audio socket (`Cast_Object(StateIndicator*,attributePointer)->AddAudioWatcher(this)`), NOT on any Simulation's audioWatcherSocket. So subsystem-state audio is synchronous with the SetLevel edge and never passes through `Simulation::ExecuteWatchers`. The binding resolves via `ATTRIBUTE_ENTRY(Generator, GeneratorState, stateAlarm)` (C:\git\bt411\game\reconstructed\powersub.cpp:1051); field log confirms real per-generator pointers, no pad redirects (`[attrbind] class=28 subsys=[GeneratorA] attr=[GeneratorState] -> ptr=052DC844`, C:\git\bt411\content\solo_20260729.log:39445) [T2].
4. On a level change, `Execute()` (AttributeWatcherOf<StateIndicator>, C:\git\bt411\engine\MUNGA\WATCHER.h:265-327) compares its StateIndicator COPY (stateCount/oldState/currentState) to the live alarm, then `AudioStateWatcher::SendNotificationOfChange` calls `StateChanged(GetOldState(), GetState())`.
5. `AudioStateTrigger::StateChanged` (AUDWTHR.cpp:1046-1105): fires `ReceiveControl(controlID, controlValue)` when `(!inverseTrigger && new_state==triggerState) || (inverseTrigger && old_state==triggerState)` — i.e. NON-INVERSE = "on ENTERING the state"; INVERSE = "on LEAVING the state". The binary's own StateChanged @0043eb40 (part_005.c:6960-6978, member map from ctor @0043e7d4: trig@+0x80, inv@+0x84, ctlID@+0x88, ctlVal@+0x8c, excl@+0x90/+0x94) is logic-identical, and the manifest attributes audwthr.cpp/.hpp code in the image to the surviving engine source (C:\git\bt411\reference\BT410_SOURCE_MANIFEST.md, "Engine/other TUs ... source survives") [T1].
### 2. THE CORE FINDING: the wrong-edge behavior is AUTHORED IN THE SHIPPED 1995 CONTENT, not a port defect
The shipped BTL4.RES streams exactly TWO AudioStateTriggers per generator on `GeneratorState`, decoded byte-exactly from C:\git\bt411\content\BTL4.RES @0x2be9a-0x2bf2a (GeneratorB records; A/C/D identical) and confirmed by the runtime dump (solo_20260729.log:39445-39448 etc.) [T1]:
- `trigState=1 (GeneratorIdle), inverse=0, control=Start(1)/0` — fires on ENTERING Idle.
- `trigState=4 (GeneratorOut), inverse=1, control=Start(1)/0` — fires on LEAVING GeneratorOut.
There is NO trigger on entering state 4. Both triggers target the SAME non-looped warning sequence (comp=054135C0; `[seqcfg] ... looped=0 div=120 tempo=160 events=15`, notes 29/16/26 = Warnings01 zones z4/z0/z3 per C:\git\bt411\game\reconstructed\audiopresets.cpp:542-549), shared by all four generators. ControlID 1 = StartAudioControlID (C:\git\bt411\engine\MUNGA\AUDIO.h:81-95) [T1].
Cross-checked against the binary's GeneratorSimulation @004b1f7c (reference/decomp/all/part_013.c:9049-9110, matches C:\git\bt411\game\reconstructed\powersub.cpp:1296-1372 line for line) [T1]:
- THERMAL TRIP: heatAlarm==FailureHeat → `SetLevel(4)` from Ready(2) → StateChanged(2,4). Matches NO authored trigger → **the trip edge is silent, in the binary and in the port**.
- RECOVERY: state 4 + heatAlarm==NormalHeat → `SetLevel(0)` (gen on, restart) → StateChanged(4,0) fires the inverse leave-4 trigger → **the warning sequence STARTS on the recovery edge**. (If the gen was switched off: 4→1 fires BOTH triggers — leave-4 AND enter-1.)
- The when-it-DOES-play-at-"trip" cases: generator switched OFF LINE by the pilot (`ToggleGeneratorOnOff` → SetLevel(1), powersub.cpp:1221) and generator subsystem going destroyed/dead (`simulationState==1` → SetLevel(1) each tick, first transition fires) — both are enter-Idle, both play the warning at the moment of loss. Only the THERMAL breaker path (2→4) bypasses Idle and is silent until recovery.
So the port faithfully reproduces the shipped binary+content. Either the era pods really played the voice at thermal-recovery too (and the tester's memory conflates the destruction/switch-off cases, which DO play at loss), or the arcade floor ran a content revision whose GeneratorState triggers differ from this repo's BTL4.RES [T4 — cannot distinguish from the repo].
Authoring-intent evidence AGAINST the shipped config being intended: a census of ALL 118+ authored state triggers (solo_20260729.log statecfg dump) shows inverse triggers are otherwise used almost exclusively as `inv=1 ctl=2 (Stop)` loop-terminators (WeaponState 3-, 0-, AmmoState 3-leave → Stop). The generators' `inv=1 ctl=1 (Start)` rows are the ONLY subsystem-fault inverse-STARTs in the game (the sole other inverse-Start is Entity.AnimationState leave-1, a legitimate play-on-exit). A one-bit authoring slip (inverse_trigger=1 on a trigger meant as enter-4) fits the pattern; the era rule would then be restored by flipping that one authored flag in our stream/decode path — but that is a deviation from shipped content, flagged, not proposed [T1 census / T4 intent].
### 3. DelayWatchersFlag / updateModel — RULED OUT for this bug
- `AudioStateTrigger` never registers with `Simulation::AddAudioWatcher`; only `AudioTriggerOf`/`AudioMatchOf`/`AudioDeltaOf`/`AudioScaleOf` do (AUDWTHR.h:293, 770, 925, 535). `AreWatchersDelayed()` gates ONLY `Simulation::PerformAndWatch → ExecuteWatchers` (SIMULATE.cpp:459-479, mech4.cpp:8377) — the polled watchers. It CANNOT suppress one SetLevel edge and pass another; both trip and recovery notifications ride the same synchronous socket walk. [T0]
- What a stuck DelayWatchersFlag on the Generator sim WOULD kill: the two `GeneratorOn` AudioLogicalTriggers (match 1/match 0 → Start ctl1/0.5 — the polled on/off cue) and the ReportLeak matchers, silently and permanently (the projweap/mislanch `simulationFlags|=1` trap, C:\git\bt411\game\reconstructed\projweap.cpp:461,1049). Nothing in the generator path sets it — the known setters are the weapon-family bugs already fixed, plus the btplayer.cpp:622-642 diagnostic. `updateModel|=1` (ForceUpdate) is the replication dirty mark only; no bearing on watchers. [T1]
### 4. The heat bar / "red portion gone" — the recovery gate ALREADY equals red-gone
- The generator panel's temperature bar is a `VertTwoPartBar` bound (value=CurrentTemperature, low=DegradationTemperature, high=FailureTemperature) — GeneratorCluster ctor @004c746c, C:\git\bt411\game\reconstructed\btl4gau2.cpp:980-998; config `GeneratorCluster(... sgentemp.pcc, 9, 11 ...)` at C:\git\bt411\content\GAUGE\L4GAUGE.CFG:5046 [T1].
- `VertTwoPartBar::Execute` (@004c48fc, C:\git\bt411\game\reconstructed\btl4gaug.cpp:1318-1360): bar spans 0..FailureTemperature; tile bitmap below the warn line (DegradationTemperature), the FILL COLOR segment is drawn ONLY for [warnPix, valPix) — i.e. the colored ("red") portion exists exactly while CurrentTemperature > DegradationTemperature. "Red portion gone" ⇔ T ≤ DegradationTemperature [T1].
- The recovery gate is `heatAlarm.GetLevel()==NormalHeat` (powersub.cpp:1342), and heatAlarm is a pure no-hysteresis threshold ladder recomputed every HeatSinkSimulation tick: T>failure→2, T>degradation→1, else 0 (C:\git\bt411\game\reconstructed\heat.cpp:851-862, @004ad924) [T1].
- Therefore the generator leaves GeneratorOut on the EXACT tick the red portion of its bar disappears — the tester's era rule is already the implemented (and binary-authentic) threshold. The visible lag: leaving 4 enters Starting(0), voltage stays 0 for the authored StartTime spin-up before Ready(2) — so power (voltage bar/weapons) returns startTime seconds AFTER red-gone; that delay is authentic. Whether DegradationTemperature is numerically "half" of FailureTemperature is per-mech authored data in the streamed subsystem resources (not stored as text; not extracted) [T3].
- No-hysteresis corollary: a generator that keeps making heat can duty-cycle across the degradation threshold (trip→cool→restart→re-trip), playing the warning once per RECOVERY — a repeating warning that never coincides with a trip, which plausibly amplified the tester's wrong-edge perception. The "stuck offline forever" non-repro is consistent: exit requires only T ≤ degradationTemperature with the sim ticking; a true brick needs dissipation to be structurally gone (the open-questions "missile-spam FailureHeat brick" entry is about heat delivery, not this audio path).
### 5. Cross-check: alarms that DO play on their trip edge, and what differs
Nothing differs in code — one shared `AudioStateTrigger::StateChanged` serves all; the difference is purely the authored trigger rows:
- Condenser: `trigState=1 inv=0` AND `trigState=2 inv=0` → Start (ctl 1/4) — fires on ENTERING each fault state (trip edge). [T1, statecfg census]
- AmmoBin: enter-3 Start + leave-3 Stop (loop pair, cook-off countdown), enter-4 Start, enter-5 Start — trip-edge Starts, inverse used only as Stop. [T1]
- Reservoir: enter-1 Start. Weapons: enter-N Starts, inverse-Stop loop terminators. [T1]
- Generator alone has its fault state (4) authored ONLY with an inverse (leaving) Start, and its enter-Start sits on Idle(1) instead of Out(4). [T1]
--- KEY FACTS ---
* Subsystem state audio is edge-fired synchronously from GaugeAlarm54::SetLevel/StateIndicator::SetState walking the alarm's own +0x18 audio socket (heat.hpp:106, heat.cpp:96-111, SIMULATE.cpp:156-230, binary @0041bbd8) — it never passes through Simulation::ExecuteWatchers [T1]
* AudioStateTrigger semantics: non-inverse fires on ENTERING triggerState (new_state==trig); inverse fires on LEAVING it (old_state==trig) — engine source AUDWTHR.cpp:1079-1104 [T0], byte-confirmed in the binary @0043eb40 with member map from ctor @0043e7d4 [T1]
* Shipped BTL4.RES authors exactly two GeneratorState triggers per generator: (state 1 Idle, inv=0, Start) and (state 4 GeneratorOut, inv=1, Start) — decoded from BTL4.RES bytes @0x2be9a-0x2bf2a and matching the runtime [statecfg] dump (content/solo_20260729.log:39445-39448); there is NO trigger on entering state 4 [T1]
* Thermal trip (heatAlarm==FailureHeat -> SetLevel(4), Ready 2->4, @004b1f7c == powersub.cpp:1304-1312) matches no authored trigger -> trip edge is SILENT in both the port and the 1995 binary; recovery (4->0 or 4->1) fires the inverse leave-4 Start -> the reported wrong-edge behavior is the shipped content+engine behavior, not a port defect [T1]
* The warning plays at the moment of LOSS only for the enter-Idle paths: pilot switch-off (SetLevel(1), powersub.cpp:1221) and subsystem-dead simulationState==1 (powersub.cpp:1302) [T1]
* Both generator triggers on all four generators target one shared non-looped sequence (notes 29/16/26 = Warnings01 zones z4/z0/z3, audiopresets.cpp:542-549); controlID 1 = StartAudioControlID (AUDIO.h:83) [T1]
* DelayWatchersFlag (simulationFlags bit 0) gates only polled watchers (ExecuteWatchers, SIMULATE.cpp:472-474); it cannot suppress one SetLevel edge and pass another; on a generator it would instead silently kill the polled GeneratorOn on/off matchers [T0/T1]
* Authoring census: inverse triggers elsewhere are almost exclusively inv=1+Stop loop terminators; the generators' inv=1+Start on a fault state is unique among subsystem alarms — consistent with a one-flag authoring slip in the 1995 content [T1 census, T4 intent]
* Generator heat bar (VertTwoPartBar @004c48fc, btl4gau2.cpp:980-998): colored fill exists only while CurrentTemperature > DegradationTemperature, so 'red portion gone' == T <= DegradationTemperature == exactly the existing recovery gate (heatAlarm NormalHeat, heat.cpp:851-862, no hysteresis) — the tester's era rule for the return threshold is already implemented; only the authored StartTime spin-up delays visible power return [T1]
* heatAlarm has no hysteresis, so a heat-producing generator can duty-cycle trip/restart, replaying the warning once per RECOVERY [T1]
--- UNCERTAINTIES ---
* Whether the arcade-floor pods ran a content revision whose GeneratorState audio triggers differ from this repo's BTL4.RES — the era-rule claim (voice at thermal trip) contradicts the shipped authoring we have; cannot be settled from the repo [T4]
* Sample identity of Warnings01 zones z4/z0/z3 (notes 29/16/26) — assumed to be the audible 'Generator Out' voice/klaxon material; not verified by listening [T3]
* Authored DegradationTemperature/FailureTemperature values per mech (whether 'red gone' is numerically HALF-cooled) — baked into streamed subsystem resources, not extracted [T3]
* Replicant-side behavior (whether a passenger/other pod hears the warning on the same edges) — out of this lane, untraced
* No field log with BT_HEAT_LOG active captures an actual trip/recovery temperature pair, so the duty-cycle amplification of the wrong-edge perception is mechanism-derived, not field-observed [T3]
---
## Phase 2 — adversarial verification (two skeptic lanes)
#### VERIFIER 0 ####
ADVERSARIAL VERIFICATION OF #154 "two triggers, no enter-4" — ATTACK FAILED ON THE DECODE; PARTIAL HIT ON THE BEHAVIORAL CLAIM.
== 1. Read order: source vs binary vs port — NO DIVERGENCE [T1] ==
T0 source (engine/MUNGA/AUDWTHR.cpp:974-979): AudioStateTrigger ctor reads triggerState, inverseTrigger, controlID, controlValue, excludeTransition, excludeState, after the base chain reads subsystem CString, attribute CString, sendNotificationOnChange (WATCHER.cpp:60,91,145), dump_value (WATCHER.h:212), audio_component ObjectID (AUDWTHR.h:151). All ints/enums are 4 bytes (STYLE.H:132,137; AUDIO.h:98 AudioControlValue=Scalar float); CString = len(4)+bytes+NUL (CSTR.cpp:353-356).
Binary: ctor @0043e7d4 reads six consecutive 4-byte fields into +0x80..+0x94 after base @0043e738. Field MEANINGS (not just positions) are pinned by StateChanged @0043eb40: +0x80 is the state comparand, +0x84 the inverse boolean (selects old-vs-new comparison), +0x88/+0x8c go to ReceiveControl, +0x90/+0x94 the exclude pair, with the exclude-clause structure exactly mirroring the source. Writer @0043e8a4 emits name-list keys in the same order (trigger_state, inverse_trigger, control_ID, control_value, exclude_state).
The one attack surface the prior analysis never checked — the CALLER's argument order — also holds: SendNotificationOfChange @0043e79c passes (watcher+0x30, watcher+0x34); the snapshot filler @0041bb88 copies indicator +0x0c/+0x10/+0x14 = stateCount/oldState/currentState, so arg1=oldState, arg2=newState, matching the source. Had the caller passed (new,old), the inverse trigger WOULD have fired on enter-4 (audible trip) — this was the only single-fault reinterpretation that could rescue the testers' expectation, and it is excluded by the binary's own code.
== 2. Independent byte re-decode (my parser, from the exe-verified layout, not the port's) ==
Record A @0x02be8f: classID=0x1c, objID=0x033b @0x2be93, "GeneratorB" (len@0x2be97), "GeneratorState" (len@0x2bea6), sendNotif=1 @0x2beb9, dump=0 @0x2bebd, comp objID=0x032f @0x2bec1; trigger fields @0x2bec5: trigState=1 @0x2bec5, inverse=0 @0x2bec9, controlID=1(Start) @0x2becd, controlValue=0.0 @0x2bed1, exclTrans=0 @0x2bed5, exclState=0 @0x2bed9.
Record B @0x02bedd: objID=0x033c, same strings, comp=0x032f; fields @0x2bf13: trigState=4 @0x2bf13, inverse=1 @0x2bf17, controlID=1(Start) @0x2bf1b, controlValue=0.0 @0x2bf1f, exclTrans=0 @0x2bf23, exclState=0 @0x2bf27 (record ends 0x2bf2b — the prior's cited window 0x2be9a-0x2bf2a is these two records).
Structural proof of the layout: my strict parser recovered 2306 valid class-0x1c records file-wide with contiguous record chaining, sane enums, and printable strings — impossible under a shifted/swapped layout. RES-wide census: ALL 144 GeneratorState triggers (4 gens x 36 vehicle pages) are exactly the two shapes (1,inv0,Start) and (4,inv1,Start). No enter-4 anywhere, no Stop, no exclude. The prior analysis's decode is CORRECT.
== 3. State numbering — 0-based, verified against the binary sim and LIVE receipts ==
The binary's generator sim FUN_004b1f7c writes raw levels through SetState @0041bbd8 with no translation: heatAlarm==2 -> SetLevel(4) (thermal breaker); switch cases 0/1/3/4; recovery from 4 -> SetLevel(0) (gen on) or SetLevel(1) (gen off). Authored trigState compares verbatim against these [T1, binary-internal, port-independent].
Calibration: AmmoState authored set {enter-0 Start (feed whir), leave-0 Stop, enter-1 Start, enter-2 Start, enter-3 Start + leave-3 Stop, enter-4 Start/1, enter-5 Start/3} maps 1:1 onto ammobin.hpp levels 0=Feeding..5=Dumped; LIVE receipts in content/genedge.log: edge 0->3 fired enter-3 Start, edge 3->2 fired leave-3 Stop AND enter-2 Start — inverse semantics and 0-based numbering confirmed live. Condenser: authored enter-1/enter-2 Start/4 matches sim writes 1(degraded)/2(severe)/0(normal) (heatfamily_reslice.cpp:1443-1445).
CORRECTION to the prior analysis: its "AmmoBin cook-off enter-3/leave-3" label is WRONG — AmmoState 3 is EJECTING (eject-hold countdown loop); the cook-off warning is gated on FireCountdownStarted match watchers (ammobin.hpp:104-134). The calibration still works; the label must be fixed.
If authored trigState were 1-based: (1,inv0)->enter-Starting(0), (4,inv1)->leave-Shorted(3) — STILL no enter-4. Swapped trig/inverse fields on record B (payload 4,1) -> fire-on-leaving-state-1 — still no enter-4, and it would garble hundreds of other records (AnimationState trig=23 would become inverse=23). The ONLY reinterpretation putting a Start on ENTER GeneratorOut(4) is flipping the inverse semantics (inv=1 = fire on new==trig), excluded at T1 by @0043eb40 + @0043e79c/@0041bb88 and by the live AmmoState receipts (flipped semantics would start the eject loop on RELEASE).
== 4. VERDICT: the decode is SOLID [T1]; the behavioral conclusion is UNDER-EVIDENCED ==
The two-trigger/no-enter-4 authoring is now confirmed by three mutually independent routes (T0 source, binary pseudocode semantics, my own byte decode) plus a live semantics calibration. It is not a parser echo chamber anymore.
HOWEVER, two things keep the prior CONCLUSION short of certain, and give the testers room to be right:
(a) The live trip observation NEVER HAPPENED: content/genedge.log (the #154 bench, run 2026-08-11 06:30) contains ZERO [gen] TRIPPED/RESTARTING lines and zero GeneratorState 2->4 or 4->x [audioedge] edges — the BT_GEN_HEAT injection did not take effect (bench's own verdict: INCONCLUSIVE). "Warning plays on recovery" in the port is still a static prediction. The only generator edges captured live are 0->2 power-ups, which correctly fire nothing.
(b) "Silent by authoring" does not mean "testers hear a consistent nothing at trip": the recovery paths are asymmetric (4->0 fires ONE Start; 4->1 fires TWO Starts — leave-4 AND enter-1 — on the SAME component 0x32f), the GeneratorOn logical match watchers fire Start/0.5 on BOTH edges ([matchcfg] receipts), the 38-watcher ReportLeak warning family is known-dead (AUDIO_FIDELITY.md F6), and genedge.log shows a real delivery quirk: old=3 new=3 same-state deliveries fired BOTH the Start and the Stop of one loop (root: SetState/SetLevel writes oldState unconditionally even on no-change — binary @0041bbd8:5520 does the same, and the polling compare @0041bba8 includes oldState — so it may be authentic, but the port's polling cadence vs the binary's is unverified). Any of these is a credible source of "wrong AND inconsistent" without the RES decode being wrong.
To call it certain: re-run scratchpad/night15/gen_edge_bench.sh with the heat injection actually reaching the sim (verify the BT_GEN_HEAT gate: generatorNumber==1 && viewpoint entity; confirm the EGG's vehicle carries GeneratorA) and capture trip-edge no-fire + recovery-edge FIRED; identify what sample/sequence audio component 0x32f actually plays (warning klaxon vs generator spin-up whine — decides whether the authored intent was ever a "warning" at all); ideally, one original-cab/emulator recording of a thermal trip as behavioral ground truth.
KEY FACTS:
* Read order identical across T0 source (AUDWTHR.cpp:974-979), binary ctor @0043e7d4 (+0x80..+0x94), and the port; field meanings pinned semantically by @0043eb40 (StateChanged) and the writer @0043e8a4
* Caller argument order verified: @0043e79c passes (oldState,newState) — snapshot filled by @0041bb88 from indicator +0x10/+0x14; a swapped order was the only decode fault that could make the trip audible, and it is excluded
* Independent re-decode of BTL4.RES @0x2be8f/@0x2bedd: (trigState=1,inv=0,Start) and (trigState=4,inv=1,Start) on GeneratorB.GeneratorState, comp 0x32f — matches the prior analysis exactly; all field offsets stated (fields @0x2bec5 and @0x2bf13)
* RES-wide census (my parser, 2306 class-0x1c records): all 144 GeneratorState triggers are the same two shapes; no enter-4 trigger exists anywhere in the file
* State numbering is 0-based and raw: binary sim FUN_004b1f7c writes SetLevel 0/1/2/3/4 with 4=thermal breaker; recovery 4->0 (gen on) or 4->1 (gen off); no translation layer anywhere
* Live calibration receipts (content/genedge.log): AmmoState 0->3 fired enter-3 Start, 3->2 fired leave-3 Stop + enter-2 Start — inverse semantics and numbering confirmed at runtime
* PRIOR-ANALYSIS ERROR FOUND: AmmoState level 3 is Ejecting (eject-hold countdown), NOT cook-off; the cook-off klaxon is gated on FireCountdownStarted match watchers (ammobin.hpp:104-134)
* The #154 live bench never observed a trip: genedge.log (2026-08-11) has 0 TRIPPED/RESTARTING lines and no 2->4/4->x edges — BT_GEN_HEAT injection did not take effect; 'recovery fires the warning' is still static-only for the port
* Candidate explanations for 'inconsistent' tester reports that do NOT require a decode error: asymmetric recovery (4->1 double-Start vs 4->0 single), GeneratorOn match watchers firing Start on both edges, dead ReportLeak warning family (AUDIO_FIDELITY.md F6), and live same-state deliveries (old=3 new=3) firing Start+Stop pairs due to the unconditional oldState write in SetState (@0041bbd8, mirrored by port GaugeAlarm54::SetLevel)
UNCERTAINTIES:
* Whether the port's recovery edge actually fires the sequence live (T2 missing): the gen_edge_bench must be re-run with BT_GEN_HEAT actually reaching GeneratorSimulation (the 2026-08-11 run shows zero [gen] lines — injection gate or vehicle/generator naming issue)
* What audio component 0x32f (runtime 054135C0) actually plays — a warning sequence or the generator spin-up/idle sound; this decides whether 'warning plays on recovery' is even the right characterization of the authored intent
* Whether the same-state (old==new) Start+Stop double-delivery seen live is authentic 1995 behavior at the same frequency — the binary has the identical unconditional oldState write and snapshot compare, but the port's polling cadence vs the binary's notify path is unverified
* Whether the 1995 cab sounded anything else at thermal trip via non-AudioStateTrigger paths (heat-alarm audio, gauge alarm streams, cockpit MFD alerts) — outside this decode's scope but inside the testers' perception
* GeneratorState level-name labels (0=Starting,1=Idle) are best-effort [T3] per powersub.hpp — the numbering is certain, the English names are not
#### VERIFIER 1 ####
VERDICT: The prior analysis's AUTHORING DECODE SURVIVES verification, but its CONCLUSION ("thermal trip silent, warning on recovery, therefore behavior is consistent-by-authoring") is REFUTED on three grounds: (1) it was never runtime-verified — the #154 bench (scratchpad/night15/gen_edge_bench.sh -> content/genedge.log) contains ZERO "[gen] TRIPPED"/"RESTARTING" receipts, so no trip or recovery edge was ever observed live; the recovery-edge claim is code-derived [T1], not [T2]; (2) the enter-Idle trigger makes the SAME 3-tone sequence fire on at least five different game events with opposite meanings, four of them not mentioned by the prior analysis; (3) three field-measured port mechanisms make even correctly-fired warnings intermittent.
== 1. THE COMPLETE EVENT->SOUND TABLE (generator warning sequence = AudioControlSequence, 3 notes 29/16/26 = Warnings01_z4(loop)/z0/z3, ~3.25 s at div=120 tempo=160; ONE sequence component SHARED by all four generators A-D — solo_20260729.log:39452-39476, all four statecfg rows target comp 054135C0) ==
Authored triggers per generator (verified in both logs, excl=0/0 so NO exclude-transition clause): T-A trigState=1 inv=0 ctl=Start/0 (fires on ANY entry to state 1) and T-B trigState=4 inv=1 ctl=Start/0 (fires on ANY exit from state 4). GeneratorState enum [T1, powersub.hpp:377-388]: 0=Starting 1=Idle 2=Ready 3=Shorted 4=Out.
- Mission start / ResetToInitialState (SetLevel(0);SetLevel(2), powersub.cpp:1269-1270): edges (x,0),(0,2) -> SILENT (bench-confirmed: 4x "old=0 new=2 ... no-fire" in genedge.log).
- THERMAL TRIP 2->4 (powersub.cpp:1341, per-frame while heatAlarm==FailureHeat): SILENT. Prior analysis correct on this edge. But outputVoltage collapses -> !HasVoltage status bit (stateAlarm==4 read, powersub.cpp:1836) -> masterAlarm/MechTech + gotoEngineering gauge invite CAN sound at trip [T3 — masterAlarm edges are live in the bench log but the specific generator condition->voice line was not traced]. Testers hearing a VOICE at trip and the TONE 10-60 s later at recovery = "timing feels wrong".
- THERMAL RECOVERY 4->0 (generatorOn==1, powersub.cpp:1384): T-B FIRES — warning tone announces power RETURNING. One tone per generator recovery; four generators trip/recover STAGGERED, and each Start on the shared sequence STOPS and RESTARTS it from t=0 (AUDSEQ.cpp:409-412 StartSequence stops-if-running; StopSequence chases pending Stops, cutting the current note) -> stuttered/truncated warnings whose count varies with heat spread. A player riding the heat edge gets a periodic tone on EVERY trip/recover duty cycle — always at the recovery, never at the loss.
- RECOVERY WHILE SWITCHED OFF 4->1 (powersub.cpp:1376): BOTH T-A and T-B fire — double Start same frame (second restarts the sequence).
- PILOT TOGGLES GENERATOR OFF (ToggleGeneratorOnOff, powersub.cpp:1221, byte-matched to @004b1ed0): x->1, T-A FIRES — the "generator out" warning plays for a deliberate heat-management action (authentic, but a tester counts it as spurious). Toggle ON: SetLevel(0) only if heat healthy — silent; if heat-degraded, NO state write at all (silent, state stays at fault).
- GENERATOR CRIT-DEATH: no direct SetLevel on the crit path (BTGeneratorMarkOut has ZERO callers — the powersub.cpp:483 method is documentation-only; hpp:471 says the ForceCriticalFailure bridge was removed), BUT GeneratorSimulation's pin `if (simulationState==1) SetLevel(GeneratorIdle)` (powersub.cpp:1330-1332, authentic @004b1f7c) fires T-A on the next tick: 2->1 (or 4->1 = DOUBLE fire if it died tripped). So a generator LOSS by crit DOES play the warning — one frame late — directly contradicting "loss is silent by authoring".
- SHORT 2->3 and short-recovery 3->2 (powersub.cpp:1422,1365): SILENT both ways.
- RESPAWN WITH A TRIPPED GENERATOR: ResetToInitialState SetLevel(0) delivers (4,0) -> T-B FIRES the warning right at respawn; dying un-tripped respawns silent. Inconsistent respawn audio depending on how you died. [T1 code-read, unbenched]
- GeneratorOn POLLED MATCHERS (AudioLogicalTrigger, solo log: match=1 -> ctl1(Start)/0.5 and match=0 -> ctl1(Start)/0.5, per generator): generatorOn is init 1 and written ONLY by the toggle (powersub.cpp:1214,1222) and reset (=1). Thermal trip does NOT touch it. So: bind-time prime fires the match=1 Start (spawn hum — every AudioMatchOf subclass ctor calls PrimeWatcher, and AudioMatchOf::SendNotificationOfChange has no edge memory: it fires whenever notified AND equal, AUDWTHR.h:818-848); manual OFF fires the match=0 Start (a second sound stacked on the T-A warning). Target components unidentified (matchcfg does not print comp) — [T4 what they sound like].
== 2. INTERMITTENCY MECHANISMS (the "inconsistent" the testers report) ==
A. VOICE STEAL / TRUE DROP AT COMBAT PEAK [T2, field-measured]: L4AUDRND.cpp:1287-1409 — on channel exhaustion the incoming Start steals only from LOWER-weight running sources (transient victims are STOPPED mid-play, sustained SUSPENDED); if nothing outranks, the Start is genuinely dropped (gBTAudioTrueDrops). The 2026-08-10 field logs show up to 932 steals and 22-30 acquire-failure prints per session (scratchpad/night15/steam_20260810_f_eleng_ALIA.log). Heat trips correlate exactly with peak firefight + heat klaxon + MechTech voices, so the 3-note warning's Starts land in the most contended window: any of its three notes can be stolen mid-sequence or dropped. AUDIO_FIDELITY.md F3 adds that steal/cull/ducking are DISTANCE-BLIND in the port (AL linear model), further randomizing which source loses.
B. TRANSIENT-START VOLUME CULL: AUDREND.cpp:236-251 silently discards any Start on a transient source whose volume scale < LowAudioVolumeThreshold (0.3), and AUDREND.cpp:175-193 discards Starts on clipped transient sources entirely. Whether the cockpit warning target (seq tgt comp) is transient was not determined [T4]; if it is, this is a second silent-drop gate.
C. BIND-TIME PRIME FIRES TRIGGERS [T2, bench-observed]: AudioStateTrigger ctor calls PrimeWatcher() (AUDWTHR.cpp:989) -> GrabCurrentValue -> StateChanged(alarm.oldState, alarm.currentState) at bind. Combined with the GaugeAlarm54::SetLevel oldState-clobber (heat.hpp:106 — `levelB = level` runs even when the level does NOT change, so any state that is re-set per frame reads old==new), a trigger bound while its alarm sits AT trigState fires BOTH polarities immediately: genedge.log lines 878/881 show "[audioedge] old=3 new=3 trig=3 inv=0 ... FIRED" AND "inv=1 ... FIRED" during a mid-run mech build. For generators this is benign at normal spawn (state 2) but fires the warning on any bind/rebuild that catches state 1 or 4.
D. THE T3 BRING-UP GUARD CAN SILENTLY UNPLUG A GENERATOR'S STATE AUDIO: AUDWTHR.cpp:893-917 skips AddAudioWatcher when the bound alarm's +0x18 socket reads 0/0xCDCDCDCD — permanently silent for that subsystem, logged only under BT_AUDIO_LOG. GeneratorState binds with vtbl=00000000 (raw GaugeAlarm54 header) in every log; registration currently succeeds (edges were delivered in the bench), but the guard makes generator audio construction-order-dependent — a latent per-mech/per-build inconsistency [T3].
E. DelayWatchersFlag: gates ONLY the polled watchers (Simulation::Simulate, SIMULATE.cpp:472; mech4.cpp:8377) — i.e. the GeneratorOn/ReportLeak matchers — NOT the state triggers (those are called directly from the alarm's NotifyWatchers, heat.cpp:96-111). RequestEncore sets it (SIMULATE.cpp:727) and only the encore path clears it (UPDATE.cpp:213-215). The historical bug (death path setting bit 0, permanently muting a player's polled audio) is fixed and canary-guarded (btplayer.cpp:635-643). No current generator-warning exposure, but a regression would kill the GeneratorOn sounds while leaving the state warning alive — a plausible past contributor to "inconsistent" reports depending on build vintage.
== 3. CROSS-POD / REPLICANT ==
Replicant mechs DO build audio objects (interest teardown destroys them — context/multiplayer.md interest-teardown crash section [T2]), and subsystems DO tick on replicants when executable (#148 instance branch, mech4.cpp:8077-8104). Whether a replicant's Generator ever reaches state 1/4 locally depends on whether heat state feeds its sim — per-subsystem heat is not replicated (damage is master-side only per the #45 note), so remote generator warnings should not track the remote pilot's actual thermal state [T3]. Remote sources are also subject to the clip/volume gates, so at typical ranges they are inaudible. An observer/passenger riding a REPLICANT viewpoint would NOT hear that mech's authentic generator warnings (its master simulates elsewhere) [T3].
== 4. WHAT SURVIVES OF THE PRIOR ANALYSIS ==
The RES decode itself is confirmed at runtime: exactly two AudioStateTriggers per generator, trigState=1/inv=0/Start and trigState=4/inv=1/Start, excl=0/0, both targeting the shared 3-note sequence (solo_20260729.log:39445+ and genedge.log both match). "The trip edge 2->4 fires nothing" is true [T1]. Everything downstream of that — "therefore silent-by-authoring at loss, warning on recovery, consistent" — is refuted: crit-loss sounds (via the sim-state pin), manual OFF sounds, respawn-after-tripped sounds, recoveries stutter-restart a shared sequence, and the delivery/rendering pipeline drops or truncates the sound under exactly the conditions in which trips happen. RECOMMENDED NEXT STEP: re-run gen_edge_bench (it never tripped — zero [gen] receipts; verify the BT_GEN_HEAT injection actually reaches the viewpoint Generator A and the build contains the injection block), and add a crit-death + toggle + respawn leg to capture the T-A firings live.
KEY FACTS:
* The #154 bench log content/genedge.log contains ZERO '[gen] TRIPPED' or 'RESTARTING' receipts — the thermal trip/recovery edge mapping was NEVER observed live; the prior conclusion is code-derived [T1], not runtime-verified
* All four generators (A-D) share ONE warning sequence component (statecfg comp=054135C0 in solo_20260729.log:39452-39476); AudioControlSequence::StartSequence stops-and-restarts if already running (AUDSEQ.cpp:409-412), so staggered per-generator recoveries stutter/truncate the warning
* GeneratorState enum: 0=Starting 1=Idle 2=Ready 3=Shorted 4=Out (powersub.hpp:377-388); authored triggers: enter-1 Start and leave-4 Start, no exclude clauses (excl=0/0)
* The enter-Idle trigger fires the SAME warning on: generator crit-death (simulationState==1 pin, powersub.cpp:1330-1332), pilot toggling a generator OFF (powersub.cpp:1221), and recovery-while-off 4->1 (double-fire with the inverse trigger)
* ResetToInitialState does SetLevel(0);SetLevel(2) (powersub.cpp:1269-1270): a mech that died with a tripped generator (state 4) fires the warning AT RESPAWN via the 4->0 edge; dying un-tripped respawns silent
* GaugeAlarm54::SetLevel (heat.hpp:106) clobbers oldState unconditionally (levelB=level even when unchanged) and notifies only on change; per-frame same-level SetLevel therefore makes old==new at the alarm
* Every concrete audio trigger ctor calls PrimeWatcher() at bind, which delivers StateChanged(old,cur) unconditionally; bench lines show old==new deliveries that FIRED both trigger polarities (genedge.log '[audioedge] old=3 new=3 trig=3 ... FIRED' for inv=0 AND inv=1) — a trigger bound while its alarm sits at trigState fires spuriously
* Voice contention is live in current builds: 2026-08-10 field logs show up to 932 voice steals and 22-30 acquire failures per session (scratchpad/night15/steam_20260810_f_eleng_ALIA.log); the steal loop stops transient victims mid-play and true-drops unrankable Starts (L4AUDRND.cpp:1287-1409)
* Transient Starts are silently discarded when clipped or below LowAudioVolumeThreshold=0.3 (AUDREND.cpp:175-251), and the cull is distance-blind (AUDIO_FIDELITY.md F3)
* The AudioStateWatcher bring-up guard (AUDWTHR.cpp:893-917) silently skips watcher registration on an unconstructed alarm socket — construction-order-dependent permanent silence for that subsystem's state audio [T3 latent]
* DelayWatchersFlag gates only POLLED watchers (GeneratorOn/ReportLeak matchers), never the alarm-socket state triggers; the historical death-path bit-0 bug is fixed and canary-guarded (btplayer.cpp:635-643)
* GeneratorOn is written ONLY by the pilot toggle and reset — a thermal trip does NOT clear it; its two matchers both issue Start (ctl1/0.5): match=1 at bind/toggle-ON, match=0 at toggle-OFF
* BTGeneratorMarkOut has zero call sites — generator crit-death does NOT SetLevel(4); the warning at crit-death comes from the simulationState==1 Idle pin one tick later
* Thermal trip zeroes outputVoltage -> !HasVoltage status bit (stateAlarm==4, powersub.cpp:1836) -> masterAlarm/MechTech + gotoEngineering invite can sound AT TRIP while the tone sequence sounds at RECOVERY — a perception split for testers
UNCERTAINTIES:
* Why the bench never tripped: BT_GEN_HEAT=50000 for 25 s should exceed FailureTemperature — either the injection block wasn't in the running build, the viewpoint-entity/generatorNumber gate didn't match, or the run window missed; needs a re-run before ANY edge claim is called verified
* Whether the warning sequence's target source (tgt comp, e.g. 0540FA18) is TransientAudioRenderType — determines exposure to the volume-cull and clip drop gates (AUDREND.cpp:175-251)
* The exact source of the REPEATED old==new deliveries to the same comps in genedge.log (lines 814/978/1030): interpreted as bind-time primes of successive identical weapons sharing sequence comps during a mid-run mech build; not traced to completion — an unidentified re-prime/re-execute path would widen mechanism C to mid-life spurious warnings (including old=4,new=4 firing the warning DURING a trip)
* What the GeneratorOn matchers' Starts (ctl1/0.5) actually sound like — [matchcfg] does not print the target component; needs a matchfire capture or RES decode of those pages
* Whether a MechTech VOICE line specifically fires on the generator !HasVoltage condition at trip time in the port (masterAlarm is live, but the condition->voice mapping for generator-out was not traced) [T3]
* Whether replicant Generator subsystems ever reach state 1/4 in MP (per-subsystem heat is believed master-side only) and whether their warning sequences route to spatialized sources or a center-channel path — the passenger/observer-seat audio experience is unaudited
* Whether destroyed generators keep ticking GeneratorSimulation long enough for the simulationState==1 pin to deliver its edge in every case (DoNothingOnce/NeverExecute latching order at subsystem death)
* Whether any encore/re-stream path rebinds audio pages mid-round (which would prime-fire triggers against in-state alarms); NotifyOfReplacementEntityCreation is Fail('under construction') so late re-creation may be impossible today
+1 -1
View File
@@ -339,7 +339,7 @@ cavern.egg`. This is every option the original console exposed for a BattleTech
- experience (4 [T1 game decode]): novice, standard, veteran, expert ← was hardcoded `expert` - experience (4 [T1 game decode]): novice, standard, veteran, expert ← was hardcoded `expert`
- vehicle (18, friendly→tag): Black Hawk=blkhawk, Loki=loki, Nova=bhk1, Mad Cat=madcat, Thor=thor, - vehicle (18, friendly→tag): Black Hawk=blkhawk, Loki=loki, Nova=bhk1, Mad Cat=madcat, Thor=thor,
Owens=owens, Kotori=own1, Summoner=thr1, Hellbringer=lok1, Timber Wolf=mad1, Avatar=avatar, Owens=owens, Kotori=own1, Summoner=thr1, Hellbringer=lok1, Timber Wolf=mad1, Avatar=avatar,
Satyr=ava1, Sunder=sunder, Denkou=snd1 (#69), Vulture=vulture, Mad Dog=vul1, Mischief=lok2, Satyr=ava1, Sunder=sunder, Sunder V1=snd1, Vulture=vulture, Mad Dog=vul1, Mischief=lok2,
Zanin Neko=mad2 (the 18 ModelList names; matches the 2026-07-18 vehicle sweep) Zanin Neko=mad2 (the 18 ModelList names; matches the 2026-07-18 vehicle sweep)
- color/camo (7, display→tag): White=White, Black=Black, Brown=Brown, Red=**Crimson**, Green=Green, - color/camo (7, display→tag): White=White, Black=Black, Brown=Brown, Red=**Crimson**, Green=Green,
Gray=**Grey**, Tan=Tan ← was White/Black/Crimson only Gray=**Grey**, Tan=Tan ← was White/Black/Crimson only
-478
View File
@@ -1,478 +0,0 @@
# The K/D-score chain audit -- full findings (2026-08-11, ticket #162)
> The five-agent authority-model audit that settled the kill/death/score chain
> once and for all. CURATED digest: `context/combat-damage.md` section "The
> K/D-score AUTHORITY MODEL". This file is the complete evidence record --
> every dispatch, handler arm, wire layout, replication fact, and field-receipt
> correlation, with decomp line cites. Read it before touching ANY limb of the
> scoring chain. The mandatory regression gate is scratchpad/night15/kd_bench.sh
> (five hard invariants).
>
> Fixes shipped from this audit (4.11.885, commit 3545056): death tail
> master-gated; #81 latch-clear moved to the binary's DropZoneReply site; steam
> eggs stamp advancedDamage mission-wide; #38 badge/patch ride the lobby.
# Task 5 — #150 fix archaeology + bench coverage hole
## 1. Exactly what 46ff8dd changed (the #150 fix, 2026-08-10 10:30, "4.11.87x" era)
Commit `46ff8dd262b20a08334d44338e69fc686a6c7958` touched only `game/reconstructed/mech.cpp` (+28/-1) and added `scratchpad/night14/scorepair.sh` (new, 30 lines). All changes are inside `Mech::TakeDamageMessageHandler`.
**Before (pre-fix state of the report tail):**
- The call to `BTMechPostCombatReports(this, message, damageTally, zoneDestroyed, reportZone, newly_destroyed)` ran **unconditionally** — on BOTH master and replicant instances of the victim mech (the port deliberately applies damage on replicants for local visual response, and the report tail rode along).
- The `newly_destroyed` argument was computed as `deathBlastArmed && IsMechDestroyed()` — a movement-mode-derived flag, not an entry/exit edge.
**After (the two binary-cited gates):**
1. **Master-only gate** (mech.cpp:1270 today): `if (GetInstance() != ReplicantInstance) { BTMechPostCombatReports(...); }`. Grounded in the binary's opening assertion `if ((flags & 0xc) == 4) Fail("Replicant Mech recieving takedamage", MECH.CPP, 0x3da)` [part_012.c:14613] — in 1995 the tail could only ever run on the victim's master. Local damage application on replicants stays.
2. **destroyedAtEntry latch** (mech.cpp:1039 today): `const int destroyedAtEntry = IsMechDestroyed();` sampled **at handler entry**, and the last argument became `!destroyedAtEntry && IsMechDestroyed()` — the binary's `local_14` 0->1 edge across this handler call (part_012.c:14625-14629). A later salvo of the killing volley (arriving after the mech is already destroyed) takes the inflicted-only path.
**What the latch gates vs what it does NOT gate (verified against current mech.cpp):**
- It gates **only the `newly_destroyed` argument**, i.e. block A (the type-2 KILL report to the shooter's player) inside `BTMechPostCombatReports` (btplayer.cpp, `if (newly_destroyed)` branch).
- It does **NOT** gate block B (type-0 inflicted report) or block C (type-1 received report) — post-death salvos on the master still emit those.
- It does **NOT** gate the **death_tail** at mech.cpp:1335-1343: `if (deathBlastArmed && IsMechDestroyed()) { BTMechPostVehicleDead(...); BTApplyDeathSplash(...); }` — this still uses the OLD pre-latch predicate, and **the master-only gate does not cover it either** (it sits outside the `GetInstance() != ReplicantInstance` block). `BTMechPostVehicleDead` is the dispatcher of the VehicleDead message whose `deathCount == -1` branch in `BTPlayer` does `++deathCount` / `++deathTally`, logs `PLAYER_DEAD`, and applies the -500 death cost (btplayer.cpp:582-736). **[Inference, flagged]** This ungated, un-latched VehicleDead dispatch is the natural suspect for #162's deathCount multi-increment — 46ff8dd hardened the score reports and left the death notification on the old predicate.
## 2. Other recent score/death/penalty changes in btplayer.cpp (last 3 weeks)
`git log` on `game/reconstructed/btplayer.cpp` (all within 2026-07-25 → 2026-08-08):
- **a52207d / 48d47ef (07-25, #45):** reclaimed the binary's DEATHS field (`deathTally` @+0x280) as the displayed/replicated counter, distinct from the engine's respawn-handshake `deathCount` (@+0x200, seeded -2); PLAYER_DEAD matchlog row logs BOTH (`deaths=` and `tally=`).
- **a357dc4 (07-30, #81 ghost):** released the stuck `deathPending` death latch + stopped a duplicate VehicleDead. Note: `deathPending` (btplayer.cpp:546-559) is the ONLY dedup on the death cycle — a death arriving while a cycle is pending is SWALLOWED with a warning.
- **1efe8ef (08-02, #118):** restored `SpecialCaseDeathPenalty` (role+0x20) and its consumer: every death with `advancedDamageOn` hands the ENGINE base `Player::ScoreMessageHandler` a type-1 award of `-penalty` via a **DIRECT base call** — deliberately bypassing BT dispatch, therefore **structurally invisible to the matchlog** (btplayer.cpp:726-736 today).
- **91bd286 (08-05, #45/#134):** the big one — reconstructed the authentic report tail `BTMechPostCombatReports` (blocks A/B/C @0x4a02f4-0x4a0890) + `BTMechPostVehicleDead` (@0x4a07d4); retired the bring-up producers `BTPostDamageScore`/`BTPostKillScore`. This is what moved report emission into the victim's TakeDamage handler — the code 46ff8dd later had to gate.
- **2772175 (08-07):** restored the type-0 Dispatch-override interceptor (`BTPlayer::ScoreInflictedMessageHandler` @004c0200) — per-hit inflicted credit was real 1995 behavior.
- **e82f54c (08-07):** finding-only + a console-flush watermark; two recorded wrong turns.
- **1324c81 (08-07):** inflicted credit lands on the OWNER's machine — Dispatch intercepts type 0 on a MASTER only; the type-0 arm of ScoreMessageHandler DELEGATES (wire deliveries go straight to the handler table, never the virtual Dispatch).
- **b2498ca (08-07):** the delegating type-0 arm must `return`, not `break` — the fall-through clobbered `scoreAward` to 0 and ran the base handler again (a duplicate-row generator, then harmless-by-luck).
- **a4bfb64 (08-07):** BOUND `scenarioRole` (was NULL forever — one commented-out lookup). This is what made deathPenalty=500 / killBonus=500 real; it also established that **-500 on an ordinary combat death is authentic** (gate = `advancedDamageOn` alone, @004c05c4 tail).
- **e0b91df (08-07):** correction — the death cost was never missing; it is invisible to the matchlog because of the direct base call. Added the ungated `[deathcost] ... -> APPLYING/SKIPPED` DEBUG_STREAM receipt (btplayer.cpp:716-724). Explicit lesson recorded: "a value that cannot appear in the log you are reading is not evidence of absence."
- **29b4d68 (08-07):** the +1000 MissionStarting seed grant. **5b7e481 (08-08):** mech+0x354 vestigial label, no behavior change.
## 3. The bench (scorepair.sh) — what it verified and what it could not see
`scratchpad/night14/scorepair.sh` (added by 46ff8dd): 2 nodes — B unarmed respawning target (player 3:1), A autofire missile killer that panic-ejects on a timer (player 2:1), relay via btconsole.py, 300 s. Its RESULT section is three checks:
1. `grep -ac "Mech::Reset" sc_b.log` — a count of B's resets (proxy for "kills A should have earned").
2. `grep -ah "SCORE\|KILL\|EJECT\|DEATH" matchlog_*.txt | head -25` — the score sequence.
3. `grep -a "scorepost" sc_a.log / sc_b.log`**"scorepost" matches NOTHING in the codebase** (grep over game/ + engine/ returns zero hits); these two checks were dead on arrival and always print empty.
**What the captured receipts actually show:**
- `scratchpad/night14/sc.out` (02:56, pre-fix run) and `sc2.out` (10:28, the run cited in the commit): in BOTH, the `head -25` was exhausted by per-hit noise rows (type=0 award≈1.97 spam in sc.out; the victim node's `type=1 award=-0.00` spam in sc2.out) **before a single KILL / type=2 / DEATH row appeared**. The .out receipts therefore contain zero evidence about kills, deaths, or penalties. The commit's verified numbers ("kills 0->1->2, zero doubles, -900/-1050") match the raw matchlogs, so the verification was done by out-of-band manual matchlog reading, not by the bench's printed result.
- **PLAYER_DEAD is matched by none of the bench's grep patterns** — "DEATH" does not substring-match "PLAYER_DEAD". The death counter was never surfaced, let alone compared against real deaths.
- **`award=-500` can never appear in any matchlog** — the death cost is a direct `Player::ScoreMessageHandler` base call bypassing BTMatchLog (btplayer.cpp:712-715 says so explicitly; e0b91df is the commit that learned this). The only receipts are the `[deathcost]` DEBUG_STREAM lines in the node logs — which the bench never greps. (They DO exist in the post-fix run: `content/sc_a.log` has 4× `[deathcost] player 2:1 ... penalty=500 ... -> APPLYING`, `content/sc_b.log` has 2× for player 3:1 — so **at fix time the -500 was applying on both nodes**, provable only by artifacts the bench never printed.)
**The smoking gun in the pre-fix artifacts:** `scratchpad/night14/mlbak/matchlog_20260810_0250*.txt` (the run that reproduced #150) already contains the #162 death-side signature, in the same file as the double kill:
```
PLAYER_DEAD t=1827953 w=02:53:58.090 st=5 player=3:1 deaths=1 tally=1
PLAYER_DEAD t=1827953 w=02:53:58.091 st=5 player=3:1 deaths=2 tally=2
```
— two deathCount increments 1 ms apart for ONE death, immediately adjacent to the double kill rows (`type=2 award=501.96 kills=1` / `award=505.88 kills=2`, also 1 ms apart) that #150 was chasing. Post-fix (10:24 run, `content/matchlog_20260810_102334_1912.txt`) PLAYER_DEAD rows read singly (deaths=1 then deaths=2, minutes apart), so in THIS bench topology the fix incidentally quieted the double too — but nothing asserted it, and no one looked at the death side of either artifact.
## 4. The precise coverage hole
1. **The bench asserted only the kill side, and only manually.** Its scripted receipts were (a) a respawn count on the victim, (b) a `head -25` matchlog slice that noise-truncated before any kill/death row in both captured runs, and (c) two greps for a log tag (`scorepost`) that does not exist. Nothing scripted compared type-2 count vs death count, PLAYER_DEAD count vs actual deaths, or score totals vs expected arithmetic.
2. **PLAYER_DEAD was un-greppable by the pattern used** ("DEATH" ≠ "PLAYER_DEAD"), so death-count duplication — already present and visible in the pre-fix artifact — was structurally invisible to the bench.
3. **The -500 penalty is invisible to the matchlog by design** (direct base-handler call), and the bench never grepped the `[deathcost]` DEBUG_STREAM receipts or computed total-arithmetic deltas — the only two ways to see it. A regression that silenced the penalty would print an identical bench RESULT.
4. **The fix itself is asymmetric** and the bench could not detect the asymmetry: 46ff8dd's master-only gate + entry latch cover only `BTMechPostCombatReports` (mech.cpp:1270-1276); the death_tail's `BTMechPostVehicleDead` (mech.cpp:1335-1343) — the sole driver of `++deathCount`/`++deathTally`/PLAYER_DEAD and the death-cost block — kept the old `deathBlastArmed && IsMechDestroyed()` predicate, ungated by instance and un-latched. **[Inference]** That is the seam where #162's death-side breakage shipped: the kill side got both gates and a (manual) receipt; the death side got neither a gate nor a single line of bench assertion.
==============================================================================
# TASK 1 — Binary truth: the report tail of `Mech::TakeDamageMessageHandler` @004a0230
Function: `FUN_004a0230` (`reference/decomp/all/part_012.c:14474-14837`, export tag `file=bt/mech.cpp`), bound as **Mech entity message id 0x12 TakeDamage** (`context/decomp-reference.md:265`). All claims below are [T1] read from this export unless flagged.
## 0. Entry protocol (context for the tail)
- **Replicant assert** — part_012.c:14613-14616: `(mech+0x28 & 0xc) == 4``Fail("Replicant Mech recieving takedamage", MECH.CPP, 0x3da)`. The handler body runs ONLY on the victim's master node.
- **Gyro bounce** — :14617-14624 (`mech+0x528``FUN_004b2980(gyro, msg+0x2c damageType, msg+0x30 amount)`); no messages.
- **THE LATCH** — :14625-14629: `local_14 = (FUN_0049fb54(mech) != 0)` = destroyed-at-ENTRY. `FUN_0049fb54` @0049fb54 (part_012.c:14174-14188) returns 1 iff `mech+0x40 (mode) == 2 || == 9`. **Note it does NOT count mode 10** (see §3 gate asymmetry).
- **lastInflictingID stamp** — :14631: `mech+0x43c ← msg+0x1c` (inflictor EntityID).
- **Party resolve** — :14630-14635: registry-find shooter entity by `msg+0x1c`; `local_1c = *(shooter+0x190)` = **SHOOTER's player**; `local_20 = *(mech+0x190)` = **VICTIM's own player**. Both resolved BEFORE the collision divert.
- **COLLISION DIVERT** — :14636-14639: `msg+0x2c (damageType) == 0``FUN_0049ffcc(mech, &msg+0x2c)` then **`goto LAB_004a07b5`** — jumps straight to the death tail (binary `jmp @0x4a0375 → 0x4a07b5`, `context/combat-damage.md:598-601`). Ram/wall damage therefore emits **NO id-0x16 report of any type** but STILL posts VehicleDead/blast if it killed. `FUN_0049ffcc` @0049ffcc (part_012.c:14374-14430) applies scaled internal-subsystem damage (gated `victimPlayer+0x268 != 0`, random subsystem via `FUN_004ac07c`) and **sends no messages**.
- **Damage loop** — :14655-14684: `local_28` (tally) += per-burst applied `msg+0x30` **plus** each critically-destroyed subsystem's value `*(subsys+0x108)` (:14669); `local_30` (vitalHit) = 1 when the struck zone's destroyed flag `zone[9]` goes 1 (:14674-14676); zone re-rolled per burst (:14680); loop breaks when bursts (`msg+0x58`) exhaust OR the mech becomes destroyed mid-volley (`FUN_0049fb54`, :14683). The do-while shape means **the first burst always applies, even on a corpse**, so tally can be nonzero when `local_14 != 0`.
All dispatches below are `(**(code**)(*player + 0xc))(…)` — vtbl slot 3 = `Entity::Dispatch`, which reroutes over the wire when the target player is a replicant on this node (engine/MUNGA/ENTITY.cpp:244-251 [T0], cited in `docs/KD_SCOREBOARD_PLAN.md:86`).
## 1. Wire layout of the id-0x16 report (ScoreMessage, sizeof 0x3C) [T1, static_assert-locked per decomp-reference.md:290-293]
`{+0x00 size 0x3C, +0x04 id 0x16, +0x08 =1, +0x0c destID null (DAT_00522524), +0x14 =0, +0x18 =0, +0x1c scoreAward, +0x20 type, +0x24 basis, +0x28 vitalHit, +0x2c zone, +0x30 inflictingSubsystemID (=msg+0x5c, engine T0 name), +0x34 senderMechID}`
## 2. The report block — three mutually structured id-0x16 dispatches
### local_14 == 0 (ALIVE at entry) — part_012.c:14685-14709
Re-tests `FUN_0049fb54` (:14686). If **destroyed NOW** (the alive→dead edge of THIS volley):
**Report A — type 2 KILL → SHOOTER's player** (:14688-14708; binary @0x4a04da, `combat-damage.md:605-608`; the KD plan calls this edge @0x4a052b-0x4a05ce, `KD_SCOREBOARD_PLAN.md:164`):
- `+0x1c scoreAward` = applied tally `local_28` (:14693,14700)
- `+0x20 type` = **2** (:14702)
- `+0x24 basis` = `*(*(victimPlayer+0x208) + 0x1c)` (:14691) = the **VICTIM role's killBonus** (role+0x1c; = 500 in shipped `Role::Default`) — this is the `@0x4a0506` killBonus read (`decomp-reference.md:477-478`)
- `+0x28 vitalHit` = `local_30`; `+0x2c zone` = `msg+0x24` (loop-ENTRY zone); `+0x30` = `msg+0x5c`; `+0x34 senderMechID` = **the VICTIM's own mech ID** (`mech+0x184`, :14692,14704)
- Dispatch :14708 → `local_1c` = shooter's player. On the victim's node that object is a **replicant**, so Dispatch reroutes it over the wire to the killer's master — where `BTPlayer` 0x16 `Score@0x4c02e4` case 2 runs CalcKillScore × (senderTonnage/ownTonnage), `killCount@0x27c++` (dual — the 1995 phantom partner increment), self-kill `fchs` negation (`decomp-reference.md:280-284`).
- Note: A and B are **exclusive** — a killing hit emits NO type-0.
If NOT destroyed now → `goto LAB_004a05d9` (falls into the same block the already-dead path uses):
**Report B — type 0 DamageInflicted → SHOOTER's player** (`LAB_004a05d9`, :14711-14734; @0x4a05d9):
- Gate: `local_28 != 0.0f` (the constant `_DAT_004a0c14` is a 0.0f literal parked after function end)
- `+0x1c scoreAward` = tally; `+0x20 type` = **0** (:14727); `+0x24 basis` = tally again (:14716,14728); `+0x28/2c/30` as above; `+0x34 senderMechID` = victim's mech ID (:14717,14729)
- Dispatch :14733 → shooter's player. Receiver side: `BTPlayer::Dispatch` override (`@004bffa0`, vtbl @00513300 slot 3) splits type 0 off to `@004c0200 ScoreInflictedMessageHandler` before base dispatch (`combat-damage.md:609-623`).
### local_14 != 0 (ALREADY DESTROYED at entry)
Enters directly at `LAB_004a05d9` → emits **Report B (type 0) if tally ≠ 0**. The believed "inflicted-only" is **half right**: the latch suppresses only the type-2 kill and the death tail — **Report C below still fires too**, because it sits OUTSIDE both branches.
### Report C — type 1 DamageReceived → VICTIM's OWN player (:14736-14759; @0x4a06c0) — **unconditional on the latch**
- Gate: `0.0f < local_28` only (:14736). Fires on non-lethal hits, on the killing volley, AND on corpse-hits whose first burst applied.
- `+0x1c scoreAward` = applied tally; `+0x20 type` = **1** (:14752); `+0x24 basis` = **INTENDED** damage = `msg+0x58 burstCount × msg+0x30 amount` (:14737,14753); `+0x28 vitalHit`; `+0x2c zone`; `+0x30 subsysID`; `+0x34 senderMechID` = **the SHOOTER's mech ID** (`msg+0x1c`, :14742,14754)
- Dispatch :14758 → `local_20` = victim's own player (master, local delivery). Receiver: `Score@0x4c02e4` case 1 = `(ownTonnage/senderTonnage) × role->CalcDamageReceivedScore(basis)` + the console VTVDamaged post (`decomp-reference.md:276-279`).
## 3. The death tail — `LAB_004a07b5` (:14760-14835; @0x4a07b5; also reached by the collision divert)
**Gate (:14761): `local_14 == 0 && (mech+0x40 == 9 || mech+0x40 == 10)`** — was-alive-at-entry AND now in death mode 9|10 (`combat-damage.md:691` calls +0x40 movementMode).
**Latch asymmetry [T1 fact, T4 as bug mechanism]:** the latch (`FUN_0049fb54`) counts modes **2|9** but the tail gate accepts **9|10**. If a mech can sit at mode 10 at the ENTRY of a subsequent TakeDamage (e.g. the 1ms-later duplicate hits of the killing volley), `local_14` stays 0 and the ENTIRE death tail — including VehicleDead — **re-fires**. Whether mode 10 persists across invocations in the binary is not established here; in the port it depends on when the reconstruction advances the mode. This is the one structural hole in the binary's own dedup and maps exactly onto the #162 deathCount multi-increment shape.
Dispatches inside the tail, in order:
**(1) VehicleDead — id 0x17, BT-extended sizeof 0x38 → the VICTIM's OWN player** (:14762-14775):
- `{+0x00 0x38, +0x04 0x17, +0x08 1, +0x0c destID null, +0x14 0, +0x18 0, +0x1c deathCount = -1 (0xffffffff, :14770), +0x20 dropZone EntityID = null, +0x28 = 0, +0x2c killed-by PLAYER EntityID = shooter-player's ID (local_1c+0x184 via `local_1c + 0x61` int* math, :14762,14773), +0x34 kill zone (msg+0x24, :14763,14774)}` — matches the locked layout at `decomp-reference.md:293-294`.
- Dispatch :14775 → `local_20` (victim's own player, master → local). Receiver: **BTPlayer 0x17 `VehicleDead@0x4c05c4`** — increments the DEATHS cell `+0x280` on BOTH victim and killer players (`inc [edi+0x280]/[ebx+0x280]` @0x4c0670-0x4c067a) and, in its tail `@004c07cd-0x4c0828`, gated on `advancedDamageOn` (player+0x264), **direct-calls the ENGINE base `Player::ScoreMessageHandler` @0042da20 with a type-1 ScoreMessage whose `scoreAward = ScenarioRole::specialCaseDeathPenalty` (role+0x20, 500 in `Role::Default`)** — the 500 death cost (`decomp-reference.md:458-478`, `KD_SCOREBOARD_PLAN.md:88`).
- **This is the ONLY VehicleDead-class dispatch anywhere in FUN_004a0230**, and it is emitted at most once per handler invocation, solely under the was-alive latch + mode 9|10 gate. deathCount increment and 500 penalty share this single carrier: in the binary they cannot diverge (duplicate increments + missing penalty can only co-occur if the receiver path was split, which is a port-side property — inference, [T4] w.r.t. build 883).
**(2) Death explosion — engine message id 3, sizeof 0x5C — NOT dispatched, consumed locally** (:14776-14798):
- `{+0x04 id 3, +0x1c model/class 0x31, +0x28 = mech+0x4c0, +0x2c = 0x100, +0x30..0x48 = mech position/orientation (mech+0x100..0x118), +0x4c and +0x54 = victim mech EntityID}` (:14779-14797)
- Passed to `FUN_004302ec(&msg)` @004302ec (part_004.c:2362-2385): registry-finds the entity at msg+0x4c, `new`s a 0x1dc-byte object (ctor `FUN_00430180`, class data @DAT_004e6cc4) = the blast/Explosion entity, kept as `local_a0`. Nothing goes on the wire from here.
**(3) Delayed self-message — id 0x17 to the MECH, sizeof 0x1C — the respawn trigger, SCHEDULED not dispatched** (:14799-14811):
- `{0x1c, 0x17, 1, destID null, 0, 0}` (:14799-14804). Receiver id-space is the MECH's: mech-table **0x17 = SetBurningState@0049f674**, whose body actually randomizes position + graphicAlarm 2 + sim re-arm — the respawn (`decomp-reference.md:266`, label/body mismatch).
- Scheduled via `FUN_0041acbc(world+0x60, 3, mech, msg, when)` @0041acbc (part_002.c:4641-4658): allocates a scheduler node from pool 0x4e2fa4 (`FUN_0041a818(mech,msg,when)`) and inserts it in the world's delayed-delivery list. `when = *FUN_00414b60() (global clock, part_001.c:7125) + __ftol(mech+0x430)``FUN_004dcd94` is an ST0-arg `__ftol` the decompiler renders argless (`decomp-reference.md:278-279`); the float loaded is `mech+0x430` (:14805-14809), the respawn delay. **This is NOT a VehicleDead** despite sharing id 0x17 — per-receiver-class id spaces.
- (Do not confuse with mech id **0x16** = BalanceCoolant — the player/mech 0x16 ids are unrelated.)
**(4) Roster sweep** (:14812-14817): iterator over the `mech+0x418` list (`FUN_004a4eab`/`FUN_004a4ee9`), calls vtbl+0x48 per item — no messages.
**(5) Splash damage — the one tail helper that DOES send more messages** (:14818-14832):
- Gate: `victimPlayer+0x264 != 0 && victimPlayer+0x258 == 0` (:14819; +0x264 = advancedDamageOn per decomp-reference.md:460; the +0x258 slot is labeled suppressConsole in the VTVDamaged context — reuse here unverified [T4]).
- Builds damage spec `local_274 {type=2, amount=mech+0x520, victim position, __ftol(...)}` (:14820-14828) and calls `FUN_0042fad0(explosionEntity local_a0, victimMechID, &spec, radius = mech+0x524)` (:14831).
- `FUN_0042fad0` @0042fad0 (part_004.c:2059-2307) = the #89 SplashDamage: sweeps a ±radius box, and for each hit entity **dispatches a fresh id-0x12 TakeDamageMessage (size 100=0x64)** via `(**(code**)(*piVar3+0xc))(piVar3,&local_214)` (part_004.c:2276-2299), burst count = `max(1, __ftol(distance falloff))` (:2258-2261), inflictor ID stamped into the target's `+0x184`-relative slot from the passed mech ID (:2272-2275). Exclusions (:2203, :2226): the explosion entity itself, `*(explosion+0x1c8)` (its source — the dying mech, inferred from the ctor message carrying the mech ID [T4 on the identity]), and one under-recovered extra stack arg (`in_stack_00000024` — the decompiled 5-arg signature is short; [T4]). So the corpse does not re-splash itself, but every NEARBY mech's master re-enters this same handler with collateral damage during the killing volley.
**(6) ForceUpdate** (:14833): `FUN_004a4c54(mech, 1)` @004a4c54 (part_012.c:17180-17193): `mech+0x18 |= 1` (updateModel bit 0 = `Simulation::ForceUpdate`, corroborated `KD_SCOREBOARD_PLAN.md:131`); if destroyed, mask `& 0xfe03` still passes bit 0. Marks the dead mech for replication. No messages.
## 4. Emission matrix (per single handler invocation)
| Scenario at entry | type 0 → shooter | type 1 → victim | type 2 → shooter | VehicleDead 0x17 → victim's player | tail extras (blast/respawn-sched/splash) |
|---|---|---|---|---|---|
| alive, survives volley | ✔ (tally≠0) | ✔ (tally>0) | — | — | — |
| alive → DEAD this volley | — | ✔ | ✔ (basis=victim killBonus) | ✔ (exactly once) | ✔ |
| already destroyed (mode 2\|9) | ✔ (tally≠0) | ✔ (tally>0) | — | — | — |
| collision (type 0 dmg), killed | — | — | — | ✔ (killed-by = rammer's player) | ✔ |
| collision, survived | — | — | — | — | — |
Corrections to the task's priors: (a) the already-destroyed branch is **not** inflicted-only — the type-1 received report to the victim's own player also flows (Report C is outside both latch branches, gated only on tally>0); (b) the killing hit emits **no type-0** (A/B exclusive); (c) collision damage emits **no 0x16 at all** yet still runs the full death tail.
## 5. Relevance flags for #162 (inference, clearly marked [T4] against build 883)
- deathCount++ and the 500 penalty ride the SAME single VehicleDead message in the binary; 8 increments + 0 penalties means the port's VehicleDead emission or its receiver diverged from this map, not the binary's report logic.
- The only binary-native re-fire hole is the mode-10 latch gap (§3): destroyed-at-entry is tested as mode 2|9 while the tail fires on 9|10.
- "Duplicate pairs" of damage awards is consistent with B+C both existing per hit (one to each party) and/or corpse-hits still emitting B+C — and splash generates additional id-0x12 volleys during a kill.
==============================================================================
# FIELD RECEIPTS A/B — build 857 (night14) vs build 883 (night15), ticket #162
All paths below are absolute. Steam logs have no per-line timestamps (line numbers cited); matchlog receipts carry `t=` (ms tick) and `w=HH:MM:SS.mmm` wall clock. Receipt-format matchlogs were only collected from the ALIA machine on night15 (2 files) plus a dev-machine archive `scratchpad/night14/mlbak/` (builds 4.11.8374.11.870, DESKTOP-AE1SU9U). The night14 field steam logs (build 857) contain NO receipt-format lines at all — no `SCORE t=`, no `PLAYER_DEAD` (0 matches in all four files); the 857 baseline evidence is the `[score]`/`[respawn]`/`[deathcost]` prose lines. [T2 throughout — all claims below are read directly from field logs]
## 1. BUILD-857 BASELINE (night14 steam logs)
**Multi-increment already existed in 857.** Clustering `[respawn] player X:1 death cycle START (death #N)` lines (same player, consecutive N, <200 lines apart) across the four logs:
| log | increments | real events | multiplicity |
|---|---|---|---|
| steam_20260808_a_dave_SCREECH.log | 9 | 6 | {1:4, 2:1, 3:1} |
| steam_20260808_b_santo_MSFIREFLY.log | 7 | 6 | {1:5, 2:1} |
| steam_20260808_c_michael_XIAOLONG.log | 11 | 9 | {1:7, 2:2} |
| steam_20260808_e_rajel_GAMERSLAB.log | 13 | 9 | {1:7, 3:2} |
| **TOTAL 857** | **40** | **30** | **{1:23, 2:4, 3:3}** |
**Every increment fired the full chain: KILL report + death cycle + deathcost.** KILL-report count == increment count in every log (9/9, 7/7, 11/11, 13/13) — this is the receipt-level shape of the 857 "kills doubled" bug that 46ff8dd later fixed.
**The penalty fired once per increment, gated on `advDmg`:** all 8 `-> APPLYING` lines have `advDmg=1`; all 32 `-> SKIPPED` have `advDmg=0`. Perfect 40/40 correlation. Verbatim triple-death, triple-penalty sample (dave log, C:/git/bt411/scratchpad/night14/steam_20260808_a_dave_SCREECH.log:59301-59317, one killing volley, scoreBefore stepping 500 each time — sequential application proof):
```
[score] *** KILL report *** -> shooterPlayer=0BE8BC20 tally=2.45198 bonus=500
[respawn] player 2:1 death cycle START (death #2) -- drop-zone hunt in 5s
[score] VehicleDead(-1): death #2, respawn hunt in 5s
[deathcost] player 2:1 advDmg=1 role=bound penalty=500 scoreBefore=4788.47 -> APPLYING
[death] VehicleDead(-1) dispatched to the owning player (killedBy=6:1 zone=21)
[score] *** KILL report *** -> shooterPlayer=0BE8BC20 tally=3.43099 bonus=500
[respawn] player 2:1 death cycle START (death #3) -- drop-zone hunt in 5s
[score] VehicleDead(-1): death #3, respawn hunt in 5s
[deathcost] player 2:1 advDmg=1 role=bound penalty=500 scoreBefore=4288.47 -> APPLYING
[death] VehicleDead(-1) dispatched to the owning player (killedBy=6:1 zone=21)
[score] *** KILL report *** -> shooterPlayer=0BE8BC20 tally=3.43232 bonus=500
[respawn] player 2:1 death cycle START (death #4) -- drop-zone hunt in 5s
[score] VehicleDead(-1): death #4, respawn hunt in 5s
[deathcost] player 2:1 advDmg=1 role=bound penalty=500 scoreBefore=3788.47 -> APPLYING
```
Other verbatim samples: single APPLYING (michael log :82303-82307, `advDmg=1 scoreBefore=1513.08 -> APPLYING`); triple SKIPPED (rajel log :57467-57481, player 6:1, 3× `advDmg=0 scoreBefore=6858.66 -> SKIPPED`, note scoreBefore does NOT move — skipped means no deduction); double SKIPPED (santo log :41879-41889, player 4:1, 2× `scoreBefore=1264.11 -> SKIPPED`).
**Are there award=-500.00 SCORE receipt lines?** No — in ANY build. In the entire mlbak receipt archive (builds 837870) plus both 883 matchlogs, `award=` is never 500; the penalty is applied OUTSIDE the id-0x16 SCORE receipt and shows only as a silent 500 drop in the running `total=` between consecutive receipts. E.g. mlbak/matchlog_20260807_235237_15576.txt (4.11.837): total 0.00 → 500.00 between lines 32→40 with all awards 0.00; mlbak/matchlog_20260809_165517_13216.txt (4.11.865) line ~172: total 1000.00 → 501.97 (= 500 + a 1.97 award). The type= on the surrounding lines is type=1 (DamageReceived) with award=-0.00 — the 500 never rides a typed report.
**499 eject evidence:** not present in the four night14 field steam logs (greps for `-499`/eject-score hit only coordinates and weapon-jam EJECT lines). The documented evidence is docs/OPEN_ISSUES_FOR_TESTERS.txt:87 — `#151 Panic eject costs -499: verified REAL 1995 scoring (your death scored as suicide + death penalty)` — i.e. 499 = suicide credit (+1) + death penalty (500) [inference on the arithmetic; the 499 itself is a ledger claim, not in these logs].
## 2. BUILD-883 (night15)
**PLAYER_DEAD multiplicity — matchlog receipts (ALIA machine, both matches):**
- matchlog_20260810_213656_20476.txt (player 6:1 = Elengil): 8 PLAYER_DEAD, **5 real deaths**, multiplicity per event: **2, 1, 1, 3, 1** (events at w=21:39:49.419, 21:41:17.884, 21:43:13.203, 21:44:36.445, 21:46:29.337). Matches ticket #162's "5 real deaths → 8 increments" exactly.
- matchlog_20260810_223651_12120.txt (player 2:1, second match): 10 PLAYER_DEAD, **5 real deaths**, multiplicity: **1, 2, 2, 4, 1**.
- DEAD_NOTIFY count == PLAYER_DEAD count in both (8/8, 10/10); every PLAYER_DEAD is immediately preceded by its own `DEAD_NOTIFY ... link=<same ptr>`.
**All-machine distribution** (six steam logs, death-cycle-START clustering, all players' ledgers): a:{1:4,2:2}, b:{1:16,2:6}, c:{1:9,2:3,4:1}, d:{1:11,2:8,3:5}, e:{1:8,2:6,3:1,4:2}, f:{1:14,2:6,3:1,6:1} → **TOTAL {1:62, 2:31, 3:7, 4:3, 6:1}** — 163 increments over 104 events; ~40% of deaths multi-count, up to 6×.
**award=-500 count: 0** across both matchlogs (1,326 SCORE lines). No negative award of any magnitude exists (the only negative-looking value is the literal `award=-0.00` on every type=1 line — 745 of them, all exactly 0.00). Also **no silent 500 total-drops**: the only two total-vs-award mismatches (ml1 lines 305, 1117) are the type=2 kill receipts, which print `total=` BEFORE adding the award (next receipt shows total jumped +512.00: 1471.83 → 1992.58 = 1471.83+512+8.75). So in 883 the penalty is gone from the receipts entirely.
**Deathcost is still evaluated once per increment but always skips:** across all six steam logs 163 `[deathcost]` lines: **162× `advDmg=0 -> SKIPPED`, 1× `advDmg=1 -> APPLYING`** (steam_20260810_a_Dave_SCREECH-PC.log:5383 — player 2:1's FIRST death of the first round, `scoreBefore=1000 -> APPLYING`; the same player's later deathcost lines :19317, :26970 are `advDmg=0 -> SKIPPED`). Contrast 857 where 2:1's advDmg stayed 1 across deaths #1#4 mid-round (scoreBefore 2907…4788). Receipt-level verdict: **the 500 path is intact but its `advDmg` gate reads 0 on (almost) every death in 883**, where it read 1 persistently for the same player in 857. Eleng's own machine evaluated deathcost for 6:1 exactly 8 times (once per increment) — all SKIPPED.
## 3. ELENGIL CORRELATION (6:1, matchlog_20260810_213656_20476.txt)
**death#1 @ w=21:39:49.419 — 2 increments.** DMG victim=6:19 within ±3s: from **7:19**: 12.000 (lvl .8806), 13.000 (lvl .6195) at .180/.181, and 8.750 burst=3 at .558 (post-death); from **5:19**: 1.964/3.432/4.913 (lvl .8488→.9130) then **type=4 amt=14.701 lvl=1.0000** → DEAD_NOTIFY → `PLAYER_DEAD deaths=1`, then **type=4 amt=15.701 lvl=1.0000** → DEAD_NOTIFY → `PLAYER_DEAD deaths=2`, then `DEATH ... killer=5:19 killdmg=15.701` 46ms later. **2 increments = 2 lethal (lvl=1.0000) damage rounds**, both from ONE shooter (5:19). Distinct shooters in window = 2; peers = 8.
**death#4 @ w=21:44:36.445 — 3 increments.** Window: **8:19** lands 9 rounds .596.866 (4×3.446, 2×5.889, 13.000, 3.500 burst=9, 1.500 burst=2 — none lethal); **5:19** lands 7 rounds: 1.977×2, 3.444 (lvl .9236→.9653), then **4.903 lvl=1.0000** → PLAYER_DEAD deaths=5, **15.717 lvl=1.0000** → deaths=6, **15.717 lvl=1.0000** → deaths=7, all at t=613221000 same ms, DEATH latch 53ms later. **3 increments = 3 lethal rounds, all from ONE shooter.**
**Verdict: increment multiplicity == the number of KILLING-WINDOW DAMAGE ROUNDS** (damage applications with lvl=1.0000 that land between the killing blow and the DEATH-transition latch ~3050ms later). It is NOT the number of distinct shooters (death#1: 2 shooters in window, 2 increments from one; death#4: 2 shooters, 3 increments from one; ml2's 4-increment event at 22:45:40.768 has all 4 lethal rounds from the single shooter 8:19 while earlier lvl=1.0000 rounds from 6:19 and 9:19 at .323/.628 produced NO increment). It is NOT the peer count (8 peers; multiplicity observed 16, mode 12). Each in-flight round of the killing volley that applies before the wreck latch re-fires DEAD_NOTIFY→PLAYER_DEAD.
## 4. THE "DUPLICATE SCORE PAIRS" IN 883 RECEIPTS
Same-key (t + player + type + award) SCORE multiplicity histograms: ml1 type=0 {1:98, **2:63**}, type=1 {1:46, 2:28, 3:10, 4:20, 5:3, 6:19}; ml2 type=0 {1:115, **2:120**}, type=1 up to ×6. The nonzero-award duplicated groups are **all type=0 (DamageInflicted)**; pair members carry **IDENTICAL award and DIFFERENT, sequentially-advancing totals** — e.g. ml1 lines 45-48:
```
SCORE t=612804640 w=21:37:40.090 st=5 player=6:1 type=0 award=1.97 total=1001.97 kills=0
SCORE t=612804640 w=21:37:40.090 st=5 player=6:1 type=0 award=1.97 total=1003.94 kills=0
SCORE t=612804640 w=21:37:40.090 st=5 player=6:1 type=0 award=3.44 total=1007.38 kills=0
SCORE t=612804640 w=21:37:40.090 st=5 player=6:1 type=0 award=3.44 total=1010.82 kills=0
```
**BUT these type=0 pairs are not duplicates.** Each pair member maps 1:1 to a distinct weapon event: the volley above is FIRE wpn=25 amt=3.439 / wpn=26 amt=3.439 / wpn=27 amt=1.971 / wpn=28 amt=1.971 (twin-mounted lasers, ml1 lines 41-44), and the following `award=13.00` pair maps to PROJ wpn=22 + wpn=24 both amt=13.000 (lines 49-52). Aggregate: SCORE type=0 count ≤ hit-event count in both matchlogs (224 vs 242; 355 vs 408 — the excess hits are missile rounds paid as lump awards like 35.00=4×8.75). A build-865 control (mlbak/matchlog_20260809_165517_3316.txt lines 16-19: 2 FIRE → 2 identical 1.97 awards; 105 type=0 vs 108 hits) shows the identical pattern pre-883. **Verdict: the type=0 "duplicate pairs" are twin-weapon awards, present in old builds, correctly paid once per hit — not part of bug #162.** What IS genuinely duplicated per killing volley: the DMG/type=1 receipt (one per damage round, always award=-0.00, ×2–×6 same-ms) and DEAD_NOTIFY/PLAYER_DEAD (the real bug). Kill credit is exact in 883: `SCORE type=2 award=512.00` / `award=500.98` once per kill, SBMIRROR +1 steps (ml1 lines 305, 1117).
==============================================================================
# TASK 2 — Binary truth: 1995 player-side score/K-D handlers + replication
All addresses verified against the 2026-08-06 re-export (`reference/decomp/all/part_013.c`, `part_012.c`, `part_004.c`) and, where load-bearing, re-verified with raw capstone disasm of `content/BTL4OPT.EXE` (`tools/disas2.py`). Tier tags inline.
## 0. The handler table (who consumes what) [T1, byte-scanned table + export]
BTPlayer message table (20-byte rows, 6 entries — matches the T0 `PLAYER.h:235-240` enum `DropZoneReply=Entity::NextMessageID(0x15), Score(0x16), VehicleDead(0x17), MissionStarting(0x18), MissionEnding(0x19), NextMessageID(0x1a)`):
- **0x15 DropZoneReply → `FUN_004bffd0`** (part_013.c:18814)
- **0x16 Score → `FUN_004c02e4`** (part_013.c:18941) — the type-0/1/2 switch
- **0x17 VehicleDead → `FUN_004c05c4`** (part_013.c:19066; formerly an export gap, now exported AND raw-disasm-confirmed this pass)
- **0x1a ScoreUpdate → `FUN_004c02a8`** (part_013.c:18926)
- `FUN_004c0200` "ScoreInflicted" (part_013.c:18896) is bound in NO table entry — **dead code in 1995** (confirms `context/decomp-reference.md:273-275`).
## 1. The id-0x16 Score handler `@0x4c02e4` — all three arms [T1]
Entry: replicant Verify (`(this+0x28 & 0xc)==4` → Verify print, btplayer.cpp line 0x1e9), then **MissionEnding gate `this+0x40 != 4`** (whole body skipped during mission end). `iVar3 = GetEntityPointer(msg->senderMechID @+0x34)`; `local_c = playerVehicle(+0x1fc)`.
**type 0 (DamageInflicted)** — part_013.c:18970-18973: nothing but `Verify` (line 0x296). `local_10` (the award local) is **never written**, and the shared tail (part_013.c:19015-19016) still runs: `msg->scoreAward(+0x1c) = local_10 /*UNINITIALIZED*/; FUN_0042da20(this,msg)`**folds stack garbage into engine currentScore(+0x1c8)** — the shipped 1995 bug the KB documents (`decomp-reference.md:275-276`). No counter increments.
**type 1 (DamageReceived)** — part_013.c:18974-18991: only if `senderMech != own vehicle`: `award = (ownTonnage(+0x4bc)/senderTonnage) × CalcDamageReceivedScore(basis@msg+0x24)` where **`FUN_00429b94` (part_003.c:10156-10160) = `(basis × role->damageReceivedModifier(+0x10))`** — a NEGATIVE (penalty). Then the console VTVDamaged post (code 5; gated ConsoleHost && `!suppressConsole(+0x258)`). **If senderMech == own vehicle (self-inflicted), `local_10` stays uninitialized and the tail still folds it** — same uninit-fold class as type 0 (NEW this pass). No counter increments. dfltrole `dmgRcv=0` ⇒ award 0 in shipped content.
**type 2 (Kill)** — part_013.c:18992-19013:
- `award = CalcKillScore(this, &basis@+0x24, victimMech, tally@+0x1c) × (victimTonnage/ownTonnage)`.
- **`CalcKillScore @0x4c052c`** (part_013.c:19027-19060): team game (`freeForAll(+0x250)==0`) + same `teamName(+0x20c)``local_8 = friendlyFirePenalty(role+0x14)`; else `local_8 = victimMech->avgZoneDamage(+0x354) × damageBias(role+0x18) + 1.0 (@0x4c05c0)`; returns `(basis + tally) × damageInflictedModifier(role+0xC) × local_8`.
- **Suicide (`victimMech == own vehicle`): `local_10 = -local_10` (the `fchs`) and NO killCount increment.**
- Else — **the dual killCount++, exact context** (part_013.c:18998-19001; `inc [+0x27c]` @0x4c0397/@0x4c03a3):
```c
else {
*(int *)(param_1 + 0x27c) = *(int *)(param_1 + 0x27c) + 1; // shooter (this) killCount++
piVar1 = (int *)(*(int *)(iVar3 + 400) + 0x27c); // victimMech+0x190 -> victim's player
*piVar1 = *piVar1 + 1; // PHANTOM: victim's player killCount++ (wrong column)
}
```
On the shooter's node the second object is the **victim's replicant player copy** — never corrected in 1995 (no replication, §4).
- StatusMessage `{type 0 "Destroyed", victim player, 6.0s}` (pool @0x512f6c, vtable @0x513344) added to this player; if `victimMech == objectiveMech(+0x284)` → clear-target `FUN_004b04d8` via own vehicle's roster head (+0x128).
- Tail (all types): `msg->scoreAward = award; FUN_0042da20(this,msg)``currentScore(+0x1c8) += award; playerVehicle->RespondToScoreMessage(msg)` (part_004.c:449-450 ≡ T0 `engine/MUNGA/PLAYER.cpp:143,150`).
**killCount(+0x27c) is written NOWHERE else in the image** — only the type-2 arm.
## 2. WHERE deathCount++ lives and WHERE the deathPenalty is subtracted [T1, raw disasm this pass]
Both live in **`BTPlayer::VehicleDeadMessageHandler @0x4c05c4`**, in the **`msg->deathCount(+0x1c) == -1` arm** — one straight-line block, no exit between them:
- Entry gates: `this+0x40 != 4` (MissionEnding); `msg+0x1c == -1` else → engine base `FUN_0042db80` (drop-zone hunt); **`deathPending(+0x290) == 0`** guard @0x4c05f7.
- `edi = GetEntityPointer(msg+0x2c)` = the **killer's PLAYER** (the BT 0x38-byte extension field).
- StatusMessage `{type 1 "KILLED BY", killer player, 6.0s}` @0x4c062d-0x4c0668.
- **The deaths increment @0x4c0670-0x4c067f:**
```
0x4c0670: cmp ebx, edi ; victim(this) vs killer player
0x4c0672: je 0x4c067a ; self-kill: skip the partner bump
0x4c0674: inc dword ptr [edi + 0x280] ; killer's player deaths++ (wrong-column slip; on the victim's node this is the killer's REPLICANT copy)
0x4c067a: inc dword ptr [ebx + 0x280] ; victim's deaths++ (the real DEATHS cell) — UNCONDITIONAL
```
- Console death notice (code 5, `FUN_004c18cc`) if `!suppressConsole(+0x258)` && console present.
- Revenge lock `objectiveMech(+0x284) = killerPlayer->playerVehicle(+0x1fc)` @0x4c06dd.
- Death sweep `FUN_0049fe0c(playerVehicle, 0)` @0x4c06e9 (DeathShutdown arg 0).
- Lives branch on `role(+0x208)->returnFromDeath(+0x28)`:
- `> 0`: re-post the SAME VehicleDead to **SELF** at Now + **5.0 s** (@0x4c0830) with `msg->deathCount ← this->deathCount(+0x200)` (raw disasm recovered `mov esi,ebx` @0x4c0731 — the Post target is the player, matching the export).
- `< 1` (out of lives): post `{size 0x1c, id 0x18}` to the **VEHICLE** at Now + **10.0 s** (@0x4c0838) — in the Mech id space 0x18 = ClearBurningState; no respawn.
- **THE DEATH PENALTY @0x4c07cd-0x4c0825** — BOTH branches converge here (`jmp 0x4c07cd` @0x4c0754):
```
0x4c07cd: mov ecx, [ebx + 0x264] ; advancedDamageOn — THE ONLY GATE
0x4c07d5: je 0x4c0828 ; off -> no penalty
0x4c07d7: mov eax, [ebx + 0x208] ; scenarioRole
0x4c07dd: fld dword ptr [eax + 0x20] ; specialCaseDeathPenalty (dfltrole = 500)
0x4c07e0: fchs ; negate
... build a BARE 0x20-byte engine ScoreMessage {id 0x16, scoreAward@+0x1c = -penalty} (NO type field — the msg ends at 0x20)
0x4c0820: call 0x42da20 ; DIRECT call to ENGINE Player::ScoreMessageHandler
```
So the 500: **victim's master node, inside the id-0x17 VehicleDead(-1) arm, gated ONLY on `advancedDamageOn(+0x264)`, amount = `role+0x20` read live, applied by a DIRECT call** (no Dispatch → no reroute, no BT type switch, no Verify) into `currentScore(+0x1c8)` + `playerVehicle->RespondToScoreMessage`. Confirms `decomp-reference.md:458-478` (#118) and adds: **the penalty also applies on the out-of-lives path.**
**The engine respawn counter is a DIFFERENT field:** `Player::deathCount(+0x200)` (ctor seed 2, `PLAYER.cpp:777`; zeroed at first spawn, part_013.c:18834) increments ONLY in **`FUN_004c012c`** (part_013.c:18872, the DropZoneReply respawn helper) — once per completed respawn cycle, alongside `msg->deathCount` restamp, `role->returnFromDeath(+0x28)--`, **`ForceUpdate` (`or word [ebx+0x18],1`)**, sim-state SetLevel(1)=DropZoneAcquired, respawn-location choose (advancedDamageOn → the ORIGINAL drop @DAT_00524b38, else the assigned zone), a delayed self re-post (the 2-pass latch), and **clearing `+0x290` and `suppressConsole(+0x258)`** (part_013.c:18887-18888).
### NEW [T1, whole-image byte scan]: the `deathPending(+0x290)` guard is NEVER SET in 1995
Every code-section instruction touching disp32 0x290: `push 0x290` @0x429294 (an immediate, not a member); zero-writes @0x4b77fd (different object), **@0x4c01e5 (`FUN_004c012c`: = 0)**, **@0x4c0c07 (BTPlayer ctor: = 0)**; the lone READ @0x4c05f9 (the handler guard). **No instruction ever writes it non-zero.** The 1995 receiver has NO dedup — single increment/single penalty is guaranteed solely by the SENDER emitting exactly one VehicleDead per death (§3). Implication for #162: in the binary, deaths++ (@0x4c067a) and the penalty (@0x4c07cd) are one straight-line block — "deaths multi-increments while the penalty never applies" is impossible in a faithful transcription; it requires (i) `advancedDamageOn==0`, (ii) an unbound/zero role (+0x20==0 — the pre-2026-08-07 port state), or (iii) a port-side early exit between the two.
## 3. The sender: `Mech::TakeDamageMessageHandler @0x4a0230` report tail [T1] (part_012.c:14474-14837)
Runs ONLY on the **victim mech's MASTER node** (replicant Verify at entry, line 0x3da; field: 0/18818 DMG rows inst=R). Per killing/damaging hit:
1. **Destroyed-at-entry latch: `local_14 = FUN_0049fb54(this)` (IsDisabled) at ENTRY** — a hit landing on an already-dead mech can never re-send kill/VehicleDead.
2. `lastInflictingID(+0x43c) ← msg->inflictingEntityID(+0x1c)` — every hit, last-writer-wins.
3. **`damageType(msg+0x2c) == 0` (Collision) → `FUN_0049ffcc` and `goto` the death tail — SKIPS ALL THREE id-0x16 reports.** 1995 ram kills award NO kill score and NO killCount; they still produce VehicleDead → deaths + penalty (state-9 timing inside FUN_0049ffcc not read this pass, [T3]).
4. Burst loop applies damage; `local_28` accumulates applied damage + crit contributions; the loop breaks when the mech goes disabled mid-burst.
5. Reports, all ScoreMessage id 0x16 size 0x3C `{+0x1c tally, +0x20 type, +0x24 basis, +0x28 vitalHit, +0x2c zone, +0x30 subsysID, +0x34 senderMechID}`:
- **KILL EDGE** (alive at entry && disabled after) part_012.c:14685-14708: **type 2 → Dispatch to the SHOOTER's owning player** (`*(shooterMech+0x190)`; a replicant locally → `Entity::Dispatch` reroutes to the shooter's master). `tally = local_28` (applied damage of the volley), **`basis = the VICTIM's own `role->killBonus(+0x1c)`** (the @0x4a0506 read; dfltrole 500), `senderMechID = the victim mech's own ID(+0x184)`.
- **Else** (still alive, or wreck-shooting), if `local_28 != 0` (part_012.c:14710-14734): **type 0 → the SHOOTER's player** (tally = basis = local_28) — lands in the 1995 dead/uninit arm (§1).
- If `local_28 > 0` (part_012.c:14736-14758, independent — ALSO fires on the kill edge): **type 1 → the VICTIM's OWN player** (local master → synchronous). `tally = local_28`, `basis = burstCount(+0x58) × damageAmount(+0x30)` (nominal volley), `senderMechID = the inflicting entity (msg+0x1c)`.
6. **DEATH TAIL** (part_012.c:14760-14835): gate `local_14 == 0 && (this+0x40 == 9 || == 10)` (10 = eject) — the one-shot alive→dead edge. Builds **BT VehicleDeadMessage `{size 0x38, id 0x17, deathCount(+0x1c) = -1, dropZoneID = Null, +0x28 = 0, +0x2c = the killer's PLAYER EntityID (`shooterPlayer+0x184`), +0x34 = kill zone}`****Dispatch to the VICTIM's OWN player** (synchronous, master). Then: death-effect broadcast (id 3, 0x5c), a delayed SetBurningState (id 0x17, 0x1c) to the mech itself (delay = `mech+0x430`), watcher sweep, console damage-effect notify (advancedDamageOn && !suppressConsole), `FUN_004a4c54(this,1)`. **Exactly one VehicleDead per death, sender-side guaranteed.**
## 4. Replication: what rides the Player update record [T0 + T1]
- `Player::WriteUpdateRecord` (`PLAYER.cpp:645-661` ≡ binary `FUN_0042e2e4`, part_004.c:859-867): `Entity::WriteUpdateRecord`, `recordLength = 0x3c`, **`record+0x2c = currentScore(+0x1c8)`, `record+0x30 = dropZoneLocation(+0x1d0)`** — nothing else. `ReadUpdateRecord` (`PLAYER.cpp:631-641``FUN_0042e2ac`) applies the same two.
- **BTPlayer does NOT override** — vtable @0x513300 slots 6/7 = 0x42e2ac/0x42e2e4, the engine's (KD plan §1 [T1]).
- Therefore in 1995: **`currentScore(+0x1c8)` is the ONLY replicated scoring field.** `killCount(+0x27c)`, `deaths(+0x280)`, respawn `deathCount(+0x200)`, and the bank `+0x278` NEVER cross the wire; each is authoritative (and only meaningful) on the single node where its increment ran. Bystanders show 0/0 K/D for everyone all mission; the two wrong-column partner increments (on replicant copies) are locally visible and never corrected.
- `Player::CalcRanking` (`PLAYER.cpp:542-626``FUN_0042e1ac`): per-node sort of the local "Players" group **by `currentScore`**`playerRanking(+0x1cc)`/`playerHighlighted(+0x1c4)`. Runs in Player/CameraShip simulation each frame.
- **The scoreboard PilotList `@0x4cabd0`** (part_014.c:7656-7657) draws per row exactly `(float)[player+0x27c]` (KILLS) and `(float)[player+0x280]` (DEATHS) — no score cell in this widget.
- **`+0x278` is a pending-score BANK, not the score** (NEW precision): sole writer = the dead `@0x4c0200` (`+0x278 += tonnageRatio × CalcKillScore(...)`, self-negated); the flusher `FUN_004c083c` (part_013.c:19164-19192, called from the BTPlayer master performance `FUN_004c0904`) every **10.0 s** (@0x4c08fc) self-Dispatches a `{0x20, id 0x1a, award = +0x278}` ScoreUpdate then zeroes the bank; handler `@0x4c02a8` = Verify-master + engine fold. Design = replicant-side observed-score banking with reroute-to-owner; dead in 1995 because the only writer is unbound. The periodic console score post (every 15.0 s @0x4c0bc0) reads **+0x1c8** (`param_1[0x72]`, part_013.c:19274).
## 5. THE 1995 FLOW — one death event (shooter S kills victim V, weapon damage, advanced damage ON)
On **V's node** (V master), synchronously inside the killing `TakeDamageMessage` (id 0x12) processing:
1. `V.mech.lastInflictingID ← S.mech` ; damage applied; tally accumulated.
2. **[out] ScoreMessage 0x16 type 2** → dispatched at S.player-replicant → rerouted to S's node (`ENTITY.cpp:244-251`).
3. **[sync] ScoreMessage 0x16 type 1** → V.player (master): `V.currentScore(+0x1c8) += (Vt/St) × (basis × rcvMod)` (0 with dfltrole); console VTVDamaged.
4. **[sync] VehicleDead 0x17 (deathCount=1, killer=S.player, zone)** → V.player (master):
a. "KILLED BY" ticker; b. `S.player-replicant.deaths(+0x280)++` (wrong column, local-only); c. **`V.deaths(+0x280)++`** ← THE death count; d. console death notice (unless suppressConsole — eject sets it); e. `V.objectiveMech = S.vehicle`; f. DeathShutdown(0) sweep; g. lives>0 → repost VehicleDead(deathCount=+0x200) to self @ +5 s; h. **`V.currentScore(+0x1c8) = role.specialCaseDeathPenalty(500)`** — direct engine call, advancedDamageOn-gated. No ForceUpdate anywhere in this block.
On **S's node**, when the rerouted type-2 executes on S.player (master):
5. `award = (killBonus + tally) × dmgInfMod × (V.avgZoneDmg × bias + 1) × (Vt/St)` [or `ffPenalty` same-team; negated + no counters on suicide]; **`S.killCount(+0x27c)++`**; **`V.player-replicant.killCount(+0x27c)++`** (phantom, local-only); "DESTROYED" ticker; revenge clear-target; `S.currentScore(+0x1c8) += award`.
Respawn (V's node): +5 s repost → engine `FUN_0042db80` (gate `msg->deathCount == +0x200 && simState != DropZoneAcquired`): closest non-"win" DropZone, `AssignDropZone{reply 0x15, deathCount}` → DropZone dispatches **DropZoneReply 0x15**`FUN_004bffd0`: stale gate (`msg+0x38 == +0x200`), MissionEnding gate; pass 1 (state≠1) → **`FUN_004c012c`: `deathCount(+0x200)++`, `returnFromDeath--`, ForceUpdate, state←1, location choose, clear +0x290/+0x258, delayed self-repost**; pass 2 (state==1) → state←2, **`Mech::Reset(vehicle, loc, 1)` (`FUN_0049fb74`)**.
Convergence: peers learn ONLY `currentScore` (and position) from the Player update record; K/D never converge in 1995.
## Authority table (1995)
| field | offset | sole writer(s) | node | replicates |
|---|---|---|---|---|
| currentScore | +0x1c8 | engine `@0x42da20` (BT 0x16 tail all types; death penalty direct; 0x1a flush) | wherever the owning handler runs (master after reroute) | **YES** (only one) |
| killCount | +0x27c | 0x16 type-2 arm only (@0x4c0397 + phantom @0x4c03a3) | shooter's master node | no |
| deaths | +0x280 | 0x17 1 arm only (@0x4c067a + wrong-column @0x4c0674) | victim's master node | no |
| deathCount (respawn id) | +0x200 | ctor 2; first-spawn =0; `@0x4c012c` ++ | victim's master | no |
| score bank | +0x278 | dead `@0x4c0200`; flushed/zeroed `@0x4c083c` | — | no |
| deathPending | +0x290 | **never set**; zeroed ctor + `@0x4c01e5` | — | no |
==============================================================================
# TASK 3 — The as-built scoring/death chain at HEAD (build 883, post-46ff8dd)
## 1. Producer: `Mech::TakeDamageMessageHandler` (game/reconstructed/mech.cpp:1031-1344)
Handler anatomy, in execution order:
| Step | Site | Notes |
|---|---|---|
| `destroyedAtEntry = IsMechDestroyed()` | mech.cpp:1039 | #150 entry latch (added in 46ff8dd), sampled at handler ENTRY; `IsMechDestroyed()` = `graphicAlarm >= 9` (mech.hpp:1145, impl mech4.cpp:2215) |
| gyro bounce, lastInflictingID/Damage, HUD threat | mech.cpp:1063-1087 | no score effect |
| `deathBlastArmed = !(MovementMode()==9 \|\| MovementMode()==10)` | mech.cpp:1119-1120 | the OTHER once-latch, movementMode-based — arms the death tail |
| COLLISION DIVERT (`damageType==0`) | mech.cpp:1142-1146 | `DistributeCollisionDamage` then `goto death_tail`**bypasses the report tail entirely** (a collision death posts VehicleDead but NO 0x16 reports; credits no one — documented authentic, mech.cpp:1130-1140) |
| zone/burst application loop | mech.cpp:1193-1241 | accumulates `damageTally` (applied damage + crit bonuses, :1216/:1227), `zoneDestroyed` (:1229) |
| **REPORT TAIL master-gate** | mech.cpp:1270 | `if (GetInstance() != ReplicantInstance)``BTMechPostCombatReports(this, msg, damageTally, zoneDestroyed, reportZone, !destroyedAtEntry && IsMechDestroyed())` (:1272-1275). The kill flag = the 0→1 edge of `IsMechDestroyed()` across THIS call |
| matchlog `DMG` receipt | mech.cpp:1283-1304 | prints `inst=M/R` (:1294) — runs on BOTH instances, ungated |
| **DEATH TAIL** | mech.cpp:1335-1343 | `if (deathBlastArmed && IsMechDestroyed())``BTMechPostVehicleDead(...)` (:1339) + `BTApplyDeathSplash(this)` (:1342) |
**The critical asymmetry (46ff8dd scope):** the commit gated ONLY the 0x16 report tail (mech.cpp:1270) and only the report tail got the `destroyedAtEntry` edge-latch. The DEATH TAIL is (a) **not instance-gated** — it runs on replicant victim copies too — and (b) latched on `deathBlastArmed` (movementMode), **not** on `destroyedAtEntry`. On a replicant, movementMode cannot flip to 9/10 until the owner's death update record round-trips (replicants reach mode 9 only via replicated simulationState — mech4.cpp:2721-2725), while `graphicAlarm` IS raised to 9 locally by the damage application itself (mechdmg.cpp:495, :497, :548, :710, :798 — leg-gone/vital-zone/vital-subsystem/mech-kill writers that run wherever damage is applied). So inside the round-trip window **every additional salvo of the killing volley re-enters the death tail on each peer's replicant copy**: `deathBlastArmed`=1 (mode still 1) && `IsMechDestroyed()`=1 → another VehicleDead + another death splash, per salvo, per peer. This is byte-for-byte the same mechanism 46ff8dd proved for the duplicated kill reports ("two type-2 arms 1ms apart, victim inst=R" — commit message) — the fix covered the reports and left the tail.
## 2. `BTMechPostCombatReports` (game/reconstructed/btplayer.cpp:2574-2691) — every dispatch
Resolution: `shooter` from registry by `msg->inflictingEntity` (:2595), `shooter_player = shooter mech playerLink` (:2598), `victim_player = victim playerLink` (:2600).
| Block | Guard | Message | Destination | kill-flag use |
|---|---|---|---|---|
| **A** kill (:2602-2644) | `if (newly_destroyed)` && `shooter_player != 0` | ScoreMessage id 0x16 **type 2**, scoreAward=damageTally, damageAmount=victim role killBonus (:2612-2626) | `shooter_player->Dispatch(&kill)` (:2627) — replicant on the victim's node → Entity::Dispatch reroutes to killer's host (ENTITY.cpp:244-251) | **consumed** — sole gate. killer==victim IS dispatched (eject/suicide; handler negates) :2608-2610. Else-arm: matchlog `NOCREDIT` (:2633-2644) |
| **B** inflicted (:2646-2667) | `else if (damage_tally != 0 && shooter_player != 0)` | id 0x16 **type 0**, award & basis = tally (:2658-2665) | `shooter_player->Dispatch(&inflicted)` (:2666) | **consumed via the else** — the killing hit posts NO inflicted report (kill replaces it) |
| **C** received (:2669-2690) | `if (damage_tally > 0 && victim_player != 0)` — NOT an else | id 0x16 **type 1**, award=tally, damageAmount=INTENDED (burstCount×amount) (:2678-2688), senderMechID=INFLICTOR | `victim_player->Dispatch(&received)` (:2689) — master on the victim's own node → local | **ignored** — runs after A too, kills included (binary behavior, :2672-2674) |
All three sit INSIDE the mech.cpp:1270 master-gate. Per-TakeDamage cadence: B+C once per non-lethal hit, A+C once on the killing hit, B+C again on every post-mortem salvo that still tallies damage on the wreck (destroyedAtEntry=1 → newly=0 → the "inflicted-only path", mech.cpp:1038).
## 3. `BTMechPostVehicleDead` (btplayer.cpp:2702-2758)
Builds `BTPlayer::VehicleDeadMessage` id 0x17, 0x38 bytes, killed_by = shooter PLAYER EntityID, killZone (:2748-2752); engine-base `deathCount` ctor-defaults to **-1** (engine/MUNGA/PLAYER.h:111,120; btplayer.hpp:183) → every dispatch takes the consumer's "-1 immediate death notification" arm. Owner resolved via playerLink with the #55 mission-player fallback (:2711-2723); `DEAD_NOTIFY` matchlog (:2724). `owner->Dispatch(&vehicle_dead)` (:2753) — on a peer node `owner` is a replicant player → reroute to the owning host (ENTITY.cpp:244-251). **No instance gate, no once-per-death latch of its own** — it fires as often as the death tail fires (see §1 asymmetry).
`BTApplyDeathSplash` (mech4.cpp:1382-1428): gates = victim player's `advancedDamageOn` && !`suppressConsole` (:1399; punch-outs never blast), radius/damage authored per chassis; dispatches a type-2 TakeDamage (inflictor = the DYING mech, :1337 via BTSplashCore :1426) to every live mech in radius via `e->Dispatch(&td)` (mech4.cpp:1335-1338). Because the death tail runs on every node's copy, a bystander's master can receive the SAME death blast once locally (its own node's replicant-victim tail) and once over the wire (the victim-master's tail, rerouted) — **each application then posts its own Block-C received award: a structural producer of "damage awards in duplicate pairs"** [T3 inference from as-built structure; not yet log-confirmed].
## 4. Consumers (btplayer.cpp) — everything that mutates score/killCount/deathCount
Handler table: btplayer.cpp:346-356 (DropZoneReply, VehicleDead, Score, ScoreInflicted, ScoreUpdate, MissionStarting, MissionEnding).
### 4a. `BTPlayer::Dispatch` type-0 interceptor (:891-917)
Local id-0x16 type-0 dispatches, `GetInstance()==MasterInstance` only (:911) → `ScoreInflictedMessageHandler`; otherwise falls to `Player::Dispatch` (replicant → wire reroute). Wire deliveries NEVER pass through this vtable — they land in the handler table via Receive (:1092-1105).
### 4b. `ScoreInflictedMessageHandler` (:963-1022) — type 0
Verify-asserts type==0 (:971-980); drops if `our_mech==0` (:994); `currentScore += (tonnage ratio) * award`, negated if target==self (:1001-1012); `ForceUpdate()` (:1013); **matchlog receipt `SCORE ... type=0`** (:1018-1021). No instance guard at all.
### 4c. `ScoreUpdateMessageHandler` (:1032-1046)
Replicant check is `Verify(False, ...)` (:1036) then falls through to the base handler (:1045).
### 4d. `ScoreMessageHandler` (:1057-1279) — the 0x16 type-1/2 consumer
- **Replicant guard is a no-op at runtime**: `Verify(False, ...)` (:1061-1067) — and `Verify` compiles to NOTHING: no `DEBUG_LEVEL` is defined anywhere in the build (grep of CMakeLists), so engine/MUNGA/STYLE.H:10-14 defaults DEBUG_LEVEL=0 → DEBUGOFF.h:42 `#define Verify(c)` (empty). Same for :973 and :1036. **If a 0x16 ever reaches a replicant player copy, the handler executes fully on it.** Delivery-side, `EntityManager::ReceiveNetworkPacket` (engine/MUNGA/NTTMGR.cpp:107-149) posts an inbound wire message to whatever LOCAL copy matches the entityID — **no instance check** — so nothing structural prevents that.
- MissionEndingState drop (:1073-1076).
- **type 0** (:1086-1115): delegates to ScoreInflictedMessageHandler and RETURNS (wire-rerouted inflicted reports land here; :1114-1115).
- **type 1** DamageReceived (:1117-1169): award = tonnage-ratio × `scenarioRole->CalcDamageReceivedScore(msg->damageAmount)` — guards sender/our mech non-null, sender≠self, role bound (:1125-1131); self-hit → award stays 0 (deliberate deviation, :1132-1136); console VTVDamaged side-message (:1149-1167).
- **type 2** Kill (:1171-1241): award = tonnage-ratio × `CalcInflictedScore(msg->damageAmount, sender_mech, msg->scoreAward)` (:1188-1190); `sender==our_mech → award = -award` (suicide/eject, :1192-1195 — the previous build's measured **-499**); else `++killCount` (:1204) **and `++sender_owner->killCount`** — the binary's wrong-column slip reproduced on the VICTIM's (replicant) player copy (:1206, banner :2376-2389); status ticker (:1219-1228); objective notify (:1234-1240).
- Common tail: `message->scoreAward = award` (:1252); **matchlog `SCORE ... type=<1|2>`** (:1257-1262); `currentScore += award` — via direct add if vehicleless (:1264-1268) else `Player::ScoreMessageHandler` (:1270); `ForceUpdate()` (:1278).
### 4e. `VehicleDeadMessageHandler` (:418-771) — the deathCount writer
- MissionEndingState swallow (:426-439).
- `deathCount != -1` branch (:441-539): the +5s re-post / -2 probe → drop-zone hunt via engine base (:537). Moot if mech alive again (:452-462).
- **`deathCount == -1` arm** (:541-771), the death notification. Dedup gate `if (deathPending != 0) return` (:546-558) — **but `deathPending` is SET at :559 and CLEARED at :768 inside the SAME synchronous invocation** (the #81 fix moved the clear here, banner :739-767). A queued/wire duplicate arriving even 1ms later sails through. Per accepted -1 message: `++deathCount` (:582), `message->deathCount = deathCount` (:583), `++deathTally` (:592), **matchlog `PLAYER_DEAD`** (:601-602), life debit (:606-610), ForceUpdate (:634), `killerName` (:673), +5s respawn re-post (:690-695), **THE DEATH COST** (below), latch clear (:768), `suppressConsole=0` (:770). **No instance guard anywhere in this handler.**
### 4f. The -500 deathPenalty — the ONLY site (:698-736)
```
if (advancedDamageOn && scenarioRole != 0) // :726
ScoreMessage death_cost(ScoreInflictedMessageID /*0x16*/, ..., type 1,
-scenarioRole->GetSpecialCaseDeathPenalty(), 0.0f, EntityID::Null); // :728-734
Player::ScoreMessageHandler(&death_cost); // :735 DIRECT base call
```
Properties: applied via a DIRECT engine-base call (PLAYER.cpp `currentScore += scoreAward`) — **never dispatched, never on the wire, produces NO `SCORE` matchlog receipt** (only the running total moves; acknowledged at :712-715). The one witness is the **ungated `[deathcost]` DEBUG line** (:716-724) printing `advDmg=/role=/penalty=/scoreBefore=/APPLYING|SKIPPED`. Gates: `advancedDamageOn` — assigned ONLY in the ctor's MASTER branch from `bt_mission->AdvancedDamageOn()` (:2137-2138); on a replicant player copy it is **never assigned** (ctor does not zero it; :1944-1947 zero only console/score/deathPending) [T3: indeterminate there]. `scenarioRole` — bound for both instances with a `Role::Default` fallback (:2066-2084).
### 4g. `DropZoneReplyMessageHandler` (:1743-1920)
`deathCount = 0` on vehicle create (:1798); stale-reply discard when `message->deathCount != deathCount` (:1835-1867 — duplicate deaths that bump deathCount between the re-post and the reply strand the respawn here); `RESPAWN` matchlog (:1906).
### 4h. Scoreboard replication (:2391-2477)
`WriteUpdateRecord` ships killCount/deathTally (:2415-2416); `ReadUpdateRecord` mirrors onto replicants only (:2450-2458), `SBMIRROR` receipt (:2467). **`deathCount` (the respawn-handshake identity) is NOT replicated — only deathTally is** — so replicant-side deathCount writes are never corrected.
## 5. `MechDeathHandler` (game/reconstructed/mechdmg.cpp:1208-1363+)
Nothing score- or count-adjacent: skate detector (:1233-1313), ghost detector (:1315-1342), replicant un-wreck on the mode-9/2 exit edge (:1344-1360). The death-relevant writes in mechdmg.cpp are the `graphicAlarm.SetLevel(9)` sites (:495, :497, :548, :710, :798) which flip `IsMechDestroyed()` wherever damage is applied — including on replicant copies — which is what re-arms the ungated death tail (§1).
## 6. Every site that can run MORE THAN ONCE per death (vs a once-per-event model)
1. **Death tail on replicant victim copies** (mech.cpp:1335-1343): ungated by instance, latched on movementMode which is frozen on replicants for the whole death-record round-trip → **one VehicleDead + one death splash per salvo per peer node**. The exact #150 mechanism, un-fixed for the tail. → explains deathCount 5→8, duplicates 1ms apart / same-ms ×N (each duplicate reroutes to the victim's master and is counted).
2. **VehicleDeadMessageHandler -1 arm has no effective dedup** (btplayer.cpp:546/559/768): the latch is raised and lowered inside one synchronous body, so every queued duplicate increments deathCount/deathTally, debits a life, posts another +5s re-post, and (when its gates pass) applies another death cost.
3. **Death splash multi-application** (mech4.cpp:1382-1428 via mech.cpp:1342): fires from the master's tail AND from every peer replicant's tail (×salvos) → bystander masters take the blast ≥2×**paired type-1 damage awards** (§3) [T3].
4. **Post-mortem salvos on the wreck**: each still posts Block B+C (mech.cpp:1038 "inflicted-only path"; btplayer.cpp:2646-2690) — by design, but it means received penalties continue after death.
5. **Replicant execution of the 0x16 consumers is unguarded at runtime**: `Verify` is compiled empty (DEBUG_LEVEL=0 → DEBUGOFF.h:42), VehicleDeadMessageHandler has no guard at all, and NTTMGR.cpp:107-149 delivers wire messages to the local copy by entityID with no instance check.
6. **`++sender_owner->killCount` on the victim's replicant player copy** (btplayer.cpp:1206) — bounded by the SBMIRROR correction (banner :2376-2389), kills currently read exact in the field.
## 7. Why the -500 can read "never applies" — as-built observations (mechanism NOT proven here)
The penalty has exactly one producer (§4f) and it is receipt-less: no matchlog row, no wire message — a duplicate-VehicleDead storm shows up as PLAYER_DEAD rows while the penalty leaves only the `[deathcost]` line and the raw total. As-built, the arm CANNOT be reached without also incrementing deathCount (:582 precedes :726 in the same arm), so "deathCount multi-increments AND penalty never applies" requires the :726 gate to fail on every accepted -1 — i.e. field `[deathcost]` lines reading `advDmg=0` or `role=NULL -> SKIPPED`, or the -1 arms executing on player copies whose `advancedDamageOn` was never assigned (§4f). The previous build's -499 eject proof exercised the **type-2 suicide negation** (btplayer.cpp:1192-1195) plus the death cost (46ff8dd message: "1000 - ~999 - 500 = -499"), so the discriminator for build 883 is the ungated `[deathcost]` line and the `[exp] master player ... advDamage=` ctor line (:2141-2145) in the field logs.
## 8. Cross-node routing spine (for the graph)
- `Entity::Dispatch` on a replicant reroutes to the owner host and does NOT execute locally (engine/MUNGA/ENTITY.cpp:235-272).
- Wire arrival: `EntityManager::ReceiveNetworkPacket` posts to the local copy by entityID, handler table via Receive — bypassing the `BTPlayer::Dispatch` type-0 interceptor (btplayer.cpp:1092-1105) and any instance discipline (NTTMGR.cpp:107-149).
- Matchlog receipt map: `DMG` mech.cpp:1290 · `SCORE type=0` btplayer.cpp:1018 · `SCORE type=1/2` :1258 · `PLAYER_DEAD` :601 · `DEAD_NOTIFY` :2724 · `NOCREDIT` :2637 · `SBMIRROR` :2467 · `RESPAWN` :1906 · `PROJ` mech4.cpp:2001 · `SPLASH` mech4.cpp:1349. The death cost has NO tag.
-7
View File
@@ -422,10 +422,3 @@ positions. Consequences for this plan:
- §Headline-6's wrong-column slip (dual +0x27c increment) remains faithfully reproduced and - §Headline-6's wrong-column slip (dual +0x27c increment) remains faithfully reproduced and
replication-masked. The type-1 received report also fixes the console VTVDamaged replication-masked. The type-1 received report also fixes the console VTVDamaged
points_transfered field (was a Now() misread; it is Round(award), raw disasm @0x4c04a7). points_transfered field (was a Now() misread; it is Round(award), raw disasm @0x4c04a7).
## 2026-08-11 -- SETTLED
The chain was audited end-to-end and fixed at the authority model (ticket #162, build 4.11.885). The definitive references now live in
`context/combat-damage.md` (curated model) and `docs/KD_CHAIN_AUDIT_20260811.md`
(complete evidence). This plan file is historical from here on.
-339
View File
@@ -1,339 +0,0 @@
# The mission-end / exit-hang audit -- full findings (2026-08-11, ticket #163)
> Four-agent audit of the round-end freeze + hang-on-exit family. VERDICT:
> the mission-end ladder is CLEAN on every machine (StopMission never even
> fires in steam fields -- each pod self-ends on its local clock); the bug
> lives in the unlogged exit window: bare ExitProcess with no SteamAPI_Shutdown
> anywhere in the codebase -> intermittent DLL-detach deadlock -> the dead
> process's fullscreen frame squats on screen while its menu child hangs in
> SteamAPI_Init against the wedged sibling (field-captured: ZEUS 22:12:38).
> Fix shipped same day: BTSteamNet_ShutdownAll + ordered exit (flush, steam
> down BEFORE child spawn, hide windows, TerminateProcess) at every exit path.
> Side-yield: #156's fade root cause (POVStartEndRenderable ctor is an empty
> stub, btstubs.cpp:339) + the complete binary end-of-mission ladder.
# #163 — Task 2: The end-of-mission ladder as built, every stall-capable site flagged
Stall-capable sites are tagged **[S#]**. Evidence tiers: [T0]=our engine source read directly; claims about runtime behavior of third-party code marked [T4].
## Stage A — the HOST marshal (game/glass/btl4console.cpp)
**Arming.** btl4main.cpp:1322-1330 — if `BT_FE_EGG` is set, `BTLocalConsole_Start(fe_egg, BT_FE_PODS, atoi(BT_FE_SECS))` runs. For Steam hosting the menu sets `BT_STEAM_NET=1`, `BT_FE_MYFAKE`, `BT_FE_STEAMMAP` and *falls into the marshal-armed default* (btl4main.cpp:1284-1296). `BTLocalConsole_Start` (btl4console.cpp:588-606) copies the args into `marshalState` and `CreateThread(MarshalThread)`; the handle is closed immediately (:604) — **the marshal thread is never joined by anything**.
**MarshalThread** (btl4console.cpp:434-586), step by step:
1. Read the egg (:440-446). Failure → `MarshalLog("cannot read egg...")` and the thread just returns — the pods sit at WaitingForEgg forever **[S0]** (not tonight's shape, but a stall).
2. Connect every pod in `BT_FE_PODS` order, **self first**, sequentially (:452-482): `ConnectWithRetry(host, port, 60)` (:388-432). The Steam branch calls `BTSteamNet_Connect` which **blocks up to 30 s per attempt** (L4STEAMNET.cpp:595-609) — worst case 60×~31 s ≈ 31 min per pod **[S1]**. Any pod that never answers → `BTFE_RelaunchSelfAndExit("")` — the whole host process aborts the start (:473-476).
3. Egg → every pod (:492-497) via `SendEggChunks` (:334-363). The Winsock branch uses **blocking** `send()` — the marshal's raw sockets are never set FIONBIO (only TCP_NODELAY, :423-425) **[S2]**. Errors surface only as a `-1` chunk count in the log; no retry.
4. `Sleep(20s)`; RunMission #1 to all; `Sleep(4s)`; RunMission #2 to all (:500-512). Send results **ignored**.
5. **The mission clock** (:519-570): `mission_end = GetTickCount() + missionSeconds*1000`; loop drains and discards pod→console traffic (select() for real sockets, a nonblocking `BTSteamNet_Recv` pass for Steam pseudo-sockets :559-569). Note: a Steam connection that dies mid-mission is **invisible** here — `closedByPeer` makes Recv return 0/err and the loop just moves on; nothing logs or repairs it.
6. **StopMission at expiry** (:572-576):
```
MarshalLog("mission clock expired -- StopMission to all pods");
for (int p = 0; p < pod_count; ++p)
SendApplicationMessage(pods[p], StopMissionMessageID);
```
`SendApplicationMessage` (:369-382) writes one 28-byte packet (header clientID=4/ApplicationClientID, msg id 6, ReliableFlag). **One shot per pod. No retry. No acknowledge. The return value is ignored. There is no per-pod success/failure log** — only the single "clock expired" line **[S3 — the silent-drop site]**. Over the Steam seam, `BTSteamNet_Send` (L4STEAMNET.cpp:424-442) returns SOCKET_ERROR if the connection is `closedByPeer` (WSAECONNRESET) or if `SendMessageToConnection` fails (WSAEWOULDBLOCK) — all swallowed. Over raw TCP the blocking `send` could also wedge the loop between pods so *later pods in podList never get their stop* **[S4]** (needs a full send buffer; unlikely for 28 bytes).
7. `Sleep(MissionEndGraceSeconds=8s)` (:577), `MarshalClose` every pod (:578-581), `BTFE_RelaunchSelfAndExit("")` (:583-584) → Stage D. During the 8 s grace the marshal **no longer drains** pod traffic (pods push an immediate console score update in EndingMission — btplayer.cpp:1361-1366).
8. **Host-side race** **[S5]**: the host's *own* pod (self, first in podList) receives StopMission over its loop link, finishes its fade in ~3-4 s, and the MAIN thread reaches the BT_FE_LOOP relaunch (btl4main.cpp:1669-1674) → `ExitProcess`*while the marshal thread is still inside its 8 s grace sleep*. Whichever thread reaches `ExitProcess(0)` first kills the other mid-flight; if the main thread is slow (long Shutdown) both can pass `CreateProcessW` → two menu children. Nothing serializes the two relaunch paths.
## Stage B — the receiving pod: StopMission → RunMissions returns
**Delivery.** The 28-byte packet is routed by `networkManager->RoutePacket()`, pumped at least once per frame from `Application::ExecuteBackgroundTask` (APP.cpp:771-781; in non-Running states it routes greedily). Steam-carried console bytes are drained lazily by the seam's recv (L4STEAMNET pump is called from the seam entries).
**Handler chain**: `BTL4Application::StopMissionMessageHandler` (game/reconstructed/btl4app.cpp:765-781, @004d3a94 — plasma display off) → `L4Application::` (L4APP.cpp:693-732 — `PilotIllumination(True)` + a LightsOut self-post at +30 s) → `Application::` (APP.cpp:1706-1778):
- `gBTMissionStoppedByConsole = 1` (:1723-1726); if state==RunningMission: `gBTRoundCompleted=1`, launch pends voided (:1733-1739).
- switch (:1746-1777): `StoppingMission` → ignore; `EndingMission`/`AbortingMission``Stop()`; **default**`SetState(EndingMission)`, `networkManager->Mode(ReliableMode)` (:1762), dispatch `Player::MissionEndingMessage` to `GetMissionPlayer()` (:1759-1769); **if the mission player is NULL → `Stop()` immediately** (:1771-1774).
**The fade.** `Player::MissionEndingMessageHandler` (engine/MUNGA/PLAYER.cpp:106-113): `fadeTimeRemaining = 3.0f; ForceUpdate(); SetSimulationState(MissionEndingState)`. **BTPlayer has NO override**`MESSAGE_ENTRY(BTPlayer, MissionEnding)` (btplayer.cpp:355) resolves to the inherited Player handler; the "fade ForceUpdate site" named in the task brief is PLAYER.cpp:110.
**EVERY condition required to progress from EndingMission to `Stop()`** — the countdown lives ONLY in `Player::ManageApplicationStatus` (PLAYER.cpp:408-476; the MissionEndingState arm :444-472 counts `fadeTimeRemaining -= time_slice` and, at ≤0 with app state EndingMission, dispatches `StopMissionMessage(NullExitCodeID)` back at the application :451-456):
1. A mission player must exist (else Stop() already ran — faster, not a stall).
2. `ManageApplicationStatus` is reached ONLY from the player's active Performance: `Player::PlayerSimulation` (PLAYER.cpp:505, called from `BTPlayer::PlayerSimulation` btplayer.cpp:1329) or `CameraShipSimulation` (PLAYER.cpp:486). **If the mission player's Performance is `HuntForDropZone` (PLAYER.cpp:768 — no MAS call) or `DoNothingOnce` (set at PLAYER.cpp:380 once a dropzone answered the initial hunt; `DoNothingOnce` = `NeverExecute()`, SIMULATE.cpp:484-488 — the entity STOPS EXECUTING ENTIRELY), the fade NEVER counts down and the app sits in EndingMission forever** **[S6 — hard in-engine stall]**. This window exists from mission start until `DropZoneReply` creates the vehicle and sets PlayerSimulation + `AlwaysExecute()` (btplayer.cpp:1771/1780/1784). After that, the reset-based respawn keeps the same Performance through death, so a dead-at-mission-end pilot still fades normally.
3. Fade 3 s of simulated time must elapse; then the re-dispatched StopMission lands in the `EndingMission` case → `Stop()` (APP.cpp:1751-1754) → `executeFrames = False` + `SetState(StoppingMission)` (APP.cpp:818-820).
- There are **no queue-drain gates, no all-entities gates, no peer gates, and no other timers** in this leg.
**Backstop worth knowing**: the solo-clock shim (APP.cpp:674-695) is **ACTIVE in Steam matches** — its only gate is `getenv("BT_RELAY") == 0`, and glass/steam mode does not set BT_RELAY; the glass egg writes `length=` (btl4fe.cpp:277) → `Mission::gameLength > 0` (MISSION.cpp:336-337). So a pod that never receives the marshal's StopMission still self-posts one when its own clock (started at RunningMission entry, APP.cpp:1559) expires. A *lost* StopMission alone therefore should not strand a pod in RunningMission [T0 code-read; runtime unverified].
**RunMissions returning** (engine/MUNGA/APPMGR.cpp:39-273): next frame `ExecuteForeground` returns False (APP.cpp:516-519) → APPMGR calls `application->Shutdown()` (APPMGR.cpp:77-91). `Application::Shutdown` (APP.cpp:828-927): gauge/video/audio `Shutdown()`, viewpoint delete, `interestManager->Shutdown()`, `hostManager->Shutdown()`, `networkManager->Shutdown()` — L4NetworkManager::Shutdown (L4NET.CPP:1296-1411) closes every non-console host via `CloseConnection` = `shutdown(SD_BOTH)` + `BTNetClose` (L4NET.CPP:4658-4659), all nonblocking (engine sockets are FIONBIO throughout, e.g. L4NET.CPP:4554-4558) — deletes the mission, and **returns False** (APP.cpp:925). App is moved to the ended list; the next loop pass finds no apps → `Terminate()` (APP.cpp:934+ — deletes renderers/managers; D3D/DirectSound teardown lives here **[S7, T4]**) → `delete application`**RunMissions returns** (APPMGR.cpp:128-138). Alternate exit: WM_QUIT (window destroyed) → Terminate + return (APPMGR.cpp:102-115).
## Stage C — after RunMissions returns (game/btl4main.cpp:1618-1684)
- :1619 `"[boot] RunMissions returned (mission loop exited)."`**the key forensic marker** separating a Stage-B stall from a Stage-C/D hang.
- :1623-1626 `BTProjectilesClearAll()` — static pool scrub, memory-only.
- :1632-1635 `BTRelayUploadMatchLog()`**relay-only**: gated on `s_relayGameAddrCached` (L4NET.CPP:707-708), a **no-op on Steam nights**. When it does run: bounded 3 s nonblocking connect (L4NET.CPP:738-753), but then an **unbounded blocking SendAll** (L4NET.CPP:770) **[S8, relay only]**.
- :1645-1660 BT_RELAY rejoin — not taken under Steam.
- :1669-1674 `BT_FE_LOOP` set (it always is on menu-launched missions, btl4main.cpp:1248) → `BTFE_RelaunchSelfAndExit("")`.
## Stage D — the exit/relaunch choke point (btl4console.cpp:156-276)
1. `gBTUserRequestedExit` (WM_CLOSE stamp, btl4main.cpp:164-172) → `ExitProcess(0)` directly (:171-181).
2. Storm damper: generation <15 s → `Sleep(5000)` (:193-202).
3. Menu relaunch clears BT_FE_EGG/PODS/SECS/LOOP from the env (:211-217).
4. `CreateProcessW` of the menu child (:259-274). **A CreateProcessW failure is completely silent — no log, no retry — and control still falls to ExitProcess: window closes, no menu appears** **[S9]**.
5. `ExitProcess(0)` (:275) — **the exit-hang site [S10]**. ExitProcess terminates all other threads (the marshal, or the main thread if the marshal got here first) then runs DLL_PROCESS_DETACH serially under the loader lock. **`SteamAPI_Shutdown()` is never called anywhere in the tree** (grep hits only the SDK header, extern/steamworks_sdk_164/public/steam/steam_api.h:107) — so steam_api64/steamclient are detached with live SDR connections and their service threads hard-terminated; a detach-time deadlock there is the standing hypothesis for "BTL4 hang on exit, hosting" [T4 — inference, matches the host being the machine that hosts the most Steam connections]. D3D/driver DLL detach is the sibling candidate [T4].
6. Because every glass-path exit is `ExitProcess`, **CRT atexit/static dtors are skipped** — the RIO dtor's bounded 5 s flush (L4RIO.cpp:1160-1184) and `PCSerialPacket::ShutdownRxThread`'s **unbounded** `WaitForSingleObject(hRxThread, INFINITE)` (L4PCSPAK.cpp:204) **[S11]** only run on the plain `return Exit_Code` path (btl4main.cpp:1683, taken only when BT_FE_LOOP is unset) — and only in serial-RIO builds; glass uses PadRIO which owns no thread (no CreateThread in L4PADRIO.cpp).
## Stage E — is there any all-pods handshake at mission end? NO.
- Start side has three gates: the egg acknowledge (`AcknowledgeEggFileMessage`, L4NET.H:276/424-433), the READY notify (APP.cpp:1466-1470), and the connection gate (L4NET.CPP:1957 comment). **The end has none**: no end-acknowledge message exists (grep across engine/), the marshal reads nothing back after StopMission, and pod teardown never waits on a peer.
- `HostDisconnected` during EndingMission: GameMachineHostType arm just decrements the count and closes (L4NET.CPP:1672-1677) — no state change, no wait. Console loss mid-mission in glass/steam (non-relay, scene presented): the ConsoleHostType arm recreates the console listener and **continues the mission** (L4NET.CPP:1686-1753) — the relay-mode 15 s graceful self-stop (RelayGameDown, L4NET.CPP:2458-2472) does NOT apply outside BT_RELAY.
## Cross-cutting observations relevant to the incidents
- **Thread-safety hole [S12]**: `BTSteamNet_Pump` (SteamAPI_RunCallbacks + unlocked `connections[].ring` writes, L4STEAMNET.cpp:244-306) is invoked from BOTH the marshal thread (via BTSteamNet_Send/Recv/Connect — btl4console.cpp:119-124, 564) and the game thread (seam entries; L4STEAMNET.h:34 documents "game thread"). No lock anywhere. Concurrent RunCallbacks + ring index races are possible on the HOST only (the only process with a marshal) [T0 for the code paths; consequences T4].
- **Frozen-view interpretation** [inference]: a stalled EndingMission does NOT freeze the picture — ExecuteForeground has no EndingMission early-out, the update manager keeps simulating and the renderers keep presenting (APP.cpp:560-565 only overlays pre-run states). A literally static final frame with no menu means the frame loop *stopped* while the window lived: i.e., RunMissions returned (or Terminate/Shutdown hung) and the process then wedged in Stage C/D — most plausibly at ExitProcess [S10] or silent CreateProcessW failure [S9]. Log triage for the frozen machines: `"[boot] RunMissions returned"` present → Stage C/D hang; absent but `StopMission`/plasma-off lines present → Stage B stall ([S6]/[S7]); no stop lines at all → the marshal's send was silently dropped ([S3]) and the solo-clock backstop should then have fired ~launch-skew seconds later — if it didn't, check `length=` in the session egg.
## Stall-site index
| # | Site | File:line | Bound |
|---|------|-----------|-------|
| S0 | egg unreadable → marshal thread exits, pods stranded | btl4console.cpp:440-446 | forever |
| S1 | ConnectWithRetry, Steam branch | btl4console.cpp:388-408 + L4STEAMNET.cpp:595-609 | ~31 min/pod |
| S2 | blocking egg/TCP sends (marshal sockets never nonblocking) | btl4console.cpp:334-363, 409-431 | OS send timeout |
| S3 | StopMission one-shot, errors swallowed, no per-pod log | btl4console.cpp:572-576, 369-382; L4STEAMNET.cpp:424-442 | n/a (silent drop) |
| S4 | blocking StopMission send can starve later pods | btl4console.cpp:573-576 | TCP buffer dependent |
| S5 | main-thread relaunch races marshal's 8 s grace; dual relaunch | btl4main.cpp:1669-1674 vs btl4console.cpp:577-584 | race |
| S6 | fade counts only in PlayerSimulation; DoNothingOnce=NeverExecute strands EndingMission | PLAYER.cpp:380, 444-472; SIMULATE.cpp:484-488 | forever |
| S7 | Terminate/Shutdown renderer teardown (D3D/DSound) | APP.cpp:838-865, 944-973 | unbounded [T4] |
| S8 | matchlog upload blocking SendAll (relay only) | L4NET.CPP:770 | TCP dependent |
| S9 | CreateProcessW failure silent, no menu | btl4console.cpp:259-274 | n/a |
| S10 | ExitProcess w/ live SteamAPI (no SteamAPI_Shutdown anywhere), DLL detach | btl4console.cpp:275; steam_api.h:107 | unbounded [T4] |
| S11 | PCSerialPacket join INFINITE (static-dtor path only, non-glass) | L4PCSPAK.cpp:204 | unbounded |
| S12 | unsynchronized two-thread Steam pump on the host | L4STEAMNET.cpp:244-306; btl4console.cpp:119-124 | corruption risk |
==============================================================================
# Task 4 — Steam console-link delivery of StopMission (ticket #163)
## 1. The exact delivery chain for StopMission under Steam
**Dial timing — at GO, once, never again.** The host's mission process arms the marshal thread at boot (`game/btl4main.cpp:1322-1329``BTLocalConsole_Start`, `game/glass/btl4console.cpp:588-606`). `MarshalThread` dials EVERY podList entry up front, serially, before anything else: `ConnectWithRetry(host, port, 60)` at `btl4console.cpp:470`. For a fake-IP entry the Steam branch (`btl4console.cpp:391-408`) calls `BTSteamNet_Connect` up to 60 times, 1 s apart; each attempt itself blocks up to 30 s in the SDR handshake wait (`engine/MUNGA_L4/L4STEAMNET.cpp:595-609`) — worst case ~31 min of near-silence before the abort at `btl4console.cpp:473-476` (which aborts the mission start for EVERYONE via relaunch). [T1 — read from our own source]
**Token resolution.** `BTSteamNet_Connect` maps the token IP to a SteamID64 via the `tokens[]` table (`L4STEAMNET.cpp:556-568`), loaded ONCE per process at `BTSteamNet_Install` from env `BT_FE_STEAMMAP` (`L4STEAMNET.cpp:354-384`); port 1501 selects P2P virtual channel 0 = console (`L4STEAMNET.cpp:569`, `TokenConsolePort` at `:67`). The connection is a `ConnectP2P` handle wrapped in a pseudo-SOCKET (`0x5EA0xxxx`, `L4STEAMNET.cpp:93-97`).
**Send path — RunMission and StopMission ride the SAME connection.** Egg chunks (`SendEggChunks`, `btl4console.cpp:334-363`), RunMission #1 (+20 s), RunMission #2 (+4 s), then the marshal holds the clock (`:519-570`) draining pod→console bytes, then StopMission at clock expiry (`:572-576`), 8 s grace, then `MarshalClose` all pods (`:577-581`) and relaunch (`:583-584`). There is **no re-dial, no ack, no health check anywhere** — StopMission depends on the connection dialed at GO still being alive ~10 min later. Wire message: 28-byte packet, clientID=4 (ApplicationClientID), messageID=6 (`StopMissionMessageID`, `btl4console.cpp:75`, per `APP.h:383`), sent via `BTSteamNet_Send``SendMessageToConnection(..., k_nSteamNetworkingSend_ReliableNoNagle)` (`L4STEAMNET.cpp:433-435`).
## 2. Every silent-failure point in that chain
1. **Send results are discarded.** `SendApplicationMessage` returns -1 on failure but the return is ignored at all three call sites (`btl4console.cpp:503, 509, 575`). A failed StopMission produces NO log line; the marshal logs only the aggregate "StopMission to all pods" (`:572`) BEFORE sending. [T1]
2. **Send-on-dead-connection is an error only if the death was already noticed.** `BTSteamNet_Send` returns SOCKET_ERROR/WSAECONNRESET only when `closedByPeer` was set by the status callback (`L4STEAMNET.cpp:428-431`), and SOCKET_ERROR/WSAEWOULDBLOCK when `SendMessageToConnection` fails (`:436-440`) — both swallowed by (1). Worse, a reliable send on a dying-but-not-yet-flagged connection returns `k_EResultOK`, buffers the bytes, and they are dropped when the connection is later closed — **undetectable even if (1) were fixed**. [T4 — SDK semantics, not exercised in a bench]
3. **The marshal never learns a console link died mid-match.** The clock-hold drain loop treats Steam recv()==0 (dead: `L4STEAMNET.cpp:459-462`) identically to -1/no-data (`while (BTSteamNet_Recv(...) > 0)`, `btl4console.cpp:564`); the real-socket path likewise ignores `recv`==0 (`:550`). No close callback reaches the marshal; the only trace is the engine-side `[steamnet] connection N closed (…)` line printed from the status callback during a pump (`L4STEAMNET.cpp:219-231`). No reconnect logic exists. [T1]
4. **Mesh/Steam pods have NO console-loss StopMission fallback.** The 15 s self-stop on console loss is RELAY-ONLY (`RelayGameDown`, `engine/MUNGA_L4/L4NET.CPP:2456-2472`). In mesh/steam mode a `ConsoleHostType` disconnect just destroys and re-listens the console host (`L4NET.CPP:1686-1752`, `CreateConsoleHost` at `:885-1023`) — a pod that loses its console link mid-match plays a **forever-mission**: peers time out and freeze as stale replicants — literally "a frozen view of how the game ended" with no menu transition. [T1 code + T2 field, below]
5. **Pump concurrency hazard (host only).** `BTSteamNet_Pump` mutates the global `connections[]`/rings and runs `SteamAPI_RunCallbacks` with zero locking (`L4STEAMNET.cpp:244-306`), and on the HOST it is called concurrently from the marshal thread (via Send/Recv/Connect, e.g. `btl4console.cpp:564`) and the main game thread (via the L4NET seam, `L4NET.CPP:48-96`). Only the host has this exposure. [T1 for the race's existence; T3 for consequences]
6. **Accept is channel-blind.** `BTSteamNet_Accept` ignores which listener polled and dequeues either channel (`L4STEAMNET.cpp:476-501`); the console-host accept path trusts "whatever connects to a console host" (`L4NET.CPP:3876-3894`). Benign so far, but a game-channel dial can be adopted as the console. [T1, no field hits]
7. **Second-round state:** the connection table is per-process (fresh each round — every round is a new process), and mission children get fresh `BT_FE_MYFAKE`/`BT_FE_STEAMMAP` at GO (`btl4main.cpp:1279-1287`). But a MENU relaunch clears only `BT_FE_EGG/PODS/SECS/LOOP` (`btl4console.cpp:211-217`) — menu children carry the PREVIOUS round's token map (visible in the field: relaunched menus log "N roster token(s) incl. self" at boot). The real cross-round asymmetry found is **zombie processes**: a round-N process wedged at exit stays Steam-online into round N+1 (see acaci's ghost lobby seat, below). [T1/T2]
**Receiving side (pod).** `CreateConsoleHost` listens on 1501 (`L4NET.CPP:962-993`) — under Steam the marshal's dial arrives via the P2P channel-0 listen socket, is accepted inside the status callback (`L4STEAMNET.cpp:189-203`), queued, and dequeued by the console host's `BTNetAccept` poll (`L4NET.CPP:3814-3821`); the console host goes OnLine (`:1593-1595`, logs "Connected to ConsoleHost at <token .1>:1501"). Marshal-side death is detected only by `BTNetRecv` returning 0 (or WSAECONNRESET→0) during `CheckBuffers` (`L4NET.CPP:3989-4019`). At mission end the pod does NOT close the console link — teardown explicitly keeps the console host (`L4NET.CPP:1367-1370`); the MARSHAL closes it 8 s after StopMission (`btl4console.cpp:577-581`), which the pod handles as a normal console disconnect + re-listen. So there is no close-while-the-other-side-needs-it on the pod side. [T1]
## 3. Field evidence (scratchpad/night15, six logs; frozen = santo/MS-FIREFLY, acaci/ZEUS; Lynx has NO log)
**StopMission WAS delivered to every logged pod in both incident rounds.** Every mission session on santo ends with the full clean tail `[boot] RunMissions returned` + `[fe] mission over -- relaunching the menu` (14/14, e.g. santo:57956-57957 for the round ending 21:40:50 — the 21:41 incident round — and santo:201693 for the round ending 22:47:21 — the 22:48 incident round). Michael (d:153225-153226) and Dave (a: tail before 218096) likewise ended the 22:47 round cleanly. **No `[steamnet]` close, no ConsoleHost disconnect, no PEER_DOWN anomaly at either incident round-end on any logged machine.** The freeze on the logged machines happened AFTER the last log line of a clean in-process mission end — i.e., in the relaunch/exit seam, NOT in Stop delivery. [T2]
**But genuine console-link death happened that night — twice — and its signature matches the reported symptom exactly.** Mid-round, all pods lost BOTH links to the host token (.1) with `[steamnet] connection N closed (Timeout; remote problem. Rx age server 12-21s ...)` followed by `Disconnected from ConsoleHost at 169.254.77.1:1501`: eleng f:22366-22372 (+ a second episode f:50374-50455) and the santo(e:35887-35922)/rajel(b:20340-20416) round (same round; rajel's clock is -3 h). After the loss the pod keeps simulating with no console and no fallback (finding 4): peers ghost out (`[ghost] replicant 5:29 has received NO update records for 601 frames`, f:~22434) and the mission never ends — each player eventually closed the window by hand (`[marshal] window closed by the user -- exiting for real`, e:36012, f:22510). The "Timeout; remote problem" reason means the HOST process went silent mid-round (crash/wedge, not graceful close) — the host is the machine with the marshal-thread pump race (finding 5). [T2; the race attribution is T3]
**The frozen machines' actual signatures:**
- **santo ~21:41:** round ended cleanly 21:40:50 (e:57957); the menu child (pid 28112, e:57959-57966) booted fully, auto-joined the lobby within 1 s, and received GO normally at 21:43:10 (mission child pid 31404, `relaunched generation`, e:57967). The 2m20s hole between a clean end and the next GO, with a healthy-but-invisible menu, fits a wedged mission-parent window sitting frozen on top of the live menu (the foreground-handoff problem this code already documents, `btl4console.cpp:262-271`). [T2 logs; interpretation T3]
- **acaci 22:48:** ZEUS's log goes SILENT at 22:12:38 — the post-round menu child (pid 29344, c:213897-213900) printed exactly 3 boot lines and never reached `[steamnet] up`, which for a relaunched child is unconditional ~1 s after boot (`BT_STEAM_NET` is inherited; boot-time install at `btl4main.cpp:1306-1316`). It hung at/before `SteamAPI_Init` (`L4STEAMNET.cpp:337`). acaci never played again (file mtime 23:18, nothing after 22:12:38) — **acaci was NOT in the 22:37-22:47 round at all**; yet acaci's SteamID (76561198064247614) still appears as token .2 in the 22:47 lobby map on santo/Michael (e:201702, d:153233) — a ghost lobby seat held by the wedged process. [T2; "hung at SteamAPI_Init" is T3 — the alternative, a user-kill inside the ~1 s boot window, is implausible given the same machine repeated it]
- The same 3-line pre-SteamAPI_Init signature ends EVERY machine's log (Dave 22:47:22 — the exact incident round end, a:218096-218099; eleng 21:47:21; rajel 20:25:37; Michael 23:25:36; santo 23:25:37) and appears mid-night followed by ~1-2 min manual-restart gaps (Dave 20:31:52→20:32:40 a:31170; acaci 21:07:11→21:09:13 c:60457). The relaunch/exit seam wedges routinely; nobody calls `SteamAPI_Shutdown` anywhere (grep: SDK headers only), and every round-end exits via `ExitProcess` from `BTFE_RelaunchSelfAndExit` (`btl4console.cpp:275`) — on the HOST from the marshal thread while the main thread is mid-teardown (`btl4console.cpp:583-584`), matching Lynx's "hang on exit, hosting". [T2 signatures; ExitProcess/DLL-detach mechanism is T4]
## 4. Bottom line for #163
The StopMission delivery chain is a single unmonitored 10-minute-old connection per pod with at least four fully silent failure modes (send-result discarded; error mapped then discarded; reliable-buffer drop; dead-vs-empty indistinguishable in the drain loop) and no mesh-mode fallback on the pod — a real design hole, and the night's two host-death rounds show exactly what its failure looks like (orphaned forever-mission, frozen peers). **However, for the two ticketed incidents the field logs exonerate delivery:** every logged pod received Stop and ended its mission in-process; the freeze and the exit hang live in the round-end relaunch/exit seam (ExitProcess without Steam shutdown; menu children hanging pre-SteamAPI_Init; ghost lobby seats from wedged processes), on which the marshal's host-side thread-kill-at-clock-expiry piles extra risk for the host machine.
==============================================================================
# #163 Field Forensics — night15 (2026-08-10, build 4.11.883)
## 0. Ground work: clock alignment and identity map (all T2 unless noted)
Wall clocks were aligned by matching identical `[lobby] join ... map [...]` strings and identical session-cadence across machines (per instructions, not by raw wall clock):
| Machine | User | Steam ID | Clock offset vs ET | Evidence |
|---|---|---|---|---|
| MS-FIREFLY | santo | 76561198049064449 | **= ET** | own token, santo log line 15 |
| SCREECH-PC | Dave | 76561198022091594 | = ET | sessions 21:30:18/21:40:54… match santo; hosted the 19:40 2-pod round (Dave 766) whose lobby shows .1=…022091594 (eleng line 9) |
| XIAOLONG | Michael | 76561198020775163 | = ET | sessions 21:43:10/21:53:46 match santo; hosted the 23:15 round (Michael 209577) whose map has .1=…020775163 (santo 255102) |
| DESKTOP-QR9VPJQ | Lynx | 76561198659597127 | = ET | matchlog B HDR (his machine) is exactly +1:00:00 vs ALIA's matchlog A of the same events |
| ALIA | eleng | 76561198147980449 | **ET 1:00:00** | glass sessions 20:30:17/20:43:10/…/21:36:48 = santo's 21:30:17/…/22:36:48 to the second |
| ZEUS | acaci | 76561198064247614 | ET 1:00:13 (≈ALIA13s) | ZEUS 21:36:35→21:47:11 pairs with ALIA 21:36:48→21:47:21 |
| GAMERSLAB | rajel | 76561197970523304 | **ET 3:00:00** | rajel 18:40:50/18:43:10/18:53:46… = ALIA 20:40:50/20:43:10/20:53:47 exactly 2h (⇒ 3h vs ET, matching the "~3h behind" note) |
| (no log) | ? | 76561197976435895, 76561197995508393 | — | extra players, day logs not collected |
**CRITICAL CORRECTION TO THE TICKET'S PREMISE [T1]:** `matchlog_20260810_213656_20476.txt` (ALIA, local 21:36) and `matchlog_20260810_223651_12120.txt` (DESKTOP-QR9VPJQ, local 22:36) are **the SAME mission** — the 22:36 ET round — recorded on two machines whose clocks differ by exactly 1h. Proof: identical MISSION run instant (A line 22: `MISSION … w=21:37:21.041 st=4 run` vs B line 22: `w=22:37:20.047`), identical terminal events (A line ~1319: `DEATH victim=2:20 … pos=-27.5,0.0,144.2` + final `SBMIRROR player=8:1 kills=4 deaths=2 wasKills=3 wasDeaths=2`; B lines 1816-1818: same death at `pos=-27.6,0.0,144.2`, same SBMIRROR). Matchlogs are written per-machine (each pod keeps one), confirmed by dev-bench pairs in `scratchpad/night15/mlbak/` (two matchlogs per bench round). There is **no matchlog for the 21:41-incident round** in the staged set. Lynx DID host the 22:36 round (host token .1=…659597127 in the round's map, ALIA line 172106) — and in fact hosted **every round from ET 21:08 through 22:47**, including BOTH incident rounds (santo maps at 36020, 46238, 57965, 68589, 101519, 124366, 156700 all show .1=…659597127).
## 1. The mission-end ladder as it appears in these logs
There are **no** 'EndingMission'/'StopMission'/state-name lines in this build's day logs. The observed healthy end shape (every clean round, every machine) is:
```
[mission] solo game clock expired (600s|1200s) -- ending the mission
[score] gauge read / ~RankAndScore: I think my score is N
[lamp] 0xNN <- 0x0 (×41, cockpit lamps off)
SVGA16::~SVGA16: pixel management statistics + 4 lines
[glasswin] destroy entry #1 windows=0
[glasswin] destroy entry #2 windows=0
[boot] RunMissions returned (mission loop exited).
[fe] mission over -- relaunching the menu <=== LAST LINE the process ever logs
===== BT411 SESSION ... ===== (the relaunched MENU process, new pid)
```
(e.g. santo 57904→57959). ~50-60 lines, wall time ≤3s (the menu header lands the same/next second as the computed 600s expiry). After `[fe] mission over` the process calls `BTFE_RelaunchSelfAndExit("")``CreateProcessW` + `ExitProcess(0)` (game/btl4main.cpp:1671-1673, game/glass/btl4console.cpp:157-275) — **everything after the `[fe]` line is unlogged by construction**.
## 2. Incident A — ~21:41 ET, santo (MS-FIREFLY) frozen
**Round:** ET 21:30→21:40:50, Lynx-hosted, 6 players (santo 46238 map: Lynx.1, eleng.2, acaci.3, santo.4, Dave.5, rajel.6), 600s clock. Michael not in it.
**santo's end sequence is textbook-clean and on time [T1]:**
- 57887-57903: normal in-mission ticks (santo idle: `speedDemand=0`, alive — targeting active; the last nearby deaths are replicants 7:4 at 54956 and 6:4 at 57832, NOT santo).
- 57904 `[mission] solo game clock expired (600s)` → 57906 `~RankAndScore: I think my score is 2686` → lamps → 57948 `SVGA16::~SVGA16` → 57954-5 glasswin destroys → 57956 `RunMissions returned` → 57957 `[fe] mission over -- relaunching the menu`**57959 new MENU session at 21:40:50** (same second as everyone else, below). The log never goes silent mid-ladder and never keeps ticking past the end. (No `[glassperf]` beats exist on this machine — the tag appears only in Dave's and Michael's logs.)
- **Recovery:** the relaunched menu was alive immediately — 57963-57965 it joined the NEXT round's lobby (token .7); santo was playing again at **21:43:10 ET** (57967), ≈2m20s after the freeze report. No manual cold restart visible.
**Same-round healthy comparison (identical ladder shapes, same wall second):**
| machine | EXPIRED line | menu-header line | menu local time (→ET) |
|---|---|---|---|
| santo (frozen) | 57904 | 57959 | 21:40:50 |
| eleng | 73366 | 73424 | 20:40:50 (21:40:50) |
| acaci | 11986 | 12045 | 20:40:37 (≈21:40:50) |
| Dave | 75044 | 75126 | 21:40:54 |
| rajel | 44983 | 45041 | 18:40:50 (21:40:50) |
**Conclusion [inference, flagged]:** the freeze left NO trace in santo's day log. Everything up to and including the menu relaunch executed on time; the only unlogged window is the old glass process's `ExitProcess(0)` path after `[fe]`. The "frozen view of how the game ended" is consistent with the OLD process hanging after spawning the menu — its last rendered frame stays on the fullscreen/plasma window on top while the new menu opens BEHIND (the build even logs `[marshal] foreground handoff denied -- the next window may open behind` for this Z-order hazard, e.g. santo 46239; emit site btl4console.cpp:267-271).
## 3. Incident B — ~22:48 ET, acaci (ZEUS) + Lynx (host) frozen
**Round:** ET 22:36:51→22:47:2x, Lynx-hosted, 8 players (map ALIA 172106: Lynx.1, acaci.2, …976435895.3, santo.4, eleng.5, Michael.6, Dave.7, rajel.8), GO at 22:37:20.0 (matchlog B line 22), 600s ⇒ nominal expiry 22:47:20 ET.
**acaci (ZEUS) end sequence [T1]** — clean, but with the night's most interesting receipt:
- 159680 `[wreck] replicant 2:20 entered wreck state … at (-27.6352,144.223)`**Lynx's mech dying at 22:47:15.6**, the same death both matchlogs end on.
- **159890 `[steamnet] connection 1 closed (closed)` + 159892 `Disconnected from GameMachineHost at 169.254.77.1:1502` — the HOST (Lynx) dropped ZEUS's game link BEFORE ZEUS's own clock expired** (ZEUS's 600s clock ran ~3-10s later than the pack because its RunMission receipt timing; it was the last machine still in-mission).
- 159894 `EXPIRED (600s)` → 159909/159911 second disconnect (`169.254.77.5:1502` = eleng, tearing down in parallel) → 159970 `RunMissions returned` → 159971 `[fe]`**159973 menu session 21:47:11 local (≈22:47:24 ET)**.
- **Recovery:** menu joined the 22:52-round lobby immediately (159979); acaci played the 22:52 ET round (159981, glass 21:52:09 local) — recovered ≤5 min; quit for the night cleanly at 23:14:36 ET (`lastrun_steam_acaci_ZEUS_8.txt` line 29: `[2026-08-10 22:14:36] clean exit: player quit from the menu`; his day log's final 3-line menu stub at 213897 is the systemic buffered-tail loss described in §6, not a wedge).
**Same-round healthy machines:** Michael 153171→153227 (menu 22:47:20), santo 201637→201695 (22:47:21), rajel 192406→192463 (19:47:21=22:47:21), eleng 216583→216639 (21:47:21=22:47:21), Dave 218029→218096 (22:47:22). All within 0-4s of nominal expiry, identical ladders. eleng then quit for the night at 22:47:48 ET (lastrun line 47) — unaffected.
**Lynx (host, no day log):** his own matchlog (B) stops at 1818 `SBMIRROR … w=22:47:15.805` — his own death (`PLAYER_DEAD … player=2:1 deaths=10` at 22:47:15.642, B line ~1813), **5.6s before expiry, respawn never recorded** — and contains no mission-end receipt. He reported "BTL4 hang on exit, hosting" + Task Manager; he is absent from both later lobbies (22:52 map santo 201701; 23:15 map santo 255102) — **never returned**. The pre-expiry link close seen on ZEUS (`(closed)`, i.e. orderly close, not timeout) proves Lynx's process DID start its teardown (its clock expired first); the hang happened later in his exit path [inference].
## 4. Did StopMission ever arrive? (Task 4)
**No — on any machine, at any round, all night [T1]:**
- `grep StopMission` over all six day logs (≈90MB): **0 hits**.
- Every mission end on every machine is the LOCAL fallback: `[mission] solo game clock expired (Ns) -- ending the mission` (14 on santo, 10 on eleng, 8 on acaci, etc.).
- The marshal's only sends all night are launches: `[marshal] RunMission #1 sent to N pod(s)` / `RunMission #2 sent -- mission running; clock Ns` (Dave 766/899, 13401/13588, 16085/16289, 23146/23304, 32151/32319, 50684/50831; rajel 19145/19269; Michael 615/733, 209577/209723). No `[marshal] StopMission` line ever.
- Consequence: rounds end as N independent local expiries skewed by RunMission-delivery jitter (0-13s spread observed), and the host tears its links down whenever ITS clock fires — peers see `Disconnected from GameMachineHost … :1502` pre-expiry when their clock is late (ZEUS 159890 in incident B; also Dave 118701 & 141778, Michael 13749, eleng 60403 & 84435 — all Lynx-hosted rounds).
- Corroborating shape from earlier the same night: when a HOST vanished mid-round (Dave closed his hosting window at 20:21:27 and 21:06:28 ET), peers did NOT transition — they sat in-mission until SteamNetworkingSockets timed out (`Timeout; remote problem. Rx age server 11-21s`: rajel 20340-20416, santo 35887-35922, eleng 22366-22440 & 50374-50455) and each user had to close the window (`RunMissions returned` + `[marshal] window closed by the user`). There is no host-loss or console-driven end path under Steam in the field [T1].
## 5. Matchlog tails vs bench baseline (Task 3)
- Field matchlogs (both machines, incident-B round) end mid-combat with NO end receipt: A ends `SBMIRROR … w=21:47:16.821`, B ends `SBMIRROR … w=22:47:15.805`. Neither contains any `st=6`/stop/`PEER_DOWN` record; the only MISSION line each is `st=4 run`.
- Dev-bench matchlogs (mlbak/, builds 874-885) normally end with **`PEER_DOWN t=… host=1 type=3`** (the console/relay link closing at teardown) — e.g. matchlog_20260811_005558_2240.txt last line, matchlog_20260810_102334_9548.txt last line.
- So both field matchlogs lost their teardown tail. Since ALIA demonstrably transitioned cleanly, the missing tail is NOT diagnostic of the hang; the likely mechanism is the `ExitProcess(0)` relaunch path discarding the matchlog's unflushed stdio buffer (btl4console.cpp:275) [T3].
## 6. Systemic log-tail artifact (don't misread it)
Every collected day log ends with a fresh MENU session header + exactly 3 `[boot]` lines and nothing more (Dave 22:47:22, eleng 21:47:21, rajel 20:25:37, Michael 23:25:36, acaci 22:12:38, santo 23:25:37). The lastrun files prove these menus ran fine and exited cleanly minutes later (ZEUS quit 22:14:36 local; ALIA quit 21:47:48 local; santo's final sessions rotated into `steam_20260810.1.log` — not collected; Dave's post-22:47 activity, including hosting the 22:52 ET round per map token .1=…022091594, is likewise missing from the collected file). The `[boot]` lines carry explicit `std::flush` (btl4main.cpp:1605-1619); the later `[steamnet]`/`[lobby]` lines evidently don't survive the quit path's buffer loss. Truncated final menu stub ≠ wedge.
## 7. Healthy baseline round (Task 1 control): ET 22:22:36 → 22:33:11, Lynx-hosted, 8 players
| machine | glass session (local) | EXPIRED line | menu line | menu local (→ET) |
|---|---|---|---|---|
| santo | 22:22:36 (124368) | 156637 | 156694 | 22:33:11 |
| Michael | 22:22:36 (74169) | 107656 | 107712 | 22:33:10 |
| Dave | 22:22:37 (141879) | 173829 | 173890 | 22:33:11 |
| eleng | 21:22:36 (140157) | 172042 | 172100 | 21:33:11 (22:33:11) |
| acaci | 21:22:23 (83362) | 115098 | 115152 | 21:32:57 (≈22:33:10) |
| rajel | 19:22:36 (113680) | 146649 | 146702 | 19:33:11 (22:33:11) |
Identical ~55-line ladders, all six menus stamped within 1s. The incident-round ladders of the frozen machines are line-for-line indistinguishable from this baseline.
## 8. Synthesis (clearly marked inference where noted)
1. [T1] The in-log mission-end ladder COMPLETED normally and on time on every frozen machine; the freeze lives entirely in the unlogged post-`[fe]` exit path (`BTFE_RelaunchSelfAndExit``ExitProcess(0)`, btl4console.cpp:275).
2. [Inference] "Frozen view of the ended mission" = the old glass process hung inside `ExitProcess` (classic DLL-detach/terminated-thread deadlock territory: SNS threads, D3D, the plasma-window thread) with its last frame still on screen, while the already-spawned menu opened BEHIND it (`AllowSetForegroundWindow` hazard logged at btl4console.cpp:269). Task Manager kill of the OLD pid clears it — exactly Lynx's report.
3. [T1] Both incidents happened in Lynx-hosted rounds; Lynx's own machine hung at exit in incident B while hosting (7 console links + relay + marshal to unwind). In incident B, Lynx's teardown began BEFORE the last peer's expiry (orderly `(closed)` on ZEUS 159890) and then hung.
4. [T1] StopMission is not part of this build's field behavior — every end is the local solo clock; the marshal only launches. Host death mid-round strands every peer in-mission until manual window close (three occurrences 20:08-21:07 ET).
5. [T2] Lynx died in-game 5.6s before the clock and his respawn never appears before the end; acaci and santo were alive at their freezes — a death-at-end race is NOT the common factor across the three frozen instances.
6. Recovery: santo ≈2m20s (auto-relaunch worked; next round 21:43:10 ET); acaci ≈5 min (next round 22:52 ET, quit cleanly 23:14:36 ET); Lynx: killed via Task Manager, never rejoined.
==============================================================================
# Ticket #163 / #156 — TASK 3: The binary's end-of-mission truth
Scope: the 1995 `BTL4OPT.EXE` end ladder, read from `reference/decomp/all/part_*.c` + the T0 WinTesla MUNGA source the port compiles against, with constants byte-read from the image (`scratchpad/rdva.py`) and one raw disasm (`tools/disas2.py`). Everything is [T1] (decompiled + cross-checked against T0/raw bytes) unless tagged.
## 0. Headline answer
**The 1995 end sequence is EVENT → TIME → EVENT, and the only timed leg runs inside the mission player's own simulation tick.** Console `StopMission` (event) puts the app in `EndingMission` (state 6) and dispatches `MissionEnding` to the mission player; the player then counts a **3.0-second** `fadeTimeRemaining` down **in player-Performance time** (`ManageApplicationStatus` @0x42df8c); at ≤0 it self-dispatches a **second `StopMissionMessage`** (event) which, arriving at state 6, calls `Application::Stop()` @0x44e6c0`executeFrames=0` → the mission loop unwinds. **Nothing in the ladder waits on the visual fade** — the fog fade renderable (@0x45447c) is a passive video watcher. **The ladder CAN stall in exactly two binary-grounded ways:** (a) StopMission #1 never arrives — the mission runs forever (1995's console owned the clock; the pod never stops itself — `context/multiplayer.md:639-641`); (b) the mission player's Performance never executes while in `MissionEndingState` — then `fadeTimeRemaining` never decrements, StopMission #2 is never dispatched, and the app parks in `EndingMission` FOREVER. Once the countdown completes, StopMission #2 is a **direct synchronous `application->Dispatch`** (PLAYER.cpp:454-455; @0x42df8c line 740) — it cannot be lost in transit.
## 1. The Application state machine's EndingMission arm (binary addresses)
State enum (T0 `engine/MUNGA/APP.h:188-202`): Initializing=0, WaitingForEgg=1, LoadingMission=2, WaitingForLaunch=3, LaunchingMission=4, RunningMission=5, **EndingMission=6**, StoppingMission=7, Suspending=8, Resuming=9, Aborting=10, CreatingMission=11. App state read as `*(app+0x88)`; the `StateIndicator` sits at app+0x74 with current state at indicator+0x14, and `SetState` = `FUN_0041bbd8` @0x41bbd8 (part_002.c:5510) — which also **pings three watcher chains** (indicator+0x18/+0x2c/+0x40, vtbl+0x14 notify) — this is how the fade renderable learns of state flips.
Binary Application TU (part_007.c:3090-3810):
- **`FUN_0044eeb4` @0x44eeb4 = `Application::StopMissionMessageHandler`** (part_007.c:3698-3737): state 7 → ignore; state 6 or 10 → `Stop()` (@0x44e6c0) — this is the SECOND arrival; default → `SetState(6)`, and if `missionPlayer(app+0x14)` non-null: `networkManager->Mode(0=ReliableMode)` (vtbl+0x30 on app+0x20, part_007.c:3721 == T0 APP.cpp:1762) then dispatch **`Player::MissionEndingMessage {size 0x1c, id 0x19}`** to the player; if NO mission player → `Stop()` directly. Matches T0 APP.cpp:1705-1778 exactly.
- **`FUN_0044ef4c` @0x44ef4c = AbortMissionMessageHandler** — same shape, `SetState(10)`, no ReliableMode switch.
- **`FUN_0044e6c0` @0x44e6c0 = `Application::Stop()`**: `executeFrames(app+0x70)=0`; flush DEBUG stream; `SetState(7)`. (T0 APP.cpp:797-821.)
- **`FUN_0044e488` @0x44e488 = `Application::ExecuteForeground`** (part_007.c:3138-3176): returns 0 immediately when `executeFrames==0`; at state 5 recomputes `secondsRemainingInGame(app+0x58) = mission->length(+0xe0) (NowgameStarted(+0x5c))/tps`; final return is `executeFrames && !Exit_Code` where **Exit_Code = `DAT_004efc98`**. **There is NO pod-side action when the clock hits 0** — app+0x58 is display-only; the stop was the console's job.
- **`FUN_0044e6e8` @0x44e6e8 = base `Application::Shutdown`** (part_007.c:3239-3278): gauge/video/audio renderer `Shutdown+UnlinkFromEntity` (+0x4c/+0x48/+0x44), delete viewpointEntity (+0x6c), interestManager/hostManager/networkManager shutdown, delete `currentMission(+0xC8)`, then **`executeFrames=1; SetState(1=WaitingForEgg); return 0`**.
- Other rungs for contrast: CheckLoad `@0x44ebec` (2→3, and if **no console host** (`FUN_00429078(app+0x2c)`) self-launches RunMission — the console-less path), RunMission `@0x44ecdc` (3→4 + dispatch **MissionStarting id 0x18** to player; 4→5 + `gameStarted=Now()`; else Fail @APP.CPP:0x609 — the port's launch/load-race crash site), Suspend `@0x44ed88` (5→8 + dispatch id 0x19 — suspend ALSO rides the player MissionEnding fade), Resume `@0x44ee10`, KeyCommand `@0x44efd8` ('&'=0x26 → `Exit_Code=1` + `Stop()`).
**There is NO app-side timer in the end arm.** The only end-sequence time constant anywhere is the player's 3.0 s fade (plus the fade renderable's cosmetic 1.0 s, §3). No mission-review display period exists at the app level; the ranking window is a separate per-player display driver (§below).
**Ranking-window behavior at end** — `FUN_0042eb38` @0x42eb38 (part_004.c:1387-1426, called from PlayerSimulation/CameraShipSimulation): at states **6/7 the window flag (player+0x224) is forced 0 (hidden)**; while `secondsRemainingInGame > 30.0` (`_DAT_0042ec2c`=30.0, byte-read) it runs a periodic show/hide machine (+0x228/+0x22c/+0x230/+0x234); at ≤30 s it pins the flag to 1 (solid on). So the authentic end was: standings solid for the final 30 s, then StopMission → **ranking hidden + 1 s fade to black**. (The camera-seat DIRECTOR.cpp:107 additionally shows its ranking during EndingMission — different display, camera seat only.)
**Crucial executability fact [T0]:** `Entity::Execute` (ENTITY.cpp:556-558, quoted in `context/reconstruction-gotchas.md §29:920-934`) calls `PerformAndWatch` only in `RunningMission || EndingMission || IsPreRunnable()` — so the world (and the player's fade countdown) KEEPS TICKING during state 6, and stops at state 7. A screen showing a *frozen last frame* therefore means the app got PAST state 6 (Stop() ran, `executeFrames=0`, renderers no longer execute) but the process never finished Shutdown/relaunch — whereas a player *stuck in state 6* would see a live, still-simulating world that never returns to menu. (Relevance flag for #163 triage, [T4 inference]: post-`RunMissions` in the modern port comes the matchlog-upload dial + relaunch (`context/multiplayer.md:110`, `:541-545`) — a blocking network call there matches "frozen final frame + hang on exit".)
## 2. The Player handlers @0x4bfc20 / @0x4bfbe8
Base handlers (part_004.c:409-430):
- **`FUN_0042d9c0` @0x42d9c0 = base `Player::MissionStartingMessageHandler`**: `fadeTimeRemaining(+0x1F4) = 3.0f`; `SetSimulationState(3=MissionStartingState)`.
- **`FUN_0042d9e0` @0x42d9e0 = base `Player::MissionEndingMessageHandler`**: `fadeTimeRemaining = 3.0f`; **`ForceUpdate` (`+0x18 |= 1`)** — this ships the final player update record (score) under the just-switched reliable mode; `SetSimulationState(4=MissionEndingState)`. Neither touches the Performance pointer or `simulationFlags`.
**BT overrides** (part_013.c:18556-18588; raw disasm of @0x4bfc20 via `tools/disas2.py` this session):
- **`FUN_004bfbe8` @0x4bfbe8 = BTPlayer::MissionStarting**: base @0x42d9c0, then `if (app->state==4 /*LaunchingMission*/ && !(this+0x29 & 0x40 /*NonScoringPlayerBit*/)) currentScore(+0x1c8) = 1000.0f` — the manual's "+1000 starting the game" seed (port: `game/reconstructed/btplayer.cpp:788-806`).
- **`FUN_004bfc20` @0x4bfc20 = BTPlayer::MissionEnding** — full body:
1. base @0x42d9e0 (fade 3.0 + ForceUpdate + state 4);
2. **`this+0x288 = Round(15.0f × DAT_0052140c + 0.5)`** — recovered from raw bytes @0x4bfc3b-0x4bfc52 (`fld 15.0f @0x4bfca4; fmul [0x52140c]; fadd 0.5f @0x4bfca8; call __ftol@0x4dcd94; sub [ebx+0x288],eax`): it **rewinds the 15-second console score-total clock by exactly one period**, so the next PlayerSimulation tick immediately pushes the FINAL total score to the console (see §5). The old export dropped the x87 operand (`FUN_004dcd94()` rendered argless — the §19 gotcha); now pinned.
3. `if (app->state == 8 /*SuspendingMission*/ && playerVehicle(+0x1fc) IsDerivedFrom Mech (tag 0x50bdb4))``FUN_0049fb74(mech, &DAT_00524b38, 1)` (= `Mech::Reset`, per the KB respawn chain — `context/decomp-reference.md` "Respawn SIM path"; the argument blob `DAT_00524b38` semantics unverified [T4]) + `ForceUpdate`. So the operator SUSPEND path resets the vehicle; the normal state-6 path does NOT.
- **No score finalization, no fade rendering, no waiting happens in this handler** — it is fire-and-forget bookkeeping; the actual fade/countdown/exit all run elsewhere.
**The countdown** — **`FUN_0042df8c` @0x42df8c = `Player::ManageApplicationStatus`** (part_004.c:706-752; == T0 PLAYER.cpp:408-476): state 3: `fade = dt`; at ≤0 (`_DAT_0042e0dc`=0.0) → state 2 (VehicleTranslocated) + dispatch RunMission (app state 4) / Resume (state 9). **State 4 (MissionEnding): `fade = dt`; at ≤0 → app state 6 → dispatch `StopMissionMessage(exit 0)` (ctor @0x44f5d0); state 8 → SuspendMission; state 10 → AbortMission.** Called every tick from `Player::PlayerSimulation` @0x42e100 and `Player::CameraShipSimulation` @0x42e0e0 (part_004.c:756-781) — both flavors complete the ladder. (KB nit: `btplayer.cpp:1329` cites base PlayerSimulation as `FUN_0042e168`; @0x42e168 is only the vehicle-position-copy tail — the full base is @0x42e100.)
**When can the countdown not run?** `Simulation::DoNothingOnce` = `NeverExecute()` = `simulationFlags |= DontExecuteFlag` (SIMULATE.cpp:484-488, SIMULATE.h:167-206). In the 1995 binary the master player is parked on DoNothingOnce only **pre-first-spawn** (`Player::HuntForDropZone` @0x42ddcc sets the pmf `PTR_FUN_004e67fc` at part_004.c:669 once the drop zone is found, until DropZoneReply re-arms). The BT death cycle does NOT park it: **`FUN_004c05c4` @0x4c05c4 = BTPlayer::VehicleDead** (part_013.c:19064-19156) contains **no SetPerformance/simulationFlags writes** — a mid-mission-dead player still ticks PlayerSimulation, so a StopMission arriving during a death window still completes the fade in 1995. Its first line is the state-4 gate (`this+0x40==4 → return`): once MissionEnding, all death traffic is swallowed. Also in the -1 arm: deaths dual-increment (+0x280), console **MechKilled id 8** via ctor @0x4c18cc gated `suppressConsole(+0x258)==0`, objectiveMech(+0x284)=killer's vehicle, lives check `role(+0x208)->returnFromDeath(+0x28) < 1` → post Mech id 0x18 (ClearBurningState) to the wreck at +10 s and NO respawn re-post; else re-post VehicleDead to self at +5.0 s (`@0x4c0830`=5.0f, byte-read); then the straight-line death cost gated `advancedDamageOn(+0x264)` via direct call to base ScoreMessageHandler @0x42da20.
## 3. THE FADE (ticket #156)
**Mechanism: a fog-color/fog-range animation on the main view — `POVStartEndRenderable`** (T0 `engine/MUNGA_L4/L4VIDRND.cpp:2126-2394`, binary ctor **`FUN_00454394` @0x454394**, Execute **`FUN_0045447c` @0x45447c**, part_007.c:8468-8606). Not a palette fade, not a gauge overlay, not the warp machinery.
- **Built for the LOCAL (master) player only** by the video builder **`FUN_004d0774` @0x4d0774** (part_014.c:10673-10693): classID 0xBDA (BTPlayer): replicant (`(entity+0x28 & 0xc)==4`) → translocation-warp renderable @0x458d2c; **master → alloc 0x50 → `FUN_00454394(entity, Watcher=2, mainView, mainZone, deathZone, trigger, fogRGB, fogNear/Far, 3, 4)`**. The trigger = the player's **SimulationState attribute** (`FUN_0041bf44(entity,1)`), i.e. the same `StateIndicator` the MissionStarting/Ending handlers write; start state **3** = MissionStartingState, end state **4** = MissionEndingState.
- **State machine** (@0x45447c; myState at +0x1c, timer +0x3c, trigger state read at `trigger+0x14`): 0 WaitForStart → on trigger==3: fog snapped to WHITE (1,1,1) with near/far 0.01/0.05 (screen fully fogged = white flash), AddDynamicRenderable; 1 FlashScreen: hold **0.1 s** (`@0x454740`); 2 FadeIn: lerp fog color/ranges back over **1.0 s** (`@0x454744`); 3 MissionRunning: wait for trigger==4 → 4 FadeOut: fog color × percent-left → black, ranges collapse to 0.01/0.05 (`@0x45474c/0x454750`), over **1.0 s in the binary** — note the WinTesla source's `FADE_OUT_TIME (0.5f)` (L4VIDRND.cpp:2150) is a later edit; the shipped 1995 constant is 1.0 (the same `@0x454744` is reused as the fade-out duration at part_007.c:8584); at expiry → back to state 0 + RemoveDynamicRenderable.
- **What advances it:** the renderer's frame clock (`GetCurrentFrameTime`), NOT the player sim — once state 4 is set it completes even if the player sim is dormant. **What would happen if it never completed: nothing.** The ladder never reads the renderable; `ManageApplicationStatus` is the sole gatekeeper. The fade-out (1.0 s) simply fits inside the 3.0 s window; the pod then sat on a black screen ~2 s before teardown.
- **Why the port has no fade (#156 root cause):** the builder call exists (`game/reconstructed/btl4vid.cpp:311-315`, states 3/4 passed), but **`BTPOVStartEndRenderable`'s ctor is an empty stub — `game/reconstructed/btstubs.cpp:339-342`** — it never registers with the video renderer or the state dial. The white mission-start flash is missing for the same reason. All the plumbing it needs (SetState watcher ping @0x41bbd8, fog style/limits on the D3D renderer) already exists in the port.
## 4. RunMissions exit + what the 1995 pod did next
- **Exit condition** (T0 `engine/MUNGA/APPMGR.cpp:39-161`): `RunMissions` loops foreground/packet-route/background passes; when `application->ExecuteForeground(...)` returns False (binary: `executeFrames==0 || Exit_Code(DAT_004efc98)`@0x44e488) it calls `application->Shutdown(n)`; **Shutdown returning False removes the app from `runningApplications`, and with no apps left RunMissions Terminates them and RETURNS.**
- **1995: the pod did NOT exit** — the L4 (pod) application overrides Shutdown: **`FUN_0047c560` @0x47c560** (part_010.c:2805-2820) = base Shutdown @0x44e6e8 (which re-arms `executeFrames=1` + `SetState(WaitingForEgg)`) then **`return (Exit_Code==0 && app+0xd4==0) ? 1 : 0`** — i.e. the app STAYS in the manager and the pod parks in WaitingForEgg for the console's next egg (the between-rounds attract wait). RunMissions only returned on the '&' keystroke (Exit_Code, @0x44efd8) or the `app+0xd4` shutdown latch (set by the two-phase handler **@0x47c2c4** — first receipt latches +0xd4 and re-posts, second receipt tears down; a console-initiated pod shutdown [T3 on its exact message id]).
- The modern build diverges deliberately: T0 APP.cpp:922-926 replaced the original `return !Exit_Code` with a hard `return False` (`#if 0` preserved in-source), and `engine/MUNGA_L4/L4APP.cpp` has **no** Shutdown override — so every mission end exits RunMissions, then btl4main uploads the matchlog and BT_FE_LOOP relaunches the menu (`context/multiplayer.md:110,541-545`).
## 5. The end-of-mission pod↔console handshake
All pod→console, fire-and-forget over the console link (clientID 5 = ConsoleClientID); **the ladder never waits for any console reply**. Names from the surviving original `game/original/BT/BTCNSL.CPP` (cnslmsgs.cpp) + T0 `engine/MUNGA/CONSOLE.h:11-18`:
| When | Message {size, id} | Binary ctor / site |
|---|---|---|
| Every 10 s AND immediately once app state == **6** | score **DELTA** flush: self-dispatch ScoreUpdate {0x20, 0x1a} → the +0x278 delta cell is sent then **zeroed** | **`FUN_004c083c` @0x4c083c** (part_013.c:19160-19192): `10.0(@0x4c08fc) <= (this+0x10 this+0x28c)/tps \|\| app+0x88==6`; port analog `btplayer.cpp:1361-1427` |
| Every **15 s** while state 5 or 6; forced immediately at MissionEnding by the @0x4bfc20 clock rewind | **ConsolePlayerMechScoreUpdate** (running TOTAL, from +0x1c8) {0x14, id 9} | ctor **@0x4c18f4**; send site part_013.c:19269-19279 (`_DAT_004c0bc0`=15.0, clock at +0x288) |
| Once, when `fadeTimeRemaining ≤ 1.5` in MissionEndingState (i.e. **halfway through the 3 s fade**, while the link is guaranteed still up), one-shot latch +0x254 | state 6: **ConsoleApplicationEndMissionMessage {0x14, id 7} {host, Round(score)}** (added 10/05/95 per BTCNSL.CPP:10); otherwise (abort/suspend): **ConsoleApplicationAbortMissionMessage {0x10, id 0xb}** | **BTPlayer::PlayerSimulation `FUN_004c0904` @0x4c0904**, block part_013.c:19251-19268, Tell string `"Sending EndMission..."` @0x5132d7; ctors @0x42998c / @0x4299b4 |
| On death (any time) | **ConsolePlayerMechKilled** {0x14, id 8} {victim, killer} | ctor @0x4c18cc, sent from VehicleDead @0x4c05c4 (part_013.c:19110-19114), suppressed after eject via +0x258 |
| On eject | DeathWithoutHonor (id 5) | `FUN_004c198c` (KB: decomp-reference EJECT cluster) |
| (team variants) | TeamScoreUpdate {0x14, 0xd} @0x4c191c; TeamEndMission {0x1c, 0xe} @0x4299d8 | |
Plus two non-console end actions: **`networkManager->Mode(ReliableMode)`** at StopMission #1 (@0x44eeb4/T0 APP.cpp:1762) and the base MissionEnding **ForceUpdate** shipping the final player record to peers. Also note part_013.c:19280-19282: while state 5 **or 6**, the player record is re-dirtied (`+0x18 |= 1`) whenever none has shipped for 1.0 s (`_DAT_004c0bc4`) — a 1995 1 Hz player-record keepalive that keeps running through the ending window.
## 6. Stall matrix (the ticket's question, binary-grounded)
| Leg | Driven by | Can it stall? |
|---|---|---|
| StopMission #1 → state 6 | console event over the console link | YES — if it never arrives the mission runs forever (1995 design; modern relay adds the +15 s RelayGameDown fallback, multiplayer.md:519-521). World stays LIVE (not frozen). |
| state 6 → StopMission #2 | **3.0 s of MISSION-PLAYER SIM TIME** (@0x42df8c) | YES — iff the mission player's Performance stops executing while in MissionEndingState (DontExecuteFlag latched, or its Performance never calls ManageApplicationStatus). In 1995 the only such window is pre-first-spawn (HuntForDropZone→DoNothingOnce, part_004.c:669). Result: app parks in state 6 forever — world keeps ticking (ENTITY.cpp:556-558), ranking window hidden (@0x42eb38), never returns to menu. |
| StopMission #2 → Stop() | direct synchronous Dispatch | NO (cannot be lost). |
| Stop() → RunMissions return | next foreground pass: ExecuteForeground→0, then Shutdown (synchronous renderer/network/mission teardown @0x44e6e8) | Only if a Shutdown callee BLOCKS (e.g. a network shutdown on a dead transport). From Stop() onward the screen is a FROZEN LAST FRAME (renderers no longer execute) — the shape #163 describes. |
| visual fade | renderer frame clock, passive watcher | irrelevant — nothing waits on it; in the port it's a stub anyway (btstubs.cpp:339-342). |
-135
View File
@@ -1,135 +0,0 @@
=============================================================================
BT411 -- ALL OPEN TRACKER ISSUES (59), SORTED FOR PLAYTESTERS
Updated 2026-08-10 (evening) -- after Oracle's full annotated sweep, which
closed 35 issues. Thank you Oracle: that is what this list is for.
=============================================================================
HOW TO REPORT: drop a line in Discord with the issue number and PASS /
FAIL / what you saw. Screenshots and rough times help; logs get collected
as usual.
=============================================================================
-----------------------------------------------------------------------------
A. FIXED -- PLEASE VERIFY (headliners first: new fixes fielded THIS build)
-----------------------------------------------------------------------------
#149 Glass cockpit stutter / hitching ** HEADLINER **
First fielded build with the fix. Glass users -- especially Oracle:
mission load and play should feel smooth again (repaints now skip
windows whose gauges did not change).
#150 Double kill credit (one kill counted twice) ** HEADLINER **
First fielded build with the fix (868's double-kills were pre-fix).
TEST: kill someone with a missile volley -- scoreboard +1, not +2.
#69 "Sunder V1" renamed to DENKOU
TEST: vehicle picker shows Denkou -- era witnesses, confirm spelling.
#71 Closing the Plasma window lost it until restart
TEST: X the plasma window -- it minimizes to the taskbar; click restores.
#25 Gamepad turn profile (stick X could not turn the mech)
TEST: RajelAran -- bind an axis to "Turn" in content\bindings.txt.
#27 Autocannon/Gauss dealt the wrong damage type
Oracle asked for a RETEST before closing: ballistics should feel/score
like solid shot (no blast splash).
#38 Mech colors reverted on respawn
TEST: custom paint, die, respawn -- colors stay.
#39 Controls were live before the game screen appeared
TEST: mash fire during load -- nothing happens until the scene shows.
#41 (David/Auric) launcher killed before it could run -- forensics armed
TEST: if join.bat ever fails again, send content\lastrun_join.txt.
#56 MFD buttons drift away from where they are drawn
TEST: alt-tab / focus changes mid-round -- buttons stay aligned.
#147 Range caret vanished for the session (ticks stayed)
Monitoring: if it happens, the log traps the culprit -- just report it.
#158 (Sauron) could not move after respawn until throttle cycled
TEST when it happens: does cycling the throttle free you?
YES -> the #146 release mechanic (expected). NO -> map + spawn spot.
-----------------------------------------------------------------------------
B. OPEN -- HELP US REPRODUCE / NARROW (what to note when it happens)
-----------------------------------------------------------------------------
#24 Controller hat/look view sticks (recovers after wiggling)
#37 Mech torso appears BACKWARDS to other players (transient)
#74 8BitDo gamepad works solo, dead in Steam MP (VexUbiquity)
#102 Owens: full-throttle run -> hold reverse = animation/audio hang
REPRO known (exterior cam, savannah); self-clears on a torso twist.
#125 Peers do not see the FIRST destroyed-zone graphic (2nd hit shows it)
#126 Peer weapon fire invisible AND silent from some positions
#128 Ramming terrain = INSTANT coolant leak -- Oracle re-confirms STILL LIVE
#130 Vulture "skating" (gliding without walk animation)
#132 (Conn Man) persistent yellow line on the HUD in every mech
#135 Coolant leak with NO flashing annunciators -- Oracle re-confirms LIVE
#136 A SHUT leaking loop starts/stops leaking with COMPONENT toggles
(Oracle marked "Clarify" -- one more description pass requested.)
#143 One panel hit -> smoke erupts from MULTIPLE places at once
#153 Basic-mode elevation limit only applies after cycling modes (fix pending)
#154 Generator Out warning fires on the WRONG EDGE (at recovery, not trip)
Refined by Oracle: the stuck-forever gen did NOT reproduce; era rule =
gens return at HALF-cooled (red bar portion gone). Threshold audit due.
#155 PANIC lit: Eng + Weapon MFD banks dead; Score/Radar/Coolant stay live
(Oracle mapped the exact bank split; #50 closed into this ticket.
Flicker half = authentic. Next: is the bank capture authentic too?)
#159 (Conn Man) lag during drops -- missiles / fire exploding in empty space
NOTE: shooter + target + rough time; match receipts measure the rest.
#160 Pod cab: RIO axis dropouts + boot wedges (hardware investigation)
Cab-side only. Bounded-write hardening is in this build; the cage
hardware work continues with Nick.
-----------------------------------------------------------------------------
C. STILL-OPEN ERA QUESTIONS
-----------------------------------------------------------------------------
#103 Mech-to-mech RAM: armor damage or internals only? (Oracle: don't know;
Lynx / Ronin / Draco -- your call. Binary does internals-only.)
#133 Generator X-out on the MFD: Oracle recalls an X "under certain
circumstances" -- WHICH circumstances? (shutdown / overheat / destroyed)
ALSO (#152 rider): did Mid/Adv auto-recentre the torso in the pod, or only
Basic? (Oracle: don't know. Binary says: no auto-recentre.)
-----------------------------------------------------------------------------
D. DESIGN CALLS -- group opinions wanted
-----------------------------------------------------------------------------
#151 Panic eject costs -499: verified REAL 1995 scoring (your death scored
as suicide + death penalty). Keep authentic, or house-rule a flat cost?
#23 Non-glass screen layout: Oracle proposes widescreen-aware sizing --
world view wider + taller, moved up, allowed to overlap the radar.
Opinions welcome before anyone prototypes it.
#77 Resizable Secondary/Radar window (touch-panel cockpits). Want it?
-----------------------------------------------------------------------------
E. KNOWN GAPS -- confirmed and on the list, no need to re-report
-----------------------------------------------------------------------------
#49 Radar/map does not draw the arena WALL geometry
#76 Glass layout save/restore rework (some windows not saved)
#83 INCOMING MISSILE alarm not implemented yet
#90 Flames/smoke from serious damage: less than the original (tuning)
#104 Mad Dog "NARC 15" label: NARC postdates our 4.10 binary (Oracle: added
in a late 4.x release; zero NARC in our decomp) -- content rename due.
#105 Sensor damage effects: Oracle's era spec captured (HUD flicker/loss,
radar loss) -- implementation pending.
#111 Peers see a dying mech's blue warp bubble (should see explosion only)
#112 AFC rounds shotgun across zones (should land as one projectile)
#113 LRM burn/hit state lasts too long
#115 Hot box / target select not connected (Oracle era note: hotbox was a
STANDARD-mode feature; absent in Expert -- gating to reconcile)
#121 Sensor HEAT has no consequences yet
#123 Searchlight: the visible LIGHT + night-fog swap still to come
#145 IR "predator vision": decode done, display not implemented
#156 Fade-to-black at mission end not happening
-----------------------------------------------------------------------------
F. INTERNAL ENGINEERING BACKLOG (nothing to test)
-----------------------------------------------------------------------------
#14 Glass panel unreconstructed button handler backlog (census)
#17 Cavern upper-butte halves lack a collision stream (authenticity TBD)
#21 Weapon recharge arc dark after eng-page use (symptom trail -> #62)
#28 Confirm the vital-subsystem-crit death path can fire (machinery in)
#30 Weapon "unavailable" (level-7) display verification + accessor cleanup
#63 PlayerStatus reads raw 1995 offsets (databinding trap, dormant)
#64 damageZone shadow field (half-fixed; consequences disarmed)
#65 HandleMessage non-virtual across 10 classes (latent dispatch trap)
#72 BTLoadPump swallows WM_QUIT during load (latent)
#107 Destroyed HeatSink annunciates nothing (no lamp for condition 0)
#117 KB note: per-chassis cockpit bounce was deliberate (Ronin) -- FYI
=============================================================================
End of list. 59 open issues total:
12 verify (2 headliners), 17 reproduce, 3 era questions, 3 design calls,
14 known gaps, 11 internal.
35 issues closed 2026-08-10 on Oracle's annotated sweep.
=============================================================================
-204
View File
@@ -1,204 +0,0 @@
# The terrain-ram coolant-leak audit -- full findings (2026-08-11, ticket #128)
> Four-agent audit of the collision->damage->leak chain. VERDICT: pricing,
> gate, scaling, free floor and sub-hit math are all byte-faithful; the ONE
> divergence is ELIGIBILITY -- the port read GUID 0x50e590 as the whole
> HeatSink base family (making myomers/condensers/reservoirs rattle-eligible,
> every one coolant-bearing -> instant leak per over-floor ram), while the
> binary's node is the AGGREGATE BANK only: eligible set {HeatSinkBank 0.30,
> Gyroscope 0.35, Torso 0.25}, 10% of rolls land nowhere, myomers' authored
> 0.35 weight is DEAD DATA. Gyro/Torso cannot leak; a 1995 ram is gyro/torso
> damage with at worst a bounded bank drip. Also established: the 0.5-pt free
> floor eats ALL scrape taps (the per-frame [crashdmg] spam is log noise, not
> damage -- now sampled), and the +0x268 advancedDamage gate is honored
> faithfully (night-15's 'leaks' were combat-caused; the collision leak only
> ever fired where advDmg=1 -- and #162's egg fix would have armed it for
> every steam match, making this fix urgent). Fixed in mech.cpp
> (IsDerivedFrom("HeatSinkBank")) same day; bench ram_bench.sh.
TICKET #128 — PORT AS-BUILT CHAIN (terrain ram/scrape -> instant coolant leak)
=========================================================================
STAGE 1 — THE EMITTER (physical collision -> TakeDamage dispatch)
=========================================================================
The [crashdmg] print lives in Mech::AuthenticGroundAndCollide, game/reconstructed/mech4.cpp:8526. Full path:
1a. Per frame, the master mech (only movers with a collisionAssistant run the collision half — replicants bail at mech4.cpp:8427-8428) fetches contacts: GetCurrentCollisions() (mech4.cpp:8431), then Mover::ProcessCollisionList(cols, dt, old_position, &dmg) (mech4.cpp:8450-8452). `Damage dmg` is stack-constructed: Damage::Damage (engine/MUNGA/DAMAGE.cpp:35-45) sets damageAmount=0, burstCount=1; **damageType is NOT initialized by the ctor** — it is stamped Damage::CollisionDamageType(0) inside Mover::ProcessCollisionList at engine/MUNGA/MOVER.cpp:1228, and the accumulated `total_damage` across the frame's contacts is written at MOVER.cpp:1350.
1b. Per contact, the engine calls the virtual Mech::ProcessCollision (mech4.cpp:8754-8883, binary @004abb40). It restores frameEntryWorldVelocity first (mech4.cpp:8777 — PORT ADDITION, "COLLISION-DAMAGE ECONOMY GUARD": prevents multi-solid frames compounding StaticBounce reflections; commented as fixing 4x-40x over-pricing), runs the BoxedSolid resolver, then prices via Mover::StaticBounce (mech4.cpp:8796-8797 -> engine/MUNGA/MOVER.cpp:1432-1528).
1c. THE AMOUNT FORMULA (engine T0, MOVER.cpp:1527): `return -0.0005 * (vn*vp) * moverMass`, where impact = worldLinearVelocity·normal; damage is 0 if impact > 0 (separating, MOVER.cpp:1465-1469). vn/vp are the reflected normal/parallel components scaled by (1+elasticity). Constants: 0.0005 (MOVER.cpp:1527); minimumBounceSpeed only ZEROES elasticity when -impact <= bounce_min*dt (MOVER.cpp:1470-1473) — **there is NO minimum-speed gate on the damage amount itself; any closing contact prices > 0**. Measured economy (context/combat-damage.md:413-415): raw ≈ 600·v² for a head-on mech (59221 @ |v|=9.94; moverMass is tonnage-scale 60000-90000).
1d. OWNER CLASSIFICATION (mech4.cpp:8815-8866): a Mover owner -> mech-vs-mech dispatch via BTDispatchCollisionDamage, edge-gated by ramLastVictim/ramContactLinger 0.35s (mech4.cpp:8837-8846 — PORT ADDITION, explicit one-bump-one-hit; the binary's edge was implicit in its bounce). A CulturalIcon owner -> crunch dispatch + 0.00123f walk-through sentinel. **Plain UnscalableTerrain owners (ground/hills/cliffs/canyon walls) match NEITHER branch — their StaticBounce damage stands with NO edge gate whatsoever** (comment mech4.cpp:8656-8658).
1e. THE TERRAIN CRASH RESPONSE (mech4.cpp:8485-8537): if dmg.damageAmount > 0.0f, the frame is fully rejected (velocity zeroed 8487, position restored 8488), [crashdmg] prints (8526-8531, always-on), and an Entity::TakeDamageMessage{inflictor=SELF, zone=-1, dmg VERBATIM} is dispatched at OUR OWN mech (8532-8536, binary @4aa984-4aaab4). Stamps: damageType=0 (from MOVER.cpp:1228), burstCount=1 (Damage ctor), amount=raw StaticBounce kinetic price.
1f. FIRE RATE: per frame while scraping. The freshBlock hysteresis was deliberately REMOVED (pricing audit 2026-07-31; comment mech4.cpp:8560-8565). The only natural pacing is the knockdown: iv2 > 40 (i.e. |v| > ~6.3 u/s, const @0x4ab184, mech4.cpp:8540) binds the bmp clip whose cycleSpeed 0 collapses velocity. **Below iv2=40 there is no knockdown, so a sustained scrape with a closing normal component re-prices and re-dispatches EVERY FRAME at full drive speed** (the gait re-derives velocity each frame — context/combat-damage.md:427-431 documents exactly this hazard for the mech-vs-mech case, which got the ramContactLinger fix; terrain never did).
=========================================================================
STAGE 2 — THE DISTRIBUTOR (Mech::DistributeCollisionDamage)
=========================================================================
Divert site: mech.cpp:1160-1164 — `if (message->damageData.damageType == 0) { DistributeCollisionDamage(&message->damageData); goto death_tail; }` (binary @0x4a0368; never reaches the zone/armor loop; jumps to the death tail, not return — mech.cpp:1149-1158).
Full body mech.cpp:980-1028 (binary FUN_0049ffcc):
- GATE (mech.cpp:982-985): `player = GetPlayerLink()` (mech+0x190); return if null or !BTPlayerAdvancedDamageOn(player). The bridge (btplayer.cpp:2201-2204) reads **advancedDamageOn2 = the SECOND cell this[0x9a] = +0x268** — it DOES reproduce the binary's +0x268 gate (@0x49ffde) [T1]. Both cells are stamped together at btplayer.cpp:2131 from btMission->advancedDamageOn (mission+0xf0, the egg "advancedDamage" technician flag). NOTE [inference, T3]: commit 3545056 ("steam eggs stamp the host's advancedDamage mission-wide — penalty was silently off for every steam match") means this gate only recently began PASSING in steam matches — plausibly why two testers suddenly report the leak as new.
- SCALE (mech.cpp:987-990): `scale = ((2000/moverMass) / (100·0.27777779)²) / (1 elasticity²)` — constants match binary (tbyte @0x4a0148 = 1/3.6, 2000f @0x4a0154; mass +0x20c, elasticity +0x244). ≈ 4.5e-5 at 60t/e=0.2 (measured, context/combat-damage.md:1124). Note the mass CANCELS against StaticBounce's ·mass, so scaled amount ≈ (vn·vp)/(771.6·(1e²)) — mass-independent.
- FREE FLOOR (mech.cpp:991-992): scaled < 0.5f -> return (taps free; @0x4a003c). **This floor IS the effective "minimum speed": measured 2.55 pts at 9.6 u/s -> the floor clears at |v| ≈ 4.25 u/s head-on.** Between ~4.3 and ~6.3 u/s a scrape prices 0.5-1.2 pts per frame with no knockdown pacing (see 1f).
- SUB-HITS (mech.cpp:994-997): n = Round(amount·2), min 1; each sub-hit = amount/n (~0.5 pts each).
- SELECTION (mech.cpp:1007-1025): per sub-hit, one [0,1) roll walks the full subsystem roster accumulating collisionCriticalHitWeight (+0x10C, authored per subsystem resource "CollisionCriticalHitWeight", mechsub.cpp:200/652); eligibility = IsDerivedFrom HeatSink-family(GUID 0x50e590) / Gyroscope(0x50fdc0) / Torso(0x510b08). Measured weights (scratchpad/SESSION_HANDOFF_20260801.md:39): HeatSinkBank 0.3 / Gyro 0.35 / Torso 0.25 / Myomers 0.35. Un-won rolls land nowhere (weights un-normalized — faithful). Winner takes the sub-hit via MechSubsystem::ApplyDamageAndMeasure (mechsub.cpp:459-481, binary @0x4ac07c = the audited FUN_004ac07c) -> MechSubsystem::TakeDamage (mechsub.cpp:513-538, @0x4ac0bc) -> the subsystem's PRIVATE DamageZone::TakeDamage: `damageLevel += amount × damageScale[0]` (engine DAMAGE.cpp:391), scale = points-normalized from authored damageTypePoints/weaponDamagePoints (mechsub.cpp:220-237).
- Subsystems per event: up to n (one per sub-hit); a full-run terrain crash (~2.6 pts) = n=5 sub-hits, so up to 5 subsystem rattles per impact.
MASTER GATING: **DistributeCollisionDamage is NOT master-gated.** The #150 gate (mech.cpp:1288) covers only the score-report tail and the #162 gate (mech.cpp:1372-1373) only the death tail; the type-0 divert at mech.cpp:1160 runs BEFORE both, on any instance. The port deliberately applies damage on replicants for visual response (comment mech.cpp:1276-1282; binary asserted replicants OUT of this handler entirely, part_012.c:14613-14616). So the shooter-side ram dispatch at the victim's local REPLICANT (BTDispatchCollisionDamage, mech4.cpp:8845; victim->Dispatch, mech4.cpp:8717 — also streamed cross-pod to the master per task #47, messmgr.cpp:348) runs the divert on the replicant too, subject only to the replicant's playerLink/advancedDamageOn2 gate. The TERRAIN self-crash emitter itself is master-only (replicants have no collisionAssistant, mech4.cpp:8427), so #128's chain is master-side.
=========================================================================
STAGE 3 — THE LEAK ONSET (subsystem damage -> coolant leak)
=========================================================================
HeatSink::UpdateCoolant, game/reconstructed/heat.cpp:1061-1118 (binary @004adbf8):
- `coolantDraw = ownZone->damageLevel × heatLoad` (heat.cpp:1078-1080; reads the subsystem's OWN private DamageZone.damageLevel — the exact cell the rattle sub-hit raised).
- Floors/thresholds (heat.cpp:70-80, byte-verified): draw < 0.0025 -> zeroed (_DAT_0050e3d8); coolantActive flips ON at draw > 0.003 (_DAT_0050e3d4), OFF below 0.0025 — hysteresis (heat.cpp:1110-1117).
- coolantActive IS the leak: it feeds the ReportLeak attribute (heat.cpp:489, all 19 authored leak watchers -> the "warning: coolant leak" voice) and coolantDraw is published as CoolantMassLeakRate (heat.cpp:481) -> the cockpit LeakGauge (btl4gau2.cpp:984/1008). Coolant then drains at coolantDraw/s with central-tank top-up via DrawCoolant (heat.cpp:1095-1105).
- heatLoad = 15-sample filtered average of 0.002×temperature×coolantLevel, clamped [0,1] (heat.cpp:70-72, 886-926); typical running value ~0.6 (radiatedHeat ~300 band comment heat.cpp:60-61).
- THE THRESHOLD: leak springs when damageLevel > 0.003/heatLoad ≈ 0.005 at typical load. A single 0.5-pt sub-hit raises the private zone by 0.5×damageScale[0] ≈ 0.5/points — for any plausible authored points (10-50) that is 0.01-0.05, i.e. **an ORDER OF MAGNITUDE above the leak threshold. One free-floor-clearing sub-hit landing on ANY heat-family subsystem = instant audible leak, deterministically.** And since Myomers is heat-family (weight 0.35) alongside HeatSinkBank/Condensers/Generator/Reservoir, most of the eligible weight IS heat-family; with n=5 sub-hits at run speed, at least one landing heat-family is near-certain — matching "every single time".
- Design intent note: heat.cpp:1070-1072 states the AUTHENTIC behavior — draw rises only as the heat subsystem takes battle damage; there is no repair, so the leak persists once lit (clears only when heatLoad drops enough to cross the 0.0025 OFF hysteresis — the "warning coo-- war--" oscillation, heat.cpp:876-879).
=========================================================================
GUESSES / STAND-INS / PORT DEVIATIONS in the chain (admitted in comments)
=========================================================================
1. mech4.cpp:8868-8873 [T3]: InitialHit(1) vs Slide(2) contact-state split — the two collision floats @0x240/0x244 are fieldAt() STUBS returning 0; port always accumulates InitialHit. (Audio-only; not damage.)
2. mech4.cpp:8777 + 8436-8441: frameEntryWorldVelocity restore — PORT ADDITION (economy guard), not in the binary (its ground was a heightfield probe, never a collision-list entry).
3. mech4.cpp:8837-8846: ramContactLinger 0.35s one-bump gate — PORT ADDITION, mech-vs-mech ONLY; the binary's edge was implicit in its bounce separation. **Terrain self-damage got no equivalent gate — the per-frame scrape pricing is the port's own emergent behavior**, bounded in the binary by its velocity-reversing bounce.
4. mech4.cpp:8566-8567: knockdown mid-clip rebind guard — admitted "cheap belt-and-braces port addition"; the binary has no rebind guard.
5. mech4.cpp:8508-8510 TODO: throttleState = fall-surface material not forwarded (binary (**[mech+0x2f8])+0x24) — payload nicety, unmapped.
6. mech.cpp:984: `player == 0` null guard is a port guard (binary reads +0x268 through the link; the audit says the link is always live there).
7. mech.cpp:1086-1089 [T3]: lastInflictingID write site is a reconstruction guess (binary write site not located).
8. Replicant damage application (mech.cpp:1276-1282) — deliberate port deviation from the 1995 replicant assertion; consequence: the divert/distributor can run on replicant copies (see master-gating above).
9. Cross-pod TakeDamage streaming (task #47, messmgr.cpp:348) — port addition; in the 1995 pod, ram damage was network-inert (context/combat-damage.md:416-419).
10. Subsystem private-zone damageScale values are authored content (res+0x30/+0x44, mechsub.cpp:220-237) — I did not extract the per-subsystem numbers; the "0.01-0.05 per 0.5-pt hit" figure is inference from the points normalization, not a measured value.
==============================================================================
# Task 4 — Reconstruction archaeology of the collision→coolant-leak chain (#128)
## A. TIMELINE — how the chain reached its current shape
**2026-07-05 (7b7d465, initial commit).** WinTesla engine baseline. The engine already contained the collision PRICING: `Mover::StaticBounce` returns `-0.0005 * (vn·vp) * moverMass` (C:/git/bt411/engine/MUNGA/MOVER.cpp:1527) and `Mover::ProcessCollisionList` stamps `damage->damageType = Damage::CollisionDamageType` (MOVER.cpp:1228) and accumulates the total into the Damage record. The engine RETURNS the Damage to the caller — it never dispatches it. So the pricing formula is ENGINE truth [T0], not a port invention, and the crash-response FORWARDING is game-side policy.
**2026-07-08 (2af401e) — first collision damage dispatch.** `BTDispatchCollisionDamage` added (mech-vs-mech + cultural-icon crunch), unblocked by the STEP-6 cylinder hit-location work. Terrain explicitly did NOT damage: "Terrain (walls/hills) matches neither branch, so it still BLOCKS without damage (faithful to the binary)" — a claim later falsified (the terrain self-damage was hiding in an export gap). The self-TakeDamage on a blocking hit was left as a marked "DEFERRED" note in the response policy (mech4.cpp ProcessCollision header, still visible at mech4.cpp:8660-8664). Era symptom: "grinding has no effect" was structurally true — wall crashes cost nothing.
**2026-07-12 (bb795e2) — the ram one-shot + first guard.** First live MP wave found StaticBounce reflections compounding 4-40x across multi-solid frames ("a walking bump one-shot a pristine mech for 112,375 pts"). Fix: `frameEntryWorldVelocity` per-contact restore (port guard for an engine-facility drift, gotcha #16) + `Mech::Reset` motion zeroing. Both are port additions [T3-justified], still in place (mech4.cpp:8436-8441).
**2026-07-13 (065c114) — the ×1e-3 normalization [T3, admitted guess].** "Port normalizes x1e-3 to the point economy [T3]" — an INVENTED scale patched in because raw kinetic amounts (~59,000 pts @10 m/s, moverMass ~60-90k) were hitting the victim's ARMOR zones directly (the divert did not exist yet). Also added `ramLastVictim`/`ramContactLinger` contact-edge (one-bump-one-hit, 0.35s re-arm) — a port invention [T3] compensating for the port's kinematic gait velocity never reading as "separating" the way the binary's bounce-reversed physics velocity did. The contact edge SURVIVES today (mech4.cpp:8444-8449).
**2026-07-30 (9c83a46, #82 ROOT FIX) — terrain crash SELF-damage recovered.** The wall-grind "knockdown storm / skating" report decomposed; the missing piece was recovered from the EXPORT GAP by raw disasm (scratchpad/night6/gap_4a9770.txt, binary @4aa89f-4aaab4): fallDirection = worldToLocal(damageForce), fallScalar = -(fallDirection·localVelocity), and a self-dispatched `TakeDamageMessage{0x64, zone=-1, engine Damage verbatim}`. This closed the old DEFERRED note — terrain crashes now hurt, per the 1995 binary [T1 raw disasm]. The `[crashdmg]` log line was added here as the pricing arbiter ("run 14 field report: 'tapped a wall and died instantly', so the forwarded amount is suspect vs binary" — mech4.cpp:8523-8531). Also FALSIFIED en route: the action-request-flags drive-suppressor theory. A port-only mid-clip rebind guard kept as "belt-and-braces" (mech4.cpp:8560-8567).
**2026-07-30 (14ff351, #83) — the COLLISION DIVERT, hours later.** The self-damage immediately exposed its missing second half: raw kinetic figures were falling into the WEAPON zone/armor loop ("tapped a wall and died instantly"). Recovered @0x4a0368 (hub) → FUN_0049ffcc (distributor, also an export-gap fn, raw disasm scratchpad/night6/gap_49ffcc.txt): gate on victimPlayer advancedDamage copy 2 (+0x268, @0x49ffde); scale = (2000/moverMass)/(100km/h)^2/(1-e²) (tbyte 1/3.6 @0x4a0148, 2000f @0x4a0154); <0.5 FREE (@0x4a015c); Round(2×) sub-hits on random HeatSink-family/Gyro/Torso subsystems via cumulative collisionCriticalHitWeight (+0x10C) and ApplyDamageAndMeasure (@0x4ac07c). All byte-anchored [T1] (mech.cpp:960-1028). Bridges (BTHeatSinkFamily/BTGyroscopeFamily/BTTorsoFamily, BTPlayerAdvancedDamageOn) added same commit.
**2026-07-31 (37dd7f9) — collision-pricing audit, two REMOVALS of port inventions.** (A) The 0.4s `gBlockCooldown` contact-hysteresis (a port stand-in) DELETED — it had suppressed the knockdown while contact held, letting the drive re-slam at full price every frame → the night-7 SAURON grind death (110 priced frames @|v|~34.7). Binary needs no guard (crash threshold @0x4ab178 = 0.0 read from the exe). (B) The ×1e-3 normalization DELETED — it double-normalized against the divert's ~4e-5 scale, making victim-side rams a no-op. Raw dispatch restored [T1 @part_012:15324]. Bench: scratchpad/night7/mp_rampricing.sh — 150s wall push = 0 rattle 0 deaths; 9.6 m/s ram = 2.55 pts rattle. KB swept: the "moverMass ≈1.3e6" figure corrected to measured 60-90k tonnage scale (an earlier mis-attribution).
**2026-07-31 (a8a0042, #88) — coolant leaks BECOME POSSIBLE.** Until this commit a leak was STRUCTURALLY IMPOSSIBLE: the MechSubsystem ctor filled only its re-declared SHADOW damageZone (#64, gotcha #1), so `HeatSink::UpdateCoolant`'s engine-member read was NULL forever. Fix aliased the engine base member; from this build on, `coolantDraw = zoneDamage × heatLoad` prices live. Same commit documents the authored collision-rattle weights extracted from BTL4.RES [T1]: HeatSinkBank 0.30 / Gyro 0.35 / Torso 0.25 / Myomers 0.35, Condenser+Reservoir 0 for collisions. NOTE the coupling: #83 (rattle) and #88 (leaks) went live ONE DAY apart — the terrain-ram→leak behavior could not have been observed before 2026-07-31 builds.
**2026-07-31 (839b040) — night-8: the divert declared "matches the field".** KB records: "RAM damage: the binary's internal-only collision divert MATCHES what players observe (gyro/myomer damage, coolant leak, NO armor damage, grinding inert). May be working as intended; awaiting a player ruling. Gitea #103." (context/open-questions.md:1023-1028). I.e. ram→coolant-leak was seen on night-8 and DISPOSED AS AUTHENTIC-SHAPED, with only the armor question left open.
**2026-08-02 (night-10, build 716) — #128 filed.** "Ramming terrain = INSTANT coolant leak (4 for 4 that night). NOTE: still 100%? speed at impact; which terrain." (first tester handout, git show bf04e1c:docs/OPEN_ISSUES_FOR_TESTERS.txt:126-127). #125-#130 appear as "carried" in scratchpad/SESSION_HANDOFF_20260803.md:51. **No commit has ever addressed #128** (git -S/--grep sweeps find only the two handout commits).
**2026-08-05 (91bd286) — divert tail corrected.** The #83 block had shipped with an early `return`; the binary actually JUMPS TO THE DEATH TAIL (raw disasm @0x4a0375: jmp 0x4a07b5) — fixed to `goto death_tail` so a rattle death still posts VehicleDead; no score reports on this path (mech.cpp:1149-1164).
**2026-08-10 (night-15) — Oracle re-confirms:** "#128 Fail - Happens every single time I collide with terrain at speed, needs to be at some mimimum speed. Confirmed." (scratchpad/night15/OPEN_ISSUES_20260810_commentary2.txt:151). Handout regenerated (40c3594) with "#128 ... Oracle re-confirms STILL LIVE".
**2026-08-11 (3545056, #162) — advancedDamage stamping.** "Steam eggs stamp the host's advancedDamage mission-wide (penalty was silently off for every steam match)". Night-14 steam logs show advDmg MIXED across players (docs/KD_CHAIN_AUDIT_20260811.md:194: 8 APPLYING all advDmg=1, 32 SKIPPED all advDmg=0). Since the rattle gate is the SECOND advancedDamage cell (+0x268), set from the same mission egg copy (btplayer.cpp:2128-2132: `advancedDamageOn = advancedDamageOn2 = bt_mission->AdvancedDamageOn()`), the collision rattle was per-node-egg-dependent in steam matches before build 885 and is now uniformly the host's setting.
## B. Provenance verdict per element
- **Pricing ([crashdmg] amount):** ENGINE (WinTesla MOVER.cpp StaticBounce), shipped 1995 — NOT invented [T0]. The [crashdmg] fields (mass, |v|, force) are diagnostics only.
- **Terrain crash→TakeDamage forwarding:** 1995 BT game code @4aa89f-4aaab4, recovered by raw disasm 2026-07-30 [T1]. OUR addition history: absent (pre-07-30) → present raw. The engine never forwarded; the game always did.
- **The divert + DistributeCollisionDamage:** byte-anchored reconstruction [T1], constants read from the exe.
- **Rattle target weights:** authored data (BTL4.RES) [T1].
- **Leak pricing (`coolantDraw = zoneDamage × heatLoad`, floor 0.0025 / ON 0.003):** byte-verified constants (_DAT_0050e3d8/_DAT_0050e3d4, heat.cpp:63-80); an earlier 1e-4 misread made them fire 25-30x too eagerly (task #9 correction).
- **Port inventions still live [T3]:** frameEntryWorldVelocity restore; ramLastVictim/ramContactLinger 0.35s contact edge; the mid-clip bmp rebind guard; the collisionAssistant null guard. Port inventions REMOVED: ×1e-3 normalization, gBlockCooldown.
- **Known port drift touching the leak:** the heatLoad smoothing window is frame-rate dependent (28 Hz pod vs ~59 fps port halves the window → heatLoad 2x twitchier, heat.cpp:872-880) — documented as the #119 leak-voice stutter cause; also the port's impact |v| comes from the kinematic gait, not binary mover physics (the whole reason the contact-edge guards exist).
## C. Prior unresolved observations this audit should explain
1. **#103 (night-8, open era question):** ram → gyro/myomer damage + myomer coolant leak, no armor — filed as possibly-authentic, "awaiting a player ruling" on armor. #128 is the terrain-side escalation of the same mechanism; the night-8 disposition ("matches the divert, may be WAI") is the reason nobody treated the leak itself as a bug for ten days.
2. **The determinism testers report is SHAPED like the reconstruction:** ≥0.5 scaled pts (the "minimum speed" testers observe — ~18.5 u/s at 65t per 37dd7f9's message) → guaranteed ≥1 sub-hit → 0.3+0.35 cumulative weight on heat-family targets → any zone damage × running heatLoad ≥ 0.0025 → leak flag. Whether the 1995 pod also leaked on EVERY over-floor terrain crash is the open question — no ledger records a 1995-side check of leak frequency, only of the divert's structure.
3. **#135 (leak with NO flashing annunciators) and #97 (three-level leak severity, we only ever produce the lowest)** — both still open, both plausibly entangled with the leak-magnitude side of #128.
4. **The steam advancedDamage inconsistency:** before 4.11.885 the +0x268 gate varied per node in steam matches; testers could legitimately disagree about whether terrain rams did anything at all. Post-885 it is the host's egg mission-wide — if the host egg authors advancedDamage ON, every player rattles/leaks.
5. **Un-won roll fidelity:** the distributor deliberately does NOT normalize weights (mech.cpp:976-977 "faithful: the binary does not normalize") — with only heat-family+gyro+torso eligible, a sub-hit can land NOWHERE; nobody has verified the eligible-roster cumulative weight against the binary's actual roster order, which affects how often the rattle hits a heat-family (leaking) member vs fizzling.
==============================================================================
## Ticket #128 Task 3 — Field receipts from night-14 (build 4.11.857, 4 machines) and night-15 (build 4.11.883, 6 machines) steam logs
**Method note:** logs have no per-line timestamps; times were reconstructed from `[rstat] frames=N` anchors at 0.017 s/frame (validated against `[drive] dt=0.017`), linearly interpolated by line number (accuracy ~±1 s). Leak receipt = `[techstat] <sub> condition 2 SET` (condition 2 = CoolantLeaking per game/reconstructed/btl4galm.cpp:116) plus first-seen `[galm-item] cond=2` per subsystem. Scripts: scratchpad crash_analysis{,2,3}.py (session scratchpad dir).
### (1) Distribution
Night 14: **8,528** [crashdmg] events (dave 805, santo 1,065, michael 4,351, rajel 2,307). Night 15: **30,715** (Dave 856, rajel 6,468, acaci 9,378, Michael 9,680, santo 2,765, eleng 1,568). Amounts are wildly bimodal: **~98% of events are amount<2,000** (median ~250530, p95 ~1.11.8k) at scrape speeds |v| 36.5; a thin tail of discrete rams: amount 10k145k at |v| 1660. n14 amt≥10k: 47 events (0.55%); n15: ~145 (0.47%). |v| shows hard structure: scraping saturates at |v|≈6.46.5; a dense cluster at |v|=34.334.9 (full-throttle wall rams, esp. rajel: 20+ rams all at 34.x); outliers 4060 (falls/jump landings — inference). mass field is the mover's own mass (35k90k, varies per mech pick), never the partner's.
### (2) Correlation + empirical threshold — THE KEY RESULT
The collision→instant-leak signature exists **only in advDamage=1 missions** and **only for discrete high-speed rams**, not scrapes:
- **Night 14, advDamage=1, amount≥10k: 7 events → 6/7 (86%) followed by an internal-subsystem `[techstat]` receipt within ≤1 s; 4/7 (57%) an explicit CoolantLeaking (cond 2) SET at +0.20.4 s.** Michael's 3 rams (14.8k/26.5k/30k raw, |v| 24.443.6): ALL THREE → `Myomers condition 2 SET` (coolant leak) at +0.20.4 s. Dave's: 76k ram → Gyroscope+HeatSink DMG at +0.0/+0.3 s; 33k ram → Myomers LEAK at +0.3 s. The struck subsystems (Myomers/HeatSink/Gyroscope/Condenser) are exactly the DistributeCollisionDamage lottery families — this is the rattle firing, receipt-latency "instant" [T2 from field receipts].
- **advDamage=0 (n14 40 events amt≥10k + n15 ~145): ~2/185 (~1%) show a ≤1 s receipt**, and those are ambiguous (one is ERSLaser_1, not a rattle-family sub — concurrent weapon fire). All other follow-ups sit at +1.39.9 s, i.e. ambient combat damage.
- **Empirical threshold:** no event below |v|≈15.5 ever priced ≥8k raw; the smallest ram that sprang a leak was raw=14,811 @ |v|=24.4 (65t). Scrape events (|v|≤6.5) never exceeded ~3.1k raw. So the field threshold ≈ **raw amount ~15k ⇔ |v| in the low-to-mid 20s** — matching Oracle's "needs some minimum speed" and consistent with the 0.5-pt free floor of the (2000/mass)/(27.78)² pricing (raw floor ≈ 0.19×mass = 9.6k17k depending on tonnage/elasticity) [inference from mech.cpp:987-991 + data].
- 5-s burst-level correlation over ALL bursts (including scrapes) is only 522% (n14) and 06% (n15) — the tester "every single time" is about solid rams under advDamage=1, NOT scrapes. Baseline: leaks are common from ordinary weapon fire — 85100% of cond-2 SETs have NO crashdmg in the prior 5 s (n14: 144/161 orphaned; n15: ~124/142).
### (3) Cadence — scraping is priced per frame
Yes, repeat-fire: consecutive-line [crashdmg] runs of 38120 events at **2582 events/s** (frame rate ≈59 → up to ~1.4 pricings per rendered frame). Confirms the santo observation: santo n14 worst run = 50 events in 1.1 s (45/s) totaling 35,601 raw; dave n14 = 50 in 0.6 s (82/s), 51,230 raw; rajel n15 = 94 in 2.2 s, 121,335 raw. Cluster-level (≤2 s gaps): worst = rajel n15, 1,829 events over 43.3 s totaling 1.77M raw. Each per-frame event is individually tiny (med ~300 raw ≈ 0.011 pt after scaling at 70t) → every one falls under the 0.5-pt free floor, so a scrape run costs ZERO through the rattle even when gated on — but the run's raw SUM (25k121k) would rattle 14+ sub-hits if it were priced once as a single impact. The per-frame re-pricing therefore currently under-damages scrapes rather than over-damaging them (through this path).
### (4) advDamage=0 cross-check — THE GATE HOLDS
Night-15 receipts: advDamage=0 for effectively every mission with crash traffic (Dave 11×0/2×1 early, acaci's lone =1 receipt superseded by =0 600 lines later before any ram, Michael's =1 was pre-mission line 56; rajel/santo/eleng all 0). Under those adv=0 receipts, **the instant collision→leak signature is ABSENT** (01 borderline in ~145 rattle-eligible rams, 0.7%). The signature appears exactly and only where [exp] advDamage=1 (dave/michael night 14). **Field receipts therefore show the port's rattle path HONORING the +0x268 gate — the leak does NOT come from a gate bypass.** Corollary: the testers who reported "every single time" were almost certainly dave/michael in the n14 advDamage=1 missions (5 and 3 such receipts respectively), or in non-steam sessions outside this evidence set.
### (5) Wall vs mech-ram
Not distinguishable from these receipts: [crashdmg] carries no partner identity and mass= is always the logger's own mover mass. Indirect only: the |v|=34.x pile-up is top ground speed into static geometry; |v| 4060 outliers imply falls/DFA. No receipt-visible difference in leak outcome by |v| class beyond the amount threshold.
==============================================================================
TICKET #128 — BINARY TRUTH: THE COMPLETE 1995 COLLISION-DAMAGE CHAIN
=== STAGE 1: THE EMITTER (who dispatches, with what amount) ===
Pricing is ENGINE code we compile against [T0], dispatch is BT binary code [T1].
1a. Per-contact pricing — Mover::StaticBounce (C:/git/bt411/engine/MUNGA/MOVER.cpp:1433-1528) [T0]:
- impact = worldLinearVelocity · surfaceNormal (the NORMAL component only; grazing scrapes contribute little).
- impact > 0 (moving away) -> return 0. No other speed gate on damage: minimumBounceSpeed (streamed Mover attr "MinimumBounceSpeed") only zeroes *elasticity for slow hits (MOVER.cpp:1470-1473) — it kills the rebound, NOT the damage.
- Return value (MOVER.cpp:1527): -0.0005·(vn·vp)·moverMass, which algebraically reduces to raw = 0.0005 · moverMass · (1e²) · impact² — the kinetic-energy loss in kilojoules (mass in kg, velocity m/s; the DynamicBounce comment at MOVER.cpp:1569 confirms the kJ intent). This is the amount [crashdmg] prints.
- Mover::ProcessCollision (MOVER.cpp:1365-1414) calls StaticBounce per contact; Mover::ProcessCollisionList (MOVER.cpp:1214-1360) SUMS raw damage across the frame's contacts (total_damage, :1283/:1350), sets damageType = Damage::CollisionDamageType (= 0, :1228) and damageForce = postpre velocity delta (:1351).
1b. Dispatch — the binary's ground+collide response block (port Mech::AuthenticGroundAndCollide, game/reconstructed/mech4.cpp:8383-8537; binary @4aa630-@4aab5f, recovered byte-by-byte from the export gap, raw disasm scratchpad/night6/gap_4a9770.txt) [T1]:
- crushable-icon contact (0.00123f sentinel) -> gyro crunch only (@4aa7ce-4aa871), NO TakeDamage.
- blocking contact with damageAmount > 0 -> full frame rejection (velocity zeroed, position restored) + @4aa984-4aaab4 builds TakeDamageMessage{id 0x64, zone=-1, the collision Damage VERBATIM} and Dispatches it at the mech ITSELF. There is NO minimum-amount/speed gate at the emitter: ANY nonzero closing contact dispatches. (iv2 > 40 (u/s)² @0x4ab184 gates only the knockdown/bump ANIMATION ≈ 22.8 km/h.)
=== STAGE 2: THE DISTRIBUTOR — FUN_0049ffcc @0049ffcc (part_012.c:14374-14430) [T1, decomp + raw disasm scratchpad/night6/gap_49ffcc.txt] ===
Reached ONLY via the hub Mech::TakeDamageMessageHandler @0x4a0230: part_012.c:14636-14638 — `if (damage.damageType == 0) { FUN_0049ffcc(mech, &damage); goto LAB_004a07b5; }` — the goto jumps past the zone/armor loop entirely. Collision damage NEVER touches zone armor, in either gate state.
2a. THE GATE (@0x49ffde): `if (*(victimMech+0x190 /*owning BTPlayer*/ +0x268) == 0) return;` — the SECOND advancedDamage cell (egg/technician "splash/collision damage" setting). Gate OFF = TRULY NOTHING: the entire function body is inside this if; combined with the hub's goto, a collision then costs zero armor, zero subsystems, zero anything. [T1]
2b. THE SCALING (@0x49ffff-0x4a0030): amount *= ((2000/moverMass) / ((100·(1/3.6))²)) / (1 elasticity²). Constants from raw disasm: tbyte @0x4a0148 = 0.2777778 (1/3.6, km/h→m/s), 2000f @0x4a0154, 1.0f @0x4a0158, mass @mover+0x20c, elasticity @mover+0x244. Multiplied against the raw kJ from Stage 1, EVERYTHING CANCELS: scaled = 0.0005·m·(1e²)·v_n² × 2000/(m·771.605·(1e²)) = (v_n/27.778)² — the squared ratio of normal impact speed to 100 km/h, independent of mech mass and elasticity [T1 algebra on T0+T1 inputs].
2c. THE THRESHOLD (@0x4a003c-0x4a0045): scaled < 0.5f (@0x4a015c) -> return, FREE. => minimum damaging impact: v_n ≥ 27.778·√0.5 = 19.64 m/s ≈ 70.7 km/h closing normal speed, for EVERY mech. Walking scrapes (tangential, or slow) are free by construction.
2d. SUB-HIT COUNT (@0x4a0051): n = _ftol(scaled × 2.0f (@0x4a0160)) — FUN_004dcd94 is the CRT _ftol, not a helper (Ghidra shows ROUND(ST0); MSVC _ftol truncates — minor rounding uncertainty, n identical at all half-integer boundaries). amount /= n (@0x4a0065). With truncation each sub-hit is 0.5-1.0 points (n=1 for scaled∈[0.5,1), n=2 at 100 km/h, n=3 at ~140 km/h, n=6 at 182 km/h supercharge).
2e. TARGET SELECTION (the loop @0x4a006b-0x4a0121; NOT FUN_004ac07c): per sub-hit, roll = FUN_00408050() = rand()·(1/32768) ∈ [0,1) (part_000.c:7930); walk the subsystem roster (count mech+0x124, array mech+0x128) in ROSTER ORDER; an entry is ELIGIBLE iff FUN_0041a1a4(its derivation chain, TAG) matches one of THREE tags (part_012.c:14403-14410): 0x50e590, 0x50fdc0, 0x510b08; accumulate eligible entries' collisionCriticalHitWeight (+0x10C, streamed res+0xDC "CollisionCriticalHitWeight"); first entry whose cumulative ≥ roll receives the sub-hit via FUN_004ac07c and the walk breaks. Weights are UN-NORMALIZED: an un-won roll lands NOWHERE (faithful).
2f. ELIGIBILITY — the tags [T1, with a KB-internal contradiction I must flag]: FUN_0041a1a4 (part_002.c:3843) matches by Derivation-object POINTER IDENTITY walked up the parent chain. 0x50fdc0 = Gyroscope's node (DefaultData 0x50fdb0+0x10), 0x510b08 = Torso's node (DefaultData 0x510af8+0x10). 0x50e590 = the HeatSinkBank/AggregateHeatSink (0xBBE) node — NOT the HeatSink base family: decomp-reference.md rows 55+87 (task #57 correction) say so, and it is logically forced by part_013.c:5864 — the HeatSink base ctor itself tests 0x50e590 to let ONLY the bank skip the sink link-attach (heat.cpp:620-628 task #9, live-verified: treating it broader blocked condenser→bank conduction and caused the heat runaway). So the BINARY's eligible set = {HeatSinkBank, Gyroscope, Torso} — authored weights 0.30/0.35/0.25 (Σ=0.90; 10% of rolls land nowhere). The authored Myomer weight 0.35 is DEAD DATA for collisions (myomers are PoweredSubsystem-derived, no gyro/torso/bank tag in their chain; the other +0x10C-like path, zone crits @0049ccc4, uses the ZONE's crit-entry list weights instead). ⚠ CONTRADICTION: game/reconstructed/mech.cpp:1004-1018 (DistributeCollisionDamage) and context/combat-damage.md:1120 read 0x50e590 as the whole HeatSink FAMILY (BTHeatSinkFamily() returns HeatSink::GetClassDerivations(), heat.cpp:1447-1450) — that broadens eligibility to condenser/reservoir/generator/sensor/MYOMERS/weapons, i.e., nearly the entire coolant-bearing branch. This is a prime #128 root-cause candidate (see verdict below).
2g. THE APPLICATOR — FUN_004ac07c (part_013.c:4274-4282) [T1]: NOT a picker. It snapshots the target subsystem's PRIVATE zone damageLevel (this[0x38]+0x158, the Subsystem::damageZone @+0xE0), calls the subsystem's TakeDamage virtual (vtbl+0x24) with the (scaled, divided) Damage, returns the delta. The MechSubsystem vtbl+0x24 analog FUN_004ac0bc (part_013.c:4290): zone->TakeDamage (zone vtbl+0x18; DamageZone::TakeDamage @0041e4e0 == DAMAGE.cpp:391: damageLevel += amount × damageScale[type]) then if level ≥ 1.0 (_DAT_004ac140) -> failure states (Destroyed). The private zone's damageScale[0] (collision) is the authored damageTypePoints normalized as scale = 1/(points·defaultArmorPoints) (mechsub.cpp:221-237, ArmourNumerator=1.0 @0x4ac860) — exact authored bank values not pinned [T3].
=== STAGE 3: THE LEAK ONSET — HeatSink::UpdateCoolant FUN_004adbf8 (part_013.c:5733) [T1 decomp, constants T2] ===
Run per-frame from HeatSink::Update for every HeatSink-DERIVED subsystem only (CLASSMAP.md tree: HeatSink -> Condenser/Reservoir/HeatSinkBank/Generator/PoweredSubsystem -> Sensor/Myomers/MechWeapon...). Gyroscope and Torso are HeatWatcher->PowerWatcher-derived (CLASSMAP.md:34-98) — they have NO coolant members and CANNOT leak.
- coolantDraw(+0x130) = ownZoneDamage (this[0x38]+0x158) × heatLoad (+0x120, the SMOOTHED radiated-heat running average, attr "NormalizedPressure");
- draw < 0.0025 (_DAT_0050e3d8) -> zeroed (the floor);
- the draw drains own coolantLevel(+0x12C) × dt, replenished by DrawCoolant (vtbl+0x38, @0x4add00) recursing up linkedSinks × coolantFlowScale(+0x15C) to the Reservoir (@0x4af3b0) — the cockpit coolant bar visibly drops;
- HYSTERESIS coolantActive(+0x138): OFF->ON when draw > 0.003 (_DAT_0050e3d4); ON->OFF when draw < 0.0025. coolantActive IS the ReportLeak attribute ridden by the 19 authored leak watchers (the 3-note warning) and the "CoolantLeaking" TechStatusType (@50df17).
- LEAK CONDITION: zoneDamage × heatLoad > 0.003. One 0.5-1.0-pt rattle sub-hit gives zoneDamage = amount × damageScale[0]; with any working heat load (live port measurements saw myomer heatLoad ≈ 0.014 at idle-ish work: 0.5×0.014 = 0.007 > 0.003) a SINGLE sub-hit plausibly crosses the threshold immediately — the leak is instant once a coolant-bearing subsystem is rattled at all [formula T1; magnitude T3 pending authored bank points].
=== THE EXPLICIT ANSWERS ===
(a) When does 1995 dispatch damage at all? The self-TakeDamage(type 0) is dispatched on ANY blocking contact with nonzero closing normal speed (no emitter threshold). But it has EFFECT only when the distributor's scaled amount (v_n/27.778 m/s)² ≥ 0.5, i.e. closing NORMAL speed ≥ 19.64 m/s ≈ 70.7 km/h — mass- and elasticity-independent (exact cancellation). Below that every scrape/tap is free; gate (+0x268) OFF makes ALL collisions free. Mech envelope: stat-sheet speeds 40 (gimped)/143 (normal)/182 (supercharged) kph (context/pod-hardware.md:589-598) — so a run-speed head-on ram crosses easily, a walk or graze never does.
(b) When does 1995 first spring a coolant leak? Requires ALL of: (i) advancedDamage(+0x268) ON; (ii) v_n ≥ 70.7 km/h (≥1 sub-hit); (iii) the lottery landing on the HeatSinkBank — the ONLY coolant-bearing eligible class — probability 0.30 per sub-hit (gyro 0.35 and torso 0.25 rattles cannot leak); (iv) resulting bank zoneDamage × bank heatLoad > 0.003 (plausibly crossed by one sub-hit on any warm mech). Per-ram leak probability ≈ 10.7ⁿ: ~30% at 71 km/h (n=1), ~51% at 100 km/h (n=2), ~66% at 140 km/h (n=3), ~88% at 182 km/h (n=6). 1995 is NEVER "every single time".
VERDICT for #128 (inference from the above): the deterministic instant leak the testers report cannot be authentic 1995 behavior at any speed. The port's DistributeCollisionDamage (mech.cpp:1016) broadened 0x50e590 from bank-only to the whole HeatSink family — so myomers (weight 0.35 EACH, several per mech) and other coolant-bearing subsystems enter the lottery, the cumulative weight saturates, and nearly every rattle sub-hit lands on something that runs UpdateCoolant -> leak virtually every qualifying ram. Compounding: the steam eggs now stamp advancedDamage mission-wide (commit 3545056), turning the gate ON for matches where it was silently OFF before, and the run-14 note (mech4.cpp:8523-8525) already flags the forwarded [crashdmg] amount as suspect vs binary.
+2 -9
View File
@@ -14,15 +14,8 @@ Resolve AFTER all 7 families report (Stage 1 reconciliation), then again at link
- **Player__VehicleDeadMessage**: BT build carried `killerName` (+0x1c) the engine base lacks; read via offset. - **Player__VehicleDeadMessage**: BT build carried `killerName` (+0x1c) the engine base lacks; read via offset.
### Stubbed (no WinTesla analog) — revisit at integration ### Stubbed (no WinTesla analog) — revisit at integration
- ~~BTPlayer ctor role-registry resolution~~**RESOLVED 2026-08-07.** The claim "no WinTesla - BTPlayer ctor role/mission-registry resolution: `GetMissionRegistry()` / `GetRoleRegistry()->Lookup()` /
analog → stubbed; base-set `scenarioRole` stands" was wrong twice over: the base ctor sets `GetMission()->GetGameModel()` have no WinTesla analog → stubbed; base-set `scenarioRole` stands.
`scenarioRole` to **NULL** (PLAYER.cpp:680), so nothing "stood"; and the analog exists —
`Mission::GetScenarioRole(name)` (MISSION.h:162) walks the same `scenarioRoleChain` that
`BTL4Mission` fills via `AddScenarioRole()` when it parses the role pages. A NULL role zeroed
EVERY scoring value (a kill scored 4.88 instead of 505.88, the eject charge read 0, the death
cost was skipped). Now bound, with a `Role::Default` fallback and a BOUND/NULL receipt.
`GetMission()->GetGameModel()` (the freeForAll compare) stays genuinely stubbed — the shipped
code discarded its result anyway.
### Link-time externs to be PROVIDED by owning modules (Stage 3) ### Link-time externs to be PROVIDED by owning modules (Stage 3)
- `ToggleVoiceAssist`, `Is_Destroyed` (mechmppr) - `ToggleVoiceAssist`, `Is_Destroyed` (mechmppr)
+4 -41
View File
@@ -1,5 +1,4 @@
#include <cstdlib> #include <cstdlib>
#include <cstring>
#include "munga.h" #include "munga.h"
#pragma hdrstop #pragma hdrstop
@@ -979,31 +978,6 @@ AudioStateTrigger::AudioStateTrigger(
MemoryStream_Read(stream, &excludeTransition); MemoryStream_Read(stream, &excludeTransition);
MemoryStream_Read(stream, &excludeState); MemoryStream_Read(stream, &excludeState);
// #154 DELIBERATE DEVIATION from shipped 1995 content (the ONLY authored-
// content override in the port -- revert with BT_GEN_WARN_SHIPPED=1).
// BTL4.RES authors the generator-out warning as (trigState=4, inverse=1,
// Start) = fire on LEAVING GeneratorOut -- so a thermal trip was SILENT and
// the klaxon played at RECOVERY/respawn instead (live-benched,
// gen_edge_bench.sh; bytes @0x2bf13, decode triple-verified). Every other
// fault alarm in the game is authored enter-state, inverse is otherwise
// only a Stop terminator (144-row census), and the manual (p14/p23) lists
// this as a loss-time warning: a one-bit 1995 authoring slip. Flip it to
// enter-4 so the warning speaks at the TRIP, stays silent at recovery and
// at the respawn reset's 4->0 edge. (Testers' "voice at trip" memories are
// Firestorm's speech system -- BT 4.10 shipped no speech samples at all.)
{
extern char g_lastBoundAttrName[64];
if (triggerState == 4 && inverseTrigger != 0
&& strcmp(g_lastBoundAttrName, "GeneratorState") == 0
&& getenv("BT_GEN_WARN_SHIPPED") == 0)
{
inverseTrigger = 0;
if (getenv("BT_ATTRBIND_LOG"))
DEBUG_STREAM << "[statecfg] #154 fixup: GeneratorState leave-4"
" Start -> enter-4 Start\n" << std::flush;
}
}
if (getenv("BT_ATTRBIND_LOG")) { static int s_stc=0; if (s_stc++<120) if (getenv("BT_ATTRBIND_LOG")) { static int s_stc=0; if (s_stc++<120)
DEBUG_STREAM << "[statecfg] attrPtr=" << (void*)attributePointer DEBUG_STREAM << "[statecfg] attrPtr=" << (void*)attributePointer
<< " comp=" << (void*)audioComponentSocket.GetCurrent() << " comp=" << (void*)audioComponentSocket.GetCurrent()
@@ -1102,21 +1076,10 @@ void
// //
// Check for the trigger state // Check for the trigger state
// //
const int bt_fires = if (
((!inverseTrigger && new_state == triggerState) || (!inverseTrigger && new_state == triggerState) ||
(inverseTrigger && old_state == triggerState)); (inverseTrigger && old_state == triggerState)
// #154 diag (BT_AUDIO_EDGE=1): EVERY state-edge delivery to EVERY authored )
// trigger, fired or not -- state edges are rare, so this is low-volume.
// Correlate with the [gen] TRIPPED/RESTARTING lines to observe which edge
// actually starts a warning sequence.
if (getenv("BT_AUDIO_EDGE"))
DEBUG_STREAM << "[audioedge] old=" << old_state << " new=" << new_state
<< " trig=" << triggerState << " inv=" << (int)inverseTrigger
<< " ctl=" << (int)controlID << "/" << controlValue
<< " comp=" << (void*)audioComponentSocket.GetCurrent()
<< (bt_fires ? " FIRED" : " no-fire")
<< "\n" << std::flush;
if (bt_fires)
{ {
Check(&audioComponentSocket); Check(&audioComponentSocket);
Check(audioComponentSocket.GetCurrent()); Check(audioComponentSocket.GetCurrent());
-24
View File
@@ -117,16 +117,6 @@ EntitySegment*
} }
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// #149 perf telemetry (BT_PERF_LOG; counters cost two increments when unset).
// gBTSegWDirty counts the EXPENSIVE arm -- the mark-every-segment pass that
// invalidates the whole table -- separately from plain calls, because the #141
// sweep put this accessor on per-frame weapon/beam paths and the open question
// is whether that multiplied the dirty-pass rate (each pass forces the next
// query of EVERY segment to re-derive its parent chain).
int gBTSegWCalls = 0;
int gBTSegWDirty = 0;
double gBTSegWMs = 0.0;
// //
void void
JointedMover::GetSegmentToWorld( JointedMover::GetSegmentToWorld(
@@ -135,13 +125,6 @@ void
) )
{ {
Check(this); Check(this);
// DEFAULT ON (operator, 2026-08-09): every player's session log should
// carry this -- cross-machine comparison is the point. BT_PERF_LOG=0
// opts out. Cost: ~2 QPC reads per call, microseconds per second.
static const int sPerf = !(getenv("BT_PERF_LOG") && *getenv("BT_PERF_LOG") == '0');
LARGE_INTEGER t0, t1, fq;
++gBTSegWCalls;
if (sPerf) QueryPerformanceCounter(&t0);
JointSubsystem *joints = GetJointSubsystem(); JointSubsystem *joints = GetJointSubsystem();
Check(joints); Check(joints);
@@ -152,7 +135,6 @@ void
// //
if (joints->AreJointsModified()) if (joints->AreJointsModified())
{ {
++gBTSegWDirty;
EntitySegment::SegmentTableIterator iterator(segmentTable); EntitySegment::SegmentTableIterator iterator(segmentTable);
EntitySegment *current_segment; EntitySegment *current_segment;
while( (current_segment = iterator.ReadAndNext() ) != NULL) while( (current_segment = iterator.ReadAndNext() ) != NULL)
@@ -171,12 +153,6 @@ void
my_segment.GetSegmentToEntity(), my_segment.GetSegmentToEntity(),
localToWorld localToWorld
); );
if (sPerf)
{
QueryPerformanceCounter(&t1);
QueryPerformanceFrequency(&fq);
gBTSegWMs += 1000.0 * (double)(t1.QuadPart - t0.QuadPart) / (double)fq.QuadPart;
}
Check_Fpu(); Check_Fpu();
} }
-20
View File
@@ -53,17 +53,6 @@ void
while ((lamp_pointer=i.ReadAndNext()) != NULL) while ((lamp_pointer=i.ReadAndNext()) != NULL)
{ {
Check(lamp_pointer); Check(lamp_pointer);
// #135: an edge that arrived while this lamp's page was not current
// (or during the one-frame mask race of a page transition) skipped its
// NotifyOfStateChange -- the panel value latches the last asserted
// flash, so a missed CLEAR left loop buttons flashing forever. The
// lamp is on the ACTIVE list here, so its mode is current: deliver
// the missed notification now.
if (lamp_pointer->staleAlertNotify)
{
lamp_pointer->staleAlertNotify = False;
lamp_pointer->NotifyOfStateChange();
}
lamp_pointer->Update(); lamp_pointer->Update();
} }
Check_Fpu(); Check_Fpu();
@@ -259,7 +248,6 @@ Lamp::Lamp(
previousState = LampStateUndefined; previousState = LampStateUndefined;
manager = lamp_manager; manager = lamp_manager;
alertActive = 0; alertActive = 0;
staleAlertNotify = False; // #135
manager->AddLamp(this); manager->AddLamp(this);
Check_Fpu(); Check_Fpu();
@@ -293,10 +281,6 @@ void
{ {
NotifyOfStateChange(); NotifyOfStateChange();
} }
else
{
staleAlertNotify = True; // #135: deliver at next in-mode Update
}
} }
Check_Fpu(); Check_Fpu();
} }
@@ -327,10 +311,6 @@ void
{ {
NotifyOfStateChange(); NotifyOfStateChange();
} }
else
{
staleAlertNotify = True; // #135: deliver at next in-mode Update
}
Check_Fpu(); Check_Fpu();
} }
-8
View File
@@ -78,14 +78,6 @@ protected:
*manager; *manager;
int int
alertActive; alertActive;
// #135: an alert-state EDGE whose NotifyOfStateChange was skipped by the
// mode gate (page not current, or the one-frame mask race during a page
// transition). The panel value latches the last asserted flash until the
// next assert, so a skipped clear-edge left loop buttons flashing forever
// (field: Oracle night-15, "Loop 6 NEVER stopped"). Delivered by Update()
// at the lamp's next in-mode frame.
Logical
staleAlertNotify;
}; };
//######################################################################### //#########################################################################
-23
View File
@@ -55,32 +55,9 @@ ScenarioRole::ScenarioRole(const CString &role_name, const CString &model_file)
damageBias = player_data->damageBias; damageBias = player_data->damageBias;
friendlyFirePenalty = player_data->friendlyFirePenalty; friendlyFirePenalty = player_data->friendlyFirePenalty;
player_res_des->Unlock(); player_res_des->Unlock();
// SCORE PROVENANCE (ungated, 2026-08-07). Every unmatched row of the
// original manual's scoring chart -- +500 a kill, -1000 an eject, -500
// an ammo death -- is sourced HERE, from the role's GameModel record
// (type 0xf, 7 dwords; the binary's ctor @00429a9c copies rec[0..6]).
// A field log showed `charge=0 (role killBonus)`, and the two ways that
// happens -- record authored zero, or resource lookup missed -- are
// indistinguishable in release because the miss path's Warn/Tell compile
// out (DEBUGOFF.h). One line per role settles it.
DEBUG_STREAM << "[role] '" << (const char *)role_name
<< "' model='" << (const char *)model_file
<< "' killBonus=" << (float)killBonus
<< " deathPenalty=" << (float)specialCaseDeathPenalty
<< " dmgInf=" << (float)damageInflictedModifier
<< " dmgRcv=" << (float)damageReceivedModifier
<< " bias=" << (float)damageBias
<< " ff=" << (float)friendlyFirePenalty
<< " return=" << (int)returnFromDeath
<< "\n" << std::flush;
} }
else else
{ {
DEBUG_STREAM << "[role] '" << (const char *)role_name
<< "' model='" << (const char *)model_file
<< "' -- RESOURCE NOT FOUND, all score values default to 0"
<< "\n" << std::flush;
Tell(role_name); Tell(role_name);
Warn(" does not exists in resource! "); Warn(" does not exists in resource! ");
damageReceivedModifier = 0.0f; damageReceivedModifier = 0.0f;
-13
View File
@@ -96,19 +96,6 @@ AttributeWatcher::AttributeWatcher(
attributePointer = simulation->GetAttributePointer(attribute_name); attributePointer = simulation->GetAttributePointer(attribute_name);
// The bound attribute's NAME, visible to derived ctors (the stream layout
// puts the trigger fields AFTER the base bind, so a derived ctor that needs
// to know WHICH attribute it just bound -- the #154 GeneratorState fixup --
// reads it from here). Valid only during construction.
{
extern char g_lastBoundAttrName[64];
const char *an = (const char *)attribute_name;
int i = 0;
for (; an != 0 && an[i] != '\0' && i < 63; ++i)
g_lastBoundAttrName[i] = an[i];
g_lastBoundAttrName[i] = '\0';
}
if (getenv("BT_ATTRBIND_LOG")) if (getenv("BT_ATTRBIND_LOG"))
{ {
extern int g_curAudioWatcherClass; extern int g_curAudioWatcherClass;
-1
View File
@@ -23,7 +23,6 @@ ALuint *g_buffers;
int g_numBuffers; int g_numBuffers;
const char *g_bufferNames[512]; // bufferInd -> sample file (diag: the live-playing dump) const char *g_bufferNames[512]; // bufferInd -> sample file (diag: the live-playing dump)
int g_curAudioWatcherClass = 0; int g_curAudioWatcherClass = 0;
char g_lastBoundAttrName[64] = {0}; // AttributeWatcher bind name, for derived-ctor fixups (#154)
// //
// Minimal canonical-PCM WAV reader. The repo's libsndfile-1.dll is a STUB // Minimal canonical-PCM WAV reader. The repo's libsndfile-1.dll is a STUB
+5 -16
View File
@@ -1118,22 +1118,11 @@ LBE4ControlsManager::~LBE4ControlsManager()
#ifdef BT_GLASS #ifdef BT_GLASS
// //
// Symmetric with the Create above (2026-08-06). The HARDWARE RIO's dtor // Symmetric with the Create above (2026-08-06). ~PadRIO tears the panels
// knows nothing about the panels, and windows that outlive the surfaces // down itself, so this is a no-op there -- but the HARDWARE RIO's dtor
// they blit are a crash waiting for the next mission cycle. // knows nothing about them, and windows that outlive the surfaces they
// // blit are a crash waiting for the next mission cycle. Destroy is safe
// CORRECTION (2026-08-07, #140): the original comment here claimed this was // if they were never created.
// "a no-op" on the PadRIO path because ~PadRIO tears the panels down itself.
// It was not. ~PadRIO runs first (delete rioPointer, above), zeroing the
// window list -- and BTGlassPanels_Destroy unconditionally ran SaveLayout
// BEFORE looking at whether anything was left, so this second call rewrote
// glass_layout.cfg with every MFD and radar line missing. Only the plasma
// window survived, because external windows cached a last-known rect and the
// per-display windows did not. That is the regression testers hit on the
// desktop the same day the pod panels were wired up; the pod itself was
// unaffected (no PadRIO, so only ONE destroy, and it runs BT_GLASS_LAYOUT=
// load anyway). Fixed on both sides in L4GLASSWIN: the geometry is now
// remembered across teardown, and the teardown save is guarded.
// //
BTGlassPanels_Destroy(); BTGlassPanels_Destroy();
#endif #endif
+27 -357
View File
@@ -96,8 +96,6 @@ struct GButton
struct GWin struct GWin
{ {
double perfMs; // [glassperf] paint ms this window, this second
int perfN; // [glassperf] paints this second
const char *title; const char *title;
HWND hwnd; HWND hwnd;
@@ -752,70 +750,7 @@ void
// Heat MFD=monitor:2,bare (index into the enumeration order) // Heat MFD=monitor:2,bare (index into the enumeration order)
// The surface is centred on that monitor; an exact-size panel fills it. // The surface is centred on that monitor; an exact-size panel fills it.
//--------------------------------------------------------------------------- //---------------------------------------------------------------------------
struct MonScan { int index; const char *want; const char *wantId; int wantIndex; RECT rect; int found; }; struct MonScan { int index; const char *want; int wantIndex; RECT rect; int found; };
// ---------------------------------------------------------------------------
// STABLE PANEL IDENTITY (2026-08-08) -- the fix for "the monitors came back in a
// different order after I moved cables and rebooted".
//
// Windows renumbers \\.\DISPLAYn, and reorders the enumeration, when a panel is
// power-cycled or re-cabled -- even when the visible desktop arrangement is
// unchanged. So BOTH existing binding forms are boot-fragile: `monitor:2` is an
// enumeration index and `monitor:\.\DISPLAY4` is a GDI name Windows reassigns.
// (The same trap bites GameOS next door: its -tmon takes DirectDraw device
// indices, which are neither Windows monitor numbers nor stable -- and its
// NULL-device merge shifts every index down by one on top of that.)
//
// What IS stable is the panel's own hardware identity. EnumDisplayDevices on a
// display's MONITOR child returns a DeviceID like
// MONITOR\DEL4231\{4d36e96e-e325-11ce-bfc1-08002be10318}\0002
// or, with EDD_GET_DEVICE_INTERFACE_NAME,
// \\?\DISPLAY#DEL4231#5&1a2b3c&0&UID4353#{e6f07b5f-...}
// The `DEL4231` field is the EDID manufacturer + product code, and the UID/
// instance identifies the physical connector. Neither moves on a reboot.
//
// Fill `out` with that string for a \\.\DISPLAYn adapter name. Empty on failure
// (no monitor child / remote session) -- callers must treat "" as "no identity"
// and fall back, never as a match.
static void
MonitorStableId(const char *displayName, char *out, size_t outLen)
{
if (outLen == 0) return;
out[0] = '\0';
if (displayName == NULL || displayName[0] == '\0') return;
DISPLAY_DEVICEA mon;
memset(&mon, 0, sizeof(mon));
mon.cb = sizeof(mon);
// index 0 = the attached monitor child. The interface-name flag gives the
// richer path (includes the connector UID); it is Vista+, and if the call
// fails we retry without it for the plain MONITOR\... form.
if (!EnumDisplayDevicesA(displayName, 0, &mon, EDD_GET_DEVICE_INTERFACE_NAME))
{
memset(&mon, 0, sizeof(mon));
mon.cb = sizeof(mon);
if (!EnumDisplayDevicesA(displayName, 0, &mon, 0))
return;
}
strncpy(out, mon.DeviceID, outLen - 1);
out[outLen - 1] = '\0';
}
// Case-insensitive substring test -- the cfg quotes a FRAGMENT of the identity
// (usually just the EDID code, e.g. `id:DEL4231`) rather than the whole path,
// because the full string is long and contains characters a config file and a
// shell each mangle differently.
static int
IdContains(const char *haystack, const char *needle)
{
if (haystack == NULL || needle == NULL || *needle == '\0') return 0;
size_t hl = strlen(haystack), nl = strlen(needle);
if (nl > hl) return 0;
for (size_t i = 0; i + nl <= hl; ++i)
if (_strnicmp(haystack + i, needle, nl) == 0)
return 1;
return 0;
}
static BOOL CALLBACK static BOOL CALLBACK
MonScanProc(HMONITOR mon, HDC, LPRECT, LPARAM param) MonScanProc(HMONITOR mon, HDC, LPRECT, LPARAM param)
@@ -827,17 +762,7 @@ static BOOL CALLBACK
if (GetMonitorInfoA(mon, (MONITORINFO *)&mi)) if (GetMonitorInfoA(mon, (MONITORINFO *)&mi))
{ {
int hit = 0; int hit = 0;
// `id:<fragment>` -- match the panel's HARDWARE identity, not its if (sc->want != NULL)
// current \\.\DISPLAYn or enumeration slot. Boot-stable; this is the
// form the pod should use.
if (sc->wantId != NULL)
{
char sid[256];
MonitorStableId(mi.szDevice, sid, sizeof(sid));
if (sid[0] != '\0' && IdContains(sid, sc->wantId))
hit = 1;
}
if (!hit && sc->want != NULL)
{ {
// Match the full device name OR just its tail, so a hand-written // Match the full device name OR just its tail, so a hand-written
// cfg can say `monitor:DISPLAY4` and skip the \\.\ prefix entirely // cfg can say `monitor:DISPLAY4` and skip the \\.\ prefix entirely
@@ -862,96 +787,6 @@ static BOOL CALLBACK
return TRUE; return TRUE;
} }
// BT_GLASS_IDS=1 -- dump every attached panel with its BOOT-STABLE identity, and
// a ready-to-paste `monitor:id:` fragment for glass_layout.cfg. This is the
// discovery half of the fix: run it once on the pod, copy the fragments into the
// cfg, and the assignment survives reboots and re-cabling.
//
// Prints the volatile identifiers too, deliberately side by side -- seeing
// \\.\DISPLAYn and the enumeration index MOVE between two runs while the id
// stays put is the proof that the id form is the right one.
struct MonDump { int index; };
static BOOL CALLBACK
MonDumpProc(HMONITOR mon, HDC, LPRECT, LPARAM param)
{
MonDump *d = (MonDump *)param;
MONITORINFOEXA mi;
memset(&mi, 0, sizeof(mi));
mi.cbSize = sizeof(mi);
if (GetMonitorInfoA(mon, (MONITORINFO *)&mi))
{
char sid[256];
MonitorStableId(mi.szDevice, sid, sizeof(sid));
// The EDID make+product sits between the first two separators of the
// DeviceID (MONITOR\DEL4231\... or \\?\DISPLAY#DEL4231#...). Offer it
// as the suggested fragment -- short, and unique when the panels are
// different models. Identical models need more (see the note below).
// Find it STRUCTURALLY rather than by position: an EDID PnP code is
// exactly 3 letters + 4 hex digits (AUO10ED, DEL4231). Walking the
// separators positionally broke on the interface-name form, whose
// `\\?\DISPLAY#...` prefix has a different number of leading segments
// than the plain `MONITOR\...` form -- and which form you get depends on
// whether EDD_GET_DEVICE_INTERFACE_NAME succeeded. Tokenising on all of
// \ # ? handles every variant the same way.
char frag[64];
frag[0] = '\0';
for (const char *t = sid; *t != '\0'; )
{
while (*t == '\\' || *t == '#' || *t == '?') ++t;
const char *e = t;
while (*e != '\0' && *e != '\\' && *e != '#' && *e != '?') ++e;
size_t n = (size_t)(e - t);
if (n == 7)
{
int ok = 1;
for (int i = 0; i < 3 && ok; ++i)
if (!isalpha((unsigned char)t[i])) ok = 0;
for (int i = 3; i < 7 && ok; ++i)
if (!isxdigit((unsigned char)t[i])) ok = 0;
if (ok)
{
memcpy(frag, t, 7);
frag[7] = '\0';
break;
}
}
t = e;
}
DEBUG_STREAM << "[glassid] index=" << d->index
<< " device=" << mi.szDevice
<< (((mi.dwFlags & MONITORINFOF_PRIMARY) != 0) ? " PRIMARY" : "")
<< " rect=" << (int)mi.rcMonitor.left << "," << (int)mi.rcMonitor.top
<< " " << (int)(mi.rcMonitor.right - mi.rcMonitor.left)
<< "x" << (int)(mi.rcMonitor.bottom - mi.rcMonitor.top)
<< "\n stable-id = " << (sid[0] ? sid : "(unavailable)")
<< "\n cfg form = monitor:id:" << (frag[0] ? frag : "<see stable-id>")
<< "\n" << std::flush;
}
++d->index;
return TRUE;
}
void
BTGlassDumpMonitorIds()
{
if (getenv("BT_GLASS_IDS") == NULL)
return;
DEBUG_STREAM << "[glassid] ---- attached panels, boot-STABLE identities ----\n"
<< "[glassid] index and device= move across reboots / power-cycles;\n"
<< "[glassid] stable-id does not. Bind the pod with monitor:id:<fragment>.\n"
<< "[glassid] If two panels are the SAME MODEL their EDID codes match --\n"
<< "[glassid] use a longer fragment from stable-id (the UID/instance tail\n"
<< "[glassid] differs per connector) so each line matches exactly one.\n"
<< std::flush;
MonDump d;
d.index = 0;
EnumDisplayMonitors(NULL, NULL, MonDumpProc, (LPARAM)&d);
DEBUG_STREAM << "[glassid] ---- " << d.index << " panel(s) ----\n" << std::flush;
}
// Resolve "monitor:<spec>" to a rect. Returns 1 on success. // Resolve "monitor:<spec>" to a rect. Returns 1 on success.
static int static int
ResolveMonitorSpec(const char *spec, RECT *out) ResolveMonitorSpec(const char *spec, RECT *out)
@@ -960,31 +795,13 @@ static int
MonScan sc; MonScan sc;
memset(&sc, 0, sizeof(sc)); memset(&sc, 0, sizeof(sc));
sc.wantIndex = -1; sc.wantIndex = -1;
// `monitor:id:<fragment>` -- BOOT-STABLE hardware identity (preferred on the if (spec[0] >= '0' && spec[0] <= '9')
// pod). Anything else keeps its historic meaning exactly: a leading digit is
// the enumeration index, otherwise a \\.\DISPLAYn device name (or its tail).
if (_strnicmp(spec, "id:", 3) == 0)
{
sc.wantId = spec + 3;
while (*sc.wantId == ' ' || *sc.wantId == '\t') ++sc.wantId;
}
else if (spec[0] >= '0' && spec[0] <= '9')
sc.wantIndex = atoi(spec); sc.wantIndex = atoi(spec);
else else
sc.want = spec; sc.want = spec;
EnumDisplayMonitors(NULL, NULL, MonScanProc, (LPARAM)&sc); EnumDisplayMonitors(NULL, NULL, MonScanProc, (LPARAM)&sc);
if (sc.found) if (sc.found)
*out = sc.rect; *out = sc.rect;
else if (sc.wantId != NULL)
{
// An id: binding that matched nothing is worth shouting about: the panel
// is unplugged, asleep, or the cfg fragment is wrong. Silently falling
// back to computed placement would put a picture on the wrong glass and
// look like the very bug this form exists to prevent.
DEBUG_STREAM << "[glasswin] monitor id '" << sc.wantId
<< "' matched NO attached panel -- check BT_GLASS_IDS=1 output"
<< "\n" << std::flush;
}
return sc.found; return sc.found;
} }
@@ -1105,50 +922,9 @@ static void
<< layoutFileName << "\n" << std::flush; << layoutFileName << "\n" << std::flush;
} }
// LAST-KNOWN GEOMETRY, kept ACROSS teardown (2026-08-07, #140). // Write every window's current on-screen frame rect. Whole-file rewrite (it is
// // tiny), so partial/hard kills never leave a half-written file for long. Called
// SaveLayout rewrites the whole file, so it can only be as complete as what it // on finished-drag and on teardown in save mode.
// can see -- and it could only see LIVE windows. Destroy() nulls every hwnd and
// zeroes gWinCount, so any save that ran after a teardown wrote a file with
// every MFD and radar line MISSING. The plasma window survived that because
// external windows already cached a last-known rect (gExtern[].haveLast); the
// per-display windows had no such cache, which is exactly the reported
// signature: "all the MFDs and secondary lines missing, but plasma was still
// there". Give the glass windows the same guarantee, so the file is monotonic
// -- a save can update a line or add one, never drop one.
struct SavedGeom
{
char title[64];
RECT r;
int noFrame;
};
static SavedGeom gLastGeom[16];
static int gLastGeomCount = 0;
static void
RememberGeom(const char *title, const RECT &r, int noFrame)
{
if (title == NULL || title[0] == '\0')
return;
for (int i = 0; i < gLastGeomCount; ++i)
if (strcmp(gLastGeom[i].title, title) == 0)
{
gLastGeom[i].r = r; gLastGeom[i].noFrame = noFrame;
return;
}
if (gLastGeomCount >= (int)(sizeof(gLastGeom) / sizeof(gLastGeom[0])))
return;
SavedGeom &g = gLastGeom[gLastGeomCount++];
strncpy(g.title, title, sizeof(g.title) - 1);
g.title[sizeof(g.title) - 1] = '\0';
g.r = r; g.noFrame = noFrame;
}
// Write every window's frame rect. Whole-file rewrite (it is tiny), so
// partial/hard kills never leave a half-written file for long. Called on
// finished-drag and on teardown in save mode. Live windows refresh the
// remembered geometry first; the FILE is then written from the remembered set,
// so a window that has already been torn down keeps its line.
static void static void
SaveLayout() SaveLayout()
{ {
@@ -1175,7 +951,6 @@ static void
"# this list too -- it can be dragged, remembered and set ,noframe.\n", "# this list too -- it can be dragged, remembered and set ,noframe.\n",
f); f);
int wrote = 0; int wrote = 0;
// 1. refresh the remembered geometry from whatever is currently alive
for (int i = 0; i < gWinCount; ++i) for (int i = 0; i < gWinCount; ++i)
{ {
GWin &gw = gWins[i]; GWin &gw = gWins[i];
@@ -1184,16 +959,10 @@ static void
RECT r; RECT r;
if (!GetWindowRect(gw.hwnd, &r)) if (!GetWindowRect(gw.hwnd, &r))
continue; continue;
RememberGeom(gw.title, r, gw.noFrame); // keep the hand-added option fprintf(f, "%s=%ld,%ld,%ld,%ld%s\n", gw.title,
} (long)r.left, (long)r.top,
// 2. write the remembered set -- including windows already torn down (long)(r.right - r.left), (long)(r.bottom - r.top),
for (int i = 0; i < gLastGeomCount; ++i) gw.noFrame ? ",noframe" : ""); // keep the hand-added option
{
const SavedGeom &g = gLastGeom[i];
fprintf(f, "%s=%ld,%ld,%ld,%ld%s\n", g.title,
(long)g.r.left, (long)g.r.top,
(long)(g.r.right - g.r.left), (long)(g.r.bottom - g.r.top),
g.noFrame ? ",noframe" : "");
++wrote; ++wrote;
} }
// External windows (the plasma window) ride the same file. Cache the last // External windows (the plasma window) ride the same file. Cache the last
@@ -1219,15 +988,8 @@ static void
++wrote; ++wrote;
} }
fclose(f); fclose(f);
// #140 receipt (ungated): `live=` is the diagnostic that matters. A save
// that runs with live=0 is the corruption case -- before the remembered-
// geometry cache it wrote a file containing ONLY the plasma line, which is
// what testers reported. It stays in the log so the FIELD can tell us which
// caller does that (teardown ordering, a drag after a round boundary, ...),
// which no bench here managed to reach.
DEBUG_STREAM << "[glasswin] saved " << wrote << " window position(s) to " DEBUG_STREAM << "[glasswin] saved " << wrote << " window position(s) to "
<< layoutFileName << " (live=" << gWinCount << layoutFileName << "\n" << std::flush;
<< " remembered=" << gLastGeomCount << ")\n" << std::flush;
} }
// Public save trigger for registered external windows -- their WndProc calls // Public save trigger for registered external windows -- their WndProc calls
@@ -1712,20 +1474,7 @@ static LRESULT CALLBACK
switch (message) switch (message)
{ {
case WM_PAINT: case WM_PAINT:
if (w != NULL) if (w != NULL) { PaintGlass(window, w); return 0; }
{
// [glassperf] (#149): time EVERY panel paint. The blit is a
// HALFTONE StretchDIBits whose cost is strongly driver-dependent
// -- the whole point is to measure it on the machine that pays it.
LARGE_INTEGER t0, t1, fq;
QueryPerformanceCounter(&t0);
PaintGlass(window, w);
QueryPerformanceCounter(&t1);
QueryPerformanceFrequency(&fq);
w->perfMs += 1000.0 * (double)(t1.QuadPart - t0.QuadPart) / (double)fq.QuadPart;
++w->perfN;
return 0;
}
break; break;
case WM_ERASEBKGND: case WM_ERASEBKGND:
@@ -1768,14 +1517,6 @@ static LRESULT CALLBACK
} }
else else
{ {
// #166: a press must never ORPHAN a previous one -- if a
// prior button is still logically down (capture stolen by
// an overlay/popup so its WM_LBUTTONUP never arrived),
// release it before pressing the new one. An orphaned
// press on the ammo-EJECT panel button is a permanent
// weapon jam (bin parked at Ejecting).
if (pressedAddress >= 0 && pressedAddress != a)
PadRIO::SetScreenButton(pressedAddress, 0);
pressedAddress = a; pressedAddress = a;
SetCapture(window); SetCapture(window);
PadRIO::SetScreenButton(a, 1); PadRIO::SetScreenButton(a, 1);
@@ -1795,21 +1536,6 @@ static LRESULT CALLBACK
} }
return 0; return 0;
case WM_CAPTURECHANGED:
// #166: mouse capture stolen mid-press (Steam overlay, popups, focus
// change) -- WM_LBUTTONUP will never arrive, so release the held
// button NOW instead of leaving its control (e.g. ammo EJECT) pressed
// forever from the game's point of view.
if (pressedAddress >= 0)
{
DEBUG_STREAM << "[glasswin] capture LOST with button 0x" << std::hex
<< pressedAddress << std::dec << " down -- releasing\n" << std::flush;
PadRIO::SetScreenButton(pressedAddress, 0);
pressedAddress = -1;
InvalidateRect(window, NULL, FALSE);
}
return 0;
case WM_RBUTTONDOWN: case WM_RBUTTONDOWN:
if (w != NULL) if (w != NULL)
{ {
@@ -1850,11 +1576,6 @@ void
if (gWinCount != 0) if (gWinCount != 0)
return; // already up return; // already up
// BT_GLASS_IDS=1: dump every panel's boot-stable identity before any window
// is placed, so the log shows what the cfg COULD bind to right next to what
// it actually did. No-op without the env -- playtesters see nothing new.
BTGlassDumpMonitorIds();
// BT_GAUGE_SEC_ROT: how far to turn the secondary/radar surface. // BT_GAUGE_SEC_ROT: how far to turn the secondary/radar surface.
// 0 = none, 1 = 90 CCW, 2 = 180, 3 = 90 CW (default -- the pod's // 0 = none, 1 = 90 CCW, 2 = 180, 3 = 90 CW (default -- the pod's
// portrait CRT, user-verified upright). // portrait CRT, user-verified upright).
@@ -2035,24 +1756,8 @@ void
void void
BTGlassPanels_Destroy() BTGlassPanels_Destroy()
{ {
// #140 receipt (ungated, one line per teardown): this function has two SaveLayout(); // backstop for a clean teardown (WM_EXITSIZEMOVE already
// callers on the desktop path and the ORDER is what broke the layout file. // caught every finished drag); no-op unless mode==save
// A run that shows `entry #2 windows=0` is the double-destroy, on the
// record, without needing to catch the cfg mid-corruption.
{
static int s_destroyN = 0;
DEBUG_STREAM << "[glasswin] destroy entry #" << ++s_destroyN
<< " windows=" << gWinCount << "\n" << std::flush;
}
// Backstop for a clean teardown (WM_EXITSIZEMOVE already caught every
// finished drag); no-op unless mode==save. GUARDED on there being windows:
// this function has TWO callers (~PadRIO and ~LBE4ControlsManager), so on
// the desktop path it runs twice, and the second pass has nothing live to
// report. The remembered-geometry cache above already makes that harmless,
// but there is no reason to rewrite the file to say the same thing.
if (gWinCount > 0)
SaveLayout();
for (int i = 0; i < gWinCount; ++i) for (int i = 0; i < gWinCount; ++i)
{ {
@@ -2127,7 +1832,19 @@ static unsigned long
if (svga == NULL) svga = static_cast<SVGA16*>(p->graphicsDisplay); if (svga == NULL) svga = static_cast<SVGA16*>(p->graphicsDisplay);
} }
if (svga != NULL && combined != 0) if (svga != NULL && combined != 0)
{
token ^= svga->PlaneChecksum(combined); token ^= svga->PlaneChecksum(combined);
// PALETTE-ANIMATED content (2026-08-10): a palette-expanding window
// (the radar; MFDs under BT_GLASS_MFD_PAL) changes colour with ZERO
// pixel writes -- the ColorMapper family (armor rosette tints, the
// adpal/adpal2 damage flash) writes CLUT entries. Fold the palette
// write generation in so those changes repaint; mono-tint windows
// don't read the palette and keep their pixel-only token.
static int sPalTok = -1;
if (sPalTok < 0) sPalTok = getenv("BT_GLASS_MFD_PAL") ? 1 : 0;
if (w->monoTint < 0 || sPalTok)
token = (token ^ svga->PaletteGeneration()) * 16777619UL;
}
} }
// Lamps: each button's RENDERED brightness + held/latched, so a flash toggle or a // Lamps: each button's RENDERED brightness + held/latched, so a flash toggle or a
@@ -2144,36 +1861,18 @@ static unsigned long
return token; return token;
} }
// [rio] health printer (L4RIO.cpp): the serial poll path only COUNTS (kept
// I/O-free by design; the 2026-08-10 wedges once blamed on prints there were
// really the USB-serial adapter hanging an unbounded overlapped write --
// see PCSerialPacket::SendPacket); the report rides this tick, which
// already streams [glassperf] every second.
extern "C" void BTRioHealthReport();
void void
BTGlassPanels_Tick() BTGlassPanels_Tick()
{ {
if (gWinCount == 0) if (gWinCount == 0)
return; return;
BTRioHealthReport();
static unsigned long sLastPaint = 0; static unsigned long sLastPaint = 0;
unsigned long now = GetTickCount(); unsigned long now = GetTickCount();
if (now - sLastPaint < (unsigned long)RepaintMilliseconds) if (now - sLastPaint < (unsigned long)RepaintMilliseconds)
return; return;
sLastPaint = now; sLastPaint = now;
// MERGED: Cyd's dirty-skip (glass-panel-perf) + the #149 [glassperf]
// telemetry. BT_GLASS_SWEEP=1 bypasses the skip entirely -- the legacy
// always-repaint escape hatch, kept so the field can A/B in one env var.
static const int sGlassPerf =
!(getenv("BT_PERF_LOG") && *getenv("BT_PERF_LOG") == '0');
static const int sLegacySweep =
(getenv("BT_GLASS_SWEEP") && *getenv("BT_GLASS_SWEEP") == '1');
LARGE_INTEGER gt0, gt1, gfq;
QueryPerformanceCounter(&gt0);
GaugeRenderer *gr = BTResolveGaugeRenderer(); GaugeRenderer *gr = BTResolveGaugeRenderer();
int repainted = 0; int repainted = 0;
for (int i = 0; i < gWinCount; ++i) for (int i = 0; i < gWinCount; ++i)
@@ -2182,7 +1881,7 @@ void
if (w.hwnd == NULL) if (w.hwnd == NULL)
continue; continue;
unsigned long token = GlassWindowToken(gr, &w, now); unsigned long token = GlassWindowToken(gr, &w, now);
if (!sLegacySweep && w.haveToken && token == w.lastToken) if (w.haveToken && token == w.lastToken)
continue; // gauges + lamps unchanged -> skip this window continue; // gauges + lamps unchanged -> skip this window
w.lastToken = token; w.lastToken = token;
w.haveToken = 1; w.haveToken = 1;
@@ -2208,33 +1907,4 @@ void
sWin = now; sPumps = 0; sPaints = 0; sWin = now; sPumps = 0; sPaints = 0;
} }
} }
QueryPerformanceCounter(&gt1);
QueryPerformanceFrequency(&gfq);
// [glassperf] (#149): one line per second -- the tick's total synchronous
// cost plus each window's own paint time. This runs INSIDE the render
// frame (L4VIDEO calls the tick), so on a machine where GDI serialises
// against D3D present, tickMs IS the per-frame tax and the per-window
// split names the guilty panel. Default ON like [segperf]; BT_PERF_LOG=0
// opts out.
static double sTickMs = 0.0; static int sTicks = 0; static int sPaintAcc = 0;
sPaintAcc += repainted;
static unsigned long sLastReport = 0;
sTickMs += 1000.0 * (double)(gt1.QuadPart - gt0.QuadPart) / (double)gfq.QuadPart;
++sTicks;
if (sGlassPerf && now - sLastReport >= 1000)
{
sLastReport = now;
DEBUG_STREAM << "[glassperf] ticks=" << sTicks << " tickMs=" << sTickMs
<< " paints=" << sPaintAcc;
for (int i = 0; i < gWinCount; ++i)
{
if (gWins[i].perfN > 0)
DEBUG_STREAM << " | " << gWins[i].title
<< " n=" << gWins[i].perfN << " ms=" << gWins[i].perfMs;
gWins[i].perfMs = 0.0; gWins[i].perfN = 0;
}
DEBUG_STREAM << "\n" << std::flush;
sTickMs = 0.0; sTicks = 0; sPaintAcc = 0;
}
} }
-12
View File
@@ -36,7 +36,6 @@ int gBTPadViewToggleEdges = 0;
// desktop bridge, which owns `reverseThrust` (mapper attr 6 @0x124) every frame. // desktop bridge, which owns `reverseThrust` (mapper attr 6 @0x124) every frame.
// //
int gBTReverseHeld = 0; int gBTReverseHeld = 0;
int gBTTorsoCenterHeld = 0; // button 0x42 hold (#152; same seam as 0x3F)
// //
// The desktop per-MFD preset-page cycle edges (J/K/L -> Mfd1/2/3), consumed // The desktop per-MFD preset-page cycle edges (J/K/L -> Mfd1/2/3), consumed
@@ -459,17 +458,6 @@ void
// via SetScreenButton), so the desktop bridge can honour the button exactly // via SetScreenButton), so the desktop bridge can honour the button exactly
// like the pod's RIO board did. // like the pod's RIO board did.
// //
// TORSO CENTER (pod button 0x42, 'the shipped .RES name' -- UP arrow via
// bindings.txt). Same chokepoint pattern as 0x3F below: publish the HOLD
// state so the mapper's unified recenter writer (#152) can honour it on
// every rig. Before this, no RIO/glass path reached centerCommand at all
// -- bench: two scripted 0x42 holds, ctrCmd=0 throughout.
if (address == 0x42)
{
extern int gBTTorsoCenterHeld;
gBTTorsoCenterHeld = pressed ? 1 : 0;
}
if (address == 0x3F) if (address == 0x3F)
{ {
gBTReverseHeld = pressed ? 1 : 0; gBTReverseHeld = pressed ? 1 : 0;
+4 -49
View File
@@ -410,20 +410,6 @@ int PCSerialPacket::ReceivePacket(BYTE *destPtr) // returns len (zero if none)
// Returns: // Returns:
// void // void
//------------------------------------------------------------------------- //-------------------------------------------------------------------------
//
// Serial-write health (see the bounded wait below): after a write timeout
// the link is SICK for 3 s and writes fast-drop -- one stall, not one per
// poll. BTPcspakTxTimeouts feeds the [rio] telemetry.
//
static unsigned gPcspakTxTimeout = 0;
static DWORD gPcspakTxSickUntil = 0;
extern "C" unsigned
BTPcspakTxTimeouts()
{
return gPcspakTxTimeout;
}
void PCSerialPacket::SendPacket(BYTE *srcPtr) //sends packet void PCSerialPacket::SendPacket(BYTE *srcPtr) //sends packet
{ {
//-------------------------------------------------------------- //--------------------------------------------------------------
@@ -432,11 +418,6 @@ void PCSerialPacket::SendPacket(BYTE *srcPtr) //sends packet
if(!enabled) //if disabled, don't even try if(!enabled) //if disabled, don't even try
return; return;
if (gPcspakTxSickUntil != 0 && GetTickCount() < gPcspakTxSickUntil)
{
return; // adapter sick: drop, don't stall
}
BYTE cmd = *srcPtr; //get the command byte BYTE cmd = *srcPtr; //get the command byte
cmd &= 0x7F; //remove MSB cmd &= 0x7F; //remove MSB
int length = txLengthPtr[cmd]; //get the length in al int length = txLengthPtr[cmd]; //get the length in al
@@ -478,37 +459,11 @@ void PCSerialPacket::SendPacket(BYTE *srcPtr) //sends packet
return; return;
} }
} }
// DWORD bytesWritten;
// BOUNDED completion wait (2026-08-10, ALPHA-MR field minidump). The GetOverlappedResult(hComm, &overlapSend, &bytesWritten, TRUE);
// RIO's "COM1" is a USB-serial adapter; when its USB link drops with a if(bytesWritten != length + 1)
// write in flight the overlapped write NEVER completes, and the original
// GetOverlappedResult(..., TRUE) parked the whole game at 0 CPU forever
// (cdb-proven: NtWaitForSingleObject <- GetOverlappedResult <-
// SendPacket <- RIO ctor). Now: 500 ms bound (9600 baud needs ~21 ms),
// CancelIo on expiry, and a 3 s SICK window that fast-drops writes so a
// dead adapter costs one stall, not one per 50 ms poll -- the game stays
// at frame rate with a degraded RIO and the keyboard fallback alive.
// gPcspakTxTimeout feeds the [rio] health line (txTO=).
//
DWORD bytesWritten = 0;
DWORD wait_result = WaitForSingleObject(overlapSend.hEvent, 500);
if (wait_result == WAIT_OBJECT_0)
{ {
GetOverlappedResult(hComm, &overlapSend, &bytesWritten, FALSE); DEBUG_STREAM << "Not all data was written to serial port!\n" << std::flush;
gPcspakTxSickUntil = 0;
if (bytesWritten != (DWORD)(length + 1))
{
DEBUG_STREAM << "Not all data was written to serial port!\n"
<< std::flush;
}
}
else
{
// No print here -- serial-path discipline; the counter surfaces in
// the [rio] health line (txTO=) from the glass-tick printer.
CancelIo(hComm);
++gPcspakTxTimeout;
gPcspakTxSickUntil = GetTickCount() + 3000;
} }
delete buffer; delete buffer;
} }
+1 -21
View File
@@ -25,11 +25,7 @@ static LRESULT CALLBACK
return 0; return 0;
case WM_CLOSE: case WM_CLOSE:
// Minimize, never hide (#71): SW_HIDE left no way back short of a ShowWindow(window, SW_HIDE); // hide only; the renderer owns it
// restart. A minimized window keeps its taskbar button (both framed
// and ,noframe styles), so the player can recover the display. The
// renderer still owns the window either way.
ShowWindow(window, SW_MINIMIZE);
return 0; return 0;
} }
return DefWindowProcW(window, message, wparam, lparam); return DefWindowProcW(window, message, wparam, lparam);
@@ -54,22 +50,6 @@ PlasmaWindow::PlasmaWindow():
} }
blitBuffer = new unsigned long[plasmaWidth * plasmaHeight]; blitBuffer = new unsigned long[plasmaWidth * plasmaHeight];
memset(blitBuffer, 0, plasmaWidth * plasmaHeight * sizeof(unsigned long)); memset(blitBuffer, 0, plasmaWidth * plasmaHeight * sizeof(unsigned long));
//
// INITIALIZE THE DISPLAY AREA (2026-08-10). PixelMap8(w,h) allocates its
// pixel bytes UNCLEARED (GRAPH2D.cpp: `new Byte[w*h]`, no memset), so the
// 128x32 plasma buffer starts as heap garbage. The real pod never showed
// it: the serial PlasmaDisplay transfers only CHANGED lines (changedLine[]
// is zeroed by the base ctor), so the garbage never left the buffer and
// the hardware marquee sat at its power-on blank. This desktop window
// blits the WHOLE buffer every frame, so the junk was visible from boot
// until the gauge renderer first drew the callsigns. Clear to index 0
// (the near-black background) -- the window comes up blank like the
// hardware did.
//
if (pixelBuffer != NULL && pixelBuffer->Data.MapPointer != NULL)
memset(pixelBuffer->Data.MapPointer, 0, plasmaWidth * plasmaHeight);
DEBUG_STREAM << "[plasmawin] desktop plasma display up (scale x" DEBUG_STREAM << "[plasmawin] desktop plasma display up (scale x"
<< scale << ")\n" << std::flush; << scale << ")\n" << std::flush;
} }
-156
View File
@@ -1240,105 +1240,6 @@ RIO::~RIO()
Check_Fpu(); Check_Fpu();
} }
//
// [rio] telemetry state (see the tracking block in GetNextEvent). File-scope
// statics on purpose: one hardware RIO per process, no header/layout churn --
// and the PRINTER (BTRioHealthReport, called from the glass tick) reads ONLY
// these statics, never RIO members, so it needs no instance and touches
// nothing the serial layer locks.
//
static int gRioPresent = 0;
static unsigned gRioReqCount = 0;
static unsigned gRioRepCount = 0;
static unsigned gRioBtnCount = 0;
static int gRioReqPending = 0;
static DWORD gRioLastReqTick = 0;
static DWORD gRioLastReplyTick = 0;
static int gRioStallActive = 0;
static DWORD gRioStallStart = 0;
static unsigned gRioStallBtnBase = 0;
static unsigned gRioStallCount = 0;
static DWORD gRioLastStallMs = 0;
static unsigned gRioLastStallBtns = 0;
static unsigned gRioLineErrMirror = 0;
static unsigned gRioOverrunMirror = 0;
static unsigned gRioAbandonMirror = 0;
static unsigned gRioRRetryMirror = 0;
static unsigned gRioRAbandonMirror = 0;
static unsigned gRioRFullMirror = 0;
extern "C" unsigned BTPcspakTxTimeouts(); // L4PCSPAK.cpp: bounded-write stat
//
// The printer half -- called from BTGlassPanels_Tick beside the [glassperf]
// report (a stream site that prints every second without incident), NEVER
// from the serial path. Edge lines land at the tick's ~1 s granularity,
// ample for the multi-second field dropouts this exists to catch.
// BT_RIO_LOG=0 opts out; silent when no hardware RIO ever came up.
//
extern "C" void
BTRioHealthReport()
{
static int sLog = -1;
static DWORD sLastLine = 0;
static unsigned sSeenStalls = 0;
static int sSawActive = 0;
if (!gRioPresent)
{
return;
}
if (sLog < 0)
{
const char *e = getenv("BT_RIO_LOG");
sLog = (e != 0 && *e == '0') ? 0 : 1;
}
if (!sLog)
{
return;
}
DWORD now = GetTickCount();
if (gRioStallActive && !sSawActive)
{
sSawActive = 1;
DEBUG_STREAM << "[rio] STALL analog silent "
<< (now - gRioStallStart) << "ms and counting req="
<< gRioReqCount << " rep=" << gRioRepCount
<< "\n" << std::flush;
}
else if (!gRioStallActive && sSawActive)
{
sSawActive = 0;
}
if (sSeenStalls != gRioStallCount)
{
sSeenStalls = gRioStallCount;
DEBUG_STREAM << "[rio] RECOVER stall#" << gRioStallCount
<< " lasted " << gRioLastStallMs
<< "ms btnDuring=" << gRioLastStallBtns
<< "\n" << std::flush;
}
if (now - sLastLine >= 5000)
{
sLastLine = now;
DEBUG_STREAM << "[rio] req=" << gRioReqCount
<< " rep=" << gRioRepCount
<< " btn=" << gRioBtnCount
<< " age=" << (gRioLastReplyTick != 0
? (now - gRioLastReplyTick) : 0)
<< "ms stalls=" << gRioStallCount
<< " txTO=" << BTPcspakTxTimeouts()
<< " lineErr=" << gRioLineErrMirror
<< " overrun=" << gRioOverrunMirror
<< " abandon=" << gRioAbandonMirror
<< " rRetry=" << gRioRRetryMirror
<< " rAbandon=" << gRioRAbandonMirror
<< " rFull=" << gRioRFullMirror
<< "\n" << std::flush;
}
}
Logical Logical
RIO::TestInstance() const RIO::TestInstance() const
{ {
@@ -1363,44 +1264,6 @@ Logical
Logical reply, looping; Logical reply, looping;
Word errors; Word errors;
//
// [rio] link-health TRACKING -- pure arithmetic, no I/O here by design.
// CORRECTED HISTORY (2026-08-10): the boot wedges first blamed on prints
// from this path were actually the USB-serial adapter dropping its link
// with a write in flight -- the unbounded GetOverlappedResult in
// PCSerialPacket::SendPacket parked the game (minidump-proven; the
// print-deadlock A/B was confounded by the adapter behaving that boot).
// The count-here / print-from-the-glass-tick split stays anyway: it
// keeps this hot polled path I/O-free and the printer at one known
// stream site (BTRioHealthReport below, beside [glassperf]).
//
if (operational)
{
DWORD now = GetTickCount();
// #160 telemetry fix: the detector used to key on REQUEST age -- but the
// poll loop sends a fresh request every ~50ms regardless of replies, so
// request-age never reached 500ms and the STALL line could never fire
// (the field logs' age= field carried the signal instead). Key on the
// REPLY drought: requests outstanding AND no AnalogReply for 500ms
// since the last good one. Requires one good reply first (boot-mute
// shows in the req/rep counters, not here).
if (!gRioStallActive && gRioReqPending
&& gRioLastReplyTick != 0
&& now - gRioLastReplyTick > 500)
{
gRioStallActive = 1;
gRioStallStart = gRioLastReplyTick;
gRioStallBtnBase = gRioBtnCount;
}
gRioPresent = 1;
gRioLineErrMirror = (unsigned) lineErrorCount;
gRioOverrunMirror = (unsigned) overrunCount;
gRioAbandonMirror = (unsigned) abandonCount;
gRioRRetryMirror = (unsigned) remoteRetryCount;
gRioRAbandonMirror = (unsigned) remoteAbandonCount;
gRioRFullMirror = (unsigned) remoteFullBufferCount;
}
//PCSPAKState(this); //PCSPAKState(this);
// //
//cout << flush; //cout << flush;
@@ -1514,20 +1377,6 @@ Logical
break; break;
case AnalogReply: case AnalogReply:
++gRioRepCount;
gRioReqPending = 0;
gRioLastReplyTick = GetTickCount();
if (gRioStallActive)
{
// The reply that ended the drought -- record only; the
// glass-tick printer emits the RECOVER line. btnDuring
// is the field theory's verdict: >0 on every recover =
// button traffic resyncs the link; 0 = it self-heals.
gRioStallActive = 0;
++gRioStallCount;
gRioLastStallMs = gRioLastReplyTick - gRioStallStart;
gRioLastStallBtns = gRioBtnCount - gRioStallBtnBase;
}
destination->Type = RIO::AnalogEvent; destination->Type = RIO::AnalogEvent;
// //
// NOTE: no data is sent in this packet. // NOTE: no data is sent in this packet.
@@ -1574,13 +1423,11 @@ Logical
break; break;
case ButtonPressed: case ButtonPressed:
++gRioBtnCount;
destination->Type = RIO::ButtonPressedEvent; destination->Type = RIO::ButtonPressedEvent;
destination->Data.Unit = receive_buffer[1]; destination->Data.Unit = receive_buffer[1];
break; break;
case ButtonReleased: case ButtonReleased:
++gRioBtnCount;
destination->Type = RIO::ButtonReleasedEvent; destination->Type = RIO::ButtonReleasedEvent;
destination->Data.Unit = receive_buffer[1]; destination->Data.Unit = receive_buffer[1];
break; break;
@@ -1721,9 +1568,6 @@ void
if (operational && !TestModeActive) if (operational && !TestModeActive)
{ {
SendPacket((Byte *) request_analog_string); SendPacket((Byte *) request_analog_string);
++gRioReqCount;
gRioReqPending = 1;
gRioLastReqTick = GetTickCount();
} }
Check_Fpu(); Check_Fpu();
} }
-46
View File
@@ -632,49 +632,3 @@ unsigned long long
} }
return SteamUser()->GetSteamID().ConvertToUint64(); return SteamUser()->GetSteamID().ConvertToUint64();
} }
//
// #163 -- the ORDERED transport shutdown for process exit (2026-08-11).
//
// The port never called SteamAPI_Shutdown(): every round-end died via bare
// ExitProcess with live SNS connections and Steam callback threads, and the
// DLL_PROCESS_DETACH path intermittently DEADLOCKED. When it did, the old
// process froze holding its last fullscreen frame ("frozen view of how the
// game ended") while its already-spawned menu child hung inside
// SteamAPI_Init against the wedged sibling's client session -- field-
// captured on ZEUS at 22:12:38 (a 3-line child boot stub that never reached
// the unconditional [steamnet] up line). Closing every connection and
// shutting the API down BEFORE the child is spawned hands it a clean Steam
// client and leaves detach nothing to strangle. Callers then die via
// TerminateProcess, which skips DLL detach entirely.
//
void
BTSteamNet_ShutdownAll()
{
if (!steamActive)
{
return;
}
ISteamNetworkingSockets *sockets = SteamNetworkingSockets();
for (int i = 0; i < MaxConnections; ++i)
{
if (connections[i].inUse
&& connections[i].connection != k_HSteamNetConnection_Invalid)
{
sockets->CloseConnection(connections[i].connection, 0,
"round over", false);
}
connections[i].inUse = 0;
}
for (int p = 0; p < FakePortCount; ++p)
{
if (listenSockets[p] != k_HSteamListenSocket_Invalid)
{
sockets->CloseListenSocket(listenSockets[p]);
listenSockets[p] = k_HSteamListenSocket_Invalid;
}
}
SteamAPI_RunCallbacks(); // let the closes flush client-side
steamActive = 0;
SteamAPI_Shutdown();
}
-4
View File
@@ -32,10 +32,6 @@ int
BTSteamNet_Active(); BTSteamNet_Active();
void void
BTSteamNet_Pump(); // callbacks + rx drain (game thread) BTSteamNet_Pump(); // callbacks + rx drain (game thread)
void
BTSteamNet_ShutdownAll(); // #163: ordered transport death for
// process exit -- close every
// connection, then SteamAPI_Shutdown
// //
// The wire-seam surface (BTNet* wrappers in L4NET.CPP + the marshal). // The wire-seam surface (BTNet* wrappers in L4NET.CPP + the marshal).
+40 -6
View File
@@ -5445,6 +5445,7 @@ SVGA16::SVGA16(
BuildWindows(init_width,init_height,windowed, secondaryIndex, aux1Index, aux2Index); BuildWindows(init_width,init_height,windowed, secondaryIndex, aux1Index, aux2Index);
for (int _i = 0; _i < 10; _i++) // DEV-COMPOSITE: lazily created on first surface draw for (int _i = 0; _i < 10; _i++) // DEV-COMPOSITE: lazily created on first surface draw
mDevSurfaceTex[_i] = NULL; mDevSurfaceTex[_i] = NULL;
paletteGeneration = 0; // GLASS dirty-skip palette tracking
//STUBBED: VIDEO RB 1/15/07 //STUBBED: VIDEO RB 1/15/07
# if defined(DEBUG) # if defined(DEBUG)
Tell("SVGA16::SVGA16()\n"); Tell("SVGA16::SVGA16()\n");
@@ -7190,6 +7191,7 @@ void
svga_palette->paletteData.Valid = True; svga_palette->paletteData.Valid = True;
svga_palette->modified = True; svga_palette->modified = True;
((SVGA16 *) graphicsDisplay)->paletteGeneration++; // glass dirty-skip (full rebuild)
Check_Fpu(); Check_Fpu();
} }
@@ -7234,6 +7236,13 @@ void
SVGA16Palette SVGA16Palette
*svga_palette(&((SVGA16 *) graphicsDisplay)->palette[paletteID]); *svga_palette(&((SVGA16 *) graphicsDisplay)->palette[paletteID]);
// GLASS dirty-skip: track whether this write CHANGES anything -- the
// ColorMapper family calls this every Execute (the adpal/adpal2 flash
// alternates palettes even at zero damage, usually writing identical RGB),
// so bump the generation only on a real change or every glass window that
// palette-expands would repaint every pump for nothing.
int palette_changed = 0;
//------------------------------------------- //-------------------------------------------
// If any of the ...TransparentZero modes are used, // If any of the ...TransparentZero modes are used,
// leave color zero undefined for this bit group by // leave color zero undefined for this bit group by
@@ -7275,21 +7284,31 @@ void
{ {
case RedChannel: case RedChannel:
case RedChannelTransparentZero: case RedChannelTransparentZero:
if (destination_triplet->Red != source_triplet->Red)
palette_changed = 1;
destination_triplet->Red = source_triplet->Red; destination_triplet->Red = source_triplet->Red;
break; break;
case GreenChannel: case GreenChannel:
case GreenChannelTransparentZero: case GreenChannelTransparentZero:
if (destination_triplet->Green != source_triplet->Green)
palette_changed = 1;
destination_triplet->Green = source_triplet->Green; destination_triplet->Green = source_triplet->Green;
break; break;
case BlueChannel: case BlueChannel:
case BlueChannelTransparentZero: case BlueChannelTransparentZero:
if (destination_triplet->Blue != source_triplet->Blue)
palette_changed = 1;
destination_triplet->Blue = source_triplet->Blue; destination_triplet->Blue = source_triplet->Blue;
break; break;
case AllChannels: case AllChannels:
case AllChannelsTransparentZero: case AllChannelsTransparentZero:
if (destination_triplet->Red != source_triplet->Red
|| destination_triplet->Green != source_triplet->Green
|| destination_triplet->Blue != source_triplet->Blue)
palette_changed = 1;
*destination_triplet = *source_triplet; *destination_triplet = *source_triplet;
break; break;
} }
@@ -7310,6 +7329,8 @@ void
svga_palette->paletteData.Valid = True; svga_palette->paletteData.Valid = True;
svga_palette->modified = True; svga_palette->modified = True;
if (palette_changed)
((SVGA16 *) graphicsDisplay)->paletteGeneration++; // glass dirty-skip
Check_Fpu(); Check_Fpu();
} }
@@ -7413,6 +7434,7 @@ void
svga_palette->paletteData.Valid = True; svga_palette->paletteData.Valid = True;
svga_palette->modified = True; svga_palette->modified = True;
((SVGA16 *) graphicsDisplay)->paletteGeneration++; // glass dirty-skip (full rebuild)
# if defined(TESTPALETTE) # if defined(TESTPALETTE)
std::cout << "L4GraphicsPort::BuildAuxiliaryPalette for port " << std::cout << "L4GraphicsPort::BuildAuxiliaryPalette for port " <<
@@ -7523,6 +7545,7 @@ void
svga_palette->paletteData.Valid = True; svga_palette->paletteData.Valid = True;
svga_palette->modified = True; svga_palette->modified = True;
((SVGA16 *) graphicsDisplay)->paletteGeneration++; // glass dirty-skip (full rebuild)
Check_Fpu(); Check_Fpu();
} }
@@ -7557,17 +7580,28 @@ void
// LEFT AS HEAP GARBAGE and any pixmap pixel >= that count wrote the // LEFT AS HEAP GARBAGE and any pixmap pixel >= that count wrote the
// garbage's high bits into other displays' planes (the #48 stray // garbage's high bits into other displays' planes (the #48 stray
// blocks; convicted live by BT_PLANE_AUDIT -- the 480x640 radar // blocks; convicted live by BT_PLANE_AUDIT -- the 480x640 radar
// background carries index 217). Cycle the in-plane pattern across // background carries index 217). Map the remainder to the plane
// the remainder: high-index art degrades to its (index mod 2^bits) // BACKGROUND (translationTable[0]): high-index art renders as the
// colour IN-PLANE, and can never leak. (The 1995 binary shipped the // port's background colour in-plane, and can never leak.
// same 64-entry fill and relied on art discipline; garbage is not a //
// preservable behaviour, so this is a guarded PORT deviation.) // REFINED 2026-08-10 (the sec-surface phantoms): the first #48 fill
// cycled the remainder IN-PLANE (index mod 2^bits), which mapped art
// index 254 onto plane slot 62 -- a LIVE colorMapperMultiArmor damage
// slot. SMODE.PCC's inactive control-mode box interiors and a stray
// 52x13 idx-254 bar baked into BTSEC1.PCX (between the heading dial
// and the armor rosette) lit up in the current right-armor damage
// colour on every render path. On the shipped machine those regions
// rendered BLACK (the heap garbage's low plane bits were 0 ->
// in-plane index 0), so background IS the authentic on-screen result.
// (The 1995 binary shipped the same 64-entry fill and relied on art
// discipline; garbage is not a preservable behaviour, so this stays a
// guarded PORT deviation.)
{ {
int filled = 1; int filled = 1;
for (int b = bitMask & 0xFF; b != 0; b &= (b - 1)) for (int b = bitMask & 0xFF; b != 0; b &= (b - 1))
filled <<= 1; filled <<= 1;
for (int i = filled; i < 256; ++i) for (int i = filled; i < 256; ++i)
translationTable[i] = translationTable[i & (filled - 1)]; translationTable[i] = translationTable[0];
} }
Check_Fpu(); Check_Fpu();
} }
+14
View File
@@ -382,6 +382,15 @@ protected:
SVGA16Palette SVGA16Palette
palette[PaletteCount]; palette[PaletteCount];
// GLASS dirty-skip (2026-08-10): bumped by the palette writers
// (L4GraphicsPort::BuildSecondaryColor on a REAL entry change; the full
// palette rebuilds unconditionally) so the glass repaint token can see
// palette-only animation -- the ColorMapper family (armor rosette tints,
// damage flash) changes COLORS with zero pixel writes, which the pixel
// checksum alone can never catch.
unsigned long
paletteGeneration;
private: private:
int NUMGAUGEWINDOWS; int NUMGAUGEWINDOWS;
HWND *gaugeWindows; HWND *gaugeWindows;
@@ -425,6 +434,11 @@ public:
// glass repaint pump re-blit ONLY the windows whose plane actually changed // glass repaint pump re-blit ONLY the windows whose plane actually changed
// (idle MFDs / static panels skip; the sweeping radar keeps updating). // (idle MFDs / static panels skip; the sweeping radar keeps updating).
unsigned long PlaneChecksum(int mask) const; unsigned long PlaneChecksum(int mask) const;
// GLASS dirty-skip: the palette write generation (see paletteGeneration).
// Folded into the repaint token of palette-expanding windows so ColorMapper
// palette animation (armor tints / damage flash) repaints without pixel churn.
unsigned long PaletteGeneration() const { return paletteGeneration; }
}; };
//######################################################################## //########################################################################
-115
View File
@@ -8441,107 +8441,6 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
lastFrameTime = mTargetRenderTime; lastFrameTime = mTargetRenderTime;
currentFrameTime = Now(); currentFrameTime = Now();
// #156 -- the MISSION START/END FADE (POVStartEndRenderable, ctor @00454394,
// Execute @0045447c; the port class is armed by MakeEntityRenderables'
// BTPlayer master case via BTMissionFadeArm). The 1995 renderable drives
// the POV zone fog through a 5-phase machine keyed on the local player's
// SimulationState; constants byte-read from BTL4OPT.EXE @0x454740-0x454750:
// flash hold 0.1s, fades 1.0s, plane floors near 0.01 / far 0.05. Phases:
// 0 armed -> (MissionStarting) 1 WHITE FLASH (fog 1,1,1 @ 0.01/0.05) ->
// 2 FADE-IN (white -> live style; planes floor -> authored) -> 3 running ->
// (MissionEnding) 4 FADE-OUT (color x local -> BLACK, planes close) ->
// re-arm. The binary re-reads the LIVE fog each frame mid-fade (style
// swaps track); we read the STYLE members (fogRed/G/B, fogNear/Far), never
// currentFogNear/Far which we are overriding. Placed at the frame head:
// the Clear below samples FOGCOLOR, so the fade owns the WHOLE frame
// including sky/void. Receipts: [fade] phase lines under BT_FADE_LOG or
// BT_FOG_LOG.
{
extern void *gBTFadeIndicator; // BTMissionFadeArm (btl4vid.cpp)
extern int gBTFadeStart, gBTFadeEnd;
static int s_phase = 0;
static float s_T = 0.0f;
if (gBTFadeIndicator != 0)
{
const float kFlash = 0.1f, kFade = 1.0f; // @0x454740 / @0x454744
const float kNearFloor = 0.01f, kFarFloor = 0.05f; // @0x454750 / @0x45474c
int st = ((StateIndicator *)gBTFadeIndicator)->GetState();
float fdt = (float)dT;
if (fdt < 0.0f) fdt = 0.0f; else if (fdt > 0.1f) fdt = 0.1f;
float r = -1.0f, g = 0.0f, b = 0.0f, fn = 0.0f, ff = 0.0f;
switch (s_phase)
{
case 0:
if (st == gBTFadeStart) { s_phase = 1; s_T = 0.0f; }
break;
case 1:
r = 1.0f; g = 1.0f; b = 1.0f; fn = kNearFloor; ff = kFarFloor;
s_T += fdt;
if (s_T >= kFlash) { s_phase = 2; s_T = 0.0f; }
break;
case 2:
{
float local = kFade - s_T; if (local < 0.0f) local = 0.0f;
float el = kFade - local;
r = (1.0f - fogRed) * local + fogRed;
g = (1.0f - fogGreen) * local + fogGreen;
b = (1.0f - fogBlue) * local + fogBlue;
fn = fogNear * el + kNearFloor;
ff = fogFar * el + kFarFloor;
s_T += fdt;
if (local <= 0.0f) { s_phase = 3; r = -1.0f; } // fog back to the style
break;
}
case 3:
if (st == gBTFadeEnd) { s_phase = 4; s_T = 0.0f; }
break;
case 4:
{
float local = kFade - s_T; if (local < 0.0f) local = 0.0f;
r = fogRed * local;
g = fogGreen * local;
b = fogBlue * local;
fn = fogNear * local + kNearFloor;
ff = fogFar * local + kFarFloor;
s_T += fdt;
if (local <= 0.0f) { s_phase = 0; } // re-armed; final frame stays black
break;
}
}
if (r >= 0.0f)
{
currentFogNear = fn;
currentFogFar = ff;
mDevice->SetRenderState(D3DRS_FOGCOLOR,
D3DCOLOR_XRGB((int)(255 * r), (int)(255 * g), (int)(255 * b)));
mDevice->SetRenderState(D3DRS_FOGENABLE, TRUE);
}
else if (s_phase == 3 || s_phase == 0)
{
// Out of the fade: restore the style planes once so a fade that
// ended mid-style leaves the world exactly on its authored fog.
static int s_prev = -1;
if (s_prev != s_phase)
{
currentFogNear = fogNear;
currentFogFar = fogFar;
mDevice->SetRenderState(D3DRS_FOGCOLOR,
D3DCOLOR_XRGB((int)(255 * fogRed), (int)(255 * fogGreen),
(int)(255 * fogBlue)));
}
s_prev = s_phase;
}
static int s_lastLogged = -1;
if (s_phase != s_lastLogged)
{
s_lastLogged = s_phase;
if (getenv("BT_FADE_LOG") || getenv("BT_FOG_LOG"))
DEBUG_STREAM << "[fade] phase " << s_phase
<< " (simState=" << st << ")\n" << std::flush;
}
}
}
// DIAG (turn-hitch hunt): time the render phases -- draw CPU vs Present // DIAG (turn-hitch hunt): time the render phases -- draw CPU vs Present
// (GPU-queue block). Logged on slow frames + 1 Hz stats. // (GPU-queue block). Logged on slow frames + 1 Hz stats.
LARGE_INTEGER _rt0; QueryPerformanceCounter(&_rt0); LARGE_INTEGER _rt0; QueryPerformanceCounter(&_rt0);
@@ -9135,20 +9034,6 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
DEBUG_STREAM << "[rstat] frames=" << sFrames << " avg=" << (sAcc / sFrames) DEBUG_STREAM << "[rstat] frames=" << sFrames << " avg=" << (sAcc / sFrames)
<< "ms maxDraw=" << sMaxD << " maxPresent=" << sMaxP << "ms maxDraw=" << sMaxD << " maxPresent=" << sMaxP
<< " batches=" << gNumBatches << " culled=" << gBTNumCulled << "\n" << std::flush; << " batches=" << gNumBatches << " culled=" << gBTNumCulled << "\n" << std::flush;
// #149: segment-refresh telemetry on the same cadence (BT_PERF_LOG).
// calls = GetSegmentToWorld entries; dirty = the mark-every-segment
// invalidation passes (the expensive arm the #141 sweep may have
// multiplied); ms = time inside the accessor for the whole window.
{
static const int sSegPerf = !(getenv("BT_PERF_LOG") && *getenv("BT_PERF_LOG") == '0');
extern int gBTSegWCalls, gBTSegWDirty;
extern double gBTSegWMs;
if (sSegPerf)
DEBUG_STREAM << "[segperf] calls=" << gBTSegWCalls
<< " dirty=" << gBTSegWDirty
<< " ms=" << gBTSegWMs << "\n" << std::flush;
gBTSegWCalls = 0; gBTSegWDirty = 0; gBTSegWMs = 0.0;
}
sAcc = 0.0; sFrames = 0; sMaxD = 0.0; sMaxP = 0.0; sAcc = 0.0; sFrames = 0; sMaxD = 0.0; sMaxP = 0.0;
} }
} }
-6
View File
@@ -31,12 +31,6 @@ void
void void
BTGlassPanels_Destroy(); BTGlassPanels_Destroy();
// BT_GLASS_IDS=1 -- log every attached panel's BOOT-STABLE hardware identity
// plus a ready-to-paste `monitor:id:<fragment>` line for glass_layout.cfg.
// Called from BTGlassPanels_Create; no-op unless the env is set.
void
BTGlassDumpMonitorIds();
// //
// Per-frame repaint pump. Call once per frame from the main render loop so the // Per-frame repaint pump. Call once per frame from the main render loop so the
// per-display windows' lamp flash keeps animating even when they are in the // per-display windows' lamp flash keeps animating even when they are in the
-42
View File
@@ -234,36 +234,6 @@ static LONG WINAPI
return EXCEPTION_EXECUTE_HANDLER; // die (after the evidence is out) return EXCEPTION_EXECUTE_HANDLER; // die (after the evidence is out)
} }
//
// lastrun_<stem>.txt clean-exit markers (#157, 2026-08-10): the launch record
// only ever said "exe reached WinMain", so a night that ended with the player
// closing the menu was indistinguishable from a silent death -- and the bat's
// sign-off taught players to report normal quits as crashes. The deliberate
// exit paths stamp the record themselves now, so the file answers "crash or
// quit?" on its own. Name captured at breadcrumb time (cwd is content\ by
// then -- the same file the bats bracket).
//
static char gBTLastrunName[MAX_PATH] = "";
void
BTLastrunNote(const char *note)
{
if (gBTLastrunName[0] == 0)
{
return;
}
SYSTEMTIME lt;
GetLocalTime(&lt);
std::ofstream f(gBTLastrunName, std::ios::out | std::ios::app);
if (f.is_open())
{
char stamp[64];
wsprintfA(stamp, "[%04d-%02d-%02d %02d:%02d:%02d] ",
lt.wYear, lt.wMonth, lt.wDay, lt.wHour, lt.wMinute, lt.wSecond);
f << stamp << note << "\n";
}
}
//===========================================================================// //===========================================================================//
// environ.ini -- the player's settings file (cwd = content\). One KEY=VALUE // environ.ini -- the player's settings file (cwd = content\). One KEY=VALUE
// per line; the real environment always WINS, so a launcher .bat or a shell // per line; the real environment always WINS, so a launcher .bat or a shell
@@ -863,7 +833,6 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
GetComputerNameA(machine, &machine_len); GetComputerNameA(machine, &machine_len);
char lastrun_name[MAX_PATH]; char lastrun_name[MAX_PATH];
wsprintfA(lastrun_name, "lastrun_%s.txt", logStem); wsprintfA(lastrun_name, "lastrun_%s.txt", logStem);
lstrcpynA(gBTLastrunName, lastrun_name, MAX_PATH);
std::ofstream breadcrumb(lastrun_name, std::ios::out | std::ios::app); std::ofstream breadcrumb(lastrun_name, std::ios::out | std::ios::app);
if (breadcrumb.is_open()) if (breadcrumb.is_open())
{ {
@@ -1240,17 +1209,6 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
BTFeLaunchSpec fe_spec; BTFeLaunchSpec fe_spec;
if (BTFrontEnd_Run(&fe_spec) != 0 || fe_spec.mode == BTFeLaunchNone) if (BTFrontEnd_Run(&fe_spec) != 0 || fe_spec.mode == BTFeLaunchNone)
{ {
BTLastrunNote("clean exit: player quit from the menu");
#ifdef BT_STEAM
// #163: the menu keeps the Steam transport up for the lobby;
// returning through the CRT would run DLL detach with live
// Steam threads -- the same intermittent deadlock the
// round-end relaunch hit. Tear it down deliberately first.
{
extern void BTSteamNet_ShutdownAll();
BTSteamNet_ShutdownAll();
}
#endif
return 0; // quit from the menu return 0; // quit from the menu
} }
char fe_arguments[192]; char fe_arguments[192];
+2 -71
View File
@@ -94,7 +94,6 @@ static MarshalState marshalState;
// //
#ifdef BT_STEAM #ifdef BT_STEAM
extern int BTSteamNet_IsFakeAddress(unsigned long internet_address_be); extern int BTSteamNet_IsFakeAddress(unsigned long internet_address_be);
extern void BTSteamNet_ShutdownAll(); // #163: ordered exit
extern SOCKET BTSteamNet_Connect(unsigned long internet_address_be, int remote_port); extern SOCKET BTSteamNet_Connect(unsigned long internet_address_be, int remote_port);
extern int BTSteamNet_Owns(SOCKET wire_socket); extern int BTSteamNet_Owns(SOCKET wire_socket);
extern int BTSteamNet_Send(SOCKET wire_socket, const char *data, int length); extern int BTSteamNet_Send(SOCKET wire_socket, const char *data, int length);
@@ -154,48 +153,6 @@ static unsigned long
return gBTBootTick != 0 ? gBTBootTick : GetTickCount(); return gBTBootTick != 0 ? gBTBootTick : GetTickCount();
} }
//
// #163 -- ORDERLY DEATH (2026-08-11). Bare ExitProcess ran DLL detach with
// live Steam threads and intermittently deadlocked: the dying process kept
// its last fullscreen frame on screen ("frozen view of how the game ended")
// while the healthy menu child it had already spawned hung in SteamAPI_Init
// against the wedged sibling -- both round-end freeze reports AND the
// hang-on-exit reports, one mechanism (the HOST worst: this exit runs on
// the MARSHAL thread while the main thread is mid-teardown). The order:
// flush the forensics, tear the Steam transport down, hide every window
// this process owns (a residual wedge must never squat on the screen), then
// TerminateProcess -- which skips DLL detach entirely, making the deadlock
// structurally unreachable. The 1995 pod never exited between rounds at
// all (@0x47c560 parks in WaitingForEgg), so exit is pure port territory --
// no binary semantic to preserve.
//
static BOOL CALLBACK
HideProcessWindow(HWND window, LPARAM pid)
{
DWORD owner = 0;
GetWindowThreadProcessId(window, &owner);
if (owner == (DWORD)pid)
{
ShowWindow(window, SW_HIDE);
}
return TRUE;
}
static void
BTOrderlyDie(void)
{
{
extern void BTMatchLogClose();
BTMatchLogClose(); // complete receipts on disk (the
} // field matchlogs lost their tails)
fflush(NULL);
#ifdef BT_STEAM
BTSteamNet_ShutdownAll();
#endif
EnumWindows(HideProcessWindow, (LPARAM)GetCurrentProcessId());
TerminateProcess(GetCurrentProcess(), 0);
}
void void
BTFE_RelaunchSelfAndExit(const char *arguments) BTFE_RelaunchSelfAndExit(const char *arguments)
{ {
@@ -217,9 +174,7 @@ void
{ {
MarshalLog("window closed by the user -- exiting for real " MarshalLog("window closed by the user -- exiting for real "
"(no relaunch)"); "(no relaunch)");
extern void BTLastrunNote(const char *); ExitProcess(0);
BTLastrunNote("clean exit: player closed the game window");
BTOrderlyDie(); // #163: never bare ExitProcess
} }
} }
@@ -294,18 +249,6 @@ void
} }
command_line[n] = 0; command_line[n] = 0;
// #163 step (a): forensics flushed + the Steam transport DOWN before the
// child exists -- it must find a clean Steam client (the field capture:
// a menu child hung in SteamAPI_Init against its wedged sibling).
{
extern void BTMatchLogClose();
BTMatchLogClose();
}
fflush(NULL);
#ifdef BT_STEAM
BTSteamNet_ShutdownAll();
#endif
STARTUPINFOW startup; STARTUPINFOW startup;
PROCESS_INFORMATION process; PROCESS_INFORMATION process;
memset(&startup, 0, sizeof(startup)); memset(&startup, 0, sizeof(startup));
@@ -314,22 +257,10 @@ void
if (CreateProcessW(exe_path, command_line, NULL, NULL, FALSE, if (CreateProcessW(exe_path, command_line, NULL, NULL, FALSE,
0, NULL, NULL, &startup, &process)) 0, NULL, NULL, &startup, &process))
{ {
// Hand the child our foreground right BEFORE we die (#157): Windows
// refuses SetForegroundWindow to a process spawned by an exiting
// background parent, so the round-end menu could open BEHIND the
// desktop clutter -- which is how a normal between-rounds relaunch
// read as "the game crashed" from the cockpit seat.
if (!AllowSetForegroundWindow(process.dwProcessId))
{
MarshalLog("foreground handoff denied -- the next window may "
"open behind");
}
CloseHandle(process.hThread); CloseHandle(process.hThread);
CloseHandle(process.hProcess); CloseHandle(process.hProcess);
} }
// #163 step (b): vanish, then die without touching DLL detach. ExitProcess(0);
EnumWindows(HideProcessWindow, (LPARAM)GetCurrentProcessId());
TerminateProcess(GetCurrentProcess(), 0);
} }
//########################################################################### //###########################################################################
+1 -18
View File
@@ -412,7 +412,7 @@ static const CatalogEntry kVehicles[] =
{ "avatar", "Avatar" }, { "avatar", "Avatar" },
{ "ava1", "Satyr" }, { "ava1", "Satyr" },
{ "sunder", "Sunder" }, { "sunder", "Sunder" },
{ "snd1", "Denkou" }, // #69: the 4.x-era name (two era witnesses) { "snd1", "Sunder V1" },
{ "vulture", "Vulture" }, { "vulture", "Vulture" },
{ "vul1", "Mad Dog" }, { "vul1", "Mad Dog" },
{ "lok2", "Mischief" }, { "lok2", "Mischief" },
@@ -1200,7 +1200,6 @@ int
{ {
BTLobbyRoster roster; BTLobbyRoster roster;
if (BTLobby_HostAndRoom(self.name, self.vehicle, self.color, self.experience, if (BTLobby_HostAndRoom(self.name, self.vehicle, self.color, self.experience,
self.badge, self.patch,
&roster, spec->steamMyToken, sizeof(spec->steamMyToken), &roster, spec->steamMyToken, sizeof(spec->steamMyToken),
spec->steamMap, sizeof(spec->steamMap)) != 0 || spec->steamMap, sizeof(spec->steamMap)) != 0 ||
roster.memberCount == 0) roster.memberCount == 0)
@@ -1208,15 +1207,6 @@ int
return 1; // cancelled / Steam unavailable return 1; // cancelled / Steam unavailable
} }
spec->mode = BTFeLaunchHostSteam; 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; mission.pilotCount = 0;
sprintf(spec->podList, "127.0.0.1:%d", console_port); sprintf(spec->podList, "127.0.0.1:%d", console_port);
for (int m = 0; m < roster.memberCount && m < 8; ++m) for (int m = 0; m < roster.memberCount && m < 8; ++m)
@@ -1236,12 +1226,6 @@ int
// but now only for clients that never published a choice. // but now only for clients that never published a choice.
strncpy(pilot.experience, member.experience, strncpy(pilot.experience, member.experience,
sizeof(pilot.experience) - 1); 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", sprintf(pilot.address, "%s:%d",
member.fakeAddress, member.gamePort); member.fakeAddress, member.gamePort);
if (!member.isSelf) if (!member.isSelf)
@@ -1261,7 +1245,6 @@ int
if (menu.steamAction == 2) if (menu.steamAction == 2)
{ {
if (BTLobby_JoinAndWait(self.name, self.vehicle, self.color, self.experience, if (BTLobby_JoinAndWait(self.name, self.vehicle, self.color, self.experience,
self.badge, self.patch,
spec->steamMyToken, sizeof(spec->steamMyToken), spec->steamMyToken, sizeof(spec->steamMyToken),
spec->steamMap, sizeof(spec->steamMap)) != 0) spec->steamMap, sizeof(spec->steamMap)) != 0)
{ {
+9 -34
View File
@@ -129,7 +129,7 @@ static CSteamID currentLobby;
static void static void
PublishSelf(const char *pilot_name, const char *vehicle, const char *color, PublishSelf(const char *pilot_name, const char *vehicle, const char *color,
const char *experience, const char *badge, const char *patch) const char *experience)
{ {
// //
// Identity is implicit (the member's SteamID); the roster TOKENS are // Identity is implicit (the member's SteamID); the roster TOKENS are
@@ -140,10 +140,6 @@ static void
matchmaking->SetLobbyMemberData(currentLobby, "vh", vehicle); matchmaking->SetLobbyMemberData(currentLobby, "vh", vehicle);
matchmaking->SetLobbyMemberData(currentLobby, "cl", color); matchmaking->SetLobbyMemberData(currentLobby, "cl", color);
matchmaking->SetLobbyMemberData(currentLobby, "xp", experience); 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 // BUILD GATE, member half: publish our exact build so the HOST can
// reject mismatches at GO time. This is what catches OLD exes -- they // 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 // predate the joiner-side lobby filter, but they can't fake a "bv" key
@@ -182,16 +178,6 @@ static int
if (xp != NULL) if (xp != NULL)
strncpy(out->experience, xp, sizeof(out->experience) - 1); 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()); out->isSelf = (member == SteamUser()->GetSteamID());
if (out->name[0] == 0) if (out->name[0] == 0)
{ {
@@ -207,15 +193,8 @@ static int
static int roomIsHost = 0; static int roomIsHost = 0;
static int roomResult = 0; // 0 pending, 1 go, -1 cancel static int roomResult = 0; // 0 pending, 1 go, -1 cancel
// #169: the buttons sit BELOW the full 8-slot roster (rows y=60..268), and the static RECT roomLaunchRect = { 140, 250, 420, 296 };
// window is sized by CLIENT rect (AdjustWindowRect at creation) -- the old static RECT roomLeaveRect = { 140, 306, 300, 336 };
// 560x360 was the OUTER size, so the caption bar ate the bottom ~47px and the
// LEAVE button (bottom=336) drew half-clipped, while rosters past 6 members
// plowed into the fixed LAUNCH frame (Lynx's 8-player clutter shot).
static RECT roomLaunchRect = { 140, 280, 420, 326 };
static RECT roomLeaveRect = { 140, 336, 300, 366 };
static const int kRoomClientW = 560;
static const int kRoomClientH = 382; // LEAVE bottom 366 + 16 margin
static void static void
PaintRoom(HWND window) PaintRoom(HWND window)
@@ -267,9 +246,7 @@ static void
member.vehicle[0] ? member.vehicle : "mech", member.vehicle[0] ? member.vehicle : "mech",
member.color[0] ? member.color : "-", member.color[0] ? member.color : "-",
member.isSelf ? L" (you)" : L"", member.isSelf ? L" (you)" : L"",
// #169: keep the tag short -- the long "-- WILL NOT LAUNCH" build_ok ? L"" : L" [WRONG BUILD -- WILL NOT LAUNCH]");
// tail clipped at the 536px row edge on long name+vehicle rows.
build_ok ? L"" : L" [WRONG BUILD]");
} }
else else
{ {
@@ -371,12 +348,10 @@ static int
window_class.lpszClassName = L"BTLobbyRoomWnd"; window_class.lpszClassName = L"BTLobbyRoomWnd";
RegisterClassW(&window_class); RegisterClassW(&window_class);
RECT outer = { 0, 0, kRoomClientW, kRoomClientH }; // #169: size by CLIENT rect
AdjustWindowRect(&outer, WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU, FALSE);
HWND window = CreateWindowW( HWND window = CreateWindowW(
L"BTLobbyRoomWnd", L"BattleTech - Steam Lobby", L"BTLobbyRoomWnd", L"BattleTech - Steam Lobby",
WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU, WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU,
160, 160, outer.right - outer.left, outer.bottom - outer.top, 160, 160, 560, 360,
NULL, NULL, GetModuleHandleW(NULL), NULL); NULL, NULL, GetModuleHandleW(NULL), NULL);
if (window == NULL) if (window == NULL)
{ {
@@ -404,7 +379,7 @@ static int
int int
BTLobby_HostAndRoom( BTLobby_HostAndRoom(
const char *pilot_name, const char *vehicle, const char *color, const char *pilot_name, const char *vehicle, const char *color,
const char *experience, const char *badge, const char *patch, const char *experience,
BTLobbyRoster *roster_out, BTLobbyRoster *roster_out,
char *my_token_out, int my_token_capacity, char *my_token_out, int my_token_capacity,
char *steam_map_out, int steam_map_capacity) char *steam_map_out, int steam_map_capacity)
@@ -434,7 +409,7 @@ int
// one stale-zip player desyncs one connection's stream). Same build or // one stale-zip player desyncs one connection's stream). Same build or
// no entry. // no entry.
SteamMatchmaking()->SetLobbyData(currentLobby, "btl4ver", BT_VERSION_STRING); SteamMatchmaking()->SetLobbyData(currentLobby, "btl4ver", BT_VERSION_STRING);
PublishSelf(pilot_name, vehicle, color, experience, badge, patch); PublishSelf(pilot_name, vehicle, color, experience);
LobbyLog("host: lobby up (%llu)", (unsigned long long)created.m_ulSteamIDLobby); LobbyLog("host: lobby up (%llu)", (unsigned long long)created.m_ulSteamIDLobby);
if (RunRoom(1) != 0) if (RunRoom(1) != 0)
@@ -521,7 +496,7 @@ int
int int
BTLobby_JoinAndWait( BTLobby_JoinAndWait(
const char *pilot_name, const char *vehicle, const char *color, const char *pilot_name, const char *vehicle, const char *color,
const char *experience, const char *badge, const char *patch, const char *experience,
char *my_token_out, int my_token_capacity, char *my_token_out, int my_token_capacity,
char *steam_map_out, int steam_map_capacity) char *steam_map_out, int steam_map_capacity)
{ {
@@ -611,7 +586,7 @@ int
return -1; return -1;
} }
} }
PublishSelf(pilot_name, vehicle, color, experience, badge, patch); PublishSelf(pilot_name, vehicle, color, experience);
LobbyLog("join: in lobby, waiting for GO"); LobbyLog("join: in lobby, waiting for GO");
int result = RunRoom(0); int result = RunRoom(0);
+2 -5
View File
@@ -34,9 +34,6 @@ struct BTLobbyMember
// without it on the wire, the host's // without it on the wire, the host's
// egg authored every JOINER as the // egg authored every JOINER as the
// WriteEgg "veteran" fallback // 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; int isSelf;
unsigned long long steamID; unsigned long long steamID;
}; };
@@ -54,7 +51,7 @@ struct BTLobbyRoster
int int
BTLobby_HostAndRoom( BTLobby_HostAndRoom(
const char *pilot_name, const char *vehicle, const char *color, const char *pilot_name, const char *vehicle, const char *color,
const char *experience, const char *badge, const char *patch, const char *experience,
BTLobbyRoster *roster_out, BTLobbyRoster *roster_out,
char *my_token_out, int my_token_capacity, char *my_token_out, int my_token_capacity,
char *steam_map_out, int steam_map_capacity); char *steam_map_out, int steam_map_capacity);
@@ -66,6 +63,6 @@ int
int int
BTLobby_JoinAndWait( BTLobby_JoinAndWait(
const char *pilot_name, const char *vehicle, const char *color, const char *pilot_name, const char *vehicle, const char *color,
const char *experience, const char *badge, const char *patch, const char *experience,
char *my_token_out, int my_token_capacity, char *my_token_out, int my_token_capacity,
char *steam_map_out, int steam_map_capacity); char *steam_map_out, int steam_map_capacity);
+1 -20
View File
@@ -398,29 +398,10 @@ Missile flight-entity cluster (spawned by MissileLauncher::FireWeapon @4bcc60, s
members (best-effort, past Entity base): modelUpdateContext@0x250(+bound update PTR_FUN_005129dc@0x254), world@0x300, currentSegment@0x304, members (best-effort, past Entity base): modelUpdateContext@0x250(+bound update PTR_FUN_005129dc@0x254), world@0x300, currentSegment@0x304,
segmentIndex@0x308(desc+0x84), sourceEntity@0x30C(desc+0x88), sourceWeapon@0x310(desc+0x8C), launchPosition@0x314(desc+0x90), segmentIndex@0x308(desc+0x84), sourceEntity@0x30C(desc+0x88), sourceWeapon@0x310(desc+0x8C), launchPosition@0x314(desc+0x90),
launchVelocity@0x320(desc+0x9C), aimDirection@0x32C(desc+0xA8), modelIndex@0x338(desc+0xB4), damageRecord@0x33C(desc+0xB8). launchVelocity@0x320(desc+0x9C), aimDirection@0x32C(desc+0xA8), modelIndex@0x338(desc+0xB4), damageRecord@0x33C(desc+0xB8).
Performance variants (CORRECTED 2026-08-11, was swapped): ctor @4be1bc picks by instance — Performance variants PTR_LAB_005129e8(authoritative)/005129f4(ghost); base MoveAndCollide body past window — live integrator is Missile override @4bef78.
(flags&0xC)==ReplicantInstance(4, ENTITY.h:318 [T0]) → PTR_LAB_005129e8 → FUN_004221c0 = the REPLICANT
dead-reckoning smoother (blends toward predicted state, no physics/damage); else (master/local) →
PTR_LAB_005129f4 → FUN_004bddec = the LIVE plain-Projectile integrator (was "past window" in the old
export; present in the 2026-08-06 rebuild). Same split in the Missile ctor: 512da0→4221c0 (replicant),
512dac→4bef78 (master integrator).
Plain-Projectile flight/expiry @4bddec [T1]: integrate → world/entity collide FUN_0042291c (shooter
excluded via @4be058) → hit: detonate+damage FUN_004be078; no-hit: dead-reckon predictor
FUN_00422060 via +0x254 refreshes predicted pos +0x260, divergence² > _DAT_004be050=0.1 sets
+0x18|=1 (replication-dirty, NOT kill); tail: age since spawn(+0x19c) > _DAT_004be054=5.0s →
FUN_0042061c kill (remove + flags|=0x2000|2). NO WeaponRange / distance cap on flight — a shell
flies exactly 5.0s (#168: the authored range=750 is fire-control data only; FireWeapon @4bcc60
reads no effectiveRange +0x328 — the trigger has no range gate).
└─ Missile vtable 00512f2c ctor @4bf5b4 dtor @4bf890 alloc @4bf8bc (size 0x368) [missile.cpp] └─ Missile vtable 00512f2c ctor @4bf5b4 dtor @4bf890 alloc @4bf8bc (size 0x368) [missile.cpp]
MoveAndCollide @4bef78 (age/guide/integrate/collide; collision FUN_0042291c, detonate FUN_004be078 MoveAndCollide @4bef78 (age/guide/integrate/collide; collision FUN_0042291c, detonate FUN_004be078
streamed ClassID 0x5C), WriteUpdateRecord @4bef4c (slot7, tag 0x78). streamed ClassID 0x5C), WriteUpdateRecord @4bef4c (slot7, tag 0x78).
Expiry @4bef78 tail [T1]: age since spawn(+0x19c) > burnTime(+0x340, model rec +0x44) +
_DAT_004bf5ac=10.0s coast margin, OR pos.y(+0x104) < _DAT_004bf5b0=-1.0 kill-plane → FUN_0042061c
kill. Also: no replication update for _DAT_004bf594=2.0s → +0x18|=1 (dirty keepalive);
dead-reckon divergence² > _DAT_004bf5a8=0.1 → +0x18|=1. Seeker target DROPPED when the victim's
movementMode ∈ {2,9} = destroyed (FUN_0049fb54). PROXIMITY FUSE: seeker rangeToTarget(+0x10C) <
_DAT_004bf5a4=4.0 → treat targetEntity as the collision victim (detonate) even without a geometry
hit. LRM "range" is therefore physics (speed × lifetime), not a gate.
Hosts 2 subsystems: this[0x49]=2 @0x124, roster @0x128 → {Seeker, MissileThruster}. Hosts 2 subsystems: this[0x49]=2 @0x124, roster @0x128 → {Seeker, MissileThruster}.
enum {SeekerSubsystem=0, MissileThrusterSubsystem=1}. members (best-effort): enum {SeekerSubsystem=0, MissileThrusterSubsystem=1}. members (best-effort):
lifetime@0x340 ageFraction@0x344 thrustScale@0x348 targetConn@0x34C targetOffset@0x350 lifetime@0x340 ageFraction@0x344 thrustScale@0x348 targetConn@0x34C targetOffset@0x350
+5 -16
View File
@@ -170,13 +170,7 @@ static const int kBTEngBankTop[12] = { 0xF,0xF,0xF,0xF, 0x27,0x27,0x27,0x27, 0
// no 0x29) -- overlapping but distinct lamps. Slot 6 (0x29) is also // no 0x29) -- overlapping but distinct lamps. Slot 6 (0x29) is also
// DAT_0051d070[0], the first per-placement lamp: the two tables abut. // DAT_0051d070[0], the first per-placement lamp: the two tables abut.
static const int kBTCondenserLamp[7] = { 0x7, 0x2F,0x2E,0x2D,0x2B,0x2A, 0x29 }; static const int kBTCondenserLamp[7] = { 0x7, 0x2F,0x2E,0x2D,0x2B,0x2A, 0x29 };
// #135 CORRECTED: these are the GENERATOR leak lamps -- the binary's table at static const int kBTPlacementLamp[5] = { 0x29, 0x1A,0x1B,0x1C,0x1D }; // [auxScreenPlacement]
// 0x51d070 (@004cc27c), indexed by generatorNumber 1-BASED (A=1..D=4; slot 0
// overlaps the condenser table's last entry and is unused; slot 5 is already
// string bytes in the image, hence the bounds guard). The old name/comment
// ("auxScreenPlacement") described a mis-reconstruction that indexed this
// table with PoweredSubsystem aux fields generators never fill.
static const int kBTGeneratorLamp[5] = { 0x29, 0x1A,0x1B,0x1C,0x1D };
// //
// @004cc148 -- the fixed (<0x80) map: coolingLoop1..6 -> the heat-display bank. // @004cc148 -- the fixed (<0x80) map: coolingLoop1..6 -> the heat-display bank.
@@ -341,15 +335,10 @@ void
{ {
if (the_condition == 2) // CoolantLeaking if (the_condition == 2) // CoolantLeaking
{ {
// #135 FIX: the binary's generator special @004cc27c indexes the int screen = 0, placement = -1;
// 0x51d070 table by generatorNumber (sub+0x1E0, A=1..D=4) -- NOT by BTGetSubsystemAuxScreen(the_subsystem, &screen, &placement, 0);
// any aux-screen placement (generators never fill those fields; the if (placement >= 0 && placement < 5)
// old probe left placement=-1 and a leaking generator annunciated lamp_id = kBTPlacementLamp[placement];
// NOTHING, the field-logged 'NO LAMP RESOLVED (generator=1)').
extern int BTGeneratorNumber(::Subsystem *sub); // powersub.cpp
int n = BTGeneratorNumber(the_subsystem);
if (n >= 1 && n <= 4)
lamp_id = kBTGeneratorLamp[n];
} }
} }
else if (lamp_code < 0x80) else if (lamp_code < 0x80)
+1 -18
View File
@@ -1744,27 +1744,10 @@ Logical
L4Warehouse *warehouse = (L4Warehouse *)gauge_renderer->warehousePointer; L4Warehouse *warehouse = (L4Warehouse *)gauge_renderer->warehousePointer;
if (warehouse->pixelMap8Bin.Get(p[2].data.string) == NULL) // FUN_00442d2b if (warehouse->pixelMap8Bin.Get(p[2].data.string) == NULL) // FUN_00442d2b
{ {
// WAS DebugStream -- the no-op ReconStream (gotcha: use DEBUG_STREAM). DebugStream << "OneOfSeveralPixInt: Missing image '" << p[2].data.string << "'\n";
// A missing strip therefore failed COMPLETELY SILENTLY, which is
// exactly the state #142 was stuck in: the crouch symbol never drew and
// nothing anywhere said why.
DEBUG_STREAM << "[gauge] oneOfSeveralPixInt: MISSING IMAGE '"
<< p[2].data.string << "' -- element not created\n" << std::flush;
return False; return False;
} }
warehouse->pixelMap8Bin.Release(p[2].data.string); // FUN_00442e51 warehouse->pixelMap8Bin.Release(p[2].data.string); // FUN_00442e51
// #142 receipt (ungated, one line per element): does this strip exist, and
// did its integer attribute actually RESOLVE? A NULL attributePointer
// leaves the connection reading nothing, so the strip pins to frame 0 and
// looks like "no animation at all" -- indistinguishable, from outside, from
// a missing image or an unbuilt page.
DEBUG_STREAM << "[gauge] oneOfSeveralPixInt '" << p[2].data.string
<< "' frames=" << p[3].data.integer << "x" << p[4].data.integer
<< " port=" << display_port_index
<< " at(" << position.x << "," << position.y << ")"
<< " attr=" << (p[5].data.attributePointer != 0 ? "BOUND" : "NULL !!")
<< "\n" << std::flush;
return True; return True;
} }
-37
View File
@@ -2622,14 +2622,6 @@ void
{ {
// the range caret translate, from the live target range // the range caret translate, from the live target range
Scalar range = (rangeAttr2 != 0) ? *rangeAttr2 : 0.0f; Scalar range = (rangeAttr2 != 0) ? *rangeAttr2 : 0.0f;
// #147: NaN-SAFE clamp. `range < minRange` and `range > maxRange` are BOTH
// false for NaN, so the old pair let a poisoned value straight through into
// AddPoint/ConcatMatrix below -- degenerate geometry, and the caret + its
// bar silently STOP DRAWING while the static ticks remain. Test for NaN
// first (x == x is false only for NaN) and fall back to the binary's
// no-target default rather than rendering nothing.
if (!(range == range))
range = maxRange; // 1200: the authentic no-target peg
if (range < minRange) range = minRange; if (range < minRange) range = minRange;
if (range > maxRange) range = maxRange; if (range > maxRange) range = maxRange;
Scalar frac = (range - minRange) / (maxRange - minRange); Scalar frac = (range - minRange) / (maxRange - minRange);
@@ -3687,35 +3679,6 @@ namespace {
extern void BTSetWorldDead(int dead); // L4VIDRND.cpp bridge -> l4_application->SetIsDead extern void BTSetWorldDead(int dead); // L4VIDRND.cpp bridge -> l4_application->SetIsDead
//===========================================================================//
// #156 -- the mission start/end fade arming seam. The phase machine itself
// runs at the renderer frame head (L4VIDEO.cpp, beside the FOGCOLOR clear);
// these globals are what MakeEntityRenderables' BTPlayer master case arms
// through the now-real BTPOVStartEndRenderable ctor below.
//===========================================================================//
void *gBTFadeIndicator = 0;
int gBTFadeStart = 3, gBTFadeEnd = 4;
// The real ctor (was a btstubs.cpp inert stub -- the #156 root cause: rounds
// hard-cut instead of fading). Binary ctor @00454394 stores the indicator +
// the authored fog params and registers on the view's watcher list; the port
// keeps the object inert (house pattern, like the translocation renderable)
// and arms the frame-head machine instead. The fog params ride the renderer
// members live, exactly as the binary re-reads them per fade frame.
BTPOVStartEndRenderable::BTPOVStartEndRenderable(
Entity *entity, int, dpl_VIEW *, dpl_ZONE *, dpl_ZONE *,
StateIndicator *sim_state,
float, float, float, float, float, int start_state, int end_state)
: BTRenderableBase(entity)
{
gBTFadeIndicator = (void *)sim_state;
gBTFadeStart = start_state;
gBTFadeEnd = end_state;
if (getenv("BT_FADE_LOG") || getenv("BT_FOG_LOG"))
DEBUG_STREAM << "[fade] armed (states " << start_state << "/"
<< end_state << ")\n" << std::flush;
}
// The renderable objects the entity tree builds for the translocation wiring are // The renderable objects the entity tree builds for the translocation wiring are
// inert now (the warp is the self-contained one-shot below); the ctor/dtor just // inert now (the warp is the self-contained one-shot below); the ctor/dtor just
// satisfy MakeEntityRenderables. // satisfy MakeEntityRenderables.
+59 -353
View File
@@ -305,38 +305,13 @@ static const Scalar TicksPerSecond = 1.0f; // (see note in PlayerSimulation)
// tonnage ratio stubbed 1.0 + damage bias stubbed 0.0 for bring-up (SCORE == raw // tonnage ratio stubbed 1.0 + damage bias stubbed 0.0 for bring-up (SCORE == raw
// damage, un-tonnage-scaled; the real per-mech tonnage/bias accessors are a follow-up). // damage, un-tonnage-scaled; the real per-mech tonnage/bias accessors are a follow-up).
#define MECH_TONNAGE(m) (1.0f) // bring-up: ratio == 1 #define MECH_TONNAGE(m) (1.0f) // bring-up: ratio == 1
// mech+0x354 -- the score formula's per-target damage-bias factor. #define MECH_DAMAGE_BIAS(m) (0.0f) // bring-up: factor = 0*bias+1 = 1
//
// ⚠ DO NOT "FINISH" THIS. 0.0f is not a stand-in; it is the value the shipped
// binary always has, established by audit 2026-08-08:
//
// * mech+0x354 has exactly ONE writer in the whole image -- Mech::Reset
// (@0049fb74, part_012.c:14340). Nothing touches it during play.
// * Reset computes it as mean(zone + 0x158) across every damage zone, and it
// does that AFTER the zone heal has already zeroed those cells. So it is
// ~0 the instant it is written, stays ~0 for the mech's entire life, and is
// recomputed as ~0 on the next respawn.
// * It has exactly ONE reader -- CalcInflictedScore (@004c052c,
// part_013.c:19055) -- where it appears as `avg * role.damageBias + 1.0`.
//
// So the factor is 1.0 for the whole game, and returning 0.0f here reproduces
// the binary EXACTLY. It is a vestigial aggregate (0x358 and 0x35c are the
// same computation and have NO reader at all).
//
// Wiring this to live accumulated damage would look like completing an unfinished
// port and would silently inflate every inflicted and kill award -- the numbers
// verified against the original manual's scoring chart (+1 a damage point, +500
// a kill) all assume this term is 1.0.
#define MECH_DAMAGE_BIAS(m) (0.0f) // = the binary's value; see above
#define MECH_OWNING_PLAYER(m) ((BTPlayer *)((Mech *)(m))->GetPlayerLink()) // ENTITY.h:430 (NULL for the dummy) #define MECH_OWNING_PLAYER(m) ((BTPlayer *)((Mech *)(m))->GetPlayerLink()) // ENTITY.h:430 (NULL for the dummy)
//############################################################################# //#############################################################################
//############################### BTPlayer ############################## //############################### BTPlayer ##############################
//############################################################################# //#############################################################################
Scalar BTScoreWatermarkOf(int owner);
void BTScoreWatermarkSet(int owner, Scalar sent);
//############################################################################# //#############################################################################
// Message Support // Message Support
// //
@@ -709,20 +684,6 @@ void
// scoreboard (they read +0x278), so the pod's penalty may never have been // scoreboard (they read +0x278), so the pod's penalty may never have been
// visible; our port has ONE currentScore, so it shows. // visible; our port has ONE currentScore, so it shows.
// //
// #45 receipt (ungated): this cost is dispatched by a DIRECT base-handler
// call, so it never reaches the BT matchlog and only the running total
// exposes it -- which is how a combat death costing -500 while a SELF-KILL
// cost nothing went unnoticed. Say out loud whether it fires and why not.
DEBUG_STREAM << "[deathcost] player " << BTMatchHostOf(GetEntityID())
<< ":" << (int)GetEntityID()
<< " advDmg=" << (int)advancedDamageOn
<< " role=" << (scenarioRole != 0 ? "bound" : "NULL")
<< " penalty=" << (scenarioRole != 0
? (float)scenarioRole->GetSpecialCaseDeathPenalty() : 0.0f)
<< " scoreBefore=" << (float)currentScore
<< ((advancedDamageOn && scenarioRole != 0) ? " -> APPLYING" : " -> SKIPPED")
<< "\n" << std::flush;
if (advancedDamageOn && scenarioRole != 0) // this+0x264 (binary derefs role unguarded) if (advancedDamageOn && scenarioRole != 0) // this+0x264 (binary derefs role unguarded)
{ {
BTPlayer::ScoreMessage death_cost( BTPlayer::ScoreMessage death_cost(
@@ -736,208 +697,40 @@ void
} }
// //
// #162 CORRECTION (2026-08-11): the #81 clear that used to sit HERE was a // RELEASE THE DEATH LATCH (#81, the GHOST MECH fix -- 2026-07-30).
// 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
} }
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// MissionStartingMessageHandler (@004bfbe8)
//
// THE "+1000 STARTING THE GAME" ROW of the original manual's scoring chart,
// decoded 2026-08-07. BT overrides the engine's MissionStarting purely to
// seed the score, and the override was never reconstructed -- the
// MESSAGE_ENTRY resolved to the inherited Player:: handler, so the grant
// simply never happened. The binary:
//
// FUN_004bfbe8(player):
// base_MissionStarting(player);
// if (app->state == 4 && (player[0x29] & 0x40) == 0)
// player[0x1c8] = 0x447a0000; // = 1000.0f
//
// Both operands decode exactly: application state 4 is LaunchingMission
// (APP.h -- the same enum whose 6 is EndingMission, already used by the
// console flush), and simulationFlags bit 14 is NonScoringPlayerBit
// (PLAYER.h: `NonScoringPlayerBit = Entity::NextBit`), so the byte test
// `(+0x29 & 0x40) == 0` IS `IsScoringPlayer()`. Camera-ship and spectator
// players are non-scoring and correctly get nothing.
//
// CELL NOTE: the binary seeds the ENGINE score cell (+0x1c8), not BT's own
// (+0x278) -- the 1995 build carried two accumulators, which is why the KB
// suspected the pod's death cost "may never have displayed". Our port has a
// single currentScore, so the grant, the awards and the death cost all land
// together, and the chart reads coherently for a player: start at 1000, +1 a
// damage point, +500 a kill, -500 a special-case death.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
void
BTPlayer::MissionStartingMessageHandler(Entity::Message *message)
{
Player::MissionStartingMessageHandler(message); // FUN_0042d9c0
if (application->GetApplicationState() == Application::LaunchingMission // app+0x88 == 4
&& IsScoringPlayer()) // !(simulationFlags & NonScoringPlayerFlag)
{
currentScore = 1000.0f; // this+0x1c8 = 0x447a0000
// The console watermark is what we have already reported; a fresh
// mission must report the grant, not the difference from the last
// round's tally.
BTScoreWatermarkSet(ownerID, 0.0f);
DEBUG_STREAM << "[score] mission start: player "
<< BTMatchHostOf(GetEntityID()) << ":" << (int)GetEntityID()
<< " seeded to " << (float)currentScore
<< " (chart: +1000 starting the game)\n" << std::flush;
}
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// 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)
//
// THE TYPE-0 INTERCEPTOR. Restored 2026-08-07 -- it was missing, and its
// absence silently deleted per-hit inflicted scoring:
//
// * the binary carries all three score reports under ONE id (0x16) and
// splits type 0 off here, BEFORE base dispatch, straight into
// ScoreInflictedMessageHandler (@004c0200 -- which names itself in its own
// Verify string, "BTPlayer::ScoreInflictedMessageHandler");
// * ScoreMessageHandler's type-0 arm Verify-rejects ON PURPOSE, because this
// interceptor guarantees type 0 never gets that far;
// * the port had the handler, faithfully reconstructed, and NO interceptor.
// Block B sends its inflicted report under Player::ScoreMessageID, so every
// one of them landed in the rejecting arm and banked 0.
//
// The KB previously recorded @004c0200 as "in NO table entry: dead code" and
// concluded 1995 folded an uninitialised stack float into the shooter's score
// on every non-lethal hit -- and the port's per-hit crediting was retired as an
// "invention" on that basis (#45/#134, build 787, the build players report
// scoring regressed in). That reading was wrong: the handler is live through
// THIS vtable slot, and the original manual's SCORING CHART independently
// corroborates what it computes -- "+1 each damage point scored on opponent's
// armor" and "-1 each self-inflicted point of armor damage", which is exactly
// this handler's negate-if-target-is-self arm. combat-damage.md is corrected.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
void
BTPlayer::Dispatch(Receiver::Message *what)
{
if (what != 0
&& what->messageID == Player::ScoreMessageID
&& ((BTPlayer::ScoreMessage *)what)->scoreType
== BTPlayer::ScoreMessage::DamageInflictedScore
//
// MASTER ONLY. The binary intercepts unconditionally because its
// +0x278 is a console DELTA -- every node's contribution is flushed
// under the scoring player's ownerID and the CONSOLE totals it, so the
// computing node is irrelevant. Our port has no console tally
// (btconsole.py/btoperator.py handle no score at all) and reads +0x278
// on the OWNING node for the SCORE gauge, CalcRanking and the replicated
// Player__UpdateRecord. Banking on the victim's replicant copy
// therefore loses the credit to the master's next update record.
// Falling through lets Entity::Dispatch reroute to the owner, where the
// wire delivery lands in ScoreMessageHandler's type-0 arm (which now
// delegates back to the inflicted handler).
//
&& GetInstance() == Entity::MasterInstance)
{
ScoreInflictedMessageHandler((BTPlayer::ScoreMessage *)what);
return;
}
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 // ScoreInflictedMessageHandler
// //
@@ -1072,34 +865,16 @@ void
{ {
case BTPlayer::ScoreMessage::DamageInflictedScore: // 0 case BTPlayer::ScoreMessage::DamageInflictedScore: // 0
// //
// Inflicted-damage messages belong to ScoreInflictedMessageHandler, and // Inflicted-damage messages belong to ScoreInflictedMessageHandler.
// the binary's Dispatch override (@004bffa0) guarantees they never reach
// here -- which is why the original arm is a bare Verify.
// //
// PORT DIVERGENCE (2026-08-07), and it is a DELIVERY-PATH difference, Verify(
// not a scoring one. Damage is applied on the VICTIM's node, so block B False,
// dispatches the inflicted report to the SHOOTER's player object there -- "BTPlayer::ScoreMessageHandler should not be "
// a REPLICANT -- and Entity::Dispatch reroutes it to the owning host so "given DamageInflictedScoreMessages!", // @0051324a
// the credit lands on the shooter's OWN machine (the same reroute that "d:\\tesla_bt\\bt\\btplayer.cpp", // @0051329a
// carries kill credit, ENTITY.cpp:244-251). But a message arriving over 0x296
// the WIRE is delivered through Receive(), straight to this handler );
// table: the virtual Dispatch override is never called on the receiving break;
// side. So the rerouted report lands HERE, and Verify-rejecting it
// threw away every cross-node inflicted credit (benched: award=0.00).
//
// Delegate instead. Local deliveries are still intercepted by Dispatch
// exactly as the binary does; wire deliveries land here and get the same
// handler. One accumulator, on the machine that owns the score.
//
// RETURN, not break. The post-switch tail folds the local `award` into
// message->scoreAward and hands it to the base handler -- and for this
// arm `award` is still 0, so falling through clobbered scoreAward to
// zero, added nothing, and emitted a second SCORE row reading
// "type=0 award=0.00" (the 80 real + 80 zero rows in the bench).
// ScoreInflictedMessageHandler is self-contained: it accumulates,
// ForceUpdate()s and logs its own receipt.
ScoreInflictedMessageHandler(message);
return;
case BTPlayer::ScoreMessage::DamageReceivedScore: // 1 case BTPlayer::ScoreMessage::DamageReceivedScore: // 1
{ {
@@ -1368,38 +1143,20 @@ void
// //
// Only bother if our score actually changed since last time. // Only bother if our score actually changed since last time.
// //
// DELTA vs RUNNING TOTAL (2026-08-07). The binary sends currentScore if ((Scalar)currentScore != 0.0f) // this[0x9e] != _DAT_004c0900 (0.0f)
// 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)delta; int score = (int)currentScore;
ConsolePlayerVTVScoreUpdateMessage score_message( ConsolePlayerVTVScoreUpdateMessage score_message(
ownerID, ownerID,
score score
); // FUN_00420ea4(0x20, 0x1a, 1, ...) ); // 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 = Host *console_host =
application->GetHostManager()->GetConsoleHost(); // FUN_00429078 application->GetHostManager()->GetConsoleHost(); // FUN_00429078
if (console_host) if (console_host)
@@ -1418,10 +1175,7 @@ void
NetworkClient::ConsoleClientID, // 5 NetworkClient::ConsoleClientID, // 5
&score_message &score_message
); );
// The binary does `currentScore = 0` here. We advance the currentScore = 0; // this[0x9e] = 0
// 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);
} }
} }
} }
@@ -1786,8 +1540,6 @@ void
deathPending = 0; // #55 step 1: a latch carried into a deathPending = 0; // #55 step 1: a latch carried into a
// fresh spawn would permanently kill // fresh spawn would permanently kill
// every later respawn (the #57 class) // 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) // (warp fired in the shared placement below -- initial drop-in + respawn)
} }
else if (deathCount == message->deathCount) // param_2[0xe] == param_1[0x80] else if (deathCount == message->deathCount) // param_2[0xe] == param_1[0x80]
@@ -1887,11 +1639,6 @@ void
// the same process. The cycle's completion is HERE, so clear it here. // the same process. The cycle's completion is HERE, so clear it here.
// //
deathPending = 0; // this+0x290 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()) DEBUG_STREAM << "[respawn] player " << BTMatchHostOf(GetEntityID())
<< ":" << (int)GetEntityID() << " RESET at drop zone (death #" << ":" << (int)GetEntityID() << " RESET at drop zone (death #"
<< deathCount << ") -- death cycle complete, latch cleared\n" << deathCount << ") -- death cycle complete, latch cleared\n"
@@ -2031,51 +1778,15 @@ BTPlayer::BTPlayer(
} }
// //
// Look the scoring role up in the role registry (keyed by the role name in // Look the scoring role up in the role registry (keyed by the role name
// the creation message, +0x90) and stash it as our scenarioRole. // in the creation message, +0x90) and stash it as our scenarioRole. The
// // BT role registry (BTMission::GetRoleRegistry()->Lookup) has no WinTesla
// WIRED 2026-08-07. This was commented out with "the BT role registry // analog, so the scenarioRole set by the base Player ctor stands.
// (BTMission::GetRoleRegistry()->Lookup) has no WinTesla analog, so the // CROSS-FAMILY: needs BTMission role-registry access. BEST-EFFORT.
// scenarioRole set by the base Player ctor stands". The base ctor sets it
// to NULL (PLAYER.cpp:680), so it stood NULL forever -- and EVERY scoring
// value the game has lives on that pointer:
//
// killBonus -> the kill award basis (authored 500)
// specialCaseDeathPenalty-> the death cost (authored 500)
// damageInflictedModifier-> the per-hit multiplier (authored 1)
// returnFromDeath -> the entry credit (authored 1000)
//
// With it NULL: kill_bonus reads 0 (a kill scored the damage tally alone --
// benched 4.88 instead of ~500), the eject charge is 0 (field log:
// "PUNCH-OUT: charge=0 (role killBonus)" = #134's missing penalty), and the
// death cost block is skipped entirely. That is four rows of the original
// manual's scoring chart, all from one commented-out line.
//
// The analog DOES exist: Mission::GetScenarioRole(name) (MISSION.h:162)
// walks scenarioRoleChain -- the very dictionary BTL4Mission fills via
// AddScenarioRole() when it parses the role pages (btl4mssn.cpp), whose own
// comment already says the WinTesla base exposes it. Same lookup, same
// key, no cross-family gap.
// //
CString role_key(creation_message->roleName); // make+0x90 CString role_key(creation_message->roleName); // make+0x90
if (playerMission != 0) (void)role_key;
{ // scenarioRole = playerMission->GetRoleRegistry()->Lookup(&role_key); // this[0x7e]+0x50, this[0x82]
ScenarioRole *found = playerMission->GetScenarioRole(role_key); // this[0x7e]+0x50
if (found == 0)
{
// The shipped content authors ONE role page, "Role::Default"
// (model dfltrole). A creation message naming anything else -- or
// naming nothing -- must still score, so fall back to it rather
// than leave the pointer NULL and silently zero every award.
found = playerMission->GetScenarioRole(CString("Role::Default"));
}
scenarioRole = found; // this[0x82]
DEBUG_STREAM << "[role] player " << BTMatchHostOf(GetEntityID())
<< ":" << (int)GetEntityID() << " key='" << (const char *)role_key
<< "' -> " << (scenarioRole != 0 ? "BOUND" : "NULL (scores will be 0)")
<< (scenarioRole != 0 ? "" : " !!")
<< "\n" << std::flush;
}
if ((simulationFlags & 0xc) == 4) if ((simulationFlags & 0xc) == 4)
{ {
@@ -2640,14 +2351,9 @@ void BTMechPostCombatReports(
else if (damage_tally != 0.0f && shooter_player != 0) else if (damage_tally != 0.0f && shooter_player != 0)
{ {
// //
// Block B: the inflicted report -- "+1 each damage point scored on // Block B: the plain inflicted report. Wire fidelity only -- the 0x16
// opponent's armor" (original manual scoring chart). Goes out under // handler Verify-rejects type 0 and banks award 0 (1995 banked an
// Player::ScoreMessageID with type 0, exactly as the binary does; the // uninitialized stack float; see the handler's type-0 arm note).
// Dispatch override (@004bffa0) intercepts it into
// ScoreInflictedMessageHandler. It is NOT wire-fidelity-only -- the
// old note here claimed the handler banks 0 because @004c0200 was
// "dead code", which was a misreading of the vtable; corrected
// 2026-08-07 and the interceptor restored.
// //
BTPlayer::ScoreMessage inflicted( BTPlayer::ScoreMessage inflicted(
Player::ScoreMessageID, Player::ScoreMessageID,
+8 -41
View File
@@ -84,24 +84,16 @@ class DropZone__ReplyMessage;
public: public:
// //
// Kind of scoring event. Recovered from the branch selector at // Kind of scoring event. Recovered from the branch selector at
// @004c02e4 (this->scoreType, message+0x20). // @004c02e4 (this->scoreType, message+0x20). NOTE: type 0 has NO
// // scoring arm in the binary -- @004c02e4 Verify-rejects it (line 662)
// CORRECTED 2026-08-07. This note used to read "type 0 has NO scoring // and @004c0200, the only function that accepts it, appears in no
// arm in the binary ... @004c0200 appears in no handler-table entry ... // handler-table entry (byte-scan 2026-08-05: the BTPlayer table at
// per-hit inflicted credit never existed", and build 787 deleted the // file 0x112dxx has exactly 6 entries, none binding it). 1995 pod
// credit on that basis. The byte-scan was right that no TABLE entry // scoring = kills + received-damage penalties; per-hit inflicted
// binds @004c0200 and wrong to conclude it is unreachable: BTPlayer // credit never existed.
// overrides Dispatch (vtable @00513300 slot 3 = FUN_004bffa0), which
// splits type 0 off BEFORE base dispatch and calls it directly. That
// is also WHY @004c02e4 Verify-rejects type 0 -- the interceptor
// guarantees it never arrives there. @004c0200 names itself
// "BTPlayer::ScoreInflictedMessageHandler" in its own Verify string,
// and the original manual's scoring chart independently confirms what
// it computes: "+1 each damage point scored on opponent's armor",
// "-1 each self-inflicted point".
// //
enum ScoreType { enum ScoreType {
DamageInflictedScore = 0, // per-hit inflicted credit (LIVE, see above) DamageInflictedScore = 0, // sent, but scores nothing (see above)
DamageReceivedScore = 1, // I took damage DamageReceivedScore = 1, // I took damage
KillScore = 2 // I destroyed / was destroyed KillScore = 2 // I destroyed / was destroyed
}; };
@@ -296,23 +288,6 @@ class DropZone__ReplyMessage;
private: private:
static const HandlerEntry MessageHandlerEntries[]; static const HandlerEntry MessageHandlerEntries[];
public:
//
// @004bffa0 -- the DISPATCH OVERRIDE (vtable @00513300 slot 3). The
// binary uses ONE message id (0x16) for all three score reports and
// splits type 0 off HERE, before base dispatch:
//
// if (msg->id == 0x16 && msg->type == 0) ScoreInflicted(msg);
// else base dispatch;
//
// which is why ScoreMessageHandler's own type-0 arm can Verify-reject:
// the interceptor guarantees type 0 never reaches it. Without this
// override every inflicted report lands in the rejecting arm and scores
// nothing -- see the note in btplayer.cpp.
//
virtual void
Dispatch(Receiver::Message *what); // @004bffa0
protected: protected:
static MessageHandlerSet& GetMessageHandlers(); static MessageHandlerSet& GetMessageHandlers();
@@ -327,14 +302,6 @@ class DropZone__ReplyMessage;
void void
ScoreMessageHandler(ScoreMessage *message); // @004c02e4 ScoreMessageHandler(ScoreMessage *message); // @004c02e4
//
// @004bfbe8 -- BT's MissionStarting override. Seeds the starting score
// ("+1000 Starting the game", original manual scoring chart). The base
// Player handler does the fade-in; BT adds the grant.
//
void
MissionStartingMessageHandler(Entity::Message *message); // @004bfbe8
// //
// @004bffd0 -- the spawn / respawn handshake. When the drop zone // @004bffd0 -- the spawn / respawn handshake. When the drop zone
// replies with our spawn location we create (or reset) the player's // replies with our spawn location we create (or reset) the player's
+4 -3
View File
@@ -336,9 +336,10 @@ BTMarkerWatcherRenderable::BTMarkerWatcherRenderable(
// BTTranslocationRenderable (the "blue warp" translocation sphere) is now a real // BTTranslocationRenderable (the "blue warp" translocation sphere) is now a real
// reconstruction in btl4vid.cpp (task #52) -- no longer a stub here. // reconstruction in btl4vid.cpp (task #52) -- no longer a stub here.
// BTPOVStartEndRenderable (the mission start/end fade) is now a real BTPOVStartEndRenderable::BTPOVStartEndRenderable(
// reconstruction: the arming ctor lives in btl4vid.cpp and the 5-phase fog Entity *entity, int, dpl_VIEW *, dpl_ZONE *, dpl_ZONE *, StateIndicator *,
// machine at the renderer frame head (L4VIDEO.cpp) -- #156. float, float, float, float, float, int, int)
: BTRenderableBase(entity) {}
BTTracerEffectRenderable::BTTracerEffectRenderable( BTTracerEffectRenderable::BTTracerEffectRenderable(
Entity *entity, int, void *, int, dpl_ZONE *, dpl_DCS *, LinearMatrix *) Entity *entity, int, void *, int, dpl_ZONE *, dpl_DCS *, LinearMatrix *)
+1 -8
View File
@@ -304,14 +304,7 @@ void
// BRING-UP verify (rate-limited): prove the real fire path executes + feeds the heat // BRING-UP verify (rate-limited): prove the real fire path executes + feeds the heat
// sim (heatPortion -> pendingHeat -> flows to the central sink -> mech temp climbs). // sim (heatPortion -> pendingHeat -> flows to the central sink -> mech temp climbs).
static int s_fireLog = 0; static int s_fireLog = 0;
// #165 bench: EVERY discharge, NAMED, under BT_DMG_LOG (the old unnamed if ((s_fireLog++ % 19) == 0) // 19 prime: rotates across the 5 emitters (20 aliased to one)
// 1-in-19 sample could not answer "which weapon fired" -- the #110 nit);
// the rate-limited unnamed line stays for ambient runs without the env.
if (getenv("BT_DMG_LOG"))
DEBUG_STREAM << "[emitter] FIRED '" << (GetName() ? GetName() : "?")
<< "' damage=" << damagePortion << " heat=" << heatPortion
<< "\n" << std::flush;
else if ((s_fireLog++ % 19) == 0) // 19 prime: rotates across the 5 emitters (20 aliased to one)
DEBUG_STREAM << "[emitter] FIRED #" << s_fireLog << " damage=" << damagePortion DEBUG_STREAM << "[emitter] FIRED #" << s_fireLog << " damage=" << damagePortion
<< " heat=" << heatPortion << " pendingHeat=" << pendingHeat << "\n" << std::flush; << " heat=" << heatPortion << " pendingHeat=" << pendingHeat << "\n" << std::flush;
+8 -141
View File
@@ -947,26 +947,6 @@ void
if (other != 0 && coolantAvailable != 0) if (other != 0 && coolantAvailable != 0)
{ {
Scalar flow = ComputeHeatFlow(other, time_slice); // FUN_004ad9ec Scalar flow = ComputeHeatFlow(other, time_slice); // FUN_004ad9ec
// #137 FLOW TRAP (BT_HEAT_LOG): the myomers gains ~2e9 of energy within
// 30 frames of a respawn while every partner reads T=77 -- and the only
// writers into its pendingHeat are its (dead-at-spawn) integrator and
// THIS line. Any e6-scale single flow is the bug caught in the act;
// print the complete operand set so the arithmetic can be re-run by
// hand instead of guessed at.
if (getenv("BT_HEAT_LOG") != 0 && (flow > 1.0e6f || flow < -1.0e6f))
{
DEBUG_STREAM << "[heatflow] " << (GetName() ? GetName() : "?")
<< " -> " << (other->GetName() ? other->GetName() : "?")
<< " flow=" << flow << " dt=" << time_slice
<< " | this: T=" << currentTemperature << " E=" << heatEnergy
<< " pend=" << pendingHeat << " m=" << thermalMass
<< " mScale=" << massScale << " k=" << thermalConductance
<< " lvl=" << coolantLevel << " cap=" << thermalCapacity
<< " fScale=" << coolantFlowScale
<< " | other: T=" << other->currentTemperature
<< " E=" << other->heatEnergy << " pend=" << other->pendingHeat
<< " m=" << other->thermalMass << "\n" << std::flush;
}
other->pendingHeat += flow; other->pendingHeat += flow;
pendingHeat -= flow; pendingHeat -= flow;
BalanceCoolant(time_slice); // FUN_004ada94 BalanceCoolant(time_slice); // FUN_004ada94
@@ -1439,14 +1419,15 @@ int BTCondenserNumber(::Subsystem *sub)
// //
// The #83-era BTHeatSinkFamily bridge that lived here is GONE (#128, // #83 bridge: the collision-damage distributor (mech.cpp, FUN_0049ffcc) tests
// 2026-08-11): it returned the whole HeatSink base family for the // roster members against this family's derivation chain (binary GUID 0x50e590).
// collision distributor's 0x50e590 test, but 0x50e590 is the AGGREGATE // Lives here because HeatSink is a complete type only in this TU.
// BANK's node (0xBBE "HeatSinkBank") -- the same task-#9 identity the
// link-attach guard above proved live (heat.cpp:627). The distributor
// now uses the string-name derivation test directly (mech.cpp), so no
// complete-type bridge is needed.
// //
Derivation *
BTHeatSinkFamily()
{
return HeatSink::GetClassDerivations();
}
//===========================================================================// //===========================================================================//
// BTHeatSinkBankCoolantFraction -- complete-type bridge for the EJECT- // BTHeatSinkBankCoolantFraction -- complete-type bridge for the EJECT-
@@ -1465,117 +1446,3 @@ int BTHeatSinkBankCoolantFraction(Subsystem *sub, Scalar *out)
*out = bank->CoolantFractionOf(); *out = bank->CoolantFractionOf();
return 1; return 1;
} }
//===========================================================================//
// BTReportHeatAtReset -- #137 forensic (ungated when BT_HEAT_LOG is set).
//
// The [heat-t] census runs on a 5-second per-instance timer, which is far too
// coarse to answer the question #137 actually poses: "respawn came back with
// MYOMERS heat MAXED". Is the temperature high BECAUSE the reset did not
// clear it, or because it climbs again within the first second? Those need a
// sample taken AT the reset, which is what this is. Called from Mech::Reset
// immediately after the subsystem sweep, so every heat-bearing subsystem
// reports the temperature the reset actually left it at.
//===========================================================================//
void BTReportHeatAtReset(void *mech_v)
{
if (mech_v == 0 || getenv("BT_HEAT_LOG") == 0)
return;
Entity *mech = (Entity *)mech_v;
const int count = mech->GetSubsystemCount();
for (int i = 0; i < count; ++i)
{
Subsystem *s = mech->GetSubsystem(i);
if (s == 0)
continue;
// UNFILTERED first: the earlier pass filtered on IsDerivedFrom(HeatSink)
// and reported no Condensers. That test rides a hand-built Derivation
// chain, so a false negative there is indistinguishable from "not in the
// roster" -- name every roster entry and say whether the test passed.
if (!s->IsDerivedFrom(*HeatableSubsystem::GetClassDerivations()))
{
DEBUG_STREAM << "[heat-reset] roster[" << i << "] "
<< (s->GetName() ? s->GetName() : "?")
<< " (not HeatSink-derived)" << "\n" << std::flush;
continue;
}
HeatableSubsystem *sink = (HeatableSubsystem *)s;
DEBUG_STREAM << "[heat-reset] " << (s->GetName() ? s->GetName() : "?")
<< " T=" << (float)sink->currentTemperature
<< "\n" << std::flush;
}
}
//===========================================================================//
// BTReportMyomerFreeze -- #137 forensic (BT_HEAT_LOG), the POST-reset trace.
//
// BTReportHeatAtReset samples AT the reset and showed every subsystem at
// T == startingTemperature, which is what made the reset path look innocent and
// got #137 wrongly closed. The field then proved the freeze is real (5 of 61
// respawns: throttle up, speedDemand pinned at 0) -- so the interesting window
// is the frames immediately AFTER the reset, which nothing was sampling.
//
// Three explanations survive the decomp read and only data separates them:
// (a) RESET DIDN'T TAKE -> temp is high right after the reset
// (b) STALE CACHE -> temp is at start but speedEffect is still 0
// (HeatSink::RTIS @004ad760 writes only bytes
// 0x114/0x12C/0x130/0x134/0x138/0x158/0x15C --
// it does NOT touch Myomers::speedEffect @0x31C,
// and Myomers::RTIS @004b8aa4 only chains to the
// PoweredSubsystem one, so the pre-death value
// survives until the myomers next ticks)
// (c) INSTANT RE-HEAT -> temp starts at start and climbs back at once
//
// Prints per myomers: temperature, its own speedEffect, and the mech-level MAX
// the mover actually multiplies by. Freeze == that MAX at 0.
//===========================================================================//
void BTReportMyomerFreeze(void *mech_v, const char *when)
{
if (mech_v == 0 || getenv("BT_HEAT_LOG") == 0)
return;
Entity *mech = (Entity *)mech_v;
extern Scalar BTMyomersSpeedEffectOf(void *subsystem);
const int count = mech->GetSubsystemCount();
Scalar best = -1.0f;
for (int i = 0; i < count; ++i)
{
Subsystem *s = mech->GetSubsystem(i);
if (s == 0)
continue;
// ROSTER-WIDE now (2026-08-09): the myomers-only version proved the
// myomers is being COOKED FROM OUTSIDE -- +2.3e9 of energy arrives in
// <=30 frames while its own integrator reads near-zero v (our Reset
// zeroes localVelocity, matching the binary's +0x1c4 zero-fill, so
// termKinetic/termAccel are dead at spawn). ConductHeat flow is
// bounded by deltaT, so an e9 slug through it demands a NEIGHBOUR at
// extreme temperature. Print EVERY heat-bearing subsystem's T plus
// the two cells T is actually derived from (heatEnergy @0x158,
// pendingHeat @0x1C8) to NAME that neighbour.
if (!s->IsDerivedFrom(*HeatableSubsystem::GetClassDerivations()))
continue;
HeatableSubsystem *h = (HeatableSubsystem *)s;
// Every Heatable-positive roster member in this game IS a HeatSink
// (the Watcher branch -- Torso/HUD/Gyro -- rides HeatWatcher, which is
// not HeatableSubsystem-derived), so the downcast for E/pend is safe.
// Deliberately NOT IsDerivedFrom(HeatSink): that hand-built chain
// returns false for Condenser (the night-13 trap).
HeatSink *hs = (HeatSink *)s;
Scalar f = BTMyomersSpeedEffectOf(s); // >= 0 only for a Myomers
if (f > best) best = f;
DEBUG_STREAM << "[myofreeze] " << when << " " << (s->GetName() ? s->GetName() : "?")
<< " T=" << h->currentTemperature
<< " E=" << hs->heatEnergy
<< " pend=" << hs->pendingHeat
<< " fail=" << h->failureTemperature;
if (f >= 0.0f)
{
DEBUG_STREAM << " speedEffect=" << f
<< ((f <= 1.0e-4f && h->currentTemperature < h->failureTemperature)
? " <<<< STALE (cold but zero)" : "");
}
DEBUG_STREAM << "\n" << std::flush;
}
if (best >= 0.0f)
DEBUG_STREAM << "[myofreeze] " << when << " CHAIN MAX=" << best
<< (best <= 1.0e-4f ? " <<<< FROZEN" : "") << "\n" << std::flush;
}
+9 -56
View File
@@ -52,40 +52,14 @@ struct HUDLayoutCheck
}; };
// //
// Tuning constants read as read-only float globals adjacent to the // Tuning constants observed as read-only float globals adjacent to the
// HudSimulation body. The VALUES BELOW ARE THE BINARY'S, read straight off // HudSimulation body (.rdata, recovered from section_dump.txt).
// the .rdata rows in reference/decomp/section_dump.txt [T1]:
// 4b7ec0 8be55dc3 0000403f 0000803f 0000c842
// 4b7ed0 00000000
// -> ec4 = 0.75f ec8 = 1.0f ecc = 100.0f ed0 = 0.0f
//
// CORRECTED 2026-08-08. Every entry here used to be a 0.0f/500.0f STAND-IN
// under a guessed name, and the names described the wrong mechanism: there is
// no "heat threshold for HUD page visibility" at 0x4b7ec4 -- ec4/ec8 are the
// fire-control LOCK damage limits, and "MaxTorsoSlew = 500.0f" at ed0 read the
// range-slide Abs() idiom backwards (ed0 is the ZERO; 500.0 is an immediate).
// The LIVE implementation of the lock rule and the range slide is mech4.cpp's
// targeting step, which had both thresholds right all along. These stay so
// the addresses resolve to the truth for the next reader.
// //
static const Point3D HudZeroVector(0.0f, 0.0f, 0.0f); // DAT_004e0f74/78/7c static const Point3D HudZeroVector(0.0f, 0.0f, 0.0f); // DAT_004e0f74/78/7c
// LOCK gate (a): your OWN HUD's host zone must be BELOW 75% damage, else static const Scalar SegmentTempLimit = 0.0f; // _DAT_004b7ec4 (heat threshold for HUD page visibility)
// fire-control lock is lost -- the `_DAT_004b7ec4 <= ownZone->damageLevel` static const Scalar TargetTempLimit = 0.0f; // _DAT_004b7ec8
// arm of HudSimulation. A shot-up cockpit drops to "target held, no lock". static const Scalar RangeBias = 0.0f; // _DAT_004b7ecc
static const Scalar LockOwnZoneDamageLimit = 0.75f; // _DAT_004b7ec4 static const Scalar MaxTorsoSlew = 500.0f; // _DAT_004b7ed0
// LOCK gate (b): the TARGETED zone must be below 1.0 damage (a whole-mech
// target checks zone 0), so a dead zone cannot be re-locked.
static const Scalar LockTargetZoneDamageLimit = 1.0f; // _DAT_004b7ec8
// Subtracted from RangeToTarget (@0x1EC) every frame while the timed flag
// @0x22C is set (timer @0x21C accumulates to @0x1D8, then both clear).
// NOT reconstructed in the port's targeting step -- tracked, not implemented.
static const Scalar RangeBias = 100.0f; // _DAT_004b7ecc
// The shared ZERO: the right-hand side of the Abs() idiom on the range slide
// (`dt * 500.0 <= 0.0` picks the sign) and of an `== 0.0f` test at @0x28C.
// The 500 m/s slide RATE is an immediate literal (0x43fa0000), never this.
static const Scalar HudZero = 0.0f; // _DAT_004b7ed0
// The decay FLOOR for horizontalTorsoOffset (@0x294) in FUN_004b7ed4. The
// decay RATE is the object's own @0x298, not a constant. (Value verified.)
static const Scalar FlickerFloor = 0.0f; // _DAT_004b7f90 static const Scalar FlickerFloor = 0.0f; // _DAT_004b7f90
// //
@@ -348,13 +322,9 @@ Logical
// (+0x100), sliding at 500 m/s (:5652-5670), default 1200.0 with no // (+0x100), sliding at 500 m/s (:5652-5670), default 1200.0 with no
// target; the compass Scalar @0x214 = yaw euler[0] + torso twist (:5676). // target; the compass Scalar @0x214 = yaw euler[0] + torso twist (:5676).
// 6. Torso-horizon slew: horizontalTorsoOffset (@0x294) is moved toward the // 6. Torso-horizon slew: horizontalTorsoOffset (@0x294) is moved toward the
// commanded torso heading, clamped to +/- horizontalLimit (@0x29C), then // commanded torso heading at up to MaxTorsoSlew (500/sec), clamped to
// written to the graphic at mech +0x36C. The flicker helper (@004b7ed4) // +/- horizontalLimit (@0x29C), then written to the graphic at
// damps the settle: it decays @0x294 toward ZERO at the object's own // mech +0x36C. The flicker helper (@004b7ed4) damps the settle.
// @0x298 (horizontalMovementPerSecond) x time_slice. (CORRECTED
// 2026-08-08: this used to read "at up to MaxTorsoSlew (500/sec)" -- the
// rate is that per-object field, and there is no 500 constant here. The
// 500 m/s belongs to the RANGE slide in step 5, as an immediate.)
// //
void void
HUD::HudSimulation(Scalar time_slice) HUD::HudSimulation(Scalar time_slice)
@@ -505,20 +475,3 @@ void
{ {
ResetToInitialState(reset_command != 0); // @004b77bc ResetToInitialState(reset_command != 0); // @004b77bc
} }
//
// BTSetHudFlickerActive -- complete-type bridge for the CONTROL-MODE switch
// (mechmppr.cpp treats the mech's subsystems as opaque pointers, so it cannot
// touch HUD members directly; same pattern as torso.cpp's BTGetTorsoTwistAddr).
//
// @004afbe0's BASIC arm ends in `*(mech+0x5b4 + 0x2a0) = 1` -- mech+0x5b4 is the
// HUD subsystem cache and +0x2A0 is flickerActive. Basic mode re-centres the
// torso, so the HUD horizon is kicked into its settle animation to follow it
// (UpdateFlicker @004b7ed4 decays horizontalTorsoOffset and reports whether it
// is still moving). The port never made this call.
//
void BTSetHudFlickerActive(Subsystem *hud)
{
if (hud != 0)
((HUD *)hud)->SetFlickerActive(1);
}
-6
View File
@@ -196,12 +196,6 @@
// Simulation Support // Simulation Support
// //
public: public:
// @0x2A0 -- raised by the CONTROL-MODE switch's BASIC arm
// (`*(mech+0x5b4 + 0x2a0) = 1`, @004afbe0) so the HUD horizon re-settles
// with the torso that Basic just re-centred. Reached from mechmppr via
// hud.cpp's BTSetHudFlickerActive bridge (that TU sees Subsystem*, not HUD).
void SetFlickerActive(int on) { Check(this); flickerActive = on; }
typedef void typedef void
(HUD::*Performance)(Scalar time_slice); (HUD::*Performance)(Scalar time_slice);
+11 -144
View File
@@ -510,25 +510,8 @@ void
{ {
return; return;
} }
// #142: duckState is the POSTURE the cockpit's crouch-symbol animation duckState = 1;
// reads -- L4GAUGE.CFG:5001 binds attribute 0x37 to a THREE-frame DEBUG_STREAM << "[duck] DuckRequest: duckState -> 1" << std::endl << std::flush;
// bduck.pcc strip, confirmed on screen as a duck animation:
// 0 = standing 1 = moving between 2 = crouched
//
// A bare 1 here is therefore exactly right, and is what the binary writes:
// it means "in transition", which is both the request AND the middle frame.
// The consumer (mech4.cpp) reads the parked leg alarm to decide DIRECTION
// -- parked means the pending move is a rise, not parked means a squat --
// and settles duckState to 0 or 2 when the clip finishes. No separate
// request cell, no toggle, no divergence from @0049fa00.
//
// (An earlier revision toggled 0<->1 here. That produced a two-pose snap,
// which is what the cockpit reported as "it lights up and sticks, no
// animation": frame 2 was never reachable.)
duckState = 1; // show the MIDDLE frame at once (the binary's write)
duckRequest = 1; // and remember that a move is pending (#142)
DEBUG_STREAM << "[duck] DuckRequest: duckState -> 1 (in transition)"
<< std::endl << std::flush;
} }
// //
@@ -1001,18 +984,7 @@ void
<< " pts in " << n << " sub-hits (scale=" << scale << ")" << " pts in " << n << " sub-hits (scale=" << scale << ")"
<< std::endl << std::flush; << std::endl << std::flush;
// #128 CORRECTION (2026-08-11): 0x50e590 is the AGGREGATE BANK's node extern Derivation *BTHeatSinkFamily(void); // heat.cpp (0x50e590)
// (0xBBE "HeatSinkBank"), NOT the HeatSink base family -- the identity
// task #9 proved live for the link-attach guard (heat.cpp:627, same
// binary test FUN_0041a1a4 vs 0x50e590). The old family-wide test made
// condensers, reservoirs, generators, sensors, weapons and MYOMERS
// rattle-eligible -- every one coolant-bearing, so a single over-floor
// ram sub-hit sprang an instant leak ("every single time"; Michael's
// three field rams all logged 'Myomers condition 2 SET', a subsystem
// the 1995 tag test can never select -- its authored 0.35 collision
// weight is DEAD DATA in the binary). Authentic eligible set:
// {HeatSinkBank, Gyroscope, Torso}. Only the bank carries coolant, so
// a 1995 ram is gyro/torso damage with at worst a bounded bank drip.
extern Derivation *BTGyroscopeFamily(void); // gyro.cpp (0x50fdc0) extern Derivation *BTGyroscopeFamily(void); // gyro.cpp (0x50fdc0)
extern Derivation *BTTorsoFamily(void); // torso.cpp (0x510b08) extern Derivation *BTTorsoFamily(void); // torso.cpp (0x510b08)
for (int i = 0; i < n; ++i) // @0x4a006b for (int i = 0; i < n; ++i) // @0x4a006b
@@ -1024,7 +996,7 @@ void
Subsystem *s = GetSubsystem(b); // roster @+0x128 Subsystem *s = GetSubsystem(b); // roster @+0x128
if (s == 0) if (s == 0)
continue; continue;
if (!s->IsDerivedFrom("HeatSinkBank") // @0x4a0092 (0x50e590, the BANK) if (!s->IsDerivedFrom(*BTHeatSinkFamily()) // @0x4a0092
&& !s->IsDerivedFrom(*BTGyroscopeFamily()) // @0x4a00b2 && !s->IsDerivedFrom(*BTGyroscopeFamily()) // @0x4a00b2
&& !s->IsDerivedFrom(*BTTorsoFamily())) // @0x4a00d2 && !s->IsDerivedFrom(*BTTorsoFamily())) // @0x4a00d2
continue; continue;
@@ -1043,30 +1015,6 @@ void
{ {
Check(message); Check(message);
// #150 -- the binary's ONCE-PER-DEATH latch (local_14 @0x4a0230 body,
// part_012.c:14625-14629). The kill report posts only on the 0->1 edge
// (!destroyedAtEntry && destroyed-now).
// CORRECTED by the #162 audit: a later salvo of the killing volley is NOT
// "inflicted-only" -- it emits type-0 Inflicted (shooter) AND type-1
// Received (victim, latch-independent, part_012.c:14736-14759); what the
// latch suppresses is the type-2 kill and the death tail. Full map:
// docs/KD_CHAIN_AUDIT_20260811.md.
//
// ⚠ PREDICATE (eject canary, 2026-08-11): the binary's local_14 samples
// FUN_0049fb54 = movementMode 2||9 (part_012.c:14174-14188) -- the MODE,
// not the alarm. The first cut here used the alarm-based
// IsMechDestroyed(), and the EJECT arm broke exactly the way #108's
// deathBlastArmed once did: EjectPilot raises graphicAlarm to 10 BEFORE
// dispatching its killBonus self-damage, so the handler entered reading
// "already destroyed" and the SUICIDE type-2 was suppressed -- a real
// punch-out charged the -500 cost but never the -(basis+tally) award,
// while ammo cook-off suicides (no alarm pre-write) scored correctly.
// Mode 2|9 is untouched by the eject's alarm write, so the mode form
// latches post-death volley rounds exactly as before AND lets the eject's
// suicide report through -- byte-faithful to the 1995 predicate.
const int destroyedAtEntry =
(MovementMode() == 2 || MovementMode() == 9); // FUN_0049fb54
// MP DIAGNOSTIC (task #47): confirm the handler runs on the OWNING MASTER // MP DIAGNOSTIC (task #47): confirm the handler runs on the OWNING MASTER
// for a cross-pod (network-delivered) TakeDamage + what state it carries. // for a cross-pod (network-delivered) TakeDamage + what state it carries.
if (getenv("BT_MP_NET")) if (getenv("BT_MP_NET"))
@@ -1122,31 +1070,7 @@ void
// Capturing it after the divert (where #89 first placed it) meant a // Capturing it after the divert (where #89 first placed it) meant a
// COLLISION death could never arm the tail. // COLLISION death could never arm the tail.
// //
// WAS-ALIVE-AT-ENTRY, on the BINARY's predicate (movementMode), not the const int deathBlastArmed = !IsMechDestroyed(); // [ebp-0x10], inverted
// graphic alarm. FIXED 2026-08-07 -- this substitution was the eject-ghost.
//
// The binary tests movementMode 9|10 here; the port used IsMechDestroyed()
// (graphicAlarm >= 9) and justified it with "the death transition sets mode
// 9 synchronously with the structural flag on every path through here, so
// the edges coincide". That is true of every DAMAGE path and false of the
// one that matters: Mech::EjectPilotMessageHandler raises graphicAlarm to
// 10 (the EJECT state) BEFORE dispatching its self-damage, while
// movementMode is still 1. So on an eject the handler entered already
// reading "destroyed", the latch never armed, and the whole death tail was
// skipped -- no VehicleDead, which IS the respawn trigger.
//
// Consequences, all three reported from the field on the same night:
// * the ejecting player never respawns ("panic button, didn't respawn");
// * the peer wrecks the mech and never un-wrecks it, because the un-wreck
// rides the master's respawn -> the permanent EJECT GHOST (#108);
// * the eject scores only its -500 self-damage: no negated kill award and
// no -500 death cost, because both live in the tail that never ran --
// which is why the manual chart's "-1000 ejecting" never materialised.
//
// MovementMode 9|10 is untouched by the eject's alarm write, so the latch
// now arms on the eject exactly as it does on a combat death.
const int deathBlastArmed =
!(MovementMode() == 9 || MovementMode() == 10); // [ebp-0x10], inverted (@0x4a0303)
// //
// The zone the reports + VehicleDead carry: msg+0x24 as of loop entry. // The zone the reports + VehicleDead carry: msg+0x24 as of loop entry.
// The binary never rewrites msg+0x24 after the initial cylinder resolve; // The binary never rewrites msg+0x24 after the initial cylinder resolve;
@@ -1277,31 +1201,11 @@ void
// vital-wreck flag, and the loop-ENTRY zone. This retires // vital-wreck flag, and the loop-ENTRY zone. This retires
// BTPostDamageScore/BTPostKillScore -- see the btplayer.cpp tombstone. // BTPostDamageScore/BTPostKillScore -- see the btplayer.cpp tombstone.
// //
// #150 DOUBLE-KILL FIX, two binary-grounded gates (2026-08-10):
//
// (1) MASTER ONLY. The binary OPENS this handler with the assertion
// `if ((flags & 0xc) == 4) Fail("Replicant Mech recieving takedam...",
// MECH.CPP, 0x3da)` (part_012.c:14613-14616) -- in 1995 a replicant
// executing TakeDamage was a BUG, so the report tail could only ever
// run on the victim's master, once. This port deliberately applies
// damage on replicants too (local visual responsiveness), so the tail
// ALSO ran there -- and the replicant's movement mode can't flip to
// wreck until the death record round-trips, so a multi-round killing
// volley posted a kill report PER ROUND inside that window. Measured:
// one death -> two type-2 arms 1ms apart, kills 0->2 (the field's
// "one kill counted twice", #150). Gate the REPORTS to the master;
// the local damage application stays.
//
// (2) ENTRY LATCH. The binary's newly-destroyed test is the 0->1 edge of
// IsMechDestroyed() across THIS handler call (local_14), not the
// movement-mode-derived deathBlastArmed -- on the master both agree,
// but the binary's form is the spec.
if (GetInstance() != ReplicantInstance)
{ {
extern void BTMechPostCombatReports(void *, void *, float, int, int, int); extern void BTMechPostCombatReports(void *, void *, float, int, int, int);
BTMechPostCombatReports((void *)this, (void *)message, BTMechPostCombatReports((void *)this, (void *)message,
(float)damageTally, zoneDestroyed, reportZone, (float)damageTally, zoneDestroyed, reportZone,
!destroyedAtEntry && IsMechDestroyed()); deathBlastArmed && IsMechDestroyed());
} }
} }
@@ -1357,54 +1261,18 @@ void
// falloff bursts/dist^1.25 per victim inside the shared core. // falloff bursts/dist^1.25 per victim inside the shared core.
// 4. ForceUpdate(1). // 4. ForceUpdate(1).
// The gate predicate here is IsMechDestroyed() (graphicAlarm >= 9, the // The gate predicate here is IsMechDestroyed() (graphicAlarm >= 9, the
// structural flag @0x49fb54) -- the binary tests movementMode 9|10, and // structural flag @0x49fb54) -- the binary tests movementMode 9|10, but
// sets mode 9 SYNCHRONOUSLY inside this same damage pass (its own tail // the death transition sets mode 9 synchronously with the structural flag
// gate part_012.c:14761 reads mode 9||10 already flipped). An earlier // on every path through here, so the edges coincide; this exact predicate
// comment here claimed our transition was synchronous too -- it was NOT // is the one the #89 blast benches verified both ways.
// (UpdateDeathState ran per-frame), and the 4-node retest caught the
// window: TWO lethal deliveries in one event drain (direct + splash of
// the same salvo, 2ms apart, no sim tick between) each read
// destroyedAtEntry=0 and re-ran the whole death block -- duplicate kill
// credit to the shooter (kills 1->2 in 2ms) and a duplicate VehicleDead
// (the receiver dedup swallowed the death cycle but the kill report had
// already been posted). The transition is now invoked synchronously
// below, closing the window exactly the way the binary does.
death_tail: death_tail:
// #162 (2026-08-11) -- MASTER ONLY: the discipline #150 restored for the if (deathBlastArmed && IsMechDestroyed())
// 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); extern void BTMechPostVehicleDead(void *, void *, int);
BTMechPostVehicleDead((void *)this, (void *)message, reportZone); BTMechPostVehicleDead((void *)this, (void *)message, reportZone);
extern void BTApplyDeathSplash(void *mech_v); extern void BTApplyDeathSplash(void *mech_v);
BTApplyDeathSplash((void *)this); BTApplyDeathSplash((void *)this);
// Run the once-per-death transition NOW (mode 9 + type-6 ForceUpdate +
// death effects), not next tick -- the binary flips mode inside this
// same pass, which is the entire same-frame double-delivery latch: the
// NEXT lethal message of this volley reads destroyedAtEntry=1 and
// deathBlastArmed=0, so it can neither re-credit the kill nor re-post
// VehicleDead. dt=0: the transition branch has no time dependence; the
// per-frame caller takes the wreck-maintenance branch from here on.
UpdateDeathState(0.0f);
} }
} }
@@ -1676,7 +1544,6 @@ Mech::Mech(
radarLinearPosition = &localOrigin.linearPosition; // map reads the mech's live world position... radarLinearPosition = &localOrigin.linearPosition; // map reads the mech's live world position...
radarAngularPosition= &localOrigin.angularPosition; // ...and orientation (pointers into the base origin) radarAngularPosition= &localOrigin.angularPosition; // ...and orientation (pointers into the base origin)
duckState = 0; // not crouching duckState = 0; // not crouching
duckRequest = 0; // no pending duck request (#142)
// (AUDIO_FIDELITY F7) missile alarm: the binary reset writes 0 / FLT_MAX // (AUDIO_FIDELITY F7) missile alarm: the binary reset writes 0 / FLT_MAX
// (part_012.c:9446-9447; FLT_MAX = "no missile" far default) // (part_012.c:9446-9447; FLT_MAX = "no missile" far default)
incomingLock = 0; incomingLock = 0;
+1 -9
View File
@@ -964,15 +964,7 @@ protected:
Scalar radarRange; // 0x2f RadarRange (scale/max) Scalar radarRange; // 0x2f RadarRange (scale/max)
Point3D *radarLinearPosition; // 0x30 RadarLinearPosition Point3D *radarLinearPosition; // 0x30 RadarLinearPosition
Quaternion *radarAngularPosition; // 0x31 RadarAngularPosition Quaternion *radarAngularPosition; // 0x31 RadarAngularPosition
int duckState; // 0x37 DuckState (crouch POSTURE the cockpit int duckState; // 0x37 DuckState (crouch posture)
// strip draws: 0 stand, 1 moving, 2 crouched)
// PORT-ONLY (#142): the pending duck REQUEST, kept separate from the
// posture above. duckState cannot carry both -- settling it to the
// real posture destroys the request, and the consumer then re-issues
// the opposite direction on the very frame the clip parks (benched:
// 69 squat/rise transitions from 2 presses). Not a binary field; it
// is appended, never read by offset.
int duckRequest;
// (AUDIO_FIDELITY F7) the incoming-missile alarm attributes. Binary // (AUDIO_FIDELITY F7) the incoming-missile alarm attributes. Binary
// Mech table [T1]: IncomingLock id 54 @0x3fc (Logical; authored match // Mech table [T1]: IncomingLock id 54 @0x3fc (Logical; authored match
// ==1 Start / ==0 Stop of the looped beeper), DistanceToMissile id 56 // ==1 Start / ==0 Stop of the looped beeper), DistanceToMissile id 56
+1 -51
View File
@@ -1246,10 +1246,6 @@ Scalar
Mech::AdvanceBodyAnimation(Scalar time_slice, int loop) Mech::AdvanceBodyAnimation(Scalar time_slice, int loop)
{ {
Scalar distance = 0.0f; Scalar distance = 0.0f;
// #52 probe (BT_BODY_SM_LOG): case 0 and the inserted turn block below run
// in the SAME invocation, so a plain local proves the arm->reset pair --
// no cross-frame state, no per-mech bookkeeping.
int armedFromStanding = 0;
// In the binary `bodyAnimationState`@0x728 IS `bodyStateAlarm`'s level (one field); // In the binary `bodyAnimationState`@0x728 IS `bodyStateAlarm`'s level (one field);
// the reconstruction split them, so SetBodyAnimation's `bodyStateAlarm.SetLevel(state)` // the reconstruction split them, so SetBodyAnimation's `bodyStateAlarm.SetLevel(state)`
@@ -1301,35 +1297,7 @@ Scalar
} }
SetBodyAnimation(0x10); SetBodyAnimation(0x10);
} }
// FALLTHROUGH -- into the ADVANCE GROUP, which is where the binary // FALLTHROUGH
// sends it. #52 SKATE ROOT CAUSE (2026-08-07): in FUN_004a5678 case 4
// is a MEMBER of the advance list (case 2,3,*4*,5,8,...), so a state
// just armed away from Standing lands on Advance(). The port's turn
// block below is an INSERTION (task #64 lockstep twin) and, sitting
// between case 0 and the advance group, it intercepted that fallthrough.
// On a REPLICANT that is fatal and not a race: case 0 arms walk iff
// `standSpeed < bodyTargetSpeed`, and the inserted block's exit tests
// `standSpeed < bspd` where bspd IS bodyTargetSpeed for a replicant --
// the SAME expression. Arm and reset therefore fire on the same frame,
// every frame, and a peer parked at Standing with a live replicated
// demand can never start cycling (reverse likewise: both sides test
// `< ZeroSpeed`). It cycles again only when a record sets the state
// directly (ReadUpdateRecord, mech.cpp) -- the observed self-recovery.
// Masters escape because their two tests read DIFFERENT cells
// (bodyTargetSpeed = last-sent vs the live mapper speedDemand) and
// because the body channel is mj=0 there, so its stall is invisible.
// Introduced by e91d447 (#82): before it the replicant branch read the
// dead mapper cell (0 forever), so the exit never fired and this
// fallthrough worked BY ACCIDENT. Fixing the dead cell closed the
// accidental escape hatch and the trn-lock skate came back as a
// Standing-lock skate. BT_NO_BODY_FALLTHRU=1 restores the old path.
armedFromStanding = (int)bodyStateAlarm.GetLevel();
{
static const int s_bodyFallthru = getenv("BT_NO_BODY_FALLTHRU") ? 0 : 1;
if (s_bodyFallthru)
goto advance_body_normally;
}
// FALLTHROUGH (legacy path only)
case 4: // TURN-IN-PLACE, LOCKSTEP twin (task #64) case 4: // TURN-IN-PLACE, LOCKSTEP twin (task #64)
// The body channel runs trn in LOCKSTEP with the leg: armed together at // The body channel runs trn in LOCKSTEP with the leg: armed together at
@@ -1356,23 +1324,6 @@ Scalar
: (bm != 0) ? bm->speedDemand : 0.0f; : (bm != 0) ? bm->speedDemand : 0.0f;
if (standSpeed < bspd || bspd < ZeroSpeed) // walk / reverse (leg-symmetric) if (standSpeed < bspd || bspd < ZeroSpeed) // walk / reverse (leg-symmetric)
{ {
// #52 probe: when this fires on a state case 0 JUST armed, the
// mech is being pushed straight back to Standing on the same
// frame it tried to leave it -- the Standing-lock. On a
// replicant `bspd` IS the same cell case 0 tested, so the pair
// is unconditional, not a race.
if (armedFromStanding != 0 && getenv("BT_BODY_SM_LOG"))
{
static float s_bsm = 0.0f; s_bsm += time_slice;
if (s_bsm >= 1.0f) { s_bsm = 0.0f;
DEBUG_STREAM << "[bodySM] " << (GetInstance() == ReplicantInstance
? "REPLICANT " : "master ")
<< GetEntityID() << " case0 armed " << armedFromStanding
<< " -> turn-block RESET to Standing bspd=" << (float)bspd
<< " bts=" << (float)bodyTargetSpeed
<< " standSpeed=" << (float)standSpeed
<< " (STANDING-LOCK)\n" << std::flush; }
}
bodyStateAlarm.SetLevel(0); bodyStateAlarm.SetLevel(0);
ForceUpdate(8); ForceUpdate(8);
distance = 0.0f; distance = 0.0f;
@@ -1396,7 +1347,6 @@ Scalar
case 2: case 3: case 5: case 8: case 9: case 10: case 0x0b: case 2: case 3: case 5: case 8: case 9: case 10: case 0x0b:
case 0x0e: case 0x0f: case 0x10: case 0x11: case 0x14: case 0x15: case 0x0e: case 0x0f: case 0x10: case 0x11: case 0x14: case 0x15:
case 0x1c: case 0x1d: case 0x1e: case 0x1f: case 0x20: case 0x1c: case 0x1d: case 0x1e: case 0x1f: case 0x20:
advance_body_normally: // case 0's fallthrough target (leg twin: advance_normally)
distance = bodyAnimation.Advance( // FUN_0042790c(this+0x6bc, ...) distance = bodyAnimation.Advance( // FUN_0042790c(this+0x6bc, ...)
time_slice * globalTimeScale * idleStrideScale, loop); time_slice * globalTimeScale * idleStrideScale, loop);
bodyCycleSpeed = distance / time_slice; // this+0x6b8 bodyCycleSpeed = distance / time_slice; // this+0x6b8
+38 -588
View File
@@ -666,7 +666,6 @@ static int gBTPPCKey = 0;
static int gBTMissileKey = 0; static int gBTMissileKey = 0;
static int gBTPinkyKey = 0; // key '4' = the pod's 4th fire button (Pinky 0x45) static int gBTPinkyKey = 0; // key '4' = the pod's 4th fire button (Pinky 0x45)
int gBTModeCycle = 0; // 'M' edge: cycle the control mode (mapper consumes) int gBTModeCycle = 0; // 'M' edge: cycle the control mode (mapper consumes)
int gBTMyoTrace = 0; // #137: frames of post-reset myomer tracing left (armed by Mech::Reset)
int gBTDisplayCycle = 0; // 'N' edge: cycle the secondary schematic (Gitea #6, mapper consumes) int gBTDisplayCycle = 0; // 'N' edge: cycle the secondary schematic (Gitea #6, mapper consumes)
int gBTPresetCycle[3] = {0,0,0}; // J/K/L edges: cycle an upper-MFD preset page (Gitea #9, L4 mapper consumes) int gBTPresetCycle[3] = {0,0,0}; // J/K/L edges: cycle an upper-MFD preset page (Gitea #9, L4 mapper consumes)
// //
@@ -791,26 +790,8 @@ void
EntitySegment *seg = m->GetSegment(segIndex); // owner+0x300 table, GetNth(index) EntitySegment *seg = m->GetSegment(segIndex); // owner+0x300 table, GetNth(index)
if (seg != 0) if (seg != 0)
{ {
// #141 -- THE BINARY GOES THROUGH FUN_00424da8, AND SO MUST WE. AffineMatrix mw;
// @004b9948 ends in `FUN_00424da8(owner, segment, out)`, which is mw.Multiply(seg->GetSegmentToEntity(), m->localToWorld); // segment -> world (== mech4 gun-port path)
// JointedMover::GetSegmentToWorld instruction-for-instruction:
// iVar1 = FUN_00417ab4(param_1 + 0x31c); // GetJointSubsystem()
// if (*(int *)(iVar1 + 0xfc) != 0) { // AreJointsModified()
// ...walk owner+0x300 setting seg+0xc = 1... // ModifySegment()
// *(int *)(iVar1 + 0xfc) = 0; // ModifyJoints(False)
// }
// FUN_0040b104(out, FUN_004244dc(seg), owner+0xd0); // x localToWorld
// So in the 1995 image EVERY muzzle query performs the joints->segments
// refresh. This port hand-composed GetSegmentToEntity() x localToWorld
// and skipped it -- and GetSegmentToEntity only recomputes when
// segmentModified is already set (SEGMENT.cpp:262), so it returned a
// stale cache. On the MASTER that was invisible (the render pass
// refreshes the local mech every frame); on a REPLICANT nothing did, so
// peer muzzles sat at the BIND POSE -- the missile launched along the
// leg facing (#141). Use the engine accessor; do NOT force the dirty
// flag, the binary does not.
LinearMatrix mw;
m->GetSegmentToWorld(*seg, &mw);
out = mw; // Point3D = matrix W_Axis translation out = mw; // Point3D = matrix W_Axis translation
} }
else else
@@ -844,9 +825,8 @@ struct BTProjectile {
Point3D pos; Point3D pos;
Vector3D vel; // world velocity (authored MuzzleVelocity, steered per frame) Vector3D vel; // world velocity (authored MuzzleVelocity, steered per frame)
Scalar speed; // |vel| held constant through the steer Scalar speed; // |vel| held constant through the steer
Scalar age; // flight seconds since launch (#168) Scalar traveled;
Scalar ttl; // authentic lifetime cap (s): 5.0 plain shell (@004bddec), Scalar range;
// BurnTime+10.0 missile (@004bef78) -- see the launch site
Entity *target; Entity *target;
Point3D targetPos; Point3D targetPos;
Scalar aimOffsetY; // vertical aim offset vs the target's origin (live re-lead) Scalar aimOffsetY; // vertical aim offset vs the target's origin (live re-lead)
@@ -1064,13 +1044,8 @@ int
{ {
if (seg->GetIndex() == seg_index) if (seg->GetIndex() == seg_index)
{ {
// #141 sweep: go through the engine accessor, which is the AffineMatrix mw;
// binary's FUN_00424da8 (the joints->segments refresh). A hand mw.Multiply(seg->GetSegmentToEntity(), m->localToWorld);
// composed GetSegmentToEntity() x localToWorld reads a STALE
// cache on any mech whose segments were not refreshed this frame
// -- i.e. every REPLICANT. See BTResolveWeaponMuzzle.
LinearMatrix mw;
m->GetSegmentToWorld(*seg, &mw);
p = mw; // Point3D = matrix translation p = mw; // Point3D = matrix translation
break; break;
} }
@@ -1474,8 +1449,6 @@ void
? (mz.y - ((Mech *)shooter)->localOrigin.linearPosition.y) : mz.y; ? (mz.y - ((Mech *)shooter)->localOrigin.linearPosition.y) : mz.y;
DEBUG_STREAM << "[projectile] PUSH target=" << (void*)target DEBUG_STREAM << "[projectile] PUSH target=" << (void*)target
<< " len=" << len << " speed=" << speed << " dmg=" << damage << " len=" << len << " speed=" << speed << " dmg=" << damage
<< " guided=" << guided
<< " ttl=" << (guided ? ((thrust_burn > 0.0f ? thrust_burn : 0.0f) + 10.0f) : 5.0f)
<< " mz=(" << mz.x << "," << mz.y << "," << mz.z << ") relY=" << relY << " mz=(" << mz.x << "," << mz.y << "," << mz.z << ") relY=" << relY
<< " lv=" << (launch_velocity << " lv=" << (launch_velocity
? "(auth)" : "(fallback)") << "\n" << std::flush; ? "(auth)" : "(fallback)") << "\n" << std::flush;
@@ -1520,57 +1493,8 @@ void
EntitySegment *seg = sm->GetSegment(muzzle_seg); EntitySegment *seg = sm->GetSegment(muzzle_seg);
if (seg != 0) if (seg != 0)
{ {
// #141 -- USE THE ENGINE ACCESSOR, not a hand-rolled AffineMatrix mw;
// GetSegmentToEntity() x localToWorld. mw.Multiply(seg->GetSegmentToEntity(), sm->localToWorld);
//
// EntitySegment::GetSegmentToEntity (SEGMENT.cpp:262)
// recomputes ONLY when `segmentModified` is set; otherwise it
// returns the CACHED matrix. The thing that sets that flag
// after a joint moves is JointedMover::GetSegmentToWorld
// (JMOVER.cpp:136-146): it tests AreJointsModified() and, when
// set, marks EVERY segment dirty and clears the flag. Compose
// the matrix by hand and you skip that entirely -- you read
// whatever cache happens to be sitting there.
//
// On the MASTER that was invisible: the renderer / cockpit
// camera call GetSegmentToWorld for the LOCAL mech every frame,
// so the cache was already fresh when we fired. A REPLICANT
// gets no such refresh, so its cache stayed at the BIND POSE
// and the torso twist never reached the launch frame.
//
// Measured (scratchpad/night13/missileframe.sh, 165 salvos
// mirrored 1:1): master |twistDelta| max 2.2962 / mean 1.2283,
// 100% > 0.1 rad -- REPLICANT max 0.0000, mean 0.0000, 0%,
// with segResolved=1 and segYaw == bodyYaw EXACTLY, while that
// same peer's copy torso was demonstrably writing its joint
// (`PushTwist COPY ... twist=-1.49601`) off correctly
// replicated records (`cur=-2.13987 target=-2.13987 copy=1`).
// Twist arrived, joint moved, segment cache never refreshed.
// That is #141: "missiles launch along the LEG/FOOT facing,
// then curve to the target -- peer POV only".
// FORCE the recompute. GetSegmentToWorld only refreshes when
// AreJointsModified() is set, and by fire time the frame's
// renderer/camera pass has already consumed and cleared that
// flag on BOTH nodes (measured: jointsDirty=0 master AND peer).
// On the master the cache it left behind is correct, because
// that pass ran AFTER the local torso pushed its joint. On a
// replicant the cache is stale, so seg 18 returned its
// bind-pose matrix (segYaw == bodyYaw EXACTLY) even though the
// hierarchy is identical -- same parentIdx 4, same non-null
// parent + joint subsystem. Setting the flag makes
// GetSegmentToWorld mark every segment dirty so the whole
// chain re-derives from the CURRENT joint angles. Costs one
// segment-table walk per salvo.
// NO forced dirty flag here. An earlier pass set
// ModifyJoints(True) before this read; it bought 64% of
// salvos but it is NOT what the binary does -- @00424da8
// tests AreJointsModified() and never sets it. The authentic
// refresh happens in the MUZZLE query (GetMuzzlePoint ->
// @00424da8), which the launcher calls just above this, so by
// the time we compose the launch frame the segment cache is
// already current. See BTResolveWeaponMuzzle.
LinearMatrix mw;
sm->GetSegmentToWorld(*seg, &mw);
mw.GetFromAxis(X_Axis, &ax); mw.GetFromAxis(X_Axis, &ax);
mw.GetFromAxis(Y_Axis, &ay); mw.GetFromAxis(Y_Axis, &ay);
mw.GetFromAxis(Z_Axis, &az); mw.GetFromAxis(Z_Axis, &az);
@@ -1583,55 +1507,6 @@ void
sm->localToWorld.GetFromAxis(Y_Axis, &ay); sm->localToWorld.GetFromAxis(Y_Axis, &ay);
sm->localToWorld.GetFromAxis(Z_Axis, &az); sm->localToWorld.GetFromAxis(Z_Axis, &az);
} }
// #141 DIAGNOSTIC (BT_PROJ_LOG). The peer-POV report is that the
// round leaves along the LEG facing, ignoring torso twist, while the
// shooter's own view is correct. Both nodes pass GetSegmentIndex()
// as the mount frame, so if this is real the difference is whether
// the SEGMENT actually carries the twist on a replicant. Print the
// frame we launched through on BOTH sides: twistDelta is the yaw of
// the launch forward vs the BODY forward, so it should equal the
// torso twist on the master and MUST match on the replicant. A
// replicant reading ~0 while the master reads non-zero IS the bug.
if (getenv("BT_PROJ_LOG"))
{
UnitVector bz;
sm->localToWorld.GetFromAxis(Z_Axis, &bz);
const float kPi = 3.14159265f;
float segYaw = atan2f(-(float)az.x, -(float)az.z);
float bodyYaw = atan2f(-(float)bz.x, -(float)bz.z);
float dYaw = segYaw - bodyYaw;
while (dYaw > kPi) dYaw -= 2.0f * kPi;
while (dYaw < -kPi) dYaw += 2.0f * kPi;
// #141 probe 2: GetSegmentToEntity only RECOMPUTES when
// (segmentModified && parentSegment). A null parent means it can
// never recompute -- it returns the bind-pose baseOffset forever,
// which would read as segYaw == bodyYaw exactly. Print the
// hierarchy + joint-dirty state so master and peer can be diffed.
EntitySegment *pseg = (muzzle_seg >= 0) ? sm->GetSegment(muzzle_seg) : 0;
const void *parent = (pseg != 0) ? (const void *)pseg->GetParent() : 0;
int parentIdx = (pseg != 0) ? pseg->GetParentIndex() : -99;
JointSubsystem *js = sm->GetJointSubsystem();
// #148: the SHOOTER's live torso twist AT THIS INSTANT. The
// [torso-copy] probe samples every 120th call, so its "first
// non-zero" tells you when it first SAMPLED, not when the twist
// started -- that is exactly the artifact that made the earlier
// "the peer had no twist to carry" reading look right. Read the
// cell directly instead, so twistDelta and the twist that should
// be driving it are on the SAME line.
extern Scalar *BTGetTorsoTwistAddr(Subsystem *torso);
Scalar *twp = BTGetTorsoTwistAddr(sm->GetTorsoSubsystem());
DEBUG_STREAM << "[launchframe] "
<< (sm->GetInstance() == Entity::ReplicantInstance
? "REPLICANT" : "master ")
<< " liveTwist=" << (twp != 0 ? (float)*twp : -99.0f)
<< " seg=" << muzzle_seg << " segResolved=" << haveFrame
<< " segYaw=" << segYaw << " bodyYaw=" << bodyYaw
<< " twistDelta=" << dYaw
<< " parent=" << parent << " parentIdx=" << parentIdx
<< " joints=" << (void *)js
<< " jointsDirty=" << (js != 0 ? (int)js->AreJointsModified() : -1)
<< "\n" << std::flush;
}
p.vel.x = ax.x*launch_velocity->x + ay.x*launch_velocity->y - az.x*launch_velocity->z; p.vel.x = ax.x*launch_velocity->x + ay.x*launch_velocity->y - az.x*launch_velocity->z;
p.vel.y = ax.y*launch_velocity->x + ay.y*launch_velocity->y - az.y*launch_velocity->z; p.vel.y = ax.y*launch_velocity->x + ay.y*launch_velocity->y - az.y*launch_velocity->z;
p.vel.z = ax.z*launch_velocity->x + ay.z*launch_velocity->y - az.z*launch_velocity->z; p.vel.z = ax.z*launch_velocity->x + ay.z*launch_velocity->y - az.z*launch_velocity->z;
@@ -1648,18 +1523,8 @@ void
{ {
p.vel.x = d.x/len*p.speed; p.vel.y = d.y/len*p.speed; p.vel.z = d.z/len*p.speed; p.vel.x = d.x/len*p.speed; p.vel.y = d.y/len*p.speed; p.vel.z = d.z/len*p.speed;
} }
p.age = 0.0f; p.traveled = 0.0f;
// AUTHENTIC lifetime (#168). The old cap (len*1.3+60, distance-to-target) p.range = len * 1.3f + 60.0f; // arc margin; expire past the target
// was a port invention: the binary never range-caps a round in flight.
// The plain Projectile lives exactly 5.0s (@004bddec tail:
// age > _DAT_004be054=5.0 -> FUN_0042061c kill) and the Missile lives
// BurnTime+10.0s (@004bef78 tail: age > burnTime(+0x340) +
// _DAT_004bf5ac=10.0), plus a kill-plane at y < -1 (_DAT_004bf5b0).
// WeaponRange is FIRE-CONTROL data only (effectiveRange @0x328) -- the
// trigger itself has no range gate (@004bcc60 reads no +0x328), so a
// shell landing far beyond its listed range is authentic arcade behavior.
p.ttl = guided ? ((thrust_burn > 0.0f ? thrust_burn : 0.0f) + 10.0f)
: 5.0f;
p.target = (Entity *)target; p.target = (Entity *)target;
p.targetPos = tpos; // resolved target position (fallback-aware) p.targetPos = tpos; // resolved target position (fallback-aware)
// live re-lead (authentic: the Seeker re-leads the MOVING target every // live re-lead (authentic: the Seeker re-leads the MOVING target every
@@ -1737,14 +1602,11 @@ static void
// squared-error guidance: rotate the velocity toward the lofted aim at // squared-error guidance: rotate the velocity toward the lofted aim at
// the decomp turn gain (MissileTurnGain = 4.0, _DAT_004bf5a4), speed // the decomp turn gain (MissileTurnGain = 4.0, _DAT_004bf5a4), speed
// held at the authored launch speed. // held at the authored launch speed.
// LIVE TARGET TRACKING -- for EVERY round kind, not just guided (#168 if (p.guided)
// audit). The contact sphere below is the port's stand-in for the {
// binary's real-geometry collision (FUN_0042291c hits the mech's BODY // LIVE RE-LEAD (authentic: Seeker::LeadTarget runs every slice on
// wherever it is NOW); testing it against the fire-time pick point let // the MOVING target): refresh the aim from the target's current
// an unguided shell "contact" empty air after the target moved and // position, preserving the launch aim's body height.
// still dispatch full damage to the departed mech -- a locked
// autocannon could not miss a moving target. Track the entity for the
// contact test; only GUIDED rounds also steer onto it.
extern int BTIsRegisteredMech(Entity *e); extern int BTIsRegisteredMech(Entity *e);
if (p.target != 0 && BTIsRegisteredMech(p.target) if (p.target != 0 && BTIsRegisteredMech(p.target)
&& !((Mech *)p.target)->IsMechDestroyed()) && !((Mech *)p.target)->IsMechDestroyed())
@@ -1752,8 +1614,7 @@ static void
p.targetPos = ((Mech *)p.target)->localOrigin.linearPosition; p.targetPos = ((Mech *)p.target)->localOrigin.linearPosition;
p.targetPos.y += p.aimOffsetY; p.targetPos.y += p.aimOffsetY;
} }
if (p.guided)
{
Point3D aim = p.targetPos; Point3D aim = p.targetPos;
Scalar rx = aim.x - p.pos.x, ry = aim.y - p.pos.y, rz = aim.z - p.pos.z; Scalar rx = aim.x - p.pos.x, ry = aim.y - p.pos.y, rz = aim.z - p.pos.z;
Scalar range = (Scalar)sqrtf(rx*rx + ry*ry + rz*rz); Scalar range = (Scalar)sqrtf(rx*rx + ry*ry + rz*rz);
@@ -1806,7 +1667,7 @@ static void
p.speed = ns; p.speed = ns;
} }
p.pos.x += p.vel.x*dt; p.pos.y += p.vel.y*dt; p.pos.z += p.vel.z*dt; p.pos.x += p.vel.x*dt; p.pos.y += p.vel.y*dt; p.pos.z += p.vel.z*dt;
p.age += dt; p.traveled += p.speed * dt;
// WORLD IMPACT (authentic: the binary missile runs a world collision // WORLD IMPACT (authentic: the binary missile runs a world collision
// query every frame, FUN_0042291c, and DETONATES on geometry). Ray the // query every frame, FUN_0042291c, and DETONATES on geometry). Ray the
@@ -1911,13 +1772,10 @@ static void
contactD2 = cx*cx + cy*cy + cz*cz; contactD2 = cx*cx + cy*cy + cz*cz;
} }
const int contact = (contactD2 < (10.0f*10.0f)); const int contact = (contactD2 < (10.0f*10.0f));
if (!contact && (p.age >= p.ttl || (p.guided && p.pos.y < -1.0f))) if (!contact && p.traveled >= p.range)
{ {
if (getenv("BT_PROJ_LOG")) if (getenv("BT_PROJ_LOG"))
DEBUG_STREAM << "[projectile] FIZZLE t=" << p.age DEBUG_STREAM << "[projectile] FIZZLE (flight cap, no contact)\n" << std::flush;
<< " ttl=" << p.ttl << " guided=" << p.guided
<< ((p.guided && p.pos.y < -1.0f) ? " KILLPLANE" : "")
<< "\n" << std::flush;
p.active = 0; p.active = 0;
continue; continue;
} }
@@ -2180,11 +2038,8 @@ void
{ {
if (seg->GetIndex() == segment_index) if (seg->GetIndex() == segment_index)
{ {
// #141 sweep: engine accessor (== the binary's FUN_00424da8), AffineMatrix mw;
// not a hand-composed product -- otherwise a peer's damage mw.Multiply(seg->GetSegmentToEntity(), mech->localToWorld);
// effect anchors to the BIND-POSE segment.
LinearMatrix mw;
mech->GetSegmentToWorld(*seg, &mw);
fxPos = mw; // Point3D = matrix translation fxPos = mw; // Point3D = matrix translation
break; break;
} }
@@ -2291,39 +2146,6 @@ void
worldLinearVelocity = Vector3D(0.0f, 0.0f, 0.0f); worldLinearVelocity = Vector3D(0.0f, 0.0f, 0.0f);
localVelocity = Motion::Identity; localVelocity = Motion::Identity;
frameEntryWorldVelocity = Vector3D(0.0f, 0.0f, 0.0f); frameEntryWorldVelocity = Vector3D(0.0f, 0.0f, 0.0f);
// Binary zero-fills restored (Reset @0049fb74 zero-fills FOUR Motion cells:
// +0x1c4/+0x1dc/+0x298/+0x2c8; +0x1dc = localAcceleration by the engine
// Mover layout). NOTE: zeroing these did NOT fix #137 on its own -- the
// acceleration snapshot is REBUILT from a position finite-difference one
// frame later (see accelPrevPos below), so the stale-carryover story first
// written here was wrong. The zero-fills stay because the binary does them.
localAcceleration = Motion::Identity; // binary +0x1dc zero-fill
worldLinearAcceleration = Vector3D(0.0f, 0.0f, 0.0f); // world-space mirror
// #137 ROOT CAUSE -- the DROPPED RE-SEED. The binary Reset's SECOND
// instruction is FUN_00408440(mech+0x58c, param_2): re-seed the Point3D at
// +0x58c to the NEW ORIGIN. +0x58c is the previous-position memory of the
// AccelerationLastFrame ring feed (+0x81c/0x824/0x828/0x82c -- the F19
// block below, port member accelPrevPos). This port reconstructed the
// ring (ctor part_012.c:9836-9840, derivative :15169-15195) but its Reset
// never got the +0x58c line -- so the first post-respawn sample computed
// |newPos - accelPrevPos| / dt = TELEPORT DISTANCE / dt ~ 1e5
// into the velocity ring, and the ring-mean derivative turned that into an
// AccelerationLastFrame spike of 3e4..2.4e5 (pure forward/-z; with a +z
// ECHO ~15 frames later as the garbage sample rotates out of the mean).
// The myomer drive-heat integrator (@004b8d18) then computed
// termAccel = (1-accEff) * |v| * |a| * mass * dt
// with |a|~1e5 while the gait re-published |v|~40 under a still-held
// throttle: ONE tick deposited ~2.7e9 into pendingHeat -> heatEnergy,
// snapping the freshly-reset myomers from T=77 to T~9000 (failT=2000) ->
// speedEffect 0 -> speedDemand *= 0 -> "respawned unable to move until it
// cools" (#137). Measured: top deposits 3.35e9/3.29e9/3.05e9, every one
// 3-4 log lines after a Mech::Reset, aXYZ pure z, master mech.
// Why ~8% in the field: the deposit needs |v| in the SAME 1-2 frames, so
// only pilots whose throttle is still forward at the respawn (physical
// lever / HOTAS -- exactly who reported it) get the freeze; idle-throttle
// respawns read v~0 and deposit nothing.
accelPrevPos = origin.linearPosition; // binary +0x58c re-seed
ramLastVictim = 0; ramLastVictim = 0;
ramContactLinger = 0.0f; ramContactLinger = 0.0f;
// StopAllEntityEffects (@004d0c14): a respawned mech must not trail its // StopAllEntityEffects (@004d0c14): a respawned mech must not trail its
@@ -2350,26 +2172,6 @@ void
poseSyncLatch = 0; // @0x77c poseSyncLatch = 0; // @0x77c
graphicAlarm.SetLevel(0); // clear >=9 (the vital-kill trigger) graphicAlarm.SetLevel(0); // clear >=9 (the vital-kill trigger)
// --- STAND THE MECH UP. Restored 2026-08-07 (#142, Oracle: "crouch wasn't
// resetting on respawn"). These are in the binary's own Reset sweep and the
// port had dropped all of them, so a pilot who died CROUCHED respawned
// crouched -- leg parked in 'sqd', the cockpit strip still showing the
// up-arrow "press to rise" frame. Verbatim from FUN_0049fb74:
// *(this+0x398) = 0 duckState -- not crouching
// FUN_0041bbd8(this+0x39c, 0) legStateAlarm -> 0 (standing)
// FUN_0041bbd8(this+0x714, 0) bodyStateAlarm -> 0 (standing)
// *(this+0x650/0x654/0x658) = 0 death + leg/body reset latches
// *(this+0x5ac) = 1.0f idleStrideScale
duckState = 0; // @0x398
duckRequest = 0; // port-only pending flag (#142)
legStateAlarm.SetLevel(0); // @0x39c -- stand
bodyStateAlarm.SetLevel(0); // @0x714 -- stand
stabilityAlarm.SetLevel(1); // risen (the rise path's value)
deathAnimationLatched = 0; // @0x650
legResetLatch = 0; // @0x654
bodyResetLatch = 0; // @0x658
idleStrideScale = 1.0f; // @0x5ac = 0x3f800000
// --- HEAL every damage zone: full structure, intact skin, no burning --- // --- HEAL every damage zone: full structure, intact skin, no burning ---
for (int z = 0; z < damageZoneCount; ++z) for (int z = 0; z < damageZoneCount; ++z)
{ {
@@ -2427,62 +2229,6 @@ void
BTRecomputeCondenserValves((Entity *)this); BTRecomputeCondenserValves((Entity *)this);
} }
// #137 forensic: sample every heat-bearing subsystem's temperature AT the
// reset, so "respawn came back with heat MAXED" can be split into "the
// reset did not clear it" vs "it climbs again immediately".
{
extern void BTReportHeatAtReset(void *mech_v);
BTReportHeatAtReset((void *)this);
// #137: sample the myomers AT the reset, and arm the POST-reset trace.
// Sampling only here is what got this bug wrongly closed -- every
// subsystem reads T == start at this instant, which looks innocent.
// The freeze shows up in the frames AFTER.
extern void BTReportMyomerFreeze(void *mech_v, const char *when);
extern int gBTMyoTrace;
BTReportMyomerFreeze((void *)this, "at-reset");
gBTMyoTrace = 240; // ~4 s of post-reset frames
}
// --- DESKTOP THROTTLE RELEASE (#146) -- PORT LAYER, desktop-only ---------
// The pod's throttle is a PHYSICAL lever and the binary deliberately
// leaves it alone across a respawn: Reset's subsystem loop starts at
// index 2 and the ControlsMapper is index 0, so a pod pilot comes back
// under whatever power their hand is still holding. Authentic; it stays.
//
// The desktop bridge only EMULATES that lever, with the persistent ramp
// accumulator sLever below -- and a pad/keyboard pilot is physically
// holding nothing, with the lever position invisible to them. So they
// respawned at speed for no reason they could see, and the mech earned a
// real heat load straight out of the drop zone (myomers -> Condenser5 ->
// GeneratorD all tripping Overheating inside 1-2s). That is the true
// cause of the "#137 respawn came back with heat MAXED" reports -- the
// reset itself was always clean. Field-diagnosed from Sauron's
// 2026-08-06 log, which reads thr=1 / cycleSpeed=14.6 AT the reset.
//
// Reuse the existing all-stop path instead of touching sLever directly:
// it already zeroes the lever AND clears the zero-crossing detent, and it
// lives inside the virtual-controls block that owns that state.
//
// LOCAL VIEWPOINT MECH ONLY -- gBTDrive is the local bridge's state and
// Reset also runs for replicants, so an ungated write here would all-stop
// the player every time a REMOTE mech respawned. (Same guard idiom as
// the isPlayerMech test in PerformAndWatch.) Pod-safe besides: with a RIO
// present mechmppr's key bridge is off (BTRIODevicePresent, mechmppr.cpp
// :672) and gBTDrive.throttle is never read at all.
// BT_NO_RESPAWN_THROTTLE_RELEASE=1 reverts.
if (application != 0 && (Entity *)this == application->GetViewpointEntity())
{
static const int s_releaseThrottle =
getenv("BT_NO_RESPAWN_THROTTLE_RELEASE") ? 0 : 1;
if (s_releaseThrottle)
{
gBTDrive.allStop = 1;
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[respawn] desktop throttle released (all-stop queued)\n"
<< std::flush;
}
}
// --- locomotion pre-run + interest gates (a reset master must tick) --- // --- locomotion pre-run + interest gates (a reset master must tick) ---
SetPreRunFlag(); SetPreRunFlag();
if (interestCount == 0) interestCount = 1; if (interestCount == 0) interestCount = 1;
@@ -2651,14 +2397,6 @@ void
// (wreck shape: alarms/state settle, ammo // (wreck shape: alarms/state settle, ammo
// bins do NOT refill the corpse) // bins do NOT refill the corpse)
} }
// #137 forensic: roster heat state right AFTER the death shutdown sweep --
// whatever is still hot here is what the wreck period starts from, and a
// member that stays hot through BOTH sweeps is the conduction source that
// cooks the fresh myomers after the respawn.
{
extern void BTReportMyomerFreeze(void *mech_v, const char *when);
BTReportMyomerFreeze((void *)this, "at-death");
}
// Request the DEATH record BEFORE entering the disabled state (the // Request the DEATH record BEFORE entering the disabled state (the
// ForceUpdate filter masks types 2..8 once IsDisabled) -- the binary // ForceUpdate filter masks types 2..8 once IsDisabled) -- the binary
// death sender is Force(1) + Force(0x40) (@0x4aab2f/@0x4aab3a). The // death sender is Force(1) + Force(0x40) (@0x4aab2f/@0x4aab3a). The
@@ -2774,27 +2512,6 @@ volatile float gBTReplRenderYaw = -999.0f;
void void
Mech::PerformAndWatch(const Time& till, MemoryStream *update_stream) Mech::PerformAndWatch(const Time& till, MemoryStream *update_stream)
{ {
// #148 probe: one-shot per mech, the FIRST time this mech's per-frame
// performance runs. Every other receipt in this file is anonymous, so
// master and replicant lines are indistinguishable in a 2-node log -- which
// is exactly what made the "when does the peer torso start ticking?" search
// go in circles. Name the mech.
if (getenv("BT_NET_TRACE"))
{
static const Mech *s_seen[16]; static int s_seenN = 0;
int known = 0;
for (int si = 0; si < s_seenN; ++si) if (s_seen[si] == this) { known = 1; break; }
if (!known && s_seenN < 16)
{
s_seen[s_seenN++] = this;
DEBUG_STREAM << "[perf-first] mech " << GetEntityID()
<< " instance=" << (GetInstance() == Entity::ReplicantInstance
? "REPLICANT" : "master")
<< " this=" << (const void *)this
<< " subsysCount=" << subsystemCount << "\n" << std::flush;
}
}
// Frame time slice from the simulation clock (same idiom as Mover::Perform). // Frame time slice from the simulation clock (same idiom as Mover::Perform).
Scalar dt = till - lastPerformance; Scalar dt = till - lastPerformance;
lastPerformance = till; lastPerformance = till;
@@ -3594,23 +3311,9 @@ void
sEjAt = (e && *e) ? atoi(e) : -1; sEjAt = (e && *e) ? atoi(e) : -1;
} }
++sEjFrame; ++sEjFrame;
// Receipt: five separate rigs failed to fire a punch-out
// and it was never established whether this hook is even
// REACHED. Announce once a second while armed.
if (sEjAt > 0)
{
static int sEjLog = 0;
if ((++sEjLog % 60) == 0)
DEBUG_STREAM << "[ejecttest] armed at " << sEjAt
<< ", frame " << sEjFrame << "\n" << std::flush;
}
if (sEjAt > 0 && sEjFrame >= sEjAt if (sEjAt > 0 && sEjFrame >= sEjAt
&& ((sEjFrame - sEjAt) % 300) == 0) && ((sEjFrame - sEjAt) % 300) == 0)
{
ejectPress = 1; ejectPress = 1;
DEBUG_STREAM << "[ejecttest] FIRING punch-out at frame "
<< sEjFrame << "\n" << std::flush;
}
} }
if (ejectPress) if (ejectPress)
{ {
@@ -4703,60 +4406,9 @@ void
// stability, so peers pose the squat for free) and flip the // stability, so peers pose the squat for free) and flip the
// stability alarm (ducked = 0, risen = 1). The request is // stability alarm (ducked = 0, risen = 1). The request is
// consumed whenever both gates passed, hit or miss. // consumed whenever both gates passed, hit or miss.
// #142 RESTRUCTURE 2026-08-07 -- duckState is the POSTURE, not a if (duckState != 0 && squatCapable != 0)
// one-shot request, because the COCKPIT ANIMATION reads it.
//
// L4GAUGE.CFG:5001 authors
// oneOfSeveralPixInt(E,ModeAlwaysActive,bduck.pcc,3,1,DuckState)
// -- a 3-frame mech-symbol strip beside the CROUCH button, indexed by
// attribute 0x37 (= duckState). Asset (content/GAUGE/BDUCK.PCC),
// element (OneOfSeveralPixInt @004c5204) and factory registration
// (btl4grnd.cpp) are all present. The animation never played because
// the old consumer zeroed duckState the frame after the press, in
// BOTH directions -- so the strip sat on frame 0 with a one-frame
// blip to frame 1. That is the field report verbatim: "button
// flickers sometimes on press ... state does not change".
//
// The binary does NOT zero it per frame. Every writer of +0x398 in
// the export is: the DuckRequest handler (=1) and Mech::Reset (=0).
// The master perf FUN_004a9b5c -- which contains the address the old
// comment cited as "the DuckRequest consumer (@0x4aa011)" -- does not
// reference 0x398 anywhere, and mech.hpp's own note already said
// "duckState has NO code reader anywhere in the decomp ... whatever
// consumes it consumes it through DATABINDING". The databinding
// consumer is this gauge strip. The per-frame zeroing was ours.
//
// So: drive on DESIRED vs ACTUAL instead of on a latch. duckState is
// the desired posture (the handler now toggles it); the parked leg
// alarm is the actual one. Act only on a mismatch -- no re-fire, and
// nothing clears the attribute behind the gauge's back. A frame
// where mapPosture is not ready RETRIES next frame instead of
// silently dropping the request, which also retires the old
// "request consumed, posture=N" miss.
// THE POSTURE MACHINE (#142). duckState is the cockpit strip's frame:
// 0 = standing 1 = moving between 2 = crouched
// The handler writes 1 (the binary's exact behaviour) meaning "a move is
// pending", which doubles as the middle frame. Direction comes from the
// parked leg alarm, so no separate request cell is needed:
// parked -> the pending move is a RISE
// !parked -> the pending move is a SQUAT
// While the clip runs we hold 1; when it settles we write 0 or 2.
// Read the ALARM, not the cached legAnimationState member: the cache
// is only refreshed at the top of AdvanceLegAnimation, so in the
// frame right after SetLegAnimation it still reads the OLD state.
// With the cached read, "am I already moving?" answered no on the
// frame after issuing, the consumer re-issued, and the machine
// ping-ponged squat/rise -- 68 transitions from 2 presses, benched.
// SetLegAnimation writes the alarm synchronously, so the alarm is
// true the instant the clip is armed.
const int duckLegLvl = (int)legStateAlarm.GetLevel();
const int duckParked = (duckLegLvl == 1);
const int duckMoving = (duckLegLvl == 2 || duckLegLvl == 3); // 'sqd' / 'squ'
if (duckRequest != 0 && !duckMoving && squatCapable != 0)
{ {
duckRequest = 0; // one shot, whatever happens if (mapPosture == 1)
if (!duckParked && mapPosture == 1)
{ {
SetLegAnimation(2); // 'sqd' -- squat down SetLegAnimation(2); // 'sqd' -- squat down
ForceUpdate(8); ForceUpdate(8);
@@ -4765,7 +4417,7 @@ void
if (getenv("BT_DUCK_LOG") || getenv("BT_GAIT_LOG")) if (getenv("BT_DUCK_LOG") || getenv("BT_GAIT_LOG"))
DEBUG_STREAM << "[duck] SQUAT (posture 1 -> leg clip 2)\n" << std::flush; DEBUG_STREAM << "[duck] SQUAT (posture 1 -> leg clip 2)\n" << std::flush;
} }
else if (duckParked && mapPosture == 2) else if (mapPosture == 2)
{ {
SetLegAnimation(3); // 'squ' -- rise SetLegAnimation(3); // 'squ' -- rise
ForceUpdate(8); ForceUpdate(8);
@@ -4774,76 +4426,14 @@ void
if (getenv("BT_DUCK_LOG") || getenv("BT_GAIT_LOG")) if (getenv("BT_DUCK_LOG") || getenv("BT_GAIT_LOG"))
DEBUG_STREAM << "[duck] RISE (posture 2 -> leg clip 3)\n" << std::flush; DEBUG_STREAM << "[duck] RISE (posture 2 -> leg clip 3)\n" << std::flush;
} }
else else if (getenv("BT_DUCK_LOG"))
{ DEBUG_STREAM << "[duck] request consumed, posture=" << mapPosture
// Gate refuses -- posture reads 0 for a MOVING mech, which is the << " (mode=" << MovementMode()
// authentic rule (Lynx: "when a mech STOPS, crouch button lowers << " legLvl=" << (int)legStateAlarm.GetLevel()
// its stance"; benched: crouch at a walk gives posture=0). Settle << " simLive=" << 1 // re-read below costs a bridge call; posture already folded it
// the strip back to the truth instead of holding the mid frame or << " myo=" << myomerEffectiveness
// queueing the request for the next time the pilot stops. << " squat=" << squatCapable << ")\n" << std::flush;
static int s_duckRefuse = 0; duckState = 0; // consumed (@0x4aa0a9)
if ((s_duckRefuse++ % 30) == 0)
DEBUG_STREAM << "[duck] REFUSED (not stopped): posture="
<< mapPosture << " mode=" << MovementMode()
<< " myo=" << myomerEffectiveness << "\n" << std::flush;
}
}
// SETTLE, unconditionally, re-reading the alarm AFTER any issue
// above. This is the part that was missing: duckState stayed 1
// forever, so once a clip completed the block above kept firing and
// flipped direction every frame -- 68 transitions from 2 presses.
// The squat/rise clips finish fast enough that the alarm is already
// back to 0/1 by the next visit, so "am I still moving?" has to be
// asked fresh, and the strip settled whenever the answer is no.
{
// SETTLE to the binary's VALUE RANGE: duckState is 0 or 1, never
// 2. Corrected 2026-08-07 after checking FUN_004a9b5c -- the
// mech4 master perf is fully indexed and exported (5645 bytes,
// no dark region inside) and never references +0x398. So the
// binary has NO code reader of duckState at all: the gauge
// attribute is the only consumer, and the only writers are the
// handler (=1) and Mech::Reset (=0). Frame 2 of bduck.pcc was
// unreachable in the original too.
//
// The strip is therefore a TWO-STATE indicator -- standing and
// crouched -- and the "crouch animation" is the MECH's 'sqd'
// clip, not the symbol stepping. An earlier revision here made
// duckState a 3-state posture (0/1/2); that was an invention on
// top of a stand-in and it put the crouched pose on the wrong
// frame.
// #142: report EVERY change of the value the cockpit strip is fed,
// so 'which frames actually got shown, and for how long' stops
// being guesswork. Ungated: one line per posture change.
static int s_lastDuck = -1;
// THE STRIP IS A BUTTON-STATE INDICATOR, not a pose animation.
// Decoded 2026-08-07 by RENDERING THE ASSET itself
// (content/GAUGE/BDUCK.PCC -- PCX, 108x102, three 36x102 frames;
// see scratchpad/night13/bduck_frames.png):
//
// frame 0 GREY mech standing, GREY down-arrow -- UNAVAILABLE
// frame 1 ORANGE standing, YELLOW down -- ready to crouch
// frame 2 ORANGE CROUCHED, YELLOW up -- press to rise
//
// There is NO mid-transition pose. The arrow tells the pilot
// what the next press will do, and the grey frame says the
// button is inert -- which is the visual half of the
// must-be-stopped rule (Lynx: "when a mech STOPS").
//
// Two earlier readings were wrong: a 2-state flag (which
// ignores frame 2's up-arrow entirely) and a stand/moving/
// crouched pose animation (there is no mid pose). Both were
// inferred from code and logs; only the ART settled it, and it
// also explains the field report -- a stopped mech that COULD
// crouch was drawing the grey "unavailable" frame.
const int lvlNow = (int)legStateAlarm.GetLevel();
if (lvlNow != 2 && lvlNow != 3) // hold through the clip
{
if (lvlNow == 1) duckState = 2; // crouched
else if (squatCapable != 0 && mapPosture == 1)
duckState = 1; // ready
else duckState = 0; // inert
}
} }
// (3b) AIRBORNE AUTO-RISE -- recovered 2026-08-06 by the #60 // (3b) AIRBORNE AUTO-RISE -- recovered 2026-08-06 by the #60
@@ -6501,21 +6091,7 @@ void
// clamp(true - shown, +-dt*500) -- so the caret sweeps smoothly as // clamp(true - shown, +-dt*500) -- so the caret sweeps smoothly as
// the boresight crosses near/far ground instead of teleporting. // the boresight crosses near/far ground instead of teleporting.
// (Applies to the no-target 1200 default too.) // (Applies to the no-target 1200 default too.)
// #147: sShownRange is DISPLAY state, but it is a function-level
// static -- one cell for the whole process, shared by every mech and
// carried across drops. Re-seed it whenever the viewpoint mech
// CHANGES (a new drop hands us a new entity) so a fresh drop starts
// at the binary's 1200 default instead of inheriting the last
// mission's slid value. A respawn REUSES the entity (Mech::Reset
// heals in place), so this deliberately does not fire there -- the
// binary does not reset the readout on respawn either.
static float sShownRange = 1200.0f; static float sShownRange = 1200.0f;
static const void *sShownOwner = 0;
if (sShownOwner != (const void *)this)
{
sShownOwner = (const void *)this;
sShownRange = 1200.0f;
}
float trueRange = 1200.0f; // no target: the binary default float trueRange = 1200.0f; // no target: the binary default
Entity *des = MECH_TARGET_ENTITY(this); Entity *des = MECH_TARGET_ENTITY(this);
if (des != 0 && des != hotTarget) if (des != 0 && des != hotTarget)
@@ -6572,26 +6148,6 @@ void
// the 500 m/s slide toward trueRange (see the banner above) // the 500 m/s slide toward trueRange (see the banner above)
{ {
// #147 NaN TRAP. NaN is ABSORBING here and the clamps below
// cannot catch it: `step > maxStep` and `step < -maxStep` are
// BOTH false for NaN, so a single poisoned frame makes
// sShownRange NaN and it stays NaN for the life of the process
// (the static is never re-seeded except on a mech change).
// Downstream, BTReticleRenderable::Draw clamps the same way, so
// the NaN reaches AddPoint/ConcatMatrix and the caret + its bar
// render as degenerate geometry -- i.e. they VANISH while every
// static reticle element (the tick marks) still draws. That is
// exactly the reported "no range finder on this drop: ticks
// there, moving caret gone". Re-seed instead of propagating.
if (!(trueRange == trueRange) || !(sShownRange == sShownRange))
{
if (getenv("BT_RANGE_LOG"))
DEBUG_STREAM << "[range] NaN TRAPPED (true=" << trueRange
<< " shown=" << sShownRange << ") -- re-seeded to 1200\n"
<< std::flush;
trueRange = 1200.0f;
sShownRange = 1200.0f;
}
float maxStep = (float)dt * 500.0f; float maxStep = (float)dt * 500.0f;
if (maxStep < 0.0f) maxStep = -maxStep; if (maxStep < 0.0f) maxStep = -maxStep;
float step = trueRange - sShownRange; float step = trueRange - sShownRange;
@@ -6599,22 +6155,6 @@ void
if (step < -maxStep) step = -maxStep; if (step < -maxStep) step = -maxStep;
sShownRange += step; sShownRange += step;
BTSetHudTargetRange((Scalar)sShownRange); BTSetHudTargetRange((Scalar)sShownRange);
// The caret's ACTUAL input had NO diagnostic anywhere: BT_RANGE_LOG
// instruments the PICK (#4), and [target]'s `range=` is a separate
// locally-recomputed Sqrt in the weapon-range check -- so a field
// log could neither confirm nor refute a dead caret. Fixed.
if (getenv("BT_RANGE_LOG"))
{
static float sRlog = 0.0f;
sRlog += (float)dt;
if (sRlog >= 1.0f)
{
sRlog = 0.0f;
DEBUG_STREAM << "[range] caret input shown=" << sShownRange
<< " true=" << trueRange << " lock=" << gBTHudLockState
<< "\n" << std::flush;
}
}
} }
// BT_RANGE_LOG (Gitea #4 VERDICT instrumentation -- uncommitted diag): // BT_RANGE_LOG (Gitea #4 VERDICT instrumentation -- uncommitted diag):
@@ -7126,39 +6666,6 @@ void
gBTGenSelKey = pulse ? s_gsID : 0; gBTGenSelKey = pulse ? s_gsID : 0;
} }
// TIMEOUT PROBE (BT_PROJ_TEST=1, off by default; the #168 bench). At
// t=+5s push two DAMAGE-0 rounds aimed at a point 40000u overhead --
// unreachable at speed 400, no target, no terrain in the path -- so
// each MUST end in the [projectile] FIZZLE receipt at exactly its
// lifetime cap: unguided ttl=5.0 (@004bddec), guided burn=2 ->
// ttl=12.0 (@004bef78). The 2-node rig proves real flights never
// fizzle EARLY; this proves the timeout itself fires at the authentic
// second.
{
static int s_ptArmed = -1;
static float s_ptClock = 0.0f;
if (s_ptArmed < 0)
s_ptArmed = (getenv("BT_PROJ_TEST") != 0) ? 1 : 0;
if (s_ptArmed == 1
&& (Entity *)this == application->GetViewpointEntity())
{
s_ptClock += dt;
if (s_ptClock >= 5.0f)
{
s_ptArmed = 2; // once per process
extern void BTPushProjectile(const Point3D &, void *, void *,
const Point3D &, Scalar, Scalar, const Vector3D *, int,
int, int, int, int, Scalar, Scalar, int);
Point3D mzp = localOrigin.linearPosition; mzp.y += 14.0f;
Point3D upp = mzp; upp.y += 40000.0f;
BTPushProjectile(mzp, this, 0, upp, 400.0f, 0.0f, 0, 0,
-1, 0, -1, 2, 0.0f, 0.0f, 0);
BTPushProjectile(mzp, this, 0, upp, 400.0f, 0.0f, 0, 1,
-1, 0, -1, 2, 0.0f, 2.0f, 0);
}
}
}
// SELF-DAMAGE HARNESS (BT_SELF_DAMAGE=<amount per second>, off by default). // SELF-DAMAGE HARNESS (BT_SELF_DAMAGE=<amount per second>, off by default).
// The whole RESPAWN family -- torso twist after respawn (#70), ammo/weapon // The whole RESPAWN family -- torso twist after respawn (#70), ammo/weapon
// reset (#22), coolant/heat/generator restore (#55), the respawn latch // reset (#22), coolant/heat/generator restore (#55), the respawn latch
@@ -8074,33 +7581,7 @@ void
continue; continue;
if (i != 0) // slot 0 = the mapper (task #7) if (i != 0) // slot 0 = the mapper (task #7)
++subsystemsPresent; ++subsystemsPresent;
// #148 -- THE INSTANCE BRANCH. Entity::Perform (ENTITY.cpp:733-793, if (!subsystem->IsNonReplicantExecutable())
// real engine source [T0]) picks the predicate by instance:
// if (GetInstance() != ReplicantInstance) IsNonReplicantExecutable()
// else IsReplicantExecutable()
// and they differ exactly on the replicant case (SIMULATE.h:195-206):
// NonReplicant: (flags & DontExecuteFlag) == 0
// Replicant : (flags & DontExecuteFlag) == 0
// || lastUpdate >= lastPerformance
// `ExecuteOnUpdate()` SETS DontExecuteFlag -- it means "do not tick me
// every frame, tick me when an UPDATE ARRIVES". This loop used the
// NonReplicant predicate for every mech, so on a REPLICANT any
// ExecuteOnUpdate subsystem never ran at all, no matter how many
// records arrived for it.
//
// Measured (scratchpad/night13/missileframe2.sh): the peer's copy
// TORSO received its first record at log line 205 but its Performance
// did not run until line 1014 -- ~800 lines of arriving twist data
// integrated by nobody, so the peer's torso sat at 0 and its missiles
// launched along the body facing (the tail of #141). The records
// themselves were fine: they are sent on RATE CHANGE (the sweep's
// direction flips -- atUpd +/-2.39 with rate flipping sign), and the
// peer dead-reckons `atUpd + rate * elapsed` between them.
const Logical execOK =
(GetInstance() != Entity::ReplicantInstance)
? subsystem->IsNonReplicantExecutable()
: subsystem->IsReplicantExecutable();
if (!execOK)
continue; continue;
// The controls-mapping subsystem (roster slot 0 via Mech::SetMapping // The controls-mapping subsystem (roster slot 0 via Mech::SetMapping
@@ -8573,31 +8054,15 @@ void
fallScalar = -(fallDirection.x * impactVel.x fallScalar = -(fallDirection.x * impactVel.x
+ fallDirection.y * impactVel.y + fallDirection.y * impactVel.y
+ fallDirection.z * impactVel.z); + fallDirection.z * impactVel.z);
// Log the crash pricing -- but SAMPLED for scrape taps (#128 field // ALWAYS-log the crash pricing (rare event; the arbiter for the
// measurement: sustained scrapes re-price per frame, 25-82 ev/s, // wall-damage economy -- run 14 field report: "tapped a wall and
// up to 30k lines/night = ~20% of a field log; every one of those // died instantly", so the forwarded amount is suspect vs binary).
// taps is ~50x under the distributor's 0.5-pt free floor, so the
// per-event detail is noise). Every raw amount >= 1000 (the ones
// that can matter downstream) still logs unconditionally; the
// sub-floor spam logs 1-in-60 with a suppressed-count so scrape
// cadence stays measurable from the receipts.
{
static int sTapSuppressed = 0;
int noteworthy = (dmg.damageAmount >= 1000.0f);
if (!noteworthy)
++sTapSuppressed;
if (noteworthy || (sTapSuppressed % 60) == 1)
{
DEBUG_STREAM << "[crashdmg] amount=" << dmg.damageAmount DEBUG_STREAM << "[crashdmg] amount=" << dmg.damageAmount
<< " mass=" << moverMass << " mass=" << moverMass
<< " |v|=" << (float)Sqrt(impactVel.x * impactVel.x << " |v|=" << (float)Sqrt(impactVel.x * impactVel.x
+ impactVel.y * impactVel.y + impactVel.z * impactVel.z) + impactVel.y * impactVel.y + impactVel.z * impactVel.z)
<< " force=(" << dmg.damageForce.x << "," << dmg.damageForce.y << " force=(" << dmg.damageForce.x << "," << dmg.damageForce.y
<< "," << dmg.damageForce.z << ")" << "," << dmg.damageForce.z << ")\n" << std::flush;
<< (noteworthy ? "" : " (scrape sample; taps suppressed)")
<< "\n" << std::flush;
}
}
Entity::TakeDamageMessage td( Entity::TakeDamageMessage td(
Entity::TakeDamageMessageID, Entity::TakeDamageMessageID,
sizeof(Entity::TakeDamageMessage), sizeof(Entity::TakeDamageMessage),
@@ -9096,22 +8561,7 @@ void
} }
if (s_portCache[energyOrdinal] != 0) if (s_portCache[energyOrdinal] != 0)
{ {
// #141 sweep: engine accessor (== the binary's FUN_00424da8). AffineMatrix mw;
// This is the BEAM muzzle -- the same stale-cache exposure the
// missile launch had, so a peer's beam would also originate
// from the bind-pose gun port instead of the twisted torso.
// #149 A/B (BT_BEAM_SEGFRESH=0): revert THIS site to the
// pre-sweep plain compose, to measure whether the per-beam
// per-frame dirty-pass is the 857 draw-stall regression.
// This site runs inside the DRAW path per emitter per frame;
// the other swept sites are per-salvo/per-hit and cannot be
// a per-frame cost. Default = fresh (the swept behaviour).
static const int sBeamFresh =
!(getenv("BT_BEAM_SEGFRESH") && *getenv("BT_BEAM_SEGFRESH") == '0');
LinearMatrix mw;
if (sBeamFresh)
GetSegmentToWorld(*s_portCache[energyOrdinal], &mw);
else
mw.Multiply(s_portCache[energyOrdinal]->GetSegmentToEntity(), mw.Multiply(s_portCache[energyOrdinal]->GetSegmentToEntity(),
localToWorld); localToWorld);
mz = mw; // Point3D = matrix translation mz = mw; // Point3D = matrix translation
+1 -50
View File
@@ -1255,50 +1255,26 @@ void
+ fabsf((float)owner->bodyCycleSpeed); + fabsf((float)owner->bodyCycleSpeed);
const int movingNoLegs = (step > 0.08f && step < 5.0f // 5+: teleport/warp const int movingNoLegs = (step > 0.08f && step < 5.0f // 5+: teleport/warp
&& cyc < 0.05f) ? 1 : 0; && cyc < 0.05f) ? 1 : 0;
// #52 A/B probe (BT_BODY_SM_LOG): the POSITIVE half of the Standing-lock
// evidence. The [skate] line only speaks once a lock has ALSO produced
// 90 sustained moving frames; this says every second what a moving peer's
// body channel is actually doing. Locked: bstate=0, bodyCyc=0. Healthy:
// bstate in the walk/run family with a live cycle.
if (step > 0.08f && step < 5.0f && getenv("BT_BODY_SM_LOG"))
{
static float s_pg = 0.0f; s_pg += 1.0f / 30.0f;
if (s_pg >= 1.0f) { s_pg = 0.0f;
DEBUG_STREAM << "[peergait] replicant " << owner->GetEntityID()
<< " step=" << step
<< " bstate=" << (int)owner->bodyAnimationState
<< " bodyCyc=" << (float)owner->bodyCycleSpeed
<< " bts=" << (float)owner->bodyTargetSpeed
<< (movingNoLegs ? " <-- IDLE CHANNELS" : "")
<< "\n" << std::flush; }
}
if (movingNoLegs) if (movingNoLegs)
{ {
if (++skateFrames > 90 && !skateLogged) if (++skateFrames > 90 && !skateLogged)
{ {
skateLogged = 1; skateLogged = 1;
// bstate = the peer's BODY animation state (@0x728), the channel
// that poses a replicant (s_peerLegCh=0). Added 2026-08-07 after
// night 13: the field lines proved "moving with both channels
// idle" but not WHICH state it was idling in, and the answer
// (0 = Standing, pinned) is the whole diagnosis -- see #52.
DEBUG_STREAM << "[skate] replicant " << owner->GetEntityID() DEBUG_STREAM << "[skate] replicant " << owner->GetEntityID()
<< " SKATING: " << skateFrames << " frames moving (" << " SKATING: " << skateFrames << " frames moving ("
<< step << " u/frame) with legCyc=" << step << " u/frame) with legCyc="
<< (float)owner->legCycleSpeed << (float)owner->legCycleSpeed
<< " bodyCyc=" << (float)owner->bodyCycleSpeed << " bodyCyc=" << (float)owner->bodyCycleSpeed
<< " bodyTargetSpeed=" << (float)owner->bodyTargetSpeed << " bodyTargetSpeed=" << (float)owner->bodyTargetSpeed
<< " bstate=" << (int)owner->bodyAnimationState
<< " destroyed=" << (int)owner->IsMechDestroyed() << " destroyed=" << (int)owner->IsMechDestroyed()
<< " mode=" << (int)owner->MovementMode() << " mode=" << (int)owner->MovementMode()
<< " at (" << px << "," << pz << ")\n" << std::flush; << " at (" << px << "," << pz << ")\n" << std::flush;
if (BTMatchLogActive()) if (BTMatchLogActive())
BTMatchLog("SKATE", "mech=%d:%d frames=%d step=%.3f cyc=%.3f " BTMatchLog("SKATE", "mech=%d:%d frames=%d step=%.3f cyc=%.3f "
"cmdSpd=%.2f bstate=%d destroyed=%d mode=%d x=%.1f z=%.1f", "cmdSpd=%.2f destroyed=%d mode=%d x=%.1f z=%.1f",
BTMatchHostOf(owner->GetEntityID()), (int)owner->GetEntityID(), BTMatchHostOf(owner->GetEntityID()), (int)owner->GetEntityID(),
skateFrames, step, cyc, skateFrames, step, cyc,
(float)owner->bodyTargetSpeed, (float)owner->bodyTargetSpeed,
(int)owner->bodyAnimationState,
(int)owner->IsMechDestroyed(), (int)owner->MovementMode(), (int)owner->IsMechDestroyed(), (int)owner->MovementMode(),
px, pz); px, pz);
} }
@@ -1358,31 +1334,6 @@ void
const bool nowDead = (mode == 2 || mode == 9); const bool nowDead = (mode == 2 || mode == 9);
const bool prevDead = (oldMode == 2 || oldMode == 9); const bool prevDead = (oldMode == 2 || oldMode == 9);
prevMode = mode; prevMode = mode;
// #108 GHOST CENSUS, the ENTER edge (ungated, 2026-08-07). The
// un-wreck receipt below has had no partner, so counting ghosts in a
// field log meant pairing it against
// "[BTrender] wreck: 'thrdbr.bgf' missing -> gendbr.bgf fallback" --
// which is a MISSING-ASSET WARNING, not a death: it only prints for
// chassis whose wreck model is absent. Night 13's census therefore
// found ONE ghost while testers reported many, and there was no way to
// tell a real count from a chassis accident. This line is emitted for
// EVERY replicant that enters the wreck state, so a log's ghost count
// is exactly (wreck-enters minus un-wrecks) per entity.
if (!prevDead && nowDead
&& owner->GetInstance() == Entity::ReplicantInstance)
{
DEBUG_STREAM << "[wreck] replicant " << owner->GetEntityID()
<< " entered wreck state (mode "
<< oldMode << "->" << mode << ") at ("
<< owner->localOrigin.linearPosition.x << ","
<< owner->localOrigin.linearPosition.z << ")\n" << std::flush;
if (BTMatchLogActive())
BTMatchLog("WRECK", "mech=%d:%d mode=%d->%d x=%.1f z=%.1f",
BTMatchHostOf(owner->GetEntityID()), (int)owner->GetEntityID(),
oldMode, mode,
(float)owner->localOrigin.linearPosition.x,
(float)owner->localOrigin.linearPosition.z);
}
if (prevDead && !nowDead if (prevDead && !nowDead
&& owner->GetInstance() == Entity::ReplicantInstance) && owner->GetInstance() == Entity::ReplicantInstance)
{ {
+27 -128
View File
@@ -508,69 +508,25 @@ void
} }
NotifyOfControlModeChange(controlMode); // vtable+0x48 NotifyOfControlModeChange(controlMode); // vtable+0x48
// TYPED torso reconfiguration. The raw block this replaces wrote the // TYPED torso reconfiguration (2026-07-13): the raw block this
// BINARY's offsets straight onto OUR compiled Torso (the databinding // replaces wrote the BINARY's offsets (torso+0x1f0/0x274/0x220...)
// trap); the typed rewrite that followed then got the SEMANTICS wrong in // straight onto OUR compiled Torso -- the databinding trap: garbage
// three ways. Corrected 2026-08-08 against @004afbe0, which is a // writes into whatever members live there in this build. The
// complete spec: // observable semantics via named members: Basic clears the analog
// // axes and recenters (the sim's centerCommand -> Recenter); the
// iVar1 = mech+0x438 (TORSO) iVar2 = mech+0x5b4 (HUD) // assisted modes just free the torso (the sim clamps to the authored
// if (mode == 0) { // BASIC // limits on its own).
// *(iVar1 + 0x1f0) = 0; // analogTwistAxis
// *(iVar1 + 0x274) = 1; // recenterActive
// *(iVar1 + 0x220) = *(iVar1 + 0x228); // vertLimitTop
// *(iVar1 + 0x224) = *(iVar1 + 0x22c); // vertLimitBottom
// *(iVar2 + 0x2a0) = 1; // HUD flickerActive
// } else if (mode - 1U < 2) { // STANDARD/VETERAN
// *(iVar1 + 0x220) = *(iVar1 + 0x230);
// *(iVar1 + 0x224) = *(iVar1 + 0x234);
// }
//
// (1) THE BUG Sauron hit. Basic set `centerCommand` (@0x208) via
// CommandRecenter(). That is the HELD-BUTTON cell: TorsoSimulation
// re-arms recenterActive from it EVERY frame it is non-zero, and only
// the input path clears it -- and a MODE SWITCH has no button release
// to follow. So one visit to Basic pinned it at 1 forever, the torso
// re-centred every frame, and the digital twist commands (processed
// BEFORE the centerCommand block) were overridden as fast as they were
// applied. Cycling Standard -> Veteran -> (wraps through BASIC) ->
// Standard is enough to trigger it, which is exactly the reported
// "toggled to advanced and back, lost torso control". The binary sets
// recenterActive (@0x274) directly: a ONE-SHOT that self-clears on
// settle (`recenterActive = Recenter(dt)`) and is cancelled by any
// twist input.
// (2) The ELEVATION LIMIT SWAP was missing entirely. Two authored pairs
// exist -- BASIC @0x228/@0x22C (full top, HALF bottom) vs
// STANDARD/VETERAN @0x230/@0x234 (the full pair) -- and all four were
// ctor-written and never read by anything. So Basic never restricted
// downward travel and the assisted modes never restored it.
// (3) Basic also raises the HUD's flickerActive (@0x2A0) so the horizon
// re-settles with the torso. Not ported.
// Also: the binary zeroes ONLY analogTwistAxis (@0x1F0). The extra
// SetAnalogElevationAxis(0) was invented; removed.
Mech *mech = GetMech(); Mech *mech = GetMech();
Torso *torso = (mech != 0) ? (Torso *)mech->GetTorsoSubsystem() : 0; Torso *torso = (mech != 0) ? (Torso *)mech->GetTorsoSubsystem() : 0;
if (torso != 0) if (torso != 0)
{ {
if (controlMode == BasicMode) if (controlMode == BasicMode)
{ {
torso->SetAnalogTwistAxis(0.0f); // @0x1F0 torso->SetAnalogTwistAxis(0.0f);
// BT_LEGACY_MODE_RECENTER=1 restores the defective pre-2026-08-08 torso->SetAnalogElevationAxis(0.0f);
// behaviour (the sticky centerCommand) for A/B measurement. torso->CommandRecenter();
static const int s_legacyRecenter =
getenv("BT_LEGACY_MODE_RECENTER") ? 1 : 0;
if (s_legacyRecenter)
torso->CommandRecenter(); // @0x208 STICKY -- the bug
else
torso->BeginRecenterOnce(); // @0x274 (NOT centerCommand)
torso->ApplyBasicElevationLimits(); // @0x220/@0x224 <- @0x228/@0x22C
extern void BTSetHudFlickerActive(Subsystem *hud);
BTSetHudFlickerActive(mech->GetHudSubsystem()); // HUD @0x2A0 = 1
}
else // StandardMode / VeteranMode -- `mode - 1U < 2` in the binary
{
torso->ApplyAssistedElevationLimits(); // @0x220/@0x224 <- @0x230/@0x234
} }
// Standard/Veteran: nothing to force -- the sim's limits govern.
} }
DEBUG_STREAM << "[mode] control mode -> " << (int)controlMode DEBUG_STREAM << "[mode] control mode -> " << (int)controlMode
<< " (0=Basic 1=Standard 2=Veteran)" << std::endl; << " (0=Basic 1=Standard 2=Veteran)" << std::endl;
@@ -704,58 +660,6 @@ void
// after the push, immediately before interpretation -- making the keyboard // after the push, immediately before interpretation -- making the keyboard
// authoritative on the dev box. Interpretation below stays 100% authentic. // authoritative on the dev box. Interpretation below stays 100% authentic.
// //
// BENCH (BT_MODECYCLE_EVERY=<n>): cycle the control mode every n
// InterpretControls calls, driving the SAME body the 'M' key and the pod
// console button (key 0x13d -- not a RIO button, so BT_BTNTEST cannot press
// it) drive. Dev-only; default off.
//
// ⚠ DELIBERATELY OUTSIDE the key-bridge block below. The ONLY caller of
// ClearRecenterCommand() lives inside that block, so forcing BT_KEY_BRIDGE=1
// to make this hook run would ALSO switch on the one thing that clears
// centerCommand -- masking the very bug under test. That is exactly how the
// first run of modecycle.sh came back clean. Keeping the hook out here lets
// the bench reproduce the RIO-present (glass/PadRIO) configuration, where the
// bridge is OFF and nothing clears the cell.
{
static const char *s_mcEvery = getenv("BT_MODECYCLE_EVERY");
if (s_mcEvery != 0)
{
static int s_mcN = 0;
int period = atoi(s_mcEvery);
if (period < 1) period = 300;
if (++s_mcN % period == 0)
CycleControlModeNow();
}
}
// (#152) TORSO-CENTER -- the ONE writer of the torso's centerCommand
// (@0x208, HELD-button semantics: writer asserts while held, clears on
// release; TorsoSimulation re-arms recenterActive from it each frame).
// Sources OR'd here, deliberately OUTSIDE the key-bridge gate:
// * torsoCenter (@0x154) -- this mapper's databound "TorsoCenter" cell,
// the streamed pod-button route (button 0x42);
// * gBTTorsoRecenter -- the desktop 'X' one-frame pulse (mech4 key poll).
// The old writer lived INSIDE the key-bridge block, so on any rig with a
// RIO/PadRIO present (glass + the pod -- the bridge is off there) NO path
// could reach centerCommand: with the stuck-cell phantom auto-recentre
// fixed, those players had no way to recentre the torso in Std/Vet at all
// (Oracle's #152 report). Single-writer here also means the two sources
// can never stomp each other's clear.
{
Mech *rcMech = GetMech();
Torso *rcTorso = (rcMech != 0) ? (Torso *)rcMech->GetTorsoSubsystem() : 0;
if (rcTorso != 0)
{
int hold = (torsoCenter != 0);
extern int gBTTorsoRecenter;
extern int gBTTorsoCenterHeld; // pod button 0x42 (L4PADRIO chokepoint)
if (gBTTorsoCenterHeld) hold = 1;
if (gBTTorsoRecenter) { gBTTorsoRecenter = 0; hold = 1; }
if (hold)
rcTorso->CommandRecenter();
else
rcTorso->ClearRecenterCommand();
}
}
{ {
// STAND-DOWN (glass-cockpit step 2c): BT_KEY_BRIDGE unset = AUTO -- // STAND-DOWN (glass-cockpit step 2c): BT_KEY_BRIDGE unset = AUTO --
// the bridge runs only when NO live cockpit device (serial RIO / // the bridge runs only when NO live cockpit device (serial RIO /
@@ -967,12 +871,21 @@ void
// centerCommand is a pod BUTTON state, so the writer clears // centerCommand is a pod BUTTON state, so the writer clears
// it while unpressed (Basic's own path re-asserts every // it while unpressed (Basic's own path re-asserts every
// frame; this branch owns it in Standard/Veteran). // frame; this branch owns it in Standard/Veteran).
// (#152) the recenter writer moved OUT of this key-bridge {
// block to the unified consumer below -- inside here it was Torso *rcTorso = (Torso *)mech->GetTorsoSubsystem();
// DEAD on every RIO/glass rig (bridge off), which left those if (rcTorso != 0)
// players with no torso-centre control at all once the {
// stuck-cell phantom auto-recentre was fixed. Bench: two if (gBTTorsoRecenter)
// scripted 0x42 holds on the RIO path, ctrCmd=0 throughout. {
gBTTorsoRecenter = 0;
rcTorso->CommandRecenter();
}
else
{
rcTorso->ClearRecenterCommand();
}
}
}
} }
} }
// (stickPosition.y no longer zeroed here -- the bridge above // (stickPosition.y no longer zeroed here -- the bridge above
@@ -1110,20 +1023,6 @@ void
// reads the same live factor (dead/overheated myomers cannot squat // reads the same live factor (dead/overheated myomers cannot squat
// or rise; the posture selector tests |factor| <= 1e-4). // or rise; the posture selector tests |factor| <= 1e-4).
mech->myomerEffectiveness = drive; mech->myomerEffectiveness = drive;
// #137 POST-RESET TRACE. Mech::Reset arms gBTMyoTrace; sample here,
// where the mover's actual multiplier is formed, for a few seconds
// after a respawn. This is the window nothing was watching -- the
// at-reset sample always looks clean.
{
extern int gBTMyoTrace;
extern void BTReportMyomerFreeze(void *mech_v, const char *when);
if (gBTMyoTrace > 0)
{
--gBTMyoTrace;
if ((gBTMyoTrace % 30) == 0)
BTReportMyomerFreeze((void *)mech, "post-reset");
}
}
if (fabsf(drive) <= 1.0e-4f) // @0x4a9d89 vs _DAT_004ab16c if (fabsf(drive) <= 1.0e-4f) // @0x4a9d89 vs _DAT_004ab16c
turnDemand = 0.0f; // @0x4a9d9e: mapper+0x12C -- the FREEZE turnDemand = 0.0f; // @0x4a9d9e: mapper+0x12C -- the FREEZE
} }
+9 -25
View File
@@ -20,23 +20,9 @@
// excluded : the Projectile base ctor @004be1bc and the 0x41xxxx/0x42xxxx // excluded : the Projectile base ctor @004be1bc and the 0x41xxxx/0x42xxxx
// Entity engine vtable slots // Entity engine vtable slots
// //
// Decoded MoveAndCollide tuning constants (.rdata @004bf594..@004bf5b0). // Decoded MoveAndCollide tuning constants (.rdata @004bf594..@004bf5b0):
// ⚠ RE-DECODED 2026-08-11 (#168 audit, byte-read + full @4bef78 walk) -- the // 0x40000000 = 2.0f 0x38d1b717 = 1.0e-4f 0x40800000 = 4.0f
// original decode below misattributed most of these. Verified semantics: // 0x3dcccccd = 0.1f 0x41200000 = 10.0f 0xbf800000 = -1.0f
// _DAT_004bf594 = 2.0f replication KEEPALIVE: no update sent for 2.0s
// -> updateModel(+0x18)|=1 (ForceUpdate), NOT a death
// _DAT_004bf598 = 0.0f per-axis drag-coefficient sign pick (rel-vel vs wind)
// _DAT_004bf59c/a0 = ∓1e-4 signed-square deadband in the DRAG term
// _DAT_004bf5a4 = 4.0f PROXIMITY FUSE: seeker rangeToTarget(+0x10C) < 4.0
// -> detonate on targetEntity without a geometry hit
// _DAT_004bf5a8 = 0.1f dead-reckon divergence² (vs predictor FUN_00422060
// pos +0x260) -> replication-dirty
// _DAT_004bf5ac = 10.0f COAST MARGIN: expiry at age > burnTime(+0x340)+10.0s
// _DAT_004bf5b0 = -1.0f kill-plane: pos.y < -1 -> FUN_0042061c retire
// The steering/lifetime code below still reflects the OLD decode -- this TU is
// DORMANT (nothing constructs Missile; the mech4.cpp pool is the live path) --
// rework it against context/decomp-reference.md §5 "Projectile/Missile flight
// & expiry" before ever wiring it live.
// //
// Helper-function name mapping: // Helper-function name mapping:
// FUN_004be1bc Projectile base constructor (vtable @00512a5c) // FUN_004be1bc Projectile base constructor (vtable @00512a5c)
@@ -67,14 +53,12 @@
// //
// MoveAndCollide tuning constants (read-only globals in the decomp). // MoveAndCollide tuning constants (read-only globals in the decomp).
// //
// ⚠ names below carry the OLD (wrong) decode -- see the corrected table in the static const Scalar MissileLifeWarn = 2.0f; // _DAT_004bf594 age-ratio dead threshold
// header comment; kept only so the dormant skeleton still compiles unchanged. static const Scalar MissileSteerEps = 1.0e-4f;// _DAT_004bf598/9c/a0 steering deadband
static const Scalar MissileLifeWarn = 2.0f; // _DAT_004bf594 actually: replication keepalive (s) static const Scalar MissileTurnGain = 4.0f; // _DAT_004bf5a4
static const Scalar MissileSteerEps = 1.0e-4f;// _DAT_004bf59c/a0 actually: drag signed-square deadband static const Scalar MissileDriftGain = 0.1f; // _DAT_004bf5a8
static const Scalar MissileTurnGain = 4.0f; // _DAT_004bf5a4 actually: proximity-fuse radius (u) static const Scalar MissileMinSpeed2 = 10.0f; // _DAT_004bf5ac/a8 min squared-speed before fizzle
static const Scalar MissileDriftGain = 0.1f; // _DAT_004bf5a8 actually: dead-reckon divergence² static const Scalar MissileDead = -1.0f; // _DAT_004bf5b0
static const Scalar MissileMinSpeed2 = 10.0f; // _DAT_004bf5ac actually: coast margin (s) past burnTime
static const Scalar MissileDead = -1.0f; // _DAT_004bf5b0 kill-plane y (this one was right)
//########################################################################### //###########################################################################
//########################################################################### //###########################################################################
-25
View File
@@ -809,31 +809,6 @@ void Myomers::MyomersDriveHeat(Scalar time_slice)
Scalar termAccel = velComplement * velMag * accMag * mass * time_slice; Scalar termAccel = velComplement * velMag * accMag * mass * time_slice;
Scalar gain = ratio * ratio * damageGain; Scalar gain = ratio * ratio * damageGain;
// #137 DEPOSIT TRAP (BT_HEAT_LOG): conduction into the myomers is measured
// ZERO post-respawn (the [heatflow] trap), weapons deposit into themselves,
// so THIS add is the only writer left that can carry the observed one-shot
// slug (9e7..2.3e9, varying per respawn). The [myoheat] receipt is
// time-sampled and would miss a 1-2 frame spike; this prints EVERY add
// over 1e6 with the full operand set, unconditionally.
{
Scalar deposit = gain * (termClimb + termKinetic + termAccel);
if (getenv("BT_HEAT_LOG") != 0 && (deposit > 1.0e6f || deposit < -1.0e6f))
{
// WHOSE mech (master vs the peer's replicant shares this log!) and
// the acceleration COMPONENTS (pure-y = gravity accumulation on the
// wreck; planar = teleport/warp-derived).
Mech *om = (Mech *)owner;
const Vector3D &av = om->localAcceleration.linearMotion;
DEBUG_STREAM << "[myodep] mech=" << om->GetEntityID()
<< (om->GetInstance() == Entity::ReplicantInstance ? " REPL" : " mstr")
<< " deposit=" << deposit
<< " v=" << velMag << " a=" << accMag
<< " aXYZ=(" << av.x << "," << av.y << "," << av.z << ")"
<< " vy=" << vy << " dt=" << time_slice << " gain=" << gain
<< " climb=" << termClimb << " kinetic=" << termKinetic
<< " accel=" << termAccel << "\n" << std::flush;
}
}
pendingHeat /* @0x1C8 */ += gain * (termClimb + termKinetic + termAccel); pendingHeat /* @0x1C8 */ += gain * (termClimb + termKinetic + termAccel);
if (getenv("BT_MYO_LOG")) if (getenv("BT_MYO_LOG"))
-54
View File
@@ -1295,49 +1295,6 @@ void
void void
Generator::GeneratorSimulation(Scalar time_slice) Generator::GeneratorSimulation(Scalar time_slice)
{ {
// DEV: BT_GEN_HEAT=<kJ/s> pumps heat into the VIEWPOINT mech's Generator A
// for a bounded window (BT_GEN_HEAT_DELAY=<s> lead-in, default 10;
// BT_GEN_HEAT_SECS=<s> injection length, default 20) -- the deterministic
// thermal-trip bench for #154: drive T past FailureTemperature, stop, let
// it cool through DegradationTemperature, and correlate the [gen]
// TRIPPED/RESTARTING receipts against the [audioedge] receipts to observe
// which stateAlarm edge actually starts the warning sequence.
{
static const char *s_ghEnv = getenv("BT_GEN_HEAT");
if (s_ghEnv != 0)
{
static int s_ghSaid = 0;
if (s_ghSaid < 8)
{
++s_ghSaid;
DEBUG_STREAM << "[genheat] gate: gen#" << generatorNumber
<< " owner=" << (void *)owner
<< " viewpoint=" << (void *)application->GetViewpointEntity()
<< " T=" << currentTemperature
<< " mass=" << thermalMass << "\n" << std::flush;
}
}
if (s_ghEnv != 0 && generatorNumber == 1
&& (Entity *)owner == application->GetViewpointEntity())
{
static float s_ghClock = 0.0f;
static float s_ghDelay = -1.0f, s_ghSecs = -1.0f;
if (s_ghDelay < 0.0f)
{
const char *d = getenv("BT_GEN_HEAT_DELAY");
const char *s = getenv("BT_GEN_HEAT_SECS");
s_ghDelay = (d != 0 && *d != '\0') ? (float)atof(d) : 10.0f;
s_ghSecs = (s != 0 && *s != '\0') ? (float)atof(s) : 20.0f;
}
s_ghClock += time_slice;
if (s_ghClock >= s_ghDelay && s_ghClock < s_ghDelay + s_ghSecs)
{
heatEnergy += (Scalar)atof(s_ghEnv) * time_slice;
currentTemperature = heatEnergy / thermalMass;
}
}
}
HeatSink::HeatSinkSimulation(time_slice); // FUN_004ad924 HeatSink::HeatSinkSimulation(time_slice); // FUN_004ad924
if (simulationState == 1) // this[0x10] @0x40 if (simulationState == 1) // this[0x10] @0x40
@@ -1822,17 +1779,6 @@ int BTSubsystemIsGenerator(::Subsystem *sub)
return (sub != 0 && sub->IsDerivedFrom(*Generator::GetClassDerivations())) ? 1 : 0; return (sub != 0 && sub->IsDerivedFrom(*Generator::GetClassDerivations())) ? 1 : 0;
} }
// #135 bridge: the binary's generator lamp special @004cc27c is
// `table_0051d070[*(int*)(sub+0x1E0)]` -- indexed by generatorNumber (A=1..D=4),
// the same shape as the condenser special beside it. Expose the number so the
// alarm-lamp TU can do the authentic lookup (it had been probing the
// PoweredSubsystem aux-screen fields, which generators never fill -- the
// field-logged 'NO LAMP RESOLVED (generator=1)' silent leak).
int BTGeneratorNumber(::Subsystem *sub)
{
return BTSubsystemIsGenerator(sub) ? ((Generator *)sub)->generatorNumber : -1;
}
// Destruction -> OUT display state (see Generator::MarkGeneratorOut, the [T3 // Destruction -> OUT display state (see Generator::MarkGeneratorOut, the [T3
// inferred link]): a destroyed generator must reach stateAlarm 4 or the // inferred link]): a destroyed generator must reach stateAlarm 4 or the
// authored gotoEngineering/engEject FLASH invite (alarm conditions 4/5) never // authored gotoEngineering/engEject FLASH invite (alarm conditions 4/5) never
-12
View File
@@ -579,18 +579,6 @@ void
AmmoBin *bin = (AmmoBin*)ammoBinLink.Resolve(); // FUN_00417ab4(this+0x43c) AmmoBin *bin = (AmmoBin*)ammoBinLink.Resolve(); // FUN_00417ab4(this+0x43c)
// #166 forensics (ALWAYS on -- ejects are rare): every entry, with the
// state that decides which branch runs. Oracle's field jam (bin parked
// at Ejecting(3), rounds aboard, countdown dead) can only come from a
// press/release sequence this line will capture -- the prime suspect is
// a press whose bin-resolve fails falling through to the release block.
DEBUG_STREAM << "[weap] " << GetName() << " EJECT msg data=" << message->dataContents
<< " bin=" << (void *)bin
<< " ejectState=" << ejectState
<< " binState=" << (bin != 0 ? bin->GetAmmoState() : -1)
<< " rounds=" << (bin != 0 ? bin->GetAmmoCount() : -1)
<< "\n" << std::flush;
if (message->dataContents > 0 && bin != 0) // PRESS @4bb9d0/4bb9e9 if (message->dataContents > 0 && bin != 0) // PRESS @4bb9d0/4bb9e9
{ {
bin->SetAmmoState(AmmoBin::Ejecting); // SetLevel(bin+0x194, 3) bin->SetAmmoState(AmmoBin::Ejecting); // SetLevel(bin+0x194, 3)
+4 -49
View File
@@ -618,30 +618,6 @@ void
if (lsw != 0 && s_lockSweep <= 0.0f) s_lockSweep = 0.12f; if (lsw != 0 && s_lockSweep <= 0.0f) s_lockSweep = 0.12f;
if (s_lockSweep > 1.0f) s_lockSweep = 1.0f; if (s_lockSweep > 1.0f) s_lockSweep = 1.0f;
} }
// BENCH (BT_TWIST_PULSE=<n>): deflect the analog twist axis for n ticks,
// then RELEASE it for n ticks, repeating. BT_LOCK_SWEEP never releases,
// so it cannot show the reported symptom: with a stuck centerCommand the
// torso holds while you are actively pushing (the analog arm clears
// recenterActive) and snaps back the moment you let go (centerCommand
// re-arms it) -- "the torso centering FOUGHT my control". Measure the
// RELEASE windows: currentTwist should HOLD, not decay toward 0.
{
static const char *s_tp = getenv("BT_TWIST_PULSE");
if (s_tp != 0)
{
static int s_tpN = 0;
int period = atoi(s_tp);
if (period < 1) period = 120;
const int phase = (s_tpN++ / period) % 2;
analogTwistAxis = phase ? 0.0f : 0.6f;
if ((s_tpN % 30) == 0)
DEBUG_STREAM << "[twistpulse] phase=" << (phase ? "RELEASE" : "deflect")
<< " axis=" << analogTwistAxis
<< " twist=" << currentTwist
<< " ctrCmd=" << centerCommand
<< " recen=" << recenterActive << "\n" << std::flush;
}
}
if (s_lockSweep > 0.0f) if (s_lockSweep > 0.0f)
{ {
effectiveTwistRate = baseTwistRate; effectiveTwistRate = baseTwistRate;
@@ -670,13 +646,6 @@ void
<< " limits=(" << horizontalLimitRight << ".." << horizontalLimitLeft << ")" << " limits=(" << horizontalLimitRight << ".." << horizontalLimitLeft << ")"
<< " axis=" << analogTwistAxis << " axis=" << analogTwistAxis
<< " twist=" << currentTwist << " twist=" << currentTwist
// control-mode recenter state. centerCommand (@0x208) is the
// HELD-button cell -- if it reads 1 with no button down, the
// torso re-arms recenterActive every frame and digital twist is
// dead (Sauron's "lost torso control" after cycling modes).
<< " ctrCmd=" << centerCommand
<< " recen=" << recenterActive
<< " vLim=(" << verticalLimitBottom << ".." << verticalLimitTop << ")"
<< " wIdx=" << watchedSubsystem << " wIdx=" << watchedSubsystem
<< " w=" << (void*)w << " w=" << (void*)w
<< " wElec=" << (w ? w->electricalStateAlarm.GetLevel() : -1) << " wElec=" << (w ? w->electricalStateAlarm.GetLevel() : -1)
@@ -854,13 +823,6 @@ void
<< " vel=" << twistVelocity << " vel=" << twistVelocity
<< " lastUpd=" << lastUpdateTime << " lastUpd=" << lastUpdateTime
<< " now=" << GetCurrentTime() << " now=" << GetCurrentTime()
// #148: ComputeTargetTwist ends in Min(limitLeft)/Max(limitRight).
// If the COPY's limits never loaded they are 0/0, which pins
// targetTwist to EXACTLY 0 no matter what the record carried --
// which is what a peer stuck at zero twist would look like.
<< " limL=" << horizontalLimitLeft
<< " limR=" << horizontalLimitRight
<< " enab=" << (int)horizontalEnabled
<< " copy=" << (int)isDamagedCopy << std::endl; << " copy=" << (int)isDamagedCopy << std::endl;
} }
} }
@@ -935,21 +897,14 @@ void
// bring-up verification (env BT_TORSO_LOG; default OFF): show the first few // bring-up verification (env BT_TORSO_LOG; default OFF): show the first few
// joint writes so the per-frame path can be confirmed in a headless run. // joint writes so the per-frame path can be confirmed in a headless run.
// ⚠ SAMPLING TRAP (fixed 2026-08-08, #141): this used to sample ONE shared
// static every 30th call. With a master torso and a replicant COPY torso
// both ticking, the calls alternate 1:1 -- so every 30th call is always the
// SAME instance, and the probe reported only the local (untwisted) torso
// while the copy's writes were invisible. Sample per instance-kind instead.
static const int s_log = getenv("BT_TORSO_LOG") ? 1 : 0; static const int s_log = getenv("BT_TORSO_LOG") ? 1 : 0;
static int s_count[2] = { 0, 0 }; static int s_count = 0;
const int kind = isDamagedCopy ? 1 : 0; if (s_log && (s_count % 30) == 0 && s_count < 1800) // sample periodically to show the sweep
if (s_log && (s_count[kind] % 30) == 0 && s_count[kind] < 1800)
{ {
DEBUG_STREAM << "[torso] PushTwist " << (kind ? "COPY " : "master") DEBUG_STREAM << "[torso] PushTwist node=" << (void*)node << " type=" << (int)jt
<< " node=" << (void*)node << " type=" << (int)jt
<< " twist=" << (float)twist << "\n" << std::flush; << " twist=" << (float)twist << "\n" << std::flush;
} }
++s_count[kind]; ++s_count;
switch (jt) // node+0x10 switch (jt) // node+0x10
{ {
+1 -23
View File
@@ -235,30 +235,8 @@ class Joint; // engine skeleton node (JOINT.h); the twist target
// Controls (@0x1F0 twist, @0x1F4 elevation); proportional, no button ramp. // Controls (@0x1F0 twist, @0x1F4 elevation); proportional, no button ramp.
void SetAnalogTwistAxis(Scalar v) { analogTwistAxis = v; } void SetAnalogTwistAxis(Scalar v) { analogTwistAxis = v; }
void SetAnalogElevationAxis(Scalar v) { analogElevationAxis = v; } void SetAnalogElevationAxis(Scalar v) { analogElevationAxis = v; }
void CommandRecenter() { centerCommand = 1; } // @0x208 HELD button -- writer MUST clear it void CommandRecenter() { centerCommand = 1; } // @0x208 (Basic-mode re-center)
void ClearRecenterCommand() { centerCommand = 0; } // button released (writer-owned state) void ClearRecenterCommand() { centerCommand = 0; } // button released (writer-owned state)
// ⚠ centerCommand (@0x208) is a HELD-BUTTON cell: TorsoSimulation re-arms
// `recenterActive` from it EVERY frame it is non-zero, and only the input
// path clears it. Do NOT use CommandRecenter() for a one-shot recenter --
// nothing releases it and the torso re-centres forever, which reads to the
// pilot as "lost torso control" (Sauron, control-mode cycle).
//
// The one-shot the mode switch actually wants is recenterActive (@0x274)
// itself: TorsoSimulation runs `recenterActive = Recenter(dt)`, so it
// SELF-CLEARS on settle, and any twist input cancels it. This is exactly
// what the binary writes -- `*(torso + 0x274) = 1` @004afbe0.
void BeginRecenterOnce() { recenterActive = 1; } // @0x274 one-shot (@004afbe0)
// The TWO authored elevation-limit pairs the control mode swaps between
// (@004afbe0). BASIC gets @0x228/@0x22C (full top, HALF bottom -- reduced
// downward travel); STANDARD/VETERAN get @0x230/@0x234 (the full authored
// pair). Before 2026-08-08 all four were written by the ctor and never
// read by anything -- the port simply never implemented the swap.
void ApplyBasicElevationLimits()
{ verticalLimitTop = elevationCenter; verticalLimitBottom = elevationHalfBottom; }
void ApplyAssistedElevationLimits()
{ verticalLimitTop = twistCenterHigh; verticalLimitBottom = twistCenterLow; }
Logical GetHorizontalEnabled() const { return horizontalEnabled; } // @0x250 (mapper free-aim gate @004afd10) Logical GetHorizontalEnabled() const { return horizontalEnabled; } // @0x250 (mapper free-aim gate @004afd10)
// Reachable horizontal (yaw) half-arc the guns can be brought to bear by // Reachable horizontal (yaw) half-arc the guns can be brought to bear by
+2 -3
View File
@@ -93,9 +93,8 @@ if defined BT_STILL_RUNNING (
goto btwait goto btwait
) )
echo. echo.
echo The game has exited. (Quitting from the menu lands here -- normal.) echo The game has exited. If it closed unexpectedly, send the operator
echo If it CRASHED or would not start, send the operator BOTH of these echo BOTH of these from the content folder:
echo from the content folder:
echo content\lastrun_solo.txt (tiny -- send it EVEN IF there is echo content\lastrun_solo.txt (tiny -- send it EVEN IF there is
echo no solo_*.log at all: that combination echo no solo_*.log at all: that combination
echo is how we spot a blocked launch) echo is how we spot a blocked launch)
+1 -4
View File
@@ -97,10 +97,7 @@ if defined BT_STILL_RUNNING (
goto btwait goto btwait
) )
echo. echo.
echo The game has exited. echo The game has exited. If it closed unexpectedly, send the operator BOTH
echo (Normal exits land here too: round ends return everyone to the menu,
echo and closing the menu window quits -- neither is a crash.)
echo If it CRASHED or would not start, send the operator BOTH:
echo content\lastrun_steam.txt (tiny -- send it EVEN IF there is no echo content\lastrun_steam.txt (tiny -- send it EVEN IF there is no
echo steam_*.log at all: that is how we spot echo steam_*.log at all: that is how we spot
echo a launch blocked before it ran) echo a launch blocked before it ran)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

-127
View File
@@ -1,127 +0,0 @@
"""Night 13: close #137 (not a bug) and file the desktop throttle-latch follow-up. ASCII only."""
import sys
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
BODY_137 = """**NOT A BUG -- closing (2026-08-08).** The reset is correct on both sides. The
heat is real, and the mech earned it: it respawns still under power.
## What the field log actually shows
From Sauron's match log (`steam_20260806_c_michael_XIAOLONG.log`), the respawn at line 39450:
```
39450 [respawn] Mech::Reset 3:30 healed+moved to (...) alive=1 zones=21 subsys=33
[techstat] SLaser_1 / LLaser_1 / HUD / Gyroscope / Avionics / GeneratorA / HeatSink
... condition CLEARED <- every live condition clears
[techstat] Myomers condition 3 SET <- Overheating, immediately
[mppr] in thr=1 pre=1 -> ...
[gaitSM] cycleSpeed=14.6 legCycle=14.6 state=12 <- already RUNNING
[techstat] Condenser5 condition 3 SET <- "dumping into coolant loop 5"
39519 [techstat] GeneratorD condition 3 SET <- Sauron's generator D
```
`[rstat]` fires about once per second (98 frames @ 10.2ms), and the reset plus the myomers trip sit
inside one rstat block -- so myomers overheat in well under a second, generator D in one to two.
The reset itself is clean: **every** live condition CLEARs 12-16 lines after each `Mech::Reset`, at
every respawn in the log. The synthetic bench agrees -- every roster subsystem, including all six
Condensers, reads `T=77 start=77` at the reset.
## Why it looked like maxed heat
**`condition 3` is an operating flag, not an alarm.** Census over the whole match:
| subsystem | cond 3 SET | CLEARED |
|---|---|---|
| LLaser_2 | 33 | 33 |
| LLaser_1 | 31 | 31 |
| SRM4 | 26 | 26 |
| PPC_2 | 18 | 18 |
| GeneratorD | 5 | 4 |
| Myomers | 5 | 4 |
| Condenser5 | 1 | 1 |
Every weapon trips Overheating on each volley and clears it on cooldown. **Every subsystem is
balanced** -- the odd extra SET is only the log ending mid-heat. Nothing latches, nothing sticks.
`condition 6` (BadPower) behaves the same way (Myomers 8/8, PPC_1 3/3, MLaser_1 3/3) as the bus
browns out under simultaneous draw. GeneratorD's longest continuous overheat came out normally.
So the post-respawn SET is the mech *operating*, not a failed reset.
## Why the heat arrives instantly
The mech comes back **at whatever throttle the pilot left**: `thr=1`, `cycleSpeed=14.6`, gait state
12 at the instant of reset. `Mech::Reset`'s subsystem loop starts at **index 2**, and the
ControlsMapper is **index 0** -- so the throttle is never reset.
**The binary does exactly the same.** That is correct for a pod: the throttle is a physical lever
still under the pilot's hand. Respawning under power is authentic behaviour.
Oracle's read that the myomer heat rate "felt right" was correct, and matches the data.
## Ruled out along the way
`Mech::Reset` dispatch, the full subsystem RTIS chain (Generator `@004b215c` is an
instruction-for-instruction match; HeatSink `@004ad760` faithful; every RTIS class has a
DeathReset), stale coolant-loop links (`linkedSinks` is written only in the streaming ctor and
cannot drift), and the valve fractions (`BTRecomputeCondenserValves == @0049f788`).
## Follow-up
The one genuine defect found is a desktop-only input issue, filed separately -- see the throttle
latch ticket. It does not affect the pod.
Knowledge base updated so this is not re-chased: `context/decomp-reference.md` §TechStatus (the
routine/self-clearing semantics + this post-mortem) and `context/gauges-hud.md`."""
TITLE_NEW = "Desktop/Steam: virtual throttle lever survives death -- you respawn already running"
BODY_NEW = """Split out of #137, which was closed as not-a-bug. The heat model and the reset are
both correct; this is a port-layer input issue and it affects **desktop/Steam only, never the pod**.
## The defect
On the pod the throttle is a physical lever. Respawning under power is authentic -- the pilot's hand
is on it, they can see and feel where it is, and `Mech::Reset` deliberately does not touch it (the
subsystem loop starts at index 2; the ControlsMapper is index 0). The binary behaves the same way.
The desktop glass bridge emulates that lever with a **file-static ramp accumulator**:
* `sLever` -- `static float sLever = 0.0f;` (`game/reconstructed/mech4.cpp:3250`)
* published each frame as `gBTDrive.throttle = sLever` (mech4.cpp:3892/3897)
* consumed as `key_throttle` -> `throttlePosition` (mechmppr.cpp:677, :780)
The only things that zero `sLever` are the **X-button all-stop** (mech4.cpp:3754) and a
direction-crossing snap (:3842). **Nothing on death or respawn touches it.**
So a pad/keyboard pilot who dies at speed respawns at speed -- while physically holding nothing, and
with the lever position invisible to them. The pod's authenticity argument does not cover this case:
there is no lever to feel, and a gamepad stick self-centers, so the input affordance actively
contradicts the latched state.
Observed consequence in the field (Sauron's log, the #137 evidence): the mech leaves the drop zone
at `thr=1` / `cycleSpeed=14.6` the instant it spawns, and the myomers, Condenser5 and GeneratorD all
trip Overheating within one to two seconds. Reads to the player as "respawned with heat maxed".
Note this is **not** the same as the Thrustmaster/RIO path, which is already correct:
`MechThrustmasterMapper::InterpretControls` (`@004d2150`) recomputes `throttlePosition` from scratch
every frame off the live `throttleForward`/`throttleReverse` states, and `throttleForward` is
databound (written by the input layer each frame). Only the desktop `sLever` accumulator latches.
## Suggested fix
Zero `sLever` (and the detent) on respawn in the desktop bridge only -- the same treatment the
X all-stop already applies -- so a desktop pilot comes back stopped. Gate it so pod builds keep the
authentic physical-lever behaviour.
This is a gameplay-behaviour decision as much as a bug fix: it changes whether desktop players
respawn moving or stopped. Worth a call before it ships.
## Not yet done
Not fixed, not benched. Filed from a static read of the input path plus the field log."""
gitea.close(137, BODY_137)
num = gitea.create(TITLE_NEW, BODY_NEW)
print("closed 137; created:", num)
-147
View File
@@ -1,147 +0,0 @@
"""Night 13: report #141 (fixed) and file the torso-cadence follow-up. ASCII only."""
import sys
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
BODY_141 = """**ROOT CAUSE FOUND AND FIXED** (`f01de8c` + sweep `e6c5ac9`), reproduced and measured on a
2-node bench (`scratchpad/night13/missileframe.sh`).
## Reproduced
Only A sweeps its torso and only A fires, so every REPLICANT line in B's log mirrors one A salvo.
New `[launchframe]` receipt (`BT_PROJ_LOG`) prints the yaw of the launch forward vs the BODY forward:
| | n | max abs(twistDelta) | mean | >0.1 rad |
|---|---|---|---|---|
| master | 165 | 2.2962 | 1.2283 | **100%** |
| REPLICANT | 165 | **0.0000** | 0.0000 | **0%** |
`segResolved=1` on both, and `segYaw == bodyYaw` EXACTLY on the peer -- the launch frame was the
bind pose.
## What it was NOT
Everything upstream was already correct, which is why it looked like netcode. Both sides pass the
mount segment (`GetSegmentIndex()`, task #67 -- master `mislanch.cpp:363`, replicant mirror `:478`).
The peer's torso data is fine end to end: records arrive (`atUpd=2.44/-2.39`, `rate=0.305`), the
copy extrapolates correctly (`cur=-2.13987 target=-2.13987 copy=1`), and the copy torso
demonstrably writes its joint (`PushTwist COPY twist=-1.49601`). Hierarchy identical on both nodes
-- same seg 18, same `parentIdx=4`, non-null parent and joint subsystem.
The twist reached the joint and died at the **segment cache**.
## Root cause
`MechWeapon::GetMuzzlePoint` `@004b9948` ends in `FUN_00424da8(owner, segment, out)`, which is
`JointedMover::GetSegmentToWorld` instruction-for-instruction:
```c
iVar1 = FUN_00417ab4(param_1 + 0x31c); // GetJointSubsystem()
if (*(int *)(iVar1 + 0xfc) != 0) { // AreJointsModified() <- TESTED, never set
... walk owner+0x300, seg+0xc = 1 ... // ModifySegment() on every segment
*(int *)(iVar1 + 0xfc) = 0; // ModifyJoints(False)
}
FUN_0040b104(out, FUN_004244dc(seg), owner+0xd0); // x localToWorld
```
**In the 1995 image every muzzle query performs the joints->segments refresh.** Our
`BTResolveWeaponMuzzle` -- labelled "the faithful FUN_004b9948" -- hand-composed
`GetSegmentToEntity() x localToWorld` and skipped it. `GetSegmentToEntity` only recomputes when
`segmentModified` is already set (`SEGMENT.cpp:262`), so it returned a stale cache. On the MASTER
that was invisible (the render pass refreshes the local mech every frame, after its torso pushes
the joint); a REPLICANT gets no such refresh, so peer muzzles sat at the bind pose.
## Fix
Route the muzzle path through the engine accessor, where the binary puts it. **No forced dirty
flag** -- an earlier attempt set `ModifyJoints(True)` and scored the same, so it bought nothing and
was removed; the binary only ever tests that flag.
Swept the same unfaithful pattern at three more sites: the generic segment->world bridge, the
damage-effect anchor, and **the energy-beam gun port** -- a peer's BEAM had the identical exposure
and would also have originated from the untwisted gun port. Repo-wide there is now exactly one
`GetSegmentToEntity` call outside `SEGMENT.cpp`: inside `GetSegmentToWorld` itself, after the
refresh.
## Result
| | n | max | mean | >0.1 rad |
|---|---|---|---|---|
| master | 165 | 2.1719 | 1.2781 | 100% |
| REPLICANT | 165 | **2.0907** | 0.8201 | **64%** |
**The 64% is not a partial fix.** The failures are a contiguous PREFIX with zero interleaved cases:
```
ZZZZ...(60)...ZZZZXXXX...(105)...XXXX
```
and they end exactly when the peer acquires a twist to carry:
```
first torso RECORD received : line 206
first copy currentTwist != 0 : line 1016
first CORRECT launch frame : line 1054 (38 lines = probe sampling granularity)
```
Those 60 salvos fired while the replicated twist was genuinely 0, so launching along the body
facing was CORRECT. Once the peer has a twist, 100% of launches carry it.
## Field-verify
Unreleased. Next playtest: have a peer watch a twisted mech fire missiles -- rounds should leave
along the torso, not the feet. Also worth checking beams for the same reason (same fix).
Follow-up filed separately: the peer's torso takes far too long to FIRST acquire the master's
twist."""
TITLE_NEW = "Peer torso twist takes far too long to first sync -- only 13 update records across a 5-minute run"
BODY_NEW = """Split out of #141, whose launch-frame defect is fixed. This is a separate, measured
problem in the torso REPLICATION CADENCE.
## Measurement
From the #141 bench (`scratchpad/night13/missileframe.sh` / `missileframe2.sh`, 2 nodes, node A
sweeping its torso continuously at 0.35 rad/s for the whole run):
```
first torso RECORD received on the peer : line 206
first copy currentTwist != 0 : line 1016
```
and across the entire ~5 minute run the peer received only **13** `[torso-rec-rx]` records, despite
the master's twist changing continuously the whole time.
So the master was twisted from very early on, while the peer's copy torso reported `currentTwist`
of exactly 0 for a long stretch afterwards. The extrapolator itself is fine once fed --
`ComputeTargetTwist` predicts `twistAtUpdate + twistRate * elapsed` and the copy tracks its target
exactly (`cur=-2.13987 target=-2.13987 copy=1`). The problem is how rarely it is fed, and how late
the first useful feed arrives.
## Why it matters
* It is the entire reason #141's fix reads 64% instead of 100% on the bench -- 60 salvos fired
before the peer had any twist to carry.
* A peer's torso will visibly LAG or sit straight while the mech is actually twisted. That is
plausibly relevant to **#37** (MadCat torso is BACKWARDS) and **#70** (torso twist stops working
after respawn) -- worth re-testing both against this once it is understood.
## Not yet investigated
Whether 13 records is the authentic cadence (the binary may deliberately send torso updates rarely
and lean on `twistRate` extrapolation to cover the gaps -- in which case the bug is that our
extrapolation is not running or not seeded until late), or whether our send-side gate is simply too
conservative. `Torso::WriteUpdateRecord` snapshots `twistAtUpdate = currentTwist` at send, so the
send trigger is the thing to read first.
Diagnostics already in place: `BT_TORSO_LOG` gives `[torso-rec-rx]` (receive), `[torso-copy]`
(the copy's cur/target/atUpd/rate), and `[torso] PushTwist master|COPY` (per instance-kind -- note
that probe previously sampled one shared static every 30th call, which with two torsos ticking 1:1
always reported the SAME instance and hid the copy entirely; fixed in `05d7b58`)."""
gitea.comment(141, BODY_141)
gitea.call("/issues/141", method="PATCH", payload={"state": "closed"})
print("commented + closed #141")
num = gitea.create(TITLE_NEW, BODY_NEW)
print("created #%d" % num["number"])
-81
View File
@@ -1,81 +0,0 @@
"""Night 13: close #148 as not-a-bug with the measured chain. ASCII only."""
import sys
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
BODY = """**NOT A BUG -- closing (2026-08-08).** There was nothing to fix here. The bench was lying,
and this ticket's own premise was wrong twice over.
## The answer
`Entity::Execute` (`ENTITY.cpp:556`, real engine source [T0]) calls `PerformAndWatch` **only** when
```cpp
application->GetApplicationState() == Application::RunningMission
|| application->GetApplicationState() == Application::EndingMission
|| IsPreRunnable()
```
and otherwise merely `WriteSimulationUpdate()`s. `Entity::DefaultFlags` is
`DynamicFlag|MasterInstance` -- **no `PreRunFlag`**. Only `Player` and `Director` add it in their
DefaultFlags, and `Mech::Reset` sets it for a reset MASTER ("a reset master must tick"). A
**replicant mech never gets it.**
So a peer mech performs **zero** subsystem ticks until the round actually starts, no matter how
much correctly-replicated data is arriving for it. Measured on the observer node:
```
235 [perf-first] mech 3:161 master <- own mech, immediately
402 [torso-rec-rx] <- peer's torso records start arriving
2754 [perf-first] mech 2:55 REPLICANT <- peer's FIRST performance
2758 [torso] PushTwist COPY <- its torso ticks 4 lines later
2761 [ent-exec] state=5 <- RunningMission
```
The peer starts performing exactly at the RunningMission transition. That is the engine doing
what it says it does.
## So the symptom was a BENCH ARTIFACT
`BT_AUTOFIRE` starts shooting immediately, during `WaitingForLaunch` -- something no player can do
in a real match. Those leading salvos measured a peer whose torso, gait and subsystems had never
run. Every `ZZZZ...XXXX` prefix in this investigation was that, and the first `X` lands within a
few lines of the state transition.
**#141 is unaffected and stays fixed** -- its segment-cache defect was real and mid-match.
## Ruled out along the way (all measured, all recorded so nobody repeats them)
* **The record cadence is authentic.** My "only 13 records in 5 minutes" premise was wrong. The
payloads are the sweep EXTREMES with `rate` flipping sign at each one -- the master sends on
**rate change** and the peer dead-reckons `atUpd + rate * elapsed` between them. 12 records for
12 direction reversals is correct, not starved.
* **The `ComputeTargetTwist` clamp.** The copy's limits load correctly (`limL=2.44346
limR=-2.44346 enab=1`), so `Min/Max` was not pinning `targetTwist` to zero.
* **The torso's own executable flag.** `Entity::Perform` picks its predicate by instance
(`IsNonReplicantExecutable` vs `IsReplicantExecutable`, differing on
`|| lastUpdate >= lastPerformance`), and Mech's tick loop had dropped that branch. Restoring it
(`f36f013`) is a genuine fidelity fix and is kept -- but it moved this bug by nothing.
* **The scheduler.** The replicant entity IS offered to the performer with `executable=1` from
line 171, ~2500 lines before its first `PerformAndWatch`. The gate was inside `Execute`, not in
who gets offered.
## What came out of it
* `[perf-first]` -- a one-shot per-mech receipt naming entity ID + instance at a mech's first
performance. Every other per-frame receipt in mech4 is anonymous, which is exactly why this took
so long to see in a 2-node log.
* `[torso-copy]` now prints `limL/limR/enab`; `[launchframe]` prints the shooter's live torso twist.
* **Gotcha #29** in `context/reconstruction-gotchas.md`: judge a 2-node bench by PREFIX vs
INTERLEAVED, never by raw percentage; check `[ent-exec] state=` before suspecting replication.
`missileframe.sh` carries the same warning inline.
## Still worth checking separately
`#37` (MadCat torso BACKWARDS) and `#70` (torso twist stops after respawn) were flagged here as
possibly sharing a cause. They do **not** share this one -- it is not a defect. They should be
re-tested against the #141 segment-cache fix instead, which is a real mid-match change."""
gitea.comment(148, BODY)
gitea.call("/issues/148", method="PATCH", payload={"state": "closed"})
print("closed #148")
-90
View File
@@ -1,90 +0,0 @@
"""Night 13: report the #52 root cause on the tracker. ASCII only."""
import sys
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
BODY = """**ROOT CAUSE FOUND AND FIXED (2026-08-07, commit `6a96fb6`)** -- but read the
"what is NOT proven" section before treating this as closed.
## The defect
A replicant cannot start walking between gait-change records.
The port's body `case 4` (the task-#64 lockstep twin, mech2.cpp) is an INSERTION sitting between
`case 0` and the advance group. In the binary it is a MEMBER of that group -- `FUN_004a5678`
@004a5678 reads `case 2,3,`**`4`**`,5,8,...` with no turn block and no speed exit [T1] -- so case
0's fallthrough is meant to land on `Advance()`. The insertion intercepted it.
On a replicant that is not a race, it is an identity:
* case 0 arms walk iff `standSpeed < bodyTargetSpeed`
* the inserted block resets iff `standSpeed < bspd`, and `bspd` **IS** `bodyTargetSpeed` on a replicant
Same expression. Arm and reset fire on the same frame, every frame. A peer parked at Standing with a
live replicated demand never cycles: `bodyCycleSpeed` stays 0 while position advances from dead
reckoning. That is the skate. (Reverse is dead the same way -- both sides test `< ZeroSpeed`.)
## Why it appeared when it did
This is the sequel to `e91d447` (#82). Before that commit the replicant branch read the LOCAL
mapper's `speedDemand` -- a dead cell on a peer, 0 forever -- so the exit never fired and the
fallthrough worked BY ACCIDENT. Fixing the dead cell (correctly) closed the accidental escape
hatch, and the trn-lock skate came back as a Standing-lock skate.
## Why masters were unaffected
Two reasons, either sufficient: their two tests read DIFFERENT cells (`bodyTargetSpeed` held at
last-sent by the gait mirror, vs the live mapper `speedDemand`), so they only stall in the window
where those disagree; and the master's body channel runs `mj=0` and writes no joints, so its stall
is invisible -- the leg channel, whose case 0 falls through correctly, drives pose and travel.
## The load-bearing detail
`mech4.cpp`'s "stand; case 0 walk-begins next tick" is not an aside. A peer's body state is set from
`record->legState` only on **type-3 edges**, and ENTERING Standing emits one while LEAVING it does
not. Between gait-change records a replicant is REQUIRED to derive walking itself from the
replicated demand. The insertion removed that ability.
This also explains the shape of the field data: the lock needs a mech holding a STEADY demand, so a
mech whose gait keeps changing is continually rescued by records. Night 13's four episodes all carry
`bodyTargetSpeed` 39-48 held across 100-400 frames, and each ended when that mech next changed gait.
## Fix
`case 0` -> `goto advance_body_normally`, the leg twin's own idiom, restoring the binary's structure
without touching the #64/#82 turn logic. `BT_NO_BODY_FALLTHRU=1` reverts.
## Measured (2-node, `scratchpad/night13/skatelock.sh`)
| | legacy | fixed |
|---|---|---|
| STANDING-LOCK seconds | **336 consecutive**, `bspd=39.2324 bts=39.2324` identical every line | **0**, every pass |
| master body-Standing samples | 52 | 21 |
| turn-in-place | -- | body state 4 x9 / leg state 4 x8, armed in lockstep |
## What is NOT proven [T3]
That this accounts for the night-13 episodes. The lock is proven and proven removed; the link to the
field symptom is INFERENCE -- a locked peer has `bodyCycleSpeed==0` and never advances its clip, so
locked + translating IS the `[skate]` signature by construction -- but no bench caught the two
together. Four rigs failed to reproduce the symptom end-to-end.
The `[skate]` line now carries `bstate=`, which is the diagnostic tonight's logs lacked. Next
playtest settles it: episodes gone -> confirmed; any survivor names its own state.
**Correction for the record:** the night-12 `skatebench` "reproductions" (6 episodes, `sk_run.out`)
were a DETECTOR ARTIFACT, not this bug. The first detector build tested only `legCycleSpeed==0`,
which is NORMAL on a peer -- the body channel poses it -- so it fired on every healthy movement
phase. It was corrected the same day to require both channels idle. Old-format lines
(`legCycleSpeed=`, no `bodyCyc=`) are not evidence of anything.
Related: #130 (Vulture skating) is very likely the same defect -- re-test it against this build
before spending separate effort."""
gitea.comment(52, BODY)
gitea.comment(130,
"Cross-ref: #52's root cause was found and fixed 2026-08-07 (`6a96fb6`) -- a replicant could not "
"start walking between gait-change records, so a peer parked at Standing with a live replicated "
"demand kept its position advancing with a dead animation channel. That is the same shape as the "
"skating reported here. **Re-test this against a build newer than `6a96fb6` before investigating "
"separately.** Full write-up in #52.")
-61
View File
@@ -1,61 +0,0 @@
#!/usr/bin/env bash
# =========================================================================
# #142 CROUCH does not toggle -- name the gate.
#
# FIELD (Oracle, night 13): "the crouch button did not toggle to display
# crouched ... no state change ... the light next to it always flashes when
# pressed, but state does not change. Remains in stand mode."
#
# That is the EJECT bug's signature: the press reaches the handler (lamp
# responds) and a GATE silently declines it. DuckRequestMessageHandler
# always succeeds -- it just sets duckState=1. The CONSUMER (mech4.cpp) is
# where it dies:
#
# if (duckState != 0 && squatCapable != 0) {
# if (mapPosture == 1) squat;
# else if (mapPosture == 2) rise;
# else if (BT_DUCK_LOG) log; <- only diagnostic, env-gated
# duckState = 0;
# }
#
# squatCapable == 0 skips the whole block: no log, and the latch is not even
# consumed. A new ungated [duck] REQUEST DROPPED receipt covers both misses.
#
# Runs the SAME chassis twice is pointless -- squatCapable is per-model
# ('squ'/'sqd' clips shipped), so sweep several. CROUCH is button 0x13
# (shipped bindings: left column 0x10-0x15 = map+/map-/IR/CROUCH/searchlight/
# display). BT_BTNTEST2 exists precisely for "crouch then rise".
# =========================================================================
set -x
V="${1:-madcat}"
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f cr_${V}.log
bt_expert_egg MP.EGG CR.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=${V}/" CR.EGG
( export BT_BTNTEST=0x13,900,960 # CROUCH press
export BT_BTNTEST2=0x13,1500,1560 # and again (toggle back / retry)
export BT_DUCK_LOG=1 BT_GAIT_LOG=1 BT_KEY_NOFOCUS=1
bt_launch cr_${V}.log CR.EGG 0x03 )
for i in $(seq 1 60); do grep -aq "btntest" cr_${V}.log 2>/dev/null && break; sleep 2; done
sleep 45
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 2
echo "=================== #142 CROUCH vehicle=$V ==================="
echo "--- 1. did the press reach the handler? ---"
grep -a "btntest" cr_${V}.log | head -4
grep -a "DuckRequest" cr_${V}.log | head -3
echo
echo "--- 2. THE GATE: why was it dropped? ---"
grep -a "REQUEST DROPPED" cr_${V}.log | head -4
echo -n "dropped-receipt count: "; grep -ac "REQUEST DROPPED" cr_${V}.log
echo
echo "--- 3. did a posture change actually happen? ---"
grep -aE "\[duck\] (SQUAT|RISE)" cr_${V}.log | head -4
echo -n "SQUAT/RISE events: "; grep -acE "\[duck\] (SQUAT|RISE)" cr_${V}.log
echo
echo "--- 4. does this chassis even ship the squat clips? ---"
grep -aiE "squatCapable|squ.*clip|sqd" cr_${V}.log | head -4
-59
View File
@@ -1,59 +0,0 @@
"""#142 -- correct the report and record what benching established. ASCII only."""
import sys
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
gitea.comment(142, """**RE-SCOPED 2026-08-07 -- the mech crouches fine; this is a missing PANEL ANIMATION.**
The original report ("the crouch button did not toggle to display crouched ... the light next to
it always flashes when pressed, but state does not change") reads like a locomotion or lamp bug.
Benching says it is neither.
## What works (benched, `scratchpad/night13/crouch142.sh` + `crouchmp.sh`)
* SOLO, madcat: both presses reach `DuckRequestMessageHandler`, zero drops, `[duck] SQUAT (posture
1 -> leg clip 2)` then `squat clip parked`, then `[duck] RISE`. A full toggle cycle.
* MULTIPLAYER, same chassis: identical -- 2 requests, 0 drops, SQUAT + RISE. So the MP path is not
refusing it either.
A receipt was added at the consumer's silent miss (`[duck] REQUEST DROPPED`, ungated) covering both
gates -- `squatCapable == 0` (which skips the consumer entirely AND leaves `duckState` latched at 1,
with no log at all today) and `mapPosture` not 1/2 (previously logged only under `BT_DUCK_LOG`,
which no player sets). Neither fired on madcat. Chassis without `squ`/`sqd` clips are still
untested; the bench takes a vehicle argument for that sweep.
## What the pilot actually sees
The crouch button's LAMP is momentary press feedback, not state. Traced with `BT_LAMP_LOG`:
PRESS -> [lamp] 0x13 <- 0x3c (lit)
SQUAT -> mech crouches, clip parked
RELEASE -> [lamp] 0x13 <- 0x14 (unlit) <-- while still CROUCHED
PRESS2 -> [lamp] 0x13 <- 0x3c
RISE -> mech stands
RELEASE -> [lamp] 0x13 <- 0x14
Crouched and standing are visually identical, so the lamp can never carry posture.
## What it is SUPPOSED to be (era testimony, this day)
The operator, correcting the framing: the crouch button is supposed to **animate a MECH SYMBOL next
to the button**, standing <-> crouching. Not a two-state lamp -- a missing animation.
VGL Lynx: *"When a mech stops, crouch button lowers its stance and plays crouch animation. Mech is
immobilized until crouch is pushed again, and mech rises."* Draco: *"Checks out with my memories."*
## Two gaps, and the second is the reported one
1. **No immobilization while crouched.** Nothing in the port gates movement on `duckState` or the
parked leg alarm -- grep of every consumer of both across `game/reconstructed/` finds no
speed/throttle/demand gate. Per Lynx the mech should be immobile until the button is pushed
again. Worth checking what a crouched mech that IS driven does today, since the leg clip is
parked -- a moving mech with a parked leg channel is the [skate] signature (#52).
2. **No stance symbol.** No gauge element anywhere in `btl4gau*.cpp` draws a posture/stance icon,
and the decomp has no crouch/squat/stance/duck graphic STRING (only substring false positives:
"existance", "distance"). So it is likely an authored IMAGE element on the secondary/radar MFD
rather than something findable by name -- next step is the secondary MFD's element list in the
binary, not another string search.
Not fixed. Re-scoped, with the locomotion half cleared and the real target named.""")
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env bash
# =========================================================================
# #142 CROUCH in MULTIPLAYER -- solo works, so MP is the variable.
#
# crouch142.sh proved a madcat crouches and rises cleanly SOLO: both presses
# reached DuckRequest, zero REQUEST DROPPED, SQUAT then RISE fired. The
# reporters were in MP, and the wording matters:
# Oracle: "the crouch button did not toggle to DISPLAY crouched ... no state
# change ... the light next to it always flashes when pressed, but
# state does not change. Remains in stand mode."
#
# Three things that could produce that with the mech itself working:
# (a) MP-only refusal -- some gate differs on a networked master
# (b) replication -- master squats, PEER never poses it
# (c) indicator -- mech squats, but the button LAMP / state readout
# never latches, so the pilot sees "stand"
#
# Same chassis as the passing solo run, so MP is the only changed variable.
# A presses crouch twice; B observes. Read all three layers.
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
bt_assert_player_env
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f cm_a.log cm_b.log cm_relay.log
bt_expert_egg MP.EGG CM.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" CM.EGG
( export BT_DUCK_LOG=1 BT_GAIT_LOG=1 BT_MP_LOG=1
bt_launch cm_b.log CM.EGG 0x0C -net 1601 )
sleep 2
( export BT_BTNTEST=0x13,900,960 BT_BTNTEST2=0x13,1800,1860
export BT_DUCK_LOG=1 BT_GAIT_LOG=1 BT_MP_LOG=1 BT_KEY_NOFOCUS=1
bt_launch cm_a.log CM.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py CM.EGG 127.0.0.1:1501 127.0.0.1:1601 > cm_relay.log 2>&1 &
RELAY=$!
sleep 240
kill $RELAY 2>/dev/null
sleep 3
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
AID=$(grep -aoE "MY mech entityID=[0-9]+:[0-9]+" cm_a.log | head -1 | cut -d= -f2)
echo "=================== #142 CROUCH in MP (A=${AID:-?}) ==================="
echo "--- (a) did A's press reach the handler, and was it dropped? ---"
grep -a "btntest" cm_a.log | head -4
echo -n "DuckRequests : "; grep -ac "DuckRequest" cm_a.log
echo -n "REQUEST DROPPED: "; grep -ac "REQUEST DROPPED" cm_a.log
grep -a "REQUEST DROPPED" cm_a.log | head -3
echo
echo "--- (b) did A's own mech actually change posture? ---"
grep -aE "\[duck\] (SQUAT|RISE|squat clip)" cm_a.log | head -6
echo -n "SQUAT/RISE on A: "; grep -acE "\[duck\] (SQUAT|RISE)" cm_a.log
echo
echo "--- (c) did the PEER pose the squat? (legState ships in the type-3 record) ---"
echo -n "B duck lines for A: "; grep -ac "\[duck\]" cm_b.log
grep -a "\[duck\]" cm_b.log | head -4
echo -n "B legState-2/3 sightings: "; grep -aoE "legState=[23]" cm_b.log | wc -l
echo
echo "--- (d) the INDICATOR: does anything latch a crouched state? ---"
grep -aiE "lamp.*0x13|stability|duckState" cm_a.log | head -6
-53
View File
@@ -1,53 +0,0 @@
#!/usr/bin/env bash
# =========================================================================
# #142 -- does CROUCH reset on respawn? (Oracle: "crouch wasn't resetting
# on respawn ... i guess mechs always spawn standing")
#
# Mech::Reset (@0049fb74) stands the mech up in the binary:
# *(this+0x398) = 0 duckState
# legStateAlarm -> 0 (this+0x39c)
# bodyStateAlarm -> 0 (this+0x714)
# death + leg/body reset latches, idleStrideScale = 1.0
# The port had dropped every one of those, so a pilot who died CROUCHED came
# back crouched.
#
# THE TEST: A crouches, then is killed while crouched, then respawns.
# PASS: after "[respawn] Mech::Reset", the leg alarm is 0 (standing) and
# duckState is 0 -- and no squat clip is parked.
# FAIL: legLvl stays 1 (parked in 'sqd') across the respawn.
#
# B force-damages A so the death lands while A is parked in the squat.
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f dr_a.log dr_b.log dr_relay.log
bt_expert_egg MP.EGG DR.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" DR.EGG
( export BT_DEATH_LOG=1 BT_MP_LOG=1
bt_launch dr_b.log DR.EGG 0x0C -net 1601 )
sleep 2
# A: crouch early and STAY down, so the kill lands on a crouched mech
( export BT_BTNTEST=0x13,900,960 BT_SELF_DAMAGE=6
export BT_DUCK_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1 BT_KEY_NOFOCUS=1
bt_launch dr_a.log DR.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py DR.EGG 127.0.0.1:1501 127.0.0.1:1601 > dr_relay.log 2>&1 &
RELAY=$!
sleep 260
kill $RELAY 2>/dev/null
sleep 3
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
echo "=================== #142 CROUCH ACROSS RESPAWN ==================="
echo "--- did A crouch, then die, then respawn? ---"
grep -aE "\[duck\] (SQUAT|RISE)|death cycle START|Mech::Reset" dr_a.log | head -8
echo
echo "--- leg alarm around the respawn (1 = parked in 'sqd', 0 = standing) ---"
grep -aE "probe legLvl|Mech::Reset" dr_a.log | tail -12
echo
echo "--- the strip's frame after respawn (must NOT be the up-arrow) ---"
grep -a "probe legLvl" dr_a.log | tail -3
-73
View File
@@ -1,73 +0,0 @@
#!/usr/bin/env bash
# =========================================================================
# PANIC EJECT -> does the respawn replicate? (#108, night 13)
#
# Fourth attempt at the trigger. The first three drove the panic button
# through BT_BTNTEST (EmitButton -> RIO queue). It never reached the
# mapper: no [eject], no PUNCH-OUT, not even a lamp change -- and the same
# seam failed to toggle the searchlight (0x14), so it was the SEAM, not the
# button address. BT_EJECT_AT is the purpose-built hook (mech4.cpp:3310):
# it synthesizes the identical press inside the input path -- "one message,
# one dispatch", the same Mech::EjectPilotMessageID the key sends -- and
# repeats every 300 frames, so one run yields many punch-outs.
#
# PASS : for every punch-out on A, B logs
# "[respawn] replicant <A> un-wrecked + warp (mode 9->1)"
# FAIL : punch-outs on A with no matching un-wreck <- the ghost
#
# Control already banked (ejectghost.sh): 9 force-kill deaths -> 8 peer
# un-wrecks, 0 ghost lines. NORMAL death replication is good on this build,
# so an eject-only shortfall here is the defect, not a broken rig.
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f ea_a.log ea_b.log ea_relay.log
bt_expert_egg MP.EGG EA.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" EA.EGG
( export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 BT_FOG_LOG=1
bt_launch ea_b.log EA.EGG 0x0C -net 1601 )
sleep 2
( export BT_AUTODRIVE=0.6 BT_EJECT_AT=1800
export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
bt_launch ea_a.log EA.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py EA.EGG 127.0.0.1:1501 127.0.0.1:1601 > ea_relay.log 2>&1 &
RELAY=$!
sleep 300
kill $RELAY 2>/dev/null
sleep 3
bt_kill_ours
sleep 2
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 3
AID=$(grep -aoE "MY mech entityID=[0-9]+:[0-9]+" ea_a.log | head -1 | cut -d= -f2)
echo "=================== PANIC-EJECT REPLICATION ==================="
echo "A entity: ${AID:-UNKNOWN}"
echo
echo "--- 1. did punch-outs actually happen? (if 0 the run is VOID) ---"
echo -n "DeathWithoutHonor notices : "; grep -ac "DeathWithoutHonor" ea_a.log
echo -n "PUNCH-OUT lines : "; grep -ac "PUNCH-OUT" ea_a.log
grep -a "PUNCH-OUT" ea_a.log | head -6
echo
echo "--- 2. A's own death/respawn cycles ---"
echo -n "death cycle STARTs : "; grep -ac "death cycle START" ea_a.log
echo -n "Mech::Reset : "; grep -ac "Mech::Reset ${AID:-@@@}" ea_a.log
echo
echo "--- 3. B: the peer's un-wrecks for A (THE NUMBER THAT MATTERS) ---"
echo -n "un-wrecks seen : "; grep -a "respawn\] replicant" ea_b.log | grep -ac "${AID:-@@@}"
grep -a "respawn\] replicant" ea_b.log | grep -a "${AID:-@@@}" | head -8
echo
echo "--- 4. B: ghost detector + searchlight cones ---"
echo -n "ghost lines : "; grep -ac "\[ghost\]" ea_b.log
grep -a "\[ghost\]" ea_b.log | head -4
echo -n "cone SHOWN/HIDDEN : "; grep -ac "\[spot\] cone" ea_b.log
echo
echo "--- 5. VERDICT INPUTS ---"
echo "punch-outs=$(grep -ac 'PUNCH-OUT' ea_a.log) A-respawns=$(grep -ac 'death cycle START' ea_a.log) B-unwrecks=$(grep -a 'respawn\] replicant' ea_b.log | grep -ac "${AID:-@@@}")"
-80
View File
@@ -1,80 +0,0 @@
#!/usr/bin/env bash
# =========================================================================
# EJECT-GHOST bench (#108 / night 13) -- does a PANIC EJECT replicate its
# respawn to peers?
#
# FIELD REPORT: many ghost mechs tonight, all from panic-button self
# destructs, none respawning properly -- and the operator has seen
# eject-respawn work every time before tonight, so this is a REGRESSION
# (night 12 = build 774, tonight = 817).
#
# FIELD EVIDENCE (staged logs): the ejecting mech's OWNER ran the whole
# death path and respawned and kept driving in the same round, while BOTH
# peers created his wreck and never processed the un-wreck. The same mech's
# NORMAL deaths replicated their respawns 4-6 times in that same session.
#
# THE TEST: A punches out via the real click seam (button 0x3D, the panic
# button -- the same addr the field log shows). B watches.
# PASS : B logs "[respawn] replicant <A> un-wrecked + warp (mode 9->1)"
# FAIL : B logs the wreck and never the un-wreck <- the ghost
# A CONTROL kill comes first (B force-damages A), so the SAME run shows a
# normal death replicating correctly -- otherwise a silent B proves nothing.
#
# Press polls: >=900 per the harness contract (round-start jitter eats
# earlier presses). Panic twice, so one missed press does not read as a
# pass.
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f eg_a.log eg_b.log eg_relay.log
bt_assert_player_env
bt_expert_egg MP.EGG EG.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" EG.EGG
# ---- node B: OBSERVER + the control killer -------------------------------
( export BT_MP_FORCE_DMG=1
export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
bt_launch eg_b.log EG.EGG 0x0C -net 1601 )
sleep 2
# ---- node A: walks, gets killed (control), then PUNCHES OUT twice --------
( export BT_AUTODRIVE=0.6
export BT_BTNTEST=0x3d,900,960 # panic eject #1
export BT_BTNTEST2=0x3d,2400,2460 # panic eject #2
export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
bt_launch eg_a.log EG.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py EG.EGG 127.0.0.1:1501 127.0.0.1:1601 > eg_relay.log 2>&1 &
RELAY=$!
sleep 300
kill $RELAY 2>/dev/null
sleep 3
bt_kill_ours
sleep 2
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 3
AID=$(grep -aoE "MY mech entityID=[0-9]+:[0-9]+" eg_a.log | head -1 | cut -d= -f2)
echo "=================== EJECT-GHOST RESULT ==================="
echo "A's entity id: ${AID:-UNKNOWN}"
echo
echo "--- did the scripted panic actually press? ---"
grep -a "btntest" eg_a.log
echo
echo "--- A: punch-outs + its own death/respawn cycle ---"
grep -aE "\[eject\]|PUNCH-OUT|death cycle START|dz\] GRANTED|Mech::Reset" eg_a.log | head -20
echo
echo "--- B: the peer's view of A, in order (wreck / un-wreck) ---"
grep -aE "wreck:|respawn\] replicant" eg_b.log | grep -a "${AID:-@@@}" | head -20
echo
echo "--- B: totals for A ---"
echo -n "wrecks seen : "; grep -a "wreck:" eg_b.log | grep -ac "${AID:-@@@}"
echo -n "respawns seen : "; grep -a "respawn\] replicant" eg_b.log | grep -ac "${AID:-@@@}"
echo
echo "--- B: ghost detector ---"
grep -a "\[ghost\]" eg_b.log | head -5
echo -n "ghost lines: "; grep -ac "\[ghost\]" eg_b.log
-95
View File
@@ -1,95 +0,0 @@
#!/usr/bin/env bash
# =========================================================================
# EJECT + SEARCHLIGHT (#108, night 13) -- the operator's observation was
# that the ghost mechs all had their SEARCHLIGHTS ON. A plain panic eject
# respawns fine (ejectghost.sh), so the searchlight is the variable.
#
# WHY IT IS PLAUSIBLE, mechanically: the searchlight is the only thing that
# attaches EXTRA GEOMETRY into a peer's mech render tree -- btl4vid.cpp
# builds a spot.bgf cone as a DPLStaticChildRenderable parented to the
# lamp's mount-segment renderable. The death path then does a wreck swap
# ('victim -> thrdbr.bgf + debris') and respawn does 'rebuilt intact model
# (N segs restored, hulk dropped)'. A child renderable held across those
# two rebuilds is a lifetime hazard, and it would only bite mechs whose
# lamp was ON -- which is exactly the reported population.
#
# SEQUENCE on A: searchlight ON (0x14) -> confirm it lit -> panic eject
# (0x3D) -> respawn. B watches for the un-wreck.
# PASS : B logs "[respawn] replicant <A> un-wrecked + warp"
# FAIL : wreck with no un-wreck <- the ghost, and the searchlight is it
#
# NB 0x14 is the searchlight per the shipped bindings comment (left column
# 0x10-0x15 = map+/map-/IR/CROUCH/searchlight/display). The hardware name
# table calls it "Secondary5" -- the FUNCTION comes from the streamed .CTL
# rows, so the script VERIFIES the lamp actually lit before trusting the
# result; a silent B with a lamp that never came on proves nothing.
# =========================================================================
#
# MODE=light -- searchlight ON, then punch out
# MODE=nolight -- punch out with the lamp OFF (the A/B control)
#
# NB no BT_MP_FORCE_DMG here. ejectghost.sh had it, and that is why its
# panic presses did nothing: the "control killer" had A dead or mid-respawn
# for most of the run, and a punch-out press on a dead mech is a no-op.
# That run still produced its control result -- 8 force-kill deaths, 8 peer
# un-wrecks, so NORMAL death replication is proven good on this build.
set -x
MODE="${1:-light}"
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f el_${MODE}_a.log el_${MODE}_b.log el_${MODE}_relay.log
bt_expert_egg MP.EGG EL.EGG
# NIGHT map: the searchlight is a night system and the fog swap only has
# meaning there; also the field population was night-map rounds.
sed -i "s/^map=.*/map=cavern/; s/^time=.*/time=night/; s/^vehicle=.*/vehicle=madcat/" EL.EGG
( export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 BT_SPLASH_LOG=1
bt_launch el_${MODE}_b.log EL.EGG 0x0C -net 1601 )
sleep 2
( export BT_AUTODRIVE=0.6
if [ "$MODE" = "light" ]; then
export BT_BTNTEST=0x14,600,660 # SEARCHLIGHT on
export BT_BTNTEST2=0x3d,1500,1560 # then punch out
else
export BT_BTNTEST=0x3d,1500,1560 # punch out, lamp OFF
fi
export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
bt_launch el_${MODE}_a.log EL.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py EL.EGG 127.0.0.1:1501 127.0.0.1:1601 > el_${MODE}_relay.log 2>&1 &
RELAY=$!
sleep 300
kill $RELAY 2>/dev/null
sleep 3
bt_kill_ours
sleep 2
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 3
AID=$(grep -aoE "MY mech entityID=[0-9]+:[0-9]+" el_${MODE}_a.log | head -1 | cut -d= -f2)
echo "=================== EJECT+SEARCHLIGHT RESULT ==================="
echo "A's entity id: ${AID:-UNKNOWN}"
echo
echo "--- 1. did the scripted presses land? ---"
grep -a "btntest" el_${MODE}_a.log
echo
echo "--- 2. DID THE SEARCHLIGHT ACTUALLY COME ON? (if not, the run is void) ---"
grep -aiE "searchlight|\[spot\]|lightState" el_${MODE}_a.log | head -10
echo
echo "--- 3. A: punch-out + own respawn ---"
grep -aE "\[eject\]|PUNCH-OUT|dz\] GRANTED" el_${MODE}_a.log | head -10
echo
echo "--- 4. B: the peer's view of A (wreck / un-wreck, in order) ---"
grep -aE "wreck:|respawn\] replicant" el_${MODE}_b.log | grep -a "${AID:-@@@}" | head -20
echo -n "wrecks: "; grep -a "wreck:" el_${MODE}_b.log | grep -ac "${AID:-@@@}"
echo -n "respawns: "; grep -a "respawn\] replicant" el_${MODE}_b.log | grep -ac "${AID:-@@@}"
echo
echo "--- 5. B: searchlight cones + ghost detector ---"
grep -aE "\[spot\]" el_${MODE}_b.log | head -6
echo -n "cone skipped: "; grep -ac "cone skipped" el_${MODE}_b.log
echo -n "ghost lines : "; grep -ac "\[ghost\]" el_${MODE}_b.log
grep -a "\[ghost\]" el_${MODE}_b.log | head -3
-79
View File
@@ -1,79 +0,0 @@
#!/usr/bin/env bash
# =========================================================================
# A REAL PANIC EJECT -- at last. (#108 ghost + the chart's "-1000 ejecting")
#
# WHY SIX RIGS FAILED. It was never the trigger. BT_EJECT_AT reaches the
# dispatch every time (proved with an [ejecttest] receipt: "FIRING punch-out
# at frame 600/900/1200"), and the HANDLER refuses it:
#
# [eject] 1:139 REFUSED (mech not crippled enough)
#
# Mech::EjectPilotMessageHandler gates on EvaluateEjectPermission() (@0x414) --
# a healthy mech cannot punch out. Every bench so far ejected a pristine mech.
# In the field players eject BECAUSE they are wrecked, which is why it works
# for them and never for me. (The button seam was never broken either; that
# earlier conclusion was wrong too.)
#
# So: CRIPPLE FIRST, then eject. BT_SELF_DAMAGE grinds A down; BT_EJECT_AT
# retries every 300 frames, so the first retry after permission is granted
# takes it.
#
# WHAT THIS SETTLES:
# chart "-1000 ejecting" -- the eject total, currently arithmetic only
# #108 eject-ghost -- does the peer un-wreck after an EJECT death?
# (normal deaths replicate fine: 9 deaths -> 8
# un-wrecks, benched)
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
bt_assert_player_env
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f er_a.log er_b.log er_relay.log matchlog_*.txt
bt_expert_egg MP.EGG ER.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" ER.EGG
( export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1
bt_launch er_b.log ER.EGG 0x0C -net 1601 )
sleep 2
# A: grind itself down, then punch out once permission is granted
# THE GATE, decoded from Mech::EvaluateEjectPermission (@0049fa1c):
# permitted = liveWeapons < ejectMinWeapons || liveGenerators == 0
# || coolantFrac < 0.05 || (leg-gimped && !simLive)
# Armour damage satisfies NONE of them -- which is why grinding A down gave
# 53 attempts and 785 refusals. Killing the GENERATORS is the direct lever,
# and BT_KILL_SUBSYS's comma-list form was built for precisely this bench.
# It fires at frame 900, so arm the eject after that.
( export BT_KILL_SUBSYS=GeneratorA,GeneratorB,GeneratorC,GeneratorD BT_EJECT_AT=1200
export BT_DEATH_LOG=1 BT_MP_LOG=1 BT_MATCHLOG=1 BT_DMG_LOG=1
bt_launch er_a.log ER.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py ER.EGG 127.0.0.1:1501 127.0.0.1:1601 > er_relay.log 2>&1 &
RELAY=$!
sleep 300
kill $RELAY 2>/dev/null
sleep 3
bt_kill_ours
sleep 2
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 3
AID=$(grep -aoE "MY mech entityID=[0-9]+:[0-9]+" er_a.log | head -1 | cut -d= -f2)
echo "=================== REAL EJECT ==================="
echo "A entity: ${AID:-UNKNOWN}"
echo "--- 1. did a punch-out finally LAND? ---"
echo -n "FIRING attempts : "; grep -ac "FIRING punch-out" er_a.log
echo -n "REFUSED : "; grep -ac "REFUSED" er_a.log
echo -n "PUNCH-OUT : "; grep -ac "PUNCH-OUT" er_a.log
grep -aE "PUNCH-OUT|DeathWithoutHonor" er_a.log | head -3
echo
echo "--- 2. CHART '-1000 ejecting': the score trajectory around it ---"
grep -ah "player=2:1 type=" matchlog_*.txt | tail -6 | cut -c1-115
grep -a "\[deathcost\]" er_a.log | head -2 | cut -c1-140
echo
echo "--- 3. #108: does the peer UN-WRECK after an eject death? ---"
echo -n "B wreck-enters for A : "; grep -a "entered wreck state" er_b.log | grep -ac "${AID:-@@@}"
echo -n "B un-wrecks for A : "; grep -a "un-wrecked + warp" er_b.log | grep -ac "${AID:-@@@}"
grep -aE "entered wreck state|un-wrecked \+ warp" er_b.log | tail -6 | cut -c1-120
echo -n "B ghost lines : "; grep -ac "\[ghost\]" er_b.log
-22
View File
@@ -1,22 +0,0 @@
import sys, re
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
rows = gitea.all_issues("all")
rows = [r for r in rows if not r.get("pull_request")]
print("TOTAL ISSUES: %d" % len(rows))
terms = {
"crouch": r"crouch",
"missile-dir": r"missile.*(direction|facing|foot|feet|track)|emitter",
"night-vis": r"night|darkness|visibilit|fog|thermal|infrared|\bIR\b|predator",
"smoke-all": r"smoke",
"layout-save": r"layout|glass_layout|\bsave\b",
"eject-splash": r"eject|panic|suicide|splash",
"torso-yaw": r"torso.*(twist|yaw)|feet.*fac",
}
for k, pat in terms.items():
print("\n=== %s ===" % k)
rx = re.compile(pat, re.I)
for r in rows:
if rx.search(r["title"]):
print(" #%-4s %-7s %s" % (r["number"], r["state"], r["title"][:110]))
-91
View File
@@ -1,91 +0,0 @@
"""Night 13: post the ghost/eject findings to #108 and settle #144. ASCII only."""
import sys
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
gitea.comment(108, """Night 13 (2026-08-06, build **4.11.817**) -- eject-ghosts, and why the logs barely saw them.
## Field finding
Testers reported MANY ghost mechs, all from panic-button self-destructs, none respawning
properly -- and the operator has seen eject-respawn work every time before this night, so this is a
REGRESSION, not a gap (night 12 = build 774).
The logs show it happening, on three independent witnesses. Dave punch-ejected; on HIS machine the
whole death path ran (`DeathWithoutHonor`, `PUNCH-OUT`, death transition, wreck, explosion), he
respawned, took a drop-zone slot, and drove another 193 logged frames **in the same round**, which
then ended normally -- no crash, no disconnect. On BOTH peers his mech's history ends at the wreck
and never resumes; host 2 vanishes from santo's world for the remaining ~4300 lines of that round.
The discriminator is clean: the SAME mech's NORMAL deaths replicated their respawns 4-6 times in
that same session. Only the eject failed.
## Why the ghost detector said almost nothing
One `[ghost]` line all night, for an unrelated live mech. The gate is
&& !owner->WreckBuried() // "buried wrecks are expected-silent"
WreckBuried() { return collisionVolumeCount == 0; }
An eject-ghost IS a wreck that stopped receiving records, so the detector files it under
expected-silent. It can only ever catch a LIVE replicant going quiet -- structurally blind to this
failure.
## Why the census said "one" when testers saw many
Counting ghosts meant pairing the un-wreck receipt against
`[BTrender] wreck: 'thrdbr.bgf' missing -> gendbr.bgf fallback` -- a MISSING-ASSET warning, not a
death, which only prints for chassis whose wreck model is absent. Fixed in `4642129`: every
replicant entering the wreck state now emits an ungated
[wreck] replicant H:E entered wreck state (mode X->9) at (x,z)
symmetric with the existing un-wreck line, so the count is exactly (enters - un-wrecks) per entity.
Verified 2-node: 5 enters / 5 exits, exactly paired -- **while the old marker printed ZERO times in
the same run**. Five real deaths, invisible to what the census was reading. Treat the "one ghost on
night 13" number as a floor, not a count.
Census tooling: `scratchpad/night13/ghostcensus.py` (NB: filter the log owner's OWN mech -- an
owner's own wreck can never pair, since `un-wrecked` only logs for replicants; not doing so
manufactures false positives in every log).
## NOT reproduced by bench -- five rigs failed to trigger a punch-out at all
`BT_BTNTEST` never reached the mapper for the panic button (0x3D) or the searchlight (0x14): no
`[eject]`, no `PUNCH-OUT`, not even a lamp change -- so it is the SEAM, not the address.
`BT_EJECT_AT` (mech4.cpp:3310, the purpose-built hook) did not fire either. Benches are staged
(`ejectghost.sh`, `ejectlight.sh`, `ejectat.sh`) with their failure modes in the headers.
What the benches DID establish: **normal death replication is healthy on 817** -- 9 force-kill
deaths, 8 peer un-wrecks, 0 ghost lines. So an eject-only shortfall is the defect, not a broken rig.
## Open lead
The operator observed that the ghost mechs all had their SEARCHLIGHTS ON. Untested -- there is
currently no key or env that toggles the searchlight headlessly, so it needs a small bench hook.
Mechanically plausible: the searchlight is the only thing that attaches extra geometry into a peer's
mech render tree (btl4vid.cpp builds a `spot.bgf` cone as a `DPLStaticChildRenderable` parented to
the lamp's mount-segment renderable), and the death path does a wreck swap while respawn does
"rebuilt intact model (N segs restored, hulk dropped)" -- a child renderable held across those two
rebuilds is a lifetime hazard, and it would only bite mechs whose lamp was ON.
## Regression window
Commits 775-817. Two touch the death path: `91bd286` (787) rewrote the VehicleDead dispatch --
the message that drives the respawn cycle -- including the `killer == victim` eject case and the
filter that had blocked the panic-eject path; and `297127d` (784, death blast). Unverified.""")
gitea.close(144, """**NOT A BUG -- authentic. Closing.**
The eject sets `suppressConsole` (+0x258, `BTPlayerEjectBookkeeping`), and the #89 death-blast gate
reads it:
gates : owning player's advancedDamageOn (+0x264) AND
NOT suppressConsole (+0x258 -- eject sets it: punch-outs never blast)
[T1 @0x4a0aa8-0x4a0ad6]
So a mech that punches out does not splash its neighbourhood, by design, in the 1995 binary. SAURON's
observation ("splash worked on mechs that died close, but no splash on a panic / eject / suicide
death") is a correct reading of authentic behaviour.
Closing so nobody "fixes" it. Found while investigating the night-13 eject ghosts (#108).""")
-85
View File
@@ -1,85 +0,0 @@
"""Night 13: census every 'wreck with no following un-wreck' across all logs.
A peer's view of a mech death is a pair:
[BTrender] wreck: ... (entity=H:E) <- the wreck appears
[respawn] replicant H:E un-wrecked + warp <- it comes back
An UNPAIRED wreck -- one with no un-wreck before that log's session ends --
is the ghost signature. Report per SESSION so 'was it just one game?' is
answerable, and dump the lines immediately before the first one.
"""
import re, sys, os, io
LOGDIR = r"C:\git\bt411\scratchpad\night13"
SESS = re.compile(r"===== BT411 SESSION.*?local=(\S+ \S+)")
WRECK = re.compile(r"\[BTrender\] wreck:.*?entity=(\d+:\d+)")
UNWRECK = re.compile(r"\[respawn\] replicant (\d+:\d+) un-wrecked")
EJECT = re.compile(r"\[eject\]|PUNCH-OUT")
def scan(path):
sessions = [] # (startline, stamp)
events = [] # (line, kind, ent)
with io.open(path, "r", encoding="latin-1", errors="replace") as f:
for n, line in enumerate(f, 1):
m = SESS.search(line)
if m:
sessions.append((n, m.group(1)))
continue
m = WRECK.search(line)
if m:
events.append((n, "wreck", m.group(1))); continue
m = UNWRECK.search(line)
if m:
events.append((n, "unwreck", m.group(1))); continue
if EJECT.search(line):
events.append((n, "eject", "-"))
return sessions, events
def session_of(sessions, line):
idx, stamp = 0, "?"
for i, (sl, st) in enumerate(sessions):
if sl <= line:
idx, stamp = i + 1, st
else:
break
return idx, stamp
for fn in sorted(os.listdir(LOGDIR)):
if not fn.endswith(".log") or fn.startswith("FAILURE"):
continue
path = os.path.join(LOGDIR, fn)
sessions, events = scan(path)
# pair wrecks to the next un-wreck of the same entity IN THE SAME SESSION
pending = {} # ent -> (line, sessidx)
unpaired = []
for (n, kind, ent) in events:
si = session_of(sessions, n)[0]
if kind == "wreck":
if ent in pending and pending[ent][1] == si:
unpaired.append(pending[ent]) # wreck superseded by another wreck
pending[ent] = (n, si)
elif kind == "unwreck":
if ent in pending and pending[ent][1] == si:
del pending[ent]
for ent, (n, si) in pending.items():
unpaired.append((n, si, ent))
norm = []
for u in unpaired:
norm.append(u if len(u) == 3 else (u[0], u[1], "?"))
norm.sort()
print("=" * 72)
print("%s sessions=%d wrecks=%d unwrecks=%d ejects=%d"
% (fn, len(sessions),
sum(1 for e in events if e[1] == "wreck"),
sum(1 for e in events if e[1] == "unwreck"),
sum(1 for e in events if e[1] == "eject")))
if not norm:
print(" no unpaired wrecks")
continue
bysess = {}
for (n, si, ent) in norm:
bysess.setdefault(si, []).append((n, ent))
for si in sorted(bysess):
stamp = sessions[si - 1][1] if 0 < si <= len(sessions) else "?"
print(" SESSION %d (%s): %d unpaired -> %s"
% (si, stamp, len(bysess[si]),
", ".join("%s@%d" % (e, n) for n, e in bysess[si][:6])))
-143
View File
@@ -1,143 +0,0 @@
"""Night 13 (2026-08-06, build 4.11.817) tracker housekeeping. ASCII only."""
import sys
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
BUG, INV, WIP, HUD, AUDIO, NET, WORLD, AWAIT = 1, 2, 3, 4, 5, 6, 7, 8
SRC = ("Source: playtest night 13 (2026-08-06, build **4.11.817 (6fcff95+)**), "
"Discord #play-testing. Field logs staged in `scratchpad/night13/` "
"(4 players + the pod cart).\n\n")
new = {}
# ---------------------------------------------------------------- NEW ISSUES
i = gitea.create(
"REGRESSION (817): BT_GLASS_LAYOUT=save writes a layout file with the MFD and Secondary lines MISSING",
SRC +
"SAURON (Michael), repeatedly: \"the save command is no longer writing to the glass_layout.cfg "
"file since this latest build\" ... \"got my glass panels set, saved and borders off again now\" "
"-> \"and then relaunched and it reset again\" -> \"looks like it rewrote the glass_layout.cfg "
"again\".\n\n"
"**The diagnostic detail:** \"the glass_layout.cfg had all the MFDs and secondary lines missing, "
"but plasma was still there\". So `save` is not failing to write -- it writes a file in which the "
"MFD and Secondary/Radar entries are simply absent, and the Plasma entry survives. On the next "
"launch there is nothing to restore, so every panel resets and the border/bare state is lost too.\n\n"
"**Workaround (confirmed by SAURON):** set `BT_GLASS_LAYOUT=load` and restore a hand-kept backup "
"copy of `glass_layout.cfg` -- \"set to load and used backup copy of CFG file and working aok so far\".\n\n"
"Probably the same root cause: vwe_propwash the same night -- \"my screen order borked again so "
"I'll need the trick to re-align them\".\n\n"
"**Suspect [T4, unverified]:** today's pod-MFD work is the only thing that touched this area. "
"Candidates, in order: (a) `e179c70` / `67a4f09` -- `BT_POD_RGB` and the bare-panel mode changed "
"how panel surfaces are enumerated and added the new `monitor:<name|index>` and `,bare` line "
"forms, so the WRITER may no longer emit a line for a surface it cannot express (or the panel "
"list it walks is now populated differently); (b) `d213c98` -- panel create/destroy moved out of "
"the PadRIO ctor into `LBE4ControlsManager`, which changes panel LIFETIME: if the save runs after "
"`BTGlassPanels_Destroy()`, the windows are already gone and there is nothing to serialize, which "
"would explain exactly this signature (Plasma is not a glass panel and is destroyed elsewhere, so "
"it alone survives).\n\n"
"Related: #76 (main cockpit + Plasma positions not restored) -- that is the older, much smaller "
"version of this; fix this one first, #76 may be a subset. Desktop glass mode only; the pod cart "
"is unaffected because it runs `BT_GLASS_LAYOUT=load` off a frozen master.",
labels=[BUG, HUD])
new['layoutsave'] = i['number']
i = gitea.create(
"Missiles launch along the LEG/FOOT facing, then curve to the target -- peer POV only",
SRC +
"Oracle: \"the missile thing we observed where missiles are firing in the direction the mech feet "
"are facing\" ... \"and then coming around to track the target\" ... \"**the emitter is following "
"the foot facing**\".\n\n"
"Confirmed by Oracle as **peer POV only** (epilectrik: \"only from peer pov though right\" -> "
"\"yes correct\"): from your own cockpit the launch looks right; on a REPLICANT the launch vector "
"comes off the leg/hip yaw instead of the torso/turret yaw. The homing itself works -- the missile "
"curves onto the target after launch -- so this is the muzzle/emitter TRANSFORM on the replicated "
"mech, not the guidance.\n\n"
"Reads as the replicant's weapon hardpoint being attached to (or composed against) the wrong node "
"in the segment hierarchy -- the leg/root segment rather than the twisted torso. Compare against "
"the master-side emitter, which is correct.\n\n"
"Related: #37 (MadCat torso is BACKWARDS), #70 (torso twist stops working after respawn) -- all "
"three are torso-yaw composition on a replicated model, and may share a cause.",
labels=[BUG, NET])
new['missiledir'] = i['number']
i = gitea.create(
"CROUCH does not toggle: the lamp flashes on every press but the mech stays STANDING",
SRC +
"Reported by Oracle and previously by SAURON. Oracle: \"the crouch button did not toggle to "
"display crouched\" ... \"no state change\" ... \"button flickers sometimes on press, **the light "
"next to it always flashes when pressed**, but state does not change. Remains in stand mode\".\n\n"
"So the INPUT is arriving and the annunciator responds -- the press is seen all the way to the "
"lamp -- but the crouch state itself never changes. That narrows it to the state transition / "
"gait request rather than the button wiring.\n\n"
"**Not to be confused with (and NOT a bug):** throttle-up does not stand you back up. Settled this "
"night by primary source -- Lynx, quoting the original manual: \"Throttle up should not make you "
"stand. Verified in manual. You must push the button again to stand. I agree that throttle up "
"'should' do that, but it's not designed that way.\" Standing requires a second CROUCH press, "
"which makes this bug worse than it looks: with no working toggle there is no designed way out of "
"crouch. Locomotion/CROUCH background: `context/locomotion.md`.",
labels=[BUG])
new['crouch'] = i['number']
i = gitea.create(
"One panel hit makes smoke erupt from MULTIPLE/ALL locations at once (seen on the sensor special panel)",
SRC +
"Oracle: \"the smoke sometimes emitting from multiple locations all at once when a location like "
"the sensor special panel is hit during testing. I saw this several times. It's not just with the "
"special panel, but it might have something to do with reaching a certain damage threshold which "
"is fairly easy to do quickly with a special panel. So you hit the special with an alpha and "
"suddenly the entire target mech emits smoke from all panels.\"\n\n"
"**Open question from the reporter, and the key discriminator:** \"What I'm not sure about is if "
"it's emitting only from previously damaged panels.\" If it is only previously-damaged panels, "
"this is a threshold that re-triggers the effect on every already-damaged zone at once (a "
"retrigger/latch bug). If it is genuinely ALL panels, the emitter is being attached per-mech "
"instead of per-zone. Answer that first -- it picks the fix.\n\n"
"Suspected trigger is a whole-mech damage THRESHOLD being crossed rather than the specific zone, "
"which a special-panel alpha reaches quickly.\n\n"
"Related: #90 (flames/smoke render less often and shorter than the original), #114 (missile impact "
"smoke too thick), #129 (closed: respawned mech emitted the wreck plume) -- same damage-effect "
"emitter family.",
labels=[BUG, INV])
new['smokeall'] = i['number']
i = gitea.create(
"No SPLASH damage from a PANIC/EJECT/suicide death (splash from normal deaths now confirmed working)",
SRC +
"SAURON: \"Splash damage looked good to me, splash worked on mechs that died close, **but no "
"splash on a panic / eject / suicide death**.\"\n\n"
"Filed as the leftover sub-case of #89, which is otherwise verified working this night and closed.\n\n"
"**Authenticity is NOT established** -- do not 'fix' this before deciding what the original did. A "
"panic eject is plausibly a different death path (the pilot leaves; whether the chassis still "
"detonates with an explosion payload is an open era question), so a self-destruct that does no "
"splash could be correct. Decide from the binary's eject/death path first: does the eject route "
"raise the same explosion object as a combat death, and does that object carry the splash payload?\n\n"
"Related: #89, #106 (closed: splash burstCount dropped), #118/#109 (EJECT/PANIC wiring), #134 "
"(closed: panic eject score penalty).",
labels=[INV])
new['ejectsplash'] = i['number']
i = gitea.create(
"ThermalSight / IR ('predator vision'): the VISIBLE half is unimplemented -- and it was never a heat image",
SRC +
"epilectrik: \"IR we solved but I think haven't implemented yet\" -- decode exists, presentation "
"does not, and there was no tracker item for the visible half (#61 covers the dead ToggleLamp "
"handler; #123 is the same shape for the searchlight).\n\n"
"**Primary-source description of what it should look like** -- Oracle, who played the original "
"pods at Lazer Park, this night:\n"
"- \"the IR was not what you would expect ... it was called predator vision, but it just looked "
"more like a **random palette shift**\"\n"
"- \"it was **not a heat image**\"\n"
"- \"which is why we generally called it LSD vision in Lazer Park\"\n"
"- \"IMHO it did nothing to improve visibility. All I found was that you had to look for some "
"movement and go after that\"\n"
"- epilectrik's recollection: it was a **test mode in the TH division hardware** that they "
"switched on, so it was not a specifically designed effect. Oracle: \"hardware optimized\".\n\n"
"**Why this matters for the port [T3]:** do not build a thermal/heat-gradient shader. The target "
"is a palette/colour-table transform on the existing image -- most likely whatever the 1995 "
"hardware path did when its test mode was enabled. Scope it from the decomp (and, if it really is "
"a hardware mode, decide what the honest modern equivalent is) before writing anything.\n\n"
"Related: #61 (ThermalSight ToggleLamp), #123 (searchlight visible half -- the same "
"toggle-works/presentation-deferred split).",
labels=[INV, WORLD])
new['ir'] = i['number']
print()
print("NEW:", new)
-66
View File
@@ -1,66 +0,0 @@
"""Night 13 housekeeping, part 2: comments + state changes on existing issues."""
import sys
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
N13 = "Playtest night 13 (2026-08-06, build **4.11.817 (6fcff95+)**). "
# ---- #89 SPLASH: verified working, close -----------------------------------
gitea.close(89,
N13 + "**VERIFIED WORKING -- closing.**\n\n"
"SAURON: \"Splash damage looked good to me, **splash worked on mechs that died close**\". "
"epilectrik: \"splash damage seems ok?\" -- no dissent from any tester.\n\n"
"Oracle (original-pod player) also gave the era calibration that explains why splash reads as "
"inconsistent rather than absent, and says our current behaviour matches it: \"sometimes you "
"really catch it and sometimes not. It jogged my memory and I vividly recall that **being at "
"different elevations on a hillside sometimes made a real hash of it** -- the effect could be much "
"stronger or weaker\" ... \"it was **not** the mechwarrior 3/4 splash model\". SAURON: \"that seems "
"correct to me\". So an unpredictable, elevation-sensitive splash is AUTHENTIC and should not be "
"'fixed' into a clean radius falloff.\n\n"
"One sub-case survives and is filed separately as **#144**: no splash on a panic/eject/suicide "
"death (authenticity not yet established).")
# ---- #108 GHOST MECH: recurred ---------------------------------------------
gitea.comment(108,
N13 + "**Still present, and it was the headline problem of the night.** epilectrik: \"**ghost mech "
"biggest surprise**\" when reviewing regressions.\n\n"
"No new detail beyond the existing repro in this issue -- recording the recurrence on 817 so the "
"issue is not read as stale. Field logs for all four players (3 desktop + the pod cart) are staged "
"in `scratchpad/night13/`, including the pod's matchlogs, which is the first time this issue has "
"log coverage from a hardware seat as well. Analysis: `docs/GHOST_MECH_ANALYSIS.md`.")
# ---- #76 layout restore: superseded by #140 --------------------------------
gitea.comment(76,
N13 + "**Overtaken by #140.** As of 817 the problem is no longer 'two windows fail to restore' -- "
"`BT_GLASS_LAYOUT=save` now writes a `glass_layout.cfg` with the MFD and Secondary lines missing "
"entirely (Plasma survives), so nothing restores at all. See #140 for the reports and the suspect "
"commits.\n\n"
"Fix #140 first; this issue may turn out to be a subset of it. Re-verify the two windows named "
"here only once saving writes a complete file again.")
# ---- #123 searchlight / night visibility: era testimony --------------------
gitea.comment(123,
N13 + "**Primary-source testimony on night visibility** from Oracle, who played the original pods "
"at Lazer Park. This is directly about the deferred half of this issue (the fog swap), and it "
"raises the priority of the fog over the beam:\n\n"
"- \"some maps were not very dark, some were very dark\"\n"
"- \"**the FOG was what really killed visibility**\"\n"
"- \"I do recall basically stumbling into each other point blank at times and **having to use "
"radar to navigate**\"\n"
"- \"it could be very difficult to make a shot in those conditions\"\n"
"- \"it was 90's tech there\"\n\n"
"Takeaway for the implementation: the authentic night experience is driven by FOG DENSITY, not by "
"ambient darkness, and it was severe enough that radar navigation was the norm. The "
"`searchlightfog=` / `nosearchlightfog=` environment swap described above is therefore the "
"high-value half of this work, not the projected beam.\n\n"
"Operator note the same night: general night-visibility tuning is **deferred** until the correct "
"behaviour is known -- epilectrik: \"the night time visibility I might defer for now since we "
"don't really know the correct behavior ... I'll try to retrieve it from the binary again but "
"might be a hardware side thing\". This comment is that missing calibration.")
# ---- #61 ThermalSight: point at the new visible-half issue -----------------
gitea.comment(61,
N13 + "Cross-ref: the **visible** half of ThermalSight now has its own issue, **#145**, with "
"primary-source description of what the IR mode actually looked like (a palette shift, explicitly "
"NOT a heat image -- possibly a hardware test mode rather than a designed effect). Worth reading "
"before anyone implements a presentation off the toggle this issue restored.")
-53
View File
@@ -1,53 +0,0 @@
#!/usr/bin/env bash
# =========================================================================
# #140 -- reach the teardown the way the FIELD does: a MISSION that ENDS.
#
# Three earlier attempts never exercised BTGlassPanels_Destroy at all:
# bt_kill_ours uses `taskkill /F` (no dtors), and a graceful WM_CLOSE on a
# solo -egg run did not reach it either. The path that matters is the one
# testers hit constantly -- the controls manager is destroyed and rebuilt at
# every ROUND boundary, which is exactly what the relay drives in MP.
#
# Panels ON for both nodes; the relay starts a round and the round ends.
# The receipt to read is `[glasswin] destroy entry #N windows=M`:
# #1 windows=7 then #2 windows=0 -> the double-destroy, confirmed
# and then exactly ONE "saved N window position(s)" line, with the cfg intact.
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f glass_layout.cfg lr_a.log lr_b.log lr_relay.log
bt_expert_egg MP.EGG LR.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" LR.EGG
( export BT_GLASS_PANELS=1 BT_GLASS_LAYOUT=save BT_MP_LOG=1
bt_launch lr_b.log LR.EGG 0x0C -net 1601 )
sleep 2
( export BT_GLASS_PANELS=1 BT_GLASS_LAYOUT=save BT_MP_LOG=1
bt_launch lr_a.log LR.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py LR.EGG 127.0.0.1:1501 127.0.0.1:1601 > lr_relay.log 2>&1 &
RELAY=$!
sleep 180
kill $RELAY 2>/dev/null
sleep 5
bt_kill_ours
sleep 2
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 3
echo "=================== #140 TEARDOWN RECEIPTS ==================="
for n in a b; do
echo "--- node $n: destroy entries ---"
grep -a "destroy entry" lr_${n}.log
echo "--- node $n: SaveLayout receipts ---"
grep -a "window position" lr_${n}.log
done
echo
echo "--- glass_layout.cfg entries ---"
grep -aE "^[^#]+=" glass_layout.cfg 2>/dev/null || echo "(no file)"
echo -n "entry count: "; grep -acE "^[^#]+=" glass_layout.cfg 2>/dev/null
-67
View File
@@ -1,67 +0,0 @@
#!/usr/bin/env bash
# =========================================================================
# #140 glass_layout.cfg save regression -- the FIELD composition.
#
# SAURON's report: "set my glass panels, saved and borders off ... relaunched
# and it reset again ... the glass_layout.cfg had all the MFDs and secondary
# lines missing, but plasma was still there".
#
# So the acceptance test is the full ROUND TRIP a tester does, not just a
# single launch:
# run 1 -- BT_GLASS_LAYOUT=save, panels come up, quit cleanly
# check -- the cfg must list every MFD + the radar, not just plasma
# run 2 -- relaunch; the panels must come back where they were
#
# The teardown is what mattered: BTGlassPanels_Destroy has TWO callers on the
# desktop path (~PadRIO, then ~LBE4ControlsManager), and it ran SaveLayout
# BEFORE checking whether any windows were left -- so the second pass rewrote
# the file from an empty list.
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f glass_layout.cfg ls_run1.log ls_run2.log
# ---- run 1: create the layout ------------------------------------------
( export BT_GLASS_PANELS=1 BT_GLASS_LAYOUT=save
bt_launch ls_run1.log ARENA1.EGG 0x03 )
sleep 55
bt_kill_ours
sleep 2
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 3
echo "############ RUN 1 SAVE RESULT ############"
echo "--- every 'saved N window position(s)' line (the double-save shows here) ---"
grep -a "window position" ls_run1.log
echo
echo "--- glass_layout.cfg AFTER a clean quit ---"
if [ -f glass_layout.cfg ]; then cat glass_layout.cfg; else echo "!!! NO FILE WRITTEN"; fi
echo
echo "--- line census (comments excluded) ---"
echo -n "total entries : "; grep -acE "^[^#]+=" glass_layout.cfg 2>/dev/null
echo -n "MFD lines : "; grep -acE "^(Heat|Comm|Mfd|Eng|Weap|Sec)" glass_layout.cfg 2>/dev/null
echo -n "plasma line : "; grep -ac "Plasma" glass_layout.cfg 2>/dev/null
cp glass_layout.cfg /tmp/ls_after_run1.cfg 2>/dev/null
# ---- run 2: does it RESTORE? -------------------------------------------
( export BT_GLASS_PANELS=1 BT_GLASS_LAYOUT=save
bt_launch ls_run2.log ARENA1.EGG 0x03 )
sleep 55
bt_kill_ours
sleep 2
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 3
echo "############ RUN 2 (RELAUNCH) ############"
echo "--- restore receipts ---"
grep -aE "restored|glass_layout|window position" ls_run2.log | head -12
echo
echo "--- cfg after run 2 -- must still hold every line ---"
cat glass_layout.cfg 2>/dev/null
echo
echo "--- DIFF run1 -> run2 (empty = layout survived the round trip) ---"
diff /tmp/ls_after_run1.cfg glass_layout.cfg && echo "IDENTICAL"
-52
View File
@@ -1,52 +0,0 @@
#!/usr/bin/env bash
# =========================================================================
# #140 -- exercise the TEARDOWN path, which is where the bug lives.
#
# WHY THIS EXISTS: bench_common's bt_kill_ours uses `taskkill /F`. That is a
# HARD kill -- no destructors, so ~LBE4ControlsManager / ~PadRIO never run and
# BTGlassPanels_Destroy (the function that saves, and the one that ran twice)
# is never reached. Both of the first attempts died before teardown and the
# result looked like a difference between builds when it was really a
# difference in how far each run got. A graceful `taskkill` (NO /F) posts
# WM_CLOSE and lets the dtor chain run.
#
# Pass a label; run it once on the pre-fix build and once on the fixed one.
# =========================================================================
LABEL="${1:-run}"
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f glass_layout.cfg lt_${LABEL}.log
( export BT_GLASS_PANELS=1 BT_GLASS_LAYOUT=save
bt_launch lt_${LABEL}.log ARENA1.EGG 0x03 )
# wait for the panels to actually exist before asking for a shutdown
for i in $(seq 1 60); do
grep -aq "per-display cockpit up" lt_${LABEL}.log 2>/dev/null && break
sleep 2
done
grep -aq "per-display cockpit up" lt_${LABEL}.log || { echo "PANELS NEVER CAME UP"; }
sleep 20 # let it settle into the mission
PID=$(cat "$BT_PIDFILE" 2>/dev/null | head -1)
echo "graceful close of pid $PID"
taskkill //PID "$PID" > /dev/null 2>&1 # NO /F -- WM_CLOSE, dtors run
for i in $(seq 1 30); do
tasklist //FI "PID eq $PID" 2>/dev/null | grep -q btl4 || break
sleep 1
done
taskkill //F //IM btl4.exe > /dev/null 2>&1 # backstop
rm -f "$BT_PIDFILE"
sleep 2
echo "=============== $LABEL ==============="
echo "--- every SaveLayout receipt (TWO lines = the double-save) ---"
grep -a "window position" lt_${LABEL}.log
echo
echo "--- glass_layout.cfg entries after teardown ---"
grep -aE "^[^#]+=" glass_layout.cfg 2>/dev/null || echo "(no file)"
echo
echo -n "entry count: "; grep -acE "^[^#]+=" glass_layout.cfg 2>/dev/null || echo 0
-13
View File
@@ -1,13 +0,0 @@
import sys
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
labels = gitea.call("/labels")
print("LABELS:", ", ".join("%s=%s" % (l["id"], l["name"]) for l in labels))
print()
rows = [r for r in gitea.all_issues("open") if "pull_request" not in r or not r.get("pull_request")]
rows.sort(key=lambda r: r["number"])
print("OPEN ISSUES: %d" % len(rows))
for r in rows:
lab = ",".join(l["name"] for l in r.get("labels", []))
print("#%-4s [%-22s] %s" % (r["number"], lab, r["title"][:120]))
-121
View File
@@ -1,121 +0,0 @@
#!/usr/bin/env bash
# =========================================================================
# #141 -- "missiles launch along the LEG/FOOT facing, then curve to the
# target -- PEER POV ONLY" (Oracle, night 13: "the emitter is following the
# foot facing"; shooter's own view correct).
#
# WHAT THE CODE SAYS SO FAR. Both sides already pass the mount segment:
# MissileLauncher::FireWeapon (mislanch.cpp:363) and the REPLICANT salvo
# mirror (mislanch.cpp:478) each hand BTPushProjectile
# `GetSegmentIndex() /*task #67 mount frame*/`. BTPushProjectile then
# rotates the authored MuzzleVelocity through
# `seg->GetSegmentToEntity() * localToWorld` -- so the launch direction IS
# the segment's world frame on BOTH nodes. Task #67 fixed exactly this
# symptom once already, master-side ("missiles fire out of his back").
#
# So if the peer report is real, the difference is NOT which frame is asked
# for -- it is whether the replicant's SEGMENT actually carries the torso
# twist. Torso pushes currentTwist into the skeleton on both paths
# (TorsoSimulation and TorsoCopySimulation both call UpdateJoints), so this
# has to be measured, not reasoned about.
#
# THE MEASUREMENT. New [launchframe] receipt (BT_PROJ_LOG) prints, on both
# nodes, the yaw of the launch forward vs the BODY forward:
# [launchframe] master seg=N segResolved=1 segYaw=.. bodyYaw=.. twistDelta=..
# [launchframe] REPLICANT seg=N segResolved=1 segYaw=.. bodyYaw=.. twistDelta=..
# twistDelta is the torso twist expressed in the launch direction.
#
# BUG CONFIRMED: A (master) shows |twistDelta| sweeping well away from 0
# while B (replicant mirror) stays pinned near 0
# -- or B shows segResolved=0 (fell back to the body basis).
# NOT REPRODUCED: both sides show the same twistDelta spread.
#
# ⚠ READ THE PREFIX, NOT THE PERCENTAGE (learned the hard way, 2026-08-08).
# BT_AUTOFIRE starts shooting IMMEDIATELY, while the app is still in
# WaitingForLaunch/LaunchingMission -- and Entity::Execute (ENTITY.cpp:556,
# real engine source) only calls PerformAndWatch when the state is
# RunningMission/EndingMission or the entity IsPreRunnable(). A REPLICANT mech
# is not pre-runnable (Entity::DefaultFlags has no PreRunFlag; only Player /
# Director add it, and Mech::Reset sets it for a reset MASTER), so a peer mech
# does not tick at all until the round actually starts. Every salvo fired
# before that reads twistDelta=0 legitimately -- the peer has no twist yet.
# That is a BENCH artifact (nobody can fire pre-round in a real match), not a
# defect: it showed up as a clean leading run of zeros, e.g.
# ZZZZ...(60)...ZZZZXXXX...(105)...XXXX
# and the first X lands within a few lines of the RunningMission transition.
# So: judge this bench by whether the failures are a PREFIX (fine) or
# INTERLEAVED (real), never by the raw percentage.
#
# Only A fires and only A sweeps its torso, so every REPLICANT line in B's
# log is a mirror of an A salvo and the comparison is unambiguous.
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f mf_a.log mf_b.log mf_relay.log
bt_expert_egg MP.EGG MF.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" MF.EGG
# B: the OBSERVER. Drives at A so it stays in range, but does NOT fire and
# does NOT sweep -- so every [launchframe] REPLICANT line in mf_b.log is a
# mirror of one of A's salvos.
( export BT_GOTO=enemy BT_GOTO_STOP=150
export BT_PROJ_LOG=1 BT_MP_LOG=1
bt_launch mf_b.log MF.EGG 0x0C -net 1601 )
sleep 2
# A: the SHOOTER. Sweeps the torso hard so twistDelta is unmistakably
# non-zero at fire time, and autofires missiles at the designated enemy.
( export BT_GOTO=enemy BT_GOTO_STOP=150
export BT_AUTOFIRE=1 BT_AF_MISSILE=1 BT_AF_PERIOD=7
export BT_LOCK_SWEEP=0.35
export BT_PROJ_LOG=1 BT_TORSO_LOG=1 BT_MP_LOG=1
bt_launch mf_a.log MF.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py MF.EGG 127.0.0.1:1501 127.0.0.1:1601 > mf_relay.log 2>&1 &
RELAY=$!
sleep 300
kill $RELAY 2>/dev/null
sleep 3
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
echo "=================== #141 MISSILE LAUNCH FRAME ==================="
echo "--- did A fire, and did B mirror? ---"
echo -n " A [launchframe] master lines ....... "; grep -ac "launchframe\] master" mf_a.log
echo -n " B [launchframe] REPLICANT lines .... "; grep -ac "launchframe\] REPLICANT" mf_b.log
echo
echo "--- did the segment RESOLVE on each side? (segResolved=0 would be the bug) ---"
echo -n " A segResolved=0 ... "; grep -a "launchframe\] master" mf_a.log | grep -ac "segResolved=0"
echo -n " B segResolved=0 ... "; grep -a "launchframe\] REPLICANT" mf_b.log | grep -ac "segResolved=0"
echo
echo "--- THE COMPARISON: twistDelta spread on each side ---"
python - <<'PY'
import re, io
def stats(path, tag):
v = []
try:
for ln in io.open(path, encoding="latin-1", errors="replace"):
if "[launchframe] " + tag in ln:
m = re.search(r"twistDelta=([-\d.e+]+)", ln)
if m:
try: v.append(float(m.group(1)))
except ValueError: pass
except IOError:
print(" %s: no log" % tag); return
if not v:
print(" %-9s no samples" % tag); return
a = [abs(x) for x in v]
big = sum(1 for x in a if x > 0.10) # ~5.7 deg -- clearly twisted
print(" %-9s n=%-4d |twistDelta| max=%.4f mean=%.4f >0.10rad: %d (%.0f%%)"
% (tag, len(v), max(a), sum(a)/len(a), big, 100.0*big/len(a)))
stats(r"C:\git\bt411\content\mf_a.log", "master")
stats(r"C:\git\bt411\content\mf_b.log", "REPLICANT")
print()
print(" VERDICT: master twisted + REPLICANT pinned near 0 => #141 CONFIRMED.")
print(" both twisted alike => NOT reproduced.")
PY
echo
echo "--- sample lines, both sides ---"
grep -a "launchframe\] master" mf_a.log | head -4
grep -a "launchframe\] REPLICANT" mf_b.log | head -4
-105
View File
@@ -1,105 +0,0 @@
#!/usr/bin/env bash
# =========================================================================
# #141 -- "missiles launch along the LEG/FOOT facing, then curve to the
# target -- PEER POV ONLY" (Oracle, night 13: "the emitter is following the
# foot facing"; shooter's own view correct).
#
# WHAT THE CODE SAYS SO FAR. Both sides already pass the mount segment:
# MissileLauncher::FireWeapon (mislanch.cpp:363) and the REPLICANT salvo
# mirror (mislanch.cpp:478) each hand BTPushProjectile
# `GetSegmentIndex() /*task #67 mount frame*/`. BTPushProjectile then
# rotates the authored MuzzleVelocity through
# `seg->GetSegmentToEntity() * localToWorld` -- so the launch direction IS
# the segment's world frame on BOTH nodes. Task #67 fixed exactly this
# symptom once already, master-side ("missiles fire out of his back").
#
# So if the peer report is real, the difference is NOT which frame is asked
# for -- it is whether the replicant's SEGMENT actually carries the torso
# twist. Torso pushes currentTwist into the skeleton on both paths
# (TorsoSimulation and TorsoCopySimulation both call UpdateJoints), so this
# has to be measured, not reasoned about.
#
# THE MEASUREMENT. New [launchframe] receipt (BT_PROJ_LOG) prints, on both
# nodes, the yaw of the launch forward vs the BODY forward:
# [launchframe] master seg=N segResolved=1 segYaw=.. bodyYaw=.. twistDelta=..
# [launchframe] REPLICANT seg=N segResolved=1 segYaw=.. bodyYaw=.. twistDelta=..
# twistDelta is the torso twist expressed in the launch direction.
#
# BUG CONFIRMED: A (master) shows |twistDelta| sweeping well away from 0
# while B (replicant mirror) stays pinned near 0
# -- or B shows segResolved=0 (fell back to the body basis).
# NOT REPRODUCED: both sides show the same twistDelta spread.
#
# Only A fires and only A sweeps its torso, so every REPLICANT line in B's
# log is a mirror of an A salvo and the comparison is unambiguous.
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f mf_a.log mf_b.log mf_relay.log
bt_expert_egg MP.EGG MF.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" MF.EGG
# B: the OBSERVER. Drives at A so it stays in range, but does NOT fire and
# does NOT sweep -- so every [launchframe] REPLICANT line in mf_b.log is a
# mirror of one of A's salvos.
( export BT_GOTO=enemy BT_GOTO_STOP=150
export BT_PROJ_LOG=1 BT_MP_LOG=1 BT_TORSO_LOG=1
bt_launch mf_b.log MF.EGG 0x0C -net 1601 )
sleep 2
# A: the SHOOTER. Sweeps the torso hard so twistDelta is unmistakably
# non-zero at fire time, and autofires missiles at the designated enemy.
( export BT_GOTO=enemy BT_GOTO_STOP=150
export BT_AUTOFIRE=1 BT_AF_MISSILE=1 BT_AF_PERIOD=7
export BT_LOCK_SWEEP=0.35
export BT_PROJ_LOG=1 BT_TORSO_LOG=1 BT_MP_LOG=1
bt_launch mf_a.log MF.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py MF.EGG 127.0.0.1:1501 127.0.0.1:1601 > mf_relay.log 2>&1 &
RELAY=$!
sleep 170
kill $RELAY 2>/dev/null
sleep 3
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
echo "=================== #141 MISSILE LAUNCH FRAME ==================="
echo "--- did A fire, and did B mirror? ---"
echo -n " A [launchframe] master lines ....... "; grep -ac "launchframe\] master" mf_a.log
echo -n " B [launchframe] REPLICANT lines .... "; grep -ac "launchframe\] REPLICANT" mf_b.log
echo
echo "--- did the segment RESOLVE on each side? (segResolved=0 would be the bug) ---"
echo -n " A segResolved=0 ... "; grep -a "launchframe\] master" mf_a.log | grep -ac "segResolved=0"
echo -n " B segResolved=0 ... "; grep -a "launchframe\] REPLICANT" mf_b.log | grep -ac "segResolved=0"
echo
echo "--- THE COMPARISON: twistDelta spread on each side ---"
python - <<'PY'
import re, io
def stats(path, tag):
v = []
try:
for ln in io.open(path, encoding="latin-1", errors="replace"):
if "[launchframe] " + tag in ln:
m = re.search(r"twistDelta=([-\d.e+]+)", ln)
if m:
try: v.append(float(m.group(1)))
except ValueError: pass
except IOError:
print(" %s: no log" % tag); return
if not v:
print(" %-9s no samples" % tag); return
a = [abs(x) for x in v]
big = sum(1 for x in a if x > 0.10) # ~5.7 deg -- clearly twisted
print(" %-9s n=%-4d |twistDelta| max=%.4f mean=%.4f >0.10rad: %d (%.0f%%)"
% (tag, len(v), max(a), sum(a)/len(a), big, 100.0*big/len(a)))
stats(r"C:\git\bt411\content\mf_a.log", "master")
stats(r"C:\git\bt411\content\mf_b.log", "REPLICANT")
print()
print(" VERDICT: master twisted + REPLICANT pinned near 0 => #141 CONFIRMED.")
print(" both twisted alike => NOT reproduced.")
PY
echo
echo "--- sample lines, both sides ---"
grep -a "launchframe\] master" mf_a.log | head -4
grep -a "launchframe\] REPLICANT" mf_b.log | head -4
-105
View File
@@ -1,105 +0,0 @@
#!/usr/bin/env bash
# =========================================================================
# #141 -- "missiles launch along the LEG/FOOT facing, then curve to the
# target -- PEER POV ONLY" (Oracle, night 13: "the emitter is following the
# foot facing"; shooter's own view correct).
#
# WHAT THE CODE SAYS SO FAR. Both sides already pass the mount segment:
# MissileLauncher::FireWeapon (mislanch.cpp:363) and the REPLICANT salvo
# mirror (mislanch.cpp:478) each hand BTPushProjectile
# `GetSegmentIndex() /*task #67 mount frame*/`. BTPushProjectile then
# rotates the authored MuzzleVelocity through
# `seg->GetSegmentToEntity() * localToWorld` -- so the launch direction IS
# the segment's world frame on BOTH nodes. Task #67 fixed exactly this
# symptom once already, master-side ("missiles fire out of his back").
#
# So if the peer report is real, the difference is NOT which frame is asked
# for -- it is whether the replicant's SEGMENT actually carries the torso
# twist. Torso pushes currentTwist into the skeleton on both paths
# (TorsoSimulation and TorsoCopySimulation both call UpdateJoints), so this
# has to be measured, not reasoned about.
#
# THE MEASUREMENT. New [launchframe] receipt (BT_PROJ_LOG) prints, on both
# nodes, the yaw of the launch forward vs the BODY forward:
# [launchframe] master seg=N segResolved=1 segYaw=.. bodyYaw=.. twistDelta=..
# [launchframe] REPLICANT seg=N segResolved=1 segYaw=.. bodyYaw=.. twistDelta=..
# twistDelta is the torso twist expressed in the launch direction.
#
# BUG CONFIRMED: A (master) shows |twistDelta| sweeping well away from 0
# while B (replicant mirror) stays pinned near 0
# -- or B shows segResolved=0 (fell back to the body basis).
# NOT REPRODUCED: both sides show the same twistDelta spread.
#
# Only A fires and only A sweeps its torso, so every REPLICANT line in B's
# log is a mirror of an A salvo and the comparison is unambiguous.
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f mf_a.log mf_b.log mf_relay.log
bt_expert_egg MP.EGG MF.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" MF.EGG
# B: the OBSERVER. Drives at A so it stays in range, but does NOT fire and
# does NOT sweep -- so every [launchframe] REPLICANT line in mf_b.log is a
# mirror of one of A's salvos.
( export BT_GOTO=enemy BT_GOTO_STOP=150
export BT_PROJ_LOG=1 BT_MP_LOG=1 BT_TORSO_LOG=1 BT_NET_TRACE=1
bt_launch mf_b.log MF.EGG 0x0C -net 1601 )
sleep 2
# A: the SHOOTER. Sweeps the torso hard so twistDelta is unmistakably
# non-zero at fire time, and autofires missiles at the designated enemy.
( export BT_GOTO=enemy BT_GOTO_STOP=150
export BT_AUTOFIRE=1 BT_AF_MISSILE=1 BT_AF_PERIOD=7
export BT_LOCK_SWEEP=0.35
export BT_PROJ_LOG=1 BT_TORSO_LOG=1 BT_MP_LOG=1
bt_launch mf_a.log MF.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py MF.EGG 127.0.0.1:1501 127.0.0.1:1601 > mf_relay.log 2>&1 &
RELAY=$!
sleep 170
kill $RELAY 2>/dev/null
sleep 3
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
echo "=================== #141 MISSILE LAUNCH FRAME ==================="
echo "--- did A fire, and did B mirror? ---"
echo -n " A [launchframe] master lines ....... "; grep -ac "launchframe\] master" mf_a.log
echo -n " B [launchframe] REPLICANT lines .... "; grep -ac "launchframe\] REPLICANT" mf_b.log
echo
echo "--- did the segment RESOLVE on each side? (segResolved=0 would be the bug) ---"
echo -n " A segResolved=0 ... "; grep -a "launchframe\] master" mf_a.log | grep -ac "segResolved=0"
echo -n " B segResolved=0 ... "; grep -a "launchframe\] REPLICANT" mf_b.log | grep -ac "segResolved=0"
echo
echo "--- THE COMPARISON: twistDelta spread on each side ---"
python - <<'PY'
import re, io
def stats(path, tag):
v = []
try:
for ln in io.open(path, encoding="latin-1", errors="replace"):
if "[launchframe] " + tag in ln:
m = re.search(r"twistDelta=([-\d.e+]+)", ln)
if m:
try: v.append(float(m.group(1)))
except ValueError: pass
except IOError:
print(" %s: no log" % tag); return
if not v:
print(" %-9s no samples" % tag); return
a = [abs(x) for x in v]
big = sum(1 for x in a if x > 0.10) # ~5.7 deg -- clearly twisted
print(" %-9s n=%-4d |twistDelta| max=%.4f mean=%.4f >0.10rad: %d (%.0f%%)"
% (tag, len(v), max(a), sum(a)/len(a), big, 100.0*big/len(a)))
stats(r"C:\git\bt411\content\mf_a.log", "master")
stats(r"C:\git\bt411\content\mf_b.log", "REPLICANT")
print()
print(" VERDICT: master twisted + REPLICANT pinned near 0 => #141 CONFIRMED.")
print(" both twisted alike => NOT reproduced.")
PY
echo
echo "--- sample lines, both sides ---"
grep -a "launchframe\] master" mf_a.log | head -4
grep -a "launchframe\] REPLICANT" mf_b.log | head -4
-60
View File
@@ -1,60 +0,0 @@
#!/usr/bin/env bash
# =========================================================================
# Sauron: "toggled through advanced controls from standard to advanced and
# back to standard -- lost torso control."
#
# THE MECHANISM (@004afbe0, the binary's CycleControlModeMessageHandler):
# the mode cycles 0 Basic -> 1 Standard -> 2 Veteran -> WRAPS TO BASIC. So
# getting from Veteran/"advanced" back to Standard PASSES THROUGH BASIC, and
# the Basic arm re-centres the torso.
#
# The port set that re-centre with CommandRecenter() -> centerCommand (@0x208).
# That is the HELD-BUTTON cell: TorsoSimulation re-arms `recenterActive` from
# it EVERY frame it is non-zero and only the input path clears it -- and a mode
# switch has no button release to follow. One visit to Basic pinned it at 1
# forever. Digital twist commands are processed BEFORE the centerCommand
# block, so they were overridden as fast as they were applied = "lost torso
# control". The binary writes recenterActive (@0x274) instead: a ONE-SHOT that
# self-clears on settle and is cancelled by any twist input.
#
# THE MEASUREMENT. BT_MODECYCLE_EVERY=<n> cycles the control mode every n mapper ticks
# frames from that frame. BT_TORSO_LOG's gate probe now prints the two cells:
# [torso] ... ctrCmd=<centerCommand> recen=<recenterActive> vLim=(lo..hi)
#
# PASS: ctrCmd stays 0 across every cycle (the one-shot is used instead), and
# vLim SWAPS between the Basic pair and the assisted pair as the mode
# changes -- proving the elevation-limit swap (@0x228/@0x22C vs
# @0x230/@0x234) that the port previously never implemented.
# FAIL: ctrCmd latches to 1 after the first pass through Basic and never
# returns to 0 -> the torso re-centres forever.
#
# Single node: this is entirely local control state, no peer needed.
# =========================================================================
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f mc_a.log
bt_expert_egg MP.EGG MC.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" MC.EGG
( export BT_MODECYCLE_EVERY=400
export BT_TORSO_LOG=1 BT_KEY_NOFOCUS=1 BT_KEY_BRIDGE=0 BT_TWIST_PULSE=150 ${LEGACY:+BT_LEGACY_MODE_RECENTER=1}
bt_launch mc_a.log MC.EGG 0x03 )
sleep 150
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3
echo "=================== CONTROL-MODE TORSO STATE ==================="
echo "--- the mode cycles that happened ---"
grep -a "\[mode\] control mode" mc_a.log | head -10
echo
echo "--- centerCommand must NEVER latch (ctrCmd=1 with no button = the bug) ---"
echo -n " samples with ctrCmd=1 : "; grep -ao "ctrCmd=[0-9]*" mc_a.log | grep -c "ctrCmd=1"
echo -n " samples with ctrCmd=0 : "; grep -ao "ctrCmd=[0-9]*" mc_a.log | grep -c "ctrCmd=0"
echo
echo "--- the elevation-limit SWAP (should differ between Basic and assisted) ---"
grep -ao "vLim=([^)]*)" mc_a.log | sort | uniq -c | sort -rn | head -5
echo
echo "--- torso state around each mode change ---"
grep -aE "\[mode\] control mode|ctrCmd=" mc_a.log | grep -aA1 "\[mode\]" | head -12
-88
View File
@@ -1,88 +0,0 @@
"""Night 13: rewrite #147's body (the inline attempt was mangled by the shell). ASCII only."""
import sys
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
BODY = """**Reported by Oracle (2026-08-06 session):** "no range finder on this drop" + a screenshot.
Symptom confirmed by eye: **the ladder tick marks were present, the moving caret was not.**
Intermittent -- one drop, not every drop. He was the only tester who hit it.
## What it is NOT
Ruled out from the four field logs:
* **Not a Steam-host bug.** Oracle *was* hosting (`[lobby] host: lobby up` / `GO with N member(s)`
appears in his log and no other), but the host path is uninvolved: the range **computed** fine on
his node (1806 nonzero `[target] range=` samples) and the reticle **built** fine on every drop
(`[hud] reticle built: 7 weapon pip(s) registered` -- 6 drops, 6 builds).
* **Not the Thor chassis.** He flew a Thor, which is why the report reads as chassis-specific -- but
a second tester flew a Thor the same night *without* hosting and reported nothing, and the range
ladder is drawn by the shared `HudSimulation` / `BTReticleRenderable`, not per-chassis cockpit
content. No Thor asset-missing warnings in any log (only the known wreck-model fallbacks).
* **Not his destroyed HUD.** His HUD subsystem *was* destroyed twice -- the only tester to reach
`condition 0` all night -- but only for ~11 s and ~26 s, repaired by respawn each time. And a
destroyed HUD costs you the fire-control LOCK (own host zone >= 0.75 damage, `_DAT_004b7ec4`),
not the caret.
## The defect
`sShownRange` -- the value the caret ultimately binds to -- is a **function-level static** in
mech4.cpp's targeting step. One cell for the whole process, shared by every mech, carried across
drops, never re-seeded. Its update:
```c
float step = trueRange - sShownRange;
if (step > maxStep) step = maxStep;
if (step < -maxStep) step = -maxStep;
sShownRange += step;
```
**NaN is absorbing here, and the clamps cannot catch it** -- `step > maxStep` and `step < -maxStep`
are BOTH false for NaN. So a single poisoned frame makes `sShownRange` NaN and it stays NaN *for
the life of the process*.
The consumer repeats the same mistake -- `BTReticleRenderable::Draw`:
```c
Scalar range = (rangeAttr2 != 0) ? *rangeAttr2 : 0.0f;
if (range < minRange) range = minRange; // false for NaN
if (range > maxRange) range = maxRange; // false for NaN
Scalar frac = (range - minRange) / (maxRange - minRange);
```
NaN flows into `AddPoint` / `ConcatMatrix`, so the caret and its bar become **degenerate geometry
and stop rendering** -- while every static reticle element, tick marks included, still draws.
That is exactly the reported symptom: ticks present, caret gone, sticky until relaunch.
## Why no log could confirm it
**The caret's actual input had no diagnostic anywhere.** `BT_RANGE_LOG` instruments the *pick*
(#4), and the `range=` field in `[target]` is a separate, locally recomputed
`Sqrt(ddx*ddx + ddy*ddy + ddz*ddz)` inside the weapon-range check -- it is neither `sShownRange`
nor `gBTHudRangeStorage`. Grepping the field logs for NaN returns nothing because **the poisoned
variable was never printed.** Absence of the signal was not evidence of absence.
## Fixed (unreleased)
1. **Re-seed on mech change** -- a new drop starts at the binary's 1200 default instead of
inheriting the previous mission's slid value. Deliberately does NOT fire on respawn: that
reuses the entity, and the binary does not reset the readout on respawn either.
2. **NaN trap at the producer** -- re-seed to 1200 rather than propagate.
3. **NaN-safe clamp at the consumer** -- test `x == x` first, and fall back to the authentic
no-target peg (1200) instead of rendering nothing.
4. **`BT_RANGE_LOG` now prints the caret's real input** -- `[range] caret input shown=... true=...
lock=...` at 1 Hz, plus a `[range] NaN TRAPPED` receipt.
## Status [T3 -- honest]
The defect and the symptom match exactly, and the fix is correct on its own merits: a
process-lifetime static feeding unguarded float geometry is a bug regardless of who reported what.
But **the causal link to Oracle's report is INFERENCE, not proof.** The NaN source is unidentified
and the failure has not been reproduced. What would settle it: fly with `BT_RANGE_LOG=1` -- if the
caret dies again, the log now names the frame it happened on.
"""
gitea.call("/issues/147", method="PATCH", payload={"body": BODY})
print("rewrote #147 body")

Some files were not shown because too many files have changed in this diff Show More