Author SHA1 Message Date
CydandClaude Opus 4.8 e446781953 PPC scramble: animate it as a collapse -> hold-roll+shake -> recover -> lock
Reworks the flat per-row shear into the authentic sync-loss-and-relock
transition the playtesters described, on both render paths (surround
DrawDevSurface + glass ExpandPlaneToBGRA, native + rotated radar):

  1. COLLAPSE -- the image squeezes to a thin centred vertical line.
  2. HOLD (~0.8s, the window the card held the detuned CRTC) -- the line's
     content scrolls WILDLY fast and decelerates, plus a violent per-frame
     SHAKE (LCG jitter: vertical row bounce + horizontal scroll jitter).
  3. RECOVERY (~0.5s) -- the line broadens back to full while the scroll,
     tear and shake settle to zero.
  4. LOCK -- clean full display.

Mechanism: FunkyVideo(on, dur) records start+hold; ScrambleParams is a
two-phase envelope (hold: deep collapse + decelerating scroll = integral of
v(t)=rollMax*(1-t/hold); recovery: smoothstep broaden + settle).  The read
loops map the source through it -- `scale` sets a black-bordered collapse
band, `rollOff` SCROLLS (wraps) within it, `shear` is a per-row diagonal,
`shakeRow` bounces the source row.  The SVGA16 owns the full hold+recovery
clock, so FunkyVideo(False) is a no-op (the card restoring sync is where the
recovery begins) -- the recovery isn't cut off when the 0.8s latch clears.

Tunable by eye (pod-monitor PLL look isn't recoverable from the binary):
BT_SCRAMBLE_COLLAPSE (0.03), _ROLL (9000 px/s), _SHAKE (10 px), _SHEAR (3),
_DUR (hold), _RECOVER (0.5s).  BT_SCRAMBLE_TEST loops the transition for
tuning; BT_SCRAMBLE_CYCLE loops it stepping the roll speed.

Verified: Release links clean; surround loops the effect at slowed + true
full speed, all secondaries + radar collapse/roll/shake/recover together,
main view clean, no crash.  SHIPPING with these defaults for playtester
feedback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-09 22:12:12 -05:00
CydandClaude Opus 4.8 2e46c3fb1c PPC hit scrambles the secondary displays (phase-14, all three work items)
Restores the PPC's authentic secondary-display effect: an EnergyDamageType hit
scrambles every secondary cockpit display for 0.8s while the main out-the-window
view stays clean.  Was fully specced (phase-14) with the engine half already
present under the original VWE names; only the trigger and the visual were
missing (the visual STUBBED since 2007).

A -- TRIGGER (game/reconstructed/mech.cpp): in TakeDamageMessageHandler, at the
  binary's @0x4a03f3 position (after the cylinder resolve, before the burst
  loop, so ONCE per damage message) fire
  GetGaugeRenderer()->SpecialEffect(scrambleVideo, damageType*0.2f) on
  EnergyDamageType (==4).  That type is authored on exactly the 14 PPC/ERPPC
  records, so the branch is structurally PPC-exclusive -- no weapon-class check.
  Duration DERIVED from the ordinal (4*0.2==0.8s), not a literal.  BT_DMG_LOG
  prints [ppc-scramble].

B -- VISUAL (SVGA16::FunkyVideo, was the 2007 stub): FunkyVideo now arms
  scrambleActive; new SVGA16::ScrambleRowShift is a per-source-row horizontal
  shear+roll, read by BOTH DrawDevSurface (surround/dock) and ExpandPlaneToBGRA
  (glass windows, native + rotated radar), so all secondary surfaces shear
  together in source space and the main 3D view (separate timing chain) is
  untouched -- the modern stand-in for the VGA CRTC Horizontal-Total detune.
  Tunable: BT_SCRAMBLE_SHEAR (px/line, def 4), BT_SCRAMBLE_ROLL (px/sec, def
  220); BT_SCRAMBLE_TEST=1 forces it on for tuning by eye.

