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
146 changed files with 1025 additions and 12004 deletions
+12 -20
View File
@@ -5,32 +5,29 @@
> detail is preserved verbatim in **`docs/PROGRESS_LOG.md`** (the old monolithic CLAUDE.md) — the
> ultimate fallback if a topic digest is thin. Migration history: `phases/phase-01-context-restructure.md`.
**Project:** Port VWE's arcade **BattleTech** (BT) pod game (MUNGA engine, Tesla platform
**Project:** Port VWE's 1995 arcade **BattleTech** (BT) pod game (MUNGA engine, Tesla platform
4.10) to modern Windows + the pod hardware, by reconstructing the missing BT game logic from the
`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.
---
## YOU ARE THE BATTLETECH PORT EXPERT
**You (Claude) are the expert on this port** — the original MUNGA/BT engine, its asset formats, the
**You (Claude) are the expert on this port** — the 1995 MUNGA/BT engine, its asset formats, the
decompilation, and the reconstruction state. The `context/*.md` files, `reference/glossary.yaml`,
`reference/decomp/` (the raw pseudocode), and the `docs/*.md` ledgers are YOUR knowledge base.
**ALWAYS consult the context system before answering a question about the engine, formats, a
subsystem, or the reconstruction state — and before writing reconstruction code.** Do not rely on
training data; this is a specific, reverse-engineered 1990s codebase. The knowledge here is more
training data; this is a specific, reverse-engineered 1995 codebase. The knowledge here is more
precise than anything you can infer.
### How to answer a question
@@ -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` |
@@ -93,13 +89,9 @@ precise than anything you can infer.
| Offsets, ClassIDs, addresses, resource types, env vars | `context/decomp-reference.md` |
| What we don't know / deferred systems | `context/open-questions.md` |
| Term / acronym definitions | `reference/glossary.yaml` |
| The ORIGINAL player manual (controls, per-mech stats, coolant loops) | `reference/manual/Tesla40_BT_manual.pdf` (+ alignment audit in `context/pod-hardware.md` §Manual) |
| The ORIGINAL 1995 player manual (controls, per-mech stats, coolant loops) | `reference/manual/Tesla40_BT_manual.pdf` (+ alignment audit in `context/pod-hardware.md` §Manual) |
| The complete verbatim detail (fallback) | `docs/PROGRESS_LOG.md` (the old 2236-line CLAUDE.md) |
| 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` |
---
@@ -119,7 +111,7 @@ Tag individual claims (not sections) inline: `[T1]`, `[T2]`, … A claim inherit
---
## Conventions (CRITICAL — full detail in `context/reconstruction-gotchas.md`)
- **Never raw-read a compiled object's offsets** (`*(T*)(obj+0xNN)`) — our layout ≠ the original binary.
- **Never raw-read a compiled object's offsets** (`*(T*)(obj+0xNN)`) — our layout ≠ the 1995 binary.
Use named members / accessors / a **bridge** fn in a complete-type TU. (databinding trap)
- **Never re-declare an engine-base field** — it shadows the base (reads `0xCDCDCDCD`) + mis-offsets.
- **`/FORCE` hides unresolved symbols** — an unresolved external → runtime AV near `__ImageBase`, not
+1 -1
View File
@@ -1384,7 +1384,7 @@ cmHeat(K,ModeSecondaryHeat,58,heatpal.pcc,heatpal2.pcc,Myomers);
}
#=======================================================================
# Denkou (was: Sunder V1)
# Sunder V1
#=======================================================================
Snd1Init
{
+1 -1
View File
@@ -101,7 +101,7 @@ One shared 128×64 texmap: `bmap:` → `VIDEO/MAT/BMAP.BMF` → 6 `TEXTURE` reco
`LoadBitSliceTexture` merges a 1bpp egg BitMap into it; `FlushBitSliceTexture`
(L4VIDEO.cpp:5682, stubbed in 2007) did `dpl_LookupTexture("bmap:bmap1_tex")` +
`dpl_TexmapTexels2D(texmap, buf, 128, 64, 4)` — one upload rewrites ALL SIX slices. So the
original engine painted the **egg's callsign + ordinal bitmaps** onto these plates every mission;
1995 engine painted the **egg's callsign + ordinal bitmaps** onto these plates every mission;
the baked "PLAYER n" text is only the shipped DEFAULT. Two stacked 128×32 large-name bitmaps
per slice = exactly the PNAME v-halves; two stacked 128×32 ordinal bitmaps ("1st|2nd") = exactly
the PLACE 2×2 grid. Corollaries:
+3 -20
View File
@@ -69,24 +69,7 @@ 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 original pod shipped ONE folder — `BTL4OPT.EXE` sits
- **Why this is a BT411-only hazard [T1].** The 1995 pod shipped ONE folder — `BTL4OPT.EXE` sits
next to `BTL4.RES`/`VIDEO\`/`GAUGE\`/`AUDIO\` (it is still there in `content\`) — and RP411/RP412
keep that shape (`pack-dist.ps1` copies the exe and every asset dir into one dist root). BT411's
two-folder split (`build\Release\btl4.exe` + `content\`) is not a design decision: `mkdist.py`
@@ -97,7 +80,7 @@ run\run.cmd [EGG] # default DEV.EGG; cd's to content\ and runs btl4.exe -eg
authors.** The cwd guard stays either way (it protects the developer tree, where the split is
real and permanent).
- Interactive: ⚠ under the GLASS profile (`content\bindings.txt`, the default since the cyd
merge) drive is the **original throttle-lever scheme**, NOT WASD: **SHIFT** = throttle up (lever
merge) drive is the **1995 throttle-lever scheme**, NOT WASD: **SHIFT** = throttle up (lever
STICKS where you leave it), **CTRL** = throttle down, **ALT** = reverse thrust, **X** =
all-stop, **BACKTICK** = view toggle. ("WASD drive" was the pre-glass dev profile — telling a
glass user W is the confirmed way to watch a mech stand perfectly still, 2026-07-29.)
@@ -183,7 +166,7 @@ stamps `build=4.11.<n> (<hash>)`, so an archived PDB matches a tester's log exac
- **`tools/`** — btconsole.py, disas2.py, map/res scanners. **`context/`** — this knowledge base.
## Versioning (2026-07-18)
`4.10` = the arcade release; `4.11` = this win32 reconstruction; dev builds =
`4.10` = the 1995 arcade release; `4.11` = this win32 reconstruction; dev builds =
**`4.11.<git commit count>` + short hash**, `+` suffix = built from an uncommitted tree
(e.g. `4.11.311 (980c9cd+)`). Stamped every build by `tools/btversion.cmake`
`build/btversion.h` (`BT_VERSION_*` macros); shown in the boot banner (day-log head) and
+29 -156
View File
@@ -53,7 +53,7 @@ section). Counting the enum gives `ThermalSightClassID = 0xBD9` and `TurretClass
(0xBD6) and Searchlight (0xBD8) but disagrees on MechTech (enum 0xBDD vs CLASSMAP 0xBDC), so the
port's `VDATA.h` ordering has drifted from the shipped binary somewhere in this range. CLASSMAP is
binary-derived and wins for what the runtime does; the true numeric value of `TurretClassID` in the
original binary is **unknown**. Resolve by disassembling the ClassID each ctor stores before relying on it.
1995 binary is **unknown**. Resolve by disassembling the ClassID each ctor stores before relying on it.
## No AI — BT is PvP-only (verified, not a recovery gap)
The mission system is NETWORKED and BT never had NPCs/AI. Every mech is driven by a `BTPlayer` (or a
@@ -85,10 +85,10 @@ unlocated** [T4 candidates]: the un-exported stretch `0x4a1674-0x4a2d48` (touche
together near entry `0x4a2971`, which is called from `0x494483`), the two unidentified Mech vtable
overrides `+0x18=0x4a122c` / `+0x1c=0x4a0c2c` (both big switch functions), and lone sites
`0x4877a4` (byte read via the mission entity table `0x5015c8`), `0x4b0097`, `0x45fb14-24`.
**RESOLVED (same dig, deeper): there IS no software pick writer — the original pick was a DPL SCENE
**RESOLVED (same dig, deeper): there IS no software pick writer — the 1995 pick was a DPL SCENE
INTERSECTION** [T0 + T1 converging]. Evidence: (a) the WinTesla renderer still carries the result
members `dplHitInstance/dplHitDCS/dplHitGeoGroup/dplHitGeometry` + `vehicleReticle` (L4VIDEO.cpp
ctor, all NULL-init); (b) the stubbed original-era renderable constructors each took
ctor, all NULL-init); (b) the stubbed 1995-era renderable constructors each took
**`dpl_isect_mode_obj` ("type of intersections to do on this object") + an intersection MASK** —
per-renderable intersection configuration against the scene; (c) Auric's account ("the pod's
division card cast from the view"); (d) VGL Lynx's LOD warning reads as firsthand knowledge — the
@@ -97,7 +97,7 @@ binary or the pseudocode ever writes `rayIntersection/targetEntity/targetDamageZ
only reads, initializes (the Mech ctor @0x4a1674 constructs the embedded Reticle), gates
(`FUN_004afd10`, the look-state machine, toggles `pickPointingOn` per view with the π rear case),
and SUBMITS the reticle to the board for drawing (`FUN_00460a7c` packages +0x37c/+0x390 into dpl).
**Aimed fire in the original had PER-PART precision**: scene ray → struck triangle on the current LOD →
**Aimed fire in 1995 had PER-PART precision**: scene ray → struck triangle on the current LOD →
the DCS = the struck SEGMENT → its `dzone` (the SKL segment→zone map, `GetSegmentIndex@49db20`) =
`targetDamageZone`. The cylinder lottery (STEP 6) was only ever the UNAIMED path. The port's
whole-mech box pick + "STEP-6 zone under the boresight" funnels AIMED fire through the unaimed
@@ -127,7 +127,7 @@ patch CENTROIDS so the zone walker can exercise per-panel. Bench: [[test-harnes
drawn geometry -- the any-object SPHERE fallback and the caller's whole-mech AABB fallback are
gone (sphere only for a mesh the reader cannot parse -- none exist, counters clean; AABB only as
the pre-tree replicant grace, `BTMechSegmentPick` returns 1/0/-1). They were port stand-ins the
original card never had, and their signature regime was a LEVEL boresight over a SHORT mech: the
1995 card never had, and their signature regime was a LEVEL boresight over a SHORT mech: the
blackhawk's mesh tops out below eye-ray height, so rays cleared every triangle while threading
the fat cull spheres -- lock ring lit with the reticle above its head (night-12 field report,
operator-witnessed on the sweep bench). Instruments: `BT_LOCK_SWEEP` (torso-pan lock envelope,
@@ -156,7 +156,7 @@ stale). **Selection is SPECIFICITY-FIRST: among pierced spheres the SMALLEST rad
mesh's sphere (r≈4.1 on the MadCat vs shoulders r≈1.0) envelops the mech, so nearest-entry always
faces you AND normalized distance rewards the giant sphere (a 0.45u-off-axis ray scores 0.012 vs
it). Limbs nest inside the envelope; smallest-pierced picks the most specific part on the aim line
— the per-part semantic the original mesh intersection produced. `mech4.cpp` tries the segment pick
— the per-part semantic the 1995 mesh intersection produced. `mech4.cpp` tries the segment pick
per candidate (box `PickRayHit` + zone 1 → the unaimed lottery survives only as fallback:
no tree / wrecked / structure occlusion), and the winner's zone rides `MECH_TARGET_SUBIDX` +
`targetReticle.targetDamageZone` into `SendDamageMessage` — aimed hits now dispatch a REAL zone.
@@ -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
@@ -333,7 +325,7 @@ an enclosing box), no crash. The interactive boresight (`BTGetAimRay`) can't be
## Firing arc + muzzles
There is NO firing-arc field in the binary (`Emitter::FireWeapon` fires whenever HasActiveTarget) —
the original game got away with it because it was **cockpit-only** (a beam behind you is off-camera). The
the 1995 game got away with it because it was **cockpit-only** (a beam behind you is off-camera). The
port's external camera exposes it, so the arc is gated on the **torso twist range**
(`Torso::GetHorizontalReach`, 0 for the twist-disabled Blackhawk) + a base aim tolerance
(`BT_FIRE_ARC`, a labeled PORT presentation param). Beams fire from the mech's real gun-port SITE
@@ -549,13 +541,13 @@ measuring a PORT bug. The truth [T1, raw disasm @0x4ac7bb + live-verified]:
own error strings).
Consequences: `MechSubsystem::TakeDamage @0x4ac0bc` (now real, was a stub) accumulates authored
per-type damage on subsystems; crits land and destroy subsystems; and **the Myomers un-powered
self-repair `@004b8bb9` was LIVE in the original and is live in the port now** — the earlier "dead code in
self-repair `@004b8bb9` was LIVE in 1995 and is live in the port now** — the earlier "dead code in
the original too" note in [[subsystems]] WAVE 6 is superseded. `DistributeCriticalHit`'s direct
`damageLevel = 1.0f` pin remains the ammo-explosion path, not the general mechanism.
## ⚠ ZONE SELECTION IS A WEIGHTED LOTTERY — pixel-precise limb damage does not exist (2026-07-29) [T1]
Issue #73 ("fired only at the left arm, damage credited elsewhere, no crits") is **largely the
authored original model, not a bug**. The chain (`dmgtable.cpp`, binary-faithful, byte-verified stream
authored 1995 model, not a bug**. The chain (`dmgtable.cpp`, binary-faithful, byte-verified stream
format): impact point → `WorldToLocal`**height layer** (`floor(layerCount·y/heightRef)`) →
**pie slice** (`atan2(local.z, local.x)`, optionally rotated by live torso twist —
`rotateWithTorso` per layer) → `DamageZonePercentTable::SelectZone()` = **`RandomUnit()` against
@@ -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 → the original folded an UNINITIALIZED stack float into
the shooter's score on every non-lethal hit (real original-binary 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]: original 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
@@ -1235,10 +1172,10 @@ code involved; grammar = the binary ctors @0x49ea48/@0x49e5e4). Results:
W1|W2 SEAM. (Corrects the earlier #92 comment that put frontal hits in W6.)
* **⚠ PORT FRAME ADAPTER (#124, fixed 2026-08-03; corrected same day by the
MUZZLE-ANCHOR probe)**: that +Z-forward (and +X-right, from the W0/W7
"Right*" names) is the **original resolver frame**. OUR engine frame, measured
"Right*" names) is the **1995 resolver frame**. OUR engine frame, measured
with the mech's own asymmetric geometry as the anchor (`BT_ASPECT_TEST`:
four world-cardinal self-impacts + one at each weapon's physical muzzle):
**RIGHT = +X, same as the original; FORWARD = Z, flipped** — the LEFT missile pod
**RIGHT = +X, same as 1995; FORWARD = Z, flipped** — the LEFT missile pod
sits at raw local x=2.32, the RIGHT at +2.32, and `WorldToLocal` is a clean
R(yaw) with no hidden terms (verified at yaws 30°/150°/40°). The frames
differ by a **Z-NEGATION ONLY — a reflection** (the first commit's π
@@ -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 (the original 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 original 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 the original; 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.
+4 -151
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]
@@ -299,7 +271,7 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
Score@0x4c02e4** (the type-1/2 switch — BT shadows the engine id with its override), **0x17
VehicleDead@0x4c05c4, 0x18 MissionStarting@0x4bfbe8, 0x19 MissionEnding@0x4bfc20, 0x1a
ScoreUpdate@0x4c02a8** (= Player::NextMessageID). **@0x4c0200 ("ScoreInflicted", type-0-only,
Verify line 0x18b) is in NO entry — dead code in the original.** The original type-0 path therefore lands in
Verify line 0x18b) is in NO entry — dead code in 1995.** The 1995 type-0 path therefore lands in
@0x4c02e4's Verify arm and folds an UNINITIALIZED [ebp-0xc] into currentScore (real bug; port
banks 0). @0x4c02e4 internals [T1 raw]: `+0x40==4` MissionEnding gate; registry-find msg+0x34;
case 1 = `(ownTonnage/senderTonnage) × role->CalcDamageReceivedScore(basis)` + the
@@ -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) — the original 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 original 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):
@@ -360,7 +309,7 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
GeneratorA-D — unreconstructed).
- **Searchlight** @0x51117C: {3, "ToggleLamp"→**@004b838c**}; **ThermalSight** @0x51120C: {3,
"ToggleLamp"→**@004b860c**}. ⚠ **CORRECTED 2026-07-25 (#61)** — these were swapped here (and in
`searchlight.cpp:51`, `hud.cpp:282`), which propagated a false "original latent bug" across four
`searchlight.cpp:51`, `hud.cpp:282`), which propagated a false "1995 latent bug" across four
context files; see the WAVE-4 note in [[subsystems]]. The two TUs emit PARALLEL shared-data blocks
with identical stride — msg entry, +0x5C attribute entries, +0x84 Performance triple:
| TU | msg entry | handler | attributes | Performance |
@@ -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) — an original 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
@@ -560,19 +437,9 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
with `scoreAward = ScenarioRole::specialCaseDeathPenalty` (role+0x20, the 4.10-only
key; restored to SCNROLE.h/.cpp — record slot [1] after KillBonus, fail-hard read).
Direct call, NOT dispatched — bypasses the BT handlers' type Verifies. NUANCE [T4]:
the original engine adds at Player+0x1c8 while every BT scoreboard reads +0x278 — the
the 1995 engine adds at Player+0x1c8 while every BT scoreboard reads +0x278 — the
pod's death cost may never have displayed; our single-cell port shows it. Shipped
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**
+1 -1
View File
@@ -14,7 +14,7 @@ open_questions:
# Experience levels / simulation modes
The egg's per-pilot `experience` field is the pod's **simulation-fidelity tier** — the thing the
original site operator set per player at sign-up. It is NOT the BAS/MID/ADV control mode (a free
1995 site operator set per player at sign-up. It is NOT the BAS/MID/ADV control mode (a free
in-cockpit choice, [[locomotion]]/[[pod-hardware]]) and NOT the scenario role (scoring modifiers).
Researched end-to-end 2026-07-18; this file supersedes the older "role display toggles" /
"ROOKIE-role lockout" readings (see Corrections below).
+88 -20
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
@@ -333,7 +334,7 @@ screen paint over the shared Eng plane, pinning it on the highest screen). Pixel
lists SCORING players (flag cleared when the mech links its player, mech.cpp:688, all
nodes). Both loops now use `Player::IsScoringPlayer()`; also closes a `pilotIDs[1]` heap
overrun the undercount exposed.
2. **Latch race**: the binary's build-once latch assumes the original's synchronous pod loads; over
2. **Latch race**: the binary's build-once latch assumes 1995 synchronous pod loads; over
async relay TCP the faster node ticks InterpretControls before the peer's replicants
arrive (rig-proven: staggered node logged roster 1 → then peer arrival). [T3
accommodation, demand-latch precedent]: rebuild whenever the scoring CENSUS changes —
@@ -641,7 +642,7 @@ and every instrument is now live [T2]:**
(class key 0x46) = the engine's **`CameraShipHUDRenderable`**, the camera/broadcast seat's
followed-name banner + ranking window, which uses PNAME **and** PLACE1-8 as sorted
`[ordinal][callsign]` rows and is **already live** as screen quads (see [[multiplayer]] camera
seat). Its original source is likewise commented out in `L4VIDRND.cpp:2562-2876` — the literal
seat). Its 1995 source is likewise commented out in `L4VIDRND.cpp:2562-2876` — the literal
`dpl_*` calls, a Rosetta stone for the binary's node API.
- **Simple-X mode** (PrimaryHudOn off, mask 0x20): the minimal reticle — a small green cross
(±0.02..0.08 arms) riding the aim translate (ctor @4689-4705 [T1]) — swapped for the full HUD
@@ -674,7 +675,7 @@ BITMAPS from the egg, not text — format, renderers, and the operator-console g
**The gauge blit addresses SOURCE rows bottom-up, and the vertical strip artwork is authored
to match.** `Video16BitBuffered::DrawBitMapOpaque` converts the incoming source rect with
`sTop = map_max_y - sTop; sBottom = map_max_y - sBottom` and walks rows upward
(`engine/MUNGA_L4/L4VB16.cpp:3846-3850`, `UP_BITMAP` :2155) [T0]; the original display blit
(`engine/MUNGA_L4/L4VB16.cpp:3846-3850`, `UP_BITMAP` :2155) [T0]; the 1995 display blit
@0046bdfc performs the IDENTICAL flip [T1 disasm], and the PCC→BitMap loader stores the PCX
top scanline at memory row 0 unflipped (`GRAPH2D.cpp:27-174`). Net: in `OneOfSeveral::Execute`
`sy0 = row*frameHeight` selects frames counting from the **BOTTOM** of the image as authored.
@@ -755,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.
@@ -780,25 +864,9 @@ Or/Xor ignore the mask entirely and And clears foreign planes -- all audited):
entry=0xffffff00 mask=0x3f`, at exactly the artifact positions in the
operator's screenshot. FIX: zero the table in the ctor + cycle the in-plane
pattern across entries [2^bits..255] (high-index art degrades to its
index-mod-2^bits colour IN-PLANE, can never leak). The original binary ships the
index-mod-2^bits colour IN-PLANE, can never leak). The 1995 binary ships the
SAME 64-entry fill and relied on 6-bit art discipline -- garbage is not a
preservable behaviour, so the cycle-fill is a guarded PORT deviation.
**REFINED 2026-08-10 [T1] -- the cycle-fill itself made phantoms.** The
in-plane wrap mapped art index 254 onto plane slot 62 = the LIVE
`colorMapperMultiArmor` right-armor damage slot, and idx-254 art EXISTS:
every SMODE.PCC frame fills the INACTIVE control-mode box interiors with 254
(active border/text idx 9 yellow, inactive borders idx 5 orange-red), and
BTSEC1.PCX carries a stray 52x13 idx-254 bar at port (199-250, 101-113) --
between the heading dial and the armor rosette, geometrically a scratch
duplicate of the rosette quadrant bars. Both lit up in the current
right-armor colour (adpal ramp green/orange/red) on EVERY render path --
playtester-reported as "red boxes around MID/ADV" + "a block between Armor
and Heading". On the shipped machine those regions rendered BLACK (the
garbage entry's low plane bits were 0 -> in-plane index 0), so the fill now
maps [2^bits..255] to `translationTable[0]` (plane BACKGROUND) -- authentic
on-screen result, same no-leak guarantee. NB the 2026-07-19 audit verified
smode BEFORE the 07-25 cycle-fill landed, which is why the row said CORRECT.
Post-fix: **0 leaks over 60s** on the same probe; sim3 3-pod regression clean.
**The tripwires are DEFAULT-ON in every build** (BT_PLANE_AUDIT=0 opts out): the
plane-leak trap, plus an out-of-bounds draw-start trap in `buildDestPointer`
+5 -31
View File
@@ -203,32 +203,6 @@ appears in the cfg like any panel and honours `,noframe`. Verified: a drag wrote
at 321,222. NB the plasma window blits directly every frame (`GetDC`+`StretchDIBits`), so unlike
the panels it has no `WM_TIMER` focus-throttle to worry about.
## Glass-panel repaint pump — perf + dirty-skip (2026-08-09/10) [T2 measured]
The exploded per-display windows are pure CPU/GDI (`ExpandPlaneToBGRA` +
`StretchDIBits`), repainted synchronously on the MAIN render thread by
`BTGlassPanels_Tick` (~16 Hz). Three layered changes after a playtester reported
~20 fps in panels mode vs ~130 fps in the surround:
1. **HALFTONE → COLORONCOLOR** (`f3d27f5`, in master): the `HALFTONE` stretch
(GDI's per-output-pixel resample) × 7 windows per pump was the primary sink;
nearest is crisper for the low-res MFD pixels anyway. `BT_GLASS_SMOOTH=1` restores.
2. **Per-window dirty-skip** (`c9e25e5`, in master): each window carries a change
token = FNV over `SVGA16::PlaneChecksum(mask)` (the shared pixelBuffer masked to
every port that can feed the window) + each button's RENDERED lamp brightness +
held/latch. The pump re-blits only windows whose token moved: ~31 pumps/2s →
4-15 window repaints vs 217-224 always-on. `BT_GLASS_DIRTY=1` logs the tally.
3. **Palette generation in the token** (branch `glass-palette-token`): the
ColorMapper family (armor rosette tints, adpal/adpal2 damage flash) animates by
CLUT writes with ZERO pixel churn — invisible to a pixel checksum.
`SVGA16::paletteGeneration` is bumped by the palette writers
(`BuildSecondaryColor` only on a REAL entry change, since the flash alternates
palettes every Execute writing identical RGB at zero damage; full rebuilds
unconditionally) and folded into the token for palette-expanding windows
(`monoTint < 0`, or all under `BT_GLASS_MFD_PAL`). Without it the glass radar
held stale armor tints between pixel repaints while the D3D surround (which
re-expands every frame) tracked live.
**Turning it OFF: `L4PLASMA=NONE` (also `OFF`/`0`, 2026-08-06) [T2].** `L4GREND` creates a
marquee whenever `L4PLASMA` is set at all (`SCREEN` → the desktop window, anything else → a real
`PlasmaDisplay` on that serial port), and the GLASS profile force-defaults it to `SCREEN` — so on
@@ -387,10 +361,10 @@ It lands on BT's addresses better than it deserves to: `1-4`+`QWER` are the ENTI
(Condensers 1-6, flush, balance), `F6`/`F7` the display/control-mode cycles, `F9`-`F12` Generators
A-D, `F4` the crouch button.
⚠ **What it costs, deliberately.** A bound key is removed from the authentic original typed-hotkey
⚠ **What it costs, deliberately.** A bound key is removed from the authentic 1995 typed-hotkey
channel to stop double-dispatch, and this board binds nearly everything — so `5` (Quad page), `z`
(Eng1), `t/y/u/i/o` (pilot select) and `+`/`-` (target zoom) are given up. Unbind a key to get its
original meaning back.
1995 meaning back.
⚠ **New rule: a bindings row WINS over a built-in convenience key** (`PadRIO::KeyHasBinding`).
`V` and `J/K/L` are board buttons now, so the view-toggle and preset-cycle polls stand down for
@@ -518,7 +492,7 @@ assigned axes excluded), derives invert from the move direction (throttle from F
position), auto-adds the hat look cluster, and rewrites bindings.txt between
`# >>> BT_JOYCONFIG` markers (rest of the file preserved). Sign conventions: JoystickX + =
twist right, JoystickY + = aim up (wizard binds flight-style forward=down via invert), Turn +
= right. Legacy `L4DINPUT.cpp DIJoystick` (L4CONTROLS=DIJOYSTICK, legacy `Joystick` iface)
= right. Legacy `L4DINPUT.cpp DIJoystick` (L4CONTROLS=DIJOYSTICK, 1995 `Joystick` iface)
left untouched. Verified: grammar accept/reject by line, no-device + XInput-only polling
clean 30 s in-mission, zero DI cost with no joy rows. NOT yet verified: real stick axes
(no hardware here) — needs a tester with a stick.
@@ -590,7 +564,7 @@ copied from them.
(right-click = hold latch) — the merged default profile lives in `L4PADBINDINGS.cpp`
(auto-writes `content\bindings.txt`; W/S/A/D/Q/E/R/F/X drive, 1-4/Space/Ctrl fire, M/N/H/C =
pod buttons 0x18/0x15/0x2C/0x2F, `/V view toggle + J/K/L preset cycles hardcoded in PadRIO).
Bound keys are SUPPRESSED from the typed original channel (`PadRIO::SuppressKey`, the btinput
Bound keys are SUPPRESSED from the typed 1995 channel (`PadRIO::SuppressKey`, the btinput
pattern — ended the glass double-dispatch: 'w'=pilot-select, numpad/F-key key-up aliases);
unbound keys keep their authentic meanings (5/z presets, t-o pilot select, +/- zoom).
In BAS control mode the pod turns with the STICK (Q/E) and pedals are inactive — authentic;
@@ -654,7 +628,7 @@ copied from them.
streamed Eng-page `.CTL` dispatches subsystem msg **id 3 / 0xb** to whatever subsystem is shown,
and a **weapon (Emitter)** registers only PoweredSubsystem 4-8 + MechWeapon 9-10 — so id 3 is a
gap below `entryCount`, and `Receiver::MessageHandlerSet::Build`'s `new HandlerEntry[]` left it
as heap garbage → `(this->*garbage)(msg)`. The original binary has the identical non-zeroing
as heap garbage → `(this->*garbage)(msg)`. The 1995 binary has the identical non-zeroing
`new[]` and survived only on fresh-heap-zero luck (id 3 on a weapon was always meant to be
IGNORED — it's a Condenser/Reservoir action in a different subsystem branch; the uniform
Eng-page button template makes buttons for unimplemented actions authentically inert). The new
+2 -39
View File
@@ -185,7 +185,7 @@ Declared-separate members don't see each other's writes — alias them.
Replicants (task #50): travel = engine DeadReckon; leg channel driven joints-only from the replicated
worldLinearVelocity -> speedDemand (mech4.cpp REPLICANT MOTION) — detail in [[multiplayer]]. [T2]
## The AUTHENTIC original ground model (default-on, `BT_GROUND_REAL`)
## The AUTHENTIC 1995 ground model (default-on, `BT_GROUND_REAL`)
Decoded by the 10-agent `ground-model-decode` workflow (binary `FUN_004a9b5c` = the master perf).
**NO GRAVITY anywhere** — accelerations zeroed every frame. Vertical position = an ABSOLUTE per-frame
ground SNAP:
@@ -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
@@ -462,7 +425,7 @@ trap never fired while the value changed) resets the sim cell between damage eve
1 Hz bench harness it restarted the warning before the 1.8 s voice note; sporadic real-play
damage is unaffected. Needs a cdb write-watchpoint dig (candidates: a recon raw +0x2c-family
write or a struct copy spanning it).
### The gimp cell is REPLICATED STATE — peers limped for free in the original (#82, fixed 2026-07-29)
### The gimp cell is REPLICATED STATE — peers limped for free in 1995 (#82, fixed 2026-07-29)
Field: "peers see a limping mech **skating**" (first-person limp + voice correct; bystanders saw a
normal walk sliding at limp speed; a stop/start did not clear it). Root cause chain, all [T1/T2]:
- The binary's one `mech+0x40` **is** `Simulation::simulationState`, which
+12 -35
View File
@@ -357,7 +357,7 @@ phase). Plan: `~/.claude/plans/partitioned-snuggling-piglet.md`.
— the relay fans out server-side (kills the mesh's N1× duplication at the seam where
`EntityBroadcastToReplicants` already serialized once). Verified: relay `tcp rx==tx`, one
copy per broadcast.
- **UDP channel = the original NETNUB reliable/unreliable split RESTORED (not invented)**:
- **UDP channel = the 1995 NETNUB reliable/unreliable split RESTORED (not invented)**:
`Receiver::Message` defaults `messageFlags=ReliableFlag` (RECEIVER.h:195); the ~60Hz update
path sets flags=0 (ENTITY.cpp:590); `Mode(UnreliableMode)` at LoadingMission (APP.cpp:704,
the 2007 port's `Mode()` was a no-op — now STORES the mode). The routing gate is
@@ -397,7 +397,7 @@ phase). Plan: `~/.claude/plans/partitioned-snuggling-piglet.md`.
SEAT_REQUEST branch (btconsole.py). Verified: 8/8 stub tests (distinct seats, FULL on
exhaustion, claim clears reserve, duplicate-HELLO refused) + 2-node localhost e2e with NO
BT_SELF on either pod → both seated, full ladder, launch, UDP flowing.
- **WALK-UP CALLSIGN/MECH REQUEST (2026-07-22) [T2]**: the original front-desk conversation, over
- **WALK-UP CALLSIGN/MECH REQUEST (2026-07-22) [T2]**: the 1995 front-desk conversation, over
the wire. join.bat/join_lan.bat set `BT_FE_JOIN=1` and launch ZERO-ARG -> the FE menu in JOIN
trim (btl4fe.cpp `FeJoinOnly()`: CALLSIGN edit + the 18-mech list + JOIN; screenshot-verified)
-> `BTFeLaunchJoinRelay` relaunches with the universal join args + `BT_CALLSIGN`/`BT_MECH` env
@@ -438,7 +438,7 @@ phase). Plan: `~/.claude/plans/partitioned-snuggling-piglet.md`.
default case `Fail()`ed = abort = a SILENT crash (c0000409, no WER record; cdb stack:
ucrtbase!abort <- RunMissionMessageHandler <- RoutePacket; 3/3 reproducible in the
console-shaped run, and the field report's 464 crash died mid-LOD-load with the same
signature). The original console never hit this state (operator-paced launches). FIX
signature). The 1995 console never hit this state (operator-paced launches). FIX
(L4APP.cpp): the default case now DEFERS -- re-posts the message to itself at +1s (the
VehicleDead re-post pattern) until the ladder reaches WaitingForLaunch; bounded at 120
deferrals so a wedged load still surfaces the original Fail. `[launch] ... deferred +1s`
@@ -513,9 +513,9 @@ phase). Plan: `~/.claude/plans/partitioned-snuggling-piglet.md`.
is the console pad -- the game socket doesn't exist yet, so the first fix's RelayGameDown
hook could never fire (caught by the verification itself). Detection now lives in
`HostDisconnectedMessageHandler` case ConsoleHostType (relayMode && !scene-presented ->
`BTRelayRejoinNow`); mesh keeps the original console-less behavior. Verified 2x in one run.
`BTRelayRejoinNow`); mesh keeps 1995 console-less behavior. Verified 2x in one run.
2. **Post-egg pre-mission relay loss**: RelayGameDown -> rejoin (the original fix). ✓
3. **Mid-mission relay loss = forever-mission**: the STOP is relay-sent (original design: console owns
3. **Mid-mission relay loss = forever-mission**: the STOP is relay-sent (1995: console owns
the clock), so a pod losing its relay mid-match played alone forever. Now RelayGameDown
(scene presented) posts `StopMissionMessage` at +15s -> normal end path -> lobby relaunch
-> patient wait. Verified: scene LIVE -> relay killed -> 15s -> clean RunMissions exit.
@@ -598,14 +598,14 @@ phase). Plan: `~/.claude/plans/partitioned-snuggling-piglet.md`.
drives the pod countdown + the final-30s ranking window; the relay now SENDS
`Application::StopMissionMessage` (clientID 4, msgID 6, exitCode 0) at length+2s grace, or on
the operator's ⏹ END MISSION button (stdin `stop`). Was half-implemented — nothing fired it in
the original — the console owned the stop. Pod runs the authentic fade → mission-loop exit → clean
1995 the console owned the stop. Pod runs the authentic fade → mission-loop exit → clean
process exit. Verified live (40s test mission ended on the clock).
> **The console/relay now has its own topic: [[operator-console]]** (`context/operator-console.md`)
> — which of the two programs is which, ports, the seat model, the full round/launch lifecycle and
> its wedge states, plus liveness. Read that first for anything console-side; the notes below are
> the historical record of how it got built. Sysop how-to: `docs/OPERATOR_GUIDE.md`.
- **OPERATOR CONSOLE (2026-07-18)**: `tools/btoperator.py` (PySide6) — the lost original operator
- **OPERATOR CONSOLE (2026-07-18)**: `tools/btoperator.py` (PySide6) — the lost 1995 operator
station recreated: mission editor with ALL values validated live from BTL4.RES
(`tools/eggmodel.py`: maps=type14∩26, colors/badges/patches from the type-25 vehicletable,
vehicles from ModelLists), relay/mesh mode switch, live pod-status lights parsed from the
@@ -637,7 +637,7 @@ phase). Plan: `~/.claude/plans/partitioned-snuggling-piglet.md`.
— the egg's `[mission] length=` (600 = the 10-min pod mission) drives the pod-side countdown
(`secondsRemainingInGame`, APP.cpp:652) and the ranking-window display for the final 30s
(DIRECTOR.cpp:113), and the full end chain exists (StopMission id 6 → EndingMission → player
fade → Stop) — but NOTHING fired it: in the original the CONSOLE sent StopMission at expiry (mission
fade → Stop) — but NOTHING fired it: in 1995 the CONSOLE sent StopMission at expiry (mission
length enforcement is console-side). Now reconstructed: the relay arms the clock at RunMission
#2 and sends `Application::StopMissionMessage` (clientID 4, msgID 6, +int exitCode 0) at
length+2s grace; the operator app gets an ⏹ END MISSION button (stdin `stop`, like `launch`).
@@ -713,7 +713,7 @@ carousel, Phase 2 = true drop-in). The durable engine facts found:
existing ROSTER host to OnLine whenever it connects — arrival order/timing is free (this is
how slow loaders already work), and the relay's PEER_UP exchange is bidirectional for
newcomers (btconsole.py:1202-1209).
- **Late ENTITY sync was never finished in the original**: entity MakeMessages are broadcast ONCE at
- **Late ENTITY sync was never finished in 1995**: entity MakeMessages are broadcast ONCE at
creation and never retained. `InterestManager::NotifyOfEntityCreation` (INTEREST.cpp:694-763
→ DynamicEntityCreation :839+) sends immediately; the interest-zone bookkeeping is
`#if 0 "HACK - Partial implementation"`; and `HostManager::NotifyOfReplacementEntityCreation`
@@ -769,7 +769,7 @@ transition, HUD all landed since P6): console egg → mesh → RunningMission on
deathPending); without one → engine base @0042db80 (PLAYER.cpp:214: closest non-"win"
DropZone → AssignDropZone → 2s re-post probe) → DropZoneReply → CreatePlayerVehicle (a NEW
mech; `deathCount = 0`) + viewpoint relink (Verify(viewpointEntity==NULL) is compiled out at
DEBUG_LEVEL 0 — release overwrites, same as the original build). @0049fe0c = roster-wide
DEBUG_LEVEL 0 — release overwrites, same as the 1995 build). @0049fe0c = roster-wide
slot-11 DeathShutdown broadcast (the death transition already runs that pass).
CORRECTIONS to the earlier scouting: BTL4.RES **type 12 = InternalAudioStream** (the engine's
own resource-type table) — `*int.scp` are internal-AUDIO scripts, NOT per-mech intro scripts;
@@ -895,7 +895,7 @@ transition, HUD all landed since P6): console egg → mesh → RunningMission on
## Authentic coupled peer motion — DONE (task #50, 2026-07-14) [T1]
Decomp-verified (workflows w0odszxro/wh1h5gnmc, 3 make-or-break claims adversarially CONFIRMED; committed c52a1ad + default-promoted). The original peer per-frame path FUN_004ab430 -> FUN_004ab1c8 (IntegrateMotion) drives motion SINGLE-SOURCE:
Decomp-verified (workflows w0odszxro/wh1h5gnmc, 3 make-or-break claims adversarially CONFIRMED; committed c52a1ad + default-promoted). The 1995 peer per-frame path FUN_004ab430 -> FUN_004ab1c8 (IntegrateMotion) drives motion SINGLE-SOURCE:
- LINEAR position = GAIT. IntegrateMotion advances the BODY gait channel FUN_004a5678 (FUN_004a5028 is DEAD CODE, zero call sites), turns its cycleDistance into velocity `{0,0,-cd/dt}`, rotates by heading, integrates into projectedOrigin.linear@0x260 (part_012.c:14994/14997/15008); localOrigin.linear@0x100 is copied VERBATIM from it (FUN_00408440, part_012.c:15131 -- NOT a lerp). updateVelocity.linear is never used for position.
- ANGULAR/heading = replicated-velocity slerp (the ONLY thing Mover::DeadReckon-style velocity extrapolation drives): projectedOrigin.angular@0x26c built from updateVelocity.angular@0x2d4 (FUN_004ab188), localOrigin.angular slerped toward it.
- 0x260 aliasing: "motionDelta@0x260" and "projectedOrigin@0x260" are LITERALLY THE SAME field (base Mover::projectedOrigin, Origin 0x1c); "worldPose@0x26c" is its quaternion half. Our reconstruction's shadow names hid this and drove the false velocity-vs-gait "contradiction".
@@ -907,27 +907,4 @@ 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
original 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.
## Application control is OWNER-ONLY — the replicant player StopMission leak (2026-08-12) [T2]
`Player::ManageApplicationStatus` (the 3.0s mission-end fade countdown → the second StopMission →
`Application::Stop()`) is now gated `GetInstance() != ReplicantInstance` (PLAYER.cpp). Un-gated, a
REPLICANT player copy — whose simulationState mirrors the remote master's MissionEndingState
through its record header, and whose fadeTimeRemaining was never armed (≤0 at first tick) —
dispatched StopMission instantly and killed the mission loop before the fade could render. On an
8-player steam node ANY of the 7 replicant copies cut the end window to ~one record heartbeat:
the FIELD half of #156's "no fade". Same authority discipline as scoring (masters emit, mirrors
display); receipts: `[fade-clk]` single 124-tick countdown + phases 1→5 (`fade_bench.sh`).
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).
+13 -45
View File
@@ -29,7 +29,7 @@ authentic path scoped.
(`FUN_0048d4d4` = "flush_material", dpl command opcode 100 — siblings flush_ramp/flush_texture).
The card-side shading (how material colour selected/scaled the 256-row material ramps → final
texels) is i860 firmware, NOT in BTL4OPT.EXE — so the PERCEPTUAL damage-darkening curve of the
original pods cannot be recovered from this binary. Our port applies the host's linear factor at
1995 pods cannot be recovered from this binary. Our port applies the host's linear factor at
draw (TFACTOR modulate, L4D3D). Field testimony ("a single missile leaves visible armor
damage") is arithmetically consistent with the linear curve — one 25-pt hit snaps a 68-90-pt
zone by 0.28-0.37 (measured live: amt=25 → rarm 0→0.3247) — but if the card's ramp mapping was
@@ -64,7 +64,7 @@ authentic path scoped.
relay session — replicated player links (`bmp=1+veh`), director locks the goal (30s timer),
camera ship tracks the moving mech (`sees=1`) and CUTS between authored cameras
(screenshots). Egg: pilot page `hostType=1` + `vehicle=camera`. **RANKING WINDOW LIVE (same day)**: the stubbed
original overlay reimplemented as screen-space quads in `CameraShipHUDRenderable::Render` — the
1995 overlay reimplemented as screen-space quads in `CameraShipHUDRenderable::Render` — the
followed player's callsign banner (bottom center, the Execute index is ALREADY 0-based) + a
rank-ordered row per scoring player ([ordinal][callsign], right of center), visibility = the
Director's flash logic (10s on/15s off, solid final 30s), live `playerRank` pointers re-sort
@@ -162,7 +162,7 @@ New unaccounted functionality no prior list knew (addresses verified absent from
launcher's `ExplosionModelFile`. The MissileThruster parser `FUN_004bf8ec` writes `"SplashRadius"` to
+0x50 (size-0x54 record). Burst falloff `baseBurst / dist^exp` floored at 1 (arcade 1.25 / WinTesla
`EXPLODE.cpp:209` 1.2f). Only missiles splash (AC tracer is not a Missile). The engine `EXPLODE.cpp`
diff is DONE — the T0 source confirmed the model; the only original-vs-WinTesla drift is the exponent.
diff is DONE — the T0 source confirmed the model; the only 1995-vs-WinTesla drift is the exponent.
Port: `BTResolveSplashRadius`/`BTApplySplashDamage` (mech4.cpp), see [[combat-damage]]. **Remaining
T3:** the per-player enable sub-gate `missile+0x360 = BTPlayer+0x264` (its writers read as a per-frame
toggle, not a config flag; the `showDamageInflicted@0x264` label is a guess) — port treats authored
@@ -181,7 +181,7 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
the port now implements it byte-grounded ([[subsystems]] WAVE 6 correction). LESSON: a
"no callers in the decomp" claim is only as good as the export coverage — sweep the raw image
for the operand pattern before declaring a data path dead ([[reconstruction-gotchas]]).
Original entry below for the record:** The original manual (p20/p22 + stat sheets) says seek changes TOP SPEED (gear 4 =
Original entry below for the record:** The 1995 manual (p20/p22 + stat sheets) says seek changes TOP SPEED (gear 4 =
"Supercharge", expert-only; Loki/Thor print Normal 143 / Super Charged 182 / Gimped 40 kph).
The 4.10 binary provably has NO runtime myomer→speed coupling (full data-flow closure [T1]:
demand fn @004afd10 all branches, all speedDemand writers, AvailableOutput's 2 callers, the
@@ -193,7 +193,7 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
authorized multiplication in the demand path. [[subsystems]] [[pod-hardware]]
- **Environment gravity unwired — the myomer CLIMB-heat term is inert (found 2026-07-31, #85).**
`Mover::localEnvironment` (MOVER.h:271) is declared and never populated anywhere in the port;
`GetEnvironment()->gravityConstant` is the named analog of the original Mover's gravity POINTER at
`GetEnvironment()->gravityConstant` is the named analog of the 1995 Mover's gravity POINTER at
`+0x250` (`FUN_00421e2c` does `vy -= **(+0x250)` per tick), which the myomer drive-heat
integrator uses for its `m·g·|vy|·dt` climb-work term. The bridge (`BTMechMyomerMotionSample`)
null-guards it, so climbing currently generates no myomer heat (flat maps: moot; cavern slopes:
@@ -239,9 +239,9 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
`SetFogStyle(searchLightOn/OffFogStyle)` with the binary's inverted-cache seed (authentic DARK
night start), plus the external spot.bgf beam cone (btfx `brighten` additive veil) on the
searchlight site joint, MP-replicated. Historical attribution notes kept below — the
"ORIGINAL LATENT BUG" premise was ❌ RETRACTED 2026-07-25 (#61).** The arcade swaps fog between `fog=` (lights on) and
"ORIGINAL 1995 LATENT BUG" premise was ❌ RETRACTED 2026-07-25 (#61).** The arcade swaps fog between `fog=` (lights on) and
`nosearchlightfog=` (off) via `PullFogRenderable` watching the Searchlight's `lightState`.
**The old entry claimed the original binary itself could never light the lamp. That was wrong** — it
**The old entry claimed the 1995 binary itself could never light the lamp. That was wrong** — it
compared Searchlight's Performance (@004b841c, reads `requestedOn`@0x1E0) against **ThermalSight's**
`ToggleLamp` (@004b860c`0x1DC`), i.e. two different classes, and so invented a missing
"0x1DC→0x1E0 bridge". Searchlight's real handler is **@004b838c** (table @0x51117C) and toggles
@@ -251,7 +251,7 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
carries no novice gate. All T1.]
Searchlight's handler set is WIRED and **verified live**: pad `0x14``requestedOn 0→1`
`lightState 0→1`; the fog watcher + beam cone landed 2026-08-05 (see the header above), so no
remaining work rides this entry. (NB the original searchlight carries NO novice gate — that
remaining work rides this entry. (NB the 1995 searchlight carries NO novice gate — that
lockout is ThermalSight's; raw disasm @004b838c.)
- **`HandleMessage` is vtable slot 8/9 in the binary but NON-virtual across the reconstruction --
FILED AS GITEA #65
@@ -305,7 +305,7 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
- **PlayerStatus (the mission-review panels) reads the compiled player at RAW BINARY OFFSETS --
FILED AS GITEA #63 (2026-07-25, found via #48) [T1 code-read].** btl4gau3.cpp `PlayerStatus::Execute`:
`player+0x1FC` (vehicle), `+0x1C8` (score, drawn as a numeric), `+0x1C4` (alive/dead -- a
state flip draws a 160x240 filled box) -- all original-binary offsets, garbage on our compiled layout
state flip draws a 160x240 filled box) -- all 1995 offsets, garbage on our compiled layout
(the databinding trap). DORMANT in-game (the review context no longer survives the viewpoint
swap -- the #48 teardown fix) but LIVE the moment the mission-review screen actually runs at
round end. Bridge all three through btplayer.cpp accessors before enabling the review screen.
@@ -702,7 +702,7 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
- **Subsystem-panel online/offline gate — fields unidentified [T4] (2026-07-12).** The binary's
SubsystemCluster draw-state reads `*(subsystem+0x40)==1` and `*(subsystem+0x278)!=4`; neither
original field is identified (weapon simulationState is 0/2/3/4 -- +0x40 isn't it). The port pins
1995 field is identified (weapon simulationState is 0/2/3/4 -- +0x40 isn't it). The port pins
the panel ONLINE (GetDrawState()==1) because the old raw reads were layout NOISE that flapped
the state and black-filled the panel over the recharge arc (the frozen-PPC-dial bug; the
WeaponCluster repaint-heal also landed). Identify both fields to restore the authentic
@@ -948,24 +948,12 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
- **Collision-death tail fallthrough is inspection-tier [T3]:** the divert now falls to the death
tail (VehicleDead + blast) per @0x4a0375, but no live wall-death bench has run; field
wall-deaths exercise it nightly (watch for DEAD_NOTIFY on collision deaths).
- **The original type-0 uninit-award bug** (documented in [[decomp-reference]] §BTPlayer table): the
- **The 1995 type-0 uninit-award bug** (documented in [[decomp-reference]] §BTPlayer table): the
shipped binary folds an uninitialized stack float into the shooter's score on every non-lethal
hit's type-0 report. Era scoreboards looked sane — worth understanding WHY (dispatch path may
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
@@ -979,7 +967,7 @@ readout being wrong — it is a real behavioural gap, not a cosmetic one.
(`btfx:firesmoke1_scr_tex`) — all effects share the radial-masked grit sheet; decoding FIRE.BSL
would give the authored sheet; (c) beams draw before particles, so a beam through thick smoke
reads too bright (pass-ordering); (d) `colorWarp/alphaWarp` applied as `t^(1/warp)` [T4 convention];
(e) emission uses rate×releasePeriod batching [T4 convention vs the exact original emitter].
(e) emission uses rate×releasePeriod batching [T4 convention vs the exact 1995 emitter].
- Replacement-LOD selection for NON-additive multi-LOD models (needs the board's hot-spot/
reference-point semantics; leads: `s_dplobject.lod_ranges[16]/lod_hot_spot`, 0x2047/0x2048).
- Day/night MATERIAL path priority (BMF/IMG) — infrastructure done, gated `BT_MATPRI` (over-applies
@@ -1104,7 +1092,7 @@ Trace it live with `BT_AMMO_LOG=1` (+ `BT_PROJ_LOG=1` for per-tick heat/recoil).
## Night-9 playtest intelligence (build 4.11.693, 2026-08-01)
**A MEMBER OF THE ORIGINAL TEAM IS NOW TESTING.** "Ronin" (log user `torak`,
machine TOWER_OF_TERROR) worked on the original game and has played the pods
machine TOWER_OF_TERROR) worked on the 1995 game and has played the pods
recently, including in Japan. He is a **primary source** — treat his comparisons
against the pods as high-value evidence, above reconstruction inference. Two
contributions already:
@@ -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.
+3 -3
View File
@@ -14,7 +14,7 @@ open_questions:
# The Operator Console + Relay
The system-operator station: build a mission, run a session, watch pods arrive, launch rounds.
This is the piece the original archive lost and we rebuilt.
This is the piece the 1995 archive lost and we rebuilt.
**What survives of the original, and why so little:** the console APPLICATION is absent from every
archive (and Route A is closed). What we have is the pod's half of the conversation --
@@ -22,7 +22,7 @@ archive (and Route A is closed). What we have is the pod's half of the conversat
defining the Console* messages the game sends TO the console (MechKilled / MechDamaged /
ScoreUpdate / TeamScoreUpdate / DeathWithoutHonor / EndMission) -- plus the engine's receive-side
handlers (egg / RunMission / StopMission). Its changelog line *"06/03/95 GAH -- Added
corresponding Macintosh message definitions"* implies the original sysop station was a **Macintosh
corresponding Macintosh message definitions"* implies the 1995 sysop station was a **Macintosh
application**, a separate codebase that was never in the pod build tree -- which is why no archive
has it. `btconsole.py`/`btoperator.py` are therefore a wire-faithful RECREATION built from the
surviving parser side (+ BT_NET_PROBE against the real exe), not a port of the lost program.
@@ -292,7 +292,7 @@ no UDP fan-out, no control channel). All sends now go through `_send_all_guarded
- **Relay (internet)**`btconsole.py --relay <port> <egg>`; pods dial out. The normal mode.
- **Mesh (LAN legacy)**`btconsole.py <egg> <host:port> ...`; the console dials OUT to each pod's
`-net` port. The authentic original cabinet direction.
`-net` port. The authentic 1995 cabinet direction.
- **Remote relay / PARKED RELAY** — the GUI drives an already-running relay over the control port
instead of spawning a child. **This is a COMPLETE, SUPPORTED deployment mode** (fixed 2026-07-26 —
do not re-derive it; see §Parked relay below). The intended production shape: the relay lives
+8 -175
View File
@@ -63,7 +63,7 @@ Searchlight + Searchlight2 `ToggleLamp` (id 3, table @0x51117C fn @004b838c)**
2026-07-25 (#61)**, previously default-constructed blackholes; verified live (0x14 →
`lightState 0→1`, 0x12 → `thermalActive 0→1`). ⚠ The fn addresses were **swapped** in this file
before #61 (@004b860c is ThermalSight's) — see [[decomp-reference]] for the table evidence — and
that swap had propagated a false "original latent bug" (retracted, [[subsystems]] WAVE 4).
that swap had propagated a false "1995 latent bug" (retracted, [[subsystems]] WAVE 4).
An unhandled message is SILENTLY ignored
(Receiver::Receive finds no handler and does nothing — dead buttons produce NO log).
0x10/0x11 = map ZoomIn/Out, 0x15 = CycleDisplayMode, 0x18 = CycleControlMode (#6). Buttons
@@ -77,7 +77,7 @@ Full decode: [[gauges-hud]] §preset pages; the complete 72-button census + veri
button <addr>|axis <axis> deflect|rate <n>|keypad pilot|external <0-15>|pckey <char>|action
<name>`; `pad <button>` same targets; `padaxis <src> axis ... [invert] [deadzone d] [rate n]`.
Axes: Throttle (lever, rate-walks + sticks), LeftPedal/RightPedal/Pedals (turn, springs),
JoystickX (torso twist), **JoystickY (torso ELEVATION — pitch aim; every original control mode
JoystickX (torso twist), **JoystickY (torso ELEVATION — pitch aim; every 1995 control mode
routes it to Torso::SetAnalogElevationAxis; keys R/F + pad LeftStickY in the default map;
verified to the authentic VerticalLimitTop clamp, 20° on the Blackhawk; aims the GUNS — reads
on the HUD elevation tape, the eyepoint stays level)**.
@@ -96,7 +96,7 @@ dynamically (xinput1_4 → 9_1_0; disconnected-pad probing rate-limited to 1 Hz)
a binding are SUPPRESSED from the legacy WM_CHAR/KEYUP feed** (`BTInputSuppressKey`, hook at
L4CTRL.cpp keyboard read) — this ended the historic DOUBLE-DISPATCH: 'w' drove AND selected pilot
0; F5's key-up value 0x74 aliased to the 't' hotkey; letter key-ups (uppercase) fed the developer
fake-event dispatcher. Unbound keys keep their authentic original dispatcher meaning (reachable from
fake-event dispatcher. Unbound keys keep their authentic 1995 dispatcher meaning (reachable from
any key via `pckey`). The old dual-use 'V' (view toggle + look-behind) is split: V = ViewToggle,
B = LookBehind. Verified live: bindings load, W/NumPad8 drive (spd rises), X all-stop, aux-button
emission ([input] 0x2f PRESS), suppression both directions ('r' delivered / 'w' swallowed),
@@ -227,7 +227,7 @@ each mono CRT exactly its picture. Grouping follows `L4GAUGE.CFG`: *VGA Port A*
Mfd2, *VGA Port B* = Mfd1 + Mfd3; the radar keeps real colour on its own port.
`SVGA16::ExpandPlaneToBGRA` does the per-plane extraction, and `BT_POD_RGB` implies
`BT_POD_SURFACES`. Two companion gates came out of the bring-up: **`BT_POD_CHANMAP=<Port>=<colour>,…`**
remaps a surface's colour line when a cab's harness disagrees with the original config (it did — see
remaps a surface's colour line when a cab's harness disagrees with the 1995 config (it did — see
§ALPHA-MR), and **`BT_POD_IDENT`** / **`BT_POD_CHANTEST`** paint identifying/cycling test patterns
so you can tell which physical CRT is which without trusting the desktop layout.
**SETTLED:** Nick's cart IS splitter-wired — its three 640×480 "displays" are VGA outputs, each
@@ -235,7 +235,7 @@ feeding a three-monitor splitter, and the composite is what lit the glass correc
## MFD PANELS ON REAL HARDWARE — the bring-up path (2026-08-06) [T2 local / T4 on-pod]
Nick's crash cart (pod hardware + Chrome Remote Desktop on a burner account) is the first chance
to drive the real panels. **The original display path is NOT the way in.** That rig spanned the five
to drive the real panels. **The 1995 display path is NOT the way in.** That rig spanned the five
MFDs as ONE 1280×480 surface via **NVIDIA Horizontal Span, which every driver after XP dropped**;
`SVGA16::BuildWindows` also wants an exclusive-fullscreen D3D device per adapter, which is
fragile on modern drivers and over a remote session. Both are still in-tree and still the
@@ -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
@@ -341,13 +292,13 @@ colour planes (§THE RGB SPLIT). Desktop-coordinate order says NOTHING about phy
the first assignment, made by sorting monitors left-to-right, was wrong. Only eyes on the glass
settle it.
**Two deviations from the original config, both real, both this cab's wiring:**
**Two deviations from the 1995 config, both real, both this cab's wiring:**
- **`BT_POD_CHANMAP=Comm=blue,Heat=red`** — kills/deaths and coolant loops came up on each
other's panel. `L4GAUGE.CFG` has Comm on red and Heat on blue; THIS cab's splitter harness
wires them the other way. The env override remaps a port's colour channel without touching the
authentic config file. [T2]
- **`BT_GAUGE_SEC_ROT=0`** — the radar here is a LANDSCAPE LCD, so the 640×480 source needs no
rotation. The original pod's 270° rotation existed because its secondary CRT was mounted PORTRAIT;
rotation. The 1995 pod's 270° rotation existed because its secondary CRT was mounted PORTRAIT;
it is a mounting fact, not a format fact. (En route: 180° looked plausible and was upside down.)
**Frozen in `content/environ.ini`**, not in a launcher — `scratchpad/pod/podprofile.ini` is the
@@ -439,125 +390,7 @@ 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 original 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 original player manual — alignment audit (2026-07-18) [T1, primary source]
## The 1995 player manual — alignment audit (2026-07-18) [T1, primary source]
`reference/manual/Tesla40_BT_manual.pdf` (34pp, from Nick). CONFIRMS the reconstruction on
every checked control behavior:
- **Control modes are named BAS / MID / ADV** (basic/middle/advanced; our Basic/Standard/Veteran
+3 -12
View File
@@ -9,20 +9,11 @@ key_terms: [BT, RP, MUNGA, Tesla-platform, WinTesla, pod]
# Project Overview
Port VWE's arcade **pod** games (Tesla platform, release 4.10; 1990s, exact release year unestablished) to modern Windows and,
Port VWE's arcade **pod** games (Tesla platform, release 4.10, ~1995-96) to modern Windows and,
ultimately, the actual arcade pod hardware. Primary target: **BattleTech (BT)**; **Red Planet
(RP)** shares the engine. Full detail: `docs/PROGRESS_LOG.md §1-3`.
**Dating note (2026-08-12):** the KB previously called this "the 1995 game" — WRONG, per era
witnesses (playtesters who ran the original pods say BT 4.10 shipped later). The 1995 belief
came from the MUNGA asset-reader library RCS `$Id ... 1995/..` strings inside `BTL4OPT.EXE`,
which date the *library code*, not the game; the exe carries no game-copyright year (its PE
link timestamp is stamped 2019 — a later re-link/tool artifact, not a release date), and a
content-file timestamp of 30-Nov-1995 likewise dates assets, not the release. Until a real
date surfaces (ask Nick / era witnesses), say "the original" / "the shipped machine" — never a
year. [T2 for the library strings; T4 for any specific release year]
The ORIGINAL team — the initials in the source headers matched to names by VWE veterans
The ORIGINAL 1995-96 team — the initials in the source headers matched to names by VWE veterans
(dev team ~8, art ~6, MUNGA by a smaller internal team) — is credited in **`docs/CREDITS.md`**.
## The stack
@@ -51,7 +42,7 @@ content → `content/`, raw decomp → `reference/decomp/`). [T2]
## Current state (2026-08-06) — core gameplay reconstruction COMPLETE; polish phase
Field-tested nightly at 4-8 pods over Steam (builds 4.11.622→801). Authentic + benched: the full
SP+MP loop; per-panel mesh-true targeting/damage (+ crits, cylinder lottery, armour darkening);
the original SCORING model (kill awards + received penalties + panic cost — the id-0x16 report tail);
the 1995 SCORING model (kill awards + received penalties + panic cost — the id-0x16 report tail);
death (blast/splash, wreck, burial) + respawn (full re-arm audit); locomotion (two-channel gait,
gimp limp, knockdowns, CROUCH F4); the night kit (fog-swap SEARCHLIGHT F5 + beam cone); heat/
power/myomers (incl. the seek-4 freeze); replication (masters/replicants, kill/death columns,
+15 -166
View File
@@ -12,7 +12,7 @@ open_questions:
# Reconstruction Gotchas
The reconstruction is a **layout + linkage** problem as much as a logic problem. Our compiled
classes are NOT byte-identical to the original binary, and the BT link uses `/FORCE`, so a whole
classes are NOT byte-identical to the 1995 binary, and the BT link uses `/FORCE`, so a whole
family of bugs is **silent** — garbage that happens to be non-fatal, or a runtime AV with no
link error. When a reconstructed class misbehaves, walk this checklist FIRST; the answer is
usually here, not in the logic.
@@ -65,7 +65,7 @@ every hit is either dead code or a vanished cache.
## 3. Databinding trap — raw offsets read garbage
Our compiled layout != the original binary, so `*(T*)(obj+0xNN)` reads garbage for ANY object we
Our compiled layout != the 1995 binary, so `*(T*)(obj+0xNN)` reads garbage for ANY object we
compile. This is WHY shadow fields fail and why raw subsystem reads (e.g. a gauge reading
`owner+0x438`) return junk. **Fix:** use compiled named members/accessors; for a cross-TU raw
op, use a **bridge** (§8). A `+0x128`-style owner offset in subsystem code is the
@@ -133,7 +133,7 @@ unresolved-by-design until bridged per-module ([[open-questions]]). **Rule: when
wall exists, `tail` on the build output hides the fleet — always grep the FULL log; and verify a
stub's member TYPE against `dumpbin /symbols` of the defining obj, not against what looks right.**
**Duplicate-GLOBAL corollary (glass per-display windows, 2026-07-20):** a global DEFINED in two
libs (the original headers declare free globals without `inline`/`extern`, so `application`,
libs (the 1995 headers declare free globals without `inline`/`extern`, so `application`,
`ghWnd`, … exist in BOTH `munga_engine` and `bt410_l4`) links under `/FORCE:MULTIPLE` with
per-object binding that is **non-deterministic across links** — a given `.obj` can resolve
`application` to the copy the game ASSIGNS (correct) or to the other copy (stays NULL). A
@@ -203,7 +203,7 @@ real PoweredSubsystem). Keep the alloc SIZE + special-cache when swapping a fact
always use it. Related trap: the dense handler table (Build indexes slots by id-1) leaves
GAP slots (skipped ids) as uninitialized heap -- the NAME-based `Find(const char*)`
strcmp-walks every slot and AVs on a gap's garbage entryName (the id-based Find is safe).
The original binary's own tables carry the same holes. [T2]
The 1995 binary's own tables carry the same holes. [T2]
## 10. Container-Execute must override (gauges)
@@ -242,7 +242,7 @@ Trigger: the streamed Eng-page `.CTL` dispatches subsystem msg **id 3 / 0xb** to
subsystem is shown on that MFD page; a **weapon (Emitter)** registers only PoweredSubsystem 4-8 +
MechWeapon 9-10, so id 3 (slot[2], < entryCount 10) is a gap. Clicking the glass Engineering
panel button 0x21 on an Eng page reached it live (`eip=cdcdcdcd` debug / `0x01048748` release).
**The original binary has the IDENTICAL non-zeroing `new[]`** (part_002.c Build, `FUN_004022b0` =
**The 1995 binary has the IDENTICAL non-zeroing `new[]`** (part_002.c Build, `FUN_004022b0` =
operator new) and only survived on fresh-OS-heap-zero luck (a zero slot == NullHandler ==
ignored) — a weapon receiving id 3 was *always meant to ignore it* (id 3 = a Condenser/Reservoir
action in a different subsystem branch; the Eng-page button template is uniform, so buttons for
@@ -262,7 +262,7 @@ Receive`, suspect a gap slot, not a missing symbol. [T2]
## 12. Frame-pacing trap — the binary assumes a LOCKED 60 fps (task #11)
The original pod ran frame-locked; reconstructed per-frame logic can carry HIDDEN frame-rate
The 1995 pod ran frame-locked; reconstructed per-frame logic can carry HIDDEN frame-rate
assumptions that variable dt violates. Archetype: the Emitter Loading tick — the charge
integrates toward the generator's 10000V and the Loading→Loaded transition only fires while
`rechargeLevel` crosses the ±0.01 snap window around seekV (~0.25s of travel ≈ 15 pod frames —
@@ -366,10 +366,10 @@ corrupts the metric.
canopies are open lattices. Namespace edge keys by patch identity (and remember l/r patches are
MIRRORED — winding handedness flips, so no global winding choice can be right; orient per-face).
## 16. Engine-facility drift: 2007 terrain-solids amplify original per-contact physics (the MP ram one-shot)
## 16. Engine-facility drift: 2007 terrain-solids amplify 1995 per-contact physics (the MP ram one-shot)
`Mover::StaticBounce` [T0] MUTATES `worldLinearVelocity` (`+= delta_v`, a ×(1+e) reflection) on
every call, and `ProcessCollisionList` calls it once PER CONTACTED SOLID in the frame. In the original
every call, and `ProcessCollisionList` calls it once PER CONTACTED SOLID in the frame. In the 1995
binary the ground was a heightfield probe (FUN_0040e5f0 lineage) — never a collision-list entry —
so a mech's list held ~one solid and the mutation was harmless. The 2007 WinTesla engine models
TERRAIN AS COLLISION SOLIDS: a mech touching ground + rock + another mech reflects 2-4× in ONE
@@ -387,7 +387,7 @@ one-shots (mp_a.log:32651, 2026-07-12: a pristine mech killed by a walking bump)
~13-62), CONSTANT repeated values (a stable grind oscillation), or spikes scaling with how many
solids surround the contact. Damage = `0.0005 × (1e²) × impact² × moverMass` [T0 MOVER.cpp] —
invert it to read the implied impact speed; >100 m/s means amplified/garbage velocity, not motion.
- **Class rule:** when an original per-event computation reads MUTABLE engine state, audit what ELSE the
- **Class rule:** when a 1995 per-event computation reads MUTABLE engine state, audit what ELSE the
2007 engine feeds that state within the same event batch. (Family of gotcha #12's frame-pacing
trap: the binary's physics assumed its own engine's event granularity.)
@@ -435,7 +435,7 @@ DontExecute). **Rule:** zero every hand-built resource struct; never rely on a p
the tester's `'\'` dev key instantly "crashed the game". **Cause:** `Entity::Dispatch`
(ENTITY.cpp:236) WRITES `entityID` + `interestZoneID` into the incoming message at
`Entity::Message` offsets before routing. A bare `ReceiverDataMessageOf<T>` (Receiver-sized) on
the stack gets written PAST ITS END → the caller's frame is corrupted → wild jump. The original
the stack gets written PAST ITS END → the caller's frame is corrupted → wild jump. The 1995
binary performs the identical overwrite and survived on stack-layout luck. Subsystem/Receiver
`Dispatch` does NOT stamp — only the Entity level. **Rule:** any message dispatched at an
`Entity` must have `Entity::Message`-sized backing (derive from `Entity::Message`, or placement-
@@ -554,7 +554,7 @@ The Ghidra export (`reference/decomp/`) has coverage gaps (#60), and BTL4.RES co
appears in it at all — so "no readers", "no caller", "no such clip/asset", and "unreconstructed"
claims made by grepping the export or the port ALONE are structurally unsound. This class has
produced four wrong conclusions, two of them [T1]-tagged at the time:
1. **2026-07-25 searchlight "original latent bug"** — compared Searchlight's Performance against
1. **2026-07-25 searchlight "1995 latent bug"** — compared Searchlight's Performance against
THERMALSIGHT's handler (wrong class) and invented a missing bridge; retracted (#61).
2. **2026-07-2x `DuckState` "has no CODE consumer, authentic [T1]" + "no SQUAT clip survives"**
the consumer was the un-exported master-perf posture block (@0x4aa011), and the clips lived in
@@ -596,7 +596,7 @@ was never a regression -- it shipped with the feature.)
**The bug class:** a piece of state is installed around a resource load, mutates the load's
*output*, and is then torn down — so any LATER re-load of that resource silently produces a
different (wrong) result. Our port re-loads things the original engine loaded once, which is
different (wrong) result. Our port re-loads things the 1995 engine loaded once, which is
exactly where this bites.
**The archetype — mech paint.** The per-pilot colour/badge/patch is applied by REWRITING
@@ -755,7 +755,7 @@ treat the disagreement as a hole in YOUR evidence first, not in theirs.
## §22 — The SPLIT CELL: one binary offset, two port members (only one gets written)
**(2026-07-31, gitea #86 "destroyed weapons keep firing".)** The binary's weapon fire gates test
`subsystem+0x40`. In the original layout that offset is *inside* the embedded status alarm
`subsystem+0x40`. In the 1995 layout that offset is *inside* the embedded status alarm
(`statusAlarm@0x2C` + the indicator's level at `+0x14` = `0x40`) — **one cell**, written by
`ForceCriticalFailure` when a zone's crit-cascade kills the subsystem. The port models the same
address as TWO independent members: `AlarmIndicator statusAlarm` **and** a plain
@@ -773,13 +773,13 @@ base field): same failure shape — two cells where the binary has one, and the
dead one.
## §23 — Scaling `D3DMATERIAL9` does NOTHING for BGF geometry (the vertex-colour diffuse source)
**(2026-07-31, gitea #87 "mech armour panels don't darken".)** The original armour damage darkens a
**(2026-07-31, gitea #87 "mech armour panels don't darken".)** The 1995 armour damage darkens a
mech by scaling its MATERIALS' colour terms (`dpl_SetMaterialAmbient/Emissive/Diffuse/Specular`).
The obvious port translation — scale the draw op's `D3DMATERIAL9` before `SetMaterial` — renders
**byte-identically**: measured **0 changed pixels** at a full 0.1x scale.
**Why:** D3D9 defaults `D3DRS_DIFFUSEMATERIALSOURCE` to **`D3DMCS_COLOR1`** (and `D3DRS_COLORVERTEX`
to TRUE), so when a vertex carries a diffuse colour the material's Diffuse is never consulted.
Every BGF vertex carries a baked colour (that IS the original shading model — no-normal geometry is
Every BGF vertex carries a baked colour (that IS the 1995 shading model — no-normal geometry is
unlit and coloured by vertex/ramp), so for essentially all world geometry `SetMaterial` is inert
for colour. The material only matters for lit, normal-bearing meshes.
**Fix pattern:** modulate the FINAL fragment instead — `D3DRS_TEXTUREFACTOR` + a
@@ -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 original 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).
+5 -5
View File
@@ -73,7 +73,7 @@ DECLOUDS must stay in the sky pass). Authored TEXTURE **SCROLL** (BMF TEXTURE ta
`SetFogStyle(searchLightOn/OffFogStyle)`). TWO independent reasons it never fires:
(1) **Port:** `PullFogRenderable` is NEVER CONSTRUCTED (grep: only its .h decl + .cpp def; no `new`);
`searchLightOn/Off` is called from nowhere else (BT_FOG_LOG: over 22s only `SetFogStyle(0)`, zero 2/3).
(2) ~~**Original LATENT BUG**~~ — ❌ **RETRACTED 2026-07-25 (#61). There is no original-binary bug.** The
(2) ~~**Original 1995 LATENT BUG**~~ — ❌ **RETRACTED 2026-07-25 (#61). There is no 1995 bug.** The
claim rested on a **swapped table attribution**: @004b860c is **ThermalSight's** `ToggleLamp`
(table @0x51120C), not Searchlight's, so pitting its `0x1DC` write against Searchlight's
`SearchlightSimulation` (@004b841c, reads `requestedOn`@0x1E0) compared two different classes and
@@ -154,7 +154,7 @@ The per-weapon impact visuals are fully AUTHORED in `content/BTDPL.INI` `[effect
CannonHit/GroundHit/MinorHit/MajordHit/Fireball — "old-style" (version-1) descriptor sections
(`texture/type/size/velocity/v_bias/y_off/cook(3f)/varience/gravity/cool(2f)/count/repeats`),
most on `btfx:firesmoke1_scr_tex` (MissileHit size 12 ×7; Fireball size 14, gravity 1.5 rises;
Chunks1/2 = ballistic debris vel 40-55 grav 34-35; Sparks NOTEX). [T1: the original binary parses
Chunks1/2 = ballistic debris vel 40-55 grav 34-35; Sparks NOTEX). [T1: the 1995 binary parses
them — part_008.c:1546-1590, field strings @004f25e4-004f2663 → installer FUN_00490210 packs a
0x44-byte board command via FUN_0048e2c0.] **Simulation semantics (type/cook/cool) live in the
i860 firmware** `content/VREND.MNG` (same decode workflow as the task-#55 'damageize' handler).
@@ -331,7 +331,7 @@ A mech's armour panels **darken as their damage zone takes damage** — a per-zo
colour ramp-down, NOT a texture swap, a filter, or geometry. Players reported the port showing only
the limb swap ("the actual enemy mech in external view is not showing darkened armor panels").
**The original chain** [T1]:
**The 1995 chain** [T1]:
- **`.DZM`** (`VIDEO\<mech>SKIN.DZM`, 40 shipped) lists, per damage zone, the materials that zone
paints: `[dz_ltorso] material=avaskin:avat2_dz_ltorso_mtl`. One file per SKIN VARIANT — the
`dzm`/`dzms`/`dzmt`/`dzmo`/`dzma`/`dzmb`/`dzmc`/`dzmd` key family (same suffix set as the
@@ -400,7 +400,7 @@ the VISUALS were the gap, decoded from MakeMechRenderables @004cef28 `case 0xbd8
reads as a modest push-back of the night murk at mid/long range, NOT a flashlight. Geometry
brightness comes from the page's ambient= + ramps and never changes with the lamp -- a close
wall is identical lit/unlit. First eyeball matched exactly ("kinda sorta illuminates the
area... didn't illuminate the wall" -- that IS the original behavior). No illuminating dynamic
area... didn't illuminate the wall" -- that IS the 1995 behavior). No illuminating dynamic
light exists anywhere in BT's render path: ChildLightRenderable has ZERO instantiations
engine-wide, and the FUN_0048d060 cluster (@4568f8/@456a0c/@456b4c/@4586d0) is light-POINT
sprites (beacons/nav strobes, palette-tinted), not geometry lights. Set player expectations
@@ -409,7 +409,7 @@ the VISUALS were the gap, decoded from MakeMechRenderables @004cef28 `case 0xbd8
the searchlight SITE joint (mount = subsystem `mountSegment`@0x1DC == resource segmentIndex;
madcat 20, loki 21), shown/hidden per frame from the replicated LightOn (@0045612c watcher ->
the SetDrawObj swap). Site segments now build geometry-less DCS children (posed + parentable --
the original graph gave every site a DCS node; our tree used to skip sites entirely; this also
the 1995 graph gave every site a DCS node; our tree used to skip sites entirely; this also
enables future site-hung effects). Wrecks go dark through the authentic gate (host shutdown
forces lightState 0). Cross-node verified: B presses, A logs `[spot] cone SHOWN (seg 20)`.
- Benches: `scratchpad/night12/searchfog.sh` (solo cockpit chain + red-fog end-to-end probe),
+5 -5
View File
@@ -6,7 +6,7 @@ source_sections: "PROGRESS_LOG.md §5a; docs/BT_SOURCE_STATUS.md"
related_topics: [wintesla-port, reconstruction-method, decomp-reference, open-questions]
key_terms: [BTL4OPT, decomp, oracle, WinTesla, BT]
open_questions:
- "Backfill the missing original engine headers/TUs (vdata.hpp first) by back-dating the WinTesla/BT412 versions — the compile-closure blocker"
- "Backfill the missing 1995 engine headers/TUs (vdata.hpp first) by back-dating the WinTesla/BT412 versions — the compile-closure blocker"
---
# Source Completeness — the gating blocker
@@ -20,7 +20,7 @@ This is THE gating fact that shapes the whole project. Full map: `docs/BT_SOURCE
`.cpp` (PLAYER, NETWORK, GRAPH2D, OBJSTRM, SCNROLE, EXPLODE, L4SPLR, L4APP + ~15 smaller) AND at
least one missing engine HEADER (`vdata.hpp`, included by plug.hpp — exists only as the drifted
WinTesla/BT412 `VDATA.h`). `CODE/BT/MUNGA` is a PARTIAL BT-drift overlay (69 files) over the
fuller `CODE/RP/MUNGA` (305); an original-era build needs the merged BT-over-RP view. [T1]
fuller `CODE/RP/MUNGA` (305); a 1995 build needs the merged BT-over-RP view. [T1]
- **BT game `.hpp` headers: PARTIAL, not complete** (2026-07-19 correction — the old "all headers
present" claim was WRONG): only 17 of ~36 bt-side `.hpp` survive (mech.hpp, mech2-4, heat.hpp,
mechmppr.hpp, torso.hpp, gyro.hpp, hud.hpp, myomers.hpp, powersub.hpp, btplayer.hpp are
@@ -78,11 +78,11 @@ projtile, missile); toolchain = **Borland BCC/tlib/tlink32** (from the makefiles
targets** for future drive dumps. Full log: `phases/phase-03-bt410-source-manifest.md`.
## The literal 4.10 tree — `TeslaRel410/restoration/source410/` (started 2026-07-18)
The archival-grade recreation of the missing TUs in the original house style against the surviving
original engine headers (distinct from OUR WinTesla port). Rules + status in its README; every
The archival-grade recreation of the missing TUs in 1995 house style against the surviving
1995 engine headers (distinct from OUR WinTesla port). Rules + status in its README; every
file carries a `.NOTES.md` evidence sidecar; binary `Fail()` file:line records are treated
as LINE CONSTRAINTS. **Pilot complete: `BT_L4/BTL4APP.CPP`** — 12/12 functions, all decomp
constants resolved to named original expressions (`ApplicationID` BTL4=1 + an unreleased `BTW4`
constants resolved to named 1995 expressions (`ApplicationID` BTL4=1 + an unreleased `BTW4`
slot in APPMSG.HPP; `ModeInitial`=0x50421; `AddModeMask` inlines byte-exact), Fail lands on
its recorded line 400 exactly. [T1/T3 per the sidecar]
+4 -43
View File
@@ -48,7 +48,7 @@ Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER
default-constructed blackholes (systemic cause #1, `docs/INPUT_PATH_AUDIT.md`). Each now chains
`PowerWatcher::GetMessageHandlers()` with its own id-3 `ToggleLamp`. **Verified live**: pad `0x14`
`requestedOn 0→1``lightState 0→1`; pad `0x12``requestedOn 0→1``thermalActive 0→1`.
- ❌ **RETRACTED: the "ORIGINAL latent bug" (old task #63) NEVER EXISTED.** It was an artifact of
- ❌ **RETRACTED: the "ORIGINAL 1995 latent bug" (old task #63) NEVER EXISTED.** It was an artifact of
a **swapped table attribution**: the claim compared Searchlight's Performance (@004b841c, reads
`requestedOn`@0x1E0) against **ThermalSight's** `ToggleLamp` (@004b860c, toggles `0x1DC`) and
concluded "no 0x1DC→0x1E0 bridge, so the lamp can never light". @004b860c is not Searchlight's.
@@ -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,
@@ -131,7 +128,7 @@ Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER
thermalMass=2.5e5. Live-verified (solo arena, BT_MYO_LOG): standstill ≈ 0 generation, hard
circling drove T 77→1225 (past degradation) and the coolant loop pulled it back to ~520
equilibrium on slowing. ⚠ Two carried caveats: (1) the kinetic work term has **no dt** in the
binary (per-tick accumulate, an original fixed-frame assumption) → heat/s scales mildly with frame
binary (per-tick accumulate, a 1995 fixed-frame assumption) → heat/s scales mildly with frame
rate; kept verbatim, field-calibrate against pod veterans. (2) the climb term is wired but
inert: `Mover::localEnvironment` is never populated in the port (EnvironmentZone res type 23
unwired), so g reads 0 — see [[open-questions]].
@@ -160,7 +157,7 @@ Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER
thermal curve and zone damage; `@004b8ceb` run the inner integrator **only when `outputVoltage > 0`**.
Verified live: healthy `outV=10000 speed=1`, un-powered `outV=0 speed=0`, and 96/96 torso samples at
`elec=4` across two death/respawn cycles.
✅ **CORRECTED 2026-07-29 (#80): `@004b8bb9` — the un-powered self-repair — is LIVE, in the original and
✅ **CORRECTED 2026-07-29 (#80): `@004b8bb9` — the un-powered self-repair — is LIVE, in 1995 and
now in the port.** The 2026-07-28 "dead code in the original too" verdict here was wrong about the
original: the frozen-at-0.6 experiment was measuring a PORT bug (the subsystem ctor wrote the
zone's armour/scales through the `ReconDamageZone` proxy at struct offsets +4/+8 instead of the
@@ -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 -19
View File
@@ -204,25 +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` |
| `fade_bench.sh` | #156 fade: armed + phases 1-5 both nodes, single 3.0s countdown, end edge on simState=4 | relay-clocked 40s round, BT_FADE_LOG |
| `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)
+2 -2
View File
@@ -12,7 +12,7 @@ open_questions:
# Translocation warp (the respawn vortex)
The "blue whirlwind" respawn effect from the original pod: on death the pilot is sucked into a spinning
The "blue whirlwind" respawn effect from the 1995 pod: on death the pilot is sucked into a spinning
energy vortex; on respawn it blows outward and the reborn mech's world snaps back. VWE calls it
**translocate** (not "whirlwind" — that keyword search is why an earlier scout wrongly declared it
"not a shipped asset"). It is a pure **render-side** effect watching the player's `SimulationState`
@@ -37,7 +37,7 @@ The loader **doubles** every tri (fwd `a b c` + rev `a c b`) → 80 tris (`emitT
`tessellateWarpCone()` subdivides 4:1 × `BT_WARP_TESS` (default 3) projecting each new midpoint onto
the true cone surface (radius `10.5*(1-|z|/21)`) → ~5120 smooth tris. Untessellated, the 12 flat
facets read as a "12-sided funnel"; tessellation is what makes the swirl smooth on a modern display.
(The original low-res CRT + phosphor + fast spin hid the facets for free.)
(The 1995 low-res CRT + phosphor + fast spin hid the facets for free.)
## The material — `btfx:tsphere_mtl` (in `VIDEO/MAT/<tod>/BTFX.BMF`) [T1]
`tsphere_mtl` → texture **`tsphere_scr_tex`** (a scrolling texture, SPECIAL `SCROLL 0.0 0.0 0.1 0.5`) →
+2 -2
View File
@@ -50,7 +50,7 @@ on top of it. Full detail: `docs/PROGRESS_LOG.md §5b, §8`.
`alDeleteSources`) is working. A dropped voice there competes with ~256 already sounding, so it is
very likely sub-perceptual — treat the log noise as a symptom to watch, **not** as the old bug.
Mechanism if it ever does matter: `alcCreateContext(device, NULL)` passes no attribute list, so
OpenAL Soft applies its DEFAULT 256 mono sources (an OpenAL default, unrelated to the original audio
OpenAL Soft applies its DEFAULT 256 mono sources (an OpenAL default, unrelated to the 1995 audio
hardware), while a busy match logs ~7200 explosions × 3 `DPLIndependantEffect` voices.
**Raising the cap is deliberately OPT-IN (`BT_AUDIO_SOURCES=<n>`, no rebuild needed)** — the 256
ceiling doubles as a **governor**. The steal loop only steals when the incoming source outranks a
@@ -224,7 +224,7 @@ on top of it. Full detail: `docs/PROGRESS_LOG.md §5b, §8`.
(3) Failed acquisitions stranded partial sets forever — dropped transients are never released by
anything (the dtor didn't release; maintenance only touches running sources). Fix: handback via
`ReleaseChannels()` at both failure sites (StartRequest + dormant-resume) + a dtor backstop.
Context: the port's `RequestAudioChannels` has NO channel budget (the original per-card budget is
Context: the port's `RequestAudioChannels` has NO channel budget (the 1995 per-card budget is
the commented-out block) — it alGenSources until OpenAL Soft's ~256 cap; on failure a transient
is silently dropped (StartRequest early-return), a sustained goes dormant — which is exactly the
"cutting in and out" presentation. Priority-steal (`RequestAudioResources`) only engages at cap.
+1 -1
View File
@@ -696,7 +696,7 @@ reconfigure/externalConfigure); parse-skip list EMPTY ([gskip]=0), all 50 attr b
| 30 | sec: schematic CRITICAL view (cmCrit) | subsystem simulationState/damage | LIVE this audit: N-cycle shows the full subsystem list (GEN A-D, LOOP 1-6, HUD, SENSORS, GYRO, TORSO, weapons) | T2 | CORRECT |
| 31 | sec: schematic HEAT view (cmHeat) | subsystem currentTemperature tint | #6 pixel-verified; re-cycled this audit (mask 0x450421→0x490421→0x510421) | T2 | CORRECT |
| 32 | sec: view cycling (N / pod 0x15) | CycleDisplayMode → vtbl+0x4C @4d1ae4 | #6 resolution re-verified live ([mode] display notify 0/1/2) | T2 | CORRECT |
| 33 | sec: CONTROL MODE lamp (BAS/MID/ADV) | ControlsMapper/**ControlMode** oneOfSeveralPixInt (row previously mislabeled DisplayMode — that is the sibling sdspmod) | attr wave incr.5; M-cycle verified #6. **2026-08-10:** this row's verification PREDATED the 07-25 #48 cycle-fill, which then lit the inactive box interiors (art idx 254 → live armor slot 62) — playtester-reported, re-fixed by mapping out-of-range indices to plane background (see context/gauges-hud.md §#48 REFINED); both render paths re-verified against the reference | T2 | CORRECT |
| 33 | sec: CONTROL MODE lamp (BAS/MID/ADV) | ControlsMapper/DisplayMode oneOfSeveralPixInt | attr wave incr.5; M-cycle verified #6 | T2 | CORRECT |
| 34 | sec: duck / searchlight button lamps | duckState (ctor-zeroed only) / Searchlight LightOn | duckState writer missing (P3 leftover) | T3 | DEFERRED-FEED (duck); CORRECT (light attr published) |
| 35 | sec: messageBoard ticker | StatusMessagePool (NULL stub) + kill ticker strip 0 | 7fc4acb; kill ticker live 2026-07-12, other strips unsurveyed | T2/T3 | DEFERRED-FEED (partial) |
| 36 | MFD preset paging (J/K/L, pod RIO banks) | SetPresetMode table @0051dbf0 (little-endian re-decode) | #9; re-verified live this audit: cycles visit EXACTLY the populated set (MFD1: 1,2,4; MFD2: 1-4; MFD3: 1,2) | T2 | CORRECT |
-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.
-290
View File
File diff suppressed because one or more lines are too long
-638
View File
@@ -1,638 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BattleTech 4.11 &mdash; the mech roster</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
:root {
--ground: #0e0f11; --panel: #16181b; --rule: #2a2e33;
--ink: #e8e6e2; --ink-mid: #a3a8ae; --ink-low: #71777e;
--hot: #f39313; /* palette 6, the display's own amber */
--nrg: #ff5340; /* energy weapons */
--bal: #f39313; /* ballistic */
--msl: #58c470; /* missiles -- the armor doll's green family */
--mono: ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace;
--sans: ui-sans-serif, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
@media (prefers-color-scheme: light) {
:root {
--ground: #f4f2ee; --panel: #fbfaf8; --rule: #ddd8d0;
--ink: #14161a; --ink-mid: #565c63; --ink-low: #7d838a;
--hot: #a35f00; --nrg: #b3260f; --bal: #a35f00; --msl: #1c7a3f;
}
}
body {
margin: 0; background: var(--ground); color: var(--ink);
font: 400 15px/1.6 var(--sans);
}
.wrap { max-width: 78rem; margin: 0 auto; padding: 0 clamp(1rem, 4vw, 2.5rem); }
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
:focus-visible { outline: 2px solid var(--hot); outline-offset: 2px; }
header.top { border-bottom: 1px solid var(--rule); padding: clamp(2.5rem,6vw,4rem) 0 1.75rem; }
.eyebrow {
font: 500 12px/1 var(--mono); letter-spacing: .2em; text-transform: uppercase;
color: var(--ink-low); margin: 0 0 1rem;
}
h1 {
font: 700 clamp(1.9rem,1.4rem+2.2vw,3rem)/1.05 var(--mono);
letter-spacing: -.02em; text-transform: uppercase; margin: 0 0 .9rem;
}
h1 em { font-style: normal; color: var(--hot); }
.lede { max-width: 62ch; color: var(--ink-mid); margin: 0 0 1.4rem; font-size: 1.05rem; }
.lede strong { color: var(--ink); font-weight: 600; }
.rules { display: flex; flex-wrap: wrap; gap: .6rem 2rem; margin: 0; padding: 0; list-style: none; }
.rules li {
font: 500 12px/1.5 var(--mono); color: var(--ink-mid);
padding-left: 1.1rem; position: relative;
}
.rules li::before {
content: ""; position: absolute; left: 0; top: .45em;
width: .45rem; height: .45rem; background: var(--hot);
}
.rules b { color: var(--ink); font-weight: 700; }
.bar {
position: sticky; top: 0; z-index: 5;
background: var(--ground);
border-bottom: 1px solid var(--rule); padding: .75rem 0;
}
.bar__in { display: flex; flex-wrap: wrap; gap: .75rem 1.25rem; align-items: center; }
.seg { display: inline-flex; border: 1px solid var(--rule); }
.seg button {
font: 500 12px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase;
background: none; border: 0; color: var(--ink-mid); padding: .55rem .9rem; cursor: pointer;
}
.seg button[aria-pressed="true"] { background: var(--hot); color: var(--ground); }
.search {
flex: 1 1 12rem; min-width: 9rem;
font: 400 12px var(--mono);
background: var(--panel); color: var(--ink);
border: 1px solid var(--rule); padding: .55rem .7rem;
}
.search::placeholder { color: var(--ink-low); }
.count { font: 500 12px var(--mono); color: var(--ink-low); letter-spacing: .1em; }
.key { display: flex; flex-wrap: wrap; gap: .4rem .9rem; padding: 1.25rem 0 0; }
.key span { display: inline-flex; align-items: center; gap: .4rem;
font: 500 12px var(--mono); letter-spacing: .08em; color: var(--ink-low); }
.key i { width: .7rem; height: .7rem; display: inline-block; }
.roster { display: grid; gap: 1px; background: var(--rule); border: 1px solid var(--rule);
margin: 1.75rem 0 3rem; }
.mech { background: var(--panel); display: grid; gap: 0;
grid-template-columns: minmax(15rem, 19rem) 1fr; }
@media (max-width: 46rem) { .mech { grid-template-columns: 1fr; } }
.mech[hidden] { display: none; }
.mech__id { padding: 1.1rem 1.25rem 1.35rem; border-right: 1px solid var(--rule); }
@media (max-width: 46rem) { .mech__id { border-right: 0; border-bottom: 1px solid var(--rule); } }
.hull { margin: 0 0 .9rem; display: flex; flex-direction: column; gap: .45rem;
align-items: flex-start; }
/* The game's own review image on the display's own black, either theme. */
.hull__img {
display: block; width: 7rem; height: auto;
background: #000; border: 1px solid var(--rule); image-rendering: pixelated;
}
.hull figcaption {
font: 500 11px/1.35 var(--mono); letter-spacing: .1em;
text-transform: uppercase; color: var(--ink-low);
}
.mech__id h3 {
font: 700 1.5rem/1 var(--mono); letter-spacing: .01em;
margin: 0 0 .55rem; text-transform: uppercase;
}
.mech__key {
font: 500 12px/1 var(--mono); letter-spacing: .12em;
color: var(--ink-low); margin: -.3rem 0 .7rem;
}
.mech__load { display: flex; flex-wrap: wrap; gap: .3rem; margin: 0 0 .7rem; }
.chip {
font: 500 11px/1 var(--mono); letter-spacing: .08em;
padding: .3rem .45rem; border: 1px solid currentColor;
}
.chip--e { color: var(--nrg); }
.chip--b { color: var(--bal); }
.chip--m { color: var(--msl); }
.mech__meta { display: flex; flex-wrap: wrap; gap: .8rem; margin: 0 0 .2rem;
font: 400 12px var(--mono); color: var(--ink-low); font-variant-numeric: tabular-nums; }
.spec {
margin: .75rem 0 0; display: grid; grid-template-columns: 1fr auto;
gap: .2rem .8rem; font: 500 12px var(--mono);
}
.spec div { display: contents; }
.spec dt { color: var(--ink-low); letter-spacing: .06em; }
.spec dd { margin: 0; text-align: right; color: var(--ink); font-variant-numeric: tabular-nums; }
.mech__sheet { margin: .6rem 0 0; font: 400 11px/1.5 var(--mono); color: var(--ink-low); }
.mech__tables { min-width: 0; }
.tablewrap { overflow-x: auto; }
table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; }
thead th {
font: 500 12px/1 var(--mono); letter-spacing: .14em;
color: var(--ink-low); text-align: left; font-weight: 500;
padding: .9rem .9rem .55rem; border-bottom: 1px solid var(--rule); white-space: nowrap;
}
tbody th {
font: 500 13px/1.3 var(--mono); text-align: left; color: var(--ink);
padding: .5rem .9rem; border-bottom: 1px solid var(--rule); white-space: nowrap;
}
tbody td {
font: 400 13px/1.3 var(--mono); color: var(--ink-mid);
padding: .5rem .9rem; border-bottom: 1px solid var(--rule); white-space: nowrap;
}
tbody tr:last-child th, tbody tr:last-child td { border-bottom: 0; }
th.num, td.num { text-align: right; }
.empty-state {
margin: 2rem 0; font: 500 13px var(--mono); color: var(--ink-low);
}
.foot {
border-top: 1px solid var(--rule); padding: 1.5rem 0 0; max-width: 76ch;
color: var(--ink-low); font-size: 13px;
}
.foot:last-child { padding-bottom: 4rem; }
.foot code { font-family: var(--mono); color: var(--ink-mid); }
</style>
</head>
<body>
<header class="top">
<div class="wrap">
<p class="eyebrow">BattleTech 4.11 &middot; from btl4fe.cpp, L4GAUGE.CFG and the player's manual</p>
<h1>Every mech,<br><em>and what it carries</em></h1>
<p class="lede">All <strong>18 mechs the menu offers</strong> &mdash; 8 base chassis
and 10 variants &mdash; each with its authored weapons roster, ammo bins and the
stat sheet from the original player's manual. The portraits are the game's own
mission-review images, shown in their undamaged state.</p>
<ul class="rules">
<li><b>Variants share the chassis</b>, not the loadout</li>
<li><b>Blackhawk, Nova, Owens, Kotori</b> have a fixed torso</li>
<li>The manual sheets cover the <b>six 4.0 hulls</b> only</li>
</ul>
</div>
</header>
<div class="bar">
<div class="wrap bar__in">
<div class="seg" role="group" aria-label="Filter">
<button type="button" data-filter="all" aria-pressed="true">All 18</button>
<button type="button" data-filter="base" aria-pressed="false">Base 8</button>
<button type="button" data-filter="variant" aria-pressed="false">Variants 10</button>
</div>
<input id="search" class="search" type="search" placeholder="Search mech or weapon&hellip;"
aria-label="Search mech or weapon">
<span class="count" id="count">18 / 18 mechs</span>
</div>
</div>
<main class="wrap">
<div class="key">
<span><i style="background:var(--nrg)"></i>Energy</span>
<span><i style="background:var(--bal)"></i>Ballistic</span>
<span><i style="background:var(--msl)"></i>Missile</span>
</div>
<div class="roster">
<article class="mech" data-tags="base" data-search="blkhawk black hawk er large laser er medium laser er small laser srm 6 erllaser ermlaser erslaser srm6">
<header class="mech__id">
<figure class="hull">
<img class="hull__img" src="data:image/png;base64,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" width="158" height="203"
alt="Black Hawk mission-review portrait" loading="lazy">
<figcaption>Black Hawk chassis &middot; with Nova</figcaption>
</figure>
<h3>Black Hawk</h3>
<p class="mech__key">blkhawk &middot; base chassis</p>
<p class="mech__load"><span class="chip chip--e">ER LARGE LASER</span><span class="chip chip--e">5&times; ER MEDIUM LASER</span><span class="chip chip--e">2&times; ER SMALL LASER</span><span class="chip chip--m">2&times; SRM 6</span></p>
<p class="mech__meta"><span>10 weapons</span><span>2 ammo bins</span><span>torso FIXED</span></p>
<dl class="spec"><div><dt>Tonnage</dt><dd>50 t</dd></div><div><dt>Armor points</dt><dd>1362</dd></div><div><dt>Reservoir</dt><dd>1600 L</dd></div><div><dt>Heat sinks</dt><dd>16</dd></div><div><dt>Torso</dt><dd>FIXED</dd></div><div><dt>0&ndash;100 kph</dt><dd>5.1 s</dd></div><div><dt>Top speed</dt><dd>190 kph</dd></div><div><dt>Super charged</dt><dd>280 kph</dd></div><div><dt>Gimped</dt><dd>60 kph</dd></div></dl>
<p class="mech__sheet">manual p. 29</p>
</header>
<div class="mech__tables"><div class="tablewrap"><table>
<caption class="sr-only">Weapons carried by the Black Hawk</caption>
<thead><tr><th scope="col">WEAPON</th><th scope="col">TYPE</th>
<th scope="col" class="num">COUNT</th><th scope="col" class="num">AMMO BIN</th></tr></thead>
<tbody><tr><th scope="row">ER Large Laser</th><td>Energy</td><td class="num">1</td><td class="num">&mdash;</td></tr><tr><th scope="row">ER Medium Laser</th><td>Energy</td><td class="num">5</td><td class="num">&mdash;</td></tr><tr><th scope="row">ER Small Laser</th><td>Energy</td><td class="num">2</td><td class="num">&mdash;</td></tr><tr><th scope="row">SRM 6</th><td>Missile</td><td class="num">2</td><td class="num">2&times;</td></tr></tbody>
</table></div></div>
</article>
<article class="mech" data-tags="base" data-search="loki loki er medium laser er small laser afc 50 srm 4 ermlaser erslaser afc50 srm4">
<header class="mech__id">
<figure class="hull">
<img class="hull__img" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJ4AAADLCAMAAACoGNoNAAADAFBMVEUAAAD/AAAP/wdjY2NTU1PDYxOfn58A4wD/AAD/05f///9jY9fzgwCDUwDzwwBTMwAAAAAARwAzMwAAAABbWwB/fwDLywAPRwCDf/8fdxcAAIs7OzsPMwt7ewCHUwCTAAAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwCTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6v///+68zF9AAAGKklEQVR42u3dC3LiOBAAUFX1CeYImWXDbwYWEu5/tcHWx7Lc3ZKllhE76poiJgPhpfVxWzZEqR49evTo0aOZADxa0R3xgGZ0SO6q+ADWttTowHJXwQew2gcmd0eTxKNLZg3ef2iwPEMySXRfOy/kIe3deem8mxeOd8SGxnF7Hsx5YHn4rNx5aby2G7eVoTHTmbHRDg9CHnReMY8oCY5b8hTJA7Tcq1LvsQVVqNM+k7BtqmXOR/K2PtYA2IcxvuBt6XvGJYzaR0Eo74lZ8gZhOzwIcK3xvAYettvj6Ru7SfCg1lg132F4OofA88SAa3hT+uwGzQMp3j9TaN6e4OlkOZ4eyihPEytNwzTPGG9WxvPKhcOPmGWP5Zl52MWN451OhcLRFvCGG4Z3Q3YcJO9UJtyAp4XZo2ID3qmQNwvDKwy/cRvmjXdOrTauzl1u51vHY1JF82wGN+ARyUvJXpaP6Xto9nAdN3L1z8ycXFgeuBFi7t7QYv6mLXYnZmjzaTlz8mMbd2DpvFne0mcrloGl8xbynrDxX47P7DXo7AW80OdKAoYH+b4I73n79fWlN/Z2/x/qbrY8uXx/f1/snOf1vZL8qUXjTp1tP+CGGLddxRLoNO8y4IYYtw0P3MAoal+8WsZ5t6CcYnh2acH3iVTLhvdlY8bzj3En3rcNj+cSMPkkePpIjeE9Ho8Iz29Hz7cF76Ejmef58iY/gof3PZwX9D0IO7j2ZQ2OdbyHjXSe8UHe5EfyRqCemR3vB8EbgXpmRlYJ7L7tJMebHVoi2fvh8cJSFLAJTO9FilcJyDUWsu8lrLHkrktuxFPZPLxxSR4y7yXxfpuozlvsNTbnIUHy0pa9W+blL9oXN+4loXHzTyowPDvnpfD8Y0qEp1t1BQ9rkjnPwlJ4Fsbwnt91va893qRL8sHnGCPLblbk+bqU0QuTzgAXew3JvjfTJfjA140+nyc9sVgeuK8JPNuuGugX4PtlAMpD5hXkrJDljS+UynO97jP8nTHe3luXd6f7cJ0IzzWp86Wc1k0KAZ7nmnW8OO9+v2fyVvQ9b7jajRnvAw34uOv4oB7A8tJHLsdT2Tx8l7Wc9xJ5s9Gr4j6426B51C7K16XyPr3Bq+K+CI94YZj5QICncnjU64YVy/NOWeNS6eP7Hsv7DV4Sy4YGM7vY5K25emU+bGV4KmfeU3I8AGpaZrsfGXSHX3+NEEw+f58LUhHhqUReUBJAHZ/IVQTjdw9+wM8xzBc07EPg6oXAhQ746kozPDWWoPNSVJAnkT7d7T6hRZ7yhsbU3u3w1LRDa5QHfvIkeVfJi5TsvUOTPNUoL8QK8sR9Ay3krYhX8ExmmPQBvDB7BTxh3yjrvFZ4oj4N67xmeII+46KmZY5HTMt/F89EyDP/TdmmZ4f1nuy16Y3zDkTjugfgDUvxrqKtW40HkroF7zABmLYledfmeTI1fRFvrN1RntAhxyHGU63zmGUohnetyzv4g7NhHv/8lnn6wKfz3o93aJ0H7867tsyDzntbXtTXeQU8uyLQee/Ii/g6r+Tccd3zzqU82IRXa2iIHIdzPCiZlkGIB3X2uUJrQDQPSspREONBybEGXOsuoFGLtwcoOc4V/WQRfhEjOjFVXVsmThwk/v44DzbgqRJe7XNqqa8A6Cm/+jxVwnvpB7amPLm+LvViu4q+4QftsHh+O+1KxTMWILbL5XixigXODE/iUq8BQfF20XrvTPPOYhcaErxYFzIPoLMnwNtx2dvteN6Zy9658zqv8zpvLQ/ivPLr06M87r0EdXmQxIMCHryKB2k8KK5WojwgxkWcdy7j7RJ4O+oNXm3zzim8c9FHLCbxdvi79+rzpqKOCY4Xi/89jyOaFuYal6eV8naG9+8ihg+pTuT9WoQMD+zx2Dvz2ImlWZ6dd0lecU2lJ74Ij96pNcAjKz4D4HkSFZXmzWcFw+MWMnze/MmOJ1HwAcvj/nAPxHlCq2cDx29Yj6eSeH7DTjyp1T2aF12goniCK2g0L+XJFE9V5iW+UbomT78GwUv75Qie4OI3ykt9MsZToicOOq/zOq/z/kbeoqBawwsLKlX9Tx5t8eR1VZ+7yeD9cje1eI1m70XxBzjYCnfI0KCqAAAAAElFTkSuQmCC" width="158" height="203"
alt="Loki mission-review portrait" loading="lazy">
<figcaption>Loki chassis &middot; with Hellbringer, Mischief</figcaption>
</figure>
<h3>Loki</h3>
<p class="mech__key">loki &middot; base chassis</p>
<p class="mech__load"><span class="chip chip--e">2&times; ER MEDIUM LASER</span><span class="chip chip--e">2&times; ER SMALL LASER</span><span class="chip chip--b">2&times; AFC 50</span><span class="chip chip--m">SRM 4</span></p>
<p class="mech__meta"><span>7 weapons</span><span>3 ammo bins</span><span>torso TWISTS</span></p>
<dl class="spec"><div><dt>Tonnage</dt><dd>75 t</dd></div><div><dt>Armor points</dt><dd>2110</dd></div><div><dt>Reservoir</dt><dd>3800 L</dd></div><div><dt>Heat sinks</dt><dd>38</dd></div><div><dt>Torso</dt><dd>60&deg;/s &middot; 110&deg;</dd></div><div><dt>0&ndash;100 kph</dt><dd>4.5 s</dd></div><div><dt>Top speed</dt><dd>143 kph</dd></div><div><dt>Super charged</dt><dd>182 kph</dd></div><div><dt>Gimped</dt><dd>40 kph</dd></div></dl>
<p class="mech__sheet">manual p. 25</p>
</header>
<div class="mech__tables"><div class="tablewrap"><table>
<caption class="sr-only">Weapons carried by the Loki</caption>
<thead><tr><th scope="col">WEAPON</th><th scope="col">TYPE</th>
<th scope="col" class="num">COUNT</th><th scope="col" class="num">AMMO BIN</th></tr></thead>
<tbody><tr><th scope="row">ER Medium Laser</th><td>Energy</td><td class="num">2</td><td class="num">&mdash;</td></tr><tr><th scope="row">ER Small Laser</th><td>Energy</td><td class="num">2</td><td class="num">&mdash;</td></tr><tr><th scope="row">AFC 50</th><td>Ballistic</td><td class="num">2</td><td class="num">2&times;</td></tr><tr><th scope="row">SRM 4</th><td>Missile</td><td class="num">1</td><td class="num">1&times;</td></tr></tbody>
</table></div></div>
</article>
<article class="mech" data-tags="variant" data-search="bhk1 nova er medium laser ppc srm 6 ermlaser ppc srm6">
<header class="mech__id">
<figure class="hull">
<img class="hull__img" src="data:image/png;base64,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" width="158" height="203"
alt="Nova mission-review portrait" loading="lazy">
<figcaption>Black Hawk chassis &middot; with Black Hawk</figcaption>
</figure>
<h3>Nova</h3>
<p class="mech__key">bhk1 &middot; variant of Black Hawk</p>
<p class="mech__load"><span class="chip chip--e">3&times; ER MEDIUM LASER</span><span class="chip chip--e">2&times; PPC</span><span class="chip chip--m">2&times; SRM 6</span></p>
<p class="mech__meta"><span>7 weapons</span><span>2 ammo bins</span><span>torso FIXED</span></p>
<dl class="spec"><div><dt>Tonnage</dt><dd>&mdash;</dd></div><div><dt>Armor points</dt><dd>&mdash;</dd></div><div><dt>Reservoir</dt><dd>&mdash;</dd></div><div><dt>Heat sinks</dt><dd>&mdash;</dd></div><div><dt>Torso</dt><dd>FIXED</dd></div><div><dt>0&ndash;100 kph</dt><dd>&mdash;</dd></div><div><dt>Top speed</dt><dd>&mdash;</dd></div><div><dt>Super charged</dt><dd>&mdash;</dd></div><div><dt>Gimped</dt><dd>&mdash;</dd></div></dl>
<p class="mech__sheet">chassis sheet: see Black Hawk</p>
</header>
<div class="mech__tables"><div class="tablewrap"><table>
<caption class="sr-only">Weapons carried by the Nova</caption>
<thead><tr><th scope="col">WEAPON</th><th scope="col">TYPE</th>
<th scope="col" class="num">COUNT</th><th scope="col" class="num">AMMO BIN</th></tr></thead>
<tbody><tr><th scope="row">ER Medium Laser</th><td>Energy</td><td class="num">3</td><td class="num">&mdash;</td></tr><tr><th scope="row">PPC</th><td>Energy</td><td class="num">2</td><td class="num">&mdash;</td></tr><tr><th scope="row">SRM 6</th><td>Missile</td><td class="num">2</td><td class="num">2&times;</td></tr></tbody>
</table></div></div>
</article>
<article class="mech" data-tags="base" data-search="madcat mad cat er large laser er small laser afc 100 lrm 15 erllaser erslaser afc100 lrm15">
<header class="mech__id">
<figure class="hull">
<img class="hull__img" src="data:image/png;base64,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" width="158" height="203"
alt="Mad Cat mission-review portrait" loading="lazy">
<figcaption>Mad Cat chassis &middot; with Timber Wolf, Zanin Neko</figcaption>
</figure>
<h3>Mad Cat</h3>
<p class="mech__key">madcat &middot; base chassis</p>
<p class="mech__load"><span class="chip chip--e">ER LARGE LASER</span><span class="chip chip--e">2&times; ER SMALL LASER</span><span class="chip chip--b">AFC 100</span><span class="chip chip--m">2&times; LRM 15</span></p>
<p class="mech__meta"><span>6 weapons</span><span>3 ammo bins</span><span>torso TWISTS</span></p>
<dl class="spec"><div><dt>Tonnage</dt><dd>75 t</dd></div><div><dt>Armor points</dt><dd>2195</dd></div><div><dt>Reservoir</dt><dd>2800 L</dd></div><div><dt>Heat sinks</dt><dd>28</dd></div><div><dt>Torso</dt><dd>80&deg;/s &middot; 90&deg;</dd></div><div><dt>0&ndash;100 kph</dt><dd>4.2 s</dd></div><div><dt>Top speed</dt><dd>175 kph</dd></div><div><dt>Super charged</dt><dd>220 kph</dd></div><div><dt>Gimped</dt><dd>62 kph</dd></div></dl>
<p class="mech__sheet">manual p. 26</p>
</header>
<div class="mech__tables"><div class="tablewrap"><table>
<caption class="sr-only">Weapons carried by the Mad Cat</caption>
<thead><tr><th scope="col">WEAPON</th><th scope="col">TYPE</th>
<th scope="col" class="num">COUNT</th><th scope="col" class="num">AMMO BIN</th></tr></thead>
<tbody><tr><th scope="row">ER Large Laser</th><td>Energy</td><td class="num">1</td><td class="num">&mdash;</td></tr><tr><th scope="row">ER Small Laser</th><td>Energy</td><td class="num">2</td><td class="num">&mdash;</td></tr><tr><th scope="row">AFC 100</th><td>Ballistic</td><td class="num">1</td><td class="num">1&times;</td></tr><tr><th scope="row">LRM 15</th><td>Missile</td><td class="num">2</td><td class="num">2&times;</td></tr></tbody>
</table></div></div>
</article>
<article class="mech" data-tags="base" data-search="thor thor er medium laser er ppc er small laser large laser lrm 15 ermlaser erppc erslaser llaser lrm15">
<header class="mech__id">
<figure class="hull">
<img class="hull__img" src="data:image/png;base64,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" width="158" height="204"
alt="Thor mission-review portrait" loading="lazy">
<figcaption>Thor chassis &middot; with Summoner</figcaption>
</figure>
<h3>Thor</h3>
<p class="mech__key">thor &middot; base chassis</p>
<p class="mech__load"><span class="chip chip--e">ER MEDIUM LASER</span><span class="chip chip--e">2&times; ER PPC</span><span class="chip chip--e">2&times; ER SMALL LASER</span><span class="chip chip--e">LARGE LASER</span><span class="chip chip--m">LRM 15</span></p>
<p class="mech__meta"><span>7 weapons</span><span>1 ammo bins</span><span>torso TWISTS</span></p>
<dl class="spec"><div><dt>Tonnage</dt><dd>75 t</dd></div><div><dt>Armor points</dt><dd>2110</dd></div><div><dt>Reservoir</dt><dd>3800 L</dd></div><div><dt>Heat sinks</dt><dd>38</dd></div><div><dt>Torso</dt><dd>60&deg;/s &middot; 110&deg;</dd></div><div><dt>0&ndash;100 kph</dt><dd>4.5 s</dd></div><div><dt>Top speed</dt><dd>143 kph</dd></div><div><dt>Super charged</dt><dd>182 kph</dd></div><div><dt>Gimped</dt><dd>40 kph</dd></div></dl>
<p class="mech__sheet">manual p. 25</p>
</header>
<div class="mech__tables"><div class="tablewrap"><table>
<caption class="sr-only">Weapons carried by the Thor</caption>
<thead><tr><th scope="col">WEAPON</th><th scope="col">TYPE</th>
<th scope="col" class="num">COUNT</th><th scope="col" class="num">AMMO BIN</th></tr></thead>
<tbody><tr><th scope="row">ER Medium Laser</th><td>Energy</td><td class="num">1</td><td class="num">&mdash;</td></tr><tr><th scope="row">ER PPC</th><td>Energy</td><td class="num">2</td><td class="num">&mdash;</td></tr><tr><th scope="row">ER Small Laser</th><td>Energy</td><td class="num">2</td><td class="num">&mdash;</td></tr><tr><th scope="row">Large Laser</th><td>Energy</td><td class="num">1</td><td class="num">&mdash;</td></tr><tr><th scope="row">LRM 15</th><td>Missile</td><td class="num">1</td><td class="num">1&times;</td></tr></tbody>
</table></div></div>
</article>
<article class="mech" data-tags="base" data-search="owens owens er medium laser er small laser srm 6 ermlaser erslaser srm6">
<header class="mech__id">
<figure class="hull">
<img class="hull__img" src="data:image/png;base64,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" width="158" height="203"
alt="Owens mission-review portrait" loading="lazy">
<figcaption>Owens chassis &middot; with Kotori</figcaption>
</figure>
<h3>Owens</h3>
<p class="mech__key">owens &middot; base chassis</p>
<p class="mech__load"><span class="chip chip--e">2&times; ER MEDIUM LASER</span><span class="chip chip--e">4&times; ER SMALL LASER</span><span class="chip chip--m">2&times; SRM 6</span></p>
<p class="mech__meta"><span>8 weapons</span><span>2 ammo bins</span><span>torso FIXED</span></p>
<dl class="spec"><div><dt>Tonnage</dt><dd>25 t</dd></div><div><dt>Armor points</dt><dd>760</dd></div><div><dt>Reservoir</dt><dd>1500 L</dd></div><div><dt>Heat sinks</dt><dd>15</dd></div><div><dt>Torso</dt><dd>FIXED</dd></div><div><dt>0&ndash;100 kph</dt><dd>4.2 s</dd></div><div><dt>Top speed</dt><dd>154 kph</dd></div><div><dt>Super charged</dt><dd>265 kph</dd></div><div><dt>Gimped</dt><dd>63 kph</dd></div></dl>
<p class="mech__sheet">manual p. 29</p>
</header>
<div class="mech__tables"><div class="tablewrap"><table>
<caption class="sr-only">Weapons carried by the Owens</caption>
<thead><tr><th scope="col">WEAPON</th><th scope="col">TYPE</th>
<th scope="col" class="num">COUNT</th><th scope="col" class="num">AMMO BIN</th></tr></thead>
<tbody><tr><th scope="row">ER Medium Laser</th><td>Energy</td><td class="num">2</td><td class="num">&mdash;</td></tr><tr><th scope="row">ER Small Laser</th><td>Energy</td><td class="num">4</td><td class="num">&mdash;</td></tr><tr><th scope="row">SRM 6</th><td>Missile</td><td class="num">2</td><td class="num">2&times;</td></tr></tbody>
</table></div></div>
</article>
<article class="mech" data-tags="variant" data-search="own1 kotori er medium laser er small laser afc 25 nrk 5 ermlaser erslaser afc25 nrk5">
<header class="mech__id">
<figure class="hull">
<img class="hull__img" src="data:image/png;base64,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" width="158" height="203"
alt="Kotori mission-review portrait" loading="lazy">
<figcaption>Owens chassis &middot; with Owens</figcaption>
</figure>
<h3>Kotori</h3>
<p class="mech__key">own1 &middot; variant of Owens</p>
<p class="mech__load"><span class="chip chip--e">ER MEDIUM LASER</span><span class="chip chip--e">ER SMALL LASER</span><span class="chip chip--b">AFC 25</span><span class="chip chip--b">2&times; NRK 5</span></p>
<p class="mech__meta"><span>5 weapons</span><span>3 ammo bins</span><span>torso FIXED</span></p>
<dl class="spec"><div><dt>Tonnage</dt><dd>&mdash;</dd></div><div><dt>Armor points</dt><dd>&mdash;</dd></div><div><dt>Reservoir</dt><dd>&mdash;</dd></div><div><dt>Heat sinks</dt><dd>&mdash;</dd></div><div><dt>Torso</dt><dd>FIXED</dd></div><div><dt>0&ndash;100 kph</dt><dd>&mdash;</dd></div><div><dt>Top speed</dt><dd>&mdash;</dd></div><div><dt>Super charged</dt><dd>&mdash;</dd></div><div><dt>Gimped</dt><dd>&mdash;</dd></div></dl>
<p class="mech__sheet">chassis sheet: see Owens</p>
</header>
<div class="mech__tables"><div class="tablewrap"><table>
<caption class="sr-only">Weapons carried by the Kotori</caption>
<thead><tr><th scope="col">WEAPON</th><th scope="col">TYPE</th>
<th scope="col" class="num">COUNT</th><th scope="col" class="num">AMMO BIN</th></tr></thead>
<tbody><tr><th scope="row">ER Medium Laser</th><td>Energy</td><td class="num">1</td><td class="num">&mdash;</td></tr><tr><th scope="row">ER Small Laser</th><td>Energy</td><td class="num">1</td><td class="num">&mdash;</td></tr><tr><th scope="row">AFC 25</th><td>Ballistic</td><td class="num">1</td><td class="num">1&times;</td></tr><tr><th scope="row">NRK 5</th><td>Ballistic</td><td class="num">2</td><td class="num">2&times;</td></tr></tbody>
</table></div></div>
</article>
<article class="mech" data-tags="variant" data-search="thr1 summoner er small laser large laser afc 25 lrm 10 erslaser llaser afc25 lrm10">
<header class="mech__id">
<figure class="hull">
<img class="hull__img" src="data:image/png;base64,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" width="158" height="204"
alt="Summoner mission-review portrait" loading="lazy">
<figcaption>Thor chassis &middot; with Thor</figcaption>
</figure>
<h3>Summoner</h3>
<p class="mech__key">thr1 &middot; variant of Thor</p>
<p class="mech__load"><span class="chip chip--e">4&times; ER SMALL LASER</span><span class="chip chip--e">2&times; LARGE LASER</span><span class="chip chip--b">2&times; AFC 25</span><span class="chip chip--m">LRM 10</span></p>
<p class="mech__meta"><span>9 weapons</span><span>3 ammo bins</span><span>torso TWISTS</span></p>
<dl class="spec"><div><dt>Tonnage</dt><dd>&mdash;</dd></div><div><dt>Armor points</dt><dd>&mdash;</dd></div><div><dt>Reservoir</dt><dd>&mdash;</dd></div><div><dt>Heat sinks</dt><dd>&mdash;</dd></div><div><dt>Torso</dt><dd>twists</dd></div><div><dt>0&ndash;100 kph</dt><dd>&mdash;</dd></div><div><dt>Top speed</dt><dd>&mdash;</dd></div><div><dt>Super charged</dt><dd>&mdash;</dd></div><div><dt>Gimped</dt><dd>&mdash;</dd></div></dl>
<p class="mech__sheet">chassis sheet: see Thor</p>
</header>
<div class="mech__tables"><div class="tablewrap"><table>
<caption class="sr-only">Weapons carried by the Summoner</caption>
<thead><tr><th scope="col">WEAPON</th><th scope="col">TYPE</th>
<th scope="col" class="num">COUNT</th><th scope="col" class="num">AMMO BIN</th></tr></thead>
<tbody><tr><th scope="row">ER Small Laser</th><td>Energy</td><td class="num">4</td><td class="num">&mdash;</td></tr><tr><th scope="row">Large Laser</th><td>Energy</td><td class="num">2</td><td class="num">&mdash;</td></tr><tr><th scope="row">AFC 25</th><td>Ballistic</td><td class="num">2</td><td class="num">2&times;</td></tr><tr><th scope="row">LRM 10</th><td>Missile</td><td class="num">1</td><td class="num">1&times;</td></tr></tbody>
</table></div></div>
</article>
<article class="mech" data-tags="variant" data-search="lok1 hellbringer large laser medium laser ppc small laser srm 4 llaser mlaser ppc slaser srm4">
<header class="mech__id">
<figure class="hull">
<img class="hull__img" src="data:image/png;base64,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" width="158" height="203"
alt="Hellbringer mission-review portrait" loading="lazy">
<figcaption>Loki chassis &middot; with Loki, Mischief</figcaption>
</figure>
<h3>Hellbringer</h3>
<p class="mech__key">lok1 &middot; variant of Loki</p>
<p class="mech__load"><span class="chip chip--e">2&times; LARGE LASER</span><span class="chip chip--e">2&times; MEDIUM LASER</span><span class="chip chip--e">2&times; PPC</span><span class="chip chip--e">2&times; SMALL LASER</span><span class="chip chip--m">SRM 4</span></p>
<p class="mech__meta"><span>9 weapons</span><span>1 ammo bins</span><span>torso TWISTS</span></p>
<dl class="spec"><div><dt>Tonnage</dt><dd>&mdash;</dd></div><div><dt>Armor points</dt><dd>&mdash;</dd></div><div><dt>Reservoir</dt><dd>&mdash;</dd></div><div><dt>Heat sinks</dt><dd>&mdash;</dd></div><div><dt>Torso</dt><dd>twists</dd></div><div><dt>0&ndash;100 kph</dt><dd>&mdash;</dd></div><div><dt>Top speed</dt><dd>&mdash;</dd></div><div><dt>Super charged</dt><dd>&mdash;</dd></div><div><dt>Gimped</dt><dd>&mdash;</dd></div></dl>
<p class="mech__sheet">chassis sheet: see Loki</p>
</header>
<div class="mech__tables"><div class="tablewrap"><table>
<caption class="sr-only">Weapons carried by the Hellbringer</caption>
<thead><tr><th scope="col">WEAPON</th><th scope="col">TYPE</th>
<th scope="col" class="num">COUNT</th><th scope="col" class="num">AMMO BIN</th></tr></thead>
<tbody><tr><th scope="row">Large Laser</th><td>Energy</td><td class="num">2</td><td class="num">&mdash;</td></tr><tr><th scope="row">Medium Laser</th><td>Energy</td><td class="num">2</td><td class="num">&mdash;</td></tr><tr><th scope="row">PPC</th><td>Energy</td><td class="num">2</td><td class="num">&mdash;</td></tr><tr><th scope="row">Small Laser</th><td>Energy</td><td class="num">2</td><td class="num">&mdash;</td></tr><tr><th scope="row">SRM 4</th><td>Missile</td><td class="num">1</td><td class="num">1&times;</td></tr></tbody>
</table></div></div>
</article>
<article class="mech" data-tags="variant" data-search="mad1 timber wolf medium laser afc 25 lrm 10 mlaser afc25 lrm10">
<header class="mech__id">
<figure class="hull">
<img class="hull__img" src="data:image/png;base64,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" width="158" height="203"
alt="Timber Wolf mission-review portrait" loading="lazy">
<figcaption>Mad Cat chassis &middot; with Mad Cat, Zanin Neko</figcaption>
</figure>
<h3>Timber Wolf</h3>
<p class="mech__key">mad1 &middot; variant of Mad Cat</p>
<p class="mech__load"><span class="chip chip--e">4&times; MEDIUM LASER</span><span class="chip chip--b">2&times; AFC 25</span><span class="chip chip--m">2&times; LRM 10</span></p>
<p class="mech__meta"><span>8 weapons</span><span>4 ammo bins</span><span>torso TWISTS</span></p>
<dl class="spec"><div><dt>Tonnage</dt><dd>&mdash;</dd></div><div><dt>Armor points</dt><dd>&mdash;</dd></div><div><dt>Reservoir</dt><dd>&mdash;</dd></div><div><dt>Heat sinks</dt><dd>&mdash;</dd></div><div><dt>Torso</dt><dd>twists</dd></div><div><dt>0&ndash;100 kph</dt><dd>&mdash;</dd></div><div><dt>Top speed</dt><dd>&mdash;</dd></div><div><dt>Super charged</dt><dd>&mdash;</dd></div><div><dt>Gimped</dt><dd>&mdash;</dd></div></dl>
<p class="mech__sheet">chassis sheet: see Mad Cat</p>
</header>
<div class="mech__tables"><div class="tablewrap"><table>
<caption class="sr-only">Weapons carried by the Timber Wolf</caption>
<thead><tr><th scope="col">WEAPON</th><th scope="col">TYPE</th>
<th scope="col" class="num">COUNT</th><th scope="col" class="num">AMMO BIN</th></tr></thead>
<tbody><tr><th scope="row">Medium Laser</th><td>Energy</td><td class="num">4</td><td class="num">&mdash;</td></tr><tr><th scope="row">AFC 25</th><td>Ballistic</td><td class="num">2</td><td class="num">2&times;</td></tr><tr><th scope="row">LRM 10</th><td>Missile</td><td class="num">2</td><td class="num">2&times;</td></tr></tbody>
</table></div></div>
</article>
<article class="mech" data-tags="base" data-search="avatar avatar er large laser er medium laser afc 50 lrm 10 erllaser ermlaser afc50 lrm10">
<header class="mech__id">
<figure class="hull">
<img class="hull__img" src="data:image/png;base64,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" width="158" height="203"
alt="Avatar mission-review portrait" loading="lazy">
<figcaption>Avatar chassis &middot; with Satyr</figcaption>
</figure>
<h3>Avatar</h3>
<p class="mech__key">avatar &middot; base chassis</p>
<p class="mech__load"><span class="chip chip--e">2&times; ER LARGE LASER</span><span class="chip chip--e">4&times; ER MEDIUM LASER</span><span class="chip chip--b">AFC 50</span><span class="chip chip--m">2&times; LRM 10</span></p>
<p class="mech__meta"><span>9 weapons</span><span>3 ammo bins</span><span>torso TWISTS</span></p>
<dl class="spec"><div><dt>Tonnage</dt><dd>&mdash;</dd></div><div><dt>Armor points</dt><dd>&mdash;</dd></div><div><dt>Reservoir</dt><dd>&mdash;</dd></div><div><dt>Heat sinks</dt><dd>&mdash;</dd></div><div><dt>Torso</dt><dd>twists</dd></div><div><dt>0&ndash;100 kph</dt><dd>&mdash;</dd></div><div><dt>Top speed</dt><dd>&mdash;</dd></div><div><dt>Super charged</dt><dd>&mdash;</dd></div><div><dt>Gimped</dt><dd>&mdash;</dd></div></dl>
<p class="mech__sheet">no manual sheet (4.10 addition)</p>
</header>
<div class="mech__tables"><div class="tablewrap"><table>
<caption class="sr-only">Weapons carried by the Avatar</caption>
<thead><tr><th scope="col">WEAPON</th><th scope="col">TYPE</th>
<th scope="col" class="num">COUNT</th><th scope="col" class="num">AMMO BIN</th></tr></thead>
<tbody><tr><th scope="row">ER Large Laser</th><td>Energy</td><td class="num">2</td><td class="num">&mdash;</td></tr><tr><th scope="row">ER Medium Laser</th><td>Energy</td><td class="num">4</td><td class="num">&mdash;</td></tr><tr><th scope="row">AFC 50</th><td>Ballistic</td><td class="num">1</td><td class="num">1&times;</td></tr><tr><th scope="row">LRM 10</th><td>Missile</td><td class="num">2</td><td class="num">2&times;</td></tr></tbody>
</table></div></div>
</article>
<article class="mech" data-tags="variant" data-search="ava1 satyr er medium laser ppc afc 100 lrm 5 srm 2 ermlaser ppc afc100 lrm5 srm2">
<header class="mech__id">
<figure class="hull">
<img class="hull__img" src="data:image/png;base64,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" width="158" height="203"
alt="Satyr mission-review portrait" loading="lazy">
<figcaption>Avatar chassis &middot; with Avatar</figcaption>
</figure>
<h3>Satyr</h3>
<p class="mech__key">ava1 &middot; variant of Avatar</p>
<p class="mech__load"><span class="chip chip--e">2&times; ER MEDIUM LASER</span><span class="chip chip--e">PPC</span><span class="chip chip--b">AFC 100</span><span class="chip chip--m">LRM 5</span><span class="chip chip--m">SRM 2</span></p>
<p class="mech__meta"><span>6 weapons</span><span>3 ammo bins</span><span>torso TWISTS</span></p>
<dl class="spec"><div><dt>Tonnage</dt><dd>&mdash;</dd></div><div><dt>Armor points</dt><dd>&mdash;</dd></div><div><dt>Reservoir</dt><dd>&mdash;</dd></div><div><dt>Heat sinks</dt><dd>&mdash;</dd></div><div><dt>Torso</dt><dd>twists</dd></div><div><dt>0&ndash;100 kph</dt><dd>&mdash;</dd></div><div><dt>Top speed</dt><dd>&mdash;</dd></div><div><dt>Super charged</dt><dd>&mdash;</dd></div><div><dt>Gimped</dt><dd>&mdash;</dd></div></dl>
<p class="mech__sheet">no manual sheet (4.10 addition)</p>
</header>
<div class="mech__tables"><div class="tablewrap"><table>
<caption class="sr-only">Weapons carried by the Satyr</caption>
<thead><tr><th scope="col">WEAPON</th><th scope="col">TYPE</th>
<th scope="col" class="num">COUNT</th><th scope="col" class="num">AMMO BIN</th></tr></thead>
<tbody><tr><th scope="row">ER Medium Laser</th><td>Energy</td><td class="num">2</td><td class="num">&mdash;</td></tr><tr><th scope="row">PPC</th><td>Energy</td><td class="num">1</td><td class="num">&mdash;</td></tr><tr><th scope="row">AFC 100</th><td>Ballistic</td><td class="num">1</td><td class="num">1&times;</td></tr><tr><th scope="row">LRM 5</th><td>Missile</td><td class="num">1</td><td class="num">1&times;</td></tr><tr><th scope="row">SRM 2</th><td>Missile</td><td class="num">1</td><td class="num">1&times;</td></tr></tbody>
</table></div></div>
</article>
<article class="mech" data-tags="base" data-search="sunder sunder er medium laser er small laser medium laser afc 100 afc 25 lrm 10 ermlaser erslaser mlaser afc100 afc25 lrm10">
<header class="mech__id">
<figure class="hull">
<img class="hull__img" src="data:image/png;base64,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" width="158" height="203"
alt="Sunder mission-review portrait" loading="lazy">
<figcaption>Sunder chassis &middot; with Denkou</figcaption>
</figure>
<h3>Sunder</h3>
<p class="mech__key">sunder &middot; base chassis</p>
<p class="mech__load"><span class="chip chip--e">2&times; ER MEDIUM LASER</span><span class="chip chip--e">2&times; ER SMALL LASER</span><span class="chip chip--e">2&times; MEDIUM LASER</span><span class="chip chip--b">AFC 100</span><span class="chip chip--b">AFC 25</span><span class="chip chip--m">LRM 10</span></p>
<p class="mech__meta"><span>9 weapons</span><span>3 ammo bins</span><span>torso TWISTS</span></p>
<dl class="spec"><div><dt>Tonnage</dt><dd>&mdash;</dd></div><div><dt>Armor points</dt><dd>&mdash;</dd></div><div><dt>Reservoir</dt><dd>&mdash;</dd></div><div><dt>Heat sinks</dt><dd>&mdash;</dd></div><div><dt>Torso</dt><dd>twists</dd></div><div><dt>0&ndash;100 kph</dt><dd>&mdash;</dd></div><div><dt>Top speed</dt><dd>&mdash;</dd></div><div><dt>Super charged</dt><dd>&mdash;</dd></div><div><dt>Gimped</dt><dd>&mdash;</dd></div></dl>
<p class="mech__sheet">no manual sheet (4.10 addition)</p>
</header>
<div class="mech__tables"><div class="tablewrap"><table>
<caption class="sr-only">Weapons carried by the Sunder</caption>
<thead><tr><th scope="col">WEAPON</th><th scope="col">TYPE</th>
<th scope="col" class="num">COUNT</th><th scope="col" class="num">AMMO BIN</th></tr></thead>
<tbody><tr><th scope="row">ER Medium Laser</th><td>Energy</td><td class="num">2</td><td class="num">&mdash;</td></tr><tr><th scope="row">ER Small Laser</th><td>Energy</td><td class="num">2</td><td class="num">&mdash;</td></tr><tr><th scope="row">Medium Laser</th><td>Energy</td><td class="num">2</td><td class="num">&mdash;</td></tr><tr><th scope="row">AFC 100</th><td>Ballistic</td><td class="num">1</td><td class="num">1&times;</td></tr><tr><th scope="row">AFC 25</th><td>Ballistic</td><td class="num">1</td><td class="num">1&times;</td></tr><tr><th scope="row">LRM 10</th><td>Missile</td><td class="num">1</td><td class="num">1&times;</td></tr></tbody>
</table></div></div>
</article>
<article class="mech" data-tags="variant" data-search="snd1 denkou er small laser ppc afc 25 lrm 5 erslaser ppc afc25 lrm5">
<header class="mech__id">
<figure class="hull">
<img class="hull__img" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJ4AAADLCAMAAACoGNoNAAADAFBMVEUAAAD/AAAP/wdjY2NTU1PDYxOfn58A4wD/AAD/05f///9jY9fzgwCDUwDzwwBTMwAAAAAARwAzMwAAAABbWwB/fwDLywAPRwCDf/8fdxcAAIs7OzsPMwt7ewCHUwCTAAAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwCTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6v///+68zF9AAAG20lEQVR42u3cjXLbKBAHcM3sk/jsizzx1crn+7/aWSDQgthlEYucaWDauHbj5pe/BKxA6TD01ltvv60B136C7h3eU+3x+sG+ZEIz5H1+iD6YVw9NlOSZx/jDE3jjprG8+6Y15plfrpln3MF9aO7rp9tnzXnj+Pn5OQe38pJdY+E9Mvv6+noEdxDv07ZRmN6Mm9sxPFh54HjkuWeOqePBATxzZBefOb48zx5Z2+zxbTmuEDz64G550HDIo3hk10jw1IXrQDLzxlUnSu/udTPvrj48W9qiWbsG7OoadmzWFK6TBSwfTHYgmzXQuLc8u2Ni9XyHZjLwH8aARx7cABQ8SwFrJ1rL86fiyqO6RqCIea9h28PzjBEHF5cEHC8qCRR5OKUxCA4XVFSpnC6ouINbcpSXzzyfc+mlwzM9pjC9At9iO2d5lG4Or4yHoXndeWnZc0+dl/etunPm3CtszLn3KvcRvNTBFVnK0st2YzmP67mH82LDaCeJpO4dNgdQiQckL8ouy3tDrYjHnH5QxvND3/f39zpnGN4b8j2eSMc9dvibcUXpedy3A3re2/rF3nK8P2EjfHauqOO9g+et4cl45luhffbAEsNyihfqUHy70ls+2f+Z6BXpSU3MW6rlonMP6cj88HS2LQkSPNQvtrySnpvg/Unw0Hl3juu9xLjnZ91AZyuWonEvxfuT4OF+kS1H15ogwWNnjU2T8rwPSnh43NtVsYCUd/bdY4BmFct21oCo5255vr8uuiGX3ul0StV85vXikgDQuGcfgSqSre5Y3ivgOcNNbDj94MgezvM+NO9CzMMzcm5gYXk7CiqIeY/fL7759NyACJlhmefF2QkKKtjwXn4S7xUi3kstL9X288LxL9pNwYMKz/PnHtX28gJfMj1geXgJqAXvFY97CR4qZFI9NxpYfPv4+Cjgsde566xheLD0302ZLxj3VtxHAKzhAeY5mueF6/GSy3DEc6+wvLjSBIiuily9N6NseoaX2i9ghmUXH3y4trzC8zZ1cHSN5ufdARYd2D8MtC81sEB0bP3RZXnJKj24gjT9QsJbfWo88RLe/JjjDWjjgObFXYPhiRdABxHP+ZR4ZYvwma6BfDwPzLgHWV7hFkE0sND7fRsehF3ZqtanpqDa8Eo3MKJhmd2PDOfc3NVHWH/6PbWhnOcnNXbZaoymEDixcy6YsAIe7N2+yC7aBzwb3YnnzUIc4K79XGF6bjccRZeo9SKeueDwAe7aDS/nuegMb/ntHyLeCR3hfTzpjpHjrTiU3sl+2PLsFRuopCfgmejQ6X/yuOTBxQHuTE92bJcbRUY8FMt4rovU8iDLiwtkycE9rV1k37knCs8WzmFAgq6BA7zv21MWhTcAxDjBwBIHWMXLxZwaerlhefu9DPuPbp5X36puayzhydLT5BW9R8p70j23ndd5ndd5fzcvuXq8Y9Zoe3Prk6Zdge4aNPjHtOUh2dynTEFr4uu8yjNPjwf6vKsab/ptPFDmqd81f1XkTb+LB+o80A5vw5O3DW86gLdEQ8cHZHq/igcsD0gdxwPl8Gge3UjedCBvR3qT7s8KNUgPdMPTTW86jrcnPT0eELwIkUqPHJYVj66rQzVnDcX4GJ79azI9V2a35K0hRLzr+vWZC8YHLlHM611QehbBo+Lzfzul09OJDwQ87mqb44FieOrpTUfx9qWnwQOWx8S32hke6IW35WXic+GRvKnzfj6Pf/8zeYIftXwe7yrgTZ3Xeb+SB/mO+zzeNcubOq/zWvEg2zOeyLtmeFPndd5fy6vY2m6/L85dSNamByoXus3GvcarBLWzhg4PGhVUoLRE1agc1VrfgyYXkqC2PNrkMlxvbRn4FapcfG1X5geaJ3rz1HzbBZiVecG003bTaqB40m+u6Zaf2zRIbruIpuzkfq76vkb5pN7+Rrn5n7mk2+P17Jx7uz1+JRvoVARwYXiXzA+O3Rjerd5nouN4F5Z3Y3n1vqpbLdvfp2nOu2bp3Tqv8zqv8zqv8w7jgYQHbMGS4UF7Hvn/QDTmgYRH5DfjJDyoOvMEvAvBuwl4NWcfHMKDmm4r4wFzmcHzbu15lyTv1nk/npdtHC/bannuYpamZdJjZJXzGvry/yaamPdfonWeX0GgeMtKwJN55LDcmgdCHlXs5XlQNem6WasJr7YgdQDLw6MC5tEFFeLhNyNebcGHeSg4xGPKUcxDwQU8hcVHmgeZYh54HtRfbPC83Poey9NaHuV4mbVlnqezuEzxZCvzBE/pnnnfU3V5ej8RQfNEb6Z4evtCaZ7wzWneMHRe53Ve53VezbwRlqNQcC8BxOUogDZv925n+//S5ofzntr+B4DZO8N16mqYAAAAAElFTkSuQmCC" width="158" height="203"
alt="Denkou mission-review portrait" loading="lazy">
<figcaption>Sunder chassis &middot; with Sunder</figcaption>
</figure>
<h3>Denkou</h3>
<p class="mech__key">snd1 &middot; variant of Sunder</p>
<p class="mech__load"><span class="chip chip--e">ER SMALL LASER</span><span class="chip chip--e">3&times; PPC</span><span class="chip chip--b">AFC 25</span><span class="chip chip--m">LRM 5</span></p>
<p class="mech__meta"><span>6 weapons</span><span>2 ammo bins</span><span>torso TWISTS</span></p>
<dl class="spec"><div><dt>Tonnage</dt><dd>&mdash;</dd></div><div><dt>Armor points</dt><dd>&mdash;</dd></div><div><dt>Reservoir</dt><dd>&mdash;</dd></div><div><dt>Heat sinks</dt><dd>&mdash;</dd></div><div><dt>Torso</dt><dd>twists</dd></div><div><dt>0&ndash;100 kph</dt><dd>&mdash;</dd></div><div><dt>Top speed</dt><dd>&mdash;</dd></div><div><dt>Super charged</dt><dd>&mdash;</dd></div><div><dt>Gimped</dt><dd>&mdash;</dd></div></dl>
<p class="mech__sheet">no manual sheet (4.10 addition)</p>
</header>
<div class="mech__tables"><div class="tablewrap"><table>
<caption class="sr-only">Weapons carried by the Denkou</caption>
<thead><tr><th scope="col">WEAPON</th><th scope="col">TYPE</th>
<th scope="col" class="num">COUNT</th><th scope="col" class="num">AMMO BIN</th></tr></thead>
<tbody><tr><th scope="row">ER Small Laser</th><td>Energy</td><td class="num">1</td><td class="num">&mdash;</td></tr><tr><th scope="row">PPC</th><td>Energy</td><td class="num">3</td><td class="num">&mdash;</td></tr><tr><th scope="row">AFC 25</th><td>Ballistic</td><td class="num">1</td><td class="num">1&times;</td></tr><tr><th scope="row">LRM 5</th><td>Missile</td><td class="num">1</td><td class="num">1&times;</td></tr></tbody>
</table></div></div>
</article>
<article class="mech" data-tags="base" data-search="vulture vulture er medium laser er small laser large laser lrm 20 ermlaser erslaser llaser lrm20">
<header class="mech__id">
<figure class="hull">
<img class="hull__img" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJ4AAADLCAMAAACoGNoNAAADAFBMVEUAAAD/AAAP/wdjY2NTU1PDYxOfn58A4wD/AAD/05f///9jY9fzgwCDUwDzwwBTMwAAAAAARwAzMwAAAABbWwB/fwDLywAPRwCDf/8fdxcAAIs7OzsPMwt7ewCHUwCTAAAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwCTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6v///+68zF9AAAGSUlEQVR42u3ci3baPAwA4JyjJykd/zhdGX+4vP+rjfgiy9fYjkzTTdoFaNP0q2wpTihMk4SEhMReA4LYHW+en3+XWO7BDjLkh88rxRfpLO9rfAA/M6F4Vqd8y0d+Z+IreDPMjjcrn/BaBneeiW932fMLRbLXwJtnn7eU7354c8ybhSe8Vxxzk7z9HHOt6fF4fOngLj+77XTu1vAeOhzPdj//dhxPC6N/GZ62RP8G8lTGfoa3mvOwgTyVsd/h7b/LKw1uzHv14O68NMqNJeLtq7FEfW9fjSU8qL107sFa5ca8XOXCmOMtG49VaA7k71yD+8G4MrC2Z6yURriCyZbGxzOYhGjTPBu3281vLM+t3jD0NQ3SS+73O2ksHzo4fGij2bvpcNlbbJSnhJi1uw4ve1bIyjNzDXl27hkT4T3/w/lmeTj3hvHUUN5sYOVmeLpS7zZs5f6zPDWYhFczuI7HPrhQVxppXk1pwNa24vGwrdxoa8nxXFu5k9bi82Bb7lLZc32vKnvY9+LsbcmfSl2qsUQHtXJjiQ5qhNfv0wObqNx4SVCs3GhJ4PF6ffZAO5rX57NFMXpw+3xYsqR08yuWldJIrFiMq7e/OFVyxRKcCpUbS3gq5LpKd3v2G56pEr7sAbF1Zo/4tJBr7vk23f06BjfIH1RU7vV6Xa/cwAcffbx3Ov+K11gM76pjlafmjt/7ukoDiG5aH9yQVzjPRR/0lAbQpmxmLqyVxtXGSmmYvQFtzNB49VjTwOlK11gyvOI1Fu0DPPKuXnl+fuo/E+DqAb8gxTMbJHk68peAcPcqgWcTWZ/WgbqBd68q8lfmNSQ59ygyeWWe1i8oGOR9Rqdy5/EKF9DgbY2XGGO6R8JT+cv6Ft3yt4UHbz7P73v2s208gJQPNA1aeOAuCmQHPuEr8kATIeLZxPXw/k9EH8+mMORh5vzSgDJPJSnL0yks8SAuDZ3BmKczZ/LnDrVTgQdqouV5+tP5a8vu4Otyp8sjwSM+1/emNd6SozTPVEyBN3l9z+nOycEF9KU6+Aie66aog/Tg0vwljy+J0qzjlZ8wBQcktZHh6TmY7twJnokMz9+odFBddGDKosTTfyoXEJWNpXI1ctZ/yrxqHU1mKXv1eztDYXCxNGpPTJbtDyrSPPO5ht2VSgOjdumKuiLvAPX7O2NAYdrXDKm6d6jhHezivXbXvVfs/S+Heh5s/tb1stMznt+C6FZ4ygdwecYQoyc7Ie9Qyzsg7+IZGXknLwCgjQeEh0Y2nu/aFATIxwuGl8yrQzHIhsHQcvKCsd3CI6PLP7h8vIvwhPf38uo63Qt55Ft6x41iBu3m49oy8pzy0BDO9RreoTmEJzy20uhbSQ3huVU8C497texMuJhv3gdZ7vFnLxzcbt6IxTyOLgtv+MnaxsEdcrLLx5umUS+T3M6bpkl4whOe8PbP2/lRY/c83hWL8PbCo5OOjcc2/bwzBrbS4Hw9SXymtnUxz7WcX3BjeBeeV7ucRvEuHK92OY3jXYQnPOEJT3jCeykPxi2o5JgrvG9dGntejk4w8lyD52SD4e18Br4pUPjrXf087tQln6/v3wn/833CY/0lry2v9x3wC14sRTeocHFPP1w07x2/4JcLDiLSqE4/avp1bi2jOvVoK9BmzdfZx/U86wl9OosbeMYR8vRHanmGEfKsbwtPDW4ie+rDtTxli7OnBn3b4EJ2cFvmXnZwgWFwM3OvaXBzc++X8IQnPOEJT3jCE57whNdwGtl8Kpk6jeQ5lZTBFZ7whDeq7yV47X0vcxFje99LXjnsuAQUh8w94X3bC2i7rtzkmqpjIRSvqRif9Evy+nYS8BifcImz1/6uMons9SOrXrv3kp3k9ns0Qe4e4YgPVvetNvjU8bxj7y4PgNztedtRilhQjnesBAa4T8qjwlagj0PUkdzDDbL7Jjh7x+NZcyuQ4Igy4JEUJvft4XwUvUd/gvpK879/huf0ifcjw4Q5XIpH9FW+NC7DI2MP3jvhOdwnjSSvAYjjCj4uz8NNEYg4CHBZHm5aHmGCO4aR5+H2+AZLkEhcmYfbl4CuTx7beEf//Z+AdpRa3udql456SDUPu82yk9z3X+G5btPDK6W2mgel3G7iHcujW8NLtpi/iLc5mHbyHXn7iz8lAUywKfgHyAAAAABJRU5ErkJggg==" width="158" height="203"
alt="Vulture mission-review portrait" loading="lazy">
<figcaption>Vulture chassis &middot; with Mad Dog</figcaption>
</figure>
<h3>Vulture</h3>
<p class="mech__key">vulture &middot; base chassis</p>
<p class="mech__load"><span class="chip chip--e">2&times; ER MEDIUM LASER</span><span class="chip chip--e">2&times; ER SMALL LASER</span><span class="chip chip--e">2&times; LARGE LASER</span><span class="chip chip--m">2&times; LRM 20</span></p>
<p class="mech__meta"><span>8 weapons</span><span>2 ammo bins</span><span>torso TWISTS</span></p>
<dl class="spec"><div><dt>Tonnage</dt><dd>75 t</dd></div><div><dt>Armor points</dt><dd>2025</dd></div><div><dt>Reservoir</dt><dd>2400 L</dd></div><div><dt>Heat sinks</dt><dd>24</dd></div><div><dt>Torso</dt><dd>50&deg;/s &middot; 130&deg;</dd></div><div><dt>0&ndash;100 kph</dt><dd>4.7 s</dd></div><div><dt>Top speed</dt><dd>175 kph</dd></div><div><dt>Super charged</dt><dd>235 kph</dd></div><div><dt>Gimped</dt><dd>62 kph</dd></div></dl>
<p class="mech__sheet">manual p. 26</p>
</header>
<div class="mech__tables"><div class="tablewrap"><table>
<caption class="sr-only">Weapons carried by the Vulture</caption>
<thead><tr><th scope="col">WEAPON</th><th scope="col">TYPE</th>
<th scope="col" class="num">COUNT</th><th scope="col" class="num">AMMO BIN</th></tr></thead>
<tbody><tr><th scope="row">ER Medium Laser</th><td>Energy</td><td class="num">2</td><td class="num">&mdash;</td></tr><tr><th scope="row">ER Small Laser</th><td>Energy</td><td class="num">2</td><td class="num">&mdash;</td></tr><tr><th scope="row">Large Laser</th><td>Energy</td><td class="num">2</td><td class="num">&mdash;</td></tr><tr><th scope="row">LRM 20</th><td>Missile</td><td class="num">2</td><td class="num">2&times;</td></tr></tbody>
</table></div></div>
</article>
<article class="mech" data-tags="variant" data-search="vul1 mad dog er small laser ppc lrm 15 erslaser ppc lrm15">
<header class="mech__id">
<figure class="hull">
<img class="hull__img" src="data:image/png;base64,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" width="158" height="203"
alt="Mad Dog mission-review portrait" loading="lazy">
<figcaption>Vulture chassis &middot; with Vulture</figcaption>
</figure>
<h3>Mad Dog</h3>
<p class="mech__key">vul1 &middot; variant of Vulture</p>
<p class="mech__load"><span class="chip chip--e">4&times; ER SMALL LASER</span><span class="chip chip--e">2&times; PPC</span><span class="chip chip--m">2&times; LRM 15</span></p>
<p class="mech__meta"><span>8 weapons</span><span>2 ammo bins</span><span>torso TWISTS</span></p>
<dl class="spec"><div><dt>Tonnage</dt><dd>&mdash;</dd></div><div><dt>Armor points</dt><dd>&mdash;</dd></div><div><dt>Reservoir</dt><dd>&mdash;</dd></div><div><dt>Heat sinks</dt><dd>&mdash;</dd></div><div><dt>Torso</dt><dd>twists</dd></div><div><dt>0&ndash;100 kph</dt><dd>&mdash;</dd></div><div><dt>Top speed</dt><dd>&mdash;</dd></div><div><dt>Super charged</dt><dd>&mdash;</dd></div><div><dt>Gimped</dt><dd>&mdash;</dd></div></dl>
<p class="mech__sheet">chassis sheet: see Vulture</p>
</header>
<div class="mech__tables"><div class="tablewrap"><table>
<caption class="sr-only">Weapons carried by the Mad Dog</caption>
<thead><tr><th scope="col">WEAPON</th><th scope="col">TYPE</th>
<th scope="col" class="num">COUNT</th><th scope="col" class="num">AMMO BIN</th></tr></thead>
<tbody><tr><th scope="row">ER Small Laser</th><td>Energy</td><td class="num">4</td><td class="num">&mdash;</td></tr><tr><th scope="row">PPC</th><td>Energy</td><td class="num">2</td><td class="num">&mdash;</td></tr><tr><th scope="row">LRM 15</th><td>Missile</td><td class="num">2</td><td class="num">2&times;</td></tr></tbody>
</table></div></div>
</article>
<article class="mech" data-tags="variant" data-search="lok2 mischief medium laser ppc small laser afc 25 gauss srm 4 mlaser ppc slaser afc25 gauss srm4">
<header class="mech__id">
<figure class="hull">
<img class="hull__img" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJ4AAADLCAMAAACoGNoNAAADAFBMVEUAAAD/AAAP/wdjY2NTU1PDYxOfn58A4wD/AAD/05f///9jY9fzgwCDUwDzwwBTMwAAAAAARwAzMwAAAABbWwB/fwDLywAPRwCDf/8fdxcAAIs7OzsPMwt7ewCHUwCTAAAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwAAZwCTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6uTh6v///+68zF9AAAGKklEQVR42u3dC3LiOBAAUFX1CeYImWXDbwYWEu5/tcHWx7Lc3ZKllhE76poiJgPhpfVxWzZEqR49evTo0aOZADxa0R3xgGZ0SO6q+ADWttTowHJXwQew2gcmd0eTxKNLZg3ef2iwPEMySXRfOy/kIe3deem8mxeOd8SGxnF7Hsx5YHn4rNx5aby2G7eVoTHTmbHRDg9CHnReMY8oCY5b8hTJA7Tcq1LvsQVVqNM+k7BtqmXOR/K2PtYA2IcxvuBt6XvGJYzaR0Eo74lZ8gZhOzwIcK3xvAYettvj6Ru7SfCg1lg132F4OofA88SAa3hT+uwGzQMp3j9TaN6e4OlkOZ4eyihPEytNwzTPGG9WxvPKhcOPmGWP5Zl52MWN451OhcLRFvCGG4Z3Q3YcJO9UJtyAp4XZo2ID3qmQNwvDKwy/cRvmjXdOrTauzl1u51vHY1JF82wGN+ARyUvJXpaP6Xto9nAdN3L1z8ycXFgeuBFi7t7QYv6mLXYnZmjzaTlz8mMbd2DpvFne0mcrloGl8xbynrDxX47P7DXo7AW80OdKAoYH+b4I73n79fWlN/Z2/x/qbrY8uXx/f1/snOf1vZL8qUXjTp1tP+CGGLddxRLoNO8y4IYYtw0P3MAoal+8WsZ5t6CcYnh2acH3iVTLhvdlY8bzj3En3rcNj+cSMPkkePpIjeE9Ho8Iz29Hz7cF76Ejmef58iY/gof3PZwX9D0IO7j2ZQ2OdbyHjXSe8UHe5EfyRqCemR3vB8EbgXpmRlYJ7L7tJMebHVoi2fvh8cJSFLAJTO9FilcJyDUWsu8lrLHkrktuxFPZPLxxSR4y7yXxfpuozlvsNTbnIUHy0pa9W+blL9oXN+4loXHzTyowPDvnpfD8Y0qEp1t1BQ9rkjnPwlJ4Fsbwnt91va893qRL8sHnGCPLblbk+bqU0QuTzgAXew3JvjfTJfjA140+nyc9sVgeuK8JPNuuGugX4PtlAMpD5hXkrJDljS+UynO97jP8nTHe3luXd6f7cJ0IzzWp86Wc1k0KAZ7nmnW8OO9+v2fyVvQ9b7jajRnvAw34uOv4oB7A8tJHLsdT2Tx8l7Wc9xJ5s9Gr4j6426B51C7K16XyPr3Bq+K+CI94YZj5QICncnjU64YVy/NOWeNS6eP7Hsv7DV4Sy4YGM7vY5K25emU+bGV4KmfeU3I8AGpaZrsfGXSHX3+NEEw+f58LUhHhqUReUBJAHZ/IVQTjdw9+wM8xzBc07EPg6oXAhQ746kozPDWWoPNSVJAnkT7d7T6hRZ7yhsbU3u3w1LRDa5QHfvIkeVfJi5TsvUOTPNUoL8QK8sR9Ay3krYhX8ExmmPQBvDB7BTxh3yjrvFZ4oj4N67xmeII+46KmZY5HTMt/F89EyDP/TdmmZ4f1nuy16Y3zDkTjugfgDUvxrqKtW40HkroF7zABmLYledfmeTI1fRFvrN1RntAhxyHGU63zmGUohnetyzv4g7NhHv/8lnn6wKfz3o93aJ0H7867tsyDzntbXtTXeQU8uyLQee/Ii/g6r+Tccd3zzqU82IRXa2iIHIdzPCiZlkGIB3X2uUJrQDQPSspREONBybEGXOsuoFGLtwcoOc4V/WQRfhEjOjFVXVsmThwk/v44DzbgqRJe7XNqqa8A6Cm/+jxVwnvpB7amPLm+LvViu4q+4QftsHh+O+1KxTMWILbL5XixigXODE/iUq8BQfF20XrvTPPOYhcaErxYFzIPoLMnwNtx2dvteN6Zy9658zqv8zpvLQ/ivPLr06M87r0EdXmQxIMCHryKB2k8KK5WojwgxkWcdy7j7RJ4O+oNXm3zzim8c9FHLCbxdvi79+rzpqKOCY4Xi/89jyOaFuYal6eV8naG9+8ihg+pTuT9WoQMD+zx2Dvz2ImlWZ6dd0lecU2lJ74Ij96pNcAjKz4D4HkSFZXmzWcFw+MWMnze/MmOJ1HwAcvj/nAPxHlCq2cDx29Yj6eSeH7DTjyp1T2aF12goniCK2g0L+XJFE9V5iW+UbomT78GwUv75Qie4OI3ykt9MsZToicOOq/zOq/z/kbeoqBawwsLKlX9Tx5t8eR1VZ+7yeD9cje1eI1m70XxBzjYCnfI0KCqAAAAAElFTkSuQmCC" width="158" height="203"
alt="Mischief mission-review portrait" loading="lazy">
<figcaption>Loki chassis &middot; with Loki, Hellbringer</figcaption>
</figure>
<h3>Mischief</h3>
<p class="mech__key">lok2 &middot; variant of Loki</p>
<p class="mech__load"><span class="chip chip--e">3&times; MEDIUM LASER</span><span class="chip chip--e">PPC</span><span class="chip chip--e">2&times; SMALL LASER</span><span class="chip chip--b">AFC 25</span><span class="chip chip--b">GAUSS</span><span class="chip chip--m">SRM 4</span></p>
<p class="mech__meta"><span>9 weapons</span><span>3 ammo bins</span><span>torso TWISTS</span></p>
<dl class="spec"><div><dt>Tonnage</dt><dd>&mdash;</dd></div><div><dt>Armor points</dt><dd>&mdash;</dd></div><div><dt>Reservoir</dt><dd>&mdash;</dd></div><div><dt>Heat sinks</dt><dd>&mdash;</dd></div><div><dt>Torso</dt><dd>twists</dd></div><div><dt>0&ndash;100 kph</dt><dd>&mdash;</dd></div><div><dt>Top speed</dt><dd>&mdash;</dd></div><div><dt>Super charged</dt><dd>&mdash;</dd></div><div><dt>Gimped</dt><dd>&mdash;</dd></div></dl>
<p class="mech__sheet">chassis sheet: see Loki</p>
</header>
<div class="mech__tables"><div class="tablewrap"><table>
<caption class="sr-only">Weapons carried by the Mischief</caption>
<thead><tr><th scope="col">WEAPON</th><th scope="col">TYPE</th>
<th scope="col" class="num">COUNT</th><th scope="col" class="num">AMMO BIN</th></tr></thead>
<tbody><tr><th scope="row">Medium Laser</th><td>Energy</td><td class="num">3</td><td class="num">&mdash;</td></tr><tr><th scope="row">PPC</th><td>Energy</td><td class="num">1</td><td class="num">&mdash;</td></tr><tr><th scope="row">Small Laser</th><td>Energy</td><td class="num">2</td><td class="num">&mdash;</td></tr><tr><th scope="row">AFC 25</th><td>Ballistic</td><td class="num">1</td><td class="num">1&times;</td></tr><tr><th scope="row">Gauss</th><td>Ballistic</td><td class="num">1</td><td class="num">1&times;</td></tr><tr><th scope="row">SRM 4</th><td>Missile</td><td class="num">1</td><td class="num">1&times;</td></tr></tbody>
</table></div></div>
</article>
<article class="mech" data-tags="variant" data-search="mad2 zanin neko er medium laser er ppc afc 50 lrm 15 srm 6 ermlaser erppc afc50 lrm15 srm6">
<header class="mech__id">
<figure class="hull">
<img class="hull__img" src="data:image/png;base64,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" width="158" height="203"
alt="Zanin Neko mission-review portrait" loading="lazy">
<figcaption>Mad Cat chassis &middot; with Mad Cat, Timber Wolf</figcaption>
</figure>
<h3>Zanin Neko</h3>
<p class="mech__key">mad2 &middot; variant of Mad Cat</p>
<p class="mech__load"><span class="chip chip--e">2&times; ER MEDIUM LASER</span><span class="chip chip--e">ER PPC</span><span class="chip chip--b">AFC 50</span><span class="chip chip--m">LRM 15</span><span class="chip chip--m">2&times; SRM 6</span></p>
<p class="mech__meta"><span>7 weapons</span><span>4 ammo bins</span><span>torso TWISTS</span></p>
<dl class="spec"><div><dt>Tonnage</dt><dd>&mdash;</dd></div><div><dt>Armor points</dt><dd>&mdash;</dd></div><div><dt>Reservoir</dt><dd>&mdash;</dd></div><div><dt>Heat sinks</dt><dd>&mdash;</dd></div><div><dt>Torso</dt><dd>twists</dd></div><div><dt>0&ndash;100 kph</dt><dd>&mdash;</dd></div><div><dt>Top speed</dt><dd>&mdash;</dd></div><div><dt>Super charged</dt><dd>&mdash;</dd></div><div><dt>Gimped</dt><dd>&mdash;</dd></div></dl>
<p class="mech__sheet">chassis sheet: see Mad Cat</p>
</header>
<div class="mech__tables"><div class="tablewrap"><table>
<caption class="sr-only">Weapons carried by the Zanin Neko</caption>
<thead><tr><th scope="col">WEAPON</th><th scope="col">TYPE</th>
<th scope="col" class="num">COUNT</th><th scope="col" class="num">AMMO BIN</th></tr></thead>
<tbody><tr><th scope="row">ER Medium Laser</th><td>Energy</td><td class="num">2</td><td class="num">&mdash;</td></tr><tr><th scope="row">ER PPC</th><td>Energy</td><td class="num">1</td><td class="num">&mdash;</td></tr><tr><th scope="row">AFC 50</th><td>Ballistic</td><td class="num">1</td><td class="num">1&times;</td></tr><tr><th scope="row">LRM 15</th><td>Missile</td><td class="num">1</td><td class="num">1&times;</td></tr><tr><th scope="row">SRM 6</th><td>Missile</td><td class="num">2</td><td class="num">2&times;</td></tr></tbody>
</table></div></div>
</article></div>
<p class="empty-state" id="nomatch" hidden>No mech matches that.</p>
<p class="foot">The roster is <code>kVehicles[]</code> in <code>game/glass/btl4fe.cpp</code>
&mdash; the exact catalog the in-game menu offers, in menu order. Each mech's weapons and
ammo bins are its <code>cmCrit(...)</code> lines in <code>content/GAUGE/L4GAUGE.CFG</code>,
the critical-damage schematic's authored subsystem roster (independently confirmed against
the subsystem records in <code>BTL4.RES</code>). Nothing is transcribed by hand.</p>
<p class="foot">The portraits are each mech's mission-review image
(<code>GAUGE/&lt;stem&gt;_MR.PCC</code>) resolved through the review screen's own palette.
In play, its zone indexes are live damage slots painted green&rarr;orange&rarr;red by the
ColorMapper gauges; this page shows every zone at the undamaged ramp entry &mdash; the
same green the armor doll idles at. Variants without their own portrait share the base's,
exactly as the resource file does.</p>
<p class="foot">Mech stats are the original player's manual stat sheets (pp. 25, 26, 29).
The manual documents the six 4.0 hulls; Avatar and Sunder arrived with 4.10 and have no
sheet, so their rows show a dash rather than an invented number. FIXED vs twisting torso
is read from the shipped resource file: a mech twists only if its subsystem record carries
the <code>jointtorso</code>/<code>jointtshadow</code> joints &mdash; the four fixed mechs
match the manual's Torso&nbsp;0/0 rows exactly.</p>
<p class="foot">Weapon notes: ER lasers fire red beams, PPCs blue, standard lasers yellow.
Authored ranges where the game states them: PPCs 900&nbsp;m, missiles 800&nbsp;m, the
Blackhawk's lasers 500&nbsp;m, the AFC&nbsp;50 750&nbsp;m. The Blackhawk's SRM&nbsp;6
racks fire Streak ammunition. LRM tube counts are the salvo size; each launcher's ammo
bin is listed beside it.</p>
</main>
<script>
const cards = [...document.querySelectorAll('.mech')];
const count = document.getElementById('count');
const search = document.getElementById('search');
let filter = 'all';
function apply() {
const q = search.value.trim().toLowerCase();
let shown = 0;
for (const c of cards) {
const okTag = filter === 'all' || c.dataset.tags.split(' ').includes(filter);
const okQ = !q || c.dataset.search.includes(q);
const on = okTag && okQ;
c.hidden = !on;
if (on) shown++;
}
count.textContent = shown + ' / ' + cards.length + ' mechs';
document.getElementById('nomatch').hidden = shown > 0;
}
for (const b of document.querySelectorAll('[data-filter]')) {
b.addEventListener('click', () => {
filter = b.dataset.filter;
for (const o of document.querySelectorAll('[data-filter]'))
o.setAttribute('aria-pressed', String(o === b));
apply();
});
}
search.addEventListener('input', apply);
apply();
</script>
</body>
</html>
-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). |
-163
View File
@@ -1,163 +0,0 @@
=============================================================================
BT411 -- ALL OPEN TRACKER ISSUES (58), SORTED FOR PLAYTESTERS
Updated 2026-08-12 -- for the Friday 8-player night, build 4.11.913.
Last week's reports produced 9 root-caused fixes; they are ALL in this
build. Section A is the payoff -- please verify.
=============================================================================
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)
-----------------------------------------------------------------------------
#162 Kills/deaths multi-counted; -500 death penalty missing ** HEADLINER **
A same-frame double-hit death credited the killer twice. Fixed and
stress-proven at 4 and 8 nodes. TEST: watch the scoreboard math all
night -- kills exact, one death = one -500. Wrong? Screenshot it.
#156 No fade-to-black at mission end ** HEADLINER **
The window slamming shut WAS the bug. Rounds now end with a 1-second
fade to black (and start with the white flash + fade-in).
#154 Generator: no alarm at thermal trip / never came back
"Generator out" (Yip's voice) now speaks AT the trip. Recovery was
always the manual's rule: back online at HALF-cooled after a spin-up.
TEST: ride heat until the generator drops -- voice at the loss?
#164 (Conn Man) hanging laser beam riding the rudder
Beams can no longer draw unless the weapon is genuinely firing.
If ANY beam hangs anyway, your log names the weapon -- that log is
GOLD, send it with the rough time.
#163 Frozen in the ended mission / hang on exit
Ordered exit everywhere. TEST: host and non-host, end of every round.
#135 Coolant leak with NO flashing annunciator
The leak was flashing the WRONG lamp (and could stick forever).
TEST: take a leak (ram someone) -- the generator lamp for THAT loop
flashes, and stops when it should.
#136 Leaking SHUT loop responds to component toggles
EXPLAINED AS AUTHENTIC: the alarm SOUND is one-shot per new leak,
the lamp FLASH is continuous while leaking. Confirm the description
matches what you see and we close it.
#128 Ramming terrain = instant coolant leak
Ram damage eligibility fixed -- walls and rocks no longer puncture
loops. TEST: bump terrain hard; leaks come from combat, not curbs.
#166 LRM ready on MFD but refuses to fire (until eject)
Root cause was an eject PRESS whose release got eaten (Steam overlay
/ focus steal). Structurally fixed + every eject is logged.
TEST when it happens anyway: tap EJECT (authentic unjam) and report.
#165 X'd-out weapons appear to still fire
Could NOT reproduce -- destroyed weapons refused 4,213 straight
trigger pulls on the bench. What you likely saw: the recharge dial
still cycles under the X (arcade does too) + surviving weapons'
beams. Every energy discharge is now named in the log -- if you see
it again, we will know the weapon by name.
#169 (Lynx) LEAVE LOBBY button clipped; 8-player roster clutter
TEST: host with 8 -- roster clear of the LAUNCH button, LEAVE fully
visible.
#48-family (Cyd) Secondary-display phantoms + stale colors + plasma junk
Phantom filled boxes around inactive CONTROL MODE entries and the
stray block by the heading dial: GONE. Armor rosette colors on glass
panels now track live. Plasma marquee no longer shows boot garbage.
#27 Autocannon/Gauss dealt the wrong damage type (retest for Oracle)
Ballistics = solid shot, no blast splash. BONUS this build: ballistic
rounds can now genuinely MISS a moving target -- dodge and the round
carries downrange (authentic; shells fly a full 5 seconds).
#38 Mech colors reverted on respawn
TEST: custom paint, die, respawn -- colors stay.
#21 Recharge arc + fire-ready dot dark after eng-page use
TEST: use the engineering page mid-fight, weapon keeps firing.
#30 Weapon-state presentation (jam lamp confirmed working)
TEST: btejam lamp lights ONLY on a real jam.
#41 (David/Auric) launcher killed before it could run -- forensics armed
TEST: if join.bat ever fails again, send content\lastrun_join.txt.
#147 Range caret vanished for the session (ticks stayed)
Monitoring: if it happens, the log traps the culprit -- just report.
#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)
-----------------------------------------------------------------------------
#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
#130 Vulture "skating" (gliding without walk animation)
#132 (Conn Man) persistent yellow line on the HUD in every mech
Possibly the #164 beam family -- if you get it, note which weapons
you fired last and send the log.
#143 One panel hit -> smoke erupts from MULTIPLE places at once
#153 Basic-mode elevation limit only applies after cycling modes
#155 PANIC lit: Eng + Weapon MFD banks dead; Score/Radar/Coolant stay live
(Oracle mapped the exact bank split; 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. This build adds a sharper stall detector; the cage
hardware work continues with Nick.
#167 (Oracle) reticle lock circle absent on ALL targets for a stretch
Monitoring: note the time and whether a relaunch cleared it.
-----------------------------------------------------------------------------
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 original 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
-----------------------------------------------------------------------------
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)
#28 Confirm the vital-subsystem-crit death path can fire (machinery in)
#63 PlayerStatus reads raw original-binary 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. 58 open issues total:
19 verify (2 headliners), 13 reproduce, 3 era questions, 3 design calls,
13 known gaps, 9 internal.
Last sweep: 35 issues closed 2026-08-10 (Oracle's annotated pass); this
build fields fixes for 9 of the tickets YOUR reports opened since.
=============================================================================
-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;
};
//#########################################################################
-21
View File
@@ -415,23 +415,6 @@ void
//
Check(this);
Check(application);
// #156 (BT, field-caught watching the fade live): ONLY the local mission
// player may drive application state. A REPLICANT player copy mirrors the
// remote master's simulationState through its update-record header -- so at
// mission end every replicant player on this node also reads
// MissionEndingState here, and its fadeTimeRemaining was never armed by a
// MissionEnding message (ctor value, already <= 0) -> it dispatched the
// second StopMissionMessage on its FIRST tick and Stop() killed the mission
// loop instantly: the 3.0s fade window (and the fade itself) never rendered.
// Receipts: [fade-clk] interleaving remaining=3,2.976,... with a stray
// remaining=-0.976 from the replicant copy. The 1995 pod's replicant
// players never executed this arm; the port's replicant-executable wave
// made them tick, so gate the app-control arm to the owner.
if (GetInstance() == ReplicantInstance)
{
return;
}
switch (GetSimulationState())
{
case MissionStartingState:
@@ -459,10 +442,6 @@ void
}
break;
case MissionEndingState:
// #156 diag: the 3.0s fade window's actual clock, tick by tick.
if (getenv("BT_FADE_LOG"))
DEBUG_STREAM << "[fade-clk] slice=" << time_slice
<< " remaining=" << fadeTimeRemaining << "\n" << std::flush;
fadeTimeRemaining -= time_slice;
if (fadeTimeRemaining <= 0.0f)
{
-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 -469
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,165 +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);
// PALETTE-ANIMATED content (2026-08-10): a palette-expanding window
// (the radar; MFDs under BT_GLASS_MFD_PAL) changes colour with ZERO
// pixel writes -- the ColorMapper family (armor rosette tints, the
// adpal/adpal2 damage flash) writes CLUT entries. Fold the palette
// write generation in so those changes repaint; mono-tint windows
// don't read the palette and keep their pixel-only token.
static int sPalTok = -1;
if (sPalTok < 0) sPalTok = getenv("BT_GLASS_MFD_PAL") ? 1 : 0;
if (w->monoTint < 0 || sPalTok)
token = (token ^ svga->PaletteGeneration()) * 16777619UL;
}
}
// Lamps: each button's RENDERED brightness + held/latched, so a flash toggle or a
// 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)
{
static unsigned long sWin = 0;
static int sPumps = 0, sPaints = 0;
++sPumps; sPaints += repainted;
if (now - sWin >= 2000)
if (gWins[i].hwnd != NULL)
{
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).
+240 -74
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,7 +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;
paletteGeneration = 0; // GLASS dirty-skip palette tracking
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");
@@ -6405,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;
}
//########################################################################
@@ -7191,7 +7390,6 @@ void
svga_palette->paletteData.Valid = True;
svga_palette->modified = True;
((SVGA16 *) graphicsDisplay)->paletteGeneration++; // glass dirty-skip (full rebuild)
Check_Fpu();
}
@@ -7236,13 +7434,6 @@ void
SVGA16Palette
*svga_palette(&((SVGA16 *) graphicsDisplay)->palette[paletteID]);
// GLASS dirty-skip: track whether this write CHANGES anything -- the
// ColorMapper family calls this every Execute (the adpal/adpal2 flash
// alternates palettes even at zero damage, usually writing identical RGB),
// so bump the generation only on a real change or every glass window that
// palette-expands would repaint every pump for nothing.
int palette_changed = 0;
//-------------------------------------------
// If any of the ...TransparentZero modes are used,
// leave color zero undefined for this bit group by
@@ -7284,31 +7475,21 @@ void
{
case RedChannel:
case RedChannelTransparentZero:
if (destination_triplet->Red != source_triplet->Red)
palette_changed = 1;
destination_triplet->Red = source_triplet->Red;
break;
case GreenChannel:
case GreenChannelTransparentZero:
if (destination_triplet->Green != source_triplet->Green)
palette_changed = 1;
destination_triplet->Green = source_triplet->Green;
break;
case BlueChannel:
case BlueChannelTransparentZero:
if (destination_triplet->Blue != source_triplet->Blue)
palette_changed = 1;
destination_triplet->Blue = source_triplet->Blue;
break;
case AllChannels:
case AllChannelsTransparentZero:
if (destination_triplet->Red != source_triplet->Red
|| destination_triplet->Green != source_triplet->Green
|| destination_triplet->Blue != source_triplet->Blue)
palette_changed = 1;
*destination_triplet = *source_triplet;
break;
}
@@ -7329,8 +7510,6 @@ void
svga_palette->paletteData.Valid = True;
svga_palette->modified = True;
if (palette_changed)
((SVGA16 *) graphicsDisplay)->paletteGeneration++; // glass dirty-skip
Check_Fpu();
}
@@ -7434,7 +7613,6 @@ void
svga_palette->paletteData.Valid = True;
svga_palette->modified = True;
((SVGA16 *) graphicsDisplay)->paletteGeneration++; // glass dirty-skip (full rebuild)
# if defined(TESTPALETTE)
std::cout << "L4GraphicsPort::BuildAuxiliaryPalette for port " <<
@@ -7545,7 +7723,6 @@ void
svga_palette->paletteData.Valid = True;
svga_palette->modified = True;
((SVGA16 *) graphicsDisplay)->paletteGeneration++; // glass dirty-skip (full rebuild)
Check_Fpu();
}
@@ -7580,28 +7757,17 @@ void
// LEFT AS HEAP GARBAGE and any pixmap pixel >= that count wrote the
// garbage's high bits into other displays' planes (the #48 stray
// blocks; convicted live by BT_PLANE_AUDIT -- the 480x640 radar
// background carries index 217). Map the remainder to the plane
// BACKGROUND (translationTable[0]): high-index art renders as the
// port's background colour in-plane, and can never leak.
//
// REFINED 2026-08-10 (the sec-surface phantoms): the first #48 fill
// cycled the remainder IN-PLANE (index mod 2^bits), which mapped art
// index 254 onto plane slot 62 -- a LIVE colorMapperMultiArmor damage
// slot. SMODE.PCC's inactive control-mode box interiors and a stray
// 52x13 idx-254 bar baked into BTSEC1.PCX (between the heading dial
// and the armor rosette) lit up in the current right-armor damage
// colour on every render path. On the shipped machine those regions
// rendered BLACK (the heap garbage's low plane bits were 0 ->
// in-plane index 0), so background IS the authentic on-screen result.
// (The 1995 binary shipped the same 64-entry fill and relied on art
// discipline; garbage is not a preservable behaviour, so this stays a
// guarded PORT deviation.)
// background carries index 217). Cycle the in-plane pattern across
// the remainder: high-index art degrades to its (index mod 2^bits)
// colour IN-PLANE, and can never leak. (The 1995 binary shipped the
// same 64-entry fill and relied on art discipline; garbage is not a
// preservable behaviour, so this is a guarded PORT deviation.)
{
int filled = 1;
for (int b = bitMask & 0xFF; b != 0; b &= (b - 1))
filled <<= 1;
for (int i = filled; i < 256; ++i)
translationTable[i] = translationTable[0];
translationTable[i] = translationTable[i & (filled - 1)];
}
Check_Fpu();
}
+21 -21
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:
@@ -382,15 +402,6 @@ protected:
SVGA16Palette
palette[PaletteCount];
// GLASS dirty-skip (2026-08-10): bumped by the palette writers
// (L4GraphicsPort::BuildSecondaryColor on a REAL entry change; the full
// palette rebuilds unconditionally) so the glass repaint token can see
// palette-only animation -- the ColorMapper family (armor rosette tints,
// damage flash) changes COLORS with zero pixel writes, which the pixel
// checksum alone can never catch.
unsigned long
paletteGeneration;
private:
int NUMGAUGEWINDOWS;
HWND *gaugeWindows;
@@ -428,17 +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;
// GLASS dirty-skip: the palette write generation (see paletteGeneration).
// Folded into the repaint token of palette-expanding windows so ColorMapper
// palette animation (armor tints / damage flash) repaints without pixel churn.
unsigned long PaletteGeneration() const { return paletteGeneration; }
};
//########################################################################
-127
View File
@@ -8441,119 +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;
// FIELD-CAUGHT (first live watch): completing into phase 0 hit
// the restore branch -- the world POPPED BACK for the ~2s
// between fade end and teardown, which read as "no fade at
// all". The binary's zone fog STAYS closed after the fade-out
// (nothing reopens it until the next mission's flash). Hold
// black in a terminal phase instead; the next MissionStarting
// edge re-arms through it.
if (local <= 0.0f) { s_phase = 5; }
break;
}
case 5: // held black until the next mission starts
r = 0.0f; g = 0.0f; b = 0.0f;
fn = kNearFloor; ff = kFarFloor;
if (st == gBTFadeStart) { s_phase = 1; s_T = 0.0f; }
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)
{
// Fade-in complete: restore the style planes once so the world
// lands 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);
@@ -9147,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;
-3
View File
@@ -226,9 +226,6 @@ class NotationFile;
// beam from this live sim state -- the authentic per-weapon fire look).
//
int BeamOn() const { return beamFlag; } // 0x46c
// #164: the DRAW-side state read (cross-family, gotcha-22 style) -- the
// beam renders only while the weapon is actually in Firing(0).
int WeaponStateCell() { return GetWeaponState(); }
Scalar DischargeTimer() const { return dischargeTimer; } // forensics
// REPLICANT-side beam aging (port, 2026-07-13): replicated discharge
// state arrives via update records, but replicant subsystems never
+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;
}
+10 -57
View File
@@ -52,41 +52,15 @@ 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 FlickerFloor = 0.0f; // _DAT_004b7f90
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
//
// Cross-family helper (definition lives in the mech game layer; declared here
@@ -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
+51 -689
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,23 +1602,19 @@ 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.
extern int BTIsRegisteredMech(Entity *e);
if (p.target != 0 && BTIsRegisteredMech(p.target)
&& !((Mech *)p.target)->IsMechDestroyed())
{
p.targetPos = ((Mech *)p.target)->localOrigin.linearPosition;
p.targetPos.y += p.aimOffsetY;
}
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())
{
p.targetPos = ((Mech *)p.target)->localOrigin.linearPosition;
p.targetPos.y += p.aimOffsetY;
}
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;
}
static float 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
@@ -7854,43 +7361,6 @@ void
}
}
// #164 repro (BT_CFGTEST): the field composition of Conn Man's hanging
// beam -- a config-mode PROGRAM press landing MID-DISCHARGE. Waits for
// any Emitter/PPC to enter Firing(0), then dispatches ConfigureMappables
// (msg 9, press) to that exact weapon. The alarm re-level knocks the
// state out of 0 with beamFlag still armed; the [beam] STUCK-ON receipt
// + refused draw are the PASS evidence.
if (getenv("BT_CFGTEST") && (Entity *)this == application->GetViewpointEntity())
{
static int s_cfgDone = 0;
static int s_cfgFrame = 0;
if (!s_cfgDone && ++s_cfgFrame > 900)
{
for (int s = 1; s < GetSubsystemCount(); ++s)
{
Subsystem *sub = GetSubsystem(s);
if (sub == 0)
continue;
const int cid = (int)sub->GetClassID();
if (cid != 3016 && cid != 3028) // Emitter / PPC
continue;
if (((Emitter *)sub)->WeaponStateCell() != 0)
continue; // not mid-discharge
ReceiverDataMessageOf<ControlsButton> msg(
9 /*MechWeapon::ConfigureMappablesMessageID*/,
sizeof(ReceiverDataMessageOf<ControlsButton>),
(ControlsButton)0x0f /*a config element press*/);
sub->Dispatch(&msg);
s_cfgDone = 1;
DEBUG_STREAM << "[cfgtest] ConfigureMappables dispatched to '"
<< (sub->GetName() ? sub->GetName() : "?")
<< "' MID-DISCHARGE (state was 0, beamFlag="
<< ((Emitter *)sub)->BeamOn() << ")\n" << std::flush;
break;
}
}
}
// issue #20 verify (BT_BALTEST): alternate a MoveValve (id 4) press to
// the first condenser with a Mech BalanceCoolant (id 0x16) press every
// ~2s -- BT_VALVE_LOG shows the valve going 1->5 then BALANCE pulling
@@ -8111,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
@@ -8610,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)
{
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;
}
}
// 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 << ")\n" << std::flush;
Entity::TakeDamageMessage td(
Entity::TakeDamageMessageID,
sizeof(Entity::TakeDamageMessage),
@@ -9102,57 +8530,6 @@ void
em->ReplicantServiceBeam(ttl1);
if (!em->BeamOn())
continue;
// #164 (Conn Man's hanging beam, legs-anchored, until-death): a
// DESTROYED emitter must never draw a beam. The sim gate resets the
// firing state per tick while the weapon executes, but this draw walk
// reads the raw roster with no destroyed check -- so any path that
// leaves beamFlag armed on a dead weapon (his log: the ERPPC+ERSLaser
// arm pairs died all night; a destroyed arm's segment is gone, so the
// muzzle falls back to the mech ORIGIN = the legs, riding the rudder)
// draws a feet-anchored beam forever. Clear it, log the catch loudly
// (this receipt firing = the upstream stick exists and names itself),
// and skip. Dual-cell read per gotcha 22.
if (em->SimulationStateCell() == 1 || em->StatusAlarmLevel() == 1)
{
// The receipt is the point: it firing means beamFlag stayed armed
// on a dead weapon -- the upstream stick exists and names itself.
// Capped: the draw walk runs per frame.
static int s_stuckPrints = 0;
if (em->BeamOn() && s_stuckPrints < 20)
{
++s_stuckPrints;
DEBUG_STREAM << "[beam] DESTROYED-BUT-ON: '"
<< (em->GetName() ? em->GetName() : "?")
<< "' beamFlag armed on a dead weapon -- draw refused (#164)\n"
<< std::flush;
}
continue;
}
// #164 THE FAITHFUL DRAW CONDITION (Conn Man's screenshot: RED laser
// beam, leg-level origin, weapon dials all GREEN -- a live weapon with
// a stuck beamFlag). The 1995 beam visual rode ContinueDischarge's
// PER-FRAME re-messaging, which stops the instant the weapon leaves
// Firing(0) -- e.g. a config-mode PROGRAM press mid-discharge re-levels
// weaponAlarm out of state 0, the case-0 countdown never resumes, and
// beamFlag (cleared only by ResetFiringState) stays armed forever while
// the weapon itself recovers. Drawing from beamFlag alone is the port
// divergence; require the weapon to actually BE discharging, exactly as
// the message stream did. (Replicants mirror firingActive into
// weaponAlarm 0/2 in ReadUpdateRecord, so the same test serves both.)
if (em->WeaponStateCell() != 0)
{
static int s_stateStuck = 0;
if (em->BeamOn() && s_stateStuck < 20)
{
++s_stateStuck;
DEBUG_STREAM << "[beam] STUCK-ON: '"
<< (em->GetName() ? em->GetName() : "?")
<< "' beamFlag armed outside Firing (state="
<< em->WeaponStateCell()
<< ") -- draw refused (#164)\n" << std::flush;
}
continue;
}
Point3D mz;
em->MuzzlePoint(mz); // LIVE muzzle (tracks the gun)
// MOUNT FALLBACK: when the weapon's mount segment doesn't
@@ -9184,24 +8561,9 @@ 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
mw.Multiply(s_portCache[energyOrdinal]->GetSegmentToEntity(),
localToWorld);
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)
+5 -50
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 const int s_log = getenv("BT_TORSO_LOG") ? 1 : 0;
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)
+3 -3
View File
@@ -26,7 +26,7 @@ L4:
BT:
kind: concept
definition: BattleTech — the primary port target. VWE arcade pod game, Tesla platform release 4.10 (1990s; exact release year unestablished). Game logic = CODE/BT (mostly MISSING as source; reconstructed from the binary).
definition: BattleTech — the primary port target. VWE arcade pod game, Tesla platform release 4.10 (~1995-96). Game logic = CODE/BT (mostly MISSING as source; reconstructed from the binary).
related_terms: [RP, MUNGA, BTL4OPT, source-completeness]
related_topics: [project-overview, source-completeness]
@@ -344,7 +344,7 @@ shadow-field:
databinding-trap:
kind: concept
definition: Our compiled class layout != the original binary, so a RAW offset read *(T*)(obj+0xNN) reads garbage. Use compiled named members / accessors / a bridge fn in a complete-type TU. The systemic reason raw-offset reads fail.
definition: Our compiled class layout != the 1995 binary, so a RAW offset read *(T*)(obj+0xNN) reads garbage. Use compiled named members / accessors / a bridge fn in a complete-type TU. The systemic reason raw-offset reads fail.
related_terms: [shadow-field, bridge, Wword-trap]
related_topics: [reconstruction-gotchas]
@@ -422,7 +422,7 @@ TakeDamage:
ground-model:
kind: concept
definition: The authentic original vertical-position model (FUN_004a9b5c). NO GRAVITY — an ABSOLUTE per-frame ground SNAP via the zone BoundingBoxTree (FindBoundingBoxUnder), lift = 5% of volume X-width. Walls block by FULL FRAME REJECTION; crushable props pass with a crunch.
definition: The authentic 1995 vertical-position model (FUN_004a9b5c). NO GRAVITY — an ABSOLUTE per-frame ground SNAP via the zone BoundingBoxTree (FindBoundingBoxUnder), lift = 5% of volume X-width. Walls block by FULL FRAME REJECTION; crushable props pass with a crunch.
related_terms: [SLD, BoundingBoxTree, MoveAndCollide]
related_topics: [locomotion]
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

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