C -- NON-STACKING LATCH (L4GaugeRenderer::SpecialEffect): ignore the re-arm while
  scrambleVideoFlag is set (matches the binary's `modified` latch) -- a second
  PPC during the window no longer extends it.  Was a divergence.

Verified: Release links clean; surround boots + runs with the effect forced on,
every secondary MFD + the radar shear while the out-the-window view stays clean,
no crash (screenshot).  Open (for the playtesters who filed the report): live
PPC-fire confirmation + by-eye shear tuning -- k/roll are not recoverable from
the binary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-09 22:12:12 -05:00
CydandClaude Opus 4.8 5fbe78e159 docs: PPC-hit CRTC sync-distortion finding + phase-14 port spec
Disassembly of BTL4OPT.EXE (2026-08-06) recovered the PPC's authentic
secondary-display effect: an EnergyDamageType (==4) hit calls the gauge
renderer's SpecialEffect(scrambleVideo, damageType*0.2f), which detunes the
VGA CRTC Horizontal Total by -9 for 0.8s -- every secondary cockpit display
loses horizontal sync ("looks like the CRTs are being degaussed"), the main
VPX view is untouched.  damageType 4 is authored on exactly the 14 PPC/ERPPC
records, so the branch is structurally PPC-exclusive.

  - phases/phase-14-ppc-sync-distortion.md: the port spec (trigger + visual +
    fidelity constraints + verification).
  - context/gauges-hud.md: full disasm chain + the FlashPalette non-confusion.
  - context/combat-damage.md: the damageType==4 branch in the damage handler.

NOT YET IMPLEMENTED -- this commit is the spec; the effect is the next work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-09 22:12:12 -05:00
127 changed files with 910 additions and 9831 deletions
+7 -15
View File
@@ -10,15 +10,12 @@
`BTL4OPT.EXE` binary on top of the working WinTesla engine.
**Repo of record:** the top-level `CMakeLists.txt` + `README.md` build `btl4.exe`. Layout:
`engine/ game/ content/ docs/ reference/ tools/ context/`.
**Current front (2026-08-11):** core gameplay reconstruction is COMPLETE and the K/D chain is
STRESS-PROVEN (kd4/kd8 benches: receipt-derived invariants exact at 4 and 8 nodes, gotcha §25
closed the same-frame double-credit). This week's settled fronts: projectile flight clocks +
live-target contact (#168), generator-out voice at the trip (the port's ONE content deviation,
#154), annunciator lamp table + stale-flash heal (#135), ordered exit (#163), ram eligibility
(#128). The decomp export was REBUILT 2026-08-06 (#60 closed: coverage 93.5% — cite `@ADDR`,
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.
**Current front (2026-08-06):** core gameplay reconstruction is COMPLETE — SP+MP loop, combat/
damage/scoring, locomotion (gait/gimp/CROUCH), night kit (searchlight), death/respawn, replication
all authentic + benched (builds 4.11.774→801). The decomp export was REBUILT 2026-08-06 (#60
closed: coverage 93.5%, dark code 41 KB — cite `@ADDR`, NOT `part_0NN.c:LINE`; the old export is
archived). Active work: the POLISH list. 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` |
| **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` |
| 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` |
| 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` |
@@ -95,11 +91,7 @@ precise than anything you can infer.
| 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 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` |
**`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.
| 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` |
---
+1 -1
View File
@@ -1384,7 +1384,7 @@ cmHeat(K,ModeSecondaryHeat,58,heatpal.pcc,heatpal2.pcc,Myomers);
}
#=======================================================================
# Denkou (was: Sunder V1)
# Sunder V1
#=======================================================================
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
*"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`.
- **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
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
+18 -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
(part_013.c:7758). `MechWeapon::UpdateTargetState` (`FUN_004b9bdc` [T1]): `targetWithinRange =
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
heatLoad** — the port originally computed `effectiveRange = (1 heatLoad) × weaponRange`
(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,
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`).
- **B @0x4a05d9, not newly killed && tally≠0** → type 0 to the shooter: the **per-hit INFLICTED
credit**, and it is LIVE. ⚠ **CORRECTED 2026-08-07** — this entry previously read "the ONLY
registered 0x16 receiver Verify-rejects type 0 → 1995 folded an UNINITIALIZED stack float into
the shooter's score on every non-lethal hit (real 1995 bug — @0x4c0200 … is in NO table entry:
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]
- **B @0x4a05d9, not newly killed && tally≠0** → type 0 to the shooter. The ONLY registered 0x16
receiver Verify-rejects type 0 → 1995 folded an UNINITIALIZED stack float into the shooter's
score on every non-lethal hit (real 1995 bug — @0x4c0200, the handler that accepts type 0, is
in NO table entry: dead code). Port sends it for wire fidelity, banks award 0.
- **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
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
`inflictingSubsystemID` (msg+0x5c, engine T0 name).
**Score model consequence** [T1, ⚠ REWRITTEN 2026-08-07]: 1995 pod scoring = **per-hit inflicted
credit + kill awards + received-damage penalties + death costs**. The previous text here claimed
"No per-hit inflicted credit … inventions riding the dead @0x4c0200 channel; both retired" — that
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.
**Score model consequence** [T1]: 1995 pod scoring = **kill awards + received-damage penalties +
death costs. No per-hit inflicted credit** (the port's old per-hit crediting — and the #95 salvo
fix on top of it — were inventions riding the dead @0x4c0200 channel; both retired).
Kill award = `(victimKillBonus + tally) × killerRole.damageInflictedModifier ×
(victimAvgZoneDamage@0x354 × damageBias + 1.0) × (victimTonnage/killerTonnage)`; same-team kill in
a non-FFA game = `-friendlyFirePenalty` basis (inline strcmp of `teamName@0x20c`, gate
@@ -708,6 +637,16 @@ Benches: `scratchpad/night12/scorekill.sh` (cross-node kill: killer `kills=1 awa
victim respawns, death #1 single-cycle) + `scoreself.sh` (#134 negation). Collision-death tail
fallthrough is inspection-tier [T3] — shares the benched tail code; field wall-deaths exercise it.
**The handler's OTHER damageType branch — `damageType==4` (Energy) = the PPC cockpit-sync glitch
[T1, 2026-08-06].** Between the collision divert and the burst loop sits a second type test
@`0x4a03f3`: `cmp [esi+0x2c],4 / jne 0x4a0423`. On a match it calls the gauge renderer's vtable
slot 19 with `((float)damageType × 0.2, 0)` = `(0.8f, 0)`, which detunes the **VGA CRTC Horizontal
Total by 9** for 0.8 s — every secondary cockpit display loses horizontal sync, the main VPX view
is untouched. `EnergyDamageType` is authored on **exactly the 14 PPC/ERPPC records and nothing
else**, so this is structurally PPC-exclusive. It fires **once per damage message** (outside the
burst loop). Full chain + addresses: [[gauges-hud]] §"PPC HIT = a deliberate CRTC horizontal-sync
DETUNE"; port spec: `phases/phase-14-ppc-sync-distortion.md`. **Implemented 2026-08-06** (branch `ppc-sync-distortion`).
## (HISTORICAL — the gap as found 2026-07-29, superseded above) [T1]
The authored crit machinery exists and is reconstructed — `Mech__DamageZone::CriticalHit @0049ccc4`
(half the damage to armour, half to ONE critical subsystem chosen by `criticalWeight`, capped by
@@ -1125,9 +1064,7 @@ player's advancedDamage copy (+0x268, `BTPlayerAdvancedDamageOn` bridge) — the
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
`collisionCriticalHitWeight` (@0x10C, MechSubsystem) vs a [0,1) roll, eligibility =
IsDerivedFrom HeatSinkBANK/Gyroscope/Torso (GUIDs 0x50e590/0x50fdc0/0x510b08 -- ⚠ 0x50e590
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
IsDerivedFrom HeatSink-family/Gyroscope/Torso (GUIDs 0x50e590/0x50fdc0/0x510b08; all
MechSubsystem-based, so +0x10C is valid), applied via `ApplyDamageAndMeasure` (@0x4ac07c
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
@@ -1261,70 +1198,6 @@ code involved; grammar = the binary ctors @0x49ea48/@0x49e5e4). Results:
cylinder** -- diverted at the handler head (0x4a0361: test type; 0x4a036d:
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
- Weapons/roster: [[subsystems]]. Aim source: [[locomotion]] (drive/facing). Effects: [[rendering]].
- P5 forensics: `docs/HARD_PROBLEMS.md`. Data: [[decomp-reference]] §4-5.
## The K/D-score AUTHORITY MODEL -- settled once and for all (2026-08-11, #162) [T1]
The 1995 chain has ONE emitter and NO receiver dedup; every counter is single-owner.
Fixed twice piecemeal (#150 kills, #162 deaths) before the full audit settled it -- do
not touch any single limb of this chain again without re-reading this section.
**Emitter (Mech::TakeDamageMessageHandler @0x4a0230 -- the victim's MASTER only;
replicant-asserted part_012.c:14613):** entry latch `local_14` = destroyed-at-entry
(modes 2|9, @0x49fb54). Three id-0x16 reports: **A** type-2 KILL -> shooter's player,
alive->dead edge only, exclusive with B (basis = victim role's killBonus, @0x4a0506);
**B** type-0 Inflicted -> shooter, non-lethal AND corpse hits (tally != 0); **C** type-1
Received -> victim's own player, latch-INDEPENDENT (corpse hits included; basis =
intended damage = bursts x amount). Death tail (gate `local_14==0 && mode 9|10`,
@0x4a07b5): exactly ONE **VehicleDead** (id 0x17, deathCount=-1, killer player id) ->
victim's own player, + explosion + delayed burn + splash (fresh TakeDamages, gate
advancedDamageOn && !suppressConsole) + ForceUpdate. Collision (type-0 damage) diverts
past ALL reports straight to the tail: a ram death counts + costs but credits no one.
**Consumer (BTPlayer):** 0x16 type-2 @0x4c02e4: CalcKillScore, killCount(+0x27c)++ dual
(the phantom wrong-column partner -- keep byte-faithful), suicide fchs negation skips
both increments. 0x17 @0x4c05c4 deathCount==-1 arm: deaths(+0x280)++ then STRAIGHT-LINE
the -500 cost (gate advancedDamageOn(+0x264) ONLY; -role->specialCaseDeathPenalty
(+0x20) via DIRECT Player::ScoreMessageHandler base call -- never on the wire, never in
a matchlog; the [deathcost] DEBUG line is its only receipt). **The receiver dedup gate
+0x290 is NEVER written non-zero anywhere in BTL4OPT.EXE [T1 image byte-scan]** -- the
binary's single-increment guarantee is purely the SENDER's one-shot edge. Its clear
pair (+0x290=0, +0x258=0) lives in the DropZoneReply tail (FUN_004c012c,
part_013.c:10519) -- the #81-era port put it in the VehicleDead arm by misattribution,
disarming the port's defensive latch (corrected 2026-08-11).
**Replication:** the player update record carries currentScore(+0x1c8) +
dropZoneLocation ONLY. killCount/deaths never cross the wire in 1995; each node's
scoreboard columns are locally accumulated (the port's SBMIRROR is a deliberate
deviation that makes boards consistent).
**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
(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]
@@ -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 +
0x28=0, +0x2c killed-by PLAYER EntityID, +0x34 kill zone}. Senders: the TakeDamage report tail
— 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**
(`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):
@@ -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
producer = the THERMAL BREAKER in GeneratorSimulation itself; a 2026-08-03
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
`@004c07cd-0x4c0828` (inside the @004c05c4 export gap — missing from the #52
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]:
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
content authors NO role keys, so the cost is 0 in the field. **CORRECTED 2026-08-07 —
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:
content authors NO role keys, so the cost is 0 in the field. Other role+0x1c reader:
`@0x4a0506` inside the deferred id-0x16 report tail (#45) reads killBonus.
- **THE SIM TIME MODEL — CLOSED 2026-08-02 (issue #96) [T1]:** the arcade
`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
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
- **#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.
**✅ ALL THREE PIECES BUILT same day (see [[open-questions]] for the full closure note).**
Corrections pinned while building: the SENDER is **`MechTech::TechnicalAssistance` @004ad33c**
+84 -40
View File
@@ -13,6 +13,7 @@ open_questions:
- "Upper-MFD PRESET pages RESOLVED 2026-07-19 (Gitea #9): SetPresetMode table @0051dbf0 re-decoded (little-endian -> ModeMFD bits 0-14), per-MFD pod button banks identified from the .CTL dump, desktop J/K/L cycle wired"
- "Always-active msg-4 records IDENTIFIED 2026-07-20 (glass input audit): 0x2C = Reservoir InjectCoolant (the flush button), 0x2F/0x2E/0x2D/0x2B/0x2A/0x29 = Condenser1-6 MoveValve, 0x1A-0x1D = GeneratorA-D ToggleGeneratorOnOff (@0050fb90; wired 2026-07-25, powersub.cpp); plus 0x13 = Mech DuckRequest (CROUCH -- COMPLETE 2026-08-06, [[locomotion]]), 0x28 = Mech BalanceCoolant, 0x12/0x14 = ThermalSight/Searchlight toggles (searchlight visuals done 2026-08-05) -- see pod-hardware.md + docs/GLASS_COCKPIT.md; statuses re-swept 2026-08-06"
- "MP DEATHS resolved 2026-07-12 (observed-death tally + display clamp); remaining: verify multi-death tallies stay in sync across a long session (GAUGE_COMPOSITE.md)"
- "PPC `scrambleVideo` IMPLEMENTED 2026-08-06 (branch ppc-sync-distortion): a PPC hit scrambles every secondary display for 0.8 s (modern per-scanline shear stand-in for the CRTC Horizontal-Total detune). Trigger in Mech::TakeDamageMessageHandler (damageType==4), visual in SVGA16::FunkyVideo/ScrambleRowShift (DrawDevSurface + ExpandPlaneToBGRA), non-stacking latch in L4GaugeRenderer::SpecialEffect. Screenshot-verified; live PPC-fire confirmation + by-eye shear tuning (BT_SCRAMBLE_SHEAR/ROLL) left to playtesters. Spec: phases/phase-14-ppc-sync-distortion.md"
---
# Cockpit Gauges / MFD HUD
@@ -414,14 +415,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 +
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)
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
@@ -557,38 +550,6 @@ and every instrument is now live [T2]:**
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
**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
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
@@ -795,6 +756,89 @@ pooling fix) — an alarm that cannot acquire a source is silent.
⚠ The bench cannot confirm audibility: it runs with no audio device (`live=0
pooled=0`), so the control chain is verified but final playback is not.
## PPC HIT = a deliberate CRTC horizontal-sync DETUNE on every secondary display (2026-08-06) [T1 disasm-verified]
**✅ IMPLEMENTED 2026-08-06** (branch `ppc-sync-distortion`; screenshot-verified —
every secondary MFD + the radar shear together, the out-the-window view stays
clean). Trigger + visual + non-stacking latch — see
`phases/phase-14-ppc-sync-distortion.md` for the port details and the
`BT_SCRAMBLE_*` tuning envs. Reported by playtesters as "being hit by a PPC makes
it look like all of the secondary CRTs were being degaussed" — main (VPX) view
unaffected, PPC strikes only. Both observations are exactly what the binary does.
The disasm chain below is the ground truth the port was built from.
**The gate is the damage TYPE, and only the PPC has it.** A `BTL4.RES`
subsystem census gives `damageType` 4 (`EnergyDamageType`) = **14 records,
every one PPC or ERPPC**; everything else is Ballistic (16), Explosive (30),
Laser (78). So a branch keyed on type 4 is structurally PPC-exclusive.
The chain, on the **VICTIM's** machine (all `@` from `BTL4OPT.EXE`,
md5 `a97075bcb5634d13263e9ad5a2b96fd0`):
1. `Mech::TakeDamageMessageHandler` @`0x4a0230`, branch @**`0x4a03f3`** — sits
between the collision divert and the burst loop, so it runs **once per
damage message**, not per burst:
```
004a03f3 mov ecx,[esi+0x2c] ; damage.damageType
004a03f6 cmp ecx,4 ; EnergyDamageType
004a03f9 jne 0x4a0423 ; everything else -> burst loop
004a03fb mov eax,[0x4efc94] ; the global `application`
004a0400 mov eax,[eax+0x4c] ; -> gauge renderer
004a0405 je 0x4a0423 ; null-guarded
004a0407 fild dword [esi+0x2c] ; (float)damageType == 4.0
004a040a fld xword [0x4a0c08] ; long double 0.2
004a0410 fmulp st(1) ; => 0.8
004a041d call dword [edx+0x4c] ; vtable slot 19, args (0.8f, 0)
```
⚠ The duration is **derived, not constant**: `(float)damageType × 0.2`.
2. Gauge-renderer vtable @`0x51cebc`, slot 19 (`+0x4c`) = @**`0x46ffcc`**
(an `L4GaugeRenderer` method — the 0x46xxxx MUNGA_L4 range, so the
capability is shared-engine; BT is what wires it to Energy damage).
Second arg must be 0 (`sub eax,1; jae ret`). Body: `svga16 = this[+0x1c52c]`
(null-guarded — same member RP fetches for its `FlashPalette`), then
`this[+0x1c534] = 1` (active) and `this[+0x1c538] = now + 0.8 s` in ticks.
3. @`0x46d840` — thin wrapper, drops `this`, forwards the flag.
4. @**`0x47d76d`** — the payload, straight VGA CRTC I/O:
```
out(0x3D4,0x11); v=in(0x3D5); out(0x3D5, v & 0x7F) ; unlock CRTC regs 0-7
out(0x3D4,0x00) ; CRTC 0 = HORIZONTAL TOTAL
if (arg==0) { out(0x3D5, saved); modified=0; } ; restore
else if (!modified) { modified=1; saved=in(0x3D5);
out(0x3D5, saved-9); } ; <<< shorten the scanline
out(0x3D4,0x11); out(0x3D5, v) ; restore write-protect
```
Globals: `modified` @`0x4fe0fe`, `saved` @`0x4fe0ff`. The `modified` latch
makes it **idempotent** — overlapping PPC hits do NOT stack, and a second
hit does not re-save an already-detuned value.
5. @**`0x47003c`** (per frame): `if (active && now >= expiry) { active = 0;
SVGADistortSync(svga16, 0); }` — restores the saved Horizontal Total.
**Why it reads as a degauss, and why only the secondaries.** CRTC register 0 is
the character-clock count per scanline — it *is* the horizontal scan frequency.
9 drives every attached monitor's horizontal oscillator off frequency: the
image shears/rolls/wobbles until it re-locks, then snaps back 0.8 s later. All
six secondary displays are derived by the VDB from that one VGA's timing, so
they glitch **together**; the main view comes off the Division VPX card on an
independent timing chain and is untouched. No relay, no VDB register, no
palette work — the VDB just propagates a deliberately corrupted sync. (The
`LampTesla1/2/3` "solid-state relays" in `L4CTRL.HPP` are NOT involved and are
driven by nothing in the surviving tree.)
⚠ **Do not confuse this with the `SVGA16::FlashPalette` pixel-mask cycler**
(`flashRate`/`mask[4]`, ports `0x302/0x30A/0x312`). That machinery is linked and
its per-frame cycler runs in BT, but `FlashPalette` @`0x46d5f4` has **zero call
sites and zero address-of references** in `BTL4OPT.EXE` — BT never arms it.
**RP does**: `RPL4OPT.EXE` @`0x4addce` calls `FlashPalette(palette 1 =
SecondaryPalette, rate 2.0, masks {FF,BF,7F,3F})` from its gauge-renderer ctor —
hardware-assisted alarm blinking by masking off the top two pixel bits. Same
pods, so it is an easy source of cross-game misattribution.
Scope note: callers of vtable slot 19 were not exhaustively enumerated (virtual
dispatch); the PPC site was found via the three `application+0x4c` uses
(`0x4a03fb` here, `0x4cc3be` / `0x4d1559` unrelated). The low-level path IS
exhaustive — @`0x47d76d` has exactly one caller, and @`0x46d840` exactly two
(set @`0x47002b`, restore @`0x470076`).
## Key Relationships
- Full history: `docs/GAUGE_COMPOSITE.md`; reticle recovery: `phases/phase-02-dpl2d-reticle.md`.
- Uses: [[attribute-pointer]] + [[reconstruction-gotchas]]; reads [[subsystems]] state.
-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
(`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)
`MechControlsMapper` (mechmppr.cpp @004afbe0; btl4mppr.cpp mappers) interprets input → `speedDemand`
/ `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]
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
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)
- ~~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
@@ -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
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.
## 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
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):
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
@@ -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
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]
`reference/manual/Tesla40_BT_manual.pdf` (34pp, from Nick). CONFIRMS the reconstruction on
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
operator's screenshot is worth ten theories: the red-faces capture identified
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).
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
FailureHeat with recovery gated on NormalHeat — trip at T>2000, re-arm at T≤1000, exactly
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
FailureHeat; 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
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,
@@ -302,39 +299,3 @@ the `BTGetSubsystemAuxScreen` bridge. See `docs/VEHICLE_SUBSYSTEMS.md` + [[gauge
- Data: [[decomp-reference]] (ClassIDs/hierarchy). Bugs: [[reconstruction-gotchas]].
- Feeds: [[combat-damage]] (weapons/damage), [[gauges-hud]] (attribute state).
- 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.
- **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
- 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)
- 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)
-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`
- 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,
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)
- color/camo (7, display→tag): White=White, Black=Black, Brown=Brown, Red=**Crimson**, Green=Green,
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
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).
## 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.
### Stubbed (no WinTesla analog) — revisit at integration
- ~~BTPlayer ctor role-registry resolution~~**RESOLVED 2026-08-07.** The claim "no WinTesla
analog → stubbed; base-set `scenarioRole` stands" was wrong twice over: the base ctor sets
`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.
- BTPlayer ctor role/mission-registry resolution: `GetMissionRegistry()` / `GetRoleRegistry()->Lookup()` /
`GetMission()->GetGameModel()` have no WinTesla analog → stubbed; base-set `scenarioRole` stands.
### Link-time externs to be PROVIDED by owning modules (Stage 3)
- `ToggleVoiceAssist`, `Is_Destroyed` (mechmppr)
+4 -41
View File
@@ -1,5 +1,4 @@
#include <cstdlib>
#include <cstring>
#include "munga.h"
#pragma hdrstop
@@ -979,31 +978,6 @@ AudioStateTrigger::AudioStateTrigger(
MemoryStream_Read(stream, &excludeTransition);
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)
DEBUG_STREAM << "[statecfg] attrPtr=" << (void*)attributePointer
<< " comp=" << (void*)audioComponentSocket.GetCurrent()
@@ -1102,21 +1076,10 @@ void
//
// Check for the trigger state
//
const int bt_fires =
((!inverseTrigger && new_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)
if (
(!inverseTrigger && new_state == triggerState) ||
(inverseTrigger && old_state == triggerState)
)
{
Check(&audioComponentSocket);
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
JointedMover::GetSegmentToWorld(
@@ -135,13 +125,6 @@ void
)
{
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();
Check(joints);
@@ -152,7 +135,6 @@ void
//
if (joints->AreJointsModified())
{
++gBTSegWDirty;
EntitySegment::SegmentTableIterator iterator(segmentTable);
EntitySegment *current_segment;
while( (current_segment = iterator.ReadAndNext() ) != NULL)
@@ -171,12 +153,6 @@ void
my_segment.GetSegmentToEntity(),
localToWorld
);
if (sPerf)
{
QueryPerformanceCounter(&t1);
QueryPerformanceFrequency(&fq);
gBTSegWMs += 1000.0 * (double)(t1.QuadPart - t0.QuadPart) / (double)fq.QuadPart;
}
Check_Fpu();
}
-20
View File
@@ -53,17 +53,6 @@ void
while ((lamp_pointer=i.ReadAndNext()) != NULL)
{
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();
}
Check_Fpu();
@@ -259,7 +248,6 @@ Lamp::Lamp(
previousState = LampStateUndefined;
manager = lamp_manager;
alertActive = 0;
staleAlertNotify = False; // #135
manager->AddLamp(this);
Check_Fpu();
@@ -293,10 +281,6 @@ void
{
NotifyOfStateChange();
}
else
{
staleAlertNotify = True; // #135: deliver at next in-mode Update
}
}
Check_Fpu();
}
@@ -327,10 +311,6 @@ void
{
NotifyOfStateChange();
}
else
{
staleAlertNotify = True; // #135: deliver at next in-mode Update
}
Check_Fpu();
}
-8
View File
@@ -78,14 +78,6 @@ protected:
*manager;
int
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;
friendlyFirePenalty = player_data->friendlyFirePenalty;
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
{
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);
Warn(" does not exists in resource! ");
damageReceivedModifier = 0.0f;
-13
View File
@@ -96,19 +96,6 @@ AttributeWatcher::AttributeWatcher(
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"))
{
extern int g_curAudioWatcherClass;
-1
View File
@@ -23,7 +23,6 @@ ALuint *g_buffers;
int g_numBuffers;
const char *g_bufferNames[512]; // bufferInd -> sample file (diag: the live-playing dump)
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
+5 -16
View File
@@ -1118,22 +1118,11 @@ LBE4ControlsManager::~LBE4ControlsManager()
#ifdef BT_GLASS
//
// Symmetric with the Create above (2026-08-06). The HARDWARE RIO's dtor
// knows nothing about the panels, and windows that outlive the surfaces
// they blit are a crash waiting for the next mission cycle.
//
// CORRECTION (2026-08-07, #140): the original comment here claimed this was
// "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.
// Symmetric with the Create above (2026-08-06). ~PadRIO tears the panels
// down itself, so this is a no-op there -- but the HARDWARE RIO's dtor
// knows nothing about them, and windows that outlive the surfaces they
// blit are a crash waiting for the next mission cycle. Destroy is safe
// if they were never created.
//
BTGlassPanels_Destroy();
#endif
+18 -457
View File
@@ -96,8 +96,6 @@ struct GButton
struct GWin
{
double perfMs; // [glassperf] paint ms this window, this second
int perfN; // [glassperf] paints this second
const char *title;
HWND hwnd;
@@ -129,12 +127,6 @@ struct GWin
char monitorName[40]; // the PHYSICAL monitor this window landed on
// (\.\DISPLAYn), stamped at creation; shown
// by BT_POD_IDENT so the cab can be mapped.
// Dirty-skip (2026-08-09): the last change token the pump blitted for this
// window (plane checksum + lamp render). Zero-init (static storage); the pump
// re-blits only when the token differs, so unchanged panels cost nothing.
unsigned long lastToken;
int haveToken;
};
static GWin gWins[8];
@@ -752,70 +744,7 @@ void
// Heat MFD=monitor:2,bare (index into the enumeration order)
// 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; };
// ---------------------------------------------------------------------------
// 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;
}
struct MonScan { int index; const char *want; int wantIndex; RECT rect; int found; };
static BOOL CALLBACK
MonScanProc(HMONITOR mon, HDC, LPRECT, LPARAM param)
@@ -827,17 +756,7 @@ static BOOL CALLBACK
if (GetMonitorInfoA(mon, (MONITORINFO *)&mi))
{
int hit = 0;
// `id:<fragment>` -- match the panel's HARDWARE identity, not its
// 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)
if (sc->want != NULL)
{
// Match the full device name OR just its tail, so a hand-written
// cfg can say `monitor:DISPLAY4` and skip the \\.\ prefix entirely
@@ -862,96 +781,6 @@ static BOOL CALLBACK
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.
static int
ResolveMonitorSpec(const char *spec, RECT *out)
@@ -960,31 +789,13 @@ static int
MonScan sc;
memset(&sc, 0, sizeof(sc));
sc.wantIndex = -1;
// `monitor:id:<fragment>` -- BOOT-STABLE hardware identity (preferred on the
// 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')
if (spec[0] >= '0' && spec[0] <= '9')
sc.wantIndex = atoi(spec);
else
sc.want = spec;
EnumDisplayMonitors(NULL, NULL, MonScanProc, (LPARAM)&sc);
if (sc.found)
*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;
}
@@ -1105,50 +916,9 @@ static void
<< layoutFileName << "\n" << std::flush;
}
// LAST-KNOWN GEOMETRY, kept ACROSS teardown (2026-08-07, #140).
//
// SaveLayout rewrites the whole file, so it can only be as complete as what it
// 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.
// 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
// on finished-drag and on teardown in save mode.
static void
SaveLayout()
{
@@ -1175,7 +945,6 @@ static void
"# this list too -- it can be dragged, remembered and set ,noframe.\n",
f);
int wrote = 0;
// 1. refresh the remembered geometry from whatever is currently alive
for (int i = 0; i < gWinCount; ++i)
{
GWin &gw = gWins[i];
@@ -1184,16 +953,10 @@ static void
RECT r;
if (!GetWindowRect(gw.hwnd, &r))
continue;
RememberGeom(gw.title, r, gw.noFrame); // keep the hand-added option
}
// 2. write the remembered set -- including windows already torn down
for (int i = 0; i < gLastGeomCount; ++i)
{
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" : "");
fprintf(f, "%s=%ld,%ld,%ld,%ld%s\n", gw.title,
(long)r.left, (long)r.top,
(long)(r.right - r.left), (long)(r.bottom - r.top),
gw.noFrame ? ",noframe" : ""); // keep the hand-added option
++wrote;
}
// External windows (the plasma window) ride the same file. Cache the last
@@ -1219,15 +982,8 @@ static void
++wrote;
}
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 "
<< layoutFileName << " (live=" << gWinCount
<< " remembered=" << gLastGeomCount << ")\n" << std::flush;
<< layoutFileName << "\n" << std::flush;
}
// Public save trigger for registered external windows -- their WndProc calls
@@ -1424,16 +1180,7 @@ static void
info.bmiHeader.biCompression = BI_RGB;
const RECT &r = w->surfaceRect;
// STRETCH MODE (perf, 2026-08-09): default COLORONCOLOR. HALFTONE runs a
// per-output-pixel resample filter; done synchronously for all 7 glass windows
// every ~16 Hz repaint (BTGlassPanels_Tick), it was the per-display-mode perf
// sink -- playtesters saw ~20 fps in the exploded panels vs ~130 fps in the
// cockpit surround (same scene, same machine). The MFDs are low-res pixel
// content, so nearest-neighbour reads crisp (and closer to the pod CRT).
// BT_GLASS_SMOOTH=1 restores HALFTONE for anyone who prefers smoothing to speed.
static int sSmooth = -1;
if (sSmooth < 0) sSmooth = getenv("BT_GLASS_SMOOTH") ? 1 : 0;
SetStretchBltMode(dc, sSmooth ? HALFTONE : COLORONCOLOR);
SetStretchBltMode(dc, HALFTONE);
SetBrushOrgEx(dc, 0, 0, NULL);
StretchDIBits(dc,
r.left, r.top, r.right - r.left, r.bottom - r.top,
@@ -1712,20 +1459,7 @@ static LRESULT CALLBACK
switch (message)
{
case WM_PAINT:
if (w != NULL)
{
// [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;
}
if (w != NULL) { PaintGlass(window, w); return 0; }
break;
case WM_ERASEBKGND:
@@ -1768,14 +1502,6 @@ static LRESULT CALLBACK
}
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;
SetCapture(window);
PadRIO::SetScreenButton(a, 1);
@@ -1795,21 +1521,6 @@ static LRESULT CALLBACK
}
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:
if (w != NULL)
{
@@ -1850,11 +1561,6 @@ void
if (gWinCount != 0)
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.
// 0 = none, 1 = 90 CCW, 2 = 180, 3 = 90 CW (default -- the pod's
// portrait CRT, user-verified upright).
@@ -2035,24 +1741,8 @@ void
void
BTGlassPanels_Destroy()
{
// #140 receipt (ungated, one line per teardown): this function has two
// callers on the desktop path and the ORDER is what broke the layout file.
// 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();
SaveLayout(); // backstop for a clean teardown (WM_EXITSIZEMOVE already
// caught every finished drag); no-op unless mode==save
for (int i = 0; i < gWinCount; ++i)
{
@@ -2088,153 +1778,24 @@ void
// one-shot re-snap); a focused window just repaints from whichever fires first.
//###########################################################################
//
// GLASS DIRTY-SKIP (2026-08-09): re-blit only the windows whose content changed.
// Each window is a plane of the ONE shared gauge pixelBuffer, so its change token is
// the masked plane checksum (SVGA16::PlaneChecksum over every port that can feed the
// window) combined with each button's RENDERED lamp brightness + held/latched state.
// Folding the flash BRIGHTNESS in (not the raw lamp state) means a flashing lamp
// repaints exactly when it toggles, and a static panel -- or a fully idle cockpit --
// skips its expand + StretchDIBits entirely. The plane checksum is the only added
// cost (once per window per ~16 Hz pump) and is far cheaper than the paint it saves.
//
static unsigned long
GlassWindowToken(GaugeRenderer *gr, GWin *w, unsigned long tick)
{
unsigned long token = 2166136261UL;
if (w->portPrimary != NULL && gr != NULL)
{
// Every plane that could feed the window (primary + Eng twin + RGB group +
// their twins) -- OR their masks so a change to ANY is caught; checksum once.
const char *ports[8];
int np = 0;
ports[np++] = w->portPrimary;
if (w->portAlt != NULL) ports[np++] = w->portAlt;
for (int gi = 0; gi < w->groupCount && np < 7; ++gi)
{
ports[np++] = w->groupPort[gi];
if (w->groupAlt[gi] != NULL && np < 8) ports[np++] = w->groupAlt[gi];
}
int combined = 0;
SVGA16 *svga = NULL;
for (int i = 0; i < np; ++i)
{
L4GraphicsPort *p =
static_cast<L4GraphicsPort*>(gr->GetGraphicsPort(ports[i]));
if (p == NULL) continue;
combined |= p->GetBitMask();
if (svga == NULL) svga = static_cast<SVGA16*>(p->graphicsDisplay);
}
if (svga != NULL && combined != 0)
token ^= svga->PlaneChecksum(combined);
}
// Lamps: each button's RENDERED brightness + held/latched, so a flash toggle or a
// press repaints exactly the window it lives on.
for (int j = 0; j < w->buttonCount; ++j)
{
int addr = w->buttons[j].address;
unsigned long shade =
(unsigned long)LampBrightnessOf(PadRIO::GetLampState(addr), tick);
int held = (addr == pressedAddress) || latched[addr & 0x7F];
token = (token ^ ((unsigned long)(addr & 0xFF) << 4)
^ (shade << 1) ^ (unsigned long)held) * 16777619UL;
}
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
BTGlassPanels_Tick()
{
if (gWinCount == 0)
return;
BTRioHealthReport();
static unsigned long sLastPaint = 0;
unsigned long now = GetTickCount();
if (now - sLastPaint < (unsigned long)RepaintMilliseconds)
return;
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();
int repainted = 0;
for (int i = 0; i < gWinCount; ++i)
{
GWin &w = gWins[i];
if (w.hwnd == NULL)
continue;
unsigned long token = GlassWindowToken(gr, &w, now);
if (!sLegacySweep && w.haveToken && token == w.lastToken)
continue; // gauges + lamps unchanged -> skip this window
w.lastToken = token;
w.haveToken = 1;
++repainted;
InvalidateRect(w.hwnd, NULL, FALSE);
UpdateWindow(w.hwnd); // synchronous paint, not the throttled queue
}
// BT_GLASS_DIRTY: report how many window-repaints the dirty-skip let through vs
// the old fixed gWinCount-per-pump, so the saving is visible.
static int sDirtyLog = -1;
if (sDirtyLog < 0) sDirtyLog = getenv("BT_GLASS_DIRTY") ? 1 : 0;
if (sDirtyLog)
if (gWins[i].hwnd != NULL)
{
static unsigned long sWin = 0;
static int sPumps = 0, sPaints = 0;
++sPumps; sPaints += repainted;
if (now - sWin >= 2000)
{
DEBUG_STREAM << "[glass-dirty] " << sPumps << " pumps -> " << sPaints
<< " window-repaints (was " << (sPumps * gWinCount) << " always-on)\n"
<< std::flush;
sWin = now; sPumps = 0; sPaints = 0;
InvalidateRect(gWins[i].hwnd, NULL, FALSE);
UpdateWindow(gWins[i].hwnd); // synchronous paint, not the throttled queue
}
}
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;
}
}
+7 -2
View File
@@ -829,13 +829,18 @@ void
switch (type)
{
case scrambleVideo:
if (graphicsDisplay != NULL)
// NON-STACKING LATCH (phase-14 fidelity): the binary latches on `modified`
// @0x4fe0fe -- a second PPC hit while the scramble is ALREADY live does NOT
// re-save the (already detuned) value and does NOT extend the window. This
// original unconditionally overwrote scrambleVideoTimeout, so rapid PPC fire
// STACKED the effect -- a divergence from the binary. Ignore re-arm while live.
if (graphicsDisplay != NULL && !scrambleVideoFlag)
{
scrambleVideoFlag = True;
scrambleVideoTimeout = ((Scalar)Now()) + duration;
Check(graphicsDisplay);
((SVGA16*) graphicsDisplay)->FunkyVideo(True);
((SVGA16*) graphicsDisplay)->FunkyVideo(True, duration); // duration -> the recovery envelope
}
break;
-12
View File
@@ -36,7 +36,6 @@ int gBTPadViewToggleEdges = 0;
// desktop bridge, which owns `reverseThrust` (mapper attr 6 @0x124) every frame.
//
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
@@ -459,17 +458,6 @@ void
// via SetScreenButton), so the desktop bridge can honour the button exactly
// 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)
{
gBTReverseHeld = pressed ? 1 : 0;
+4 -49
View File
@@ -410,20 +410,6 @@ int PCSerialPacket::ReceivePacket(BYTE *destPtr) // returns len (zero if none)
// Returns:
// 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
{
//--------------------------------------------------------------
@@ -432,11 +418,6 @@ void PCSerialPacket::SendPacket(BYTE *srcPtr) //sends packet
if(!enabled) //if disabled, don't even try
return;
if (gPcspakTxSickUntil != 0 && GetTickCount() < gPcspakTxSickUntil)
{
return; // adapter sick: drop, don't stall
}
BYTE cmd = *srcPtr; //get the command byte
cmd &= 0x7F; //remove MSB
int length = txLengthPtr[cmd]; //get the length in al
@@ -478,37 +459,11 @@ void PCSerialPacket::SendPacket(BYTE *srcPtr) //sends packet
return;
}
}
//
// BOUNDED completion wait (2026-08-10, ALPHA-MR field minidump). The
// RIO's "COM1" is a USB-serial adapter; when its USB link drops with a
// 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)
DWORD bytesWritten;
GetOverlappedResult(hComm, &overlapSend, &bytesWritten, TRUE);
if(bytesWritten != length + 1)
{
GetOverlappedResult(hComm, &overlapSend, &bytesWritten, FALSE);
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;
DEBUG_STREAM << "Not all data was written to serial port!\n" << std::flush;
}
delete buffer;
}
+1 -21
View File
@@ -25,11 +25,7 @@ static LRESULT CALLBACK
return 0;
case WM_CLOSE:
// Minimize, never hide (#71): SW_HIDE left no way back short of a
// 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);
ShowWindow(window, SW_HIDE); // hide only; the renderer owns it
return 0;
}
return DefWindowProcW(window, message, wparam, lparam);
@@ -54,22 +50,6 @@ PlasmaWindow::PlasmaWindow():
}
blitBuffer = new unsigned long[plasmaWidth * plasmaHeight];
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"
<< scale << ")\n" << std::flush;
}
-156
View File
@@ -1240,105 +1240,6 @@ RIO::~RIO()
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
RIO::TestInstance() const
{
@@ -1363,44 +1264,6 @@ Logical
Logical reply, looping;
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);
//
//cout << flush;
@@ -1514,20 +1377,6 @@ Logical
break;
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;
//
// NOTE: no data is sent in this packet.
@@ -1574,13 +1423,11 @@ Logical
break;
case ButtonPressed:
++gRioBtnCount;
destination->Type = RIO::ButtonPressedEvent;
destination->Data.Unit = receive_buffer[1];
break;
case ButtonReleased:
++gRioBtnCount;
destination->Type = RIO::ButtonReleasedEvent;
destination->Data.Unit = receive_buffer[1];
break;
@@ -1721,9 +1568,6 @@ void
if (operational && !TestModeActive)
{
SendPacket((Byte *) request_analog_string);
++gRioReqCount;
gRioReqPending = 1;
gRioLastReqTick = GetTickCount();
}
Check_Fpu();
}
-46
View File
@@ -632,49 +632,3 @@ unsigned long long
}
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();
void
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).
+234 -34
View File
@@ -615,17 +615,41 @@ void SVGA16::DrawDevSurface(LPDIRECT3DDEVICE9 device, int slot, int mask, int pa
Word *source = pixelBuffer.Data.MapPointer;
Word *dest = (Word*)rect.pBits;
int postRowIncrement = (rect.Pitch / 2) - w;
// PPC sync-scramble (phase-14): map the SOURCE read through the recovery
// envelope (collapse toward a line -> broaden -> lock). Identity when not
// armed (ScrambleParams returns False). Output columns outside the collapsed
// band read index 0 (black), so the picture squeezes to a line and grows back.
double scl, shr, roff; int cx, shake;
Logical scr = ScrambleParams(w, &scl, &shr, &roff, &cx, &shake);
double invS = scr ? (1.0 / scl) : 1.0;
if (monoTint < 0)
{
// PALETTE surface (sec/radar) -- palette-LUT expand (== SVGA16::Update case 0).
SVGA16Palette *pal = &palette[paletteID];
for (int y = 0; y < h; y++)
{
int sry = y + shake; if (sry < 0) sry = 0; else if (sry >= h) sry = h - 1;
Word *srcRow = source + sry * w; // vertical shake bounces the row
double rowScroll = roff + shr * (double)y; // scroll + diagonal, per row
for (int x = 0; x < w; x++)
{
PaletteTriplet *pe = &(pal->paletteData.Color[*source & mask]);
Word px;
if (scr)
{
double srcBase = ((double)x - cx) * invS + cx; // collapse band
if (srcBase < 0.0 || srcBase >= w)
px = 0; // black outside the line
else
{
long sx = (long)(srcBase + rowScroll + 0.5);
sx %= w; if (sx < 0) sx += w; // roll SCROLLS within source
px = srcRow[sx];
}
}
else px = srcRow[x];
PaletteTriplet *pe = &(pal->paletteData.Color[px & mask]);
*dest = ((pe->Red >> 3) << 11) | ((pe->Green >> 2) << 5) | (pe->Blue >> 3);
dest++; source++;
dest++;
}
dest += postRowIncrement;
}
@@ -637,10 +661,27 @@ void SVGA16::DrawDevSurface(LPDIRECT3DDEVICE9 device, int slot, int mask, int pa
Word tint = (Word) monoTint;
for (int y = 0; y < h; y++)
{
int sry = y + shake; if (sry < 0) sry = 0; else if (sry >= h) sry = h - 1;
Word *srcRow = source + sry * w; // vertical shake bounces the row
double rowScroll = roff + shr * (double)y;
for (int x = 0; x < w; x++)
{
*dest = (*source & mask) ? tint : 0;
dest++; source++;
Word px;
if (scr)
{
double srcBase = ((double)x - cx) * invS + cx;
if (srcBase < 0.0 || srcBase >= w)
px = 0;
else
{
long sx = (long)(srcBase + rowScroll + 0.5);
sx %= w; if (sx < 0) sx += w;
px = srcRow[sx];
}
}
else px = srcRow[x];
*dest = (px & mask) ? tint : 0;
dest++;
}
dest += postRowIncrement;
}
@@ -692,28 +733,6 @@ void SVGA16::DrawDevSurface(LPDIRECT3DDEVICE9 device, int slot, int mask, int pa
device->DrawPrimitiveUP(D3DPT_TRIANGLEFAN, 2, quad, sizeof(InsetVert));
}
//===========================================================================//
// GLASS dirty-skip: FNV-1a over the shared pixelBuffer masked to `mask` -- the bits
// one glass window can show. The glass repaint pump compares this per window and
// re-blits only the ones whose plane changed (L4GLASSWIN BTGlassPanels_Tick). Full
// pass (no stride) so a single-word gauge change is never missed; ~640*480 cheap
// integer ops, run at most once per window per ~16 Hz pump.
//===========================================================================//
unsigned long SVGA16::PlaneChecksum(int mask) const
{
int w = pixelBuffer.Data.Size.x;
int h = pixelBuffer.Data.Size.y;
const Word *p = pixelBuffer.Data.MapPointer;
if (p == NULL || w <= 0 || h <= 0)
return 0;
unsigned long sum = 2166136261UL; // FNV-1a offset basis
Word m = (Word)mask;
int n = w * h;
for (int i = 0; i < n; ++i)
sum = (sum ^ (unsigned long)(p[i] & m)) * 16777619UL;
return sum;
}
//===========================================================================//
// GLASS per-display windows -- the CPU (no-D3D) analog of DrawDevSurface: expand
// one bit-plane of the shared gauge pixelBuffer into a 32-bit BGRA image that the
@@ -734,16 +753,38 @@ void SVGA16::ExpandPlaneToBGRA(int mask, int paletteID, int monoTint, int rotate
Word *base = pixelBuffer.Data.MapPointer;
SVGA16Palette *pal = &palette[paletteID];
// PPC sync-scramble (phase-14) -- the recovery envelope (collapse -> broaden ->
// lock), mapped in SOURCE space so it survives the rotation. Identity when not
// armed (ScrambleParams False). Reads outside the collapsed band -> index 0 (black).
double scl, shr, roff; int cx, shake;
Logical scr = ScrambleParams(w, &scl, &shr, &roff, &cx, &shake);
double invS = scr ? (1.0 / scl) : 1.0;
if (rotateQuadrant == 0)
{
// Native orientation, top-down straight copy.
for (int y = 0; y < h; y++)
{
Word *src = base + y * w;
int sry = y + shake; if (sry < 0) sry = 0; else if (sry >= h) sry = h - 1;
Word *src = base + sry * w; // vertical shake bounces the row
unsigned long *d = dst + y * w;
double rowScroll = roff + shr * (double)y;
for (int x = 0; x < w; x++)
{
Word s = src[x];
Word s;
if (scr)
{
double srcBase = ((double)x - cx) * invS + cx; // collapse band
if (srcBase < 0.0 || srcBase >= w)
s = 0; // black outside the line
else
{
long sx = (long)(srcBase + rowScroll + 0.5);
sx %= w; if (sx < 0) sx += w; // roll SCROLLS within source
s = src[sx];
}
}
else s = src[x];
if (monoTint < 0)
{
PaletteTriplet *pe = &(pal->paletteData.Color[s & mask]);
@@ -791,6 +832,8 @@ void SVGA16::ExpandPlaneToBGRA(int mask, int paletteID, int monoTint, int rotate
// 90-degree rotation: output is transposed (ow = h, oh = w). rotate 3 = CW,
// rotate 1 = CCW (the DrawDevSurface convention; BT_GAUGE_SEC_ROT picks it).
// The scramble maps the SOURCE column (sx keyed on source row sy) through the
// same envelope, so the radar recovers the same way the landscape MFDs do.
int ow = h, oh = w;
for (int oy = 0; oy < oh; oy++)
{
@@ -808,7 +851,21 @@ void SVGA16::ExpandPlaneToBGRA(int mask, int paletteID, int monoTint, int rotate
sx = w - 1 - oy;
sy = ox;
}
Word s = base[sy * w + sx];
int syS = sy + shake; if (syS < 0) syS = 0; else if (syS >= h) syS = h - 1; // shake
Word s;
if (scr)
{
double srcBase = ((double)sx - cx) * invS + cx; // collapse band (source col)
if (srcBase < 0.0 || srcBase >= w)
s = 0;
else
{
long msx = (long)(srcBase + roff + shr * (double)sy + 0.5);
msx %= w; if (msx < 0) msx += w; // roll SCROLLS within source
s = base[syS * w + msx];
}
}
else s = base[syS * w + sx];
if (monoTint < 0)
{
PaletteTriplet *pe = &(pal->paletteData.Color[s & mask]);
@@ -5445,6 +5502,9 @@ SVGA16::SVGA16(
BuildWindows(init_width,init_height,windowed, secondaryIndex, aux1Index, aux2Index);
for (int _i = 0; _i < 10; _i++) // DEV-COMPOSITE: lazily created on first surface draw
mDevSurfaceTex[_i] = NULL;
scrambleActive = False; // PPC sync-scramble (phase-14) -- armed by FunkyVideo
scrambleStartMs = 0;
scrambleDurationS = 0.0;
//STUBBED: VIDEO RB 1/15/07
# if defined(DEBUG)
Tell("SVGA16::SVGA16()\n");
@@ -6404,13 +6464,153 @@ void
Check_Fpu();
}
//
// PPC sync-scramble (phase-14), animated as a RECOVERY. The original
// (`//STUBBED: VIDEO RB 1/15/07`) detuned the VGA CRTC Horizontal Total so every
// secondary monitor lost horizontal lock for ~0.8 s. There is no CRTC here, so
// FunkyVideo records the start + duration and the per-surface expansions
// (DrawDevSurface / ExpandPlaneToBGRA) map the pixelBuffer read through the
// ScrambleParams envelope: at the hit the image COLLAPSES horizontally toward a
// line, then BROADENS back out as the tear + roll DECAY, and LOCKS to the full
// clean display at the end. The 0.8 s window + non-stacking latch stay in
// L4GaugeRenderer; the shape lives here.
//
void
SVGA16::FunkyVideo(Logical on_off)
SVGA16::FunkyVideo(Logical on_off, Scalar duration)
{
//STUBBED: VIDEO RB 1/15/07
//Check(this);
//SVGAFunkyVideo(on_off);
//Check_Fpu();
if (on_off)
{
scrambleActive = True;
scrambleStartMs = GetTickCount();
scrambleDurationS = (duration > 0.0f) ? (double)duration : 0.8; // the HOLD
}
// FunkyVideo(False) is a NO-OP: the card restoring the sync (at HOLD end) is
// where the RECOVERY begins, so ScrambleParams keeps running past the flag,
// self-terminating at hold+recovery. scrambleActive stays armed; the clock gates.
}
//
// Two-phase envelope (per the "hold the bad sync, THEN recover" direction):
// HOLD [0, hold] -- the card holds the detuned CRTC. Deep collapse (a line),
// and a WILDLY fast horizontal roll that decelerates over
// the hold (content scrolls, wrapping, so it can't be read).
// RECOVERY [hold, +] -- the sync is restored and the monitor re-locks: the line
// BROADENS back to full while the residual scroll + tear
// settle to zero, then LOCKS (returns False = clean).
// Fills the read-loop parameters: scale (collapse band width fraction), rollOff
// (SCROLL offset in px, wrapped within the source), shear (px/row diagonal),
// centerX. Tunables (read once): BT_SCRAMBLE_COLLAPSE (min band, deeper=smaller),
// BT_SCRAMBLE_ROLL (initial scroll px/sec -- "wildly high"), BT_SCRAMBLE_SHEAR
// (px/row), BT_SCRAMBLE_RECOVER (recovery seconds), BT_SCRAMBLE_DUR (hold seconds
// override). BT_SCRAMBLE_TEST loops it; BT_SCRAMBLE_CYCLE loops stepping the roll.
//
Logical
SVGA16::ScrambleParams(int width, double *scale, double *shear,
double *rollOff, int *centerX, int *shakeRow) const
{
*shakeRow = 0;
if (width <= 0)
return False;
static int sInit = 0, sTest = 0, sCycle = 0;
static double shearMax = 3.0, rollMax = 9000.0, collapse = 0.03,
recover = 0.5, holdEnv = 0.0, shakeAmp = 10.0;
if (!sInit)
{
sInit = 1;
sTest = (getenv("BT_SCRAMBLE_TEST") != NULL) ? 1 : 0;
sCycle = (getenv("BT_SCRAMBLE_CYCLE") != NULL) ? 1 : 0;
const char *e;
if ((e = getenv("BT_SCRAMBLE_SHEAR")) != NULL) shearMax = atof(e);
if ((e = getenv("BT_SCRAMBLE_ROLL")) != NULL) rollMax = atof(e);
if ((e = getenv("BT_SCRAMBLE_COLLAPSE")) != NULL) collapse = atof(e);
if ((e = getenv("BT_SCRAMBLE_RECOVER")) != NULL) recover = atof(e);
if ((e = getenv("BT_SCRAMBLE_DUR")) != NULL) holdEnv = atof(e);
if ((e = getenv("BT_SCRAMBLE_SHAKE")) != NULL) shakeAmp = atof(e);
if (collapse < 0.004) collapse = 0.004;
if (collapse > 1.0) collapse = 1.0;
if (recover < 0.05) recover = 0.05;
}
double hold = (holdEnv > 0.0) ? holdEnv
: (scrambleDurationS > 0.0 ? scrambleDurationS : 0.8);
double total = hold + recover;
double rlMax = rollMax;
double elapsed;
if (sTest || sCycle)
{
// Tuning: loop the whole effect (hold+recovery) + a short locked pause.
// CYCLE steps the initial roll speed each loop.
double loop = total + 0.6;
double t = (double)GetTickCount() * 0.001;
long n = (long)(t / loop);
double ph = t - (double)n * loop;
if (ph >= total)
return False; // locked pause between loops
elapsed = ph;
if (sCycle)
{
static const double kR[] = { 3000.0, 6000.0, 9000.0, 14000.0, 20000.0 };
int nS = (int)(sizeof(kR) / sizeof(kR[0]));
rlMax = kR[(int)(n % nS)];
static long lastN = -1;
if (n != lastN) { lastN = n;
DEBUG_STREAM << "[scramble-cycle] loop " << n << " rollMax=" << rlMax
<< "px/s (hold " << hold << "s + recover " << recover << "s)\n"
<< std::flush; }
}
}
else
{
if (!scrambleActive)
return False;
elapsed = ((double)(GetTickCount() - scrambleStartMs)) * 0.001;
if (elapsed >= total)
return False; // LOCKED -- clean display
}
if (elapsed < 0.0) elapsed = 0.0;
double sScale, sScroll, sShear;
if (elapsed < hold)
{
// HOLD: bad sync held. Deep collapse; roll starts wild and decelerates to a
// stop by the hold end. scroll = integral of v(t)=rlMax*(1-t/hold): the
// content scrolls fast then coasts to rest (rlMax*hold/2 accumulated).
sScale = collapse;
sScroll = rlMax * elapsed * (1.0 - elapsed / (2.0 * hold));
sShear = shearMax;
}
else
{
// RECOVERY: sync restored, monitor re-locks. Broaden line->full and settle
// the residual scroll + tear to zero (smoothstep), then it hits total = lock.
double tr = (elapsed - hold) / recover;
double b = tr * tr * (3.0 - 2.0 * tr);
sScale = collapse + (1.0 - collapse) * b;
sScroll = (rlMax * hold * 0.5) * (1.0 - b);
sShear = shearMax * (1.0 - b);
}
// SHAKE: a per-FRAME jitter (LCG, so all surfaces shake together within a frame
// but re-roll each frame), violent at the hit and fading through the recovery.
// Horizontal folds into the scroll; vertical bounces the source row (shakeRow).
double shakeEnv = (elapsed < hold)
? (1.0 - 0.5 * (elapsed / hold)) // 1.0 at impact -> 0.5 at hold end
: (0.5 * (1.0 - (elapsed - hold) / recover)); // 0.5 -> 0 through recovery
if (shakeEnv < 0.0) shakeEnv = 0.0;
unsigned int fr = (unsigned int)(GetTickCount() / 16); // ~per-frame index
unsigned int r = fr * 1103515245u + 12345u;
double jX = ((double)((r >> 16) & 0x7FFF) / 16384.0) - 1.0; // [-1,1)
r = r * 1103515245u + 12345u;
double jY = ((double)((r >> 16) & 0x7FFF) / 16384.0) - 1.0;
*scale = sScale;
*shear = sShear;
*rollOff = sScroll + shakeAmp * shakeEnv * jX; // horizontal shake -> scroll
*centerX = width / 2;
*shakeRow = (int)(shakeAmp * shakeEnv * jY + (jY >= 0.0 ? 0.5 : -0.5)); // vertical bounce
return True;
}
//########################################################################
+21 -7
View File
@@ -332,7 +332,27 @@ public:
UnflashPalette(int palette_number);
void
FunkyVideo(Logical on_off);
FunkyVideo(Logical on_off, Scalar duration = 0.0f);
// PPC sync-scramble (phase-14): the modern stand-in for the VGA CRTC
// Horizontal-Total detune, animated as a RECOVERY over the effect window -- at
// the hit the image collapses horizontally toward a line, then broadens back
// out as the tear + roll decay, and LOCKS to the full clean display at the end.
// FunkyVideo(on, dur) records the start + duration; ScrambleParams derives the
// per-frame envelope (returns True while active + fills scale/shear/rollOff/
// centerX); DrawDevSurface (surround) + ExpandPlaneToBGRA (glass) map the
// pixelBuffer read through it, so all secondary surfaces recover together and
// the main 3D view is untouched. Tunable by eye: BT_SCRAMBLE_SHEAR (max
// px/line), BT_SCRAMBLE_ROLL (max px/sec), BT_SCRAMBLE_COLLAPSE (min width
// fraction 0..1), BT_SCRAMBLE_DUR (anim seconds override). BT_SCRAMBLE_TEST
// loops the transition for tuning; BT_SCRAMBLE_CYCLE loops it stepping the
// max shear. (Exact look depended on each pod monitor's PLL -- not recoverable.)
Logical
ScrambleParams(int width, double *scale, double *shear,
double *rollOff, int *centerX, int *shakeRow) const;
Logical scrambleActive;
unsigned long scrambleStartMs;
double scrambleDurationS;
protected:
@@ -419,12 +439,6 @@ public:
// dwords; the image is written TOP-DOWN. *outW/*outH receive the produced size.
void ExpandPlaneToBGRA(int mask, int paletteID, int monoTint, int rotateQuadrant,
unsigned long *dst, int *outW, int *outH);
// GLASS dirty-skip (L4GLASSWIN, 2026-08-09): FNV-1a checksum of the shared
// gauge pixelBuffer, masked to the bits a given window can show. Lets the
// glass repaint pump re-blit ONLY the windows whose plane actually changed
// (idle MFDs / static panels skip; the sweeping radar keeps updating).
unsigned long PlaneChecksum(int mask) const;
};
//########################################################################
-115
View File
@@ -8441,107 +8441,6 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
lastFrameTime = mTargetRenderTime;
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
// (GPU-queue block). Logged on slow frames + 1 Hz stats.
LARGE_INTEGER _rt0; QueryPerformanceCounter(&_rt0);
@@ -9135,20 +9034,6 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
DEBUG_STREAM << "[rstat] frames=" << sFrames << " avg=" << (sAcc / sFrames)
<< "ms maxDraw=" << sMaxD << " maxPresent=" << sMaxP
<< " 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;
}
}
-6
View File
@@ -31,12 +31,6 @@ void
void
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-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)
}
//
// 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
// 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);
char lastrun_name[MAX_PATH];
wsprintfA(lastrun_name, "lastrun_%s.txt", logStem);
lstrcpynA(gBTLastrunName, lastrun_name, MAX_PATH);
std::ofstream breadcrumb(lastrun_name, std::ios::out | std::ios::app);
if (breadcrumb.is_open())
{
@@ -1240,17 +1209,6 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
BTFeLaunchSpec fe_spec;
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
}
char fe_arguments[192];
+2 -71
View File
@@ -94,7 +94,6 @@ static MarshalState marshalState;
//
#ifdef BT_STEAM
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 int BTSteamNet_Owns(SOCKET wire_socket);
extern int BTSteamNet_Send(SOCKET wire_socket, const char *data, int length);
@@ -154,48 +153,6 @@ static unsigned long
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
BTFE_RelaunchSelfAndExit(const char *arguments)
{
@@ -217,9 +174,7 @@ void
{
MarshalLog("window closed by the user -- exiting for real "
"(no relaunch)");
extern void BTLastrunNote(const char *);
BTLastrunNote("clean exit: player closed the game window");
BTOrderlyDie(); // #163: never bare ExitProcess
ExitProcess(0);
}
}
@@ -294,18 +249,6 @@ void
}
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;
PROCESS_INFORMATION process;
memset(&startup, 0, sizeof(startup));
@@ -314,22 +257,10 @@ void
if (CreateProcessW(exe_path, command_line, NULL, NULL, FALSE,
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.hProcess);
}
// #163 step (b): vanish, then die without touching DLL detach.
EnumWindows(HideProcessWindow, (LPARAM)GetCurrentProcessId());
TerminateProcess(GetCurrentProcess(), 0);
ExitProcess(0);
}
//###########################################################################
+1 -18
View File
@@ -412,7 +412,7 @@ static const CatalogEntry kVehicles[] =
{ "avatar", "Avatar" },
{ "ava1", "Satyr" },
{ "sunder", "Sunder" },
{ "snd1", "Denkou" }, // #69: the 4.x-era name (two era witnesses)
{ "snd1", "Sunder V1" },
{ "vulture", "Vulture" },
{ "vul1", "Mad Dog" },
{ "lok2", "Mischief" },
@@ -1200,7 +1200,6 @@ int
{
BTLobbyRoster roster;
if (BTLobby_HostAndRoom(self.name, self.vehicle, self.color, self.experience,
self.badge, self.patch,
&roster, spec->steamMyToken, sizeof(spec->steamMyToken),
spec->steamMap, sizeof(spec->steamMap)) != 0 ||
roster.memberCount == 0)
@@ -1208,15 +1207,6 @@ int
return 1; // cancelled / Steam unavailable
}
spec->mode = BTFeLaunchHostSteam;
// #162: capture the host's ADV. DAMAGE menu choice BEFORE the roster
// loop -- `self` aliases mission.pilots[0], which the loop overwrites
// with the first lobby member. advancedDamage is a MISSION-level
// setting (every stock 1995 egg stamps the same value on every pilot
// page); leaving it unset here shipped advancedDamage=0 for EVERY
// pilot of EVERY steam match, which silently disabled the -500 death
// cost and the death splash all night (night-15 [exp] receipts:
// advDamage=0 on 24 of 26 group-mission spawns).
const int host_adv_damage = self.advancedDamage;
mission.pilotCount = 0;
sprintf(spec->podList, "127.0.0.1:%d", console_port);
for (int m = 0; m < roster.memberCount && m < 8; ++m)
@@ -1236,12 +1226,6 @@ int
// but now only for clients that never published a choice.
strncpy(pilot.experience, member.experience,
sizeof(pilot.experience) - 1);
// #38: badge + patch ride the lobby the same way (absent = the
// writer's VGL/Red fallback, which is exactly what every non-host
// player got all night while these fields were never carried).
strncpy(pilot.badge, member.badge, sizeof(pilot.badge) - 1);
strncpy(pilot.patch, member.patch, sizeof(pilot.patch) - 1);
pilot.advancedDamage = host_adv_damage; // #162: mission-level
sprintf(pilot.address, "%s:%d",
member.fakeAddress, member.gamePort);
if (!member.isSelf)
@@ -1261,7 +1245,6 @@ int
if (menu.steamAction == 2)
{
if (BTLobby_JoinAndWait(self.name, self.vehicle, self.color, self.experience,
self.badge, self.patch,
spec->steamMyToken, sizeof(spec->steamMyToken),
spec->steamMap, sizeof(spec->steamMap)) != 0)
{
+9 -34
View File
@@ -129,7 +129,7 @@ static CSteamID currentLobby;
static void
PublishSelf(const char *pilot_name, const char *vehicle, const char *color,
const char *experience, const char *badge, const char *patch)
const char *experience)
{
//
// 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, "cl", color);
matchmaking->SetLobbyMemberData(currentLobby, "xp", experience);
// #38: badge + patch complete the paint identity (the egg's fourth and
// fifth pilot fields) -- without them every joiner rendered VGL/Red.
matchmaking->SetLobbyMemberData(currentLobby, "bd", badge);
matchmaking->SetLobbyMemberData(currentLobby, "pt", patch);
// BUILD GATE, member half: publish our exact build so the HOST can
// reject mismatches at GO time. This is what catches OLD exes -- they
// predate the joiner-side lobby filter, but they can't fake a "bv" key
@@ -182,16 +178,6 @@ static int
if (xp != NULL)
strncpy(out->experience, xp, sizeof(out->experience) - 1);
}
{
// #38: badge + patch, same tolerance -- an older client that never
// published them keeps the old VGL/Red fallback, nobody else does.
const char *bd = matchmaking->GetLobbyMemberData(lobby, member, "bd");
if (bd != NULL)
strncpy(out->badge, bd, sizeof(out->badge) - 1);
const char *pt = matchmaking->GetLobbyMemberData(lobby, member, "pt");
if (pt != NULL)
strncpy(out->patch, pt, sizeof(out->patch) - 1);
}
out->isSelf = (member == SteamUser()->GetSteamID());
if (out->name[0] == 0)
{
@@ -207,15 +193,8 @@ static int
static int roomIsHost = 0;
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
// window is sized by CLIENT rect (AdjustWindowRect at creation) -- the old
// 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 RECT roomLaunchRect = { 140, 250, 420, 296 };
static RECT roomLeaveRect = { 140, 306, 300, 336 };
static void
PaintRoom(HWND window)
@@ -267,9 +246,7 @@ static void
member.vehicle[0] ? member.vehicle : "mech",
member.color[0] ? member.color : "-",
member.isSelf ? L" (you)" : L"",
// #169: keep the tag short -- the long "-- WILL NOT LAUNCH"
// tail clipped at the 536px row edge on long name+vehicle rows.
build_ok ? L"" : L" [WRONG BUILD]");
build_ok ? L"" : L" [WRONG BUILD -- WILL NOT LAUNCH]");
}
else
{
@@ -371,12 +348,10 @@ static int
window_class.lpszClassName = L"BTLobbyRoomWnd";
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(
L"BTLobbyRoomWnd", L"BattleTech - Steam Lobby",
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);
if (window == NULL)
{
@@ -404,7 +379,7 @@ static int
int
BTLobby_HostAndRoom(
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,
char *my_token_out, int my_token_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
// no entry.
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);
if (RunRoom(1) != 0)
@@ -521,7 +496,7 @@ int
int
BTLobby_JoinAndWait(
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 *steam_map_out, int steam_map_capacity)
{
@@ -611,7 +586,7 @@ int
return -1;
}
}
PublishSelf(pilot_name, vehicle, color, experience, badge, patch);
PublishSelf(pilot_name, vehicle, color, experience);
LobbyLog("join: in lobby, waiting for GO");
int result = RunRoom(0);
+2 -5
View File
@@ -34,9 +34,6 @@ struct BTLobbyMember
// without it on the wire, the host's
// egg authored every JOINER as the
// WriteEgg "veteran" fallback
char badge[16]; // #38: same class as experience --
char patch[16]; // absent on the wire, every joiner
// spawned VGL-badged with a Red patch
int isSelf;
unsigned long long steamID;
};
@@ -54,7 +51,7 @@ struct BTLobbyRoster
int
BTLobby_HostAndRoom(
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,
char *my_token_out, int my_token_capacity,
char *steam_map_out, int steam_map_capacity);
@@ -66,6 +63,6 @@ int
int
BTLobby_JoinAndWait(
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 *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,
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).
Performance variants (CORRECTED 2026-08-11, was swapped): ctor @4be1bc picks by instance —
(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).
Performance variants PTR_LAB_005129e8(authoritative)/005129f4(ghost); base MoveAndCollide body past window — live integrator is Missile override @4bef78.
└─ 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
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}.
enum {SeekerSubsystem=0, MissileThrusterSubsystem=1}. members (best-effort):
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
// DAT_0051d070[0], the first per-placement lamp: the two tables abut.
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
// 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 };
static const int kBTPlacementLamp[5] = { 0x29, 0x1A,0x1B,0x1C,0x1D }; // [auxScreenPlacement]
//
// @004cc148 -- the fixed (<0x80) map: coolingLoop1..6 -> the heat-display bank.
@@ -341,15 +335,10 @@ void
{
if (the_condition == 2) // CoolantLeaking
{
// #135 FIX: the binary's generator special @004cc27c indexes the
// 0x51d070 table by generatorNumber (sub+0x1E0, A=1..D=4) -- NOT by
// any aux-screen placement (generators never fill those fields; the
// old probe left placement=-1 and a leaking generator annunciated
// 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];
int screen = 0, placement = -1;
BTGetSubsystemAuxScreen(the_subsystem, &screen, &placement, 0);
if (placement >= 0 && placement < 5)
lamp_id = kBTPlacementLamp[placement];
}
}
else if (lamp_code < 0x80)
+1 -18
View File
@@ -1744,27 +1744,10 @@ Logical
L4Warehouse *warehouse = (L4Warehouse *)gauge_renderer->warehousePointer;
if (warehouse->pixelMap8Bin.Get(p[2].data.string) == NULL) // FUN_00442d2b
{
// WAS DebugStream -- the no-op ReconStream (gotcha: use DEBUG_STREAM).
// 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;
DebugStream << "OneOfSeveralPixInt: Missing image '" << p[2].data.string << "'\n";
return False;
}
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;
}
-37
View File
@@ -2622,14 +2622,6 @@ void
{
// the range caret translate, from the live target range
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 > maxRange) range = maxRange;
Scalar frac = (range - minRange) / (maxRange - minRange);
@@ -3687,35 +3679,6 @@ namespace {
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
// inert now (the warp is the self-contained one-shot below); the ctor/dtor just
// 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
// 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
// mech+0x354 -- the score formula's per-target damage-bias factor.
//
// ⚠ 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_DAMAGE_BIAS(m) (0.0f) // bring-up: factor = 0*bias+1 = 1
#define MECH_OWNING_PLAYER(m) ((BTPlayer *)((Mech *)(m))->GetPlayerLink()) // ENTITY.h:430 (NULL for the dummy)
//#############################################################################
//############################### BTPlayer ##############################
//#############################################################################
Scalar BTScoreWatermarkOf(int owner);
void BTScoreWatermarkSet(int owner, Scalar sent);
//#############################################################################
// Message Support
//
@@ -709,20 +684,6 @@ void
// scoreboard (they read +0x278), so the pod's penalty may never have been
// 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)
{
BTPlayer::ScoreMessage death_cost(
@@ -736,208 +697,40 @@ void
}
//
// #162 CORRECTION (2026-08-11): the #81 clear that used to sit HERE was a
// MISATTRIBUTION. FUN_004c012c -- whose tail is `Post(...); *(+0x290)=0;
// *(+0x258)=0` (part_013.c:10519-10523) -- is the DROP-ZONE REPLY handler,
// not this arm. Clearing the latch at the end of THIS arm disarmed the
// port's own dedup within the same invocation, so the duplicate
// VehicleDeads from the replicant-side death tails (the #162 multi-count:
// deaths=1,2 one ms apart) sailed straight through the :546 gate. The
// latch now clears where the binary clears it: the DropZoneReply paths
// (fresh spawn :1799, abandoned cycle :1810, already-alive probe :1829,
// respawn complete :1900) -- which also keep the #81 ghost fix intact:
// every terminal outcome of a cycle releases it. suppressConsole rides
// the same binary tail, so its clear moves to DropZoneReply with it
// (post-eject corpse hits keep their console posts suppressed until the
// respawn -- authentic). The binary itself never SETS +0x290 [T1 image
// byte-scan: three writes, all zero-stores] -- its guarantee is the
// sender's one-shot edge, restored in mech.cpp's death tail the same day;
// our set at :559 stays as a defensive tripwire (a duplicate that still
// arrives is SWALLOWED and logged loudly instead of counted).
// RELEASE THE DEATH LATCH (#81, the GHOST MECH fix -- 2026-07-30).
//
// 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
//
@@ -1072,34 +865,16 @@ void
{
case BTPlayer::ScoreMessage::DamageInflictedScore: // 0
//
// Inflicted-damage messages belong to ScoreInflictedMessageHandler, and
// the binary's Dispatch override (@004bffa0) guarantees they never reach
// here -- which is why the original arm is a bare Verify.
// Inflicted-damage messages belong to ScoreInflictedMessageHandler.
//
// PORT DIVERGENCE (2026-08-07), and it is a DELIVERY-PATH difference,
// not a scoring one. 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 Entity::Dispatch reroutes it to the owning host so
// the credit lands on the shooter's OWN machine (the same reroute that
// carries kill credit, ENTITY.cpp:244-251). But a message arriving over
// the WIRE is delivered through Receive(), straight to this handler
// table: the virtual Dispatch override is never called on the receiving
// 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;
Verify(
False,
"BTPlayer::ScoreMessageHandler should not be "
"given DamageInflictedScoreMessages!", // @0051324a
"d:\\tesla_bt\\bt\\btplayer.cpp", // @0051329a
0x296
);
break;
case BTPlayer::ScoreMessage::DamageReceivedScore: // 1
{
@@ -1368,38 +1143,20 @@ void
//
// Only bother if our score actually changed since last time.
//
// DELTA vs RUNNING TOTAL (2026-08-07). The binary sends currentScore
// and then ZEROES it, ungated (@FUN_..., `param_1[0x9e] = 0` right after
// the send) -- so in 1995 +0x278 was a CONSOLE DELTA and the running
// total lived on the operator console, which is also where the manual's
// "+1000 starting the game" would have been seeded.
//
// Our port cannot copy that literally: THREE port-side consumers read
// +0x278 as a running total -- GetScore() (the SCORE gauge),
// Player::CalcRanking(), and Player__UpdateRecord (the only score field
// we replicate to peers). Zeroing it made all three reset every
// CONSOLE_UPDATE_INTERVAL, which is what players saw as "scoring went
// screwy" (bench: totals climbed to ~35 and dropped back). It was
// mostly invisible until the type-0 interceptor was restored, because
// before that currentScore barely moved.
//
// So: keep the WIRE authentic (the console still receives a DELTA) and
// keep the OBJECT sane (currentScore stays a true running total). The
// last-sent watermark is a file static keyed by player -- a new data
// member would change sizeof(BTPlayer) and break the offset locks (same
// reason the console timer above is a static).
//
const Scalar sent_already = BTScoreWatermarkOf(ownerID);
const Scalar delta = (Scalar)currentScore - sent_already;
if (delta != 0.0f)
if ((Scalar)currentScore != 0.0f) // this[0x9e] != _DAT_004c0900 (0.0f)
{
int score = (int)delta;
int score = (int)currentScore;
ConsolePlayerVTVScoreUpdateMessage score_message(
ownerID,
score
); // FUN_00420ea4(0x20, 0x1a, 1, ...)
// gauge scoring wave: the binary's currentScore is a console DELTA that is
// flushed to the operator console then zeroed. Our SCORE/RANK gauges read
// currentScore as the RUNNING total, so only flush+zero when a console host
// is actually present (MP / pod); in solo there is no console -> keep the
// running score so the SCORE gauge + CalcRanking don't reset every 10s.
Host *console_host =
application->GetHostManager()->GetConsoleHost(); // FUN_00429078
if (console_host)
@@ -1418,10 +1175,7 @@ void
NetworkClient::ConsoleClientID, // 5
&score_message
);
// The binary does `currentScore = 0` here. We advance the
// watermark by the amount actually SENT instead, so the console
// sees the same deltas while the object keeps the total.
BTScoreWatermarkSet(ownerID, sent_already + (Scalar)score);
currentScore = 0; // this[0x9e] = 0
}
}
}
@@ -1786,8 +1540,6 @@ void
deathPending = 0; // #55 step 1: a latch carried into a
// fresh spawn would permanently kill
// every later respawn (the #57 class)
suppressConsole = 0; // this+0x258 (binary: FUN_004c012c
// tail pairs both clears -- #162)
// (warp fired in the shared placement below -- initial drop-in + respawn)
}
else if (deathCount == message->deathCount) // param_2[0xe] == param_1[0x80]
@@ -1887,11 +1639,6 @@ void
// the same process. The cycle's completion is HERE, so clear it here.
//
deathPending = 0; // this+0x290
suppressConsole = 0; // this+0x258 -- the binary pairs
// both clears in FUN_004c012c's
// tail (part_013.c:10519-10523);
// moved here from the VehicleDead
// arm with the #162 correction
DEBUG_STREAM << "[respawn] player " << BTMatchHostOf(GetEntityID())
<< ":" << (int)GetEntityID() << " RESET at drop zone (death #"
<< deathCount << ") -- death cycle complete, latch cleared\n"
@@ -2031,51 +1778,15 @@ BTPlayer::BTPlayer(
}
//
// Look the scoring role up in the role registry (keyed by the role name in
// the creation message, +0x90) and stash it as our scenarioRole.
//
// WIRED 2026-08-07. This was commented out with "the BT role registry
// (BTMission::GetRoleRegistry()->Lookup) has no WinTesla analog, so the
// 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.
// Look the scoring role up in the role registry (keyed by the role name
// in the creation message, +0x90) and stash it as our scenarioRole. The
// BT role registry (BTMission::GetRoleRegistry()->Lookup) has no WinTesla
// analog, so the scenarioRole set by the base Player ctor stands.
// CROSS-FAMILY: needs BTMission role-registry access. BEST-EFFORT.
//
CString role_key(creation_message->roleName); // make+0x90
if (playerMission != 0)
{
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;
}
(void)role_key;
// scenarioRole = playerMission->GetRoleRegistry()->Lookup(&role_key); // this[0x7e]+0x50, this[0x82]
if ((simulationFlags & 0xc) == 4)
{
@@ -2640,14 +2351,9 @@ void BTMechPostCombatReports(
else if (damage_tally != 0.0f && shooter_player != 0)
{
//
// Block B: the inflicted report -- "+1 each damage point scored on
// opponent's armor" (original manual scoring chart). Goes out under
// Player::ScoreMessageID with type 0, exactly as the binary does; the
// 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.
// Block B: the plain inflicted report. Wire fidelity only -- the 0x16
// handler Verify-rejects type 0 and banks award 0 (1995 banked an
// uninitialized stack float; see the handler's type-0 arm note).
//
BTPlayer::ScoreMessage inflicted(
Player::ScoreMessageID,
+8 -41
View File
@@ -84,24 +84,16 @@ class DropZone__ReplyMessage;
public:
//
// Kind of scoring event. Recovered from the branch selector at
// @004c02e4 (this->scoreType, message+0x20).
//
// CORRECTED 2026-08-07. This note used to read "type 0 has NO scoring
// arm in the binary ... @004c0200 appears in no handler-table entry ...
// per-hit inflicted credit never existed", and build 787 deleted the
// credit on that basis. The byte-scan was right that no TABLE entry
// binds @004c0200 and wrong to conclude it is unreachable: BTPlayer
// 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".
// @004c02e4 (this->scoreType, message+0x20). NOTE: type 0 has NO
// scoring arm in the binary -- @004c02e4 Verify-rejects it (line 662)
// and @004c0200, the only function that accepts it, appears in no
// handler-table entry (byte-scan 2026-08-05: the BTPlayer table at
// file 0x112dxx has exactly 6 entries, none binding it). 1995 pod
// scoring = kills + received-damage penalties; per-hit inflicted
// credit never existed.
//
enum ScoreType {
DamageInflictedScore = 0, // per-hit inflicted credit (LIVE, see above)
DamageInflictedScore = 0, // sent, but scores nothing (see above)
DamageReceivedScore = 1, // I took damage
KillScore = 2 // I destroyed / was destroyed
};
@@ -296,23 +288,6 @@ class DropZone__ReplyMessage;
private:
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:
static MessageHandlerSet& GetMessageHandlers();
@@ -327,14 +302,6 @@ class DropZone__ReplyMessage;
void
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
// 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
// reconstruction in btl4vid.cpp (task #52) -- no longer a stub here.
// BTPOVStartEndRenderable (the mission start/end fade) is now a real
// reconstruction: the arming ctor lives in btl4vid.cpp and the 5-phase fog
// machine at the renderer frame head (L4VIDEO.cpp) -- #156.
BTPOVStartEndRenderable::BTPOVStartEndRenderable(
Entity *entity, int, dpl_VIEW *, dpl_ZONE *, dpl_ZONE *, StateIndicator *,
float, float, float, float, float, int, int)
: BTRenderableBase(entity) {}
BTTracerEffectRenderable::BTTracerEffectRenderable(
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
// sim (heatPortion -> pendingHeat -> flows to the central sink -> mech temp climbs).
static int s_fireLog = 0;
// #165 bench: EVERY discharge, NAMED, under BT_DMG_LOG (the old unnamed
// 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)
if ((s_fireLog++ % 19) == 0) // 19 prime: rotates across the 5 emitters (20 aliased to one)
DEBUG_STREAM << "[emitter] FIRED #" << s_fireLog << " damage=" << damagePortion
<< " heat=" << heatPortion << " pendingHeat=" << pendingHeat << "\n" << std::flush;
+8 -141
View File
@@ -947,26 +947,6 @@ void
if (other != 0 && coolantAvailable != 0)
{
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;
pendingHeat -= flow;
BalanceCoolant(time_slice); // FUN_004ada94
@@ -1439,14 +1419,15 @@ int BTCondenserNumber(::Subsystem *sub)
//
// The #83-era BTHeatSinkFamily bridge that lived here is GONE (#128,
// 2026-08-11): it returned the whole HeatSink base family for the
// collision distributor's 0x50e590 test, but 0x50e590 is the AGGREGATE
// 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.
// #83 bridge: the collision-damage distributor (mech.cpp, FUN_0049ffcc) tests
// roster members against this family's derivation chain (binary GUID 0x50e590).
// Lives here because HeatSink is a complete type only in this TU.
//
Derivation *
BTHeatSinkFamily()
{
return HeatSink::GetClassDerivations();
}
//===========================================================================//
// BTHeatSinkBankCoolantFraction -- complete-type bridge for the EJECT-
@@ -1465,117 +1446,3 @@ int BTHeatSinkBankCoolantFraction(Subsystem *sub, Scalar *out)
*out = bank->CoolantFractionOf();
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
// HudSimulation body. The VALUES BELOW ARE THE BINARY'S, read straight off
// 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.
// Tuning constants observed as read-only float globals adjacent to the
// HudSimulation body (.rdata, recovered from section_dump.txt).
//
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
// fire-control lock is lost -- the `_DAT_004b7ec4 <= ownZone->damageLevel`
// arm of HudSimulation. A shot-up cockpit drops to "target held, no lock".
static const Scalar LockOwnZoneDamageLimit = 0.75f; // _DAT_004b7ec4
// 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 SegmentTempLimit = 0.0f; // _DAT_004b7ec4 (heat threshold for HUD page visibility)
static const Scalar TargetTempLimit = 0.0f; // _DAT_004b7ec8
static const Scalar RangeBias = 0.0f; // _DAT_004b7ecc
static const Scalar MaxTorsoSlew = 500.0f; // _DAT_004b7ed0
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
// target; the compass Scalar @0x214 = yaw euler[0] + torso twist (:5676).
// 6. Torso-horizon slew: horizontalTorsoOffset (@0x294) is moved toward the
// commanded torso heading, clamped to +/- horizontalLimit (@0x29C), then
// written to the graphic at mech +0x36C. The flicker helper (@004b7ed4)
// damps the settle: it decays @0x294 toward ZERO at the object's own
// @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.)
// commanded torso heading at up to MaxTorsoSlew (500/sec), clamped to
// +/- horizontalLimit (@0x29C), then written to the graphic at
// mech +0x36C. The flicker helper (@004b7ed4) damps the settle.
//
void
HUD::HudSimulation(Scalar time_slice)
@@ -505,20 +475,3 @@ void
{
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
//
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
(HUD::*Performance)(Scalar time_slice);
+37 -144
View File
@@ -82,6 +82,7 @@
// The torso twist is reached via a BRIDGE (BTGetTorsoTwist, defined in torso.cpp)
// for the same reason.
#include "dmgtable.hpp"
#include <GAUGREND.hpp> // GaugeRenderer::SpecialEffect (virtual) -- PPC scramble trigger
extern Scalar BTGetTorsoTwist(Subsystem *torso); // torso.cpp (Torso complete there)
// heat-bank ambient bridge (heatfamily_reslice.cpp, AggregateHeatSink complete
// there) -- mech.cpp cannot include the subsystem headers (local-stub collision)
@@ -510,25 +511,8 @@ void
{
return;
}
// #142: duckState is the POSTURE the cockpit's crouch-symbol animation
// reads -- L4GAUGE.CFG:5001 binds attribute 0x37 to a THREE-frame
// 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;
duckState = 1;
DEBUG_STREAM << "[duck] DuckRequest: duckState -> 1" << std::endl << std::flush;
}
//
@@ -1001,18 +985,7 @@ void
<< " pts in " << n << " sub-hits (scale=" << scale << ")"
<< std::endl << std::flush;
// #128 CORRECTION (2026-08-11): 0x50e590 is the AGGREGATE BANK's node
// (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 *BTHeatSinkFamily(void); // heat.cpp (0x50e590)
extern Derivation *BTGyroscopeFamily(void); // gyro.cpp (0x50fdc0)
extern Derivation *BTTorsoFamily(void); // torso.cpp (0x510b08)
for (int i = 0; i < n; ++i) // @0x4a006b
@@ -1024,7 +997,7 @@ void
Subsystem *s = GetSubsystem(b); // roster @+0x128
if (s == 0)
continue;
if (!s->IsDerivedFrom("HeatSinkBank") // @0x4a0092 (0x50e590, the BANK)
if (!s->IsDerivedFrom(*BTHeatSinkFamily()) // @0x4a0092
&& !s->IsDerivedFrom(*BTGyroscopeFamily()) // @0x4a00b2
&& !s->IsDerivedFrom(*BTTorsoFamily())) // @0x4a00d2
continue;
@@ -1043,30 +1016,6 @@ void
{
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
// for a cross-pod (network-delivered) TakeDamage + what state it carries.
if (getenv("BT_MP_NET"))
@@ -1122,31 +1071,7 @@ void
// Capturing it after the divert (where #89 first placed it) meant a
// COLLISION death could never arm the tail.
//
// WAS-ALIVE-AT-ENTRY, on the BINARY's predicate (movementMode), not the
// 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)
const int deathBlastArmed = !IsMechDestroyed(); // [ebp-0x10], inverted
//
// The zone the reports + VehicleDead carry: msg+0x24 as of loop entry.
// The binary never rewrites msg+0x24 after the initial cylinder resolve;
@@ -1198,6 +1123,31 @@ void
}
}
reportZone = message->damageZone; // post-resolve (@0x4a0396 write)
//
// @0x4a03f3 [T1] -- PPC/ERPPC SECONDARY-DISPLAY SCRAMBLE. EnergyDamageType
// (==4) is authored on exactly the 14 PPC/ERPPC subsystem records and nothing
// else, so this branch is structurally PPC-exclusive -- NO explicit weapon
// class check (the data authorship IS the gate). It sits OUTSIDE the burst
// loop below, so it fires ONCE per damage message, not per burst. The
// duration is DERIVED from the type ordinal, not a literal: (Scalar)4 * 0.2 ==
// 0.8s (binary loads long double 0.2 @0x4a0c08, fmulp). SpecialEffect ->
// L4GaugeRenderer scrambles every SECONDARY cockpit display (SVGA16 sync
// detune, FunkyVideo) for that window; the main 3D view is on an independent
// timing chain and is left clean. Spec: phases/phase-14-ppc-sync-distortion.md.
//
if (message->damageData.damageType == Damage::EnergyDamageType)
{
GaugeRenderer *gauges =
(application != 0) ? application->GetGaugeRenderer() : 0; // APP.h:355 -- named, not application+0x4c
if (gauges != 0) // the binary null-guards the renderer too (@0x4a0405)
gauges->SpecialEffect(GaugeRenderer::scrambleVideo,
(Scalar)message->damageData.damageType * 0.2f);
if (BTEnvOn("BT_DMG_LOG", 0))
DEBUG_STREAM << "[ppc-scramble] EnergyDamageType hit -> scrambleVideo for "
<< ((Scalar)message->damageData.damageType * 0.2f) << "s\n" << std::flush;
}
//
// #80 -- the faithful application loop (binary @0x4a0423-0x4a04d8), which
// REPLACES the engine-base single application. Three things the base
@@ -1277,31 +1227,11 @@ void
// vital-wreck flag, and the loop-ENTRY zone. This retires
// 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);
BTMechPostCombatReports((void *)this, (void *)message,
(float)damageTally, zoneDestroyed, reportZone,
!destroyedAtEntry && IsMechDestroyed());
deathBlastArmed && IsMechDestroyed());
}
}
@@ -1357,54 +1287,18 @@ void
// falloff bursts/dist^1.25 per victim inside the shared core.
// 4. ForceUpdate(1).
// The gate predicate here is IsMechDestroyed() (graphicAlarm >= 9, the
// structural flag @0x49fb54) -- the binary tests movementMode 9|10, and
// sets mode 9 SYNCHRONOUSLY inside this same damage pass (its own tail
// gate part_012.c:14761 reads mode 9||10 already flipped). An earlier
// comment here claimed our transition was synchronous too -- it was NOT
// (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.
// structural flag @0x49fb54) -- the binary tests movementMode 9|10, but
// the death transition sets mode 9 synchronously with the structural flag
// on every path through here, so the edges coincide; this exact predicate
// is the one the #89 blast benches verified both ways.
death_tail:
// #162 (2026-08-11) -- MASTER ONLY: the discipline #150 restored for the
// score reports, now applied to the death tail it forgot. In 1995 this
// ENTIRE handler was replicant-asserted (part_012.c:14613), so the tail
// could only run on the victim's master: ONE VehicleDead per death -- and
// the receiver needs no dedup, because the binary's deathPending gate
// (+0x290) is never written non-zero anywhere in BTL4OPT.EXE [T1 image
// byte-scan]; the sender's one-shot edge IS the 1995 guarantee. Our port
// deliberately applies damage on replicant copies for visual response,
// and on those copies movementMode does not flip to 9 until the owner's
// type-6 death record round-trips -- so deathBlastArmed stayed armed
// there, and EVERY salvo of the killing volley re-fired this tail on
// EVERY peer: a duplicate VehicleDead per salvo per node, rerouted to the
// victim's master (night-15 receipts: 5 real deaths -> 8 PLAYER_DEAD,
// deaths=1,2 one ms apart, x3 same-millisecond; multiplicity tracks the
// killing-window ROUNDS, not shooters or peers), plus a duplicate death
// splash per peer. Peers' death visuals ride the type-6 record path
// (UpdateDeathState wreck/explosion), never this tail -- gating it here
// removes only the phantom emissions the binary never made.
if (deathBlastArmed && IsMechDestroyed()
&& GetInstance() != ReplicantInstance)
if (deathBlastArmed && IsMechDestroyed())
{
extern void BTMechPostVehicleDead(void *, void *, int);
BTMechPostVehicleDead((void *)this, (void *)message, reportZone);
extern void BTApplyDeathSplash(void *mech_v);
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 +1570,6 @@ Mech::Mech(
radarLinearPosition = &localOrigin.linearPosition; // map reads the mech's live world position...
radarAngularPosition= &localOrigin.angularPosition; // ...and orientation (pointers into the base origin)
duckState = 0; // not crouching
duckRequest = 0; // no pending duck request (#142)
// (AUDIO_FIDELITY F7) missile alarm: the binary reset writes 0 / FLT_MAX
// (part_012.c:9446-9447; FLT_MAX = "no missile" far default)
incomingLock = 0;
+1 -9
View File
@@ -964,15 +964,7 @@ protected:
Scalar radarRange; // 0x2f RadarRange (scale/max)
Point3D *radarLinearPosition; // 0x30 RadarLinearPosition
Quaternion *radarAngularPosition; // 0x31 RadarAngularPosition
int duckState; // 0x37 DuckState (crouch POSTURE the cockpit
// 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;
int duckState; // 0x37 DuckState (crouch posture)
// (AUDIO_FIDELITY F7) the incoming-missile alarm attributes. Binary
// Mech table [T1]: IncomingLock id 54 @0x3fc (Logical; authored match
// ==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)
{
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);
// the reconstruction split them, so SetBodyAnimation's `bodyStateAlarm.SetLevel(state)`
@@ -1301,35 +1297,7 @@ Scalar
}
SetBodyAnimation(0x10);
}
// FALLTHROUGH -- into the ADVANCE GROUP, which is where the binary
// 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)
// FALLTHROUGH
case 4: // TURN-IN-PLACE, LOCKSTEP twin (task #64)
// The body channel runs trn in LOCKSTEP with the leg: armed together at
@@ -1356,23 +1324,6 @@ Scalar
: (bm != 0) ? bm->speedDemand : 0.0f;
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);
ForceUpdate(8);
distance = 0.0f;
@@ -1396,7 +1347,6 @@ Scalar
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 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, ...)
time_slice * globalTimeScale * idleStrideScale, loop);
bodyCycleSpeed = distance / time_slice; // this+0x6b8
+38 -588
View File
@@ -666,7 +666,6 @@ static int gBTPPCKey = 0;
static int gBTMissileKey = 0;
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 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 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)
if (seg != 0)
{
// #141 -- THE BINARY GOES THROUGH FUN_00424da8, AND SO MUST WE.
// @004b9948 ends in `FUN_00424da8(owner, segment, out)`, which is
// 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);
AffineMatrix mw;
mw.Multiply(seg->GetSegmentToEntity(), m->localToWorld); // segment -> world (== mech4 gun-port path)
out = mw; // Point3D = matrix W_Axis translation
}
else
@@ -844,9 +825,8 @@ struct BTProjectile {
Point3D pos;
Vector3D vel; // world velocity (authored MuzzleVelocity, steered per frame)
Scalar speed; // |vel| held constant through the steer
Scalar age; // flight seconds since launch (#168)
Scalar ttl; // authentic lifetime cap (s): 5.0 plain shell (@004bddec),
// BurnTime+10.0 missile (@004bef78) -- see the launch site
Scalar traveled;
Scalar range;
Entity *target;
Point3D targetPos;
Scalar aimOffsetY; // vertical aim offset vs the target's origin (live re-lead)
@@ -1064,13 +1044,8 @@ int
{
if (seg->GetIndex() == seg_index)
{
// #141 sweep: go through the engine accessor, which is the
// binary's FUN_00424da8 (the joints->segments refresh). A hand
// 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);
AffineMatrix mw;
mw.Multiply(seg->GetSegmentToEntity(), m->localToWorld);
p = mw; // Point3D = matrix translation
break;
}
@@ -1474,8 +1449,6 @@ void
? (mz.y - ((Mech *)shooter)->localOrigin.linearPosition.y) : mz.y;
DEBUG_STREAM << "[projectile] PUSH target=" << (void*)target
<< " 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
<< " lv=" << (launch_velocity
? "(auth)" : "(fallback)") << "\n" << std::flush;
@@ -1520,57 +1493,8 @@ void
EntitySegment *seg = sm->GetSegment(muzzle_seg);
if (seg != 0)
{
// #141 -- USE THE ENGINE ACCESSOR, not a hand-rolled
// GetSegmentToEntity() x 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);
AffineMatrix mw;
mw.Multiply(seg->GetSegmentToEntity(), sm->localToWorld);
mw.GetFromAxis(X_Axis, &ax);
mw.GetFromAxis(Y_Axis, &ay);
mw.GetFromAxis(Z_Axis, &az);
@@ -1583,55 +1507,6 @@ void
sm->localToWorld.GetFromAxis(Y_Axis, &ay);
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.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;
@@ -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.age = 0.0f;
// AUTHENTIC lifetime (#168). The old cap (len*1.3+60, distance-to-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.traveled = 0.0f;
p.range = len * 1.3f + 60.0f; // arc margin; expire past the target
p.target = (Entity *)target;
p.targetPos = tpos; // resolved target position (fallback-aware)
// 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
// the decomp turn gain (MissileTurnGain = 4.0, _DAT_004bf5a4), speed
// held at the authored launch speed.
// LIVE TARGET TRACKING -- for EVERY round kind, not just guided (#168
// 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
// wherever it is NOW); testing it against the fire-time pick point let
// an unguided shell "contact" empty air after the target moved and
// 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.
if (p.guided)
{
// LIVE RE-LEAD (authentic: Seeker::LeadTarget runs every slice on
// the MOVING target): refresh the aim from the target's current
// position, preserving the launch aim's body height.
extern int BTIsRegisteredMech(Entity *e);
if (p.target != 0 && BTIsRegisteredMech(p.target)
&& !((Mech *)p.target)->IsMechDestroyed())
@@ -1752,8 +1614,7 @@ static void
p.targetPos = ((Mech *)p.target)->localOrigin.linearPosition;
p.targetPos.y += p.aimOffsetY;
}
if (p.guided)
{
Point3D aim = p.targetPos;
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);
@@ -1806,7 +1667,7 @@ static void
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.age += dt;
p.traveled += p.speed * dt;
// WORLD IMPACT (authentic: the binary missile runs a world collision
// query every frame, FUN_0042291c, and DETONATES on geometry). Ray the
@@ -1911,13 +1772,10 @@ static void
contactD2 = cx*cx + cy*cy + cz*cz;
}
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"))
DEBUG_STREAM << "[projectile] FIZZLE t=" << p.age
<< " ttl=" << p.ttl << " guided=" << p.guided
<< ((p.guided && p.pos.y < -1.0f) ? " KILLPLANE" : "")
<< "\n" << std::flush;
DEBUG_STREAM << "[projectile] FIZZLE (flight cap, no contact)\n" << std::flush;
p.active = 0;
continue;
}
@@ -2180,11 +2038,8 @@ void
{
if (seg->GetIndex() == segment_index)
{
// #141 sweep: engine accessor (== the binary's FUN_00424da8),
// not a hand-composed product -- otherwise a peer's damage
// effect anchors to the BIND-POSE segment.
LinearMatrix mw;
mech->GetSegmentToWorld(*seg, &mw);
AffineMatrix mw;
mw.Multiply(seg->GetSegmentToEntity(), mech->localToWorld);
fxPos = mw; // Point3D = matrix translation
break;
}
@@ -2291,39 +2146,6 @@ void
worldLinearVelocity = Vector3D(0.0f, 0.0f, 0.0f);
localVelocity = Motion::Identity;
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;
ramContactLinger = 0.0f;
// StopAllEntityEffects (@004d0c14): a respawned mech must not trail its
@@ -2350,26 +2172,6 @@ void
poseSyncLatch = 0; // @0x77c
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 ---
for (int z = 0; z < damageZoneCount; ++z)
{
@@ -2427,62 +2229,6 @@ void
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) ---
SetPreRunFlag();
if (interestCount == 0) interestCount = 1;
@@ -2651,14 +2397,6 @@ void
// (wreck shape: alarms/state settle, ammo
// 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
// ForceUpdate filter masks types 2..8 once IsDisabled) -- the binary
// death sender is Force(1) + Force(0x40) (@0x4aab2f/@0x4aab3a). The
@@ -2774,27 +2512,6 @@ volatile float gBTReplRenderYaw = -999.0f;
void
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).
Scalar dt = till - lastPerformance;
lastPerformance = till;
@@ -3594,23 +3311,9 @@ void
sEjAt = (e && *e) ? atoi(e) : -1;
}
++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
&& ((sEjFrame - sEjAt) % 300) == 0)
{
ejectPress = 1;
DEBUG_STREAM << "[ejecttest] FIRING punch-out at frame "
<< sEjFrame << "\n" << std::flush;
}
}
if (ejectPress)
{
@@ -4703,60 +4406,9 @@ void
// stability, so peers pose the squat for free) and flip the
// stability alarm (ducked = 0, risen = 1). The request is
// consumed whenever both gates passed, hit or miss.
// #142 RESTRUCTURE 2026-08-07 -- duckState is the POSTURE, not a
// 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)
if (duckState != 0 && squatCapable != 0)
{
duckRequest = 0; // one shot, whatever happens
if (!duckParked && mapPosture == 1)
if (mapPosture == 1)
{
SetLegAnimation(2); // 'sqd' -- squat down
ForceUpdate(8);
@@ -4765,7 +4417,7 @@ void
if (getenv("BT_DUCK_LOG") || getenv("BT_GAIT_LOG"))
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
ForceUpdate(8);
@@ -4774,76 +4426,14 @@ void
if (getenv("BT_DUCK_LOG") || getenv("BT_GAIT_LOG"))
DEBUG_STREAM << "[duck] RISE (posture 2 -> leg clip 3)\n" << std::flush;
}
else
{
// Gate refuses -- posture reads 0 for a MOVING mech, which is the
// authentic rule (Lynx: "when a mech STOPS, crouch button lowers
// its stance"; benched: crouch at a walk gives posture=0). Settle
// the strip back to the truth instead of holding the mid frame or
// queueing the request for the next time the pilot stops.
static int s_duckRefuse = 0;
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
}
else if (getenv("BT_DUCK_LOG"))
DEBUG_STREAM << "[duck] request consumed, posture=" << mapPosture
<< " (mode=" << MovementMode()
<< " legLvl=" << (int)legStateAlarm.GetLevel()
<< " simLive=" << 1 // re-read below costs a bridge call; posture already folded it
<< " myo=" << myomerEffectiveness
<< " squat=" << squatCapable << ")\n" << std::flush;
duckState = 0; // consumed (@0x4aa0a9)
}
// (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
// the boresight crosses near/far ground instead of teleporting.
// (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 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
Entity *des = MECH_TARGET_ENTITY(this);
if (des != 0 && des != hotTarget)
@@ -6572,26 +6148,6 @@ void
// 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;
if (maxStep < 0.0f) maxStep = -maxStep;
float step = trueRange - sShownRange;
@@ -6599,22 +6155,6 @@ void
if (step < -maxStep) step = -maxStep;
sShownRange += step;
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):
@@ -7126,39 +6666,6 @@ void
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).
// The whole RESPAWN family -- torso twist after respawn (#70), ammo/weapon
// reset (#22), coolant/heat/generator restore (#55), the respawn latch
@@ -8074,33 +7581,7 @@ void
continue;
if (i != 0) // slot 0 = the mapper (task #7)
++subsystemsPresent;
// #148 -- THE INSTANCE BRANCH. Entity::Perform (ENTITY.cpp:733-793,
// 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)
if (!subsystem->IsNonReplicantExecutable())
continue;
// The controls-mapping subsystem (roster slot 0 via Mech::SetMapping
@@ -8573,31 +8054,15 @@ void
fallScalar = -(fallDirection.x * impactVel.x
+ fallDirection.y * impactVel.y
+ fallDirection.z * impactVel.z);
// Log the crash pricing -- but SAMPLED for scrape taps (#128 field
// measurement: sustained scrapes re-price per frame, 25-82 ev/s,
// up to 30k lines/night = ~20% of a field log; every one of those
// 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)
{
// ALWAYS-log the crash pricing (rare event; the arbiter for the
// wall-damage economy -- run 14 field report: "tapped a wall and
// died instantly", so the forwarded amount is suspect vs binary).
DEBUG_STREAM << "[crashdmg] amount=" << dmg.damageAmount
<< " mass=" << moverMass
<< " |v|=" << (float)Sqrt(impactVel.x * impactVel.x
+ impactVel.y * impactVel.y + impactVel.z * impactVel.z)
<< " force=(" << dmg.damageForce.x << "," << dmg.damageForce.y
<< "," << dmg.damageForce.z << ")"
<< (noteworthy ? "" : " (scrape sample; taps suppressed)")
<< "\n" << std::flush;
}
}
<< "," << dmg.damageForce.z << ")\n" << std::flush;
Entity::TakeDamageMessage td(
Entity::TakeDamageMessageID,
sizeof(Entity::TakeDamageMessage),
@@ -9096,22 +8561,7 @@ void
}
if (s_portCache[energyOrdinal] != 0)
{
// #141 sweep: engine accessor (== the binary's FUN_00424da8).
// 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
AffineMatrix mw;
mw.Multiply(s_portCache[energyOrdinal]->GetSegmentToEntity(),
localToWorld);
mz = mw; // Point3D = matrix translation
+1 -50
View File
@@ -1255,50 +1255,26 @@ void
+ fabsf((float)owner->bodyCycleSpeed);
const int movingNoLegs = (step > 0.08f && step < 5.0f // 5+: teleport/warp
&& 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 (++skateFrames > 90 && !skateLogged)
{
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()
<< " SKATING: " << skateFrames << " frames moving ("
<< step << " u/frame) with legCyc="
<< (float)owner->legCycleSpeed
<< " bodyCyc=" << (float)owner->bodyCycleSpeed
<< " bodyTargetSpeed=" << (float)owner->bodyTargetSpeed
<< " bstate=" << (int)owner->bodyAnimationState
<< " destroyed=" << (int)owner->IsMechDestroyed()
<< " mode=" << (int)owner->MovementMode()
<< " at (" << px << "," << pz << ")\n" << std::flush;
if (BTMatchLogActive())
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(),
skateFrames, step, cyc,
(float)owner->bodyTargetSpeed,
(int)owner->bodyAnimationState,
(int)owner->IsMechDestroyed(), (int)owner->MovementMode(),
px, pz);
}
@@ -1358,31 +1334,6 @@ void
const bool nowDead = (mode == 2 || mode == 9);
const bool prevDead = (oldMode == 2 || oldMode == 9);
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
&& owner->GetInstance() == Entity::ReplicantInstance)
{
+27 -128
View File
@@ -508,69 +508,25 @@ void
}
NotifyOfControlModeChange(controlMode); // vtable+0x48
// TYPED torso reconfiguration. The raw block this replaces wrote the
// BINARY's offsets straight onto OUR compiled Torso (the databinding
// trap); the typed rewrite that followed then got the SEMANTICS wrong in
// three ways. Corrected 2026-08-08 against @004afbe0, which is a
// complete spec:
//
// iVar1 = mech+0x438 (TORSO) iVar2 = mech+0x5b4 (HUD)
// if (mode == 0) { // BASIC
// *(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.
// TYPED torso reconfiguration (2026-07-13): the raw block this
// replaces wrote the BINARY's offsets (torso+0x1f0/0x274/0x220...)
// straight onto OUR compiled Torso -- the databinding trap: garbage
// writes into whatever members live there in this build. The
// observable semantics via named members: Basic clears the analog
// axes and recenters (the sim's centerCommand -> Recenter); the
// assisted modes just free the torso (the sim clamps to the authored
// limits on its own).
Mech *mech = GetMech();
Torso *torso = (mech != 0) ? (Torso *)mech->GetTorsoSubsystem() : 0;
if (torso != 0)
{
if (controlMode == BasicMode)
{
torso->SetAnalogTwistAxis(0.0f); // @0x1F0
// BT_LEGACY_MODE_RECENTER=1 restores the defective pre-2026-08-08
// behaviour (the sticky centerCommand) for A/B measurement.
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
torso->SetAnalogTwistAxis(0.0f);
torso->SetAnalogElevationAxis(0.0f);
torso->CommandRecenter();
}
// Standard/Veteran: nothing to force -- the sim's limits govern.
}
DEBUG_STREAM << "[mode] control mode -> " << (int)controlMode
<< " (0=Basic 1=Standard 2=Veteran)" << std::endl;
@@ -704,58 +660,6 @@ void
// after the push, immediately before interpretation -- making the keyboard
// 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 --
// 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
// it while unpressed (Basic's own path re-asserts every
// 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
// DEAD on every RIO/glass rig (bridge off), which left those
// players with no torso-centre control at all once the
// stuck-cell phantom auto-recentre was fixed. Bench: two
// scripted 0x42 holds on the RIO path, ctrCmd=0 throughout.
{
Torso *rcTorso = (Torso *)mech->GetTorsoSubsystem();
if (rcTorso != 0)
{
if (gBTTorsoRecenter)
{
gBTTorsoRecenter = 0;
rcTorso->CommandRecenter();
}
else
{
rcTorso->ClearRecenterCommand();
}
}
}
}
}
// (stickPosition.y no longer zeroed here -- the bridge above
@@ -1110,20 +1023,6 @@ void
// reads the same live factor (dead/overheated myomers cannot squat
// or rise; the posture selector tests |factor| <= 1e-4).
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
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
// Entity engine vtable slots
//
// Decoded MoveAndCollide tuning constants (.rdata @004bf594..@004bf5b0).
// ⚠ RE-DECODED 2026-08-11 (#168 audit, byte-read + full @4bef78 walk) -- the
// original decode below misattributed most of these. Verified semantics:
// _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.
// Decoded MoveAndCollide tuning constants (.rdata @004bf594..@004bf5b0):
// 0x40000000 = 2.0f 0x38d1b717 = 1.0e-4f 0x40800000 = 4.0f
// 0x3dcccccd = 0.1f 0x41200000 = 10.0f 0xbf800000 = -1.0f
//
// Helper-function name mapping:
// FUN_004be1bc Projectile base constructor (vtable @00512a5c)
@@ -67,14 +53,12 @@
//
// MoveAndCollide tuning constants (read-only globals in the decomp).
//
// ⚠ names below carry the OLD (wrong) decode -- see the corrected table in the
// header comment; kept only so the dormant skeleton still compiles unchanged.
static const Scalar MissileLifeWarn = 2.0f; // _DAT_004bf594 actually: replication keepalive (s)
static const Scalar MissileSteerEps = 1.0e-4f;// _DAT_004bf59c/a0 actually: drag signed-square deadband
static const Scalar MissileTurnGain = 4.0f; // _DAT_004bf5a4 actually: proximity-fuse radius (u)
static const Scalar MissileDriftGain = 0.1f; // _DAT_004bf5a8 actually: dead-reckon divergence²
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)
static const Scalar MissileLifeWarn = 2.0f; // _DAT_004bf594 age-ratio dead threshold
static const Scalar MissileSteerEps = 1.0e-4f;// _DAT_004bf598/9c/a0 steering deadband
static const Scalar MissileTurnGain = 4.0f; // _DAT_004bf5a4
static const Scalar MissileDriftGain = 0.1f; // _DAT_004bf5a8
static const Scalar MissileMinSpeed2 = 10.0f; // _DAT_004bf5ac/a8 min squared-speed before fizzle
static const Scalar MissileDead = -1.0f; // _DAT_004bf5b0
//###########################################################################
//###########################################################################
-25
View File
@@ -809,31 +809,6 @@ void Myomers::MyomersDriveHeat(Scalar time_slice)
Scalar termAccel = velComplement * velMag * accMag * mass * time_slice;
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);
if (getenv("BT_MYO_LOG"))
-54
View File
@@ -1295,49 +1295,6 @@ void
void
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
if (simulationState == 1) // this[0x10] @0x40
@@ -1822,17 +1779,6 @@ int BTSubsystemIsGenerator(::Subsystem *sub)
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
// inferred link]): a destroyed generator must reach stateAlarm 4 or the
// 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)
// #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
{
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 (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)
{
effectiveTwistRate = baseTwistRate;
@@ -670,13 +646,6 @@ void
<< " limits=(" << horizontalLimitRight << ".." << horizontalLimitLeft << ")"
<< " axis=" << analogTwistAxis
<< " 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
<< " w=" << (void*)w
<< " wElec=" << (w ? w->electricalStateAlarm.GetLevel() : -1)
@@ -854,13 +823,6 @@ void
<< " vel=" << twistVelocity
<< " lastUpd=" << lastUpdateTime
<< " 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;
}
}
@@ -935,21 +897,14 @@ void
// 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.
// ⚠ 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 int s_count[2] = { 0, 0 };
const int kind = isDamagedCopy ? 1 : 0;
if (s_log && (s_count[kind] % 30) == 0 && s_count[kind] < 1800)
static int s_count = 0;
if (s_log && (s_count % 30) == 0 && s_count < 1800) // sample periodically to show the sweep
{
DEBUG_STREAM << "[torso] PushTwist " << (kind ? "COPY " : "master")
<< " node=" << (void*)node << " type=" << (int)jt
DEBUG_STREAM << "[torso] PushTwist node=" << (void*)node << " type=" << (int)jt
<< " twist=" << (float)twist << "\n" << std::flush;
}
++s_count[kind];
++s_count;
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.
void SetAnalogTwistAxis(Scalar v) { analogTwistAxis = 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)
// ⚠ 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)
// Reachable horizontal (yaw) half-arc the guns can be brought to bear by
+252
View File
@@ -0,0 +1,252 @@
# Phase 14 — PPC hit = `scrambleVideo`, the cockpit-CRT sync detune
**Goal:** restore the PPC's authentic secondary effect — a PPC strike scrambles
**every secondary cockpit display for 0.8 s**, leaving the main view untouched.
**Status:** ✅ IMPLEMENTED 2026-08-06 (branch `ppc-sync-distortion`) [T2 —
visual screenshot-verified, trigger by construction]. Discovered 2026-08-06 by
disassembly of the shipped `BTL4OPT.EXE` (md5 `a97075bcb5634d13263e9ad5a2b96fd0`)
after playtesters reported *"being hit by a PPC makes it look like all of the
secondary CRTs were being degaussed."* Full findings: `context/gauges-hud.md`
§"PPC HIT = a deliberate CRTC horizontal-sync DETUNE"; cross-ref in
`context/combat-damage.md`.
**What shipped (all three work items):**
- **A — trigger:** `game/reconstructed/mech.cpp`, `Mech::TakeDamageMessageHandler`
@0x4a03f3 position (after the cylinder resolve, before the burst loop) — fires
`GetGaugeRenderer()->SpecialEffect(scrambleVideo, damageType*0.2f)` once per
EnergyDamageType message. `BT_DMG_LOG` prints `[ppc-scramble]`.
- **B — visual (animated recovery):** `SVGA16::FunkyVideo(on, dur)` (was the 2007
stub) records the start + hold; `SVGA16::ScrambleParams` (new) is a two-phase
envelope read by BOTH `DrawDevSurface` (surround/dock) and `ExpandPlaneToBGRA`
(glass windows, native + rotated radar), so all secondary surfaces recover
together and the main 3D view (separate timing chain) is untouched. The sequence
per hit: **collapse** to a thin centred line → **HOLD** for the ~0.8 s the card
held bad sync (content scrolls wildly fast + decelerating, plus a violent
**shake**) → **RECOVERY** (~0.5 s: the line broadens back to full while
scroll/tear/shake settle) → **LOCK** (clean). The read loops map the source
through the envelope: horizontal `scale` sets a black-bordered collapse band,
`rollOff` scrolls (wraps) within it, `shear` is a per-row diagonal, and a
per-frame LCG `shake` bounces the row + jitters the scroll. Tunables (by eye —
the pod-monitor PLL look is not recoverable): `BT_SCRAMBLE_COLLAPSE` (min band
fraction, 0.03), `BT_SCRAMBLE_ROLL` (initial scroll px/s, 9000),
`BT_SCRAMBLE_SHAKE` (px, 10), `BT_SCRAMBLE_SHEAR` (px/row, 3), `BT_SCRAMBLE_DUR`
(hold s), `BT_SCRAMBLE_RECOVER` (recovery s, 0.5). **`BT_SCRAMBLE_TEST=1` loops
the whole transition** for tuning without a hit; `BT_SCRAMBLE_CYCLE=1` loops it
stepping the roll speed.
- **C — non-stacking latch:** `L4GaugeRenderer::SpecialEffect` now ignores the
re-arm while `scrambleVideoFlag` is set (matches the binary's `modified` latch);
a second PPC during the window no longer extends it. NB the SVGA16 animation runs
the full hold+recovery on its own clock (`FunkyVideo(False)` is a no-op — the
card restoring sync is where the recovery *begins*), so the recovery isn't cut
off when the 0.8 s latch clears.
- Verified [T2]: Release links clean; surround boots + runs with the effect looped
(`BT_SCRAMBLE_TEST`) at both slowed and true full speed — every secondary MFD +
the radar collapse/roll/shake/recover together while the out-the-window view
stays clean, no crash (screenshots). **SHIPPING with the current defaults for
playtester feedback.** Open: live PPC-fire confirmation (one arm per hit,
non-stacking across two hits) + by-eye tuning of the envelope constants — none
recoverable from the binary, so the testers who filed the report are the ground
truth.
**Good news up front:** the engine half already exists in our tree under the
**original VWE names** (`SpecialEffect` / `scrambleVideo` / `FunkyVideo`). Only
two things are missing: the **trigger** (never ported) and the **visual**
(stubbed out in 2007). This is a small, well-bounded job.
---
## 1. What the original did [T1 — disasm-verified]
On the **victim's** machine, `Mech::TakeDamageMessageHandler` @`0x4a0230` tests
the damage type between the collision divert and the burst loop:
```
004a03f3 mov ecx,[esi+0x2c] ; damage.damageType
004a03f6 cmp ecx,4 ; EnergyDamageType
004a03f9 jne 0x4a0423 ; everything else -> burst loop
004a03fb mov eax,[0x4efc94] ; global `application`
004a0400 mov eax,[eax+0x4c] ; -> gauge renderer
004a0405 je 0x4a0423 ; null-guarded
004a0407 fild dword [esi+0x2c] ; (float)damageType == 4.0
004a040a fld xword [0x4a0c08] ; long double 0.2
004a0410 fmulp st(1) ; => 0.8
004a041d call dword [edx+0x4c] ; vtable slot 19 == SpecialEffect(0, 0.8f)
```
`L4GaugeRenderer::SpecialEffect(scrambleVideo, 0.8f)` @`0x46ffcc`
`SVGA16::FunkyVideo(True)` @`0x47d76d`, which reprograms the **VGA CRT
controller**:
```
out(0x3D4,0x11); v=in(0x3D5); out(0x3D5, v & 0x7F) ; unlock CRTC regs 0-7
out(0x3D4,0x00) ; CRTC 0 = HORIZONTAL TOTAL
saved = in(0x3D5); out(0x3D5, saved - 9) ; shorten every scanline
out(0x3D4,0x11); out(0x3D5, v) ; restore write-protect
```
A per-frame timer @`0x47003c` writes `saved` back 0.8 s later.
**Why only the PPC:** a `BTL4.RES` census gives `damageType` 4 (`Energy`) =
**14 subsystem records, every one PPC or ERPPC**. Everything else is Ballistic
(16), Explosive (30), Laser (78). The branch is structurally PPC-exclusive —
no extra gating needed.
**Why only the secondaries:** all six secondary displays are derived by the VDB
from that one VGA's timing, so they break together. The main out-the-window
view comes off the Division VPX card on an independent timing chain and is
unaffected. Playtesters confirm both halves.
---
## 2. What our tree already has
| Piece | Where | State |
|---|---|---|
| `enum VideoEffectType { scrambleVideo }` (value **0**) | `engine/MUNGA/GAUGREND.h:482` | ✅ present |
| `virtual void GaugeRenderer::SpecialEffect(VideoEffectType, Scalar) {}` | `engine/MUNGA/GAUGREND.h:488` | ✅ base no-op |
| `L4GaugeRenderer::SpecialEffect` — sets `scrambleVideoFlag`, `scrambleVideoTimeout = Now()+duration`, calls `FunkyVideo(True)` | `engine/MUNGA_L4/L4GREND.cpp:806` | ✅ implemented |
| `L4GaugeRenderer::ProcessVideoEffects()` — on timeout, `FunkyVideo(False)` | `engine/MUNGA_L4/L4GREND.cpp:832` | ✅ implemented |
| …called every frame from `ExecuteForeground` | `engine/MUNGA_L4/L4GREND.cpp:370` | ✅ live |
| `SVGA16::FunkyVideo(Logical)` | `engine/MUNGA_L4/L4VB16.cpp:6358` | ❌ **STUBBED** (`//STUBBED: VIDEO RB 1/15/07`, body commented out) |
| Any caller of `SpecialEffect` | — | ❌ **NONE** |
The base-class declaration carries its original comment:
> `// Quick and dirty hack to allow calling L4GaugeRenderer::SpecialEffect`
> `// from non L4 level. GDU 2/28/96`
That hack exists **because the damage handler (non-L4 level) had to call it**
independent corroboration that the trigger belonged in `mech.cpp`, and the
reason you can call it through the base pointer without dragging L4 headers
into a game-layer TU.
---
## 3. Work item A — the trigger (`game/reconstructed/mech.cpp`)
In `Mech::TakeDamageMessageHandler`, **after** the `damageType==0` collision
divert and **before** the burst loop, add the type-4 branch.
```cpp
// @0x4a03f3 [T1] -- PPC/ERPPC only: EnergyDamageType is authored on exactly
// the 14 PPC/ERPPC subsystem records and nothing else. Duration is DERIVED
// from the type ordinal, not a constant: (float)damageType * 0.2 == 0.8f.
if (damage.damageType == Damage::EnergyDamageType) // == 4
{
GaugeRenderer *gauges = (application != 0)
? application->GetGaugeRenderer() : 0; // APP.h:355
if (gauges != 0) // binary null-guards too
{
gauges->SpecialEffect(
GaugeRenderer::scrambleVideo,
(Scalar)damage.damageType * 0.2f); // long double 0.2 @0x4a0c08
}
}
```
**Placement matters.** The binary's branch is *outside* the burst loop, so it
fires **once per damage message**, not once per burst. Putting it inside the
loop would re-arm it `burstCount` times.
Use the named accessor — do not raw-read `application+0x4c` (databinding rule,
`context/reconstruction-gotchas.md`).
---
## 4. Work item B — the visual (`SVGA16::FunkyVideo`)
There is no CRTC to detune, so reproduce the **look**, applied to the gauge
composite only.
What the original did physically: Horizontal Total sets character clocks per
scanline. Subtracting 9 shortens every line by roughly **9%**, far outside any
monitor's sync lock range, so the picture breaks into a rolling diagonal tear
until the value is restored. VWE's own name for it — `scrambleVideo` — is the
best description of the intended result.
Suggested model (per-scanline horizontal displacement of the gauge buffer):
```
shift(y, t) = ( y * k + roll(t) ) mod width
```
- `k` — per-line shear, the fraction of a line lost. ~9% of width is the
physically-derived starting point; **tune by eye** against the playtester
description rather than treating it as exact, because the on-screen result
depended on how each pod monitor's H-sync PLL misbehaved — that is not
recoverable from the binary.
- `roll(t)` — a time-varying offset so the tear drifts rather than sitting
static. The original rolled because the monitor never re-locked.
Constraints:
- **Gauge composite only.** Apply to the `SVGA16` `pixelBuffer`
(`engine/MUNGA_L4/l4vb16.h:243`) or at the point the strip/surfaces are
presented — *never* the main 3D view. The main view being clean is a
confirmed observation, not an assumption.
- **All secondary surfaces together.** They are bit-planes of one shared
buffer, so a single buffer-level effect is authentic by construction; do not
implement it per-MFD.
- **Keep the existing timing path.** `SpecialEffect` / `ProcessVideoEffects`
already own the flag and the 0.8 s timeout and are already called per frame.
`FunkyVideo` should only set/clear state — no timing logic of its own.
---
## 5. Fidelity constraints (do not "improve" these)
1. Duration is `damageType * 0.2f`, **not** a literal `0.8f`.
2. Fires on `EnergyDamageType` — never add an explicit PPC class check; the
data authorship *is* the gate.
3. **Idempotent, non-stacking.** The binary latches on `modified` @`0x4fe0fe`:
a second PPC hit while the effect is live does **not** re-save the (already
detuned) value and does **not** extend or double the effect. Our
`SpecialEffect` currently *does* overwrite `scrambleVideoTimeout`, which
extends the effect on a second hit — **that is a divergence.** Match the
binary: ignore re-arm while `scrambleVideoFlag` is set. (The original's
latch was in `FunkyVideo`; ours must go in `SpecialEffect` or `FunkyVideo`,
but it must exist.)
4. Victim-side only. The shooter sees nothing; this runs in the victim's
damage handler.
5. Null-guard the gauge renderer — the binary does, and headless/bench runs
have none.
---
## 6. Verification
- **Headless:** add a one-line log in the new branch; fire a PPC at a dummy
with `BT_DMG_LOG`. Expect exactly one arm per PPC message, zero for laser /
autocannon / missile / Gauss.
- **Non-stacking:** two PPC hits ~0.2 s apart must produce one 0.8 s effect
measured from the *first* hit, not 1.0 s or two effects.
- **Live:** confirm every secondary MFD scrambles together and the main view
stays clean. Have the playtesters who filed the report compare — they are
the only ground truth for `k` and `roll`.
- **Against the original (optional, decisive):** in the DOSBox-X fork, log
writes to CRTC index 0 via `0x3D4`/`0x3D5` during a real BT mission.
Prediction: exactly one write of `saved-9` per PPC strike, one restore 0.8 s
later, zero for every other weapon.
---
## 7. Gotchas
- Do **not** confuse this with `SVGA16::FlashPalette` (the pixel-mask cycler on
ports `0x302/0x30A/0x312`). That machinery is linked and its per-frame cycler
runs, but `FlashPalette` @`0x46d5f4` has **zero call sites and zero
address-of references** in `BTL4OPT.EXE` — BT never arms it. **RP does**
(`RPL4OPT.EXE` @`0x4addce`, palette 1, rate 2.0, masks `{FF,BF,7F,3F}`) for
alarm blinking. Wrong mechanism, wrong game.
- The `LampTesla1/2/3` "solid-state relays" in `L4CTRL.HPP` are **not**
involved and are driven by nothing in the surviving tree.
- Scope note: callers of gauge-renderer vtable slot 19 were not exhaustively
enumerated (virtual dispatch), so another arming site may exist. The
low-level path *is* exhaustive — @`0x47d76d` has one caller, @`0x46d840` two
(set @`0x47002b`, restore @`0x470076`).
## References
- `context/gauges-hud.md` §"PPC HIT = a deliberate CRTC horizontal-sync DETUNE"
- `context/combat-damage.md` §`Mech::TakeDamageMessageHandler` (Energy branch)
- `HISTORY.md` (TeslaRel410) §"Anatomy of a surviving weapon — the PPC"
+2 -3
View File
@@ -93,9 +93,8 @@ if defined BT_STILL_RUNNING (
goto btwait
)
echo.
echo The game has exited. (Quitting from the menu lands here -- normal.)
echo If it CRASHED or would not start, send the operator BOTH of these
echo from the content folder:
echo The game has exited. If it closed unexpectedly, send the operator
echo BOTH of these from the content folder:
echo content\lastrun_solo.txt (tiny -- send it EVEN IF there is
echo no solo_*.log at all: that combination
echo is how we spot a blocked launch)
+1 -4
View File
@@ -97,10 +97,7 @@ if defined BT_STILL_RUNNING (
goto btwait
)
echo.
echo The game has exited.
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 The game has exited. If it closed unexpectedly, send the operator BOTH
echo content\lastrun_steam.txt (tiny -- send it EVEN IF there is no
echo steam_*.log at all: that is how we spot
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