Author SHA1 Message Date
CydandClaude Opus 4.8 6a60b46f8d plasma window: initialize the display area (junk shown until the callsigns)
The desktop plasma marquee showed heap garbage from boot until the gauge
renderer first drew the callsigns.  Root cause [T0]: PixelMap8(w,h) allocates
its pixel bytes UNCLEARED (GRAPH2D.cpp `new Byte[w*h]`, no memset) and
Video8BitBuffered never clears them either -- only changedLine[] is zeroed.
The real pod never showed it: the serial PlasmaDisplay transfers only CHANGED
lines, so the garbage never left the buffer and the hardware marquee sat at
its power-on blank.  PlasmaWindow::Update however blits the WHOLE buffer
every frame, exposing the junk for the window-up-to-first-draw gap (seconds
on slower rigs).

Fix: PlasmaWindow ctor memsets the pixelBuffer to index 0 (the near-black
background) -- the window comes up blank like the hardware did.  Glass-layer
only; the serial pod path is untouched.  Verified: glass boot shows a clean
marquee from the first visible frame.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:29:33 -05:00
Joe DiPrimaandClaude Fable 5 fc71f9c0bb #156 the mission start/end fade RECONSTRUCTED: POVStartEndRenderable was an inert stub (rounds hard-cut) -- the binary's 5-phase fog machine (ctor @00454394, Execute @0045447c; constants byte-read @0x454740-50: flash 0.1s, fades 1.0s, plane floors 0.01/0.05) now runs at the renderer frame head keyed on the local player's SimulationState (3 MissionStarting -> white flash + 1.0s fade-in from white; 4 MissionEnding -> 1.0s fade-to-black, planes closing to the floors, re-arm). Drives the live fog pipeline (currentFogNear/Far + FOGCOLOR, which the frame Clear also samples -- the fade owns sky/void too) and re-reads the STYLE members per frame exactly as the binary re-reads live view fog (mid-fade searchlight swaps track). Armed by the now-real ctor in btl4vid.cpp (house pattern: inert object, module machine). Bench fade_bench.sh: 40s relay-clocked round, both nodes PASS armed + phases 1/2/3/4 in order with the end edge on simState=4. Rider: the [rio] STALL detector now keys on REPLY drought instead of request age (which the 50ms poll refreshed forever -- the detector could never fire; #160 telemetry).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:54:12 -05:00
Joe DiPrimaandClaude Fable 5 2938d4918c context-system housekeeping: the 2026-08-11 wave lands in the digests -- router Current-front refreshed + the four audit ledgers listed + the docs/ AI-vs-human split labeled; test-harness gains the standing bench-suite table (kd4/kd8/proj_ttl/gen_edge/xfire + rig notes: clone-the-pilot-page pattern, only map-defined dropzones are valid); multiplayer gains the K/D authority + #45 mirror section with the 4/8-node stress record; combat-damage gains the synchronous-death-transition section (gotcha 25 pointer, tripwire semantics); open-questions replicant-delivery entry gains the 0-of-35-deaths bench datapoint (needs field conditions); the #154 multi-agent audit lanes preserved from session temp as docs/GEN_WARNING_AUDIT_20260811.md (with the no-speech retraction lens noted). checkctx: CLEAN.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:37:59 -05:00
Joe DiPrimaandClaude Fable 5 4b986af94d KB: gotcha section 25 (same-pass edge latch sampling deferred state -- the #162 double-credit class) + decomp-reference gains the synchronous mode-9 timing fact (part_012.c:14761 reads it flipped in-pass) and the 4/8-node mirror-convergence bench record
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:16:39 -05:00
Joe DiPrimaandClaude Fable 5 1b6d247e51 #162 4-node retest CAUGHT + FIXED a same-frame double-credit: two lethal deliveries of one salvo (direct + splash, 2ms apart, no sim tick between) each read destroyedAtEntry=0 -- the entry latch samples movementMode 2||9 but our death transition (mode 9 write) ran NEXT TICK in UpdateDeathState, while the binary flips mode SYNCHRONOUSLY inside the same damage pass (its tail gate part_012.c:14761 reads 9||10 already set). Result: duplicate kill credit to the shooter (bench receipts: kills 1->2 in 2ms, awards 525.00/516.67) and a duplicate VehicleDead whose death cycle the receiver dedup swallowed -- but the kill report had already escaped. Invisible to every 2-node bench (direct kills deliver one lethal hit; splash+direct overlap needs crowds) -- the 8-player field 'kills way off' shape. Fix: the death tail now runs the once-per-death transition inline (mode 9 + type-6 + effects) before returning, exactly the binary's sequence; the next lethal message latches on both destroyedAtEntry and deathBlastArmed. The wrong 'synchronous' claim in the old comment corrected. Benches: kd4_bench (receipt-derived per-player invariants + bystander mirror convergence + no-mirror-exceeds-authority + inst=R audit) PASS 9/9/9/9 SWALLOWED=0; kd8_stress staged (8-node all-vs-all melee).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 17:52:51 -05:00
Joe DiPrimaandClaude Fable 5 7fa6dc536c #135 FIXED, two defects: (1) the generator lamp special was mis-reconstructed -- the binary @004cc27c is table_0051d070[generatorNumber] (bytes {0x29,0x1A..0x1D}, 1-based A..D, same shape as the condenser special), not an aux-screen placement probe generators never fill; a leaking generator now flashes its own lamp (benched: '[lamp] 0x1a <- 0x37 (FLASHING)' on a GeneratorA leak; was the field-logged 'NO LAMP RESOLVED'). (2) the eternal loop-button flash: Lamp::SetAlertState/SetState drop their panel notify when the edge lands off-page or in the one-frame mask race of a page transition, and the panel value latches the last-asserted flash forever -- Oracle's 'Loop 6 NEVER stopped' with Condenser6 SET/CLEARED balanced 3/3 in his log (the alarm manager was right; the lamp was stale). The skipped notify is now remembered and delivered at the lamp's next in-mode frame. KB: decomp-reference gains the 0x51d070 table + the stale-flash gotcha.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 17:08:48 -05:00
Joe DiPrimaandClaude Fable 5 14c62f184d #166 mechanism pinned + two input-layer fixes: Oracle's LRM jam = the ammo bin PARKED at Ejecting(3) with rounds aboard (his log: 'NoAmmo (gate2): rounds=18 binState=3', twice; eject tap cleared it exactly as the handler predicts). The MFD/HUD 'desync' is honest: the full green circle is the RECHARGE dial (untouched by the gate2 pin) and the btejam lamp keys only on weaponAlarm 5 (binary @004c9a38) -- state-7 pins show neither, authentically. The parking producer needs an unpaired eject PRESS, which pod hardware cannot make but our glass mouse layer could: (1) WM_CAPTURECHANGED now releases a held panel button when capture is stolen (Steam overlay/popups ate the WM_LBUTTONUP), (2) a new press releases any still-held previous button instead of orphaning it. Forensics: every EJECT message now logs data/bin/ejectState/binState/rounds always-on (ejects are rare) -- Friday names the producer if it recurs. Smoke: BT_EJECTTEST replays the full press/hold/dump receipt chain incl. the authentic mid-eject gate2 pin.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 16:45:12 -05:00
Joe DiPrimaandClaude Fable 5 18fba98298 #165 does not reproduce -- the destroyed-weapon fire gate HOLDS on the exact field path: bench (xfire_bench.sh) kills ERPPC_1+ERSLaser_1 via the cascade's own ForceCriticalFailure writer under held autofire -- ERSLaser_1 9 FIRED pre-kill, 0 post, 4213 refusals (destroyed=1), surviving ERMLaser fires on. Rajel's field read explained: the refusal tail recomputes output voltage every tick (binary-authentic), so a destroyed weapon's recharge dial keeps CYCLING under its X while the surviving torso lasers put the beams peers saw. Rider: the [emitter] FIRED receipt now NAMES its weapon under BT_DMG_LOG (the #110 nit) so the next field session can settle it by name.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 16:13:39 -05:00
Joe DiPrimaandClaude Fable 5 df4d1c8ac7 #169 rider: shorten the roster build-gate tag to [WRONG BUILD] -- the '-- WILL NOT LAUNCH' tail clipped at the 536px row edge (caught in the layout preview render)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 15:55:23 -05:00
Joe DiPrimaandClaude Fable 5 c8373f2407 #169 lobby room layout: LEAVE button was half-clipped because 560x360 was the OUTER window size (the caption bar ate the bottom ~47px of a client that needed 336+), and rosters past 6 members drew through the fixed LAUNCH frame at y=250 (rows flow to 268 at 8 players). Buttons now sit below the full 8-slot roster (LAUNCH 280-326, LEAVE 336-366) and the window is sized by CLIENT rect via AdjustWindowRect (560x382). Geometry-proven; visual verify = next lobby + Friday's 8-player night.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 15:51:07 -05:00
Joe DiPrimaandClaude Fable 5 fef6fad47a #154 phrase identity ear-verified: the generator trigger's sequence (Warnings01 z4/z0/z3) is Yip's spoken 'generator out' line -- the deviation now speaks the real voice at the trip. KB tier bumped to T2.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 15:47:15 -05:00
Joe DiPrimaandClaude Fable 5 8d2f48225b #154 correct the record: the warnings ARE VOICE -- Warnings01/AllWarning zones are Yip's recorded words, phrase-sequenced by the AudioControlSequences (our own #119 commit 42b0691 said so); the generator sequence z4/z0/z3 is the spoken 'Warning, generator out'. The same-day 'BT 4.10 shipped no speech / era memory is Firestorm' claim is retracted -- it rested on preset names and ignored repo history; with the voice real, the era memory of hearing it at the TRIP may be genuine (possibly a later floor revision fixing the same authored bit our deviation flips). The fix itself stands and is strengthened. KB entry rewritten + process lesson recorded (grep git log before asserting absence).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 15:27:07 -05:00
Joe DiPrimaandClaude Fable 5 08fc87ec6e #154 settled: the generator-out warning was authored on the WRONG EDGE in the shipped 1995 content -- BTL4.RES keys it (trigState=4, inverse=1, Start) = fire on LEAVING GeneratorOut, so a thermal trip was SILENT and the klaxon played at recovery/respawn (live-benched both ways; decode triple-verified: T0 read order, binary @0043e7d4/@0043eb40 semantics, independent byte parse; 144-row census = the game's ONLY inverse-Start fault trigger). DELIBERATE DEVIATION (user-approved, the port's only content override): AudioStateTrigger ctor flips it to enter-4 at stream time -- warning at the TRIP, silent at recovery + the respawn 4->0 edge; BT_GEN_WARN_SHIPPED=1 restores shipped. Threshold audit closes AUTHENTIC: recovery at heatAlarm NormalHeat = T<=1000 vs trip at 2000 = exactly the manual p23 half-cooled rule, and the heat bar's red fill maps to it to the tick. Era-witness reconciliation: BT 4.10 shipped NO speech samples (full AUDIO1/2.RES census) -- the remembered voice at trip is Firestorm's. Rig: BT_GEN_HEAT injection + BT_AUDIO_EDGE receipts, gen_edge_bench.sh PASS (4 fixups, trip FIRED, recovery 0). KB: decomp-reference generator section + no-speech note; manual p23 also answered #133 (X = crit-destroyed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 07:39:42 -05:00
Joe DiPrimaandClaude Fable 5 e2ab17665d #168 settled from the binary: projectiles are TIME-capped, never range-capped -- plain shell 5.0s (@4bddec), missile burnTime+10s + y<-1 kill-plane (@4bef78), WeaponRange is fire-control only (FireWeapon @4bcc60 has no range gate), so AFC50 hits at 2000+u are authentic. Two port defects killed: the invented len*1.3+60 flight cap (replaced by the authentic clocks) and the stale-point auto-hit (unguided contact tested the fire-time pick, so a locked AC could not miss a moving target -- the pool now tracks the live target). KB: decomp-reference gains the flight/expiry section, CLASSMAP's swapped authoritative/ghost Performance labels corrected (ReplicantInstance=4 [T0]), missile.cpp's misdecoded @4bf594 constant pool re-annotated, Missile proximity fuse 4.0u recorded. Bench: proj_ttl_bench.sh (probe fizzles 5.012/12.012 vs caps 5.0/12.0; 86 field launches, 0 early, 0 phantoms). Rides along: the #163 BTSteamNet_ShutdownAll header decl that missed d3f4b9a.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 04:39:37 -05:00
Joe DiPrimaandClaude Fable 5 dfefdfa1d0 #128 terrain-ram instant coolant leak FIXED at the eligibility tag: GUID 0x50e590 is the AGGREGATE BANK's node (0xBBE), not the HeatSink base family -- the family-wide reading made every coolant-bearing subsystem (myomers, condensers, reservoirs...) rattle-eligible, so one over-floor ram sub-hit sprang an instant leak (field: Michael's 3 rams all 'Myomers condition 2 SET' -- a subsystem 1995 can never select). Authentic set {HeatSinkBank .30, Gyroscope .35, Torso .25}; scrapes remain free via the byte-anchored 0.5-pt floor (the 30k-lines/night [crashdmg] spam now sampled). Audit: docs/RAM_LEAK_AUDIT_20260811.md. Bench ram_bench.sh: 20 rattles, zero ineligible strikes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 02:33:14 -05:00
Joe DiPrimaandClaude Fable 5 d3f4b9a55a #163 round-end freeze + hang-on-exit FIXED: bare ExitProcess ran DLL detach with live Steam threads (no SteamAPI_Shutdown existed anywhere) and intermittently deadlocked -- the dead process's fullscreen frame squatted on screen while its menu child hung in SteamAPI_Init against the wedged sibling (field-captured, ZEUS 22:12:38). Ordered exit everywhere: flush forensics, BTSteamNet_ShutdownAll BEFORE the child spawns, hide our windows, TerminateProcess (detach skipped, deadlock structurally unreachable). Audit ledger: docs/MISSION_END_AUDIT_20260811.md (also yields #156's fade root cause). Bench: relaunch_bench.sh -- old pids dead, menu children clean
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 01:58:23 -05:00
Joe DiPrimaandClaude Fable 5 420558cf4a #162 eject-arm predicate fix, canary-caught: destroyedAtEntry now samples the binary's MODE-based FUN_0049fb54 (movementMode 2||9), not alarm-based IsMechDestroyed -- EjectPilot's alarm-10 pre-write made a real punch-out read 'already destroyed' and silently ate the suicide type-2 (-499 arithmetic) while cook-off suicides scored; the alarm-vs-mode trap #108 fixed for deathBlastArmed, one latch over. Benches: eject canary 6/6 deaths+costs+type-2s (5 suicides incl. the real eject, 1 kill); primary kd_bench 7/7/7/0 re-verified
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 01:09:13 -05:00
Joe DiPrimaandClaude Fable 5 94e80311f2 open-questions: the replicant-side TakeDamage delivery mechanism (inst=R executions vs ENTITY.cpp reroute) -- consequences gated by #162, cause unmapped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 00:36:35 -05:00
Joe DiPrimaandClaude Fable 5 77bb54d333 KB expansion from the #162 audit: full five-agent evidence record preserved (docs/KD_CHAIN_AUDIT_20260811.md), decomp-reference gains the audit's hard addresses (VehicleDead arm internals, the +0x290 never-set byte-scan, the DropZoneReply clear pair, the 2|9 vs 9|10 latch asymmetry), the stale 'inflicted-only' claim swept (mech.cpp), cross-links + KD plan marked historical
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 00:27:47 -05:00
Joe DiPrimaandClaude Fable 5 3545056395 #162 K/D chain fixed at the authority model, once and for all: death tail master-gated (the 1995 sender one-shot restored -- the binary has NO receiver dedup, +0x290 never set [T1 byte-scan]); the #81 latch-clear misattribution corrected (binary site = DropZoneReply tail); steam eggs stamp the host's advancedDamage mission-wide (penalty was silently off for every steam match); #38 badge/patch ride the lobby (bd/pt keys). Bench: kd_bench.sh, five hard invariants, 4/4 deaths+costs+credits, zero dupes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 00:21:46 -05:00
Joe DiPrimaandClaude Fable 5 40c35946cd tester handout regenerated after Oracle's annotated sweep: 35 closed, 59 open, 883 headliners called out (#149 glass fix + #150 double-kill, first fielded build with both)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 15:26:31 -05:00
Joe DiPrimaandClaude Fable 5 47dcc62fc1 pod RIO investigation day-end: board mutes ~13s after each start (render-load/power-rail hypothesis for tomorrow); USB re-enum does NOT revive it; keep tools/probe_rio.ps1 (raw COM1 hand-poll); note the stall-detector keying fix needed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 13:40:53 -05:00
Joe DiPrimaandClaude Fable 5 9754fb1295 correct the record: the 2026-08-10 boot wedges were the USB-serial adapter hanging an unbounded overlapped write, NOT print-deadlock (confounded A/B, retracted); pod runbook gets the full COM1/FTDI diagnosis + remote minidump procedure
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 13:35:13 -05:00
Joe DiPrimaandClaude Fable 5 716e10e3fa serial write hardening: bound the overlapped-write wait (500ms + CancelIo + 3s sick window) -- a dropped USB-serial link parked the game forever in GetOverlappedResult (ALPHA-MR minidump: SendPacket <- RIO ctor); txTO= counter joins the [rio] health line
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 13:30:41 -05:00
Joe DiPrimaandClaude Fable 5 ab1b3439f4 [rio] telemetry FIX: printing from the serial poll path deadlocks against the PCSPAK RX thread (0-CPU park ~10s after every live-RIO boot, A/B-convicted on ALPHA-MR) -- GetNextEvent now only COUNTS; the printer (BTRioHealthReport) rides the glass tick beside [glassperf]
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 13:06:06 -05:00
Joe DiPrimaandClaude Fable 5 7cd58d3000 pod runbook: schtasks /end orphans the game (dead-RIO trap) + self-cleaning runpod.bat
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 12:35:52 -05:00
Joe DiPrimaandClaude Fable 5 a28878beaf [rio] link-health telemetry: req/rep/btn counters, reply age, the existing (never-printed) line-error/abandon/board counters, and STALL/RECOVER event lines -- default-on for hardware RIO, BT_RIO_LOG=0 opts out (encoder-dropout hunt on ALPHA-MR)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 12:34:10 -05:00
Joe DiPrimaandClaude Fable 5 a7722d9ca9 pod: EDID monitor:id binding DEPLOYED + verified on ALPHA-MR; runbook updates (Default_Monitor radar identity, cmd 1>> trap, tester-procedure deploy verified)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 11:56:26 -05:00
Joe DiPrimaandClaude Fable 5 bf04e1c079 port-layer papercuts for the playtest: #69 Denkou rename, #71 plasma X=minimize, #157 round-end UX (foreground handoff + lastrun clean-exit markers + bat sign-off rewording); tester-facing open-issues handout (docs/) + field-log anatomy notes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 11:29:01 -05:00
Joe DiPrimaandClaude Opus 5 46ff8dd262 #150 double kill FIXED (two binary-cited gates) + #151 measured AUTHENTIC to the point
#150 -- one kill credited twice (Rajel on Oracle, Oracle on Conn Man).
CAUGHT: one death -> two type-2 arms 1ms apart (awards 501.96/505.88 -- the
tally GREW between them), victim inst=R.  Mechanism: this port deliberately
applies damage on replicants for local visual response; the report tail ran
there too, and a replicant's movement mode cannot flip to wreck until the
victim's death record round-trips -- so each round of a killing VOLLEY inside
that window re-posted the kill.  Why missiles, why intermittent.

The binary forbids the whole situation: its handler OPENS with the assertion
  if ((flags & 0xc) == 4) Fail("Replicant Mech recieving takedamage",
                               MECH.CPP, 0x3da)          [part_012.c:14613]
-- TakeDamage on a replicant was a BUG in 1995; the tail only ever ran on the
victim's master.  Fix: (1) gate the REPORT TAIL master-only (local damage
application stays); (2) adopt the binary's once-latch -- the 0->1 edge of
IsMechDestroyed() across the handler call (local_14), replacing the
deathBlastArmed form for the report gate.

VERIFIED (scratchpad/night14/scorepair.sh): 3 victim deaths, 5 shooter
respawns -> exactly one type-2 per credited kill, kills 0->1->2, zero doubles
(was: kills 0->2 on one death).

#151 -- panic eject "-499 instead of 0" is AUTHENTIC 1995 SCORING, decoded
from EjectPilot @0049f854: the punch-out dispatches a SELF TakeDamage of
  damageAmount = role->killBonus (+0x1c, 500 in dfltrole), type Explosive,
  senderMechID = SELF, player->suppressConsole = 1, graphicAlarm -> 10
which flows through the report tail as a SUICIDE kill: award = -CalcKillScore
(your own death), plus the type-1 death cost.  1000 - ~999 - 500 = -499:
the binary predicts Oracle's exact number.  Our port already used
role->killBonus for the blast (mech.cpp:652) -- faithful end to end; bench
shows each eject charged exactly once (-900/-1050 across three ejects).
Panic eject = scored as suicide.  Whether that is fun is a design question;
it is not a porting defect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-10 10:30:12 -05:00
Joe DiPrima 773595b347 integrate Cyd's glass-panel-perf (adopted over the local dirty-gate draft)
# Conflicts:
#	engine/MUNGA_L4/L4GLASSWIN.cpp
2026-08-09 23:27:16 -05:00
CydandClaude Opus 4.8 3aeb2dbbe8 glass panels: add the PlaneChecksum header decl (completes c9e25e5)
The dirty-skip commit staged the header via a lowercase path (l4vb16.h) while git
tracks it as L4VB16.h on this case-insensitive FS, so the PlaneChecksum declaration
was left out -- c9e25e5 as committed would not build from clean (L4VB16.cpp defines
SVGA16::PlaneChecksum, L4GLASSWIN.cpp calls it, but the header never declared it).
This adds the one-line decl.  HEAD now builds standalone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-09 23:03:41 -05:00
CydandClaude Opus 4.8 c9e25e59c5 glass panels: dirty-skip -- repaint only the windows whose gauges changed
Follow-on to the HALFTONE->COLORONCOLOR fix.  BTGlassPanels_Tick repainted all 7
glass windows every ~16 Hz pump unconditionally.  Now each window carries a change
token and the pump re-blits ONLY the windows whose token differs:

  token = FNV-1a checksum of the shared gauge pixelBuffer masked to the bits this
          window can show (SVGA16::PlaneChecksum over primary + Eng-twin + RGB-group
          ports)  combined with  each button's RENDERED lamp brightness + held/latch.

Folding the flash BRIGHTNESS (not the raw lamp state) into the token means a flashing
lamp repaints exactly when it toggles; a static panel or an idle cockpit skips its
expand + StretchDIBits entirely.  The masked checksum reads the RENDERED RESULT of the
gauges' values, so it catches everything -- discrete value gauges, the continuous
radar sweep, any imagery -- with no gauge->port->window plumbing and no risk of a
frozen display (full pass, no stride; collision-free in practice).

Measured (dev box, solo mission, BT_GLASS_DIRTY): ~31 pumps/2s -> 4-15 window-repaints
vs 217-224 always-on (~15-40x fewer); avg frame work 3.3 -> 0.93ms.  BT_GLASS_DIRTY=1
logs the repaint tally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-09 23:01:20 -05:00
CydandClaude Opus 4.8 f3d27f51c2 glass panels: drop HALFTONE stretch (the per-display-mode perf sink)
Playtester (Dave, SCREECH-PC, 4K) reported ~20 fps in the exploded per-display
glass panels vs ~130 fps in the cockpit surround -- same scene, same machine
(solo_20260809.log; maxDraw 57-92ms in panels vs 6-31ms surround).

Root cause: BlitSurface used SetStretchBltMode(HALFTONE) -- GDI's slowest,
per-output-pixel resample filter -- and BTGlassPanels_Tick repaints all 7 glass
windows SYNCHRONOUSLY on the main render thread every ~16 Hz.  7x HALFTONE
StretchDIBits of a 640x480 surface per pump stalls the frame.

Default to COLORONCOLOR (nearest); BT_GLASS_SMOOTH=1 restores HALFTONE.  The
MFDs are low-res pixel content, so nearest reads crisp -- arguably closer to the
pod CRT than the blur.  A/B on the dev box (fast, understates Dave's gain):
avg work 3.3->1.4ms, maxDraw stall 12-27->7ms, ~2.2x more frames per window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-09 22:40:25 -05:00
Joe DiPrimaandClaude Opus 5 2ed51cb1f3 #149: [glassperf] VERIFIED in the 2-node render harness (correcting 9e78d1c's premature claim)
9e78d1c said 'smoke-tested: lines live' -- that run never entered the render
loop and printed nothing.  This run did: 54 [glassperf] lines, e.g.

  ticks=16 tickMs=256.9 | Heat MFD 24.8 | Engineering 20.6 | Comm 18.7 |
  LeftW 20.6 | RightW 22.8 | Secondary/Radar 67.9 | ...

First finding, already: the synchronous panel sweep costs ~257ms/second even
on the DEV box (25% tax, hidden in headroom), and Secondary/Radar -- the
ROTATED portrait blit -- is the most expensive window.  Oracle's machine
paying 4-8x that through his driver is now a quantified hypothesis, not a
guess.  Optimization targets regardless of his verdict: dirty-gated repaints,
non-HALFTONE unscaled blits, cheaper rotation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-09 19:05:20 -05:00
Joe DiPrimaandClaude Opus 5 9e78d1cc23 #149: [glassperf] -- per-window glass paint timing, one line per second, default on
Oracle confirmed the separate-window glass mode WAS fast and tanked ~2 builds
back, so the regression is real and expresses only on his machine.  This names
the cost from inside his own session log:

  [glassperf] ticks=N tickMs=T | <window> n=paints ms=cost | ...

tickMs is the synchronous UpdateWindow sweep measured INSIDE the render frame
(L4VIDEO calls the tick), so on a machine where GDI serialises against D3D
present it IS the per-frame tax, and the per-window split names the panel.
Each WM_PAINT is QPC-timed at the dispatch chokepoint (the HALFTONE
StretchDIBits whose cost is driver-dependent).  Default ON like [segperf];
BT_PERF_LOG=0 opts out.  Smoke-tested: lines live in a 60s panel run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-09 18:51:51 -05:00
Joe DiPrimaandClaude Opus 5 2b6b0276fd #152: the real hole -- glass/RIO rigs had NO torso-centre control; wire pod button 0x42
The reported "torso no longer recentres on its own" decomposed into two parts:
the auto-recentre in Mid/Adv was never authentic (it was the stuck-centerCommand
bug acting as a phantom feature since ~674 -- analysis on the ticket, awaiting
Oracle's 1995-memory verdict), but underneath it sat a REAL defect: with the
phantom gone, a glass/RIO player had no way to recentre the torso at all.

WHY.  The only centerCommand writer lived inside the desktop key-bridge block,
which is OFF whenever a RIO/PadRIO is present -- glass and the pod both.  The
pod's dedicated CENTER button (0x42, "the shipped .RES name", UP arrow via
bindings.txt) reached nothing: benched two scripted 0x42 holds on the RIO path,
ctrCmd=0 throughout, twist parked forever.  (Keyboard X/NumPad5 recentred only
as a side effect of ALL-STOP -- you could not recentre without stopping.)

FIX, two pieces, both existing patterns:
  * L4PADRIO::EmitButton -- the documented single chokepoint every button
    source funnels through -- publishes the 0x42 HOLD state
    (gBTTorsoCenterHeld), exactly the 0x3F ReverseThrust precedent.
  * mechmppr gains ONE unified centerCommand writer, deliberately OUTSIDE the
    key-bridge gate (the same placement lesson as the mode-cycle hook):
    hold = torsoCenter(@0x154 databound) OR gBTTorsoCenterHeld(0x42) OR the
    one-frame X pulse; asserts while held, clears on release.  Single writer
    == the sources can never stomp each other's clear.

BENCHED (scratchpad/night14/center42.sh, RIO path, bridge off):
  before: ctrCmd=0 in all samples across two 0x42 holds; twist parked at 1.478
  after:  hold -> ctrCmd=1 recen=1, twist slews 2.22->0.44 at the authored
          0.87 rad/s; twist input DURING the hold cancels sim-side (authentic);
          release clears; no button -> aim holds (authentic Std/Vet).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-09 17:26:27 -05:00
Joe DiPrimaandClaude Opus 5 aa250c4e22 #149: [segperf] telemetry DEFAULT ON for every player (BT_PERF_LOG=0 opts out)
Operator call: an opt-in flag on the one machine we most need data from is
the silent-diagnostic mistake this project's doctrine exists to prevent.
Every session log now carries [segperf] beside every [rstat] window, so the
next playtest gives the 817->857 stall hunt cross-machine baselines for free
-- Oracle's stalling rig and Sauron's clean one, same night, same build.
Cost: ~2 QPC reads per segment query, microseconds per second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-09 17:09:15 -05:00
Joe DiPrimaandClaude Opus 5 a77c5a1407 #149: measure the filed suspect -- the #141 segment sweep is EXONERATED; ship [segperf] telemetry
A/B on identical 2-node beam-heavy runs (scratchpad/night14/segperf.sh),
BT_BEAM_SEGFRESH=1 (swept behaviour) vs =0 (pre-sweep compose at the beam
site, the only per-frame swept call):

                    fresh(swept)   legacy(pre-sweep)
  rstat blocks>50ms       0              0
  maxDraw worst         730ms          646ms      (mission-load spike, BOTH)
  segperf dirty-passes   38/s            4/s      <- the sweep DOES multiply
  segperf accessor ms   0.97/s          0.57/s    <- ...by 0.4 ms/s.  Noise.

So the invalidation-storm hypothesis I filed on #149 is wrong by three orders
of magnitude, and Oracle's sustained 50-104ms stall window does NOT reproduce
on this rig at all.  Refusing to guess a third time: the build now carries the
telemetry to answer it on the machine that actually regresses --

  [segperf] calls= dirty= ms=   printed beside every [rstat] window under
  BT_PERF_LOG (JMOVER counters; two integer increments when unset), and
  BT_BEAM_SEGFRESH=0 remains as a one-env A/B for the beam site.

Default stays FRESH (the swept accessor): its measured cost is trivial and it
is the correctness-cautious side while the peer-beam-staleness question is
unmeasured.

Next for #149: Oracle runs one session with BT_PERF_LOG=1.  If [segperf] ms is
large inside his stalled windows, segment work is implicated on HIS
configuration and BT_BEAM_SEGFRESH=0 gives the immediate A/B; if it is small
(as here), the stall is elsewhere in the 817->857 delta and we hunt with his
numbers instead of my theories.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-09 16:46:10 -05:00
Joe DiPrimaandClaude Opus 5 84f8b1c415 night14: #137 fix write-up + close
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-09 16:20:19 -05:00
Joe DiPrimaandClaude Opus 5 0d6ed40db9 #137 FIXED: restore the binary Reset's +0x58c re-seed -- the respawn freeze was a
teleport-poisoned finite difference, not heat, not the reset, not velocity

The one-line fix is the binary's own SECOND Reset instruction, dropped in
transcription:  FUN_00408440(mech+0x58c, param_2)  ==
    accelPrevPos = origin.linearPosition;
+0x58c is the previous-position memory of the AccelerationLastFrame ring feed
(+0x81c/0x824/0x828/0x82c).  The port reconstructed the ring faithfully (ctor
part_012.c:9836, derivative :15169) but Reset never re-seeded its cache, so the
first post-respawn sample computed |newPos - prevPos|/dt = TELEPORT DISTANCE/dt
(~1e5) into the velocity ring; the ring-mean derivative spiked
AccelerationLastFrame (pure forward, with an opposite-sign echo ~15 frames later
as the sample rotated out of the 15-ring); and the myomer integrator @004b8d18
turned it into a one-tick pendingHeat deposit of ~3e9:
    termAccel = (1-accEff) * |v| * |a| * m * dt
      = 0.2 * 40 * 1.04e5 * 75000 * 0.044  =  2.75e9
snapping the freshly-reset myomers from T=77 to T~9000 against failT=2000 ->
speedEffect 0 -> speedDemand *= 0 -> "respawned unable to move until it cools".

WHY ~8% IN THE FIELD: the deposit needs |v| in the same 1-2 frames, so 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.  Idle-throttle respawns deposit ~nothing.  (And frozen-subset-of-
died-hot: died hot == was running hard == lever still forward.)

MEASURED, same abusive bench (0.95 throttle + continuous autofire):
    before: deposits up to 3.35e9, every one 3-4 lines after a Mech::Reset;
            4-6 of 7 respawns frozen; post-reset myomers T 7700-12100
    after:  deposits >1e7: ZERO across 7 respawns; frozen: ZERO;
            post-reset myomers T 77-178 (vs degradeT=1000)

THEORIES KILLED ON THE WAY, each by operand data, in order:
  * stale pendingHeat carryover (bounded to 1 frame, +1.2K -- gates agent)
  * slow accumulation during the death window (consumers tick the wreck)
  * conduction from a hot neighbour (roster-wide snapshot: ALL partners 77;
    flow trap: zero e6 flows into the myomers, ever)
  * drag/impulse writers (both trapped: never fired)
  * my own earlier "velocity-driven, players accelerate to top speed" close of
    the ticket -- arithmetically impossible (input ceiling ~6.5e5/frame ~=
    60 deg/s; the observed snap was 1,100-21,000 deg/s) and corrected in
    context/subsystems.md, which carried the wrong paragraph.
  * the localAcceleration zero-fill (+0x1dc) restored along the way is KEPT --
    the binary does it -- but it was NOT the cause; the snapshot is rebuilt
    from the position difference one frame later.

Probes kept (all BT_HEAT_LOG-gated): roster-wide [myofreeze] at-death/at-reset/
post-reset (T + heatEnergy + pendingHeat per subsystem), the [heatflow]
conduction trap with full operands, the [myodep] deposit trap with mech
identity + acceleration components.  Engine MOVER.cpp traps reverted -- they
proved their negative (drag/impulse innocent) and do not belong in engine
source.

Gotcha #30 records the class: when the binary's Reset writes a cell you don't
recognize, that write IS the spec -- transcribe the whole zero/seed list; any
prev-value cell backing a finite difference must be re-seeded at every
teleport; and derived state (T) sampled at the reset proves nothing about the
backing producers.

The operator called the shape of this two days ago: "maybe the math gets screwy
in respawning while some systems are ticking while values are being reset."
That is precisely what it was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-09 16:19:36 -05:00
Joe DiPrimaandClaude Opus 5 df1b4651b3 #137 CALIBRATION SETTLED: the myomer heat math is faithful -- constants read byte-exact
Read the integrator's constants from .rdata rather than inferring them.  Row
` 4b8ee0  5dc30000 0000003f 00000000 0000803f` gives:

    _DAT_004b8ee4 = 0.5f   the kinetic half  (work = mass * |v|^2 * 0.5)
    _DAT_004b8ee8 = 0.0f   the Abs() idiom zero
    _DAT_004b8eec = 1.0f   the (1 - efficiency) complements + gear clamp floor

All three are exactly what the port computes, and the logged terms reproduce
from the authored tuning to the digit:

    work        = 75000 * 54^2 * 0.5                       = 109.3e6
    complement  = 1 - VelocityEfficiency(0.995)            = 0.005
    termKinetic = 109.3e6 * 0.005 * (dt*28 = 0.728)        = 398e3  (logged 399003)

THE ONE DEVIATION IS DELIBERATE AND IS THE FAITHFUL CHOICE.  The binary applies
NO time_slice to the kinetic term (`fVar5 * fVar1`) while climb and accel both
carry param_2 -- a per-frame energy add at the pod's FIXED ~28 Hz.  The port's
`work * (time_slice * 28)` is identical at 28 Hz (dt*28 = 1.0) and holds the same
heat-per-SECOND at any frame rate.  Transcribing it literally would add the full
term once per frame, so at Oracle's measured 170 fps it would inject ~6x the heat
the pod ever did.  Preserving behaviour beats preserving the artifact of a fixed
timestep.  BT_MYO_HZ still brackets the reference rate.

SO #137 IS NOT A CALIBRATION DEFECT.  Heat is QUADRATIC in speed, so v~50 on open
ground after a respawn is ~9x the input of v~10-18 in a fight -- which is why the
overshoot correlates with respawns without being caused by them.  It is also
self-limiting: effectiveness reaches 0, the mech stops, speed falls, it cools.
That is the authentic governor.  Whether the cliff is too punishing for players
is a DESIGN call for the operator, not a fidelity bug.

Recorded in context/subsystems.md so the next reader does not re-litigate the
dt-normalisation as a bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-09 12:22:15 -05:00
Joe DiPrimaandClaude Opus 5 6a122f6cdf #137: the post-respawn heat spike is VELOCITY-driven, not reset corruption
Operator hypothesis tested: does something tick mid-reset and compute across
half-reset values (the teleport discontinuity) and inject a heat slug?

NOT SUPPORTED, measured.  [myoheat] terms straddling a respawn:

    BEFORE (-334)  v=10.7  kinetic= 21941
    BEFORE (-116)  v=18.8  kinetic= 59560
    AFTER  (  +17) v= 6.1  kinetic=  3517   <- SMALL at the discontinuity
    AFTER  ( +129) v=46.7  kinetic=297349
    AFTER  ( +337) v=54.1  kinetic=399003

The sample immediately after the reset is small.  Nothing computes across the
teleport; there is no injected slug.

WHAT IT ACTUALLY IS.  work = mass * v^2 * 0.5, so heat is quadratic in speed.
After a respawn the mech reaches v~50 against v~10-18 while fighting before it
died -- roughly 9x the heat input -- because it comes back on open ground with
nothing to fight and accelerates to top speed.  That is why the earlier
correlation looked like causation: temperature is highest just after a reset and
decays with distance from it, but the driver is SPEED, not the reset.

Reconciles both benches: 0.95-throttle runs overshoot, 0.50-throttle runs never
approach failT, and the operator's point that ordinary gameplay feels fine holds
-- at combat speeds the model behaves.  It only runs away at sustained top speed.

Term arithmetic checks out against the authored tuning:
    work = 75000 * 54^2 * 0.5              = 109.3e6
    workComplement = 1 - VelocityEfficiency(0.995) = 0.005
    termKinetic = 109.3e6 * 0.005 * (dt*28 = 0.728) = 398e3   (logged 399003)
so the port is computing what it intends to.  The open question is whether the
INTENT is right -- i.e. whether (1 - 0.995) against the FULL kinetic energy is
the authentic scaling, or whether the binary's dt-less per-frame add at the
pod's fixed ~28 Hz means something different from our rate-normalised form at
100-170 fps.  That is now a calibration question with a specific target, not a
hunt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-09 12:18:59 -05:00
Joe DiPrimaandClaude Opus 5 abea159b23 night14: #137 mechanism write-up to the tracker
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-09 12:04:57 -05:00
Joe DiPrimaandClaude Opus 5 8c612e4e18 #137 SOLVED (mechanism): not a respawn bug -- the myomer heat RATE under load is
Controlled A/B on the same bench, only the load differs:

  0.95 throttle + continuous missile autofire : 4 respawns, 17 frozen samples,
                                                T climbs 77 -> ~11,600
  0.50 throttle, no weapons                   : 2 respawns, ZERO post-reset
                                                freezes; T stays ~77

So the respawn is exonerated on measurement, not on argument:
  * the reset works -- T == startingTemperature at every reset, both runs;
  * the stale Myomers::speedEffect (@0x31C, which no RTIS writes) is real but
    self-heals on the next tick (T=77.13 -> speedEffect=1);
  * with ordinary load the myomers never approach failT=2000 after a respawn.

WHAT PLAYERS ARE ACTUALLY SEEING.  Overheat while running hard -> myomers pass
failT=2000 -> derating curve @004b8ac0 returns 0.0 -> chain MAX 0 ->
speedDemand *= 0 -> bogged down -> die (often BECAUSE bogged down).  Respawn
correctly resets to 77.  Resume high throttle + firing and it climbs back over
the cliff within seconds, which reads as "respawned with the heat bar maxed".
That is why it looks like a reset bug and why it is intermittent (~8% of
respawns in the field): it tracks how hard you were driving, not the respawn.

THE REMAINING DEFECT is the CLIMB RATE, not the reset.  Blowing ~6x past a
cliff the design treats as coolant-managed (degradeT=1000 governor onset,
failT=2000) is not a lever a player can work with.  Suspect under review: the
kinetic term.  The binary (@004b8d18) applies NO time_slice to it --
`fVar5 * fVar1` -- while the climb and accel terms both carry param_2, i.e. it
is a per-frame energy add at the pod's fixed ~28 Hz.  Our port rate-normalises
it (`work * (time_slice * 28)`), which agrees per-second at any frame rate, so
that is NOT yet a proven discrepancy -- it needs a term-by-term dump against
the authored tuning (VelocityEfficiency 0.995, AccelerationEfficiency 0.8,
thermalMass 2.5e5, myomers linked Condenser5 = Oracle's "loop 5").

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-09 12:04:02 -05:00
Joe DiPrimaandClaude Opus 5 5556329807 #137: REPRODUCE the myomer freeze -- reset is innocent, the myomers RUN AWAY after it
First reproduction of #137 in a bench.  The reset path is exonerated and the
real defect is located, though not yet explained.

DECOMP RE-READ (what the reset actually does):
  Mech::Reset @0049fb74 walks the roster from index 2 calling vtable +0x28
    (slot 10 = ResetToInitialState), then @0049f788.
  Myomers::RTIS @004b8aa4 -> PoweredSubsystem::RTIS @004b0e6c -> ALWAYS
    HeatSink::RTIS @004ad760, whose first act is
        param_1[0x45] = param_1[0x4f]     // currentTemperature = startingTemperature
    (byte 0x114 = 0x13C).  Our port matches AND additionally resets heatEnergy,
    which it must, since HeatSinkSimulation derives
        currentTemperature = heatEnergy / thermalMass.
  The freeze itself is the derating curve @004b8ac0:
        temp >= degradation(@0x118) -> falls off
        temp >= FAILURE(@0x11C)     -> 0.0  -> chain MAX 0 -> speedDemand *= 0.
  Nothing in the reset chain touches Myomers::speedEffect (@0x31C).

MEASURED (scratchpad/night14/myofreeze.sh -- hot mech, repeated self-kill):

    at-reset   Myomers T=77      deg=1000 fail=2000  speedEffect=0   <- stale
    post-reset Myomers T=77.14                        speedEffect=1   <- 1 frame
    post-reset Myomers T=9297.8  fail=2000            speedEffect=0
    post-reset Myomers T=11620.4 fail=2000            speedEffect=0

So, in order:
  * THE RESET WORKS.  T is exactly startingTemperature at the reset.  My
    original bench was right about that much; it just stopped looking there.
  * THE STALE speedEffect IS REAL BUT HARMLESS -- it survives the reset (no
    RTIS writes it) yet self-heals on the very next tick.  Not the bug.
  * THE MYOMERS THEN RUN AWAY: 77 -> ~11,600 against a FAILURE point of 2,000,
    nearly 6x over, in seconds.  That is not heat earned by running; that is a
    runaway, and it is what pins speedDemand at 0 until it cools -- exactly
    Oracle's "maxed heat bar ... unable to move until it cools off".
  * Rate: 7 of 105 census samples over the failure temp (~6.7%), against the
    field's 5-of-61 respawns (~8%).  Same order, so the bench is reproducing
    the field condition and not a bench artifact.
  * The myomers link to Condenser5 (mass=250000 k=190000) -- literally Oracle's
    "loop 5 and generator D heating up as all the excess heat goes into the loop".

NOT YET ESTABLISHED, and the reason this is a checkpoint and not a fix: whether
the runaway is CAUSED by the respawn or is a heat-model calibration problem that
merely CORRELATES with it (you die when you overheat, so respawns cluster around
hot periods).  The bench drives at 95% throttle with continuous missile autofire,
which is abusive, and the [heat-t] census has no pre-first-reset samples to
compare against.  Next step is to instrument the heat INPUT and diff a
respawn-adjacent window against a steady-running window.

Probe added: [myofreeze] prints T / degradation / FAILURE / speedEffect per
myomers plus the chain MAX the mover multiplies by, AT the reset and for ~4 s
after (armed by Mech::Reset, sampled where the multiplier is formed).  The
post-reset window is what nothing was watching -- sampling only at the reset is
what made this look innocent and got the ticket wrongly closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-09 11:46:23 -05:00
Joe DiPrimaandClaude Opus 5 92783b9935 night14: tracker housekeeping for the 4.11.857 playtest
Closed 5 confirmed-fixed (#52 #108 #140 #141 #142), commented 3 (#135 #146
#147), REOPENED #137, filed #149-#158.

#137 reopened because I closed it wrongly.  Field scan of all four logs: 5 of
61 respawns froze (throttle up, speedDemand pinned at 0) across THREE machines
-- ~8%, which is why a bench that reset cleanly every time never saw it.  The
myomers come up Overheating in the same breath as Mech::Reset, before any
running could earn the heat.  Oracle's 'unable to move' was the detail that
disproved my 'it earns the heat by respawning under throttle' explanation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-09 11:20:21 -05:00
Joe DiPrimaandClaude Opus 5 0961a0b3f3 Revert the key G action ModeCycle default -- F7 already does it, on both keymaps
c1a87b6 added G on the premise that a keyboard-only player could not reach the
control mode.  That premise was WRONG, caught by the operator: I checked only
for `action ModeCycle` verbs and missed the CONSOLE BUTTON route.

    content/bindings.txt:147   key F7 button 0x18
    content/CONTROLS.MAP:136   key F7 button 0x18

0x18 IS CycleControlMode (context/pod-hardware.md:69), and it is wired:
MESSAGE_ENTRY(MechControlsMapper, CycleControlMode) ->
CycleControlModeMessageHandler -> CycleControlModeNow -- the same body the
gamepad Start and the pod console button drive.  So F7 has always cycled the
mode from the keyboard, on BOTH keymaps.  Every console button really is mapped.

Worse than redundant: CONTROLS.MAP:172 already binds `key G action Flush`, so
shipping G=ModeCycle in the glass default board would have made G mean different
things on the pod and on glass -- two populations with two sets of muscle memory,
which is precisely what the bindings-board migration exists to prevent.

Reverted from the shipped default template and from the local bindings.txt.
The torso-twist verification stands: the operator cycled modes and confirmed the
fix live; only the key used to do it was needlessly new.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 17:48:57 -05:00
Joe DiPrimaandClaude Opus 5 06d3b93507 pod: fix the podkit INFINITE LOOP, version-control podkit.ps1, and write the remote runbook
Deploying 4.11.854 to the cart hit a wall that was entirely self-inflicted and
entirely undocumented.  Both are fixed here.

THE BUG.  podkit.ps1 (which pushes the frozen rig config into a fresh install)
had, at line 30:

    while ($keep.Count -gt 0 -and $keep[-1].Trim() -eq '') { $keep = $keep[0..($keep.Count-2)] }

Once $keep trims to a SINGLE blank line, $keep.Count-2 is -1, and PowerShell's
$keep[0..-1] returns TWO elements (index 0 and index -1) instead of shrinking --
so the array GROWS and the loop never ends, 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 -- which is exactly what you get when you
carry config forward from the previous install, the normal upgrade path.  So this
would have bitten every future deploy, not just this one.

Signature: a BLANK cmd console on the cab, BT411Run stuck "Running", no btl4.exe,
no podrun.log.  And it CASCADES: each hung run holds environ.ini so every later
attempt blocks behind it -- while over SSH your client times out and the REMOTE
powershell keeps running, so "it returned instantly and did nothing" actually
means "it is still hung".  Six stuck processes had piled up before I spotted it.

Fixed to `-gt 1`, patched on the pod (podkit.ps1.bak is the original), and the
script is now IN THE REPO (tools/podkit.ps1) with the trap explained inline --
previously it existed only on the cab, so a restore would silently bring the bug
back.  Pod and repo copies are byte-identical.

THE RUNBOOK (context/pod-hardware.md).  Written because I improvised instead of
reading the one paragraph that already existed, on a live stream.  Now covers:
  * ssh bt411-pod -- and WHY it looked like auth was never set up: the key has
    existed since 2026-08-06, but ssh will not OFFER it without a ~/.ssh/config
    entry, so you get "Permission denied (publickey,password,...)".  Also that
    the Tailscale NODE KeyExpiry is not an SSH credential and Tailscale SSH is
    not enabled on the pod.
  * bare taskkill/setx return "The system cannot find the path specified" over
    this SSH+cmd session -- call System32 tools by ABSOLUTE path.
  * the deploy sequence, identical to a tester's: mkdist -> scp -> Expand-Archive
    -> podkit.  Local config is never clobbered because mkdist packs git-TRACKED
    content only and bindings/environ/glass_layout are gitignored.
  * PODTEST.EGG is not in the repo -- only podkit carries it; without it the
    launcher runs and nothing appears, with no error.
  * schtasks /run /tn BT411Run for GUI work (session 1); /end first, because a
    task already Running refuses /run with 2147946720.
  * the measured panel identities (below).

PANEL IDENTITY, measured over SSH (WMI is session-independent, so no GUI needed):
the cab's two RAR0005 panels SHARE one EDID code and have blank serials --
exactly the collision flagged as unproven in ec080cd -- so monitor:id:RAR0005 is
ambiguous and they must use the per-connector form (monitor:id:UID224795 /
monitor:id:UID200195).  The Dell 1908FP's code is unique.  The cab's shipped
glass_layout.cfg still uses the fragile monitor:DISPLAY4 device-name form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 17:31:40 -05:00
Joe DiPrimaandClaude Opus 5 c1a87b602c bindings: ship key G action ModeCycle in the built-in default board
ModeCycle was bound ONLY to the gamepad Start (CONTROLS.MAP), and bindings.txt
carried no action bindings at all -- so a KEYBOARD-ONLY player could not reach
the control mode.  That is the control the torso-twist fix has to be tested
through, and it is a shipped gameplay feature (Basic steers with the stick and
auto-centres the torso; Standard/Veteran twist the torso and steer with the
pedals), so it should not be gamepad-only.

G is free.  M is taken -- it is keypad button 0x02.

DELIBERATELY the default TEMPLATE only, not a bindings-board bump.  content/
bindings.txt is a per-machine runtime file (gitignored) and an existing one is
never overwritten, so:
  * a FRESH extract writes the new board and gets G -- which is the zip flow;
  * an EXISTING tester keeps their file, and their customizations, untouched.
Shipping it to existing files means bumping "# bindings-board" and extending
kHistoricalDefaultRows so board-2 rows are recognised as "the old board" rather
than player customizations.  That is the designed path, but it rewrites every
tester's bindings file, and doing that in the same build as a large unverified
fix pile is how you lose a playtest evening.  Left for a build where it is the
headline change.  Testers on an existing install can add the one line by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 16:00:05 -05:00
Joe DiPrimaandClaude Opus 5 990ad52563 podprobe: emit boot-STABLE panel identities + ready-to-paste monitor🆔 lines
Adds a section resolving each screen's panel identity via the SAME Win32 API the
game uses (EnumDisplayDevices on the display's MONITOR child) rather than the
WmiMonitorID query above -- if probe and engine read different sources the
printed fragments could fail to match what the engine tests.  Verified on the
dev box: probe and engine emit byte-identical identities.

Answers the open [T3] multi-panel question WITHOUT deploying a build (pure OS
data).  When one EDID code appears on SEVERAL panels (identical MFD models) it
detects the collision and emits the per-connector UID form instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 15:58:12 -05:00
Joe DiPrimaandClaude Opus 5 ec080cd61d pod displays: bind glass panels to EDID identity, not to Windows' shifting display numbers
Nick, after re-cabling + rebooting the pod: "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."

Both existing binding forms are boot-fragile: `monitor:2` is an ENUMERATION
INDEX and `monitor:\.\DISPLAY4` is a GDI name Windows reassigns.  Neither
survives a panel power-cycle.  (His gos-displays.txt shows the same trap next
door in GameOS: -tmon takes DIRECTDRAW device indices -- not Windows monitor
numbers -- and a NULL-device merge shifts every index down by one on top.)

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.

  DISCOVER  BT_GLASS_IDS=1 logs every attached panel's stable-id AND a
            ready-to-paste `cfg form = monitor🆔<fragment>`.  It prints the
            VOLATILE identifiers alongside on purpose: run it either side of a
            power-cycle and index/device move while stable-id does not.
  BIND      Heat MFD=monitor:id:AUO10ED,bare

NOTHING CHANGES BY DEFAULT -- no env and no `id:` prefix means identical
behaviour; `monitor:<name|index>` and raw x,y keep working, so playtester glass
builds are untouched.

An `id:` that matches nothing WARNS and falls back to computed placement.
Silence would put a picture on the wrong glass and look exactly like the bug
this form exists to prevent.

VERIFIED on a 1-monitor dev box (the pod is offline), end to end:
  * discovery printed
      stable-id = \?\DISPLAY#AUO10ED#4&31323a6c&1&UID265988#{e6f07b5f-...}
      cfg form  = monitor:id:AUO10ED
  * `Heat MFD=monitor:id:AUO10ED,bare` resolved and CENTRED correctly
      [glasswin] 'Heat MFD' bound to monitor 0,0 1920x1080 -> window at 640,300
  * a bogus id warned instead of misplacing.
The EDID-code extractor is deliberately STRUCTURAL (3 letters + 4 hex digits,
tokenising on \ # ?) rather than positional: the first cut walked separators by
position and returned EMPTY for the `\?\DISPLAY#...` interface-name form, which
is exactly the form this machine produces.  Which form you get depends on
whether EDD_GET_DEVICE_INTERFACE_NAME succeeds, so both must parse.

STILL UNPROVEN [T3] -- the pod is offline: multi-panel disambiguation when
several MFDs share one model (EDID codes collide).  The documented answer is a
longer fragment from stable-id, whose UID/instance tail differs per connector,
but that needs the cab to confirm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 14:20:38 -05:00
Joe DiPrimaandClaude Opus 5 9657fbb11e control mode: REPRODUCE the "centering fought my control" fight, and prove the fix in Sauron's config
Follow-up to 4ccc2a7, which fixed the mechanism but could not reproduce the
field symptom.  His fuller wording -- the centering "FOUGHT" his control, not
"the torso died" -- is what cracked it.

WHY "FOUGHT" IS THE PRECISE SYMPTOM.  TorsoSimulation's frame order is
  1. digital twist commands   -> currentTwist += d;  recenterActive = 0
  2. centerCommand > 0        -> recenterActive = 1        (re-armed)
  3. analog twist axis != 0   -> currentTwist += d;  recenterActive = 0
  4. if (recenterActive)      -> Recenter(dt)              (drags toward 0)
Desktop/glass torso input is ANALOG (Q/E -> gBTTwistAxis -> stickPosition.x), so
with centerCommand stuck the torso HOLDS while you are actively pushing (step 3
clears the arm) and snaps back the instant you ease off (step 2's arm survives
into step 4).  You can only hold it off-centre by pushing continuously.  That is
"the centering fought my control", exactly.

WHY IT ONLY BITES THE GLASS/POD BUILD -- and why the first bench came back clean.
The ONLY caller of ClearRecenterCommand() sits INSIDE the desktop key-bridge
block, gated on `gBTDrive.forced || !BTRIODevicePresent()`.  With a RIO present
-- and on glass builds PadRIO IS the rioPointer -- the bridge is OFF and NOTHING
ever clears centerCommand, so one pass through Basic pins it at 1 for good.  A
plain desktop build clears it every frame and self-recovers.
The first modecycle.sh run needed BT_KEY_BRIDGE=1 to make the mode-cycle hook
run at all -- and that same flag switched on the only thing that clears the cell,
masking the bug under test.  The hook is now deliberately OUTSIDE that block so
the bench can run the RIO-present configuration.

MEASURED A/B, bridge OFF (Sauron's config), BT_TWIST_PULSE deflect/release:

                          LEGACY                      FIXED
  ctrCmd=1 samples        310  (latched for good)     0
  twist during RELEASE    decays 0.443->0,            HOLDS 2.44346
                          0.900->0.436  (recen=1)
  recen=1 samples         permanently armed           14 (one-shot per Basic
                                                      entry, then self-clears)

So the authentic one-shot re-centre still happens on entering Basic; it just
settles instead of fighting the pilot forever.

New bench hook BT_TWIST_PULSE=<n>: deflect the analog twist axis for n ticks
then RELEASE for n ticks, repeating.  BT_LOCK_SWEEP never releases, so it cannot
show this symptom at all -- the release window IS the measurement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 13:59:39 -05:00
Joe DiPrimaandClaude Opus 5 4ccc2a7eec control mode: Basic re-centre used the STICKY held-button cell -- and the elevation-limit swap was never ported
Sauron: "toggled through advanced controls from standard to advanced and back
to standard -- lost torso control."

The cycle is 0 Basic -> 1 Standard -> 2 Veteran -> WRAPS TO BASIC, so getting
from "advanced" back to Standard PASSES THROUGH BASIC, whose arm re-centres the
torso.  @004afbe0 is a complete spec and the port got three things wrong:

    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. WRONG CELL.  Basic called CommandRecenter() -> centerCommand (@0x208), the
   HELD-BUTTON cell: TorsoSimulation re-arms recenterActive from it EVERY frame
   it is non-zero, and only the input path clears it -- a mode switch has no
   button release to follow.  Digital twist commands are processed BEFORE the
   centerCommand block, so while it is set they are overridden as fast as they
   are applied: the torso stops responding.  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 members were ctor-written and read by NOTHING.
   Basic never restricted downward travel; the assisted modes never restored it.
3. Basic also raises the HUD's flickerActive (@0x2A0) so the horizon re-settles
   with the torso it just re-centred.  Not ported.  (New BTSetHudFlickerActive
   bridge in hud.cpp -- mechmppr sees Subsystem*, not HUD.)
Also removed an invented SetAnalogElevationAxis(0); the binary zeroes only 0x1F0.

MEASURED A/B (scratchpad/night13/modecycle.sh, LEGACY=1 for the old path;
BT_LEGACY_MODE_RECENTER=1 is the revert switch):

    ctrCmd=1 samples   legacy 26   fixed 0
    vLim pairs         fixed run shows BOTH -- (-0.698..0.349) = -40..20 deg
                       assisted, and (-0.349..0.349) = -20..20 deg Basic.
                       Before this commit only the ctor pair ever appeared.

WHAT IS *NOT* PROVEN.  I did not reproduce Sauron's PERMANENT loss.  In this
bench the legacy latch is periodic, not sticky:

    ..........LLLL......LLLL......LLLL......LLLL......LLLL......LL

because the desktop key bridge writes centerCommand every frame and zeroes it
when no button is held, so it self-recovers.  The torso IS locked while the cell
is set, which is the symptom -- but whether it stays locked depends on the input
path OWNING that cell.  On the glass/pad route (Sauron's) nothing may clear it,
which would make it permanent.  So: mechanism fixed and binary-grounded, exact
field persistence unverified.  Field-verify by cycling modes on a pad build.

Probe: the BT_TORSO_LOG gate line now carries ctrCmd / recen / vLim.
Bench hook: BT_MODECYCLE_EVERY=<n> cycles the mode from the mapper (the pod's
own route is console key 0x13d -- not a RIO button, so BT_BTNTEST cannot press
it, and mech4's BT_MODECYCLE_TEST counter did not advance in a solo run).
NOTE the bench needs BT_KEY_BRIDGE=1: with a PadRIO present the key-bridge
block that consumes the cycle is skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 10:36:36 -05:00
Joe DiPrimaandClaude Opus 5 43777569f9 night13: close #148 as not-a-bug on the tracker
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 10:11:47 -05:00
Joe DiPrimaandClaude Opus 5 cacca58836 #148 is NOT A BUG: a peer mech does not tick before RunningMission -- by engine design
Chased to the bottom instead of stopping.  The answer is that there was
nothing to fix, and my bench was lying to me.

Entity::Execute (ENTITY.cpp:556, real engine source [T0]) calls PerformAndWatch
ONLY when the app state is RunningMission/EndingMission or the entity
IsPreRunnable(); otherwise it merely WriteSimulationUpdate()s.
Entity::DefaultFlags is DynamicFlag|MasterInstance -- no PreRunFlag.  Only
Player and Director add it, 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,
however much correctly-replicated data is arriving.  Measured on the observer:

     235  [perf-first] mech 3:161 master      <- own mech, immediately
     402  [torso-rec-rx]                      <- peer torso records 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.

WHICH MEANS THE PREFIX WAS A BENCH ARTIFACT.  BT_AUTOFIRE starts shooting
immediately, during WaitingForLaunch -- something no player can do in a real
match -- so those 60 leading salvos measured a peer whose torso had never run.
Every "ZZZZ...XXXX" pattern in this investigation was that, and the first X
lands within a few lines of the state transition.  #141's fix is unaffected and
remains verified: the segment-cache defect was real and mid-match.

Chain of things ruled out on the way, all measured:
  * record CADENCE is authentic -- sends on RATE CHANGE (payloads are the sweep
    extremes, rate flips sign), peer dead-reckons between them.  12 records for
    12 reversals is correct, not starved.  My "only 13 records" premise was wrong.
  * ComputeTargetTwist clamp -- limits load fine on the copy (+/-2.44346).
  * the torso's own executable flag -- restoring the engine's instance branch
    (f36f013) is a genuine fidelity fix but moved this by nothing.
  * the replicant entity IS offered to the performer, executable=1, from line
    171 -- 2500 lines before its first PerformAndWatch.  The gate was inside
    Execute, not the scheduler.

Adds [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 precisely why this took so long in a 2-node log -- master and replicant
lines were indistinguishable.  Name the mech.

Gotcha #29 records the bench-design rule this cost: judge a 2-node bench by
PREFIX vs INTERLEAVED, never by raw percentage, and check [ent-exec] state=
before suspecting replication.  missileframe.sh carries the same warning.

#148 to be closed as not-a-bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 10:11:14 -05:00
Joe DiPrimaandClaude Opus 5 bb6605d53b night13: #148 tracker correction script
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 09:57:00 -05:00
Joe DiPrimaandClaude Opus 5 f36f0136c8 #148: restore the ENGINE's replicant instance-branch in the Mech subsystem tick
Correct on its own merits as a fidelity fix; it is NOT the cause of #148, and
I am not claiming it is.

Entity::Perform (ENTITY.cpp:733-793, real engine source [T0]) picks the
executable predicate BY INSTANCE:

    if (GetInstance() != ReplicantInstance)  IsNonReplicantExecutable()
    else                                     IsReplicantExecutable()

and the two 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".  Mech's reconstructed tick loop used
the NonReplicant predicate for EVERY mech, dropping the branch, so on a
replicant any ExecuteOnUpdate subsystem could never run however many records
arrived.  Restored.

Measured: it does NOT move #148 (first TorsoCopySimulation call 1014 -> 1006,
noise).  So the torso's own flag was not the gate.  Keeping it because the
engine source is unambiguous about what the loop is supposed to do.

WHAT #148 ACTUALLY IS, now much better characterised:

* The record CADENCE is authentic -- my original "only 13 records" framing was
  wrong.  Payloads are the sweep EXTREMES with `rate` flipping sign each time
  (atUpd 0.0437, 2.3558, -2.3928, 2.3854, ...): 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 real defect is that the peer's copy torso PERFORMANCE does not run at
  all until log line ~1006, while its first record arrived at line 205 -- ~800
  lines of correctly-replicated twist integrated by nobody.  The first tick
  coincides with the replicant's MODEL bring-up, not with record arrival:

      [loadclips] end: fScale=0.8 ... hasGimpClips=1
      [clipfix] mech 05769358 -> EXTERIOR (lean)
      [torso] PushTwist COPY node=057A3C68 type=1 twist=-1.52319

  so the gate is above the subsystem level, in replicant model/clip init.
  Not yet found; #148 stays OPEN.

Also: [torso-copy] logs on call #0 (s_cl++ % 120), so its first line IS the
first Performance call -- that is what makes the 205-vs-1006 gap readable, and
it is why the earlier "first copy currentTwist != 0 at 1016" reading was a
SAMPLING artifact, not a measurement of when the twist started.

Probe additions kept: [torso-copy] now prints limL/limR/enab (which ruled out
the ComputeTargetTwist clamp -- limits load correctly at +/-2.44346 on the
copy), and [launchframe] now prints the shooter's live torso twist so
twistDelta and its driver sit on the SAME line.  That pairing is what proved
#141 is fully fixed: every zero-twistDelta peer launch reads liveTwist=0, and
the first launch with liveTwist=-1.84061 reads twistDelta=-1.83813.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 09:56:24 -05:00
Joe DiPrimaandClaude Opus 5 eebe7e61a4 gotcha #28: hand-composing an engine-derived transform reads a stale cache (replicant-only)
The #141 bug class, written up so it is not re-introduced.  GetSegmentToEntity
recomputes ONLY when segmentModified is set; JointedMover::GetSegmentToWorld is
what sets it -- and the binary's own GetMuzzlePoint @004b9948 goes through it
(FUN_00424da8), so every muzzle query in the 1995 image performs the
joints->segments refresh.  Four port sites hand-composed instead, one of them
commented "the faithful FUN_004b9948".

Records the four rules the investigation actually cost:
 (a) never hand-compose; call GetSegmentToWorld
 (b) never force the dirty flag to fix a stale read -- that stand-in scored
     IDENTICALLY to the faithful fix while patching only one consumer
 (c) "peer POV only" geometry bugs = suspect a cache the local render pass
     refreshes for free, before suspecting replication (it was provably fine)
 (d) a partial-looking score: check PREFIX vs interleaved before calling it
     partial -- these were a clean prefix ending when the peer first had a
     twist to carry, so the fix was complete and "64% fixed" was wrong
 (e) the probe trap: one shared static sampled every Nth call hides one of two
     alternating instances entirely

#141 closed with the full write-up; #148 filed for the torso replication
cadence (13 records in a 5-minute run) which is a separate, real problem and
likely bears on #37 and #70.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 09:29:47 -05:00
Joe DiPrimaandClaude Opus 5 e6c5ac951e #141 sweep: every hand-composed segment->world now goes through the engine accessor
Finishing the audit the #141 fix implied.  The unfaithful pattern

    mw.Multiply(seg->GetSegmentToEntity(), mech->localToWorld);

appeared at FOUR sites, not one.  GetSegmentToEntity only recomputes when
segmentModified is already set (SEGMENT.cpp:262); the thing that sets it is the
binary's FUN_00424da8 == JointedMover::GetSegmentToWorld, which tests
AreJointsModified() and marks the whole segment table dirty.  Compose by hand
and you read whatever cache is there -- fresh on the local mech (the render pass
refreshes it every frame), BIND POSE on any replicant.

Swept (the muzzle path was fixed in f01de8c):
  * BTResolveWeaponMuzzle           -- weapon muzzle          (already done)
  * BTGetMechSegmentWorldPos @1066  -- generic segment->world bridge
  * damage-effect anchor    @2148   -- peer effects anchored to the bind pose
  * energy-beam gun port    @8916   -- SAME exposure as the missile launch:
                                       a peer's BEAM would originate from the
                                       untwisted gun port too

Repo-wide grep now shows exactly one GetSegmentToEntity call outside
SEGMENT.cpp -- JMOVER.cpp:153, which is inside GetSegmentToWorld itself, after
the refresh.  That is the correct one.

No regression (scratchpad/night13/missileframe.sh):
    master     n=165  max 2.1719  mean 1.2781  >0.1rad 100%
    REPLICANT  n=165  max 2.0907  mean 0.8201  >0.1rad  64%
and the peer failures remain a clean PREFIX with zero interleaved cases --
i.e. only the window before the peer has any replicated twist to carry, which
is correct behaviour, not a miss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 09:28:04 -05:00
Joe DiPrimaandClaude Opus 5 f01de8cbfa #141 follow-up: do it the BINARY's way -- the muzzle query IS the segment refresh
The previous commit's fix worked but was NOT faithful: it set
ModifyJoints(True) to force the engine's dirty flag before reading the
segment.  The binary never does that.  Called out by the user; corrected.

WHAT THE BINARY ACTUALLY DOES.  MechWeapon::GetMuzzlePoint @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()  <- TESTED
        ... walk owner+0x300, 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,
and the flag is only ever TESTED, never set.

THE REAL DEFECT.  BTResolveWeaponMuzzle -- labelled "the faithful FUN_004b9948"
-- hand-composed `seg->GetSegmentToEntity() x localToWorld` and skipped
@00424da8 entirely.  GetSegmentToEntity only recomputes when segmentModified is
already set (SEGMENT.cpp:262), so it returned a stale cache.  On the MASTER the
render pass refreshes the local mech every frame and hid it; a REPLICANT got no
refresh, so peer muzzles sat at the BIND POSE and the missile left along the leg
facing.  Fixed at the muzzle path, where the binary puts it -- and the forced
flag in BTPushProjectile is REMOVED (the launcher calls GetMuzzlePoint just
above, so the cache is already current when the launch frame is composed).

MEASURED -- the faithful path scores exactly what the hack did, so the hack
bought nothing and is gone:

    master     n=165  max 2.1389  mean 1.2718  >0.1rad 100%
    REPLICANT  n=165  max 1.9426  mean 0.8051  >0.1rad  64%

AND THE 64% IS NOT A PARTIAL FIX -- I called that wrong last commit.  The
failures are a contiguous PREFIX, not interleaved:

    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 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.  #141 is fixed.

SEPARATE ISSUE FOUND, not fixed here: the peer's copy torso takes far too long
to first reflect the master's twist -- the master was twisted from the start,
only 13 torso records arrived across the whole run, and the copy's twist stayed
0 until line 1016.  That is a torso REPLICATION CADENCE problem, and it would
also make peer torsos visibly lag -- likely relevant to #37 (MadCat torso
backwards) and #70 (twist stops after respawn).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 08:30:01 -05:00
Joe DiPrimaandClaude Opus 5 05d7b5890a #141 peer missiles: the launch frame read a STALE segment cache on replicants
Oracle: "missiles are firing in the direction the mech feet are facing ...
and then coming around to track the target", peer POV only -- the shooter's
own view is correct.

REPRODUCED AND MEASURED (scratchpad/night13/missileframe.sh, 2-node: only A
sweeps its torso and only A fires, so every REPLICANT line in B's log is the
mirror of one A salvo).  New [launchframe] receipt prints the yaw of the
launch forward vs the BODY forward on both nodes:

    master     n=165  |twistDelta| max=2.2962  mean=1.2283  >0.1rad: 100%
    REPLICANT  n=165  |twistDelta| max=0.0000  mean=0.0000  >0.1rad:   0%

segResolved=1 on BOTH, and segYaw == bodyYaw EXACTLY on the peer.

WHAT IT IS NOT.  Both sides already pass the mount segment (mislanch.cpp:363
master, :478 replicant mirror, both `GetSegmentIndex()` from task #67), and
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 is identical too: same seg 18, same parentIdx 4,
non-null parent + joint subsystem on both.

ROOT CAUSE.  BTPushProjectile composed the frame BY HAND --
`mw.Multiply(seg->GetSegmentToEntity(), localToWorld)`.  But
EntitySegment::GetSegmentToEntity (SEGMENT.cpp:262) recomputes ONLY when
`segmentModified` is set, and the thing that sets it after a joint moves is
JointedMover::GetSegmentToWorld (JMOVER.cpp:136-146), which tests
AreJointsModified() and then marks every segment dirty.  Hand-composing skips
that, so you read whatever cache is sitting there.  On the MASTER that was
invisible -- the renderer/cockpit camera call GetSegmentToWorld for the local
mech every frame, AFTER the local torso pushes its joint, so the cache was
already correct.  A REPLICANT gets no such refresh: its cache stayed at the
BIND POSE, and the twist never reached the launch direction.

FIX.  Use the engine accessor, and set the joints-dirty flag first so it
actually refreshes (by fire time the frame's render pass has already consumed
and cleared it -- measured jointsDirty=0 on BOTH nodes).

RESULT (same bench):
    REPLICANT  max 0.0000 -> 2.1145   mean 0.0000 -> 0.8252   0% -> 64%

PARTIAL, and I am not claiming otherwise.  36% of peer salvos still read the
exact-zero stale signature while the master is 100%.  Forcing every per-joint
`jointModified` flag as well (GetSegmentToParent's own gate, SEGMENT.cpp:196)
was tried and moved the number by NOTHING -- 64% either way -- so the residual
is a different cause, most likely frame ORDER (the salvo mirror running before
the copy torso has posed that frame).  Cheap form kept.

Also fixes a SAMPLING TRAP in the torso probe: PushTwist sampled one shared
static every 30th call, and with a master torso and a copy torso ticking 1:1
every 30th call is always the SAME instance -- so the probe showed only the
local untwisted torso and hid the copy's writes entirely.  Now sampled per
instance-kind, which is what made the copy's correct joint writes visible and
moved the search downstream to the segment cache.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 08:14:25 -05:00
Joe DiPrimaandClaude Opus 5 1ae57398f1 #147 range caret: NaN poisons a process-lifetime static -- and the caret's input was never logged
Oracle: "no range finder on this drop" + a screenshot -- tick marks present,
moving caret absent, one drop, only tester affected.

Not the host, not the chassis, not his destroyed HUD.  From the four field
logs: he WAS hosting (`[lobby] host:` appears only in his log) but range
computed fine on his node (1806 nonzero samples) and the reticle built on all
6 drops; a second tester flew a Thor the same night without hosting and saw
nothing, and the ladder is shared HudSimulation/BTReticleRenderable, not
per-chassis content; his HUD was destroyed twice but for 11s and 26s only, and
a destroyed HUD costs the LOCK (_DAT_004b7ec4 = 0.75), not the caret.

THE DEFECT.  sShownRange -- what the caret binds to -- is a function-level
static in mech4's targeting step: one cell for the whole process, shared by
every mech, carried across drops, never re-seeded.  NaN is ABSORBING in

    step = trueRange - sShownRange;
    if (step >  maxStep) step =  maxStep;      // false for NaN
    if (step < -maxStep) step = -maxStep;      // false for NaN
    sShownRange += step;

so one poisoned frame makes it NaN for the life of the process.  The consumer
repeats the mistake -- BTReticleRenderable::Draw clamps with the same two
comparisons -- so NaN reaches AddPoint/ConcatMatrix and the caret + its bar
become degenerate geometry that STOPS RENDERING, while every static reticle
element including the tick marks still draws.  That is the reported symptom
exactly, and it is sticky until relaunch.

WHY NO LOG COULD SETTLE IT.  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 -- neither is
sShownRange or gBTHudRangeStorage.  Grepping the field logs for NaN returns
nothing because the poisoned variable was never printed.  Absence of the
signal was not evidence of absence.

FIX (4 parts):
  1. re-seed sShownRange when the viewpoint mech CHANGES, so a new drop starts
     at the binary's 1200 default.  Deliberately NOT on respawn -- that reuses
     the entity, and the binary does not reset the readout on respawn either.
  2. producer NaN trap -> re-seed to 1200 instead of propagating.
  3. NaN-safe consumer clamp (test x == x first) -> fall back to the authentic
     no-target peg rather than rendering nothing.
  4. BT_RANGE_LOG now prints the caret's real input at 1 Hz plus a
     "[range] NaN TRAPPED" receipt, so the next field log CAN settle it.

STATUS [T3 on the field link].  The defect and the symptom match exactly and
the fix stands on its own merits -- a process-lifetime static feeding unguarded
float geometry is a bug regardless.  But the causal link to Oracle's report is
INFERENCE: the NaN source is unidentified and this has not been reproduced.
Field-verify with BT_RANGE_LOG=1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 07:16:32 -05:00
Joe DiPrimaandClaude Opus 5 832bec0966 hud.cpp: byte-ground the HudSimulation constants -- all five were stand-ins under guessed names
Found chasing the Thor "no range finder" report: _DAT_004b7ec4 was documented
as two incompatible things -- the 0.75 LOCK damage threshold (mech4.cpp:6325)
and a "heat threshold for HUD page visibility" valued 0.0f (hud.cpp:59).

The .rdata settles it (reference/decomp/section_dump.txt):

     4b7ec0  8be55dc3 0000403f 0000803f 0000c842
     4b7ed0  00000000

  _DAT_004b7ec4 = 0.75f    _DAT_004b7ec8 = 1.0f
  _DAT_004b7ecc = 100.0f   _DAT_004b7ed0 = 0.0f    _DAT_004b7f90 = 0.0f

mech4.cpp was right on both thresholds.  hud.cpp's whole tuning block was
wrong -- every entry a 0.0f/500.0f stand-in, and three of five names named the
wrong mechanism:

  * ec4/ec8 are the fire-control LOCK limits (own HUD host zone < 0.75 damage,
    targeted zone < 1.0), NOT heat/page-visibility.  A shot-up cockpit drops to
    "target held, no lock"; a dead zone cannot be re-locked.
  * ed0 is the shared ZERO -- the right-hand side of the range-slide Abs()
    idiom (`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 (0x43fa0000).  The old
    "MaxTorsoSlew = 500.0f" read that backwards.
  * f90 (FlickerFloor 0.0f) was the only correct entry.  Its decay RATE is the
    object's own @0x298, not a constant -- the step-6 banner said "up to
    MaxTorsoSlew (500/sec)" and is corrected too (hud.cpp:229 already had it
    right, so the file disagreed with itself).

All four wrong constants were DEAD (zero code uses; MaxTorsoSlew appeared only
in a comment), so this changes no behaviour -- it stops the next reader
trusting them.  Renamed to what they are: LockOwnZoneDamageLimit,
LockTargetZoneDamageLimit, RangeBias, HudZero.  Builds clean.

GAP FOUND, filed not fixed: HudSimulation subtracts _DAT_004b7ecc (100.0f)
from RangeToTarget@0x1EC every frame while the timed flag @0x22C is set
(timer @0x21C accumulates to @0x1D8, then both clear).  Our targeting step
does the 500 m/s slide but never this bias, so the authentic timed -100 m
range offset is missing.  What sets @0x22C is unidentified.  -> open-questions.

KB swept: no context/ or docs/ file repeated the wrong constants (gauges-hud's
0-1200 ladder / 500 m/s / pegs-at-1200 claims are all correct); the error was
confined to hud.cpp.  gauges-hud.md gains the byte-grounded table + the gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 06:11:59 -05:00
Joe DiPrimaandClaude Opus 5 7b003243ae #146 respawn: release the DESKTOP throttle -- and close #137, which was never a bug
#137 ("respawn came back with MYOMERS heat MAXED", Oracle; "overheating
generator D", Sauron) sent us through a full two-sided audit of Mech::Reset
and the whole RTIS chain.  Both sides were correct.  The answer was in the
field log all along, one line after the reset:

    [respawn] Mech::Reset 3:30 healed+moved to (...) alive=1
    [techstat] ... every live condition CLEARED
    [techstat] Myomers condition 3 SET        <- Overheating, immediately
    [mppr] in thr=1 -> ...
    [gaitSM] cycleSpeed=14.6 state=12         <- already RUNNING
    [techstat] Condenser5 condition 3 SET     <- "dumping into coolant loop 5"
    [techstat] GeneratorD condition 3 SET     <- Sauron's generator D

The mech respawns STILL UNDER POWER and earns the heat honestly.  Two facts
close it:

1. condition 3 is an OPERATING flag, not an alarm.  Census over one match:
   LLaser_2 33 SET / 33 CLEARED, LLaser_1 31/31, SRM4 26/26, PPC_2 18/18 --
   every volley trips it and clears it.  EVERY subsystem is balanced
   (GeneratorD 5/4, Myomers 5/4, Condenser5 1/1; the extra SET is only the
   log ending mid-heat).  cond 6 BadPower behaves the same (Myomers 8/8).
   Nothing latches.  A post-respawn SET is not evidence of anything.

2. 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.
   That is right for a pod: the throttle is a PHYSICAL lever still under the
   pilot's hand.  Respawning under power is authentic and stays.

Oracle's read that the myomer heat rate "felt right" was correct.

WHAT IS a real defect (#146), desktop only: the glass bridge merely EMULATES
that lever, with the static ramp accumulator sLever (mech4.cpp:3250) zeroed
ONLY by the X all-stop and a direction-crossing snap.  A pad/keyboard pilot
is physically holding nothing and cannot see the lever, so they respawned at
speed for no reason they could perceive -- and ate the heat load above.  The
Thrustmaster/RIO path was never affected: InterpretControls (@004d2150)
rebuilds throttlePosition every frame from the databound throttleForward.

Fix: queue the existing all-stop at Mech::Reset, reusing the proven path
(it already clears the zero-crossing detent too).  LOCAL VIEWPOINT MECH ONLY
-- gBTDrive is the local bridge's state and Reset also runs for replicants,
so an ungated write would all-stop the player whenever a REMOTE mech
respawned.  Pod-safe besides: with a RIO present the key bridge is off and
gBTDrive.throttle is never read.  BT_NO_RESPAWN_THROTTLE_RELEASE=1 reverts.

Benched 2-node (scratchpad/night13/throttlerespawn.sh): the release fires
1:1 with local respawns on both nodes independently (A 2/2, B 1/1) and never
spuriously.  HONEST LIMIT: the viewpoint gate was NOT stressed -- B ran
Mech::Reset 0 times for A's mech, so the remote-respawn path never fired.
The gate is correct by construction (the isPlayerMech idiom), not proven.
BT_AUTODRIVE cannot test the lever itself (forced mode reads forcedThrottle,
never sLever), and the zeroing path is the X button, proven in the field.

Also keeps BTReportHeatAtReset (heat.cpp, BT_HEAT_LOG): the [heat-t] census
runs on a 5s timer, far too coarse to sample AT the reset.  It is what
proved every roster subsystem including all six Condensers sits at T=77
start=77, and it corrected an earlier false negative from filtering on
IsDerivedFrom(HeatSink).

KB: context/decomp-reference.md gains the routine/self-clearing condition
semantics + this post-mortem, so it is not re-chased; cross-ref in
context/gauges-hud.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
2026-08-08 01:23:35 -05:00
Joe DiPrimaandClaude Opus 5 5b7e481913 scoring: mech+0x354 is VESTIGIAL -- label it so nobody "finishes" it
MECH_DAMAGE_BIAS(m) returned 0.0f under a comment reading "bring-up: factor =
0*bias+1 = 1", which invites a future session to wire it up.  Auditing
Mech::Reset settled what it actually is, and 0.0f turns out to be EXACT:

  * mech+0x354 has exactly ONE writer in the image -- Mech::Reset (@0049fb74,
    part_012.c:14340).  Nothing touches it during play.
  * Reset computes mean(zone + 0x158) across every damage zone, AFTER the zone
    heal has already zeroed those cells.  So it is ~0 the moment it is written,
    stays ~0 for the mech's whole life, and is recomputed as ~0 next respawn.
  * It has exactly ONE reader -- CalcInflictedScore (@004c052c,
    part_013.c:19055) -- as `avg * role.damageBias + 1.0`.

So the factor is 1.0 for the entire game and the stand-in reproduces the
binary exactly.  0x358 and 0x35c are the same computation over subsystem zones
and have NO reader at all.

This also raises confidence in the night-13 scoring work: the 505.88 kill award
was not right DESPITE a missing term -- the term genuinely is 1.0.  Wiring
0x354 to live accumulated damage would silently inflate every inflicted and
kill award, and both chart-verified numbers (+1 a damage point, +500 a kill)
assume 1.0.

Comment rewritten at the macro; combat-damage.md carries the same finding [T1].

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-08 00:19:20 -05:00
Joe DiPrimaandClaude Opus 5 1f0923747b Mech::Reset: restore the POSTURE clears the port had dropped (#142)
Oracle: "crouch wasn't resetting on respawn ... mechs always spawn standing".
Correct -- Mech::Reset (@0049fb74) stands the mech up and the port cleared
none of it:

    *(this+0x398) = 0             duckState
    Set_Alarm_Level(this+0x39c,0) legStateAlarm  -> standing
    Set_Alarm_Level(this+0x714,0) bodyStateAlarm -> standing
    *(this+0x650/0x654/0x658) = 0 death + leg/body reset latches
    *(this+0x5ac) = 1.0f          idleStrideScale

A pilot who died CROUCHED came back crouched -- leg parked in 'sqd' -- and now
that the cockpit strip works, showing the up-arrow "press to rise" frame on a
standing mech.

Benched (crouchrespawn.sh): A squats, dies while down, respawns -> legLvl 0
(standing) after Mech::Reset.  Weak but the failure mode (stuck legLvl=1) is
absent.  NB the first attempt was void: force-damage kept A dying before it
could crouch (legLvl 22/24 = death clips), so the run tested a STANDING death.
Switched to self-damage so the mech is stopped long enough to crouch.

Also carries the #142 gauge work: the crouch strip is a BUTTON-STATE indicator
(grey unavailable / orange down-arrow ready / orange up-arrow crouched),
decoded by rendering BDUCK.PCC rather than inferring it; and the gauge
factory's missing-image path no longer uses the no-op DebugStream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-08 00:11:00 -05:00
Joe DiPrimaandClaude Opus 5 03c4d55672 #142 crouch: duckState is a THREE-state posture -- the strip is an animation
Operator confirmed on screen: bduck.pcc is a real duck ANIMATION, and stepping
duckState 0->1->2 plays it.  So the attribute is not a flag:

    0 = standing        1 = moving between        2 = crouched

Everything else was already right -- asset, element (OneOfSeveralPixInt
@004c5204), factory registration, L4GAUGE.CFG:5001, and the attribute binding
(new [gauge] receipt confirms 'bduck.pcc' frames=3x1 attr=BOUND).  We were
writing a two-value flag into a three-frame strip, so frame 2 was unreachable
and the cockpit saw a snap: "it lights up and sticks, no animation".

The handler is back to the binary's exact write (duckState = 1, @0049fa00).
That value now MEANS the middle frame, so the press gives immediate visual
feedback and the earlier toggle divergence is retired.

Needed a separate duckRequest cell, which I tried twice to avoid:
  * duckState cannot be both the request and the display.  Settling it to the
    real posture destroys the request, so on the frame the squat clip parked
    the consumer read "crouched + pending" and issued the opposite direction --
    69 transitions from 2 presses, benched, twice.
  * reading the CACHED legAnimationState instead of the alarm made it worse:
    the cache refreshes only at the top of AdvanceLegAnimation, so right after
    SetLegAnimation it still reads the old state.  Read the alarm.
duckRequest is port-only, appended, never read by offset.

Also fixes a silent failure in the gauge factory: the missing-image path used
DebugStream -- the no-op ReconStream (project gotcha) -- so a strip that failed
to load reported NOTHING.  Now DEBUG_STREAM, plus an ungated one-line receipt
per element naming the image, frame grid, port and whether the attribute BOUND
or came back NULL.  That receipt is what proved the element was healthy and
sent me looking at the value instead of the plumbing.

Benched (crouch142.sh, madcat): 2 presses -> exactly 2 transitions,
SQUAT -> parked (settles to 2) then RISE (settles to 0).  Refusal while moving
still holds (posture=0, authentic per Lynx).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 23:35:37 -05:00
Joe DiPrimaandClaude Opus 5 fcd1a0ca8d #142 crouch: refuse-and-snap, not queue -- and honour the must-be-stopped rule
Fixes a regression I introduced in 59f53da.  That revision retried the request
when the posture gate was not ready, which is worse than the drop it replaced:
benched, a crouch tapped at a walk QUEUED for 41 seconds (41 [duck] WAITING
lines) and would fire the instant the pilot stopped -- while duckState stayed
1, so the cockpit symbol read "crouched" for the whole time a STANDING mech
walked around.  duckState is what the gauge strip draws; it has to tell the
truth.

Now: if the gate refuses, snap desired back to actual (duckState = duckActual)
and say so once, throttled.

This also confirms the authentic rule rather than assuming it.  Benched, a
crouch pressed while driving gives posture=0 and no squat -- exactly Lynx:
"When a mech STOPS, crouch button lowers its stance."  Immobilization while
crouched looks EMERGENT rather than gated: the leg channel parked in 'sqd'
produces no root motion to travel on, and the operator's read ("i think you
cant walk when you crouch") matches.  No [skate] in the driving case either.

Also guards re-issue: while 'sqd'/'squ' is playing (legAnimationState 2 or 3)
the transition owns the channel, so want != actual no longer re-fires
SetLegAnimation every frame.

Benched both cases:
  stopped  duckState -> 1 (crouch) -> SQUAT -> holds -> -> 0 (rise) -> RISE
  moving   REFUSED (not stopped): posture=0 ... duckState 1 -> 0
           0 squats, 0 queued waits

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 16:39:05 -05:00
Joe DiPrimaandClaude Opus 5 59f53da07b #142 crouch: duckState is the POSTURE the cockpit animation reads
The crouch symbol animation is fully present and we were starving it.

    content/GAUGE/BDUCK.PCC                         the 3-frame strip
    OneOfSeveralPixInt  @004c5204/@004c52d8         the element, reconstructed
    btl4grnd.cpp:144                                registered in the factory
    L4GAUGE.CFG:5001                                oneOfSeveralPixInt(
                                                      E,ModeAlwaysActive,
                                                      bduck.pcc,3,1,DuckState)
    ATTRIBUTE_ENTRY(Mech, DuckState, duckState)     attribute 0x37

A 3-frame mech symbol beside the CROUCH button, indexed by duckState -- the
standing<->crouching animation Lynx and Draco describe.  It never played
because the 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.  Remains in stand mode."

THE ZEROING WAS OURS.  Every writer of +0x398 in the export is the
DuckRequest handler (=1) and Mech::Reset (=0).  FUN_004a9b5c -- the master
perf, which contains the address the old comment cited as "the DuckRequest
consumer (@0x4aa011)" -- does not reference 0x398 at all.  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, and we were clearing it behind the gauge's back.

Restructure: drive on DESIRED vs ACTUAL.  duckState is the desired posture;
the parked leg alarm is the actual.  Act only on a mismatch -- no re-fire, and
nothing clears the attribute.  A frame where mapPosture is not ready now
RETRIES (throttled [duck] WAITING) instead of silently dropping the request,
which retires the old "request consumed, posture=N" miss as well.

ONE DOCUMENTED DIVERGENCE: the handler now TOGGLES.  The binary writes a bare
1 and clears the cell only in Mech::Reset, with no per-frame reader, so a
second press could never rise -- and a pod pilot's second press must un-crouch
(Lynx: "Mech is immobilized until crouch is pushed again, and mech rises").
One cell, same meaning, noted at the site.

Benched (crouch142.sh, madcat):
  duckState -> 1 (crouch) -> SQUAT -> [holds 1 while crouched] ->
  duckState -> 0 (rise)   -> RISE
Value now persists across the crouched period instead of blipping, so frames
0/1 of the strip are reachable and stable.  Also removed the interim REQUEST
DROPPED receipt: after the restructure nothing is dropped, and a receipt that
says otherwise is a trap for the next session.

STILL OPEN on #142: no immobilization while crouched (Lynx) -- nothing gates
movement on duckState or the parked leg alarm.  A driven mech with a parked
leg channel is the [skate] signature (#52), so it may not be cosmetic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 16:31:20 -05:00
Joe DiPrimaandClaude Opus 5 0530366687 #142 crouch: the mech is fine -- it is a missing PANEL ANIMATION
Benched solo AND in MP on the same chassis: both presses reach
DuckRequestMessageHandler, zero drops, SQUAT -> squat clip parked -> RISE.
Locomotion is not the bug, and MP is not refusing it.

Added an ungated [duck] REQUEST DROPPED receipt at the consumer's silent miss.
The squatCapable==0 path skips the consumer entirely AND leaves duckState
latched at 1 with NO log today; the posture-gate miss logged only under
BT_DUCK_LOG, which no player sets.  Neither fired on madcat.

What the pilot sees, traced with BT_LAMP_LOG: the button lamp is momentary
press feedback, not state --

    PRESS   -> [lamp] 0x13 <- 0x3c
    SQUAT   -> mech crouches, clip parked
    RELEASE -> [lamp] 0x13 <- 0x14    <-- while still CROUCHED

so crouched and standing look identical.

Era testimony corrects the scope: the button should ANIMATE A MECH SYMBOL
beside it, standing <-> crouching (operator).  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 concurs.

Two real gaps, neither fixed here:
  1. no immobilization while crouched -- nothing gates movement on duckState
     or the parked leg alarm.  NB a driven mech with a parked leg channel is
     the [skate] signature (#52), so this may not be cosmetic.
  2. no stance symbol -- no gauge element draws one, and the decomp carries no
     crouch/squat/stance/duck graphic string, so it is an authored IMAGE on the
     secondary MFD; find it in that gauge's element list, not by string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 16:16:12 -05:00
Joe DiPrimaandClaude Opus 5 61f21107b4 #108 THE EJECT GHOST: the death-edge latch tested the wrong field
One substitution, three field symptoms.  Mech::TakeDamageMessageHandler arms
the whole death tail -- kill report, VehicleDead, death blast -- from a
was-alive-at-entry latch:

    const int deathBlastArmed = !IsMechDestroyed();   // graphicAlarm >= 9

The binary tests movementMode 9|10 there (@0x4a0303).  The port swapped in the
graphic alarm and justified it: "the death transition sets mode 9 synchronously
with the structural flag on every path through here, so the edges coincide".
True of every DAMAGE path.  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 death tail was skipped entirely -- including VehicleDead, which IS the
respawn trigger.

Everything the field reported on night 13 follows from that:
  * "they all self destructed with panic button and didn't respawn properly"
    -- no VehicleDead, so no drop-zone hunt, so no respawn;
  * the EJECT GHOST -- the peer wrecks the mech and never un-wrecks it, because
    the un-wreck rides the master's respawn.  Normal deaths replicated fine all
    along (9 deaths -> 8 un-wrecks, benched), which is why only ejects ghosted;
  * the manual chart's "-1000 ejecting" never materialised -- the negated kill
    award and the death cost both live in the tail that never ran.

Fix: use the binary's own predicate.  MovementMode is untouched by the eject's
alarm write, so the latch arms on an eject exactly as on a combat death.

WHY SEVEN RIGS MISSED IT: the punch-out was being REFUSED, not undelivered.
EvaluateEjectPermission (@0049fa1c) grants only on
  liveWeapons < ejectMinWeapons || liveGenerators == 0 || coolantFrac < 0.05
  || (leg-gimped && !simLive)
-- armour damage satisfies none of them, and every bench ejected a healthy
mech.  An [ejecttest] receipt (2 lines) proved the dispatch fired every time
and the handler declined; the "[eject] REFUSED (mech not crippled enough)" line
was sitting in the very first bench log, ungrepped.  BT_KILL_SUBSYS's
comma-list form ("GeneratorA,GeneratorB,...") was already built for this bench.

Verified 2-node (scratchpad/night13/ejectreal.sh), before -> after:
  PUNCH-OUT landed        0 (785 refusals)  ->  1, charge=500
  peer wreck-enters       1                 ->  1
  peer UN-WRECKS          0  (the ghost)    ->  1
  eject score (type=2)    absent            ->  award=-1000.00, score 1000 -> 0
  death cost              never ran         ->  APPLYING, penalty=500

That -1000 is the manual chart's eject row to the digit: killBonus 500 plus the
500 self-damage tally, negated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 15:38:50 -05:00
Joe DiPrimaandClaude Opus 5 29b4d68ba6 scoring: the +1000 START grant -- BT's MissionStarting override was never ported
Seventh chart row.  BT overrides MissionStarting purely to seed the score, and
the override was missing, so MESSAGE_ENTRY(BTPlayer, MissionStarting) resolved
to the inherited engine handler (which only does the fade-in) and the grant
never happened.

    FUN_004bfbe8(player):
        base_MissionStarting(player);
        if (app->state == 4 && (player[0x29] & 0x40) == 0)
            player[0x1c8] = 0x447a0000;          // = 1000.0f

Both operands decode exactly against engine headers: application state 4 is
LaunchingMission (APP.h -- same enum whose 6 is EndingMission, already used by
the console flush), and simulationFlags bit 14 is NonScoringPlayerBit
(PLAYER.h: NonScoringPlayerBit = Entity::NextBit), so `(+0x29 & 0x40) == 0` IS
IsScoringPlayer().  Camera-ship/spectator players are non-scoring and correctly
get nothing.

CELL NOTE: the binary seeds the ENGINE cell (+0x1c8), not BT's own (+0x278) --
1995 carried two accumulators, which is why the KB suspected the pod's death
cost "may never have displayed".  Our port has one currentScore, so grant,
awards and death cost land together and the chart reads coherently.

Also resets the console watermark so a fresh mission REPORTS the grant rather
than a difference from last round's tally.

Benched: both players "[score] mission start: player N:1 seeded to 1000",
scores run 1001.98 -> 1908.64 with kills=1 (1000 + ~400 damage + 505 kill).

Also corrects a FOURTH copy of the dead-code claim, in btplayer.hpp's ScoreType
enum ("type 0 has NO scoring arm ... per-hit inflicted credit never existed").
Its byte-scan was right that no TABLE entry binds @004c0200 and wrong to
conclude unreachable -- the vtable Dispatch override calls it directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 11:43:54 -05:00
Joe DiPrimaandClaude Opus 5 98082e64a0 KB sweep: retire the three claims that WERE the scoring bugs
Night 13 turned up three confident KB/source notes that each closed off a
working path, and each one was the defect:

  1. context/combat-damage.md -- "@0x4c0200 is in NO table entry: dead code",
     used to justify retiring per-hit inflicted credit in build 787.  It is
     reached through BTPlayer's Dispatch override (vtable @00513300 slot 3).
     (corrected in 2772175)
  2. context/decomp-reference.md -- "shipped content authors NO role keys, so
     the cost is 0 in the field".  Wrong on both halves: the fields come from
     the role MODEL's GameModel record, not notation keys, and dfltrole
     authors killBonus=500 / deathPenalty=500 / dmgInf=1 -- the manual's chart
     verbatim.  The cost read 0 because the role was never bound.
  3. docs/RECONCILE.md -- "role registry has no WinTesla analog -> stubbed;
     base-set scenarioRole stands".  The base ctor sets it NULL, so nothing
     stood, and Mission::GetScenarioRole IS the analog.

Common shape worth remembering: all three asserted an absence (dead code, no
authored data, no analog) and none was re-tested against the binary before
being built on.  An absence claim in this KB should carry the check that
established it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 11:22:47 -05:00
Joe DiPrimaandClaude Opus 5 e0b91df3e1 scoring: CORRECTION -- the death cost was never missing; my arithmetic was
Retracts the "open item" claimed in 2fcce53.  An ungated [deathcost] receipt
at the block settles it:

  [deathcost] player 2:1 advDmg=1 role=bound penalty=500 scoreBefore=-779
              -> APPLYING

It fires once, on a self-kill, exactly as it does on a combat death.  There is
no combat-vs-self asymmetry.

WHY I GOT IT WRONG: the cost is dispatched by a DIRECT
Player::ScoreMessageHandler() base call, so it never reaches the BT matchlog.
I computed the total from the LAST matchlog row and found no -500 in it -- but
that row is emitted BEFORE the unlogged cost.  I had noted the bypass one
message earlier and still failed to apply it to my own sum.  The lesson is the
usual one: a value that cannot appear in the log you are reading is not
evidence of absence.

The receipt stays.  A debit that moves the player-visible score while being
structurally invisible to the forensic log is exactly the kind of thing that
should announce itself.

Chart status after this: -500 special-case death cost VERIFIED applying.  The
-1000 eject ROW remains unverified -- its components (self-damage credit,
negated self-kill award, death cost) are each verified, but no real punch-out
has ever fired in a bench, so the total is still arithmetic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 11:20:20 -05:00
Joe DiPrimaandClaude Opus 5 2fcce53bb2 scoring benches: self-inflicted + deaths counter verified (night 13)
scoreself2.sh -- one rig covering three unverified items, using a SELF-DESTRUCT
to reach the same paths an eject does without the panic button that defeated
five earlier rigs.

VERIFIED:
  chart '-1 each self-inflicted point'  type=0 award=-40.00 x11, total -440
  self-kill negation (#134)             type=2 award=-539.00, kills NOT incremented
  deaths counter                        PLAYER_DEAD deaths=1 tally=1

OPEN, found by arithmetic: the -500 death cost fires on a COMBAT death (prior
run: victim total exactly -500.00) but NOT on a self-kill -- A's total is
exactly -440 + -539 = -979, with no -500 in it, despite advDamage=1 and the
role bound.  The cost is dispatched by a DIRECT Player::ScoreMessageHandler()
base call, bypassing the BT handler, so it never reaches the matchlog and only
the totals expose it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 10:29:34 -05:00
Joe DiPrimaandClaude Opus 5 a4bfb64ace scoring: BIND the scenario role -- one commented-out line zeroed the whole chart
BTPlayer::scenarioRole was never assigned.  The lookup sat commented out with
"the BT role registry (BTMission::GetRoleRegistry()->Lookup) has no WinTesla
analog, so the scenarioRole set by the base Player ctor stands" -- and the base
ctor sets it to NULL (PLAYER.cpp:680).  So it stood NULL forever.

Every scoring value the game has hangs off that pointer, and the shipped
content authors them correctly.  New ungated receipt in the ScenarioRole ctor
prints what a real mission loads:

  [role] 'Role::Default' model='dfltrole' killBonus=500 deathPenalty=500
         dmgInf=1 dmgRcv=0 bias=1 ff=1 return=1000

That IS the original manual's scoring chart -- +500 a kill, -500 a special-case
death, +1 per damage point.  With the pointer NULL every award multiplied
against zero: kills scored the damage tally alone (4.88), the eject charge read
0 (the field log's "PUNCH-OUT: charge=0 (role killBonus)" = #134's missing
penalty), and the death-cost block was skipped.

The analog DOES exist: Mission::GetScenarioRole(name) (MISSION.h:162) walks
scenarioRoleChain -- the same dictionary BTL4Mission fills via AddScenarioRole()
when it parses the role pages, whose own comment says the WinTesla base exposes
it.  Same lookup, same key.  Falls back to Role::Default when a creation
message names an unknown role (shipped content authors exactly one page), and
logs BOUND/NULL so this cannot fail silently again.

Benched cross-node:
  role binding    player 2:1 BOUND, player 3:1 BOUND
  KILL AWARD      505.88  (was 4.88)   <- chart's +500, verified
  death cost      victim total -500.00 <- chart's -500
  inflicted       still tracking, killer total 1017.32 kills=1

The -500 on an ORDINARY combat death is AUTHENTIC, not a bug: the binary's gate
is advancedDamageOn alone (@004c05c4 tail: `if (player+0x264 != 0) { -role+0x20 }`),
verified in the decomp.  It only shows now because the role finally binds.  It
also reconciles the chart's two death rows: an EJECT costs -500 (death) plus its
self-kill negating its own ~500 award = -1000, and an ammo death costs -500.

CORRECTION to my own earlier note: returnFromDeath=1000 is NOT the chart's
"+1000 starting the game" -- role+0x28 is a lives/return gate (`if (< 1)` ->
mission review, else respawn).  The 1000 is coincidence.  That row is still
unlocated and is most likely console-side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 10:21:15 -05:00
Joe DiPrimaandClaude Opus 5 b2498ca39a scoring: the type-0 arm must RETURN, not break -- it was clobbering scoreAward
Chasing the duplicate rows from 1324c81 (80 real awards + 80 reading
award=0.00).  Not a double delivery -- Entity::Dispatch sends exactly once on a
replicant.  It was the `break` I left in the delegating arm.

After delegating to ScoreInflictedMessageHandler, control fell into
ScoreMessageHandler's post-switch tail, where the LOCAL `award` is still 0:

    message->scoreAward = award;             // clobbered to 0
    BTMatchLog("SCORE", ... award=0.00 ...); // the phantom row
    Player::ScoreMessageHandler(message);    // base: currentScore += 0

Harmless to the total only because the value added happened to be zero -- but
it mutated a message on a shared path and ran a base handler for nothing.  A
later reader of scoreAward, or any side effect gained by that tail, would have
turned it into a real bug with no obvious cause.

ScoreInflictedMessageHandler is self-contained (accumulates, ForceUpdate()s,
logs its own receipt), so the arm returns.

Re-benched cross-node:
  rows on shooter's master  100, ZERO phantom rows (was 80 + 80)
  rows on victim's node     0
  running total             417.85, climbing continuously, no resets
  type-0 Verify rejects     0
  kill path                 intact (kills=1)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 10:00:24 -05:00
Joe DiPrimaandClaude Opus 5 1324c81719 scoring: land inflicted credit on the OWNER's machine (Steam + console safe)
Completes 2772175/e82f54c.  The interceptor restored the credit; this puts it
on the right node, so a player's score accumulates again.

The operator corrected two of my claims, and both were load-bearing:

1. Scores DID accumulate before build 787.  Checked: build 774 already had
   `currentScore = 0` in the console flush, so the flush was never eating
   score.  My "score zeroed every interval" theory is dropped.  The watermark
   from e82f54c stays only because it is harmless and keeps a master's own
   total intact across a flush -- it was not fixing a field bug.

2. The reroute works, and 774's own comment says so: the killer's player is a
   REPLICANT, so Entity::Dispatch reroutes to the owning host
   (ENTITY.cpp:244-251) and the credit lands on the killer's OWN machine.
   That is how kill credit has always crossed nodes.

So the earlier master-only gate was the right idea and failed for a reason I
guessed wrong.  A rerouted message arrives over the WIRE through Receive(),
which goes straight to the handler table -- the virtual Dispatch override is
never called on the receiving side.  Type 0 therefore landed in
ScoreMessageHandler's arm, which Verify-rejected it: award 0.00.

Fix is both halves:
  * Dispatch intercepts on a MASTER only -- local delivery stays exactly as
    @004bffa0 does it;
  * ScoreMessageHandler's type-0 arm DELEGATES to ScoreInflictedMessageHandler
    instead of Verify-rejecting -- wire delivery gets the same handler.
One accumulator, on the machine that owns the score.

Works for Steam today (no console tally exists -- btconsole.py/btoperator.py
handle no score at all) AND for a real operator console later: the console
flush is untouched and still ships authentic deltas under the owner's ownerID.

Benched (cross-node zone-walk kill):
  credit node      shooter's master only (victim's node banks 0)
  running total    253.60 and CLIMBING, no resets
                   (was: peaks ~35, snapping back every few seconds)
  type-0 rejects   0
LOOSE END: each real award is followed by a duplicate row with award=0.00
(80 real + 80 zero).  Harmless -- the total is unaffected -- but it means the
report is delivered twice on the owner; not yet explained.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 09:52:26 -05:00
Joe DiPrimaandClaude Opus 5 e82f54c957 scoring: the score AUTHORITY is the operator console -- and our port has none
Follow-up to 2772175 (type-0 interceptor restored).  Benching the restored
credit exposed the next layer, and two of my attempts at it were wrong; both
are recorded so they are not retried.

FINDING [T1]: the binary flushes ConsolePlayerVTVScoreUpdate(ownerID,
currentScore) 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 one:
every delta is stamped with the scoring player's ownerID and the CONSOLE
accumulates.  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() (SCORE gauge),
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
master's next update record overwrites it.  Benched: totals climb to ~35 and
snap back every few seconds.  THAT is the "scoring went screwy" report.

WRONG TURN 1 (reverted in spirit, kept only where harmless): blamed the console
flush and added a last-sent watermark so the console still gets deltas while
+0x278 keeps a total.  The resets were 2s apart, not on the 10s console
interval -- the timing was already in the data.  The watermark stays because it
does stop the FLUSH from zeroing a master's own total, but it was not the bug.

WRONG TURN 2 (reverted): gated the interception to MasterInstance so a
replicant would reroute to the master.  The message arrives, but the BT
extension fields (damageAmount@+0x24, senderMechID@+0x34) do NOT survive the
wire -- only the base scoreAward -- so every award computed 0.00.  That failure
is the clue to the answer: the kill report (type 2) credits cross-node
correctly precisely because its value rides scoreAward.

FIX SHAPE (not implemented -- landing it deliberately rather than guessing a
third time): compute the award on the victim's node, where the damage data
lives, and ship the RESULT in scoreAward the way the kill report already does,
instead of shipping the basis and recomputing on a machine that cannot see it.

State now = binary-faithful unconditional interception.  Re-benched: 79
inflicted rows, awards 0.98..25.00 all positive and tracking damage, 0 type-0
Verify rejections.  Cross-node banking still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 08:41:59 -05:00
Joe DiPrimaandClaude Opus 5 27721754da scoring: restore the type-0 INTERCEPTOR -- per-hit inflicted credit was live all along
Players reported scoring and K/D going screwy on 4.11.817.  Cause: build 787
(#45/#134) retired the port's per-hit inflicted crediting as an "invention",
on the strength of a KB claim that the type-0 score handler was dead code.
That claim was wrong.

BTPlayer overrides Dispatch -- vtable @00513300 slot 3 = FUN_004bffa0 -- and
splits type 0 off BEFORE base dispatch:

    if (msg->id == 0x16 && msg->type == 0)  FUN_004c0200(...);   // ScoreInflicted
    else                                    base dispatch;

@004c0200 names itself in its own Verify string
("BTPlayer::ScoreInflictedMessageHandler") and computes
CalcInflicted(basis) -> negate if target==self -> x (targetTonnage/ownTonnage)
-> accumulate into +0x278.  ScoreMessageHandler's type-0 arm Verify-rejects
precisely BECAUSE this interceptor guarantees type 0 never reaches it.

The port had the handler, faithfully reconstructed, and no interceptor -- so
Block B's inflicted reports all landed in the rejecting arm and banked 0.
Per-hit damage credit was silently deleted.

Independently corroborated by the ORIGINAL MANUAL'S SCORING CHART (filed as
reference/manual/scoring_chart.webp, from Lynx): "+1 each damage point scored
on opponent's armor" and "-1 each self-inflicted point of armor damage" -- the
negate-if-target-is-self arm exactly.  Without that chart the dead-code note
would probably have stood.

Verified (scratchpad/night13/scoreverify.sh, cross-node kill, 2 nodes):
  type-0 Verify rejections   0   (was firing on every non-lethal hit)
  inflicted score rows      83   awards 0.98..25.00, all positive, tracking damage
  kill path un-regressed    type=2 award=4.88 kills=1, victim respawn x1

KB: combat-damage.md report B and the score-model paragraph rewritten, with
the full chart and THREE unreconciled rows flagged [T4] -- flat +500 kill vs
the benched 4.88, +1000 at game start, and -1000 eject / -500 ammo (which
would live in ScenarioRole::specialCaseDeathPenalty @role+0x20, read by the
port but authored nowhere in shipped content).

KNOWN, NOT FIXED HERE: in MP the running total does not persist -- currentScore
is flushed to the operator console and ZEROED (btplayer.cpp ~1219) because the
binary treats it as a console DELTA.  Restoring the credit makes that very
visible (bench: totals climb to ~35 then reset).  Needs its own decision; the
chart's "+1000 starting the game" implies a persistent total lives somewhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 08:15:22 -05:00
Joe DiPrimaandClaude Opus 5 4642129e76 #108 peer-side WRECK receipt: make ghosts countable
The un-wreck receipt 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 found ONE ghost while testers
reported many, and there was no way to separate a real count from a chassis
accident.

Emit one ungated line for every REPLICANT entering the wreck state, symmetric
with the existing un-wreck line, so a log's ghost count is exactly
(wreck-enters minus un-wrecks) per entity:

    [wreck]   replicant H:E entered wreck state (mode X->9) at (x,z)
    [respawn] replicant H:E un-wrecked + warp   (mode 9->1) at (x,z)

Verified 2-node (200s, force-damage victim): 5 enters, 5 exits, exactly
paired -- while the old marker printed ZERO times in the same run.  That gap
is the point: five real deaths, invisible to what the census was reading.

Also lands the night-13 census tooling (ghostcensus.py) and the eject benches
that did NOT reproduce, with their failure modes in the headers so the next
attempt does not repeat them: five rigs failed to trigger a punch-out at all
(BT_BTNTEST never reached the mapper for 0x3D or 0x14; BT_EJECT_AT did not
fire either).  Panic-eject replication remains UNTESTED by bench.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 07:48:04 -05:00
Joe DiPrimaandClaude Opus 5 43d30ca7e4 #140 receipts: name the corruption case in a FIELD log
Two ungated one-liners, because no bench here reached the failing path and
the next playtest is a better instrument than more automation:

  [glasswin] destroy entry #N windows=M   -- N=2,M=0 is the double-destroy
  [glasswin] saved ... (live=L remembered=R) -- live=0 IS the corruption case
                                                (pre-cache that wrote a file
                                                holding only the plasma line)

Also lands the benches that did NOT reproduce it, with their failure modes
recorded in the headers so the next attempt does not repeat them:
layoutsave.sh (round trip -- passes on the fixed build), layoutteardown.sh
(graceful WM_CLOSE; still never reaches the dtor chain), layoutround.sh (MP
round boundary; the relay never started the mission inside the window).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 01:23:33 -05:00
Joe DiPrimaandClaude Opus 5 c04bec0a52 #140 glass_layout.cfg lost every MFD line on a desktop teardown
Regression from d213c98 (the pod PadRIO/panel coupling fix).

BTGlassPanels_Destroy calls SaveLayout FIRST, unconditionally, before it
looks at whether any windows are left.  d213c98 added a SECOND caller
(~LBE4ControlsManager) alongside the existing one in ~PadRIO, so on the
desktop path both run: ~LBE4ControlsManager does `delete rioPointer`, which
fires ~PadRIO -> destroy #1 saves the live windows and zeroes gWinCount ->
destroy #2 then rewrites the whole file from an empty list.

Why only the MFDs vanished, which is the detail that identifies it: external
windows (plasma) already cached a last-known rect (gExtern[].haveLast) and
were written from the cache; the per-display glass windows had no cache and
were simply skipped once their HWND was gone.  Hence the reported signature,
"all the MFDs and secondary lines missing, but plasma was still there".

The pod was never affected -- no PadRIO there, so only one destroy, and it
runs BT_GLASS_LAYOUT=load off a frozen master regardless.

Two fixes, because the guard alone would leave the trap armed for the next
teardown-ordering change:
  1. glass windows get the same remembered-geometry cache the extern windows
     have, kept ACROSS teardown.  The file is now monotonic -- a save can
     update a line or add one, never drop one.
  2. the teardown save is guarded on there being windows to report.

Also corrects the comment at the L4CTRL call site, which claimed the second
call was "a no-op on the PadRIO path".  That claim is what made it look safe.

Verified (scratchpad/night13/layoutsave.sh -- the tester's round trip, not a
single launch, since the report was "saved fine, reset on relaunch"):
  run 1  one "saved 8 window position(s)" line (was two, the second wiping)
         cfg holds all 7 glass windows + plasma
  run 2  "restored 7 window position(s)", cfg byte-identical after the trip

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 01:03:32 -05:00
Joe DiPrimaandClaude Opus 5 6a96fb6420 #52 the peer STANDING-LOCK: case 0's fallthrough was intercepted
A replicant could not start walking between gait-change records.  The port's
body case 4 (the task-#64 lockstep twin) is an INSERTION sitting between case 0
and the advance group; in the binary case 4 is a MEMBER of that group
(FUN_004a5678 @004a5678: case 2,3,4,5,8,... -- no turn block, no speed exit),
so case 0's fallthrough is meant to land on Advance().  The insertion caught it.

On a replicant that is not a race but an identity: case 0 arms walk iff
standSpeed < bodyTargetSpeed, and the inserted block resets iff standSpeed <
bspd -- where bspd IS bodyTargetSpeed for a replicant.  Same expression, so arm
and reset fire on the same frame, forever, and a peer parked at Standing with a
live replicated demand never cycles.  bodyCycleSpeed stays 0 while position
advances from dead reckoning: the skate.

This is the sequel to e91d447 (#82).  Before it the replicant branch read the
dead local mapper cell (0 forever), the exit never fired, and the fallthrough
worked BY ACCIDENT.  Fixing the dead cell closed the escape hatch.

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  336 consecutive locked seconds, bspd=39.2324 bts=39.2324 every line
  fixed   0 locks, every pass
  master body-Standing samples 52 -> 21 (it locked too, invisibly at mj=0)
  turn-in-place intact: pivoter body state 4 x9 / leg state 4 x8, in lockstep

Diagnostics (both keepers): [skate] now carries bstate= -- the field lines
proved "both channels idle" but never named the state, which was the whole
answer; [bodySM]/[peergait] under BT_BODY_SM_LOG instrument the arm->reset pair
and a moving replicant's body channel.

NOT claimed: that this accounts for the night-13 field episodes.  That link is
inference -- locked + translating IS the skate signature by construction, but no
bench caught the two together.  bstate= settles it next playtest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCJQkvq6G2JNrpVbA75tVZ
2026-08-07 00:50:54 -05:00
Joe DiPrimaandClaude Opus 5 39144813a4 pod: kit carries PODTEST.EGG (it was never in the repo)
First real new-build drop on the cart exposed it: the kit pushed environ.ini
and glass_layout.cfg into the fresh 4.11.817 extract, but runpod.bat launches
`-egg PODTEST.EGG` and that mission only ever existed on the pod -- mkdist
ships git-TRACKED content only, so the new install had no mission to run.
Master copy now sits at C:\bt411\ with the other kit files and podkit.ps1
installs it.

Does not affect testers: play_solo/join/play_steam pick their own missions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rw7No5wLTpkaUgA3ANbtZZ
2026-08-06 19:20:23 -05:00
Joe DiPrimaandClaude Opus 5 d213c980a9 fix: MFD panels never appeared with the HARDWARE RIO (PadRIO coupling)
Nick ran the cab with the real board and the mission came up on the main view
with every MFD dark -- and not one [glasswin] line in the log.

BTGlassPanels_Create() was called only from the END OF THE PadRIO CONSTRUCTOR.
The panels began as the frames around the on-screen RIO button banks, so "the
buttons ride the device" was reasonable then; it is backwards on a real cab.
L4CONTROLS=RIO:COM1 means PadRIO is never constructed, so the MFD windows were
never created -- silently, since nothing is wrong from the display layer's
point of view.

The panels are a DISPLAY concern.  Creation moves to LBE4ControlsManager,
after the L4CONTROLS parse where every device branch converges, and a
symmetric BTGlassPanels_Destroy() goes in its destructor: ~PadRIO tore them
down, ~RIO knows nothing about them, and windows outliving the surfaces they
blit would crash on the next mission cycle.  Both calls are idempotent
(Create returns on gWinCount != 0), so the proven PadRIO path is unchanged
and simply arrives first.  Both guarded by BT_GLASS -- L4GLASSWIN is only in
the build when the gate is on.

Verified on the cart: hardware RIO up AND all three surfaces on their intended
displays in the same run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rw7No5wLTpkaUgA3ANbtZZ
2026-08-06 19:05:04 -05:00
Joe DiPrimaandClaude Opus 5 6fcff95010 pod: the HARDWARE RIO on COM1 (L4CONTROLS=RIO:COM1,KEYBOARD)
The real cockpit board now drives the cab instead of PadRIO, frozen in the
profile so all five tester launchers get it.  Nothing above the seam changed
-- the 109-mapping L4 control table installs exactly as on a desktop, and the
cab keeps the GLASS display stack.  BT_PLATFORM=pod is NOT the way to this;
that would drag in the 1995 gauge path.  RIO:COM1 -> \.\COM1 at 9600 8N1.

Evidence the link is real, not just "the port opened":
  RIO successfully initialized!
  FAILURE.LOG: 4 missing boards (Slot 3:0, 3:2, 4:0, 5:0), 16 dead lamps
  [ctrlmap] push stick x=0.0595238 y=0   <- physical stick outside deadband
A specific 4-of-many board inventory is the proof: a dead serial line reports
the WHOLE address space missing.  Reproduced after deleting FAILURE.LOG.  The
dead lamps are the boards this partial crash cart does not have.

Banner honesty: "GLASS (PadRIO ...)" was hardcoded, so a wired cab reported
PadRIO on the very line you read to check which device won.  It now names the
resolved one -- GLASS (hardware RIO; plasma off [L4PLASMA]).

Recorded in pod-hardware.md, including that RIO and PAD are mutually exclusive
(both assign rioPointer, last token wins) and that a CENTRED stick reads x=0,
which is indistinguishable from no data -- so the by-hand check of stick,
throttle, pedals, buttons and the Ranger calibration is still outstanding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rw7No5wLTpkaUgA3ANbtZZ
2026-08-06 18:11:28 -05:00
Joe DiPrimaandClaude Opus 5 05d8164801 pod: make the frozen rig survive a NEW BUILD, not just a reboot
Both frozen files live inside the versioned install and neither ships in the
zip -- environ.ini is generated on first run, glass_layout.cfg is ours.  So
Nick extracting the next build would get a cab that comes up wrong with no
error anywhere, which is exactly the failure the freeze was supposed to end.

Masters now live at the stable C:\bt411\ (podprofile.ini, glass_layout.cfg,
podkit.ps1).  setup_pod.bat pushes them into the newest BT411_* folder -- run
once per extract.  runpod.bat resolves the newest install and applies the kit
itself, so the remote launch path needs no per-build edit.  Re-tuning means
editing the MASTER: the apply overwrites the install's copy on purpose, so a
moved panel has one place to look.

Also records, for playtesting on the cab: all five tester launchers set no pod
key at all, so each inherits the rig from environ.ini without knowing the pod
exists -- verified by running play_solo.bat on the cart (7 settings applied,
not 9, because the bat sets BT_PLATFORM and BT_START_INSIDE itself and the
real environment wins).  That is the case against a separate pod-only ini: a
second file would need every launcher to opt in.

Verified: kit re-applies idempotently, and the rewritten runpod.bat brings the
cab up correct -- 9 settings, GLASS, -fit borderless, all three surfaces on
their intended displays.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rw7No5wLTpkaUgA3ANbtZZ
2026-08-06 17:59:57 -05:00
Joe DiPrimaandClaude Opus 5 d96fa4f4e2 pod: BT_GLASS=1 is not a gate -- name the real one (BT_PLATFORM=glass)
BT_GLASS is a compile-time #ifdef; getenv("BT_GLASS") appears nowhere in the
tree.  The line rode along from the bring-up launcher into the frozen profile
and into the pod-hardware runbook, reading like the switch that turns the
glass path on.  It never did anything -- the rig worked because glass is the
DEFAULT profile when nothing is set.

Replaced with BT_PLATFORM=glass (the real spelling, so the cart does not lean
on that default) and noted why NOT BT_PLATFORM=pod: the pod profile selects
the 1995 multi-surface gauge path, which needs the NVIDIA horizontal span no
modern driver has.  Behaviourally identical -- both land gBTPlatformGlass=1.

Re-verified on the cart: 9 settings applied, GLASS profile, -fit borderless,
all three surfaces on their intended displays.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rw7No5wLTpkaUgA3ANbtZZ
2026-08-06 17:44:07 -05:00
Joe DiPrimaandClaude Opus 5 654277bb7b pod: freeze the ALPHA-MR rig in environ.ini (BT_FIT, L4PLASMA=NONE)
The cart's display config was carried by a launcher .bat, so it only came up
right if the game was started one particular way.  Move it into the file the
engine already reads before anything touches the environment.

  content/environ.ini  <- scratchpad/pod/podprofile.ini, merged idempotently
                          between markers by scratchpad/pod/mergeprofile.ps1

Two gates were missing for that to be enough:

  BT_FIT=1      the env spelling of -fit, so the borderless main view does not
                depend on one launcher's command line (shortcut, scheduled
                task and autostart all have to produce the same rig)
  L4PLASMA=     NONE / OFF / 0 -> no marquee at all.  Leaving it unset does
                NOT work: the GLASS profile force-defaults it to SCREEN, which
                drops a desktop plasma window on the cab's glass.  The boot
                banner now reports the live state instead of always claiming
                "plasma window".

Also lands the bring-up engine work this depended on: monitor:<name|index>
layout binding (device-bound, not pixel-bound -- desktop rects move when a
display re-enumerates), ",bare" implying frameless, rotation-aware radar
surface sizing, BT_GAUGE_SEC_ROT accepting 0-3 (it silently forced 3 for
anything but 1), 180-degree ExpandPlaneToBGRA, and the BT_POD_CHANMAP /
BT_POD_IDENT / BT_POD_CHANTEST identification gates.

Verified on the cart, build 4.11.813, launcher carrying none of it:
  [boot] environ.ini: 9 setting(s) applied
  [boot] platform profile: GLASS (PadRIO; plasma off [L4PLASMA])
  [cockpit] -fit: borderless 800x600
  [glasswin] radar rotation 0 (none)
  ... all three surfaces on their intended \.\DISPLAYn
No [plasmawin] line in an otherwise-logging run = the ctor never ran.

KB: pod-hardware.md gains the ALPHA-MR section (mapping, the two wiring
deviations, the frozen profile, the session-0 remote-work traps) and its RGB
SPLIT "OPEN: which way is the cart wired" is now SETTLED -- it is splitter
wired, the composite is what lit it.  glass-cockpit.md documents monitor:
binding, BT_FIT and L4PLASMA=NONE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rw7No5wLTpkaUgA3ANbtZZ
2026-08-06 17:40:39 -05:00
Joe DiPrimaandClaude Fable 5 bef051e837 handoff: pod bring-up + RGB split
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 12:55:30 -05:00
Joe DiPrimaandClaude Fable 5 e179c7033f BT_POD_RGB: the authentic RGB-split output -- one window per VGA PORT
Implements what the pod actually does (pod-hardware.md THE RGB SPLIT): a VGA
port's R/G/B lines each drive a separate mono MFD monitor, so a window is not
one MFD -- it is one PORT carrying up to three.  BT_POD_RGB=1 collapses the
five MFD windows into the two ports the cab drives (Port A: Comm=red,
Mfd2=green, Heat=blue; Port B: Mfd1=red, Mfd3=green) and composites each
group's planes into the colour channels, leaving the radar on its own
full-colour port.  Channel comes from the live port (GetEnableID), never a
hardcoded table, so an Eng-page swap follows automatically; BlankColor planes
contribute nothing, exactly as on the pod.  Implies BT_POD_SURFACES (bare
640x480 pictures -- the cab's buttons are physical).

Verified locally (bare windows, "RGB COMPOSITE of 2 plane(s): Comm Mfd2") and
LIVE ON NICK'S CRASH CART over the tailnet: Port A -> DISPLAY4, Port B ->
DISPLAY2, radar -> DISPLAY1, all exact-fit.  Pod scratch kit included (ssh
helper, layout cfgs for both modes, launcher; the mission-egg launcher fix --
a bare MP.EGG exits the mission loop with no relay, and BT_FE_SOLO parks at
the menu, so neither lights the panels).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 12:55:05 -05:00
Joe DiPrimaandClaude Fable 5 e0e9dcc292 KB: decode the pod's RGB SPLIT -- one VGA port drives THREE mono MFDs
Answering 'how do the panels split RGB into 3 monitors' from primary sources
rather than inference:

- content/GAUGE/L4GAUGE.CFG (the authentic 1996 pod config) configures each
  gauge port with a bit-plane mask AND A COLOUR CHANNEL: Comm=red,
  Mfd2=green, Heat=blue on clut2 (the upper row); Mfd1=red, Mfd3=green on
  clut1 (the lower row, blue spare); sec/radar = full rgb, rotation 270 (the
  portrait CRT).  Eng1/2/3 are the engineering-page twins on the same
  monitors, swapped in/out via reconfigure() with 'blank'.
- L4GraphicsPort::BuildSecondaryColor (L4VB16.cpp) proves the mechanism at
  T0: it walks the palette entries owned by the port's bit group and writes
  exactly ONE component (RedChannel->Red, GreenChannel->Green,
  BlueChannel->Blue, AllChannels->whole triplet); BlankColor blanks the
  group.  So one palettized framebuffer emits three independent pictures on
  the R/G/B analog lines, and the splitter feeds each line to its own mono
  monitor -- which is also what the '1280x480 horizontally spanned' MFD
  surface actually is: two VGA outputs x three channels.

Port consequence recorded: the per-panel window path (BT_POD_SURFACES) is
right for per-panel outputs but WRONG for splitter-wired glass, which needs a
channel-composite mode (three planes -> one RGB image, pure primary tints).
ExpandPlaneToBGRA already does the per-plane half.  Open: how Nick's cart is
actually wired.  Also lands the pod bring-up scratch (ssh helper, layout cfg,
launcher, firestorm repo browser).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 12:35:39 -05:00
Joe DiPrimaandClaude Fable 5 99956d54e3 tools: podshell_setup.ps1 -- one-shot remote-shell setup for the pod PC
OpenSSH server + a single authorized key + a private/domain-only firewall
rule, so bring-up can run over a tailnet instead of by hand through Chrome
Remote Desktop (CRD paints a canvas -- unreadable to tooling; text and logs
need a real shell).  Handles the Windows administrators_authorized_keys ACL
quirk, refuses to run on pre-Win10 (the period-pod case, which stays on the
clipboard/probe route), never opens the public profile, and prints the exact
ssh line including the Tailscale address.  Undo steps in the header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 11:48:13 -05:00
Joe DiPrimaandClaude Fable 5 38b08c96d1 podprobe: run-compatibility verdict + XP-safe .bat fallback
The crash cart may BE the period pod PC (that is where NVIDIA Horizontal Span
still exists), in which case the first question is not the display map but
whether btl4.exe can launch at all -- a modern MSVC toolset needs Win7 SP1+.
The probe now states the verdict outright, and podprobe.bat covers the case
where PowerShell/.NET is not present to run the probe in the first place
(wmic + dxdiag, both XP-era tools).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 11:35:38 -05:00
Joe DiPrimaandClaude Fable 5 67a4f09e86 POD MFD bring-up: BT_POD_SURFACES bare-panel mode + placement receipts + probe
Nick's crash cart is the first shot at driving the real MFD panels, so the
pieces the cab needs that the desktop glass path lacked:

- BT_POD_SURFACES=1 (L4GLASSWIN): crop every display window to its SURFACE
  (MFDs exactly 640x480, radar 480x640 portrait), drop the on-screen RIO
  button banks -- the cab's buttons are PHYSICAL, so drawing fake ones onto a
  real panel is exactly wrong -- go frameless, and skip the Flight Controls
  pad entirely (6 windows, not 7).  Per-window ",bare" in glass_layout.cfg
  for mixed rigs (idempotent with the global mode).
- Placement receipts: each window logs which PHYSICAL monitor it landed on
  ("[glasswin] 'Heat MFD' surface=Heat at X,Y 640x480 bare -> monitor
  \.\DISPLAYn (...)").  Nobody can see 7 surfaces at once on a cab, and over
  Chrome Remote Desktop you cannot see the panels at all -- the log is the
  only confirmation the map is right.
- tools/podprobe.ps1: run-on-the-pod topology probe (no install/admin) --
  GPUs, every monitor's virtual-desktop rect, EDID make/model, serial ports
  (the RIO board), session type, plus a PROPOSED glass_layout.cfg assigning
  the six surfaces to the non-primary monitors.  Self-tested here (correctly
  reports a single-monitor laptop and declines to map).

Local verify: 5 MFDs at 640x480 bare + portrait radar + Flight Controls
dropped, receipts printed.  KB: pod-hardware §MFD PANELS ON REAL HARDWARE
(incl. WHY the 1995 path is not the route -- NVIDIA Horizontal Span is gone
from post-XP drivers and per-adapter exclusive fullscreen is fragile over a
remote session) + glass-cockpit env table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 11:31:49 -05:00
Joe DiPrimaandClaude Fable 5 bf39b81b9d KB: document the #60 census/re-export aftermath across the context system
- CLAUDE.md front + project-overview: census/re-export DONE (93.5% coverage,
  41KB dark), polish list refreshed; the citation-policy warning rides the
  front matter so it is unmissable.
- locomotion §CROUCH addendum: the re-export CONFIRMS the hand transcription
  field-for-field, documents that Ghidra renders members as int-ARRAY indices
  ([0xfe] == byte 0x3f8 -- why offset-string greps of the export miss them),
  and records the AIRBORNE AUTO-RISE branch the raw pass missed (+ its fix).
- decomp-reference §7: citation policy (@ADDR, never part/LINE -- old
  citations resolve only against archive_2025export/), the array-index
  gotcha, and the full re-export toolchain (ghidra_reexport.sh incl. the 8.3
  short-path requirement, gapcensus, gapdiff).
- open-questions: the leads the re-export produced -- @0x4c0904 is the MASTER
  BTPlayer Performance (team-by-name resolve + EndMission post + score
  heartbeat; our @0x4c083c attribution needs a re-check) and the ~9KB
  l4splr|btmssn cluster that stayed dark THROUGH the fill (no call/data
  reference reaches it -- jump-table entry suspected).
- glossary: dark-region.
Section ordering fixed (addenda above Key Relationships); checkctx CLEAN.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 11:18:48 -05:00
Joe DiPrimaandClaude Fable 5 51dff6bd5a handoff: #60 closed (census + re-export)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 11:12:20 -05:00
Joe DiPrimaandClaude Fable 5 f44be87ab2 #60 PART 2: the RE-EXPORT -- dark code 90KB -> 41KB, coverage 87.3% -> 93.5%
Installed JDK 21 + Ghidra 12.1.2 (no admin, %LOCALAPPDATA%\bt411-tools beside
DXSDK/cmake; runner uses 8.3 SHORT paths because Ghidra's .bat expands
%JAVA_HOME% unquoted and the profile has a space).

New tooling: reference/ghidra_scripts/ExportGaps.java -- ExportAll's exact
output contract PLUS a gap-fill pass (force disassembly + createFunction at
E8 call targets outside functions, data->code pointers at a plausible
prologue, and the census's discovered starts; iterated to a fixpoint,
logged to gapfill_report.tsv).  tools/ghidra_reexport.sh (headless runner,
'reprocess' mode) and tools/gapdiff.py (score two censused exports);
gapcensus.py now censuses any export dir.

Results: 6267 -> 6472 functions (+205 created in 2 rounds: 195 census
starts, 6 call targets, 4 data pointers; 56.1KB newly covered), ZERO
decompile failures.  Dark real code 90.4 -> 40.8 KB (54.8% recovered);
game-side dark 53.1 -> 21.1 KB; regions 428 -> 321.  EVERY historically
dark function now has pseudocode -- including @0x4c05c4 VehicleDead, the
absence that opened this issue.

VALIDATION: the new pseudocode confirms this week's hand reconstruction of
the crouch field-for-field (mapPosture/duckState/squatCapable/myomerEff/
novice gate/SetLegAnimation/ForceUpdate/stability alarm) -- and exposed one
branch the raw pass missed: AIRBORNE AUTO-RISE (mode 3|4 && legState 1 ->
forced squ), now implemented in mech4.cpp and re-benched un-regressed.

PROMOTION: the re-export is canonical reference/decomp/; the previous export
is preserved at reference/decomp/archive_2025export/ so old
`part_0NN.c:LINE` citations still resolve (addresses are stable across both;
line/shard membership is NOT -- cite @ADDR).

New lead recorded: @0x4c0904 is the MASTER BTPlayer Performance (team
resolution, EndMission console post, score heartbeat) -- our @0x4c083c
PlayerSimulation attribution needs a re-check.  KB: source-completeness,
gotcha #20 (the rule is cheap now -- look it up), CLAUDE.md router/layout.
Log: phases/phase-04-gap-census.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 11:11:26 -05:00
Joe DiPrimaandClaude Fable 5 e69c0d7aa8 #60 GAP CENSUS: the export dark-region inventory (tool + report + KB)
tools/gapcensus.py (deterministic): index-vs-.text interval math, export
cross-check, function-start discovery inside dark regions (E8 call targets +
data-section code pointers), pad exclusion, TU attribution, repo-citation
flags. Output: reference/decomp/GAP_CENSUS.md + gap_census.tsv.

Headline: .text 892KB, index covers 87.3%; 428 dark regions = 90KB REAL code
(indexed-but-unexported = 0 -- the gap class is purely 'never indexed').
Game-side dark ~54KB: 66 regions visited by past digs, 159 NEVER TOUCHED.
Validation: all six historically-bitten dark addresses (VehicleDead,
ToggleLamp, death tail, master-perf, myomer integrator, duck consumer) land
inside census regions; the two most-cited regions are the two that produced
the most reconstructions.

Top uncharted leads (spot-checked real code): the ~9KB l4splr|btmssn cluster
(dispatch-table state machine -- likely BTMission's unexported heart); the
613B btplayer hole before the ctor (mission-review id-0x18 sender suspect);
btl4app tails; heat|mechmppr + mechweap|btplayer boundaries. Full log:
phases/phase-04-gap-census.md. Re-export half deferred (no local Ghidra;
scripts ready). KB: source-completeness census section + CLAUDE.md lookup
row + decomp-reference tools entry; consult the census BEFORE any 'absent
from the export' claim (gotcha #20).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 10:34:02 -05:00
Joe DiPrimaandClaude Fable 5 f49f35cd43 KB housekeeping sweep: end-of-arc staleness purge (2026-08-06)
Corrections (wrong claims fixed + swept):
- open-questions: the DuckState 'no CODE consumer, authentic [T1]' verdict
  RETRACTED (consumer = the unexported posture block; squat clips shipped in
  BTL4.RES all along); the searchlight fog swap re-marked DONE (was 'STILL
  DEFERRED'); crouch index pointer updated.
- combat-damage: old task-#60 kill-score section marked SUPERSEDED by the
  report tail (BTPostKillScore retired).
- pod-hardware: crouch census rows -> COMPLETE; gauges-hud: 07-20 note
  (generators 'unreconstructed') re-swept.

New knowledge:
- reconstruction-gotchas #20: EXPORT-GAP BLINDNESS (4 incidents, 2 falsely
  T1; the byte-scan/named-member/RES-TOC/manifest checklist) + the
  verification-viewpoint bullet in #13 (the crouch capture errors).
- decomp-reference: master posture/crouch block offsets (+0x3f8/+0x79c, the
  myomers cluster) + 8 new bench env gates.
- rendering: beam-material decode (btfx brighten) + look-pass acceptance.
- experience-levels: crouch novice lockout in the +0x25c consumer list.
- build-and-run: the stale-link build ritual; test-harness: bench-script
  gotchas (both previously oral tradition).
- glossary: export-gap-blindness; multiplayer: crouch/searchlight ride
  existing records; subsystems: searchlight visuals pointer.

Status refresh: CLAUDE.md front + project-overview current-state -> core
gameplay reconstruction COMPLETE (774->801), polish phase, #60 census next.
Ledger addenda: INPUT_PATH row 6 complete; RESPAWN_REARM VehicleDead-sender
note. .gitignore: content/ bench artifacts (pngs/logs/eggs/exe) out of
status. checkctx CLEAN.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 10:22:50 -05:00
Joe DiPrimaandClaude Fable 5 5e17bb2262 handoff: Cyd merge + re-cut 801
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 09:51:14 -05:00
Joe DiPrimaandClaude Fable 5 4beac48f31 Merge Cyd's glass-lamp-latency: #138 unfocused flash-rate fix + red Panic/Eject + plasma no-frame
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 09:48:21 -05:00
Joe DiPrimaandClaude Fable 5 3e69944293 handoff: cut 4.11.797
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 09:27:03 -05:00
Joe DiPrimaandClaude Fable 5 e1c3f2db6a Myomer factor: correct the false 'feeder unreconstructed' claim + wire the
crouch gate to the LIVE drive value

The myomer system was ALREADY COMPLETE (2026-07-31 seek audit): Performance
wrapper @004b8b9c, AvailableOutput @004b8ac0 (gear clamp x quadratic heat
degrade x (1 - zone damage)), and the master-perf chain walk + speedDemand
scale + turn freeze in mechmppr.cpp:990 -- the same @0x4a9cf2-0x4a9da4 bytes
the crouch dig re-decoded.  The 2026-08-05 banners calling the feeder dark
were an export-gap-blind grep (named members, not offsets).  Fixes:
mechmppr publishes the chain MAX into mech->myomerEffectiveness (the
binary's +0x79C home) so the crouch posture gate reads the live factor
(dead/overheated myomers now genuinely refuse squat/rise -- previously the
gate read a neutral 1.0 and never fired); the duplicate speedDemand multiply
in the posture block is removed (mechmppr's is the one application); banners
and locomotion.md corrected.  Squat re-benched green on the live wiring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 09:21:28 -05:00
Joe DiPrimaandClaude Fable 5 58c3db090b KB: crouch eye-drop residual was false -- measurement error, corrected
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 08:55:29 -05:00
Joe DiPrimaandClaude Fable 5 18418af8fd handoff: crouch complete
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 00:50:39 -05:00
Joe DiPrimaandClaude Fable 5 591d205b19 CROUCH complete: full cycle + MP replication verified
The WIP's 'pose does not hold' was a chain of bench-instrument errors, not a
code bug: every capture ran in COCKPIT view (the pilot cannot see their own
legs; the eye-height residual masked as reversion).  Joint probes prove the
park holds indefinitely (knee 1.138, root -2.219 steady); the 2-node bench
shows the observer's replicant fully crouched and held (duckmpA_031 -- the
type-3 state record carries it with zero new replication code); the second
scripted press (new BT_BTNTEST2 env) verifies RISE -> standing zeros.
MP button delivery confirmed mode-mask-clean (the one miss was round-start
jitter).  Diags added, all BT_DUCK_LOG-gated: SetLegAnimation re-arm tracer,
1 Hz joint probe, RIO press mode-mask, BT_TREE_LOG topology dump, and the
squat-park log.  RESIDUAL filed: pilot's own cockpit eye does not ride the
root drop (DPLEyeRenderable chain composition; cosmetic).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 00:50:12 -05:00
Joe DiPrimaandClaude Fable 5 dd70061e0e CROUCH reconstruction (WIP): the master posture/duck machine, decoded + wired
Raw-disasm of the dark master-perf region (@0x4a9cf0-0x4aa0af): the myomer
effectiveness factor (+0x79c, MAX over heatables' +0x31c, scales speedDemand
-- feeder @004b8be3 unreconstructed, neutral 1.0 [T3]), the posture selector
(+0x3f8: mode/novice/leg-state/myomer gates -- novices cannot crouch), and
the DuckRequest consumer (standing -> SetLegAnimation(2) 'sqd', ducked ->
SetLegAnimation(3) 'squ', ForceUpdate 8+1 ships the type-3 state record,
stability alarm flips, request consumed).  +0x1DC = mountSegment... er, the
searchlight learned that one; here: mapPosture @0x3f8 + myomerEffectiveness
@0x79c members land; value-space note (port normal mode == 1, binary 0).

VERIFIED: request->consumer chain fires ([duck] SQUAT), the sqd clip plays
(22kf/7joint, ends root -2.22 crouched -- clip data parsed from BTL4.RES,
squ is its exact mirror; loader slot map re-verified byte-exact).  OPEN: the
parked crouch pose does not HOLD on screen (reverts ~1 frame after clip end
with NO SetLegAnimation re-arm logged) -- the hold's render path is the
remaining dig; [duck] re-arm tracer left in SetLegAnimation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 23:09:51 -05:00
Joe DiPrimaandClaude Fable 5 b75bb4a04c Searchlight beam: the btfx brighten material class + BT_SPOT_SELF rig
SPOT.BGF decoded: a 7-vert cone from the mount, ~50u forward and ~35deg DOWN
(a ground-pool lamp, not an air beam), verts tinted cyan-white, material
class 'brighten' smuggling its additive factor in DIFFUSE.r (0.25) with a
warm emissive on the night page.  The loader had never met the class -- it
drew as an opaque dark-red blob.  Now: brightenFactor parsed (name-gated to
brighten*), batch -> L4DRAWOP.brightenAlpha, drawn in the blend pass as an
additive veil (dest += vertexRGB x factor), unlit.  [T3] tint compose
(vertex cyan vs night emissive warm) noted in the draw branch -- field
eyeball accepted the current look.

BT_SPOT_SELF=1 (bench-only): builds the cone on the own-cockpit tree so the
BT_CAM=face view can inspect it solo.  KB: view toggle is BACKTICK ('V' is
the rear-view hold since #68 -- the toggle skips bound keys); stale V-toggle
claims swept.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 22:21:32 -05:00
Joe DiPrimaandClaude Fable 5 7d8f109241 FIX: restore rendering.md (truncated to empty by a crashed encode in d44fae2) + searchlight section with the perceived-strength correction
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 20:01:36 -05:00
Joe DiPrimaandClaude Fable 5 d44fae2ae3 KB + handoff: searchlight reconstruction
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 19:46:09 -05:00
Joe DiPrimaandClaude Fable 5 412053d5af Searchlight reconstruction -- the pod's night kit, both halves
The subsystem (sim/toggle/attribute/replication) was already complete; this
lands the missing VISUALS, decoded from MakeMechRenderables @004cef28 case
0xbd8 (raw pseudocode part_014):

- COCKPIT: the 1995 searchlight is a FOG SWAP -- the @00456778/@00456814
  watcher switches DPLRenderer::SetFogStyle between the authored fog= (lit)
  and nosearchlightfog= (dark) sets per map/time page in BTDPL.INI.  The
  engine kept the whole system under its real names; completed the stubbed
  plane application (currentFogNear/Far) and transcribed the watcher (with
  its inverted-cache seed) into TickSearchlight.  CONSEQUENCE: night now
  STARTS on the authentic dark set (near-plane 5u on arena pages) -- our
  builds had rendered the searchlight-ON fog permanently.
- EXTERNAL: spot.bgf beam cone hung on the searchlight SITE joint, shown/
  hidden from the replicated LightOn attribute (@0045612c watcher).  Site
  segments now build geometry-less DCS children (posed + parentable, as the
  1995 graph did) -- previously they were skipped entirely.
- searchlight.hpp: commandedOn @0x1DC identified as mountSegment (resource
  segmentIndex; the cone's mount joint).

Benches: searchfog.sh (solo cockpit: first-tick dark sync, F5/0x14 press ->
SetFogStyle(2), red-fog probe end-to-end), spotcone.sh (2-node: B's button ->
lightState replication -> A logs "[spot] cone SHOWN (seg 20)").  Cone look
(size/aim on the mount) pending an eyeball pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 19:44:23 -05:00
Joe DiPrimaandClaude Fable 5 b31c6c4527 handoff: score report tail reconstruction
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 18:38:48 -05:00
Joe DiPrimaandClaude Fable 5 91bd28669e #45/#134 authentic score/death report tail -- replaces the scoring stand-ins
Reconstructs the dark-gap tail of Mech::TakeDamageMessageHandler
(@0x4a02f4-0x4a0890, raw disasm): the three id-0x16 score reports (kill to
the shooter's player / type-0 wire-fidelity / received to the victim's
player) and the BT 0x38-byte VehicleDeadMessage extension {killed-by player,
kill zone} dispatched from the death tail.  Retires BTPostDamageScore /
BTPostKillScore and the per-hit inflicted credit (never existed in 1995:
@0x4c0200 is bound in no handler-table entry -- byte-scan receipt in
decomp-reference).  Suicides now dispatch and the handler negates the award
(the #134 panic penalty).  Collision divert falls through to the death tail
per @0x4a0375 (wall deaths respawn + blast; no score).  ScoreMessage fields
renamed to decoded truth (vitalHit/zoneIndex/subsysID) + wire asserts;
console VTVDamaged points_transfered corrected (Round(award), not Now()).

Benches: scorekill.sh cross-node kill (kills=1 award=4.88, killedBy=2:1
zone=3, single death cycle), scoreself.sh suicide (type=2 award=-39.00
kills=0), deathblast2.sh re-verified (72 bursts at ~9u).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 18:36:58 -05:00
Joe DiPrima 06a8edbff2 bench: #89 gate-OFF negative verified (advDmg=0 -> gated, zero splash) 2026-08-05 17:04:58 -05:00
Joe DiPrima 5a6ee51f3a handoff: #89 death blast 2026-08-05 16:53:05 -05:00
Joe DiPrimaandClaude Fable 5 297127d0d7 #89 DEATH BLAST reconstructed: a dying mech splashes its neighborhood
The missing half of Advanced Damage, found by call-scanning
Explosion::SplashDamage @0042fad0: TWO callers, not one -- Missile::Perform
(the known #62 path) and 0x4a0bda, the UN-EXPORTED tail of
Mech::TakeDamageMessageHandler itself.  Raw disasm @0x4a07b8-0x4a0bda:
when the victim ENTERS dead(9)/eject(10) during the applications, the
binary sets the wreck burning (id 0x17, deferred -- handler not yet
reconstructed), spawns the death Explosion (model 0x31 -- our death-list
visuals stand in), and SPLASHES:
  gates : owning player's advancedDamageOn (+0x264) AND NOT
          suppressConsole (+0x258 -- eject sets it: PUNCH-OUTS NEVER
          BLAST, the authentic anti-suicide-bomb rule)
  damage: type 2 Explosive, amount = deathSplashDamage (mech+0x520),
          bursts = round(0.001 * moverMass * 15.0) -- scales with tonnage
  radius: deathSplashRadius (mech+0x524); per-victim falloff
          bursts/dist^1.25 in the shared core
Draco's collision-divert suspicion is settled: the blast is TYPE 2, the
divert never touched it -- the tail was simply never reconstructed.

Port: deathSplashDamage/Radius PROMOTED from the Wword scratch bank to
named Mech members (the bank is one GLOBAL array -- authored per-chassis
values were clobbered to the last-loaded mech); BTSplashCore split out of
the #62 weapon splash and shared; BTApplyDeathSplash + the death-edge arm
in the handler tail; BTPlayerConsoleSuppressed bridge (friend).

Bench (2-node, B parked 8.9u from a self-destructing A): blast fired with
authored madcat data (radius=50, amount=5, mass=75000 -> 1125 base
bursts), B took 73 bursts (falloff exact: 1125/8.91^1.25), cross-pod
delivery + cylinder spray verified on B's own log ([dmghit] type=2
burst=73 across zones).  ~365 damage at 9u -- Draco's 'double kills on
drops' economy restored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 16:52:32 -05:00
Joe DiPrima 85d6ef4a59 handoff: skate campaign closed, ready to cut 2026-08-05 13:58:03 -05:00
Joe DiPrimaandClaude Fable 5 6dd1212e0c #52 campaign close: full transition matrix PASSES at 2 nodes -- field instrument rides
Provocations run under the verified [skate] detector: sustained healthy
walking, 7x respawn-while-moving, gimp onset at speed, ~21s of sustained
GIMPED walking (aimed-leg self-damage: BT_SELF_DAMAGE_ZONE=dz_ldleg),
leg-destruction death (authentic: lvl 1.0 -> leg gone -> fall/death; the
mid-session "died of the gimp edge" reading was a capped-print artifact,
retracted), and respawn.  ZERO skate anywhere.  Peer gimp replication
VERIFIED live: observer reads gl=3 + sim=3 with the gimp bodyStates
cycling for the whole master limp window (#82 remains fixed).  Conclusion:
the field skating does not reproduce at lab scale; the detector + SKATE
matchlog record ship with the next cut and the field names the failing
case.  Bench scripts archived (skatebench3-7; 7 is the clean-room one --
the sed-derived chains dropped envs twice).

Also: BT_LAMP_LOG=1 joins the field bats (#135 -- lamp/annunciator edge
forensics; near-zero noise, answers leak-no-flash reports in one grep).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 13:58:01 -05:00
Joe DiPrima 869d885eb2 handoff: skate detector state 2026-08-05 12:41:57 -05:00
Joe DiPrimaandClaude Fable 5 9604f4c492 #52 SKATE detector (ungated field forensic) -- negative-verified; local repro eludes
The night-12 field logs eliminated record starvation (zero [ghost] during
three observed skating windows), so the bug lives in gait APPLICATION on
peers.  This adds the [skate] detector to the death-handler tick: a
replicant moving >0.08 u/frame for 90+ frames with BOTH animation
channels idle (legCycleSpeed + bodyCycleSpeed ~ 0) logs one line per
episode + a SKATE matchlog record carrying the discriminating inputs
(legCyc/bodyCyc/cmdSpd/destroyed/mode).

Honest history: the first build keyed on legCycleSpeed alone and
false-fired on every healthy movement phase -- the current peer
architecture poses joints from the BODY channel (s_peerLegCh=0,
AdvanceBodyAnimation mj=1), so legCycleSpeed==0 is NORMAL there.  Caught
same-session by the [gimpfeed] silence (AdvanceLegAnimation never runs
on peers); corrected to channel-agnostic before anything shipped.

Bench (skatebench2.sh, 2-node, autodrive walker + kill every ~40s):
7 death/respawn cycles, ZERO skate hits either side -- no false fires,
and light local conditions do NOT reproduce the field skating.  Next
provocations: leg-GIMPED walker (the #82 family transition) and 6-player
load; otherwise the detector rides the next cut and the field names the
failing case for us.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 12:41:55 -05:00
Joe DiPrima 6d7946a264 handoff: night-12 log scan results 2026-08-05 11:47:30 -05:00
Joe DiPrimaandClaude Fable 5 c3455d388f KB: #131 miss-means-miss pick semantics + instruments [T2]
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 11:13:44 -05:00
Joe DiPrima 754518b587 handoff: #131 fixed with receipts + build ritual note 2026-08-05 11:13:17 -05:00
Joe DiPrimaandClaude Fable 5 546aabd5ba #131 false lock FIXED: miss-means-miss -- the pick answers only for drawn geometry
Night-12 field report (Ronin/Conn Man/Oracle, blackhawk-correlated): lock
ring lit with the reticle visibly off the mech + no-reg complaints.  Root
cause: TWO port stand-ins answered where the 1995 card (which cast against
the DRAWN geometry) would miss -- the pick's any-object sphere fallback and
the caller's whole-mech AABB fallback.  The regime that exposes them: a
LEVEL boresight over a SHORT mech -- the blackhawk's mesh tops out below
eye-ray height, so the ray clears every triangle but pierces the fat cull
spheres; the ring lights with the reticle above the mech's head (the
operator watched exactly this on the sweep bench).  Careful aimed-down fire
rides triangles, which is why Oracle's per-panel audit passed on the same
build.

Fix: MechSegmentPick returns 1=drawn-geometry hit / 0=TRUE MISS / -1=no
render tree; the sphere may answer ONLY for a mesh the reader cannot parse
(pm==0 -- currently none exist: counters objs/invFail/noTri all clean);
the AABB survives ONLY as the pre-tree replicant grace.  A readable mesh
the ray misses is a MISS -- no lock.

Verified (2-node vs bhk1 at 100u, all runs on force-relinked string-
verified exes after today's stale-link flake):
- LEVEL lock-sweep: 0 locks all run (pre-fix: lock band from 168 sphere
  answers; picksrc tri=0 sphereFB=168).
- DOWN-PITCHED sweep: locks return 100%% tri-sourced (tri=158 sphereFB=0),
  landing on real parts (rarm/ldleg/rdleg) with honest gaps.
- Full zone-walk matrix: tri=18874 sphereFB=0 box=0; victim took 156 hits
  across 16 zones incl. both side torsos -- combat un-regressed.

New instruments (all env-gated): BT_LOCK_SWEEP=<axis> torso pan (the
operator-visible lock-envelope bench), [locksweep] transition log,
BT_LOCK_ENVELOPE synthetic unit-sweep probe, [picksrc]/[pickbox] source
telemetry with objs/invFail/noTri localization counters.
Bench: scratchpad/night12/zonewalk_bhk.sh.

NOTE for the field: locking is now strictly TIGHTER (ring = reticle truly
on the machine).  If era testers feel the pods were more forgiving,
Draco's "slight lock linger" memory becomes a deliberate investigation
(sourced hysteresis), not an accidental sphere halo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 11:12:43 -05:00
Joe DiPrima f279e38707 handoff: night-12 field results -- 10 closed, 8 filed (#131-#138) 2026-08-05 09:35:24 -05:00
Joe DiPrima 8b887f6cec handoff: #124 per-panel correction 2026-08-04 17:58:09 -05:00
Joe DiPrimaandClaude Fable 5 1d3616cceb #124 CORRECTION: aimed hull hits resolve the struck dz_* PANEL -- players were right
The 08-03 zone rule (struck SEGMENT's SKL dzone, ALWAYS -> every aimed
torso hit = center torso) was wrong, and era players' pushback caught it.
Byte-level proof: MAD_TOR.BGF zone-tags the hull PER PANEL (dz_utorso x36,
dz_ltorso/rtorso x18, dz_dtorso x16, all four rear panels, searchlight);
the dpl hit result kept GEOGROUP granularity (dplHitGeoGroup, T0); and the
binary's segment->zone map @49db20 has NO runtime caller (raw call-scan:
sole caller = CreateStreamedDamageZone, load time) -- no segment-level
collapse mechanism exists.  Oracle's night-10 'only LCT gets hits' audit
was the BUG's fingerprint, not the pod's design.

Fix: MechSegmentPick attributes the struck triangle to its draw op (index
range) and takes the op's .DZM-bound zone -- the #87 armour-darkening
bindings, the same authored patch->zone mapping that already paints the
panels -- with the segment dzone as the untagged fallback.  ZoneAimPoint
now aims hull zones at their patch CENTROIDS (all hull zones previously
shared the chest cull-center), which also upgrades the zone walker.

Bench (2-node zone-walk vs spinning madcat, zonewalk_madcat.sh): every
hull panel resolves individually -- utorso 11/12 in-zone, no L/R
mirroring, all four rear panels register; misses are the panel facing the
shooter mid-spin (correct geometry, not misattribution).  Victim applied
254 hits spread across every panel family.  Was 102/102 hull aims ->
dtorso before the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 17:57:14 -05:00
Joe DiPrima 98234c8b15 handoff: 4.11.771 re-cut with Cyd's lamp sweep 2026-08-04 16:56:10 -05:00
Joe DiPrimaandClaude Fable 5 ceee71ce85 lamp-sweep comment: #45 citation was the scoreboard issue -- cite the field reports
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:55:22 -05:00
Joe DiPrima a338a5db89 Merge remote-tracking branch 'origin/glass-lamp-latency' 2026-08-04 16:53:32 -05:00
Joe DiPrima 26b5743f9a handoff: 4.11.767 cut -- field-night checklist 2026-08-04 16:37:17 -05:00
Joe DiPrimaandClaude Fable 5 81dda84e9b #108 forensics block (pre-717): ghost detector + IDs + field envs
The night-11 instrumentation that makes the next ghost/K-D report
diagnosable instead of anecdotal:
- UNGATED [ghost] stale-replicant detector: ReadUpdateRecord stamps every
  applied record; the death-handler tick logs ONE line per starvation
  episode (>600 frames, unburied) with entity id + mode + last position,
  plus a GHOST matchlog record.  Verified both ways: zero false positives
  on a healthy 2-node session; fires on both nodes at frame 601 after a
  mid-session relay kill.
- Entity IDs on the render forensics (MakeMechRenderables / RemakeEntity /
  wreck-swap fallbacks) and the replicant un-wreck line un-gated -- ghost
  triage no longer needs players to set envs.
- players/*.bat (steam + both joins): BT_MATCHLOG/BT_SCORE_LOG/BT_DEATH_LOG
  on for every field session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:34:06 -05:00
Joe DiPrima c38377a6d2 handoff: respawn-reset audit complete 2026-08-04 16:14:52 -05:00
Joe DiPrimaandClaude Fable 5 26ab2fee3a Respawn-reset audit: valve restore + peer smoke cleanup (both binary-grounded)
Operator reports audited vs the binary (full matrix in RESPAWN_REARM_PLAN
addendum):
- VALVES (real gap): Condenser reset @004ae534 was missing from the decomp
  export -- raw disasm shows it chains HEATSINK (coolant refill runs; the
  old body chained HeatableSubsystem per the stale TCP shard) then, respawn-
  side, resets valveState to detent 1 and restores massScale from
  refrigerationFactor.  Mech::Reset now also runs the binary's tail call
  (@0049f788 BTRecomputeCondenserValves) so flow fractions rebuild from the
  reset detents.  Bench: detent 5 -> death -> "[respawn] Condenser1 valve
  detent 5 -> 1".
- #129 SMOKE (real gap, peers-only): the replicant un-wreck edge rebuilt
  the model without the @004d0c14 per-entity effect cleanup, so the
  observer's last 10s wreck-plume window rode the teleport onto the fresh
  mech.  BTStopEntityPfx now runs on the edge; bench shows no plume line
  after any un-wreck until the next death.
- AUTHENTIC (no fix): weapon->generator taps persist (@004b0e6c only
  resolves the link) and MFD display/control modes persist (mapper vtables
  0050f45c/0051e440 slots 8-11 = plain root bodies, read from the exe).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:14:10 -05:00
Joe DiPrimaandClaude Fable 5 b6656e35c5 KB: #124 twist-sign verified [T2] -- adapter flip correct, bench + diags recorded
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 15:51:19 -05:00
Joe DiPrima 9493fba04d handoff: #124 twist-sign verified correct 2026-08-04 15:50:17 -05:00
Joe DiPrimaandClaude Fable 5 1f995ee35a #124 twist-sign VERIFIED correct (live missile bench) + the rig to do it
The frame-adapter's inferred twist-sign flip (SelectSlice theta -= twist,
vs the binary's += pre-reflection) was the last unverified half of #124 --
every earlier probe ran at twist 0.  Bench: stationary madcat target with
the torso PINNED at 0 / +140 / -140 deg, LRM salvos from a fixed shooter
(missiles = the authentic cylinder path; the binary DROPPED zone -1 beam
damage).  Result: slice picks track the physically-facing flank in BOTH
directions (twist-left -> right-family zones for left-flank impacts,
twist-right -> left-family), deterministic, wrong-sign outcome (slice 7
vs observed slice 1) clearly excluded.  No game-code change needed.

Instrumentation added (all env-gated):
- torso.cpp BT_FORCE_TWIST=<-1..1>: HOLD the sim's analogTwistAxis (the
  input-level pin was dead -- live input rides the CONTROLS.MAP device
  push, and Basic mode auto-centers; sim-level is plumbing-independent).
- dmgtable.cpp [slice] line: rot flag, live twist, thetaIn/thetaAdj,
  chosen slice -- the weighted leaf roll made zone-only logs ambiguous.
- [dmgresolve] now names the zone (BTMechZoneSegAndName).
- mech4.cpp BT_FORCE_TWIST input pin + one-shot mode cycle (kept as doc
  of the dead path), mechmppr [mppr] mode probe.
- scratchpad/night11/twistsign.sh: the 3-config bench.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 15:49:14 -05:00
Joe DiPrima 952f10b18d handoff: #92 already fixed (zone-walk receipts) + night11 benches 2026-08-04 14:31:13 -05:00
Joe DiPrimaandClaude Fable 5 375fa28fef KB: #91 inside-view roster claim upgraded to T1 (FUN_004cef28 decomp anchor)
The binary's inside renderable build sets type A (=4, part_014.c:5077) and
its per-segment loop looks up EVERY segment's type-A mesh (FUN_00424084,
:5570) with no torso/cop filter -- the arcade drew all authored own-body
meshes (thor pod, owens legs). No opt-out exists in the binary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 14:17:21 -05:00
Joe DiPrima ac3729b41b handoff: #91 decode (own missile pod, blakskn material-key fix) 2026-08-04 12:44:18 -05:00
Joe DiPrimaandClaude Fable 5 6180a44c64 #91 thor black rectangle: it's the OWN missile pod -- blakskn now material-keyed
Three testers reported a black rectangle swaying with the footsteps in the
thor cockpit (Summoner = the thr1 label, same THX canopy -- no isolation).
Decode: the inside view is a PER-MECH AUTHORED type-A set, not the cop
alone (no fallback in EntitySegment::GetVideoObjectName -- authored data):
madcat/vulture/bhk1 = cop; sunder/loki/avatar = +tor; thor = +tor +MSL
(the shoulder pod, the reported rectangle); owens = +both legs +tshd.
The pod/leg pilot-facing surfaces use the SAME "<pfx>skin:blakskn_dz_*"
interior-structure material as the canopy frame, but the unlit frame
constant was keyed on the _cop FILENAME -- identical material rendered
(0.13,0.12,0.15) on the canopy and pure (0,0,0) on the pod/legs
([matlog]: owx_cop blakskn vcol=FF211F26 vs owx_lule vcol=FF000000).

Fix (bgfload.cpp): the frame-constant treatment keys on meshIsCop OR
material contains "skin:blakskn_dz_". mechfx:blakskn_mtl (tshd shadow
quads) deliberately excluded. Verified: zero pure-black px in the lower
view band across walk captures, the pod plate renders frame-toned and
blends with the bar at rest (the reported anomaly dissolves), owens legs
read as coherent structure, canopy/terrain un-regressed.

Diags added: BT_MAT_LOG=<stem> per-batch material routing dump (bgfload),
BT_HIDE_INSIDE_SEG=<substr> inside-mesh hide (btl4vid), [view] per-segment
inside-roster names. KB: cockpit-view.md exactly-one claim corrected +
the #91 section; bench scratchpad/night11/thorrect.sh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 12:43:36 -05:00
Joe DiPrima dcd8583449 handoff: gate rejections are player-visible now 2026-08-04 11:42:51 -05:00
Joe DiPrimaandClaude Fable 5 648f6b1675 Steam gate: rejected players now get a MESSAGE BOX, not a silent quit
A failed join returns 1 to the FE, which QUITS the exe -- so every
rejection so far was a log line plus "the game just closed" (#68's exact
complaint). New LobbyNotice() = same text in the day log (flattened, still
greppable) + blocking MessageBox. Wired to every join-side bail:

- BUILD MISMATCH: when the version-filtered search is empty, probe once
  without the version filter; if a lobby IS up, the box names the host's
  build vs ours (lobby data rides the list result -- no join needed).
  Post-entry verify mismatch gets the same box.
- NO LOBBY FOUND: probe empty too -> plain no-lobby box naming our build.
- STEAM UNAVAILABLE: transport install failed.
- LEFT BEHIND: host launched without us (no token in btl4map).

Old exes still exit silently on rejection -- nothing shipped today can
add text to a binary players already have; the host's roster marker +
REJECT log line remain the operator's view of those.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:42:01 -05:00
Joe DiPrima edff8fdb96 handoff: Steam build gate shipped (both halves) + LAN caveat 2026-08-04 11:36:28 -05:00
Joe DiPrimaandClaude Fable 5 d050db5cae Steam BUILD GATE, host half: reject OLD exes at GO (the real Conn Man case)
The joiner-side filter only runs on builds that HAVE it -- a stale-zip
player runs an old exe with no filter, finds the lobby, and joins anyway.
The host must do the rejecting, and the lever already ships in every old
build: a member omitted from btl4map hits its own "the host's map is
missing us" path and fails the join cleanly.

1. PublishSelf stamps per-member data bv=BT_VERSION_STRING; an old exe
   cannot fake a key it never sets.
2. Host GO mint: any non-self member with absent/mismatched bv gets NO
   token -- omitted from the map, loud REJECT LobbyLog with both builds.
3. Room screen: mismatched members show [WRONG BUILD -- WILL NOT LAUNCH]
   so the host sees who's stale BEFORE pressing GO, not after the match
   starts short-handed.

Verified in the deployed exe by string scan (btl4ver, REJECT-at-GO,
BUILD MISMATCH ascii + WRONG BUILD utf16 all present). Field behavior to
verify on the next Steam night.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:33:53 -05:00
Joe DiPrimaandClaude Fable 5 22f732fcfb Steam BUILD GATE: same-zip lobbies only (#108 confound killer)
Mixed-build lobbies silently corrupt raw-struct replication (night-9: one
stale-zip player, one desynced stream, ghost mechs + K/D doubt). Three
additive edits in btl4lobby.cpp:

1. HOST stamps the lobby with its exact build (btl4ver = BT_VERSION_STRING).
2. JOINER's lobby search filters on build equality -- a stale-zip player
   simply finds no lobby, and the "no lobby found" log line NAMES the local
   build so the report is self-diagnosing (#68's silent-exit lesson).
3. Post-entry verify (covers invites/direct joins + unstamped older hosts):
   mismatch -> log both versions loudly, LeaveLobby, fail the join.

LAN/relay (join.bat) handshake remains a separate 717 item -- this covers
the Steam path the operator asked about.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:24:05 -05:00
Joe DiPrimaandClaude Fable 5 15a1cf5d32 handoff: #108 version-skew confound + build-handshake item
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:12:27 -05:00
Joe DiPrimaandClaude Fable 5 24b0d2eaf7 handoff: day-2 addendum -- eject epilogue, stale-tracker sweep, #108/K-D instrumentation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 10:24:54 -05:00
Joe DiPrimaandClaude Fable 5 42b0691592 #119 leak-voice stutter SETTLED: authentic -- the wooHoo latch is authored OFF
Deterministic leak bench (BT_KILL_SUBSYS partial form, "Condenser4=0.5":
zone level without the crit -- a clean leak source) + the aud-tail receipts
decode the voice stutter end to end: the warning is a phrase-sequenced
voice patch (Warnings01 zones as notes), and every techstat leak-bit edge
restarts/stops the sequence mid-phrase (authored zero-release = hard cut).
At a drained tank the draw HUNTS the authored 0.0025/0.003 band -> edge
streams -> progressive clipping as more systems hunt.

The anti-spam wooHoo latch would bound exactly this -- but no armer exists
anywhere in the flat export, and the authored tuning is minDur=0 range=0
chance=0 (dumped live): DORMANT BY AUTHORING. The clipping is the 1995
experience; the port's one real bug here was the 2x hunt cadence, already
fixed by the 28 Hz filter (#119, 1df2c57). No code change warranted.

Bench additions: the partial-damage killsub form + the authored-tuning
dump (BT_LAMP_LOG).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 09:44:42 -05:00
Joe DiPrimaandClaude Fable 5 a728fa6de9 #118 alarm dig complete: the TechStatus bit model -- MarkGeneratorOut bridge removed
The gauge-alarm "condition" is a STATUS-FLAG BIT INDEX edge-scanned by
MechTech (bit 0/1 structure, 2 leak, 3 heat, 4 AmmoBurning, 5 Jammed,
6 !HasVoltage). Conditions 4/5 -> the engEject flash = the AMMO purge/unjam
invite (flashing the very key whose streamed function is EjectAmmo) --
never pilot eject; cond 6 -> the bus-switch invite; the PANIC lamp is the
sole pilot-eject indicator.

The destruction->stateAlarm(4) bridge is REMOVED as unfounded: alarms never
read stateAlarm; state 4 is the THERMAL BREAKER state produced by
GeneratorSimulation itself (byte-matched vs FUN_004b1f7c). And the binary's
crit distributor (@0049c9a8, read raw) touches nothing electrical -- a
generator destroyed in place keeps stale Ready voltage until any transition
recomputes output via (1 - damage) x rated. The port now matches that
subtlety exactly (verified: single-gen force-kill -> no bus invite, no arm,
silent unarmed keypad -- all authentic).

KB: the full bit table + invite semantics + electrical subtlety recorded in
decomp-reference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 03:18:50 -05:00
Joe DiPrimaandClaude Fable 5 609dde8051 #118 strict: REMOVE the coolant-clause hysteresis -- the flap is authentic
The operator's causality challenge held up: the hysteresis did NOT fix the
audio tick (the tick persisted past it and was the autofire scalpel's
20/sec jam clicks -- no game bug; control run without autofire is clean).
With the dead-code claim corrected, the boundary flap is AUTHENTIC: the
arcade evaluated the identical plain < 0.05 compare per frame, so a pod
hovering at the line flapped the same way -- cosmetic mode/lamp churn,
crash-free across hundreds of bench transitions. Deviation unjustified;
the binary's compare is restored.

Verified on the reverted build: armed PANIC press -> PUNCH-OUT (1); audio
profile clean (sparse explosions/warnings only).

Eject deviation ledger now: ONE item -- the Panic-button->pilot-keypad
desktop wire (hardware emulation, not behavior).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 02:57:03 -05:00
Joe DiPrimaandClaude Fable 5 d86b65d7aa #118 STRICT: remove the eng-page eject-key hook -- soft keys never pilot-eject
Operator called binary-faithful; the trace agrees. The hook (bfc072b/
fa3d634) fired ALONGSIDE the key's authored per-page function, so on a
weapon eng page an armed pilot pressing UNJAM/EJECT to clear a jam would
self-destruct -- a destructive hijack of a real control. Removed; the
authored press model stands alone: pilot KEYPAD bank (desktop numpad) or
the PANIC key while armed. The flashing engEject cell is the invite lamp.

Regressions: armed PANIC press -> PUNCH-OUT (1); armed soft-key 0x0B press
-> no eject (0); streamed ammo-eject route untouched (the hook was purely
additive).

Remaining marked deviations, both flagged in code and handoff: the coolant
arm hysteresis (0.05/0.06) and the Panic-button->keypad desktop wire (the
pod's physical matrix emulated).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 02:47:34 -05:00
Joe DiPrimaandClaude Fable 5 675fe68cb0 #118 correction sweep: the ARCADE's eject was LIVE -- 'dead code in 4.10' was wrong
The absolute-pointer scan missed the E8-relative call; the later byte-scan
found it (FUN_004a9b5c+0x10 -- the master performance evaluates eject
permission per frame in the shipped binary). Under deadline pressure the
disproven 'unfinished/dead code' claim leaked back into three comments and
the handoff; swept per the correction mandate.

Settled press model, now stated correctly everywhere: pilot eject = the
pilot KEYPAD bank while armed (+ the PANIC key reporting through that
matrix). The MFD soft keys NEVER pilot-eject in the binary -- every page
routes them to authored functions; the flashing engEject cell is the
INVITE LAMP. The port's page-gated eng-key eject is a MARKED CONVENIENCE
deviation (operator-requested), and the coolant hysteresis is a MARKED
smoothing deviation for a degenerate boundary oscillation -- not
completions of unfinished code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 02:40:39 -05:00
Joe DiPrimaandClaude Fable 5 38872ac726 #118: coolant-clause hysteresis kills the panic-arm flap (10 Hz mode churn)
Post-respawn under sustained fire the bank fraction hovers at the 0.05 arm
threshold and the plain compare flipped ejectPermitted EVERY FRAME --
hundreds of mode ON/off pairs churning the mask, the lamp, and the arm cue.
The evaluator was dead code in shipped 4.10, so the boundary flap is a
first-ever-exercised condition; completed with the engine's own idiom
(HeatSink coolantActive dual thresholds): ARM below 0.05 (the binary
constant), release above 0.06, no new members.  Stress bench: 1 arm
transition in 3 minutes (was: hundreds).

NOTE: the operator's ticking SOUND persists past this fix -- it is the
separate, pre-existing night-10 "audio stutter on coolant leak" family;
hunt continues at the audio-cue chokepoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 02:18:58 -05:00
Joe DiPrimaandClaude Fable 5 5a4ee55e4e #118: the eject slot graphic returns -- vtbl+0x58 is DrawBitMapOpaque [T0]
The proper RE the corruption arc demanded: counting GRAPH2D.h's declared
virtual order, GraphicsView vtbl+0x18 = SetColor and +0x24 = MoveToAbsolute
land exactly -- and +0x58 is NOT DrawBitMap (+0x54) but the NEXT virtual,
DrawBitMapOpaque(background, rotation, bmp, sx1, sy1, sx2, sy2).  The wipe
is a two-span OPAQUE draw with INVERTED fg/bg pairs -- the literal "inverse
wipe" -- and FUN_004c5fb8 transcribes byte-exactly with the real signature
(the ++level between spans is inclusive-bounds bookkeeping, no gap).  The
base ctor fields were already right (colors +0x94/+0x98, bitmap Size.x/y at
+0x9C/+0xA0, decomp @004c5e84).

BallisticWeaponCluster::ejectWipe re-wired (bteejtm.pcc <- PercentOfEject).
Stress-verified: 4 gens dead + autofire jams + leak warning, weapon pages
pristine end to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 23:56:53 -05:00
Joe DiPrimaandClaude Fable 5 5cd1947b65 handoff: evening eject sprint + MFD corruption arc addendum
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 21:07:31 -05:00
Joe DiPrimaandClaude Fable 5 0535a7ab74 KB: gotcha #27 -- drawing against compositor invariants (the #118 MFD corruption class)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 21:06:55 -05:00
Joe DiPrimaandClaude Fable 5 78722542fd #118: REMOVE the full-face flash overlays -- the MFD corruption, root-caused
The operator's three captures decode as ONE mistake drawn two ways: the
cockpit compositor deliberately draws the side buttons as BIG rects tucked
UNDER the display surfaces (step 2), with only the protruding edge visible
-- the edge IS the port's lamp light (the pod's backlit keys sat beside the
CRT; there is no in-display face to light).  My on-top "flash overlay"
(86a6bbb L4VB16, 5f79dce L4GLASSWIN) violated that design: drawn after
DrawDevSurface without resetting the bound texture state it rendered the
gauge atlas as green striped garbage at the button rects; with the state
reset it drew honest full faces -- solid flashing red covering that whole
portion of the MFD.  Both overlays removed; the original step-2 rendering
(which already tick-animates lamp shades) is the whole story.

Pixel-verified through the full stress (4 gens dead, autofire jams, leak
warning): weapon pages pristine end to end, and the 0x27 edge lamp
square-waves 34.0 <-> 28.9 across consecutive captures -- the invite
flashes at its authentic scale.

(The eject-wipe gauge -- the OTHER corrupting draw, visible in the earliest
sighting -- stays re-stubbed pending a real RE of FUN_004c5fb8's draw op.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 21:05:28 -05:00
Joe DiPrimaandClaude Fable 5 3ec30ee28a #118 fix: eject-wipe DrawBitMap arg smeared the weapon MFDs (capture.png)
The BitMapInverseWipeScalar Execute passed the colour constants as
DrawBitMap's first argument; the sibling wipe always passes 0 there (colour
selection is SetColor only). The nonzero arg smeared striped garbage across
the weapon MFD pages from the gauge's first draw (operator's capture.png).
Pixel-verified clean after the fix (weapon pages pristine, eject graphic
path unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 20:50:50 -05:00
Joe DiPrimaandClaude Fable 5 86a6bbb9cb #118: the flash overlay lands in the REAL compositor (L4VB16) + [ckflash] probe
The first visibility fix painted L4GLASSWIN -- a window the operator's
single-window layout never shows. The main-window cockpit compositor is
BTDrawCockpitPanels (L4VB16): same under-the-surface masking, so the RIO
flash was again invisible. The 3b overlay now re-draws flashing lamps
(state & 3) above the surfaces there, tick-phased; [ckflash] (BT_LAMP_LOG)
logs each animated draw. Verified in-engine: addr 0x27 state 0x37
alternating shade 1 -> 3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 20:46:43 -05:00
Joe DiPrimaandClaude Fable 5 5f79dcee3b #118: flashing lamps draw ON TOP of the MFD imagery -- the invite is visible
The glass windows paint buttons UNDER the display surface (only a sliver of
edge protrudes), so the RIO flash commands were arriving ([lamp] 0xb <- 0x37
FLASHING in every trace) while the operator "never saw it flash" -- the
blink was a few masked pixels.  Lamps carrying RIO flash bits (state & 3)
now re-draw their full face above the surface with the tick-phased shade:
the gotoEngineering / engEject invites read like the pod's backlit keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 20:36:57 -05:00
Joe DiPrimaandClaude Fable 5 fa3d634ed3 #118 crash fix: the eng-eject page gate moves into the game-thread RIO drain
The page-gated eject query ran in the window-click thread (PadRIO
EmitButton) and dereferenced the TU-local application global -- NULL there
under the /FORCE duplicate-symbol layout -> c0000005 at mode-manager +0x50
the moment the operator pressed the eng eject key (field crash,
live.log [crash] record). Moved into LBE4ControlsManager::ProcessRIOEvent's
ButtonPressedEvent branch, where mode_mask is already resolved on the game
thread; PadRIO now only queues events. Same gate semantics (armed + that
bank's ENG-page mode bits); verified no-crash + no-eject on the quad page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 20:33:32 -05:00
Joe DiPrimaandClaude Fable 5 bfc072b56f #118: the ENG-PAGE EJECT KEY ejects -- page-gated intent completion
The decode that settles it: the eng-page side-key functions align key-for-
key with the engEject lamp map (selectGenA-D = 0xF-0xC, EJECT = 0x0B,
GEN-MODE = 0xA, COOLING = 0x9, BACK = 0x8; banks 2/3 at 0x27-/0x7-), and
the streamed weapon pages route the same key to EjectAmmo -- so 0x0B/0x23/
0x03 ARE the authored EJECT position. On generator pages the shipped
stream leaves the key with NO live route (msg 9, no handler anywhere):
eject was UNFINISHED in 4.10 (the evaluator was dead code -- the arcade
never armed panic at all). Intent-completion [T3, marked]: while PANIC is
armed AND that bank displays an ENGINEERING page (kBTEngModeMask bits --
the same gating the alarm lamps use), the flashing EJECT key fires the
pilot-keypad eject. Any other page: authored function untouched (the two
field regressions came from skipping the page gate).

Verified: armed + QUAD page + 0x0B press -> no eject (gate holds); panic /
numpad / Backspace unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 20:29:16 -05:00
Joe DiPrimaandClaude Fable 5 aa54655980 #118: the FLASHING eject invite fires -- generator OUT state + pilot keypad
The last hop lands: a destroyed Generator now reaches its OUT display state
(stateAlarm 4 -- enum renamed from the placeholder "GeneratorRecovered";
[T3 inferred link, both endpoints byte-verified: the eject evaluator's
state==4 dead clause @0049fa1c and the authored alarm streams' conditions
4/5 -> gotoEngineering 0x80 + engEject 0x85]). Wired from the one crit
chokepoint (MechSubsystem::ForceCriticalFailure -> BTGeneratorMarkOut
bridge). Verified live: gen kill -> the quad-select columns FLASH
(RIO 0x37 fast-flash) -- the pod's guided "punch out now" trail.

Plus the authentic panel eject input: CONTROLS.MAP binds the desktop
NUMPAD to the pilot keypad (keypad pilot 0-9) -- while armed, any pilot-
keypad key fires the eject (the binary's only armed binding); un-armed
they feed program entry.

The full 1995 eject experience is now live: alarm -> flashing invite trail
-> lit Panic button -> press (Panic / numpad / Backspace) -> punch-out ->
death without honor -> respawn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 20:11:05 -05:00
Joe DiPrimaandClaude Fable 5 417008a6d4 #118 faithful settlement: MFD soft keys NEVER eject -- panic/keypad only
The streamed .CTL dump (BT_CTRLMAP_LOG, 109 records on the madcat) settles
the eject-input question from CONTENT: no 0x200000 record exists -- the MFD
soft keys carry their authored per-page routes unconditionally (elem 0xB on
a weapon eng page = msg 0xB EjectAmmo, the round eject), so they must never
route to pilot-eject. The invite-cell routing (b9430cc) hijacked exactly
that key -- the operator's "bottom left button self destructed" -- and is
REMOVED.

The faithful model, now fully grounded: the flashing engEject soft-key LAMP
is the INVITE indicator; the eject PRESS is the guarded PANIC key (no
button-space consumer in the image -> it reported through the pilot keypad
matrix; the 0x3D->keypad wire stays) or any pilot-keypad key while armed.

Verified: armed + soft-key 0x0B press -> NO punch-out (authored function
preserved); panic path unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 19:15:45 -05:00
Joe DiPrimaandClaude Fable 5 b4b98a64c0 #118 fix-the-fix: armed eject routes ONLY the invite cells + Panic
Field-caught by the operator within minutes: the blanket MFD key-space
routing (0x00-0x2F) made the DISPLAY navigation key eject the mech
mid-page-switch while armed. Narrowed to exactly the guarded PANIC button
(0x3D) and the three eng-page EJECT INVITE cells (0x0B/0x23/0x03, the
addresses the engEject 0x85 alarm lamp flashes on). Navigation is safe
while armed; the flashing cell still punches out (re-verified through the
click seam: armed -> press 0x0B -> PUNCH-OUT).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 19:07:38 -05:00
Joe DiPrimaandClaude Fable 5 b9430cc5a7 #118: the MFD eject cell ejects -- all pilot-keypad-space keys route while armed
The operator's pod flow ("the engineering panel's bottom-left button flashes
EJECT, you push it") is input #3 of 3. The eng-page eject cell resolves to
MFD side-column addresses 0x0B/0x23/0x03 (kBTEngBankTop - 4 per bank), and
the binary's ONLY armed eject binding is the KeyboardPilot group (0x200000
-> mech msg 0x19; exhaustive 0x200000 scan: no other consumer) -- so on the
pod those keys report through the pilot keypad matrix. Desktop mirror: MFD
key-space presses (0x00-0x2F) and Panic (0x3D) also land as pilot-keypad
keys. Outside panic mode the binding is mode-masked dead -- normal MFD
operation untouched; armed, the flashing cell (or any keypad key -- the pod
semantic) punches out.

Verified through the click seam: gens dead -> armed -> press 0x0B ->
PUNCH-OUT.

All three eject inputs now live: Panic button, Backspace/pad LeftThumb,
and the flashing MFD eject cell.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 19:02:21 -05:00
Joe DiPrimaandClaude Fable 5 0bd9bb6f51 #118: the FLASHING eject invite is authored -- engEject alarm items found
BT_LAMP_LOG now dumps each gauge-alarm stream item as it reads
([galm-item] sub/cond/itemCond/lampCode). First dump against the shipped
content settles the operator's "shouldn't something flash?" question:

  GeneratorA itemCond=4 -> lampCode 0x85 (engEject) + 0x80 (gotoEngineering)
  GeneratorA itemCond=5 -> lampCode 0x85 + 0x80

The 1995 pod FLASHES "GOTO ENGINEERING" and the engineering page's EJECT
cell when a generator goes OUT (conditions 4/5) -- the authored eject
invite. The Panic button lamp itself is solid by authoring ([lamp] 0x3d <-
0x3c, no flash bits) -- both halves of the field observation are genuine.

Remaining: the BT_KILL_SUBSYS scalpel skips the authentic state-4
transition, so the flash did not fire in the bench; verify a genuinely
damaged/overheated generator reaches state 4/5 and lights the invite
(the #118 "Generator Out vs !=4 clause" tail).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 18:49:36 -05:00
Joe DiPrimaandClaude Fable 5 751b1159b5 #118: the eject slot graphic lands -- BitMapInverseWipeScalar reconstructed
The operator's field report ("eject slot flashing but no graphic in it") was
the tracked-NULL bring-up stub in BallisticWeaponCluster. Reconstructed the
missing gauge class from the binary:

- BitMapInverseWipeScalar @004c61c8 (vtable 0x518a14; base @004c5e84,
  Execute @004c5fb8, BecameActive @004c5fa4): a bitmap COLUMN SWEEP whose
  level tracks a live Scalar -- two spans with INVERTED colour pairs,
  [0..level] fg colorA / bg colorB, remainder the inverse.
- Wired per the binary call site (part_014.c:2312): bteejtm.pcc at (0xF,0)
  on the weapon's eng port, colours 0/0xFF, watching the weapon's
  PercentOfEject (@0x3F8, the round-eject 0..1 progress) via a new
  complete-type bridge BTWeaponPercentOfEjectPtr (databinding rule).

At idle (level 0) the EJECT graphic now renders in the slot; during a
round-eject cycle it sweeps with the countdown. BTEEJTM.PCC ships in
content/GAUGE (the loose gauge bitmap set). Smoke-tested: constructs clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 17:53:33 -05:00
Joe DiPrimaandClaude Fable 5 611b1a8bbd #118: the PANIC BUTTON ejects -- keypad-bank routing + the 0x17/0x19 id bug
Two finds close the press path the operator field-tested:

1. The pod's guarded PANIC key reports through the PILOT KEYPAD bank -- the
   binary's ONLY panic press binding is keyboardGroup[KeyboardPilot].Add(
   mode 0x200000, mech, 0x19) (FUN_004d266c); there is NO buttonGroup[0x3d]
   consumer (0x3d is the lamp address). The desktop Panic click now ALSO
   lands as a pilot-keypad key (PadRIO::EmitButton -> EmitKeypad), so the
   armed-mode binding fires; a healthy press stays a mode-masked no-op,
   exactly the pod behaviour.

2. KeypressMessageID resolved to 0x17 via enum-chain arithmetic; the binary
   passes LITERAL 0x19 (two mapper ids are unreconstructed). The armed
   keypad send arrived at the Mech as msg 0x17 -- no handler, silently
   swallowed. Pinned to 0x19 (Mech::EjectPilot's id on the OWNER receiver --
   the collision is the design).

Verified headless through the REAL click seam (BT_BTNTEST=61): generators
killed -> panic-arm ON -> screen-click Panic -> [lbe4key] unit 0 mode
0x650421 -> PUNCH-OUT. Also: IsDisabled eject refusals now log; [padkey]/
[lbe4key] seam tracing under BT_PAD_LOG.

Still open (cosmetic): the flashing eject slot on the engineering MFD shows
no graphic -- the streamed page element's pixmap is unreconstructed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 17:31:16 -05:00
Joe DiPrimaandClaude Fable 5 0089b3284f Eject firsthand prep: G = coolant Flush binding + BT_FLUSH_HOLD scalpel
The Flush ACTION existed in the input engine but no key was bound in the
shipped CONTROLS.MAP -- desktop players could never hold the flush. G (free)
now maps to it. BT_FLUSH_HOLD=<frames> extends the BT_FLUSH_TEST scalpel
past its 60-frame edge test for tank-dry benches.

Dry-run finding (fl.log): a full flush drains the tank to 0/14 in ~30 s,
but does NOT arm eject by itself -- InjectCoolant moves tank coolant INTO
the loops (the bank RISES), and idle heat draw is too slow to burn the
loops down to the 5% arm threshold in minutes. Firsthand arming needs
sustained fire after tank-dry (or coolant-loop leak damage / generator
loss). Matches the binary's intent: eject arms when LEAKED nearly dry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 14:10:43 -05:00
Joe DiPrimaandClaude Fable 5 8165cee9da #118: the eject option comes alive -- per-frame arm chain closed (FUN_004a9b5c+0x10)
The evaluator's "unexported caller" found by E8-scan: the Mech MASTER
PERFORMANCE (FUN_004a9b5c, in the 004a977x..004ab188 export hole) calls
EvaluateEjectPermission as its FIRST act every frame. Without that call the
port's ejectPermitted stayed at ctor-0 forever -- the panic-armed mode never
raised, so the PANIC lamp never lit and testers "never saw the option for
eject come alive". Restored at the top of Mech::PerformAndWatch (masters
only, like the binary).

Mislabel swept: mech+0x414 is ejectPermitted, NOT "missionReviewMode" -- the
mapper's @004d196c edge-watch arms PANIC mode 0x200000 from it (the real
review mode is the GLOBAL DAT_004fd550; btl4pb now reads that global, the
GetMissionReviewMode stub is retired, mapper member renamed
previousEjectPermitted).

Verified live: kill 4 generators -> "[eject] panic-arm mode ON" -> the
miniconsole Panic button lights dark->bright (pixel captures; the pad panel
shades it from the same PadRIO lamp state the pod's physical button uses) ->
BT_EJECT_AT press -> PUNCH-OUT -> respawn heals -> mode disarms -> healthy
presses REFUSED. BT_EJECT_LOG=1 logs the arm edges.

Note: no shipped gauge rides mode 0x200000 -- the 1995 eject indication is
the button lamp; the weapon-eng MFD "UNJAM/EJECT" is the ammo-jam indicator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 13:53:09 -05:00
Joe DiPrimaandClaude Fable 5 440cee1e4f #87 dig closure: host response is linear (raw decomp re-read); the pixel curve was div-card firmware
Re-read the material damage watcher RAW (@004573e4 ctor / @00457784
change-push): 16 floats snapshotted, damaged = pristine x 0.1 (literal at
the MakeMechRenderables call site, watching zone+0x158), linear lerp, 13
floats written back, then FUN_0048d4d4 -- which is "flush_material": a dpl
command-stream marshal (opcode 100) to the i860 division card. The
material->ramp->texel response curve was CARD firmware and is not in
BTL4OPT.EXE; logged as an open question (ask: firmware/DIV docs/period
screenshots).

Field reconciliation, measured live: one 25-pt hit (ERLG class) snaps a
77-pt arm 0 -> 0.3247 in a frame -- "a single missile leaves visible
armor damage" holds under the linear curve for heavy hits; 2-pt ERS creep
(the leg-audit weapon) is the invisible case.

Bench additions: BT_WALK_ZONES=<name-list> walker filter (concentrated
per-region audits, scratchpad/night10/legwalk.sh) + BindArmourDamage
ORPHAN inventory under BT_ARMOR_LOG (proved every drawn op is
zone-claimed on the full tree).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 13:10:00 -05:00
Joe DiPrimaandClaude Fable 5 fe48accb6b #87 root cause: the level-crossing zone-record send was missing -- observers never saw damage
The binary's effect watcher (FUN_0042aa2c) raises
ForceUpdate(DamageZoneUpdateModelFlag) when a zone's damageLevel CROSSES a
band-descriptor threshold (FUN_0042a5f4, master-gated on entity+0x28 & 0xc)
-- that send is how every other pod's replicant learns zone levels
mid-fight. The port's band hub kept only the graphic-state branch, so an
observer's copy sat at 0.0 until destruction: no enemy hull darkening, no
doll movement, ever (the night-10 "no armour discoloration" report).

Restored the level branch with the already-reconstructed DescriptorCrossed
(@0042a5f4) + the master gate on both branches (mesh swap still runs on
every instance). 2-node verified: A-side armour watcher pushed 114 level
changes; every replicant peak matched the master's finals to 4 decimals
(dtorso 0.9321 == 0.9321).

Render path cleared separately (gotcha #23 discharged): BT_ARMOR_FORCE 0/1
A/B captures prove the tint renders (hull -> charcoal at 1.0). Solo
perception is the authentic economy: 2-25 pt lasers vs 68-185 pt pools.

Bench: zonewalk.sh node A now carries BT_ARMOR_LOG + BT_SHOT_EVERY (the
standing zone-replication receipt) + digest_walk.py; armorpx.sh is the
force-pair pixel rig.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:24:36 -05:00
Joe DiPrimaandClaude Fable 5 cb5426cc7f KB: #124 close-out -- triangle pick + the authored one-hull-segment zone rule
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 11:32:26 -05:00
Joe DiPrimaandClaude Fable 5 00dfbf8f9f #124 RESOLVED against the authored skeleton: the pick's zone is the
struck drawn segment's dzone, ALWAYS -- the cylinder embellishment removed

The open question ('what zone did 1995 designate for hull hits') was
already answered in the KB's #73 dig [T0+T1]: no software pick writer
exists -- the pod's pick was a dpl scene intersection, struck triangle
-> DCS segment -> its SKL-authored dzone; the cylinder lottery was only
ever the UNAIMED path (missiles/splash/rams). The live segpick dump
completes it with the authored data: the madcat's pickable skeleton is
18 segments -- hip, shoulders, guns, thighs, knees, ankles+toes, and
ONE hull piece (jointshakey -> dtorso). No ltorso/rtorso/utorso/rear/
door/searchlight segments exist. Aimed fire at any torso panel
authentically designates CENTER TORSO; the side/rear/upper panels strip
via the unaimed path.

Changes:
* the shared-carrier -> cylinder override (yesterday's embellishment)
  is REMOVED: triangle hit -> that segment's dzone, period;
* SegAimPoint: the walker's hull anchor moves from the joint ORIGIN
  (crotch gap -- rays sailed between the legs to the terrain sentinel)
  to the carrier segment's cull center (the chest);
* [picktri]/[pickwin] diagnostics grew tri/seg/zone fields -- three
  consecutive 'mystery' regressions in this stretch were the STALE-EXE
  trap (CWD-relative cmake --build from content/ resolved to a
  nonexistent dir and the old binary kept running) -- the harness doc's
  warning, hit again.

Final zone-walk matrix (spinning target, real MP, full cycles):
limbs 55/70 in-zone (legs/feet 6/6; arm strays = hull/occlusion);
hull-family aims -> dtorso 56/69; victim consumption matches. The
night-10 field table now reads as this model's fingerprint: 'only LCT
gets hits' from aimed energy IS the pod -- one hull piece, dtorso.
What was actually broken and is now fixed: the sphere mis-picks
(rgun-from-a-dtorso-aim class), the unaimed path's 180-degree frame,
and the vertical scatter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 11:31:45 -05:00
Joe DiPrimaandClaude Fable 5 1a3c268278 #124: the aimed pick now intersects the DRAWN GEOMETRY -- sphere
approximation retired; shared-hull hits route through the cylinder

The zone-walk matrix caught the pick red-handed: with the servo
verifiably holding the reticle on the dtorso segment, the per-segment
BOUNDING-SPHERE pick returned rgun/ruleg -- the gun pods and legs
thread the ray before the torso from most angles (its own comments
admitted a foot could be unhittable behind its own knee). The 1995
pick was a dpl scene intersection against the drawn geometry.

Restored that semantic:
* BTGetPickMesh caches each segment d3d_OBJECT's triangles CPU-side
  once (its own BGF VB/IB, managed-pool locks); MechSegmentPick now
  runs sphere PRE-FILTER -> Moller-Trumbore nearest-hit across the
  threaded segments' posed meshes; the old smallest-sphere selection
  survives only as the no-triangle fallback.
* Segments claimed as CARRIER by 2+ zones (the shared hull: madcat
  seg 4 carries dtorso+ltorso+rtorso+utorso+rears) cannot resolve one
  zone from geometry -- those hits return zone -1 WITH the accurate
  triangle point, and the victim's bit-verified (frame-fixed) cylinder
  assigns the panel by band/wedge. Unique carriers (legs, feet, arms,
  gun pods) keep the direct zone.
* ZoneAimPoint: the walker aims at a zone's VISUAL center (largest
  pick object's cull-center) instead of the segment origin -- joint
  origins made feet/lower legs strike the part above.
* Walker upgrades from live operation: 3-column truth (aim/pick/land),
  engage gate, damped servo with polarity watchdog, approach port.

Zone-walk verdict (full cycles, spinning target, real MP): limbs 5-6/6
direct in-zone; every hull panel routes CYL; the victim's landed zones
now include ltorso/rtorso/reardtorso/rearutorso/rearrtorso -- the
panels night-10 reported unhittable. Residuals tracked on #124:
rtorso/utorso aim-anchor placement, doors/searchlight small-zone
sample, twisted-torso twist-sign verify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 10:38:24 -05:00
Joe DiPrimaandClaude Fable 5 16e75d6e1a #124 bench: the ZONE-WALK MATRIX -- systematic per-panel targeting, operator-watchable
The precision rig the operator specified: two nodes, target visible,
every zone aimed at deliberately through the REAL reticle/pick path.

* BT_SPIN_SELF=<deg/s> (target node): rotates the node's own viewpoint
  mech in place (the BT_SPAWN_AT write pattern per frame) so every
  aspect passes the shooter's boresight; the operator watches this
  node's paper doll take the hits.
* BT_ZONE_WALK=<secs/zone> (shooter node): walks the target's damage
  zones in order, resolves each zone's carrier segment's live world
  position (BTResolveSegmentWorld + the new BTMechZoneSegAndName
  bridge), SERVOS the torso twist + aim elevation until the centered
  reticle ray (BTGetAimRay) points at the segment, fires 3 laser
  pulses, advances. [walk] ZONE/FIRE/HOLD on A pairs with [dmghit]
  zone/level lines on B.
* scratchpad/night10/zonewalk.sh: launch both + relay, map sed'd to
  grass/day, NO kill timer -- the session stays up for observation.

Hard-won servo constraints (documented in test-harness.md so they are
never relearned): the ENGAGE GATE (ray live + range<150 + bearing<1.1;
outside it RELAX the twist -- servoing at the twist limit while the
goto marches is a limit-clamp fight that visibly shakes the mech, and
a 0.55 gate deadlocks against the goto's ~0.55 resting bearing); YAW
POLARITY -1 (the twist cell's angular sense is opposite atan2(x,-z)
world yaw -- operator-observed live, the SECOND witness for the #124
SelectSlice twist-sign flip) with a divergence watchdog that
self-flips; damped correction (gain .40, cap .025/frame -- the aim ray
lags the twist write a frame); and a one-shot approach PORT to 100u
off the target instead of a cross-map march.

First live run: walker cycles zones, settles, fires; the target's
luleg climbed 0.458->0.523 under its own aimed pulses -- aimed shot,
correct panel, damage consumed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 10:04:03 -05:00
Joe DiPrimaandClaude Fable 5 b0b7adc4ea #124 CORRECTION: the frame adapter is a Z-REFLECTION, not a pi rotation --
proven by the mech's own pods

The first fix (c5dfb00) negated x AND z. The operator challenged it:
'they couldn't hit rear panels AT ALL -- a 180 rotation couldn't explain
that' -- and a rotation would also have relocated rear damage, not
erased it, while the bench premise (the dummy faces its attacker) was
assumed, never measured.

The zero-premise probe (BT_ASPECT_TEST): four self-impacts at known
WORLD cardinals + one at each weapon's PHYSICAL MUZZLE position -- the
mech's own asymmetric geometry as the left/right anchor. Measured:

* WorldToLocal is a clean R(yaw), no hidden terms (yaws -30/150/40 deg
  all consistent);
* our frame: RIGHT = +X -- SAME as the 1995 ring (W0/W7 = Right*) --
  and FORWARD = -Z, flipped vs the ring's Front*-in-+Z-arc;
* under the pi rotation the pods CROSSED (left muzzle -> rtorso);
  under z-negation every anchor lands its own side.

So: negate local z only, and -- since a reflection reverses angular
direction -- the SelectSlice torso-twist term flips sign with it
(twist was 0 in all probes; twisted-torso verify tracked on #124).

Anchor acceptance (fresh exe, third yaw): LRM15_1 left pod -> ltorso,
LRM15_2 -> right wedges, left/right arm muzzles -> larm/rarm, nose ->
front cells, tail -> rear cells, flank cardinals -> rgun / left wedges.

The BT_ASPECT_TEST probe + BTWeaponMuzzleWorld bridge stay as bench
scalpels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 09:05:35 -05:00
Joe DiPrimaandClaude Fable 5 c5dfb002f3 #124: hit-location ASPECT was rotated 180 degrees -- the resolver now speaks the 1995 frame
Night-10's systematic targeting audit (rear panels unhittable from
direct fire, Owens left torso hitting the RIGHT weapon pod and vice
versa, missiles favoring the far side) measured down to one geometric
fact: the authored wedge ring puts the Front* cells in the +Z arc
(slice-name audit, bit-verified tables), i.e. the 1995 resolver frame
has mech-forward = +Z -- while OUR engine's entity frame carries
forward along -Z. Bench proof: a victim facing its attacker took
frontal beams at local z = -4..-10, theta ~270 deg, resolving the
REAR-named cells (zones 12/14/15) every time.

The two frames differ by a pi ROTATION about Y -- rotation, not
reflection, because the field flank observations CROSS (left->right)
rather than persist. ResolveHit now rotates the local impact into the
cylinder's frame (negate x and z; y untouched; handedness preserved so
the SelectSlice torso-twist add keeps its sign).

A/B (172 resolves, live missiles+beams on a facing victim): before
theta~4.7 -> rear zones only; after theta~1.42-1.59 -> utorso/dtorso/
ltorso/rtorso/arm -- the front-authored cells, with the per-burst
re-roll scatter intact.

NOT this bug: the "one band LOW" Y-axis half of the night-10 table --
that is #16 (pick-ray boresight parallax, code-confirmed, still open).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 01:12:32 -05:00
Joe DiPrimaandClaude Fable 5 788031bc36 gitignore: tester notes + lastrun dumps join the field-log privacy rule
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 00:41:29 -05:00
Joe DiPrimaandClaude Fable 5 1df2c571ee #119: heatLoad filter restored to the POD's 28 Hz sample cadence
The clipping coolant-leak voice ("warning... war... warning coo...")
measured to a ReportLeak relaxation oscillation at a drained tank:
draw = zoneDamage x heatLoad hunts across the authored 0.0025/0.003
hysteresis band (constants byte-verified) as the dry sink's conduction
collapses and the tank trickle refills it. State machine, constants,
refill dynamics and the sequencer's chase-and-cut stop (T0 AUDSEQ.cpp)
are all the binary's own -- the ONE divergence was the 15-sample
heatLoad filter: per-Perform in the binary = a 0.536 s window at the
pod's 28 Hz, but only 0.25 s at the port's ~59 fps -- heatLoad twice as
twitchy, the flap at double the pod cadence (the myomer-kinetic dt-less
class, third instance).

UpdateHeatLoad now accrues real time and samples at 28 Hz on any
machine (catch-up capped at the 15-sample window). Per-instance clock
in a static map -- the heat-family layouts are factory-size-locked
(sizeof(Myomers) == 0x358 exact), no new members. Ctor/reset prime
calls sample unconditionally.

A/B (260 s leak-to-empty bench): trigger fires 12 -> 8, transitions
11 -> 7. The residual slow restart at a bone-dry tank is authentic 1995
behavior (same engine sequencer chase-cut); a minimum-retrigger
interval would be an opt-in deviation for the operator to decide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 20:05:06 -05:00
Joe DiPrimaandClaude Fable 5 1efe8efc68 #118 tails: death score cost + the DeathWithoutHonor console notice
* SpecialCaseDeathPenalty restored to the engine ScenarioRole (SCNROLE.h/
  .cpp) -- the 4.10-only role field the 2007 WinTesla source dropped
  (record slot [1] after KillBonus, fail-hard read; role+0x20; the
  DefaultRendererRate pattern). Its consumer decoded from the @004c05c4
  export gap (@004c07cd, missing from the #52 reconstruction): every
  death with advancedDamageOn hands the ENGINE base ScoreMessageHandler
  (@0042da20 == PLAYER.cpp:138, a DIRECT call bypassing the BT type
  Verifies) a type-1 award of -penalty. Wired verbatim. Shipped content
  authors the key nowhere -> cost 0 in the field. NUANCE [T4]: the 1995
  engine adds at Player+0x1c8, which no BT scoreboard reads (+0x278) --
  the pod's cost may never have displayed; our single-cell port shows it.

* The eject console notice: FUN_004c198c IS the
  ConsolePlayerMechDeathWithoutHonorMessage ctor (T0 BTCNSL.CPP survives
  and is compiled) -- "death without honor" is the punch-out record.
  BTPlayerEjectBookkeeping now sends it to the console host through the
  same ConsoleClientID path as the VTVDamaged notify, then latches
  suppressConsole: the eject notice REPLACES the death notify. Verified
  two-node: "notice -> console host 1" fires on the ejecting node; the
  relay-side delivery of client-5 traffic is a pre-existing question
  shared with the never-observed VTVDamaged sibling (operator-console
  workstream).

* Eject alarm-10 audio: resolved NEGATIVE -- the five Eject*.wav ship in
  AUDIO/ but BTL4.RES references them nowhere (orphaned like the SQUAT
  clips); wiring them would be a stand-in. Operator-optional deviation.

* RIO 0x38 panic control: deferred with the plumbing documented (the
  glass Flight-Controls window already renders the 0x38 bank; consumer =
  read buttonGroup[ButtonPanic] alongside the binding-engine edge) --
  pod-hardware-only, untestable on this bench.

Two-node verify (fresh-exe rerun after a stale-exe trap): REFUSED
healthy, PUNCH-OUT after generator kill, death + wreck swap + smoke +
un-wreck warp all replicated on the observer, respawned mech refuses
again, console notice fired.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:12:48 -05:00
Joe DiPrimaandClaude Fable 5 e996be249d EJECT/PANIC wired: Mech::EjectPilot (id 0x19 @0049f854) + the crippled-mech gate
The button died twice before reaching game logic: no handler (0x19 was
unregistered) and no sender (the pod's panic was a control bit, not a
mappable). Both halves reconstructed from raw disasm -- the handler AND
its permission evaluator sat in export gaps.

@0049f854 EjectPilot: press-only; gated on ejectPermitted (@0x414) and
!IsDisabled; console eject notice (relay wire = tracked tail; the
suppressConsole@0x258 latch that prevents the death double-notify IS
wired); graphicAlarm -> 10, which kills via the >=9 predicate; then a
self TakeDamage {inflicting=SELF, zone -1, Explosive, amount =
ScenarioRole::killBonus}. Role layout byte-settled via the role reader
@00429bec dest offsets + the ctor record copy: +0x1c IS killBonus, +0x20
is the 4.10-only SpecialCaseDeathPenalty, +0x28 returnFromDeath (all
prior citations reconciled). KillBonus authors NOWHERE in shipped
content -> the charge is 0 and the ALARM does the killing: our bench
outcome is the pod outcome.

@0049fa1c EvaluateEjectPermission: eject only from a CRIPPLED mech --
bank coolant fraction < 0.05 | zero live generators | live weapons
below mech+0x448 (no exported writer: zero, clause inert) | leg-gimped
novice. A healthy mech REFUSES the button; no free resets.

Input: binding-engine "Eject" action -- Backspace / pad LeftThumb
(default profile + shipped CONTROLS.MAP). Bridges per the databinding
rule: weapon/generator/bank/player reads land in their complete TUs;
MechSubsystem gains the both-cells destroyed accessors (gotcha #22).
Bench scalpels: BT_EJECT_AT=<frame> (path-identical synthetic press),
BT_KILL_SUBSYS now takes a comma list.

Verified single-node: healthy press REFUSED (x2), four generators
killed, next press PUNCH-OUT -> death, wreck smoke, respawn; the
respawned mech refuses again (permission re-evaluates after Reset).
Death rides the normal damage/death chain, so MP replication is the
proven path. Tails tracked: console relay notice, RIO 0x38 panic
control, alarm-10 eject audio/canopy, SpecialCaseDeathPenalty consumer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 16:45:54 -05:00
Joe DiPrimaandClaude Fable 5 e1ae92264a #96: the sim TIME MODEL closed from bytes -- myomer kinetic term restored to the faithful 28 Hz
The open question under every heat bracket was what the 1995 sim does with
time. Answered at the byte level:

* FUN_0041c018 IS the arcade Simulation::PerformAndWatch: slice =
  (till.ticks - last.ticks) / DAT_0052140c, stored, Perform(slice) --
  VARIABLE-STEP in real SECONDS, line-for-line the WinTesla SIMULATE.cpp
  body. Every dt-carrying reconstruction is unit-faithful; no tick/second
  gap exists anywhere.
* The myomer heat term's ONE caller is the unexported MyomersSimulation
  body @0x4b8b9a (E8-scan + raw disasm; export gap): once per Perform, raw
  slice, gated measuredVoltage>0 only. The dt-less kinetic pulse therefore
  fires at the FRAME cadence -- 28 Hz nominal, sagging under load
  (emergent hardware, not a code constant).
* Mover velocity is u/s (dead-reckoner @0x421f7c: pos += vel x ticksD/28),
  so the v operand is unit-faithful too.

The 0.5 "loaded-rate" witness bracket is RETIRED -- it modeled the sag as
a constant, wrong in exactly the reported scenario (open-field cruising is
a light scene; the pod held ~28 there). kSeekHeatCalib = 1.0.

Measured at the faithful rate (madcat cruise benches): recommended gear
equilibrates ~1450 -- inside the authored degradation band as a SOFT 20%
output governor (heatFactor = 1 - d^2/range^2), weapons untouched at
~110-150, ZERO jams, zero bay fires from movement alone; supercharge
~1770 (deep governor -- the manual's own expert-mode warning). That is
the authored system: degradeT=1000 is governor onset, failT=2000 the
cliff, and the coolant-loop controls are the player's lever.

New leads filed in open-questions: the climb term's gravity operand
samples 0 (bridge points at a dead cell); the engine Mover's own gravity
subtraction is per-frame dt-less (port impact unread); gait-noise phantom
acceleration feeds the accel term.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 16:01:04 -05:00
Joe DiPrimaandClaude Fable 5 d3de5f94ee #87/#96: 'mechs die with lightly damaged armor' AUDITED -- no port bug; it's ammo cook-off
Byte-level audit of the entire damage chain vs the binary, all links exact:
zone stream reads (@0041df5c == engine DAMAGE.cpp), ctor normalization
(@0049ce50; constants 1.0/1e-4/0.5; shipped cells uniform 1/armorPts),
TakeDamage @0041e4e0, the BT override @0049c690 (vital/leg 1.0/0.5/0.0),
CriticalHit @0049ccc4 (0.5 fraction, 1.0 cap, authored-percentage cap),
cook-off chain (@004bc3fc per-salvo stamp / @004bd300 rounds x amount /
@004ac274 divide across the bin's plugged zones).

Measured kill route: 6 of 7 bench deaths were bay-fire detonations --
LRM15 bin 10 rounds x 50 = 500 into its ONE authored carrier zone
(content: madcat bins single-zone; 15 of 22 zones vital) -> one
application 0->1.0 -> instant death, armor bright everywhere else.
Field logs confirm dominance (Rajel 12 bay-fire events, ConnMan 10).
The mech that died conventionally ended 0.97/0.93/0.92/0.85 charred --
darkening accumulates fine when mechs live long enough.

Levers: the #96 heat economy (arms the bay fires) and purge training
(EJECT hold extinguishes -- wired, logged, manual-trained in the pod).

Also corrected a wrong KB address: FUN_0041e4e0 was labeled '.DZM res
compiler' in decomp-reference.md/rendering.md -- it is DamageZone::
TakeDamage (byte-matches DAMAGE.cpp:379); swept both files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 14:42:04 -05:00
Joe DiPrimaandClaude Fable 5 152249cb76 #95: the SCOREBOARD banked one missile per salvo -- credit the DELIVERED amount
Two consumers of one impact, only one was ever verified: the victim gets
TakeDamage{amount=per-missile, burstCount=cluster roll} and applies it
burstCount times (armor was always right); the score post on the next line
sent the bare per-missile amount. An LRM10 salvo dealing 7-35 armor banked
3.5 points -- Rajel's "~3 points to score", to the digit.

Ground truth: the binary's score is the victim handler's tally (amount once
per applied burst @0x4a04da + crit bonuses) reported to the INFLICTING
player (the id-0x16 tail, deferred #45). The shooter-side stand-in now
posts amount x burstCount -- the identical figure handed to the victim, at
the identical one-post-per-TakeDamage granularity. Same pass:

* splash never credited score at all -- the binary tallies every
  TakeDamage; now posted per splash victim (amount x falloff bursts);
* the bridge credited the LOCAL player for ANY registered hit -- AI-master
  fire on the player, a dying mech's death-blast splash; now refused
  unless the shooter IS the local vehicle (MP unaffected: only local fire
  carries live damage on a node);
* direct-fire unchanged -- beams author burstCount=1 (emitter.cpp:355).

Verified per the harness doctrine: single-node field composition (madcat,
real fire, real enemy) 22/22 impact credits paired at damage x burst, zero
bare 3.33 posts; two-node replicant-victim run BOTH directions 31/31
paired across three missile authorings (3.33/2.0/5.0 per-missile) + 25-pt
ballistics, leftovers all burst-1 beam amounts. test-harness.md gains the
map=grass note (MP.EGG authors cavern/night; GOTO mechs shoot rock).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 14:11:28 -05:00
263 changed files with 258005 additions and 40390 deletions
+12
View File
@@ -49,3 +49,15 @@ operator_secret.txt
scratchpad/night*/*.log
scratchpad/night*/*.rar
scratchpad/night*/*.zip
# tester-authored notes / lastrun dumps carry player handles + machine names
# (deliberate exceptions: git add -f)
scratchpad/night*/*notes*.txt
scratchpad/night*/lastrun*.txt
# Bench artifacts in content/ (screenshots, session logs, generated eggs, deployed exe)
# -- receipts stay on disk, out of `git status`. A new AUTHORED egg: git add -f.
content/*.png
content/*.log
content/matchlog_*.txt
content/btl4.exe
content/*.EGG
+19 -5
View File
@@ -10,9 +10,15 @@
`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:** `btl4.exe` runs a full single-player loop; the gauge system is complete; the
active work is reconstructing each subsystem's authentic behavior from the binary. Details +
what's-next: `context/project-overview.md`, `context/open-questions.md`, recent git log.
**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.
---
@@ -59,6 +65,7 @@ precise than anything you can infer.
|---|---|
| What the project is, engine, platform, goal | `context/project-overview.md` |
| Missing BT source, decompilation strategy | `context/source-completeness.md` |
| **Export DARK REGIONS / "is X in the decomp?"** — the gap census | `reference/decomp/GAP_CENSUS.md` (tools: `tools/gapcensus.py`, `tools/ghidra_reexport.sh`, `tools/gapdiff.py`; log: `phases/phase-04-gap-census.md`) |
| The 4.10 literal-source manifest (per-TU function/size/address map) | `reference/BT410_SOURCE_MANIFEST.md` (tool: `tools/manifest410.py`; log: `phases/phase-03-bt410-source-manifest.md`) |
| The WinTesla Windows port (renderer/audio/HAL) | `context/wintesla-port.md` |
| Build / run / debug / repo layout / env gates | `context/build-and-run.md` + `context/decomp-reference.md` §6 |
@@ -78,6 +85,7 @@ 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` |
@@ -87,7 +95,11 @@ precise than anything you can infer.
| Term / acronym definitions | `reference/glossary.yaml` |
| The ORIGINAL 1995 player manual (controls, per-mech stats, coolant loops) | `reference/manual/Tesla40_BT_manual.pdf` (+ alignment audit in `context/pod-hardware.md` §Manual) |
| The complete verbatim detail (fallback) | `docs/PROGRESS_LOG.md` (the old 2236-line CLAUDE.md) |
| Detailed running ledgers | `docs/RECONCILE.md`, `docs/GAUGE_COMPOSITE.md`, `docs/HARD_PROBLEMS.md`, `docs/SUBSYS_PLAN.md`, `docs/P3_LOCOMOTION.md`, `docs/RESOURCE_AUDIT.md`, `docs/VEHICLE_SUBSYSTEMS.md`, `docs/BGF_FORMAT.md`, `docs/ASSET_PIPELINE.md`, `docs/BT_SOURCE_STATUS.md`, `docs/WAVE_PLAN.md`, `docs/GLASS_COCKPIT.md`, `docs/REVOLVING_DOOR_PLAN.md`, `docs/INPUT_PATH_AUDIT.md`, `docs/RESPAWN_REARM_PLAN.md`, `docs/KD_SCOREBOARD_PLAN.md`, `docs/DIST_LAYOUT_PLAN.md`, `docs/MOUSELOOK_PLAN.md`, `docs/GHOST_MECH_ANALYSIS.md` |
| 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.
---
@@ -150,7 +162,9 @@ bt411/
│ └── <topic>.md # one concept each
├── reference/
│ ├── glossary.yaml # terms/acronyms
│ ├── decomp/ # raw Ghidra pseudocode (the source-of-truth)
│ ├── decomp/ # raw Ghidra pseudocode (the source-of-truth; REBUILT 2026-08-06,
│ │ # 93.5% coverage. archive_2025export/ = the old export, kept
│ │ # ONLY so old `part_0NN.c:LINE` citations resolve -- cite @ADDR)
│ └── ghidra_scripts/ # the exporters
├── docs/ # PROGRESS_LOG.md (the full old CLAUDE.md) + detailed running ledgers
├── phases/ # restructuring / investigation logs
+28
View File
@@ -164,3 +164,31 @@ pad X action LookBehind
pad Start action ModeCycle
pad RightThumb action DisplayCycle
pad Back action Valve
# COOLANT FLUSH -- HELD key dumps reservoir coolant into the loops
# (Reservoir InjectCoolant, msg 4). The tank drains while held; with the
# tank empty the loops burn coolant with no refill -- run hot long enough
# and the bank drops below 5%, which ARMS the eject (Panic lights).
key G action Flush
# PANIC / EJECT -- the punch-out (Mech msg 0x19). Only fires on a CRIPPLED
# mech (no generators, coolant leaked below 5%, or leg-gimped novice) -- a
# healthy mech refuses the button, so a stray press is a no-op.
key Back action Eject
pad LeftThumb action Eject
# PILOT KEYPAD (the pod's MFD program keypad, KeyboardPilot unit). While
# PANIC mode is armed, ANY pilot-keypad key fires the eject (the binary's
# keyboardGroup[KeyboardPilot].Add(0x200000, mech, 0x19) -- its ONLY armed
# eject binding); un-armed, the keys feed program entry. Desktop numpad =
# that keypad.
key NumPad0 keypad pilot 0
key NumPad1 keypad pilot 1
key NumPad2 keypad pilot 2
key NumPad3 keypad pilot 3
key NumPad4 keypad pilot 4
key NumPad5 keypad pilot 5
key NumPad6 keypad pilot 6
key NumPad7 keypad pilot 7
key NumPad8 keypad pilot 8
key NumPad9 keypad pilot 9
+1 -1
View File
@@ -1384,7 +1384,7 @@ cmHeat(K,ModeSecondaryHeat,58,heatpal.pcc,heatpal2.pcc,Myomers);
}
#=======================================================================
# Sunder V1
# Denkou (was: Sunder V1)
#=======================================================================
Snd1Init
{
+29
View File
@@ -69,6 +69,23 @@ run\run.cmd [EGG] # default DEV.EGG; cd's to content\ and runs btl4.exe -eg
**Keep the `if /I "%%P"=="btl4.exe"` guard** — parsing tasklist with `tokens=2` alone reads its
*"INFO: No tasks are running"* line as a PID and spins forever with nothing running. Full
investigation, including which windows exit vs merely hide: `phases/phase-12-orphan-processes.md`.
- **Field-log anatomy — reading a steam night (#157 forensics, 2026-08-10) [T2].** A night's log is
a CHAIN of `===== BT411 SESSION` blocks: menu stubs (`args=''`, "first process") alternating with
glass generations (`-net 1501 -platform glass`, "relaunched generation"); one glass block = one
drop, and boundaries are synchronized across every machine to ±2 s (mission-clock expiry relaunches
all peers' menus in the same second). **Stub anatomy:** the steam lobby is CLICK-driven
(`steamAction`, btl4fe.cpp), so a menu closed without a click logs exactly **4 boot lines** and
exits 0 — a 4-line stub at end-of-night is a player going to bed, NOT a crash (all four machines
ended night-14 with identical 4-line stubs at 00:31:2x). 78-line stubs = a lobby cycle
(`[steamnet] up` + `[lobby] join` + map). `BTCrashFilter` is armed before the first log line and
writes a `[crash]` block + module-relative stack on ANY unhandled exception — a silent log end
means quit/kill/hang, never an unlogged AV. **`lastrun_<stem>.txt` caveats:** appends stop silently
if the file gets locked (the `is_open` guard skips) — a lastrun ending mid-night while the log
continues means the file was captured/locked during live troubleshooting, not that the chain died.
And the bat sign-off ("The game has exited. If it closed unexpectedly, send…") prints after ANY
chain end including a normal quit — it primes players to report quits as crashes; #157 ("did not
exit cleanly") was exactly this echo. The arcade-authentic round-end (mission process exits →
menu relaunch) itself reads as a crash to first-night players.
- **Why this is a BT411-only hazard [T1].** The 1995 pod shipped ONE folder — `BTL4OPT.EXE` sits
next to `BTL4.RES`/`VIDEO\`/`GAUGE\`/`AUDIO\` (it is still there in `content\`) — and RP411/RP412
keep that shape (`pack-dist.ps1` copies the exe and every asset dir into one dist root). BT411's
@@ -182,6 +199,18 @@ default (`-DBT_STEAM=ON` is the documented dev-checkout state). `BT_EXPIRE=ON` (
14-day tester window; an expire-OFF zip is renamed `-noexpire` and warns. Verify by extracting the
zip somewhere clean and booting it with no repo present — that is what catches a missing runtime DLL.
## Build ritual — the stale-link flake (bit 4+ times; MANDATORY for bench work)
MSBuild often does NOT relink `btl4.exe` when only static-lib members changed — the bench then
runs a STALE exe and the session burns hours on phantom results. Ritual for every code-change build:
1. `rm -f build/Release/btl4.exe` FIRST (force the relink);
2. build; a REAL relink prints the 20 known-benign `CreateStreamedSubsystem` LNK2019s (`/FORCE`
baseline — see the top of this file); an up-to-date run prints none;
3. STRING-VERIFY before benching: `python -c "d=open(r'build/Release/btl4.exe','rb').read();
print(b'<A-NEW-LOG-MARKER>' in d)"` — pick a string your change added (NB: release strings
carry LOG TEXT, not function names — check the .map for symbols);
4. use ABSOLUTE paths in every bench command — `cd` leaks between compound commands (the cwd
trap) and has broken builds/appends/copies repeatedly.
## Key Relationships
- Base: [[wintesla-port]] (the engine build recipe).
- Verify loop: [[reconstruction-method]]; env gates: [[decomp-reference]] §6.
+42 -2
View File
@@ -17,8 +17,21 @@ Verified in-game on all 8 mechs (Thor vs gameplay footage). [T2]
- One per mech (12: AVX BLX FIX JAX LOX MAX OWX RAX SNX STX THX VUX; model→skeleton table in
BTL4.RES @~3232850: `madcat=mad`, `blkhawk=blh`, … — the INSIDE skeleton is the X-variant, so
Madcat = `MAX_COP`). It is the torso segment's SkeletonType_A mesh; the inside view loads
EXACTLY ONE segment mesh (the `_cop`) — 25 others hidden. [T2]
Madcat = `MAX_COP`). It is the torso segment's SkeletonType_A mesh. [T2]
- **The inside view is a PER-MECH AUTHORED SET, not the cop alone** (#91 decode, 2026-08-04;
the earlier "EXACTLY ONE segment mesh" claim was madcat-derived and over-generalized —
corrected [T1, decomp-grounded: the binary's renderable build `FUN_004cef28`
(part_014.c:5077) sets skeleton type 4 (=A) for the inside build and its per-segment loop
calls `FUN_00424084(segment, type)` (:5570) for EVERY segment — NULL name = no mesh, no
torso-only filter, no cop special-case (only `siteeyepoint` diverts to the eye ctor
:5560-64); engine side: no type fallback in `EntitySegment::GetVideoObjectName`,
SEGMENT.cpp:97 — so the binary drew every authored type-A mesh, own-body pieces included;
T2: live `[view]` roster logs]). Authored type-A rosters: **madcat / vulture / bhk1 = cop only; sunder /
loki / avatar = cop + `<pfx>_tor` torso piece; thor = cop + `thx_tor` + `thx_msl` (the
shoulder LRM pod!); owens = cop + BOTH complete legs (`owx_{l,r}{ule,dle,bto,fto}`) +
`own_tshd`** — the Owens pilot saw their own legs walking; the Thor pilot saw their
missile pod edge bob at the lower frame. `BT_HIDE_INSIDE_SEG=<substr>` hides a non-cop
type-A mesh by name (diag, btl4vid.cpp ApplyViewSkeleton).
- Material: `<pfx>skin:blakskn_dz_{u,r,l}torso_mtl` — DIFFUSE/AMBIENT (0,0,0) + RAMP_REF
`softer`, **NO texture** (BLXSKIN.BMF is 238 bytes total; byte-verified). The 56 nonzero-UV
verts in BLX_COP sample nothing (planar-projection authoring leftover). [T1]
@@ -230,6 +243,33 @@ the shell green), `BT_COP_DUMP` (per-batch punch/bbox), `BT_EYE_FWD=<f>` (eye po
- Feeds: [[gauges-hud]] (the HUD overlays this view) · [[subsystems]] (gyro, task #56)
- Gotchas: [[reconstruction-gotchas]] §14 (LookAt axis guess), §15 (per-patch edge counting)
## The #91 "black rectangle" (thor) — own-body inside meshes + the blakskn rule [T2]
Three testers (Oracle + RajelAran night 7; Ronin 2026-08-03: "moves diagonally 10→4 with the
footsteps, at rest sits ON the lower frame bar, right side, half-in") reported a black
rectangle in the Thor cockpit. Decoded 2026-08-04:
- The rectangle is the **thor's own `thx_msl` missile-pod mount plate** — authored, authentic
own-body geometry (see the roster above; "Summoner" = the `thr1` FE label, same THX canopy,
so the cross-reference isolated nothing). It rides the walk-animated shoulder while the eye
rides the gyro springs → the diagonal footstep-rhythm sway. Pixel-tracked across walk
captures: ~29px wide, x/y oscillating together along the 10↔4 axis, bottom center-right,
alternating with the gait [T2, scratchpad/night11/thorrect.sh].
- **What WAS wrong:** the pilot-facing surfaces of these own-body meshes are authored with the
same `<pfx>skin:blakskn_dz_*` interior-structure material as the canopy frame (texture-less,
black diffuse, `softer` ramp lo 0.25 → hi 0.99), but the unlit frame-constant treatment was
keyed on the `_cop` FILENAME — so the identical material rendered (0.13,0.12,0.15) on the
canopy and PURE (0,0,0) on the pod/legs (`[matlog]`-verified: owx_cop blakskn vcol=FF211F26
vs owx_lule blakskn vcol=FF000000). Fixed in bgfload.cpp: the constant now keys on
`meshIsCop || material contains "skin:blakskn_dz_"`. `mechfx:blakskn_mtl` (tshd shadow
quads) deliberately does NOT match. Verified: zero pure-black pixels in the lower view band
post-fix, tab renders in the frame tone and blends with the bar at rest; owens legs read as
coherent dark structure; canopy/terrain un-regressed. The textured pod/leg batches
(THX/GEN/OWEN.BSL pages — bright, mean texel 0.55-0.74) face outboard and were never the
visible surfaces.
- Diags from the dig: `BT_MAT_LOG=<mesh-stem substr>` (bgfload per-batch material routing:
resolved colour/texture/ramp/vcol), `BT_HIDE_INSIDE_SEG=<substr>`, `[view]` per-segment
inside-roster log. The `softer` ramp resolves lo=(0.25,0.25,0.25) hi=(0.99,0.99,0.99) [T1].
## Torso-elevation aim -> EYE PITCH (fixed 2026-07-19) [T2, pixel-calibrated]
User+tester report "pitch does not work": the Torso sim integrated R/F (stick-Y)
into `currentElevation` (authored limits/rates) but NOTHING consumed it — the
+339 -10
View File
@@ -102,6 +102,50 @@ the DCS = the struck SEGMENT → its `dzone` (the SKL segment→zone map, `GetSe
`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
lottery — which is exactly the night-6 report (#73): aim at the arm, get the spray.
**COMPLETED 2026-08-03 (#124, the zone-walk matrix): the pick is TRIANGLE-ACCURATE** [T2 matrix].
The sphere approximation measurably mis-picked (aim dead-on dtorso -> picked rgun/ruleg) —
`MechSegmentPick` runs a sphere PRE-FILTER then Moller-Trumbore nearest-hit over the threaded
segments' posed meshes (CPU triangle cache from each object's own BGF buffers, `BTGetPickMesh`).
**⚠ CORRECTED 2026-08-04 (field pushback from era players — they were RIGHT): the 08-03 zone rule
("the struck segment's SKL dzone, ALWAYS" -> every aimed torso hit = CENTER TORSO) was WRONG.**
The authentic rule: **the pick's zone is the struck PATCH's authored `dz_*` tag** — the art
zone-tags the hull PER PANEL (`MAD_TOR.BGF` carries `dz_utorso`×36 / `dz_ltorso`×18 /
`dz_rtorso`×18 / `dz_dtorso`×16 + all four rear panels + `dz_searchlight` across its patches
[T1 bytes]); the dpl hit result kept GEOGROUP granularity (`dplHitGeoGroup` in the renderer's
result block [T0]); and the binary's segment->zone map `@49db20` has NO runtime caller (sole
caller = `CreateStreamedDamageZone`, load-time — raw call-scan [T1]), so no segment-level
collapse mechanism even exists. Aimed fire in the pod stripped the exact hull panel under the
reticle. The segment-level model looked airtight because LIMBS agree at both granularities (one
zone per limb mesh) — the collapse only bit the hull, and Oracle's night-10 audit ("only LCT gets
hits") was the BUG's fingerprint, not the pod's design. Port: `MechSegmentPick` attributes the
struck triangle to its draw op (index-range) and takes the op's `.DZM`-bound zone (the #87
armour-darkening bindings — the same authored patch->zone mapping that already paints those
panels), segment dzone as the untagged-patch fallback; `ZoneAimPoint` aims hull zones at their
patch CENTROIDS so the zone walker can exercise per-panel. Bench: [[test-harness]] §zone-walk
(madcat variant, scratchpad/night11/zonewalk_madcat.sh).
**MISS-MEANS-MISS (#131, 2026-08-05) [T2 both-direction bench]:** the pick answers ONLY for
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
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,
operator-watchable), `BT_LOCK_ENVELOPE` (synthetic unit sweep), `[picksrc]`/`[pickbox]` source
telemetry + objs/invFail/noTri localization. Locking is now strictly TIGHTER than 716-774; if
era testers report the pods were more forgiving, Draco's "slight lock linger" memory is the
sourced follow-up (tracked on #131).
**TWIST-SIGN VERIFIED (2026-08-04) [T2 live]:** the frame adapter's inferred angular flip
(`SelectSlice: theta -= TorsoHeading()`, vs the binary's `+=` before the z-reflection) is
CORRECT — bench: target torso pinned 0/+140°/140° (`BT_FORCE_TWIST`, sim-level
`analogTwistAxis` hold in torso.cpp — the input-level pin is DEAD, live input rides the
CONTROLS.MAP device push and Basic mode auto-centers), LRM salvos (missiles = the authentic
cylinder consumers; the binary DROPPED zone-1 beam damage). Slice picks track the
physically-facing flank in both twist directions (positive twist = `horizontalLimitLeft` =
CCW), deterministic; the wrong sign is cleanly excluded (would pick slice 7 where slice 1 is
observed). Diag: `[slice]` log (live twist, thetaIn/thetaAdj, wedge index) + zone NAMES on
`[dmgresolve]` (both under `BT_DMGTABLE_LOG`); bench `scratchpad/night11/twistsign.sh`.
**IMPLEMENTED (2026-07-29/30) [T2 bench]: the aimed PER-PART pick.**
`BTL4VideoRenderer::MechSegmentPick` (btl4vid.cpp): at tree build, each segment's draw object +
`GetPrimaryDamageZone()` (SEGMENT.h — carried per segment in the skeleton STREAM, read by
@@ -155,6 +199,14 @@ 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
@@ -234,7 +286,7 @@ no-arc). The old hardwired lock, the ±30°-default cone, and the projectile pat
fallback are all REMOVED. LMB fires lasers / RMB missiles (with SPACE/CTRL). ⚠ A view-selection
bug was fixed en route: every
renderable rebuild stomped `mCamera` back to the chase eye (btl4vid `mViewInside` now persists
the chosen view — the aim camera feed and the V toggle both depend on it).
the chosen view — the aim camera feed and the view toggle (backtick since task #68 took 'V' for rear-view) both depend on it).
**WORLD STRUCTURES ARE TARGETABLE via the STATIC COLLISION TREE (task #50, 2026-07-15) [T2].**
The `BTGroundRayHit` "terrain" tier only samples the VISUAL heightfield, which on arena1 is a
@@ -324,6 +376,34 @@ per ER-M laser; [[decomp-reference]] §5). Flying projectiles (LRM/autocannon) a
reconstruction (`BTPushProjectile` — the 2007 Entity is too small for the binary's raw integrator
offsets). [T2]
## "Mechs die with lightly damaged armor" — AUDITED, no port bug: it's AMMO COOK-OFF (2026-08-02) [T1 chain, T2 measured]
Field complaint (night 9): mechs die while their panels read nearly pristine, so "the scaling
must be off". A byte-level audit of the ENTIRE damage chain found **every link faithful**:
zone stream reads (arcade base ctor `@0041df5c` == engine DAMAGE.cpp, incl. the JointedMover
skip-5 branch); the BT ctor normalization loop (`@0049ce50` constants 1.0/1e-4/0.5 — and in
SHIPPED CONTENT the raw cells are EXACTLY `1/armorPts` uniform across all 5 types, so the
normalize is a no-op); `TakeDamage @0041e4e0` formula+clamps; the override `@0049c690`
(redirect gate, vital/leg thresholds 1.0/0.5/0.0 `@0049c99c..a4`); `CriticalHit @0049ccc4`
(fraction 0.5 `@0049ce48`, cap 1.0 `@0049ce4c`, the per-subsystem `applied<damagePercentage`
cap); the cook-off chain (`stamp @004bc3fc` per-SALVO before the missile divide, `CookOff
@004bd300` = ammoCount×amount, `DistributeCriticalHit @004ac274` = ÷(zones plugging the bin)
then one DESIGNATED-zone TakeDamageMessage each).
**The measured kill route** (death-kinetics bench, 7 deaths): 6 of 7 were **bay-fire
detonations** — e.g. `AmmoBinLRM15 10 rounds × 50 = 500 (burst 1)` into the bin's ONE authored
carrier zone (madcat bins are single-zone: LRM15_1=roster[26]→dz_ltorso, LRM15_2=[28]→dz_rtorso,
AFC100=[21]→{larm,lgun}), which is VITAL (madcat authors **15 of 22 zones vital** — all torsos,
legs, feet) → one application 0→1.0 → instant death, every other panel bright. The ÷1 is the
CONTENT's authoring, not a port shortcut — the arithmetic is the binary's own. The one mech that
died conventionally (300 applications) ended VISIBLY charred: 0.97/0.93/0.92/0.85/0.79 across 13
zones — mid-fight darkening DOES accumulate when mechs live long enough.
**Field logs confirm dominance**: Rajel's night-9 log alone has 12 bay-fire events (LRM10
455-490 pts); ConnMan 10; the "salvo barely dented my armor" reporter was dying to their OWN
cook-offs. **The real lever is HEAT** (bay fire arms at the bin heat-watcher's FAILURE level;
the [[subsystems]] heat/#96 too-hot economy arms it constantly) plus player training: EJECT
**hold** purges the burning bay (`DumpAmmo` → "bay fire EXTINGUISHED"); pod players were
manual-trained to purge ([[pod-hardware]] §Manual), testers are not. Novice sim gates also
suppress the whole path ([[experience-levels]]).
## The RAM economy — CLOSED (2026-07-12) [T1 evidence, T3 normalization]
Three-layer story, measured live + decomp-verified:
1. **The armor economy is POINTS**: every zone streams `damageScale[5]` — 5 cells indexed by
@@ -497,7 +577,9 @@ percent tables, MadCat) — see #73 on the tracker.
## layers are now fixed and live-verified
The recovery: the un-exported gap held `Mech::TakeDamageMessageHandler @0x4a0230` (found via the
Mech **message table @0x50bdf8** — rows `{id, name, handler}`: 0x12 TakeDamage, 0x14 PlayerLink,
0x15 RealMaxSpeed, 0x16 BalanceCoolant, 0x17/0x18 Set/ClearBurningState, 0x19 EjectPilot @0x49f854,
0x15 RealMaxSpeed, 0x16 BalanceCoolant, 0x17/0x18 Set/ClearBurningState (⚠ label/body mismatch:
the 0x17 handler @0x49f674 randomizes position + graphicAlarm(2) + re-enables sim — not burning;
possibly a shifted name row, cf. the factory ClassID mislabels — open-questions), 0x19 EjectPilot @0x49f854,
0x1a DuckRequest @0x49fa00) and the **crit-chance roll `@0x4a0164`**:
`p = clamp(0.7·damageLevel² + 0.01, 0..1)`, gated on the player's `simLive` flag (+0x25c — novice
never crits), rolled per BURST on the current zone (skip if the zone is already burning). The
@@ -510,12 +592,121 @@ entirely — multi-burst under-applied (burst-1)×) is superseded. `MechSubsyste
@0x4ac0bc` is real (zone damage → destroyed alarms → **vital-subsystem kill**: owner
`graphicAlarm` level 9, the #28 machinery — CLASSMAP's "HandleMessage@4ac0bc" was a mislabel).
Live-verified: `[subarmor]` shows parsed scales at spawn; `[critroll] zone=3 -> Myomers subLvl=1`
— a full chain crit destroying a subsystem. Still deferred [T4→documented]: the `damageType==0`
COLLISION divert (@0x4a0368`0x49ffcc`, its own distribution) and the **id-0x16 damage/kill
reports** to shooter+victim players (@0x4a04da-0x4a07b2 — the authentic stats plumbing, feeds #45:
`{tally, zone, destroyed-flag, inflicting subsystem, victim name}`, kill-flagged variant on newly
disabled, plus a killed-by block gated on movementMode 9/10). Diags: `BT_CRIT_LOG` (`[subarmor]` +
`[critroll]`), `BT_DMG_LOG`.
— a full chain crit destroying a subsystem. The `damageType==0` COLLISION divert (@0x4a0368
`0x49ffcc`) and the id-0x16 report tail are BOTH reconstructed — see the next section. Diags:
`BT_CRIT_LOG` (`[subarmor]` + `[critroll]`), `BT_DMG_LOG`.
## ✅ THE AUTHENTIC SCORE/DEATH REPORT TAIL (2026-08-05, #45/#134) [T1 raw disasm, T2 both benches]
The rest of `Mech::TakeDamageMessageHandler` (@0x4a02f4-0x4a0890, dark-gap raw disasm) is now
reconstructed: `BTMechPostCombatReports` + `BTMechPostVehicleDead` (btplayer.cpp), called from the
handler at the binary's exact positions (mech.cpp). Retires `BTPostDamageScore`/`BTPostKillScore`.
**Entry sequence** [T1]: gyro bounce → death-edge latch `[ebp-0x10]` (was-destroyed at ENTRY —
before the divert!) → `lastInflictingID` stamp (mech+0x43c ← msg inflictor) → shooter resolve
(registry find → `shooter+0x190` = shooter's PLAYER; victim's = `mech+0x190`) → **collision divert
JUMPS TO THE DEATH TAIL** (@0x4a0375 `jmp 0x4a07b5`, NOT a return — a wall/fall death still posts
VehicleDead + blasts, but skips the score blocks: collision deaths credit no one). Our old early
`return` here was a latent "wall-death strands the pilot" hazard, fixed with the move.
**The three id-0x16 reports** (all `Player::ScoreMessageID` 0x16, sizeof 0x3C — layout in
btplayer.hpp, static_assert-locked; `scoreAward`=APPLIED tally in all three):
- **A @0x4a04da, newly destroyed** → type 2 KillScore to the SHOOTER's player. Basis (+0x24) =
the VICTIM role's `killBonus` (role+0x1c). senderMechID = the victim. Suicide (eject charge,
self-damage) IS dispatched — the handler negates the award (@0x4c03ab `fchs`) and skips
`killCount++`: **the #134 panic penalty, live** (bench: `type=2 award=-39.00 kills=0`).
- **B @0x4a05d9, not newly killed && tally≠0** → type 0 to the shooter: the **per-hit INFLICTED
credit**, and it is LIVE. ⚠ **CORRECTED 2026-08-07** — this entry previously read "the ONLY
registered 0x16 receiver Verify-rejects type 0 → 1995 folded an UNINITIALIZED stack float into
the shooter's score on every non-lethal hit (real 1995 bug — @0x4c0200 … is in NO table entry:
dead code)". **That was wrong**, and build 787 retired the port's per-hit crediting on the
strength of it (the scoring regression players reported on 4.11.817). `BTPlayer` overrides
**`Dispatch` — vtable `@00513300` slot 3 = `FUN_004bffa0`** — and splits type 0 off BEFORE base
dispatch: `if (id==0x16 && type==0) FUN_004c0200(...); else base;`. `@004c0200` names itself in
its own Verify string (`BTPlayer::ScoreInflictedMessageHandler`) and computes
`CalcInflicted(basis) → negate if target==self → × (targetTonnage/ownTonnage) → += +0x278`.
`ScoreMessageHandler`'s type-0 arm Verify-rejects precisely BECAUSE the interceptor guarantees
type 0 never reaches it. Independently corroborated by the original manual's SCORING CHART
(`reference/manual/scoring_chart.webp`, via Lynx): "+1 each damage point scored on opponent's armor" and "-1 each
self-inflicted point of armor damage" — the negate-if-self arm exactly. Interceptor restored;
benched 83 inflicted rows, awards 0.9825.00, zero type-0 Verify rejections. [T1]
- **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
(gated `GetConsoleHost()` && !`suppressConsole` — which is why that field is named that).
Reports carry the LOOP-ENTRY zone (msg+0x24, never rewritten mid-loop), the vital-wreck flag, and
`inflictingSubsystemID` (msg+0x5c, engine T0 name).
**Score model consequence** [T1, ⚠ REWRITTEN 2026-08-07]: 1995 pod scoring = **per-hit inflicted
credit + kill awards + received-damage penalties + death costs**. The previous text here claimed
"No per-hit inflicted credit … inventions riding the dead @0x4c0200 channel; both retired" — that
followed from the dead-code misreading corrected in report B above, and retiring the credit IS the
scoring regression reported on 4.11.817. The **original manual's SCORING CHART** (`reference/manual/scoring_chart.webp`)
is the cross-check for every row and should be consulted before touching this path again:
| Points | Event |
|---|---|
| +1000 | Starting the game |
| +1 | Each damage point scored on opponent's armor |
| +10..+30 | Destroying an opponent's internal system |
| +500 | Destroying an opponent's 'Mech |
| 1 | Each self-inflicted point of armor damage |
| 10..30 | Knocking out one of your own internal systems |
| 500 | Destroying your own 'Mech by an ammo explosion |
| 1000 | Destroying your own 'Mech by ejecting |
**THE SCORE AUTHORITY IS THE OPERATOR CONSOLE, not the player object** [T1, 2026-08-07]. The
binary sends `ConsolePlayerVTVScoreUpdate(ownerID, currentScore)` every `CONSOLE_UPDATE_INTERVAL`
and then does `param_1[0x9e] = 0`**ungated**. So `+0x278` is a *console DELTA*, never a running
total, and it does not matter which NODE computed a delta: every node's contribution is flushed
stamped with the scoring player's `ownerID` and the console accumulates. This is almost certainly
where the chart's **+1000 starting the game** was seeded, which is why no game-side code grants it.
**Consequence for the port** (no console as score authority): `GetScore()` (SCORE gauge),
`Player::CalcRanking()` and the replicated `Player__UpdateRecord` all read `+0x278` **on the owning
node**. Damage is applied on the VICTIM's node, so block B dispatches the inflicted report to the
SHOOTER's player object there — a **REPLICANT** — and the credit is banked on the wrong machine,
where the master's next update record overwrites it (benched: totals climb to ~35, snap back every
few seconds = the field "scoring is screwy"). ⚠ OPEN. **Tried and rejected:** gating the type-0
interception to `MasterInstance` so a replicant reroutes — the message arrives but the BT extension
fields (`damageAmount`@+0x24, `senderMechID`@+0x34) do NOT survive the wire, only the base
`scoreAward`, so every award computes 0.00. That is also WHY the kill report (type 2) already
credits cross-node correctly: its value rides `scoreAward`. **Fix shape:** compute the award on the
victim's node (where the damage data is) and ship the RESULT in `scoreAward`, as the kill report
does — do not ship the basis and recompute where it cannot be seen.
**The damage-bias term is VESTIGIAL — do not "finish" it** [T1, audit 2026-08-08]. The kill/inflicted
formula's `(victimAvgZoneDamage@0x354 × damageBias + 1.0)` factor is **always 1.0** in the shipped
binary. `mech+0x354` has exactly one writer — `Mech::Reset` (@0049fb74, part_012.c:14340), which
computes `mean(zone+0x158)` across every damage zone *after* the zone heal has zeroed those cells —
and exactly one reader, `CalcInflictedScore` (@004c052c). Nothing recomputes it during play, so it
holds ~0 for the mech's whole life. `0x358`/`0x35c` are the same computation over subsystem zones
and have **no reader at all**. The port's `MECH_DAMAGE_BIAS(m) → 0.0f` therefore reproduces the
binary exactly; wiring it to live damage would look like completing a stub and would silently
inflate every award (the chart-verified +1/point and +500/kill both assume 1.0).
**Three chart rows are NOT yet reconciled with the reconstruction** — treat as open [T4]:
(a) a kill benches at `award=4.88`, two orders off the chart's flat **+500**; (b) **+1000 at
game start** has no known implementation; (c) **1000 eject / 500 ammo** would live in
`ScenarioRole::specialCaseDeathPenalty` (role+0x20), which the port reads
(`GetSpecialCaseDeathPenalty`, the death-cost block) but which **shipped content authors
nowhere**, so it is 0 in the field — the #134 symptom by another route. Do not "fix" these by
hard-coding chart numbers; find where the binary sources them.
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
`freeForAll@0x250==0`); self-kill negates the whole award.
**The death tail** (@0x4a07b5, shared by the divert): gate = was-alive-at-entry && movementMode
9|10 → **`Player::VehicleDeadMessage` id 0x17 to the mech's OWN player** — the respawn trigger,
in the BT 0x38-byte extension `{deathCount -1, dropZone Null, +0x2c killed-by PLAYER EntityID,
+0x34 kill zone}` (bench: `killedBy=2:1 zone=3` cross-node) — then the death Explosion (id 3,
0x5C, model 0x31; port fires the authored death list from the transition instead, #42) and the
#89 SplashDamage (re-verified after the reorder: 72 bursts at ~9u). The old mech4
death-transition dispatch site (which was flagged [T3 sender-undecoded]) is retired — this IS
that sender. The #55 NULL-playerLink fallback + DEAD_NOTIFY forensics moved into the bridge.
Benches: `scratchpad/night12/scorekill.sh` (cross-node kill: killer `kills=1 award=4.88`,
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.
## (HISTORICAL — the gap as found 2026-07-29, superseded above) [T1]
The authored crit machinery exists and is reconstructed — `Mech__DamageZone::CriticalHit @0049ccc4`
@@ -833,7 +1024,9 @@ stub let it fire and corrupt `graphicAlarm`. Now `mech->IsDisabled()` — the la
defence-in-depth. Sibling fix: `mechdmg.cpp:451` read the phantom `stance` (perma-0) instead of
`MovementMode()`, so the leg-shot-out → `graphicAlarm=9` fall/death branch was entirely DEAD; now live.
## Kill-score damage (task #60)
## Kill-score damage (task #60) — ⚠ SUPERSEDED 2026-08-05 by §THE AUTHENTIC SCORE/DEATH REPORT
## TAIL above (`BTPostKillScore` is RETIRED; the kill basis is now the applied damage TALLY +
## the victim role's killBonus, posted by the victim's own TakeDamage handler). History below.
`BTPostKillScore` (btplayer.cpp:1491) feeds the ScoreMessage `damageAmount` into the kill award
(`@0x4c02e4` → `(damageAmount + scoreAward) × roleScalar × teamMult × tonnageRatio`, `@0x4c052c`).
The port passed a flat `kShotDamage=12` (mech4.cpp:1551), so every kill scored identically regardless
@@ -932,7 +1125,9 @@ 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 HeatSink-family/Gyroscope/Torso (GUIDs 0x50e590/0x50fdc0/0x510b08; all
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
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
@@ -965,6 +1160,60 @@ returned NULL) -- the cascade "ran" and touched nothing, which is why blown-off
arms left firing gun pods across every chassis (Conn Man's three-chassis audit).
See reconstruction-gotchas #25.
## Zone-LEVEL replication -- the observer's picture (#87 root cause, 2026-08-03) [T1 decomp / T2 verified]
How an OBSERVER (replicant holder) learns a mech's zone damageLevels, end to end:
1. Damage messages do NOT echo locally: `Entity::Dispatch` on a REPLICANT
forwards to the master's node and returns (ENTITY.cpp:244) -- there is no
broadcast-bus application. The MASTER alone consumes `TakeDamage`.
2. The master ships levels via zone UPDATE RECORDS
(`DamageZone::Write/ReadUpdateRecord`, `DamageZoneUpdateModelBit`), gated by
`ForceUpdate(DamageZoneUpdateModelFlag)`.
3. **Who raises the flag (the binary's effect watcher `FUN_0042aa2c @0042aa2c`,
the BT analog of the engine's `EntityEffectWatcher` -- which itself never
runs for mechs: mech zones carry BAND DESCRIPTORS, the engine `ExplosionTable`
stays NULL and `EntityEffectWatcher` is constructed nowhere in the image):**
* level branch (zone changedFlags & 4): if the rise CROSSES a band-descriptor
threshold (`FUN_0042a5f4` = `Mech__DamageZone::DescriptorCrossed`) AND the
entity is a MASTER (`(entity+0x28 & 0xc) == 0`) -> `*(entity+0x18) |= 2`
(= ForceUpdate(DamageZoneUpdateModelFlag)).
* gstate branch (changedFlags & 8): ForceUpdate unconditionally (masters).
So observers track a fight at **band-crossing granularity** -- not per hit.
4. Consumption on the observer is passive: `ReadUpdateRecord` writes the level,
the armour watcher (`TickArmourDamage`) re-reads zones every frame and pushes
the draw-op tint; the doll gauges read `damageLevel*100` continuously.
**The port had dropped the level branch** -- mechdmg.cpp's band hub ForceUpdated
only on `graphicState != Exists`, so every observer's copy sat at 0.0 until a
zone DESTRUCTED: no enemy hull darkening, no observer-side doll movement, ever
(the "no armour discoloration" night-10 report). Restored 2026-08-03
(mechdmg.cpp band loop: `DescriptorCrossed(prev, level)` + master gate on both
branches). Verified 2-node: A-side watcher pushed 114 level changes; every
replicant peak matched the master's final level to 4 decimals (dtorso 0.9321
both sides).
Render-path note (gotcha #23 discharged): the tint itself was pixel-proven with
`BT_ARMOR_FORCE` A/B captures -- all-zones 1.0 renders the hull charcoal (0.1x
floor), forced 0.4 reads visibly grey at close range on a light skin, so "no
discoloration" was never the renderer. Solo/master-side perception is the
authentic ECONOMY: 2-25 pt laser events vs 68-185 pt zone pools. The eye
catches SNAPS, not creep: a single heavy hit (ERLG large laser / missile
class, amt=25) jumps a 77-pt arm 0 -> 0.3247 in one frame (measured live) =
the pod veterans' "a single missile leaves visible armor damage"; 2-pt ERS
fire creeps ~1.6%/hit and stays visually silent for a dozen hits (the
"legs never show" reports -- their levels peak ~0.08-0.13). The cockpit doll
maps all 28 zones (L4GAUGE.CFG:4788-4814 cmArmor lines -- feet + lower legs
included); nothing is unwired (orphan audit 2026-08-03: every drawn op with a
material name is claimed by a zone).
Fidelity boundary [T1]: the HOST binary's response is LINEAR (watcher
@004573e4/@00457784: 13 material floats lerped toward x0.1 by level, then
"flush_material" to the division card). The CARD-side material->ramp->texel
curve is i860 firmware absent from BTL4OPT.EXE -- see [[open-questions]]
"i860 DIVISION-CARD firmware". If pod darkening was perceptually steeper at
mid levels, that steepness lived there.
## The hit-location CYLINDER -- raw-bytes audit + chassis->table map (2026-08-02) [T1]
Full independent verification of the type-29 DamageLookupTable streams, parsed
@@ -984,6 +1233,22 @@ code involved; grammar = the binary ctors @0x49ea48/@0x49e5e4). Results:
"Right*", W1/W2 = "Front*" -- with theta = atan2(z,x) from +X toward +Z, the
mech-local FRONT is the **+Z arc (theta 45-135 deg)** and dead-ahead is the
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 **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 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 π
rotation CROSSED the pods: the left muzzle resolved rtorso). `ResolveHit`
negates local z; and because a reflection reverses angular direction, the
torso-twist term in `SelectSlice` enters with the OPPOSITE sign (twist was 0
in every probe — the twisted-torso verify is the tracked follow-up).
Anchor acceptance: left pod→ltorso, right pod→right wedges, left/right arm
muzzles→larm/rarm, nose→front cells, tail→rear cells, at a third yaw. The
separate "one band LOW" Y-signature is #16 (pick-ray parallax), not this.
Frontal unaimed foot-band hits therefore straddle the RightFoot/LeftFoot
cells by impact-x sign, and the authored cross-bleed (20% opposite foot,
10+10% opposite leg) is a data-true source of "hit the opposite leg" reports.
@@ -996,6 +1261,70 @@ code involved; grammar = the binary ctors @0x49ea48/@0x49e5e4). Results:
cylinder** -- diverted at the handler head (0x4a0361: test type; 0x4a036d:
call 0x49ffcc; jmp out) [T1 disasm].
## The death transition is SYNCHRONOUS within the damage pass (2026-08-11) [T1/T2]
The binary writes movementMode 9 INSIDE the lethal TakeDamage pass — its own death-tail gate
(part_012.c:14761) reads 9||10 already flipped in the same call. The port deferred the write to
the per-frame UpdateDeathState and a same-frame direct+splash lethal pair re-ran the whole death
block (duplicate kill credit + duplicate VehicleDead) — caught by the 4-node bench, fixed by
running the once-per-death transition inline at the death tail (1b6d247). Full class write-up:
[[reconstruction-gotchas]] §25; stress verification: kd4_bench 9/9/9/9 + kd8_stress 26/26/26/26,
SWALLOWED=0 both. The receiver-side dedup remains a TRIPWIRE only (1995 has none; +0x290 never
written) — if it ever fires again, a new duplicate source exists upstream.
## Key Relationships
- Weapons/roster: [[subsystems]]. Aim source: [[locomotion]] (drive/facing). Effects: [[rendering]].
- P5 forensics: `docs/HARD_PROBLEMS.md`. Data: [[decomp-reference]] §4-5.
## The K/D-score AUTHORITY MODEL -- settled once and for all (2026-08-11, #162) [T1]
The 1995 chain has ONE emitter and NO receiver dedup; every counter is single-owner.
Fixed twice piecemeal (#150 kills, #162 deaths) before the full audit settled it -- do
not touch any single limb of this chain again without re-reading this section.
**Emitter (Mech::TakeDamageMessageHandler @0x4a0230 -- the victim's MASTER only;
replicant-asserted part_012.c:14613):** entry latch `local_14` = destroyed-at-entry
(modes 2|9, @0x49fb54). Three id-0x16 reports: **A** type-2 KILL -> shooter's player,
alive->dead edge only, exclusive with B (basis = victim role's killBonus, @0x4a0506);
**B** type-0 Inflicted -> shooter, non-lethal AND corpse hits (tally != 0); **C** type-1
Received -> victim's own player, latch-INDEPENDENT (corpse hits included; basis =
intended damage = bursts x amount). Death tail (gate `local_14==0 && mode 9|10`,
@0x4a07b5): exactly ONE **VehicleDead** (id 0x17, deathCount=-1, killer player id) ->
victim's own player, + explosion + delayed burn + splash (fresh TakeDamages, gate
advancedDamageOn && !suppressConsole) + ForceUpdate. Collision (type-0 damage) diverts
past ALL reports straight to the tail: a ram death counts + costs but credits no one.
**Consumer (BTPlayer):** 0x16 type-2 @0x4c02e4: CalcKillScore, killCount(+0x27c)++ dual
(the phantom wrong-column partner -- keep byte-faithful), suicide fchs negation skips
both increments. 0x17 @0x4c05c4 deathCount==-1 arm: deaths(+0x280)++ then STRAIGHT-LINE
the -500 cost (gate advancedDamageOn(+0x264) ONLY; -role->specialCaseDeathPenalty
(+0x20) via DIRECT Player::ScoreMessageHandler base call -- never on the wire, never in
a matchlog; the [deathcost] DEBUG line is its only receipt). **The receiver dedup gate
+0x290 is NEVER written non-zero anywhere in BTL4OPT.EXE [T1 image byte-scan]** -- the
binary's single-increment guarantee is purely the SENDER's one-shot edge. Its clear
pair (+0x290=0, +0x258=0) lives in the DropZoneReply tail (FUN_004c012c,
part_013.c:10519) -- the #81-era port put it in the VehicleDead arm by misattribution,
disarming the port's defensive latch (corrected 2026-08-11).
**Replication:** the player update record carries currentScore(+0x1c8) +
dropZoneLocation ONLY. killCount/deaths never cross the wire in 1995; each node's
scoreboard columns are locally accumulated (the port's SBMIRROR is a deliberate
deviation that makes boards consistent).
**Full evidence record** (every dispatch/arm/layout with decomp line cites, the field
receipt A/B, and the fix archaeology): `docs/KD_CHAIN_AUDIT_20260811.md`.
**Port rules that keep it correct:** (0) **the entry latch is MODE-based (movementMode
2||9 == FUN_0049fb54), NEVER the alarm-based IsMechDestroyed()** -- EjectPilot raises
graphicAlarm to 10 BEFORE its killBonus self-damage, so an alarm-based latch reads a real
punch-out as "already destroyed" and silently eats the suicide type-2 (the -499 arithmetic)
while cook-off suicides still score; caught by the eject canary 2026-08-11, the same
alarm-vs-mode trap #108 fixed for deathBlastArmed; (1) the report block AND the death tail
are both master-gated + entry-latched (mech.cpp) -- replicant-side damage application is a
visual-only deviation and must never emit; (2) advancedDamageOn comes from the mission
egg per-pilot page -- every stock egg stamps `advancedDamage=1` on every page, and the
FE egg writer now stamps the host's menu choice mission-wide (a memset-zero pilot
record shipped penalty-off for entire steam nights, #162); (3) the bench for ANY change
here is scratchpad/night15/kd_bench.sh -- five HARD invariants (deaths==increments,
cost-per-death, credit-per-kill, zero swallowed, arithmetic witness); its predecessor's
assertions were greps that matched nothing, which is how both halves shipped broken.
+313 -3
View File
@@ -176,6 +176,22 @@ rows until sanity fails (`python + struct`, see the session commits `cc2b109`/`2
---
### Master posture / crouch block (FUN_004a9b5c region, raw disasm 2026-08-05/06) [T1]
- mech+0x3f8 `mapPosture` (0 none / 1 may-duck / 2 holding-squat) — selector @0x4a9f61-0x4aa007:
gates = movementMode normal, simLive (player+0x25c, NOVICE lockout), leg alarm 0/4 → 1, leg
alarm 1 → 2, myomer factor |x|>1e-4 (const @0x4ab16c).
- mech+0x79c `myomerEffectiveness` — MAX over the myomer chain's speedEffect@0x31C, published by
the mapper drive-scale block (mechmppr.cpp:990 == @0x4a9cf2-0x4a9da4); scales speedDemand,
zeroes turnDemand at ~0 (the seek-4 freeze), and gates the crouch.
- DuckRequest consumer @0x4aa011-0x4aa0af: duckState && squatCapable → SetLegAnimation(2 'sqd')
/ (3 'squ'), ForceUpdate(8)+(1) (type-3 record ships legState → peers pose it), stability
alarm 0/1, request consumed. Clips: animationClips[2]=sqd(i), [3]=squ(i) (BTL4.RES, all 8
chassis; loader slots byte-verified vs @0x50d9c8 suffix pool).
- Myomers cluster: Performance wrapper @004b8b9c → drive-heat integrator @004b8be3;
AvailableOutput @004b8ac0 = gear clamp × QUADRATIC heat degrade (+0x114 vs +0x118/+0x11c band)
× (1 legZoneDamage); speedEffect = output / ownerBaseSpeed(+0x34C) — unclamped (gear-4
supercharge ≈1.43).
## 4. Damage delivery
- `Entity::TakeDamageMessage(id, size, inflictingEntityID, zone, Damage&)``target->Dispatch(&msg)`.
@@ -183,6 +199,7 @@ rows until sanity fails (`python + struct`, see the session commits `cc2b109`/`2
- `class Damage { damageType(enum Collision/Ballistic/Explosive/Laser/Energy), damageAmount, damageForce, surfaceNormal, impactPoint, burstCount }`. [T1]
- Weapon effect id: **"explode" = 13** (`Explosion::Make`). [T2]
- `DestroyEntityMessage(id,size)` removes an entity — but a killed mech STAYS (a WRECK); death = a STATE transition (`SetGraphicState(DestroyedGraphicState)`), NOT removal. Issuing removal-on-death is the P5 teardown bug (do not). [T2]
- **BT effect watcher (zone replication + band effects)** [T1, 2026-08-03]: ctor `FUN_0042a984 @0042a984` (hooks entity+0xbc, allocs oldLevel[damageZoneCount@+0x11c]); Execute `FUN_0042aa2c @0042aa2c` — per zone: flag&4 → `FUN_0042a664` DescriptorForLevel + `FUN_0042a5f4` DescriptorCrossed(old,new) → **crossed && master (`(entity+0x28 & 0xc)==0`) → `*(ushort*)(entity+0x18) |= 2`** (= `ForceUpdate(DamageZoneUpdateModelFlag)`, the zone-record send); flag&8 → `FUN_0042a6c4` DescriptorForGraphicState + the same master-gated ForceUpdate; descriptor → effect via `FUN_0043663c/FUN_004364e4` (renderer mgr @`DAT_004efc94+0x38`); changedFlags reset when (master && !pending-update) or replicant. Zone band table @ zone+0xd4; level @+0x158; gstate @+0x78. Engine `EntityEffectWatcher`/`ExplosionTable` are DEAD for mechs (table NULL, watcher never constructed — band descriptors replace them). Port: mechdmg.cpp band hub. See [[combat-damage]] §Zone-LEVEL replication.
---
@@ -236,6 +253,34 @@ 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]
@@ -246,9 +291,59 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
### Binary message tables decoded 2026-07-20 (glass input audit) [T1]
16-byte HandlerEntry {id, namePtr, fnPtr, 0} rows in section_dump. Per-receiver-class id spaces.
- **Mech (entity)** @0x50BDF8..: 0x12 TakeDamage@004a0230, 0x14 PlayerLink@0049f624, 0x15
RealMaxSpeed@0049f604, **0x16 BalanceCoolant@0049f728 (WIRED 2026-07-21, issue #20**: press-only, NO novice guard; sets every condenser valveState@0x1D0=1 then the shared redistribute @0049f788 == BTRecomputeCondenserValves; verify BT_BALTEST=1 + BT_VALVE_LOG=1**)**, 0x17 SetBurningState@0049f674, 0x18
RealMaxSpeed@0049f604, **0x16 BalanceCoolant@0049f728 (WIRED 2026-07-21, issue #20**: press-only, NO novice guard; sets every condenser valveState@0x1D0=1 then the shared redistribute @0049f788 == BTRecomputeCondenserValves; verify BT_BALTEST=1 + BT_VALVE_LOG=1**)**, 0x17 SetBurningState@0049f674 (⚠ body mismatch — randomizes position, graphicAlarm 2, sim re-arm; see open-questions), 0x18
ClearBurningState@0049f700, **0x19 EjectPilot@0049f854**, **0x1a DuckRequest@0049fa00** (the
manual's CROUCH; streamed button 0x13 sends it — unreconstructed).
- **BTPlayer** (byte-scanned 2026-08-05, file 0x112dxx; 20-byte rows {id, namePtr, fn, 0, 0} — 6
entries EXACTLY, matching the T0 PLAYER.h enum): **0x15 DropZoneReply@0x4bffd0, 0x16
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 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
console VTVDamaged post (ConsoleHost && !suppressConsole@0x258; **arg 6 = Round(AWARD)** — an
ST0-arg __ftol @0x4dcd94 the decompiler rendered argless; the old "Now()" read was wrong); case
2 = kill: `CalcKillScore@0x4c052c` × (senderTonnage/ownTonnage), self-kill `fchs`, dual
`killCount@0x27c++` (shooter + victim's player — the phantom partner increment, masked by
replication), StatusMessage{type 0 Destroyed, victim player, 6.0s} from pool @0x512f6c with
vtable @0x513344, clear-target @0x4b04d8 via `playerVehicle+0x128` roster head when the dead
mech == `objectiveMech@0x284`. `CalcKillScore@0x4c052c` [T1]: same-team (strcmp
`teamName@0x20c`, gate `freeForAll@0x250==0`) → `-friendlyFirePenalty(role+0x14)`; else
`victimMech->avgZoneDamage@0x354 × damageBias(role+0x18) + 1.0` (const @0x4c05c0); result ×
`(basis + tally) × damageInflictedModifier(role+0xC)`. **ScenarioRole offsets** (Node base):
+0xC inflictedMod, +0x10 receivedMod, +0x14 ffPenalty, +0x18 damageBias, +0x1C killBonus,
+0x20 specialCaseDeathPenalty. **mech+0x354** = average zone damageLevel (computed with the
subsystem average @0x358 by the telemetry refresher, part_012.c:9515). **Wire layouts**
(static_assert-locked, btplayer.hpp): ScoreMessage 0x3C {+0x1c scoreAward=tally, +0x20 type,
+0x24 basis, +0x28 vitalHit, +0x2c zone, +0x30 subsysID(=TakeDamageMessage+0x5c,
inflictingSubsystemID [T0]), +0x34 senderMechID}; BT VehicleDeadMessage 0x38 {engine 0x28 +
0x28=0, +0x2c killed-by PLAYER EntityID, +0x34 kill zone}. Senders: the TakeDamage report tail
— see [[combat-damage]].
**#162 audit deltas (2026-08-11, full record docs/KD_CHAIN_AUDIT_20260811.md) [T1]:**
VehicleDead@0x4c05c4 deathCount==-1 arm: deaths(+0x280)++ dual — `inc [ebx+0x280]` victim
@0x4c067a unconditional + `inc [edi+0x280]` killer's copy @0x4c0674 (skipped on self-kill);
then STRAIGHT-LINE the death cost @0x4c07cd-0x4c0828: gate `advancedDamageOn(+0x264)` ONLY,
amount `-specialCaseDeathPenalty(role+0x20)` via `fld/fchs` @0x4c07dd, applied by a DIRECT
call to engine Player::ScoreMessageHandler @0x42da20 (never dispatched, never on the wire —
no matchlog receipt exists; the port's `[deathcost]` DEBUG line is the only witness).
**deathPending(+0x290) is NEVER written non-zero anywhere in BTL4OPT.EXE** (three writes,
all zero-stores: ctor @0x4c0c07, respawn helper @0x4c01e5, DropZoneReply tail) — 1995 has NO
receiver-side death dedup; the sender's one-shot edge is the whole guarantee. The clear pair
`{+0x290=0, +0x258=0}` lives in **FUN_004c012c = DropZoneReply**'s tail (part_013.c:10519-23)
— NOT the VehicleDead arm (the #81-era port misattribution, corrected 2026-08-11). Latch
asymmetry: the entry latch (FUN_0049fb54) tests modes **2|9** while the death-tail gate
accepts **9|10** (part_012.c:14761). **The mode-9 write is SYNCHRONOUS within the damage
pass** [T1 — 14761's gate reads it already flipped in the same call]: the sender one-shot
only holds because of this timing. The port deferred it to the per-frame UpdateDeathState
and a same-frame direct+splash double-lethal re-ran the death block (duplicate kill credit
— the 4-node retest catch, fixed 1b6d247 by running the transition inline at the death
tail; gotcha §25). `advancedDamageOn(+0x264)` ← the mission egg's
per-pilot `advancedDamage=` key (every stock egg stamps 1 on every page); Player update
record replicates ONLY currentScore(+0x1c8) + dropZoneLocation — killCount/deaths never
cross the 1995 wire (the port's #45 record extension mirrors them owner→replicant; benched
convergent at 4 and 8 nodes, kd4_bench/kd8_stress).
- **HeatableSubsystem** @0x50E41C: {3, "ToggleCooling"→@004ad6f8}. **Disassembled 2026-07-20**
(`tools/disas2.py 0x4ad6f8`): a per-subsystem coolant on/off TOGGLE (NOT a multi-level "cycle
priority" — that's the emergent effect, not the mechanism):
@@ -319,6 +414,182 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
Loaded ("full == the gear's seek voltage" is the arcade's own discharge algebra). Repro +
verify: BT_SEEKTEST seek-abuse -- pre-fix deadlocks at pct=0/alarm=3; post-fix 38 rescues,
ends Loaded/pct=1. `[seek]` log prints the per-gear table + rescue events.
- **EJECT/PANIC cluster DECODED + WIRED 2026-08-02 [T1 raw disasm]:** `@0049f854`
**Mech::EjectPilot** (table id 0x19; sat in the 0x49f854-0x49fa00 export gap) — gates
{msg+0xc > 0, ejectPermitted@0x414, !IsDisabled@0049fb54}, console eject notice
(`FUN_004c198c` ctor from ownerID@0x18c → network sender, code 5 — port tail),
player `suppressConsole`@0x258 = 1 (the following death must not double-notify),
`graphicAlarm → 10` (EJECT state; **kills via the ≥9 predicate** — IsMechDestroyed),
self TakeDamageMessage {inflicting=SELF, zone 1, Explosive, amount =
`ScenarioRole::killBonus` role+0x1c, burst 0}. Role layout byte-settled via the
reader `@00429bec` dest offsets + ctor record copy: +0xc inflicted, +0x10 received,
+0x14 ffPenalty, +0x18 damageBias, **+0x1c killBonus**, **+0x20
SpecialCaseDeathPenalty (4.10-only key)**, +0x28 returnFromDeath. `KillBonus`
authors NOWHERE in shipped content → charge 0, alarm does the killing — authentic.
`@0049fa1c` **EvaluateEjectPermission** (writes @0x414; unexported caller): roster
walk — 0xBBE bank coolant fraction (+0x12c/+0x128) < 0.05 (`@0049fb50`) | zero live
Generators (0xBC1, +0x40 ≠ 1 && stateAlarm@0x210 ≠ 4) | live MechWeapons
(0x511830; ProjectileWeapon family 0x5121a8 also needs weaponAlarm@0x364 ≠ 7 NoAmmo)
< mech+0x448 (**no exported writer — zero, clause inert** [T3]) | leg-gimped
(mode 3/4) novice (simLive 0). Port: mech.cpp handler+evaluator, bridges
BTWeaponCountsForEject / BTGeneratorCountsForEject / BTHeatSinkBankCoolantFraction /
BTPlayerEjectBookkeeping; input = binding-engine `Eject` action (Backspace /
pad LeftThumb; BENCH `BT_EJECT_AT=<frame>`, `BT_KILL_SUBSYS` now comma-list).
- **EJECT ARM CHAIN closed 2026-08-03 [T1 E8-scan / T2 lamp verified]:** the
evaluator's "unexported caller" FOUND — one relative CALL from
`0x004a9b6c` = **`FUN_004a9b5c` (the Mech MASTER PERFORMANCE) + 0x10: its
FIRST act each frame is EvaluateEjectPermission** (masters only; the fn sits
in the 004a977x-004ab188 export hole, located by E8 rel32 scan of the CODE
section). Downstream: `@004d196c` L4MechControlsMapper::InterpretControls
edge-detects @0x414 (cache @mapper+0x1a8) → Add/RemoveModeMask(**0x200000 =
the PANIC-ARMED mode** — the old "missionReviewMode" reading of mech+0x414
was a MISLABEL, swept 2026-08-03; the real review mode is the GLOBAL
`DAT_004fd550`, btl4pb) → `MakeLinkedLamp(ButtonPanic 0x3d, 0x200000)`
(`FUN_00476fc0`) lights the pod PANIC button; on glass the pad panel shades
the miniconsole Panic button from the same PadRIO lamp state
(L4PADPANEL:295, verified lit dark→bright on generator kill). NO gauge in
the shipped L4GAUGE.CFG rides mode 0x200000 — the eject indication is the
BUTTON LAMP, not an MFD element ("weapon-eng MFD eject lamp" reports = the
weapon page's UNJAM/EJECT ammo-jam indicator, a different feature). Lamp is
SOLID on arm (engine linked-lamp semantic); FLASH = the GaugeAlarm
`SetAlertState` overlay (RIO::flashFast), not authored for panic. Port fix:
per-frame `EvaluateEjectPermission()` at the top of Mech::PerformAndWatch
(master-gated) + `GetEjectPermitted()` accessor; `BT_EJECT_LOG=1` logs the
arm edges. Adjacent table fact: id 0x1a `DuckRequest@0049fa00` latches
mech+0x398 = 1 — that latch has NO reader anywhere in the image (vestigial).
- **THE TECHSTATUS / GAUGE-ALARM MODEL decoded 2026-08-04 [T1]:** the gauge-alarm
"condition" = a **STATUS-FLAG BIT INDEX** (0..6, TechStatusTypeCount). MechTech's
`TechnicalAssistance` edge-scans every monitored subsystem's `GetStatusFlags()`
(vtable +0x30) and on a bit flip calls `ReportStatusSet/Cleared(sink, owner, sub,
bitIndex, alarmModel)``GaugeAlarmManager::Activate(condition=bitIndex)` → the
SHARED 'mechalrm' stream's items matching that condition flash their lamps
(SetAlertState → RIO flashFast). The bit table, all byte-grounded overrides:
**bit 0** structure ≥ StatusThreshold (@004ac18c) | **bit 1** structure >
StatusFloor | **bit 2** coolantActive && HeatModelActive (CoolantLeaking) |
**bit 3** heatAlarm ≠ 0 | **bit 4** linked AmmoBin cookOffArmed (**AmmoBurning**)
| **bit 5** weaponAlarm == 5 (**Jammed**) | **bit 6** !HasVoltage (no usable
voltage; `HasVoltage` = source stateAlarm==2 Ready && |outputVoltage| > ε,
@004b0b5c). **Invite semantics:** conds 4/5 → gotoEngineering 0x80 + engEject
0x85 = the AMMO purge/unjam invite (flashes the very key whose streamed function
is EjectAmmo msg 0xB) — NEVER pilot eject; cond 6 → 0x80 + engBusMode 0x86 =
the bus-switch invite; cond 2 → the coolant loop/placement lamps (#98). The
PANIC LAMP (mode-linked, solid) is the SOLE pilot-eject indicator. **Electrical
subtlety [T1, @0049c9a8 + FUN_004b1f7c]:** the crit distributor touches nothing
electrical, and 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)
recomputes output via `(1 damage) × rated` and zeroes it. stateAlarm 4's
producer = the THERMAL BREAKER in GeneratorSimulation itself; a 2026-08-03
destruction→state-4 bridge was removed as unfounded.
- **Generator trip/recovery + the warning edge (#154 audit, 2026-08-11, all [T1] unless
noted).** Trip: heatAlarm==FailureHeat(2) → stateAlarm 4, i.e. T > FailureTemperature;
recovery gate (case-4 body @4b1f7c) = heatAlarm back to **NormalHeat(0)**, i.e.
T ≤ DegradationTemperature — then 4→0 Starting → StartTime spin-up → 0→2 Ready.
Authored generator temps (raw BTL4.RES): start=77, degradation=**1000**,
failure=**2000** on all six vehicles — recovery at exactly HALF the trip line;
manual p23 prints the half-way rule verbatim, and the heat bar's colored fill
(VertTwoPartBar @4c48fc) exists exactly while T > 1000, so "red gone" == the
recovery gate to the tick. NO hysteresis in the heatAlarm ladder (@4ad924).
**The warning edge is authored backwards in the shipped content**: BTL4.RES
streams exactly TWO AudioStateTriggers per generator on GeneratorState —
(trigState=1 Idle, inv=0, Start) and (trigState=4 GeneratorOut, **inv=1**, Start,
= fire on LEAVING 4). No enter-4 trigger exists (RES-wide census: 144/144 rows,
triple-verified decode + live [audioedge] capture). Consequences, ALL live-benched
(`scratchpad/night15/gen_edge_bench.sh`, receipts BT_AUDIO_EDGE/[audioedge] +
BT_GEN_HEAT injection): thermal trip 2→4 SILENT; natural recovery 4→0 FIRES the
warning; respawn-with-tripped-gen RTIS (4,0) FIRES it (ghost warning during
respawn); crit-death fires enter-Idle one frame late; 4→1 (cooled while switched
off) double-fires. The generators' inv+Start is the ONLY inverse-Start fault
trigger in the game (census) — a 1995 authoring slip [T4 intent]. Port machinery
is faithful end-to-end. **DELIBERATE DEVIATION shipped 2026-08-11 (user-approved):**
the AudioStateTrigger ctor (AUDWTHR.cpp) flips the GeneratorState (trigState=4,
inv=1) record to inv=0 at stream time — warning now fires at the TRIP, silent at
recovery/respawn; `BT_GEN_WARN_SHIPPED=1` restores shipped behavior. This is the
port's ONLY authored-content override (precedent: the self-hit score deviation).
**The warning IS a VOICE — CORRECTED 2026-08-11 (a same-day wrong claim swept):**
the Warnings01/AllWarning zones are the project's recorded voice material (Yip,
original VWE) and the AudioControlSequences chain the zone-WORDS into spoken
phrases — established by the #119 leak-voice work (commit 42b0691: "the warning
is a phrase-sequenced voice patch (Warnings01 zones as notes)"). The generator
sequence (notes 29/16/26 = zones z4/z0/z3) is the spoken "generator out" phrase —
**ear-verified 2026-08-11** (zones played in sequence order, rate-patched copies) [T2]. An earlier revision of this entry claimed BT 4.10 shipped no speech and
attributed era memories to Firestorm — WRONG: the claim rested on preset NAMES
and was contradicted by our own git history. With the voice real, era memories
of hearing it at the trip may be genuine (possibly a later floor-content
revision fixing the same bit our deviation flips).
⚠ Process lesson: before asserting an asset ABSENT, grep the project's own git
log and prior tickets for the asset family (#119 was one `git log --grep=voice`
away; six audit agents and the synthesizer all missed it).
Voice-steal note: night-15 field logs show up to 932 channel steals/session — even
fired Starts can be eaten at combat peak (L4AUDRND weight-steal).
Diag hooks: BT_GEN_HEAT=<units/s> (+_DELAY/_SECS) injects into viewpoint
GeneratorA (thermalMass=726000 raw units — rate ~1.5e8 for ~18-25 s trips it);
BT_AUDIO_EDGE=1 prints every state-edge trigger delivery FIRED/no-fire.
Related corrections: AmmoState 3 = EJECTING (an earlier lane's "cook-off enter-3"
label was wrong — cook-off warning rides FireCountdownStarted matchers); manual
p23 also answers #133: the MFD X = generator destroyed by CRIT, not overheat.
- **THE CONDITION FLAGS ARE ROUTINE, NOT ALARMS — field-verified 2026-08-08 [T2]:** a
`[techstat] <sub> condition <n> SET` is an *operating* flag ("this component is above
nominal / browned out RIGHT NOW"), not a fault report. Census over a full Steam match
(`steam_20260806_c_michael_XIAOLONG.log`): cond 3 **Overheating** fires 33× on LLaser_2,
31× LLaser_1, 26× SRM4, 18× PPC_2 — every weapon trips it on each volley and clears it on
cooldown; cond 6 **BadPower** fires 8× on Myomers, 3× each on SLaser_1/PPC_1/MLaser_1 as
the bus browns out under simultaneous draw. **Every subsystem's SET/CLEARED counts are
balanced** (GeneratorD 5/4, GeneratorA 8/7, Myomers 5/4, Condenser5 1/1 — the odd extra SET
is only the log ending mid-heat). Nothing latches. **Never read a post-respawn `condition 3
SET` as a reset failure** — that inference cost a full two-sided reset audit (#137 below).
Diagnose a heat complaint from the SET/CLEARED *balance* and the throttle state, not from
the presence of a SET.
- **#137 "respawn came back with MYOMERS heat MAXED" = NOT A BUG, closed 2026-08-08 [T2]:**
`Mech::Reset` is clean on both sides — bench: every roster subsystem incl. all six
Condensers at `T=77 start=77`; field log: every live condition CLEARs 1216 lines after each
reset. The heat is real and immediate because **the mech respawns still under power**: at the
reset the log reads `thr=1`, `cycleSpeed=14.6`, gait state 12 (running), and the myomers →
Condenser5 → GeneratorD trip Overheating within ~12 s. `Mech::Reset`'s subsystem loop starts
at **index 2** and the ControlsMapper is **index 0**, so the throttle is never reset — and the
binary does the same, which is correct for a pod whose throttle is a PHYSICAL lever still
under the pilot's hand. **Desktop caveat (real, and separate):** the glass bridge emulates
that lever with a file-static ramp accumulator `sLever` (mech4.cpp:3250), zeroed ONLY by the
X all-stop and a direction-crossing snap — so a pad/keyboard pilot, who is physically holding
nothing, likewise respawns at speed with the lever state invisible. Port-layer question, not
a heat bug.
- **DEATH SCORE COST decoded 2026-08-02 (#118 tail) [T1]:** the death handler tail
`@004c07cd-0x4c0828` (inside the @004c05c4 export gap — missing from the #52
reconstruction) gates on `advancedDamageOn`(+0x264) and hands the ENGINE base
`Player::ScoreMessageHandler` (`@0042da20` == PLAYER.cpp:138) a type-1 ScoreMessage
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 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:
`@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):
`slice = (till.ticks this[4].ticks) / DAT_0052140c; this[4] = till;
(*this[7])(this, slice)` then watchers (`FUN_0041c08c`) + WriteSimulationUpdate
(`FUN_0041bd98`) — **VARIABLE-STEP, slice in real SECONDS**, line-for-line the
WinTesla SIMULATE.cpp body. Corollaries: every Performance's `time_slice` is
seconds in BOTH binary and port (no hidden tick/second unit gap); a dt-LESS
per-frame term (myomer kinetic @4b8d18's middle term; the Mover gravity
`-= **(mover+0x250)` @0x421e77) fires at the FRAME cadence = the tick rate,
28 Hz nominal, sagging under load. Mover velocity is u/s (the replicant
dead-reckoner @0x421f7c does `pos += vel × ticksΔ/DAT_0052140c`). The myomer
heat term's ONE caller is the **unexported MyomersSimulation body
@0x4b8b9a-0x4b8d0d** (E8-scan + raw disasm; the export gap hid it): calls
@4b8d18 once per Perform, raw slice, gated `measuredVoltage > 0` only — plus
a 0.011/Perform repair trickle at alarm 1 and `speedEffect(+0x31c) =
AvailableOutput(min(measuredV, seek gear, cap)) / owner->runSpeedBase(+0x34c)`.
- **AMMO COOK-OFF cluster DECODED 2026-07-25 (issue #46, raw disasm `scratchpad/disammo.py`) [T1]:**
`@004bd394` AmmoBinSimulation (arm on heatAlarm FAILURE / detonate / cancel-on-Empty);
`@004bdb94` HandleMessage(1) = crit-induced arm; the FUSE = `Now().ticks +
@@ -351,6 +622,20 @@ 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**
@@ -409,6 +694,13 @@ default-ON (`'0'` disables).
| Var | Effect |
|---|---|
| `BT_FORCE_THROTTLE` | auto-walk forward (no key) |
| `BT_BTNTEST=addr,on,off` / `BT_BTNTEST2=...` | scripted screen-button press/release cycles at poll counts (the REAL RIO seam; 2nd cycle for e.g. squat→rise) |
| `BT_DUCK_LOG` | crouch diagnostics: RIO press mode-mask, SetLegAnimation re-arm tracer (+ra), 1 Hz joint probe, posture/squat events |
| `BT_TREE_LOG` | dump the built render-tree topology (segment/joint/parent, SITE rows) at mech build |
| `BT_SPOT_SELF` | bench-only: build the searchlight cone on the OWN-cockpit tree so `BT_CAM=face` can eyeball it solo |
| `BT_CAM=face` | chase camera in FRONT looking back (the old animation view); `BT_CAM_Y/Z` offsets |
| `BT_FOG_LOG` | log SetFogStyle transitions + fog page resolution (searchlight swap forensics) |
| `BT_GIMP_SPEED` | override the gimp-gait demand scale (1.0 = authentic) |
| `BT_SPAWN_ENEMY` | spawn a target mech 120u ahead along the spawn facing |
| `BT_AUTOFIRE=1` | hold the trigger (headless walk→fire→death harness; supersedes the dead `BT_FORCE_FIRE` — btl4main.cpp:310 set `fireForced` once at startup, but mech4.cpp:1567 unconditionally overwrites it every frame) |
| `BT_ASSERT_TO_DEBUGGER` | route CRT asserts to the debugger, not a modal box |
@@ -431,7 +723,7 @@ default-ON (`'0'` disables).
| `BT_AIM="x y"` | pin the reticle crosshair (reticle coords) — headless aim harness |
| `BT_AIM_LOG` | `[pick]` ray/box/hit diagnostics (Mech::PickRayHit) |
| `BT_FIRE_ARC=<deg>` | OPT-IN external-camera fire-arc clamp (unset = authentic no-arc) |
| `BT_START_INSIDE` | begin in the cockpit view (V toggles) |
| `BT_START_INSIDE` | begin in the cockpit view (BACKTICK toggles; 'V' = the pod rear-view hold since #68 — the toggle skips bound keys, L4PADRIO.cpp:681) |
| `BT_FORCE_MODEL=<name>` | force the player mech (`madcat`/`owens`/…; btl4mssn.cpp — per-mech cockpit bring-up) |
| `BT_HIDE_COCKPIT` | hide the `*_cop` canopy shell (SHOWS by default — see [[cockpit-view]]) |
| `BT_COP_*` / `BT_EYE_FWD=<f>` | cockpit canopy + eye diagnostics (`FRAME` (unlit frame colour override, default 0.13,0.12,0.15) / `SINGLE` (single-sided diag; double-sided is default) / `PLATES=1` (disable the punch stencil-cut kit) / `FLIP` / `DEBUG` / `DUMP`; eye forward-push probe) — [[cockpit-view]] |
@@ -501,7 +793,9 @@ ex-"clipLoadGuard"). Byte tooling: `scratchpad/dis_4b2980.py` / `dis_range.py`
`reference/decomp/section_dump.txt`.
**ARMOUR-DARKENING addresses** [T1] (#87; full story: [[rendering]]): `.DZM` res compiler
`FUN_0041e4e0` (part_002.c:7341, reads the `material` key list per `[dz_*]` section); runtime
address UNKNOWN — the old citation `FUN_0041e4e0` was WRONG (that function, part_002.c:7337,
is `DamageZone::TakeDamage`: `level += amount×scale[type]`, byte-matches DAMAGE.cpp:379 —
verified 2026-08-02, swept here + [[rendering]]); runtime
consumer inside `BTL4VideoRenderer::MakeMechRenderables` **`FUN_004cef28`** (part_014.c:5190-5272);
per-zone node ctor `FUN_00455c7c(...,0.04f)`; the per-material watcher
**`FUN_004573e4(material, &zone->damageLevel, 0.1f)`** (part_007.c:9133, vtable `PTR_LAB_004f1730`,
@@ -538,6 +832,22 @@ Respawn SIM path: `DropZoneReply` `FUN_004bffd0` → `Mech::Reset` @0x4009fb74;
---
## 7. Tools
-**CITATION POLICY (since the 2026-08-06 re-export): cite `@ADDR`, never `part_0NN.c:LINE`.**
Addresses are stable across exports; shard membership and line numbers are NOT. Pre-08-06
part/line citations resolve only against `reference/decomp/archive_2025export/`.
-**Ghidra renders member access as int-ARRAY indices** (`param_1[0xfe]` == byte offset 0x3f8,
`[0x1e7]` == 0x79c): grepping the export for an offset STRING misses every such site. Divide
by 4 (or grep both forms) — this is a live instance of gotcha §20.
- `tools/gapcensus.py [dir]` — the #60 dark-region census: index/export coverage, function-start
discovery, TU attribution, repo-citation flags → `<dir>/GAP_CENSUS.md` + `gap_census.tsv`.
Consult BEFORE any "absent from the export" claim (gotcha §20).
- `tools/ghidra_reexport.sh [reprocess]` — headless re-export: Ghidra 12.1.2 + JDK 21 (installed
in `%LOCALAPPDATA%\bt411-tools` beside DXSDK/cmake) run `reference/ghidra_scripts/ExportGaps.java`
(ExportAll's output contract + the dark-region gap fill). ⚠ the runner MUST use 8.3 SHORT paths
(`JOELAP~1`, `BT411-~1`): Ghidra's launcher .bat expands `%JAVA_HOME%` UNQUOTED and the user
profile has a space. `reprocess` re-runs the script on the saved project (skips re-analysis).
- `tools/gapdiff.py [old] [new]` — score two censused exports (coverage/dark deltas, which
previously-dark addresses the KB already cites, celebrity check).
- `tools/disas2.py <VA> [len]` — capstone disassembly of BTL4OPT.EXE at a VA (recovers x87 math
Ghidra drops; folds known call targets + float constants). THE tool for any `FUN_` the
+3 -1
View File
@@ -42,7 +42,9 @@ then unconditionally `+0x264 = +0x268 = mission->advancedDamageOn(+0xf0)` (both
## What each flag gates (binary consumers, all reached via `mech+0x190` → BTPlayer) [T1]
- **+0x25c — "sim live", off only for novice.** Consumers: ballistic jam roll `CheckForJam`
- **+0x25c — "sim live", off only for novice.** Consumers: the CROUCH posture selector
(master-perf @0x4a9f70: novice → mapPosture 0 → squat/rise refused — 2026-08-06,
[[locomotion]] §CROUCH); ballistic jam roll `CheckForJam`
@4bbfcc early-returns NO-JAM when 0 (projweap.cpp calls this `LiveFireEnabled`); **ThermalSight**
`ToggleLamp` @4b860c (thermalsight.hpp `ControlsAllowLights`) — ⚠ **CORRECTED 2026-07-25 (#61):
this was listed as *Searchlight's* gate. It is not. Raw disassembly of Searchlight's real handler
+41 -1
View File
@@ -11,7 +11,7 @@ open_questions:
- "SeekVoltageGraph RECONSTRUCTED 2026-07-19 (Gitea #11, was #10 finding A): full widget landed (see §SeekVoltageGraph below) -- ghosts gone steady-state (BT_PRESET_HOLD verification); remaining polish: a 1-frame transition artifact when a BT_SHOT lands on the exact page-switch frame (label BecameActive vs the graph's next rated Execute -- same lag class as the binary; self-heals next frame)"
- "Secondary-view cycling RESOLVED 2026-07-19 (Gitea #6): the selector is the DISPLAY mode (CycleDisplayMode -> vtbl+0x4C override @4d1ae4), NOT CycleControlMode; desktop 'N' / pad RightThumb wired; pixel-verified dama->crit->heat"
- "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, unreconstructed); plus 0x13 = Mech DuckRequest (crouch), 0x28 = Mech BalanceCoolant, 0x12/0x14 = ThermalSight/Searchlight toggles -- see pod-hardware.md + docs/GLASS_COCKPIT.md 2026-07-20"
- "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)"
---
@@ -414,6 +414,14 @@ Verified live: bay fire → lamp 0xD (the LRM's select button) flashes 0x37 + en
on detonation/purge. Diagnostics: `BT_LAMP_LOG``[techstat]`/`[galarm]`/`[lamp]`. Details +
the four load-bearing fixes en route: [[open-questions]] + [[decomp-reference]] §GaugeAlarm.
**These conditions are ROUTINE and SELF-CLEARING [T2, field-verified 2026-08-08].** A `SET` is an
operating state, not a fault: every laser volley trips Overheating (cond 3) and clears it on
cooldown (33× on one LLaser in a single match), and BadPower (cond 6) flickers whenever
simultaneous draw browns the bus. Across a full match every subsystem's SET/CLEARED counts are
balanced — nothing latches. So a lamp flashing after a respawn is the mech *operating*, not a
failed reset; diagnose from the SET/CLEARED balance, never from a lone SET. Full census + the
#137 post-mortem it settled: [[decomp-reference]] §TechStatus.
## ConfigMapGauge (the weapon panel's trigger-config joystick) — LIVE via LinkToEntity (2026-07-21)
The per-weapon btjoy.pcc joystick image + 4 cm_* state lamps (off/other/only/both) showing,
for each mappable fire button (Pinky/ThumbLow/Trigger/ThumbHigh), whether THIS panel's weapon
@@ -549,6 +557,38 @@ and every instrument is now live [T2]:**
transcription color bug caught by a period reference screenshot, 2026-07-09; same for the
bottom bowtie carets @4569-4570); pegs at 1200 with no target; the DISPLAYED range slides at
**500 m/s** toward the true pick range (HudSimulation :5652 [T1]).
**The HudSimulation tuning constants, read off .rdata 2026-08-08 [T1]** (`section_dump.txt`
rows ` 4b7ec0 8be55dc3 0000403f 0000803f 0000c842` / ` 4b7ed0 00000000`) —
`_DAT_004b7ec4` = **0.75f**, `_DAT_004b7ec8` = **1.0f**, `_DAT_004b7ecc` = **100.0f**,
`_DAT_004b7ed0` = **0.0f**, `_DAT_004b7f90` = **0.0f**. ec4/ec8 are the fire-control **LOCK**
limits (own HUD host zone < 0.75 damage, targeted zone < 1.0 — so a shot-up cockpit drops to
"target held, no lock", and a dead zone can't be re-locked); ed0 is the shared **zero** in the
range-slide `Abs()` idiom (the 500 is an immediate `0x43fa0000`, NOT a global). `hud.cpp` had
carried all five as 0.0f/500.0f stand-ins under guessed names ("SegmentTempLimit … heat
threshold for HUD page visibility" was neither heat nor page visibility) — corrected in place,
with the live lock/slide implementation staying in mech4.cpp's targeting step, which had both
thresholds right all along.
**THE CARET CAN DIE FOR THE WHOLE SESSION — #147, fixed 2026-08-08 [T2 code, T3 field link]:**
`sShownRange` (mech4's targeting step) is a **function-level static** — one cell per process,
shared by every mech, carried across drops. **NaN is absorbing** in `step = trueRange
sShownRange; sShownRange += step`, and neither the producer's clamps nor
`BTReticleRenderable::Draw`'s (`range < minRange` / `range > maxRange`) catch it — both
comparisons are **false for NaN**. A poisoned value therefore reaches `AddPoint`/`ConcatMatrix`,
and the caret + its bar become **degenerate geometry that stops rendering while the static tick
marks keep drawing** — the exact reported symptom ("no range finder on this drop", ticks there,
caret gone), sticky until relaunch. Fixed four ways: re-seed on mech change (not on respawn —
that reuses the entity, and the binary doesn't reset the readout either), a producer NaN trap, a
NaN-safe consumer clamp falling back to the 1200 peg, and — the reason no log could settle it —
**`BT_RANGE_LOG` now prints the caret's actual input** (`[range] caret input shown=`). It never
did before: `BT_RANGE_LOG` instrumented the PICK (#4) and `[target]`'s `range=` is a *separate*
locally-recomputed Sqrt in the weapon-range check, so grepping field logs for NaN found nothing
because the poisoned variable was never printed. Causal link to the field report is INFERENCE.
**GAP — the 100 m RANGE BIAS is NOT reconstructed [T1 read, unimplemented]:** HudSimulation
subtracts `_DAT_004b7ecc` (100.0f) from `RangeToTarget@0x1EC` **every frame while the timed
flag @0x22C is set**, accumulating @0x21C by `time_slice` until it reaches @0x1D8, then clearing
both. The port's targeting step (mech4.cpp) does the slide but never the bias, so whatever
in-game state sets @0x22C currently produces no range offset. Trigger for @0x22C not yet
identified — see [[open-questions]].
**VERDICT (Gitea #4, 2026-07-20): the "range slides in/out crazily while walking" report is
AUTHENTIC behavior, not a bug [T2 measured].** Per-frame `BT_RANGE_LOG` traces (mech4.cpp, with
an independent Möller-Trumbore cross-check `BTGroundRayHitExact` in btvisgnd.cpp) on scripted
+34 -1
View File
@@ -73,6 +73,7 @@ that one answer. Precedence, highest first:
| Env | Layout | Buttons live in |
|---|---|---|
| `BT_GLASS_PANELS`≠0 | per-display cockpit windows (the "exploded" view, `L4GLASSWIN`) | each display's own window |
| `BT_POD_SURFACES`=1 | **POD MODE** (2026-08-06): crop each panel window to its SURFACE (MFD 640×480, radar 480×640), drop the on-screen button banks + the Flight Controls pad, frameless. For REAL pod glass, where the buttons are physical — see [[pod-hardware]] §MFD PANELS. Per-window: `,bare` in `glass_layout.cfg`. | 6 bare surfaces |
| `BT_DEV_GAUGES_WINDOW` | the legacy separate MFD window | the single combined pad panel |
| `BT_DEV_GAUGES_DOCK` | the docked bottom gauge strip | the single combined pad panel |
| `BT_COCKPIT=0` | ...also the docked strip (the documented opt-out) | the single combined pad panel |
@@ -155,6 +156,26 @@ want bare. From then on those windows are frameless **and pinned** — with no c
nothing to drag them by, which is the point: the arrangement is finished. Delete the flag to get
the frame back.
### `monitor:<name|index>` — bind a window to a DISPLAY, not to pixels (2026-08-06) [T2 live]
Instead of an `x,y,w,h` rect, a line may name a display device:
```
Secondary / Radar=monitor:DISPLAY4,bare
VGA Port A=monitor:DISPLAY2,bare
```
The resolver (`ResolveMonitorSpec` + `MonScanProc`, `L4GLASSWIN`) matches the full device name OR
its tail, so `monitor:DISPLAY4` works without typing `\\.\`; a bare integer is a 0-based index in
enumeration order. The surface is CENTRED on that monitor. This is what makes a fixed rig
survivable: **desktop rects move** when a display is re-detected, a USB graphics adapter
re-enumerates or someone changes a resolution, and a pixel rect then silently puts a panel on the
wrong glass — a device binding does not. Required for [[pod-hardware]] §ALPHA-MR, where a
mis-bound window means a picture on the wrong CRT with no error anywhere.
`,bare` **implies frameless** (it used to need `,noframe` too, and a "bare" window came back
656×519 WITH a caption — a real bug found on the cab).
Options are comma-separated after the numbers and **unknown ones are ignored**, so an older build
reading a newer file loses the option but never the line — the `bindings.txt` grammar rule
applied here.
@@ -182,6 +203,15 @@ 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.
**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
a rig with no marquee wired, leaving the variable unset is NOT enough; the profile fills it back
in and a plasma window lands on the cab's glass. The `NONE`/`OFF`/`0` spelling nulls the string
before the branch, so no external display is constructed at all. The boot banner reads
`GLASS (PadRIO; plasma off [L4PLASMA])` when it takes, and the absence of the
`[plasmawin] desktop plasma display up` line is the runtime proof.
**Verified 2026-07-26 [T2]:** `BT_RIOBANK_LOG=1` dumps every bank's rects;
`scratchpad/checkbank.py` reports the per-bank census and proves no address is SHADOWED (has a
point no earlier button covers); `scratchpad/clickbank.py` then posts a real click at every
@@ -213,7 +243,10 @@ window.
- **World aspect** under the letterbox is the view rect's OWN aspect — the client no longer
enters into it.
- **`-fit`** (alias `-windowed-fullscreen`): borderless `WS_POPUP` over the monitor, canvas
letterboxed inside. Verified on a 3440×1440 ultrawide.
letterboxed inside. Verified on a 3440×1440 ultrawide. **`BT_FIT=1` is the env spelling**
(2026-08-06) — same flag, settable from `environ.ini`, so a fixed rig keeps its borderless main
view however it is launched instead of depending on one launcher's command line. Targets
`MONITOR_DEFAULTTOPRIMARY`, i.e. the PRIMARY display. [[pod-hardware]] §ALPHA-MR
**Ordering trap found live:** the letterbox flag was first set at device creation, but the
first `WM_SIZE` arrives BEFORE the device exists — a `-fit` boot logged `aspect=3.14` (the
+79
View File
@@ -273,6 +273,43 @@ 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
@@ -311,6 +348,48 @@ resolved with gait v5, no shadow code changed — though (a) may have contribute
T4]; (c) genuine slope burial when the quad is flat/mis-tilted (the depth-bias margin is finite).
Check `[shadowobj]` tag lines and `[sync]`/`BT_SYNC_LOG` before touching bias/tilt.
## CROUCH -- COMPLETE 2026-08-06 [T1 decode / T2 full-cycle + MP benched]
The manual's CROUCH button (streamed 0x13; glass key F4), end to end: DuckRequest (mech id 0x1a
@0049fa00) latches duckState@0x398 -> the master posture block (mech4, from the dark master-perf
@0x4a9cf0-0x4aa0af): posture selector @0x3f8 (mode 9/10/2/3/4 block, NOVICE simLive lockout,
myomer-alive gate) -> standing: SetLegAnimation(2) ('sqd', squat down; LegClipFinished case-2
PARKS the pose + leg alarm 1) / ducked: SetLegAnimation(3) ('squ', rise; case-3 alarm 0);
stability alarm flips; ForceUpdate(8)+(1) ships the type-3 state record -- peers pose the squat
for FREE (screenshot receipt: the crouched crimson madcat from the observer's cockpit,
duckmpA_031). Clip data verified from BTL4.RES (root joint drops -2.22, knees +-1.14; squ/sqd
are exact mirrors; interior 'squi/sqdi' variants ship for all 8 chassis). Full solo cycle
benched (BT_BTNTEST + BT_BTNTEST2): squat -> hold (joints steady for minutes) -> rise ->
standing zeros. Myomer factor: LIVE, not deferred -- the 2026-08-05 "feeder unreconstructed" claim was an
export-gap-blind grep (the members are NAMED: speedEffect@0x31C, myomers.cpp; the chain walk +
speedDemand scale + turn freeze = mechmppr.cpp:990, the 2026-07-31 seek audit, same bytes).
mechmppr now publishes the chain MAX into mech->myomerEffectiveness (the binary's +0x79C home)
so the crouch gate reads the live value: dead/overheated myomers (the Oracle seek-4 freeze)
refuse the squat AND the rise.
The pilot's EYE drops too -- the task-#15 bob channel (gBTEyeBobY <- jointlocal.y each frame)
carries the parked -2.22 into DPLEyeRenderable (log receipt bob=-2.2187 through the hold; frame
receipt duckcp_030 = grass-level view). A false "eye does not drop" residual was briefly filed
2026-08-06 -- measurement error (canopy-dominant diff crops; the canopy drops WITH the eye, so
the interior looks unchanged and only the through-window ground shifts). Bench traps for reruns: MP button delivery can miss if pressed
during round-start jitter (press >= poll 900 after GO); diags all BT_DUCK_LOG-gated.
### CROUCH addendum -- the #60 re-export pass (2026-08-06) [T1]
The rebuilt export (93.5% coverage) produced pseudocode for the master-perf region this block
was hand-disassembled from, and it **confirms the transcription field for field**:
`param_1[0xfe]`=mapPosture(0x3f8), `[0xe6]`=duckState(0x398), `[0x161]`=squatCapable,
`[0x1e7]`=myomerEffectiveness(0x79c), `[0x10]`=movementMode, `player+0x25c` novice gate,
`FUN_004a7fc4(2|3)`=SetLegAnimation sqd/squ, `FUN_004a4c54(8)+(1)`=ForceUpdate, `+0x131`
stability alarm, duckState cleared after. (Ghidra renders these as int-ARRAY indices --
`[0xfe]` == byte 0x3f8 -- which is why an offset-string grep of the export misses them; see
[[reconstruction-gotchas]] §20.)
**It also caught a branch the raw pass missed** (the hand disassembly stopped at `0x4aa0af`):
an **AIRBORNE AUTO-RISE** immediately after the duck consumer --
`if ((movementMode == 3 || movementMode == 4) && legState == 1) -> SetLegAnimation(3) +
ForceUpdate(8)+(1) + stability 1`. A mech that leaves the ground (jump/knockback) while PARKED
in the squat is forced upright; unconditional on duckState/squatCapable, i.e. a safety
un-crouch rather than a request. Implemented in mech4.cpp the same day; crouch re-benched
un-regressed (squat still holds on flat ground, no spurious rises).
## Key Relationships
- Detail: `docs/P3_LOCOMOTION.md`. Uses: [[asset-formats]] (SKL/ANI), [[decomp-reference]] (offsets).
- Feeds: [[combat-damage]] (collision→damage), [[rendering]] (shadow/visual-conform).
+28 -3
View File
@@ -70,6 +70,13 @@ emulator** (⚠ `NotationFile::ReadText` expects NUL-SEPARATED lines). [T2]
console LAUNCH). SIX bugs fixed to get here (dead-reckoner install, replicant-motion DeadReckon,
master emission threshold, emission gated on RunningMission, the console-must-LAUNCH fact, replicant
validity). [T2]
- **Zone-level replication (#87, 2026-08-03):** an observer's copy of a mech learns zone damageLevels
ONLY from zone update records, and the master sends those on BAND-THRESHOLD CROSSINGS (binary effect
watcher `FUN_0042aa2c`: `DescriptorCrossed` + master gate → `ForceUpdate(DamageZoneUpdateModelFlag)`)
plus every graphic-state change — never per hit. Damage messages do NOT echo locally (replicant
`Dispatch` forwards to the master and returns). The port had only the gstate branch (observers saw
0.0 until destruction); the level branch was restored + 2-node verified (replicant peaks == master
finals to 4 decimals). Full mechanism: [[combat-damage]] §Zone-LEVEL replication. [T1/T2]
- **Wire-format bug class found+fixed:** MakeMessages replicate RAW over TCP, so string payload must be
INLINE (`char[N]` at the binary offsets), not a `const char*` pointer (garbage cross-pod). Check
EVERY MakeMessage for pointer payloads. [T2]
@@ -874,8 +881,13 @@ transition, HUD all landed since P6): console egg → mesh → RunningMission on
REMAINING note is closed.
5. ✅ **2-window driving — RESOLVED (tasks #48/#51).** Keyboard MP turning was restored at the root
(the `pilotArray[1]` shrunk-span overrun fix, task #51); `BT_KEY_NOFOCUS` exists for automated
harnesses. DEATHS scoring lights via the existing BTPostKillScore MP branch. Respawn is
reconstructed (task #52 — see item 3b).
harnesses. DEATHS scoring lights via the kill/death report flow (2026-08-05: now the AUTHENTIC
report tail in the victim's TakeDamage handler — BTMechPostCombatReports, replacing
BTPostKillScore; same cross-node reroute, see [[combat-damage]] §report tail). Respawn is
reconstructed (task #52 — see item 3b; the VehicleDead sender moved to the same tail).
CROUCH and the SEARCHLIGHT replicate with ZERO new wire code (2026-08-05/06): the squat rides
the existing type-3 state record (legState) and the lamp rides subsystem record 0x14
(lightState) — [[locomotion]] §CROUCH / [[rendering]] §SEARCHLIGHT.
## Key Relationships
- Base: [[wintesla-port]] (L4NET). Depends on: [[locomotion]] (update writer), [[combat-damage]]
@@ -895,4 +907,17 @@ 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).
After the coupled body-channel peer landed (96a896a + turn-step f094d78 + cadence-mirror 23f1532), residual peer shakiness on accel/decel was RANDOM -- sometimes perfect, sometimes shaky. Root cause is the TEST RIG, proven with BT_RXJIT (record inter-arrival ms on the peer): two Debug btl4 nodes on ONE box contend for CPU, so Windows BATCHES their TCP delivery -- update records arrive in bursts (min ~0ms back-to-back, max 56-226ms gaps, burstiness max/avg 3-7x) instead of an even ~17ms. A peer dead-reckons across the long gaps then snaps when a record lands -> random shake. Pinning each node to a DISJOINT core set (ProcessorAffinity 0x00F / 0x3C0) restored even ~17ms delivery (burstiness ~1.0) and the shakiness vanished (user-confirmed). REAL pods are dedicated machines with no contention -- they never see this. DO NOT add an interpolation/jitter buffer or other un-authentic netcode to mask a single-box artifact. Use tools/mp_launch.sh (bakes in the affinity) for all 2-node tests. Diagnostic env: BT_RXJIT (arrival jitter), BT_SLIDE ([slide]/[mslide] slide-in-stand), BT_GAITEV, BT_MIRDIV, BT_NO_MIRROR_CAD / BT_PEER_LEGCH (revert the coupled-peer fixes).
## K/D authority + the scoreboard mirror — STRESS-PROVEN at 4 and 8 nodes (2026-08-11) [T2]
The scoring authority model (full map: [[combat-damage]] + `docs/KD_CHAIN_AUDIT_20260811.md`):
the VICTIM's master emits everything on its death edge (kill credit to the shooter via
replicant-Dispatch reroute, received penalty to itself, one VehicleDead); the killer's master
increments `killCount` when the rerouted credit lands; **killCount/deathTally never crossed the
1995 wire** — the port's #45 update-record extension mirrors them one-way owner→replicant
(read guarded replicant-only, mixed-version length guard, 2s heartbeat re-dirty). Bystander
nodes learn tallies ONLY through that mirror. Verified: `kd4_bench.sh` (bystander convergence +
no-mirror-exceeds-authority, receipt-derived per player) and `kd8_stress.sh` (8-node all-vs-all
melee, 26 deaths: 26/26/26/26 with zero duplicate-death swallows and zero `inst=R` deliveries).
The same-frame double-credit this closed (direct+splash lethal pair racing the deferred death
transition) is gotcha §25 — the death mode now flips synchronously inside the damage pass,
matching part_012.c:14761.
+134 -19
View File
@@ -22,6 +22,20 @@ authentic path scoped.
further assets exist; the runtime `BTL4.RES` with 8 maps + full anim set is the master we have).
- Pod specifics for Phase 8: the 7-monitor driver setup, the RIO cockpit I/O protocol, current
Win10+wrapper pod config. Status: OPEN.
- **The i860 DIVISION-CARD firmware / DIV render docs (the material→pixel response curve).**
Established 2026-08-03 (#87 dig): the HOST binary lerps 13 material colour floats linearly
toward ×0.1 by zone damageLevel (watcher @004573e4/@00457784, 0.1 literal at the
MakeMechRenderables call site) and then just **marshals the material to the card**
(`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
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
steeper than linear, mid-level creep damage read stronger on the pods than in the port.
ASK: div-card firmware image, DIV/dpl render docs, or period screenshots showing a
known-damage-level mech. Status: OPEN.
- **Did the pod throttle quadrant have MECHANICAL detents** (how many / lever positions)? The
software path is notch-free [T1, [[pod-hardware]]], but the gait SM's walk/run dead band
([[locomotion]]) only makes sense if the hardware discouraged parking the lever mid-band —
@@ -210,16 +224,22 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
⚠ This entry was stale in BOTH directions for weeks (`docs/INPUT_PATH_AUDIT.md` flagged it) —
most of the list had been reconstructed while the prose still called it dead. Check the code,
not the census, before reconstructing anything here.
- **`DuckState` has no CODE consumer, and that is authentic [T1].** `Mech::DuckRequest` sets
`duckState`(attr 0x37, binary `mech+0x398`) to 1 and nothing else: the flag has exactly two
writers in the whole binary (that handler and the mech reset) and ZERO readers. Its consumer is
a DATABINDING — `content/GAUGE/L4GAUGE.CFG` drives a 3-frame `bduck.pcc` widget off `DuckState`
on the map's legend column, verified live (the crouch icon lights grey→orange on a press). So
the button is COMPLETE as a request flag + indicator. What is NOT known: whether the 1995 game
ever consumed it for posture/collision (no SQUAT clip name survives in the decomp or in
`content/`, only `DuckServo01.wav` in AUDIO1.RES). Do not invent a crouch pose to "finish" it.
- **Searchlight-driven fog swap — STILL DEFERRED, but the "ORIGINAL 1995 LATENT BUG" premise is
❌ RETRACTED (2026-07-25, #61).** The arcade swaps fog between `fog=` (lights on) and
- **❌ RETRACTED (2026-08-06): "`DuckState` has no CODE consumer, and that is authentic [T1]".**
That verdict — and its corollary "no SQUAT clip survives; do not invent a crouch pose" — was
EXPORT-GAP BLINDNESS ([[reconstruction-gotchas]] §20, incident 2), and the [T1] tag was
unearned: "ZERO readers" was true of the *export*, not the binary. The reader is the master-perf
posture/duck block in the un-exported region (@0x4aa011-0x4aa0af, raw disasm), and the squat
clips DID ship — `<pfx>squ/sqd` + interior `squi/sqdi` for all 8 chassis live in **BTL4.RES**
(the old claim searched decomp strings + loose `content/` files, never the RES TOC;
`DuckServo01.wav` was the tell). CROUCH is now fully reconstructed — request latch posture
arbiter (novice + myomer + mode gates) → squat/hold/rise, MP-replicated on the type-3 record
([[locomotion]] §CROUCH). The `bduck.pcc` legend databinding observation stands and still works.
- **✅ Searchlight-driven fog swap — DONE 2026-08-05 (412053d/b75bb4a; [[rendering]] §SEARCHLIGHT).**
The port's `TickSearchlight` (btl4vid) is the `PullFogRenderable` equivalent: LightOn →
`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 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 1995 binary itself could never light the lamp. That was wrong** — it
compared Searchlight's Performance (@004b841c, reads `requestedOn`@0x1E0) against **ThermalSight's**
@@ -229,15 +249,10 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
un-pooled `"ToggleLamp"` strings adjacent to their own class names. Body: @004b838c sits in a Ghidra
export gap (#60) but was **recovered by raw disasm** (`scratchpad/dis838c.py`) — it toggles 0x1E0 and
carries no novice gate. All T1.]
Searchlight's handler set is now WIRED and **verified live**: pad `0x14``requestedOn 0→1`
`lightState 0→1`. So the sim needs **no repair** and the previous "DECISION: faithful to the buggy
original" is void — a working fog swap is now plain FAITHFUL reconstruction. Remaining work is a
single item: construct `PullFogRenderable` at btl4vid.cpp `MakeMechRenderables` reticle-build/inside
pass (== arcade part_014.c:5173, Dynamic, bound per Searchlight `lightState` via a new
`LightStatePtr()` accessor). `ControlsAllowLights()` is WIRED since issue #2 to the `player+0x25c`
not-novice experience flag via the BTPlayerExperienceSimLive bridge ([[experience-levels]]).
See [[rendering]] fog section. (The pre-#61 "verified inert live: BT_FOG_LOG zero `SetFogStyle(2/3)`"
observation still holds — reason (1), the un-constructed renderable, remains.)
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 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
(2026-07-25, found via #46).** Ten classes declare it (ammobin/heat×2/hud/mechsub/myomers/
@@ -694,6 +709,22 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
destroyed-subsystem dark-panel look.
## Locomotion / combat polish (non-gating)
- **Myomer climb term is DEAD in the port: the gravity operand samples 0** (found in the #96
cruise benches, `[myoheat] g=0` every line — `BTMechMyomerMotionSample` reads
`GetEnvironment()->gravityConstant` and gets 0; the binary reads `**(mover+0x250)`, a POINTER
the Mover holds to the live gravity cell). Harmless on flat ground (|vy|≈0 anyway) but the
climb-work heat never accrues on slopes. Find where our Environment gravity actually lives
and re-point the bridge. [T2 measured]
- **Engine Mover gravity is dt-LESS per-frame in the binary** (`@0x421e77: vy -= **(+0x250)`,
raw, once per Perform — same class as the myomer kinetic term). If our linked WinTesla
MOVER.cpp kept that form, gravity acceleration in the port scales with OUR ~59Hz frame rate
(≈2.1× the pod's 28) — jump arcs, falls, missile droop all stiffer than the pod. CHECK the
WinTesla source's gravity line; if per-frame, it needs the same 28Hz reference-rate
normalization the myomer kinetic term got. [T1 binary side / T4 port impact until read]
- **Gait-noise phantom acceleration feeds the myomer accel term** (`[myoheat] a=4..33` at
steady cruise — our per-frame velocity re-derivation jitters; the binary read the same
`AccelerationLastFrame` cell off ITS OWN gait, noise level unknown). Bounded contributor
(~25-40% of cruise heat-in); revisit only if the 28Hz-faithful feel is contested. [T3]
- **✅ Interior vs exterior gait CLIP SET — DONE 2026-07-13 (task #59) [T2].** The local cockpit
mech was leaning 8° into every walk because the authentic ctor clip-set gate
(@part_012.c:10308-10320) was a no-op (`LoadLowDetailBody`/`LoadHighDetailBody` mislabeled the
@@ -901,6 +932,40 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
but is never CALLED, like the salvo mirror was thought to be, or a missing ForceUpdate). A
LIVENESS audit (does a live call path reach each reconstructed fn?) would catch this class.
## Score/death report tail residuals (2026-08-05, #45/#134 reconstruction — the tail itself is DONE + benched, [[combat-damage]])
- **Mech-table 0x17 "SetBurningState" label/body MISMATCH.** The recovered name row says
Set/ClearBurningState for 0x17/0x18, but @0x49f674's body randomizes the mech's position from
two scaled randoms, rebuilds localToWorld, sets graphicAlarm level 2, ForceUpdates and clears
the not-simulated flag — nothing burns. Possibly a shifted name row (cf. the factory ClassID
mislabels). Neither handler is reconstructed (nothing in the port sends mech-0x17/0x18);
identify the real bodies before wiring. The DEATH-tail id-0x17 message that was once labeled
"SetBurningState dispatch" is RESOLVED: it is Player::VehicleDeadMessage (player table, not mech).
- **Does @0x4c05c4 (VehicleDead handler) CONSUME the BT extension fields** (+0x2c killed-by
player, +0x34 kill zone)? The function is not in the export; our #52 reconstruction predates
the fields. If it reads them, the likely consumer is a killed-by console post
(ConsolePlayerMechDeathWithoutHonor / a killed-by line) + maybe an RP-style "DestroyedBy"
status banner on the victim. The port SENDS them faithfully; raw-disasm the handler to close.
- **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 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
@@ -967,6 +1032,25 @@ stability problems or massive lag spikes"*. Relevant to the carried cross-fire-s
silent-node-crash opens: neither reproduced at 5 players this session. Not evidence of a fix
(neither was addressed), but it bounds how often they occur.
## Post-re-export leads (2026-08-06, #60 closed — [[source-completeness]], `phases/phase-04-gap-census.md`)
- **`@0x4c0904` is the MASTER BTPlayer Performance — our `@0x4c083c` attribution needs a
re-check.** Newly lit by the re-export: it resolves the player's Team by name out of the
`Teams` registry (error string "Team ... does not exist"), calls `@0x4c083c` and the engine
`Player::PlayerSimulation` (`@0x42e100`), then at mission-end (state 4, past a threshold,
once) posts "Sending EndMission" to the console host, and on a timer posts the periodic
console SCORE update (`Round(currentScore)` — corroborating our hand-built scoreboard
heartbeat). btplayer.cpp currently documents `@0x4c083c` as PlayerSimulation; read both and
fix the attribution + fold in the team resolution (we have no Team-by-name resolve today).
- **`0x480a2a-0x487ca0` (~9 KB, l4splr|btmssn boundary) stayed dark THROUGH the re-export** —
the gap fill reached it with neither a call nor a data reference, which is itself a finding:
dead code, data misparsed as code, or reachable only through a jump table. The earlier
spot-check showed a real `enter 0x3c` function driven by a global-index dispatch table
(`[0x4fef88]` → table `0x501588`), so "dead code" is unlikely. Byte-scan for the table's
writers to find the entry path.
- **~41 KB is still dark overall**, mostly munga/munga_l4 (we compile the real engine source, so
low value). Current map: `reference/decomp/GAP_CENSUS.md`; re-run the pipeline after any
future re-export.
## Key Relationships
- Feeds from: every subsystem/render topic (their deferral notes collect here).
- Gating master: [[source-completeness]] (the missing BT source).
@@ -1079,3 +1163,34 @@ tuning from taste into arithmetic.
Conn Man saw heat build-up in Standard while Rajel expected none there. See
Gitea #116 and `experience-levels.md`.
## Searchlight residuals (2026-08-05 — feature LIVE, [[rendering]] §SEARCHLIGHT)
- **Cone look pass pending**: spot.bgf hangs on the site joint with the site's rest transform;
the binary's per-case offset (local_2f0 at @004cef28 case 0xbd8) is believed to be the same
site offset [T3] — eyeball the size/aim on madcat/loki at night, then clear this.
- **BTL4VideoRenderer::LoadObject (@00498448) is a btstubs no-op** — the MechMarker beacon case
(MakeEntityRenderables 0xBBA) silently gets NULL and has never drawn. The searchlight went
around it (d3d_OBJECT::LoadObject direct). Reconstruct the wrapper or migrate the marker.
- **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.
+385 -6
View File
@@ -54,9 +54,10 @@ button** (Reservoir InjectCoolant, hold-to-flush — works), **0x2F/0x2E/0x2D/0x
the per-condenser VALVE buttons** (MoveValve, Cond1-6 — work), **0x1A-0x1D = Generator A-D
ON/OFF** (`ToggleGeneratorOnOff` id 4, binary table @0050fb90 fn @004b1ed0 — ✅ **WIRED**,
`powersub.cpp`). Newly decoded from the binary message tables: **0x13 → Mech `DuckRequest`
(0x1a @0049fa00 — the manual's CROUCH button)** — ✅ **WIRED 2026-07-26**, the last handler in
this census; press-only, sets `duckState` and the map legend's `bduck.pcc` widget lights (see
[[open-questions]] for why it has no code consumer) —, **0x28 → Mech `BalanceCoolant` (0x16
(0x1a @0049fa00 — the manual's CROUCH button)** — ✅ **COMPLETE 2026-08-06**: handler (07-26) +
the master-perf posture CONSUMER + squat/rise clips + MP replication ([[locomotion]] §CROUCH;
glass key F4); the map legend's `bduck.pcc` widget still lights. (The old "no code consumer"
verdict was export-gap blindness — [[reconstruction-gotchas]] §20) —, **0x28 → Mech `BalanceCoolant` (0x16
@0049f728)** ✅ **WIRED 2026-07-21 (#20)**, **0x12 → ThermalSight `ToggleLamp` (id 3, table @0x51120C fn @004b860c)** and **0x14 →
Searchlight + Searchlight2 `ToggleLamp` (id 3, table @0x51117C fn @004b838c)** — ✅ **BOTH WIRED
2026-07-25 (#61)**, previously default-constructed blackholes; verified live (0x14 →
@@ -183,6 +184,379 @@ All cockpit surfaces are bit-plane MASKS over ONE shared `SVGA16` pixelBuffer: `
byte; `Heat`=0x4000, `Mfd2`=0x0400, `Comm`=0x8000, `Mfd1`=0x0100, `Mfd3`=0x1000; `Eng1-3` =
engineering-mode alt planes; `overlay`=0x00C0 (shares the sec surface). See [[gauges-hud]]. [T2]
## ⭐ THE RGB SPLIT — how ONE VGA port drives THREE mono MFDs (decoded 2026-08-06) [T0 engine source + T1 authentic pod config]
The five monochrome MFDs are NOT five video outputs. Each VGA port's **R, G and B analog lines
are split to three separate monochrome monitors**, and the software puts a different MFD in each
colour channel of one shared palettized framebuffer. Mechanism, end to end:
1. **Every surface is a bit-plane + a CHANNEL.** `content/GAUGE/L4GAUGE.CFG` (the authentic 1996
pod config) configures each port as `configure(idx, port, rotation, bitMask, clut, COLOUR, palette)`:
| port | panel | mask | clut | channel |
|---|---|---|---|---|
| `Comm` | upper right | 0x8000 | clut2 | **red** |
| `Mfd2` (Engineering) | upper centre | 0x0400 | clut2 | **green** |
| `Heat` | upper left | 0x4000 | clut2 | **blue** |
| `Mfd1` | lower left | 0x0100 | clut1 | **red** |
| `Mfd3` | lower right | 0x1000 | clut1 | **green** |
| `sec` (+`overlay` 0x00C0) | secondary/radar | 0x003F | clut0 | **rgb** (full colour, rotation 270 — the physically ROTATED portrait CRT) |
`Eng1/2/3` are the engineering-page twins of Mfd1/2/3: same monitor, second bit-plane, switched
by `reconfigure(...)` giving one plane the channel and the other `blank`.
2. **The channel assignment is literally a palette write.** `L4GraphicsPort::BuildSecondaryColor`
(L4VB16.cpp) walks the palette entries owned by the port's bit group (`BitWrangler(byteMask,8)`)
and writes ONE component: `RedChannel -> triplet->Red`, `GreenChannel -> ->Green`,
`BlueChannel -> ->Blue`, `AllChannels -> the whole triplet`. `BlankColor` blanks the group
(`BlankPalette()`), which is how a page swap silences the plane it replaces. The
`*TransparentZero` variants skip colour 0 so zero reads as transparent for that group.
3. **So the DAC output carries three independent pictures**, one per analog line, and the splitter
hands each line to its own mono monitor. Three MFDs per VGA port; the pod's two MFD ports are
the **1280x480 "horizontally spanned" surface** (2 x 640x480 halves) the Displays section
describes — clut2 = the upper row (Comm/Mfd2/Heat), clut1 = the lower row (Mfd1/Mfd3, blue
spare). The radar rides its own port in real colour.
**Why this matters for the port [T2]:** our modern path renders each surface as its own
mono-tinted window on its own Windows display (see §MFD PANELS ON REAL HARDWARE), which is right
when every panel has its own output. **On splitter-wired glass it is wrong** — three monitors
would share one Windows display and each would show only its channel's share of a single tinted
image.
**`BT_POD_RGB=1` — the CHANNEL-COMPOSITE mode (built + field-verified 2026-08-06) [T2].** It
collapses the six panel windows into **three**: two VGA-port windows plus the radar. Each port
window composites its group's planes into ONE 640×480 BGRA image, each member ORed into its own
channel mask (`0x00FF0000` / `0x0000FF00` / `0x000000FF`), so the analog splitter downstream hands
each mono CRT exactly its picture. Grouping follows `L4GAUGE.CFG`: *VGA Port A* = Heat + Comm +
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 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
feeding a three-monitor splitter, and the composite is what lit the glass correctly.
## 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 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
authentic reference — they are just not the bring-up route.
**The route is the glass per-display windows** ([[glass-cockpit]]): one window per surface, which
maps 1:1 onto one physical panel per MFD, needs no special driver, and is already field-proven on
desktops. Two pieces were added for the cab:
- **`BT_POD_SURFACES=1` (pod surface mode, L4GLASSWIN)** — crops every window to its SURFACE
(MFDs exactly 640×480, radar 480×640 portrait), drops the on-screen RIO button banks (the cab's
buttons are PHYSICAL — drawing fake ones over a real panel is precisely wrong), goes frameless,
and does not create the Flight Controls pad at all (6 windows, not 7). Per-window equivalent:
append `,bare` to a line in `glass_layout.cfg` (mixed rigs).
- **Placement receipts** — every window logs `[glasswin] '<title>' surface=<port> at X,Y WxH
bare -> monitor \.\DISPLAYn (origin WxH, PRIMARY)`. On a cab nobody can see seven surfaces at
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
assigns the six surfaces to the non-primary monitors top-to-bottom/left-to-right, centred.
2. Drop the cfg in the game's working directory; run with `BT_GLASS_PANELS=1
BT_POD_SURFACES=1 BT_GLASS_LAYOUT=load` (`=save` to persist drags instead).
3. Read the receipts; re-assign titles to monitors in the cfg until each picture is on its panel.
Surface→panel roles: Heat MFD = upper left (coolant), Engineering = upper centre, Comm MFD =
upper right (hot box), Left/Right Weapons = lower left/right, Secondary/Radar = the secondary
screen (portrait). Main 3D view stays the game's own window on the main-view monitor.
**Known constraints / open on-pod questions:** whether the panels are attached to this PC at all
(the probe answers it); whether Windows offers a 640×480 mode on them (if not, the surface renders
at native size CENTRED, not scaled — a scale-to-fit option is the obvious follow-up); whether
Chrome Remote Desktop holds the CONSOLE session (it should — an RDP session would get a virtual
display and light nothing); and the RIO serial input, which is a separate task from the displays.
## ALPHA-MR — the verified cart config, FROZEN (2026-08-06) [T2, field-verified by eye]
The bring-up above landed: real pictures on real pod glass, then user-confirmed correct
("ok this is correct"). What that took, and where it now lives permanently.
**The rig.** Win10 IoT Enterprise LTSC on the cart; Intel HD630 + a "Trigger 6 External Graphics"
USB adapter. Four console displays: **DISPLAY3 800×600 PRIMARY at 0,0** (main view), plus
**DISPLAY4 @800,122**, **DISPLAY2 @1440,123**, **DISPLAY1 @2080,124** (each 640×480). Physical
cab: five mono MFD CRTs (two left stacked, two right stacked, one centre loose on the desk) plus
the colour radar LCD in the yellow-button frame.
**The mapping** (`glass_layout.cfg`, `monitor:<name>` binding — see [[glass-cockpit]]):
```
Secondary / Radar = monitor:DISPLAY4,bare # colour LCD, yellow-button frame
VGA Port A = monitor:DISPLAY2,bare # RGB triple: Heat + Comm + Mfd2
VGA Port B = monitor:DISPLAY1,bare # RGB triple: Mfd1 + Mfd3
```
Three windows, not six — under `BT_POD_RGB` each VGA-port window carries three MFDs on its three
colour planes (§THE RGB SPLIT). Desktop-coordinate order says NOTHING about physical cab position:
the first assignment, made by sorting monitors left-to-right, was wrong. Only eyes on the glass
settle it.
**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 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
block, `scratchpad/pod/mergeprofile.ps1` merges it idempotently between markers. environ.ini is
read before anything touches the environment ([[build-and-run]]), and the real environment still
WINS, so a .bat can override any of it for a one-off. The cart's `runpod.bat` now sets only
`BT_LOG`/`BT_GLASS_LOG` and the `-egg`; `runpod_env.bat` keeps the all-inline version as the
fallback. Two gates were added for this:
- **`BT_FIT=1`** — the env spelling of `-fit`, so the borderless main view survives however the
game is started (shortcut, scheduled task, autostart) instead of riding on one launcher's
command line. It fits `MONITOR_DEFAULTTOPRIMARY` = DISPLAY3, which is the main-view panel.
- **`L4PLASMA=NONE`** (also `OFF`/`0`) — no marquee at all. Needed because the GLASS profile
force-defaults `L4PLASMA=SCREEN`, so simply leaving it unset still puts a desktop plasma window
on the pod's glass. The boot banner reports `plasma off [L4PLASMA]` when it takes.
Verified on build 4.11.813 with the launcher carrying none of it: `[boot] environ.ini: 9
setting(s) applied`, `[cockpit] -fit: borderless 800x600`, `[glasswin] radar rotation 0 (none)`,
and all three surfaces on their intended `\\.\DISPLAYn`.
**Surviving a NEW BUILD, not just a reboot.** Both frozen files live INSIDE the versioned install
(`content\environ.ini`, `content\glass_layout.cfg`) and NEITHER ships in the zip — environ.ini is
generated on first run. So extracting a new build gives a cab that comes up wrong with no error
anywhere. The masters therefore live at the stable `C:\bt411\` (`podprofile.ini`,
`glass_layout.cfg`, `podkit.ps1`), and **`setup_pod.bat` pushes them into the newest `BT411_*`
folder** — run it once after each extract. `runpod.bat` resolves the newest install and applies
the kit itself before launching, so the remote path needs no hand-editing per build. Re-tuning the
cab means editing the MASTER at `C:\bt411\`, not the install's copy: the apply overwrites it, on
purpose, so there is one place to look when a panel moves. Repo copies: `scratchpad/pod/`.
**The tester launchers work on the cab unchanged (verified 2026-08-06) [T2].** `play_solo.bat`,
`join.bat`, `join_lan.bat`, `play_steam.bat` and `joyconfig.bat` set `BT_PLATFORM=glass`,
`BT_START_INSIDE=1`, `BT_DEV_GAUGES=1` plus their own mode flag — and **not one pod key** between
them. So each inherits the whole rig from environ.ini without knowing the pod exists. This is the
argument against a separate pod-only settings file: a second file would need every launcher to opt
in, which is the shape of the "competing keymaps" bug that made glass the default profile.
Observed: `play_solo.bat` on the cart logs `environ.ini: 7 setting(s) applied` — 7 not 9, because
the bat sets `BT_PLATFORM` and `BT_START_INSIDE` itself and the real environment wins.
Steam MP needs the **client installed and logged in** (AppID 480/Spacewar, so any account, no
purchase); installed on the cart 2026-08-06. ⚠ **#68: a failed JOIN/HOST exits the process
SILENTLY** — "Steam not running" and a real crash look identical to a player.
### The HARDWARE RIO is live on the cart (2026-08-06) [T2]
`L4CONTROLS=RIO:COM1,KEYBOARD` in the frozen profile — the real cockpit board instead of PadRIO.
The whole stock path above the seam (mapper, lamps, streamed `.CTL` mappings) runs unchanged; the
109-mapping `L4` control table installs exactly as it does on a desktop. **This is independent of
the platform profile** — the cab keeps the GLASS display stack (panels + RGB split) and only the
input device changes, which is what the glass/PadRIO seam was built for. Do NOT reach for
`BT_PLATFORM=pod` to get it.
- **Wiring:** `RIO:COMn``\\.\COMn` at **9600 8N1** (`L4SERIAL`); bare `RIO` defaults to COM1.
Reads are non-blocking (`ReadIntervalTimeout=MAXDWORD`, totals 0), so an absent or dead board
cannot hang the game — it just goes quiet.
- **RIO and PAD are MUTUALLY EXCLUSIVE** — both assign `rioPointer` in the `L4CONTROLS` parser, so
the last token wins. Selecting the hardware RIO turns the XInput pad OFF on that machine.
`KEYBOARD` is safe alongside: it only sets `flags.keyboardExists` and never touches
`primaryControlType`.
- **The board's own self-test is the diagnostic.** `RIO::RIO` runs it (`perform_tests = True` by
default) and writes `content\FAILURE.LOG` pages `RIOBoardErrors` / `RIODeadLamps` / `RIOErrors`,
deleting them first so the file is always current. On the cart, reproducibly: **4 missing boards**
(`Slot=3:Address=0`, `3:2`, `4:0`, `5:0`) and **16 dead lamps** (`AuxUpperRight1-8`,
`AuxUpperCenter1`, `TeslaRelay1/3`, `PanicButton`, `IcomAmpEnableRelay`, `IcomIncRelay`,
`FloorEntry`, `undefined_0x17`) — the crash cart is a PARTIAL cab, and those are the boards it
does not have. **A specific 4-of-many inventory is itself the proof the link is good**: a dead
serial line reports the whole address space missing, not four slots.
- **Input reaching the mech** was seen live as `[ctrlmap] push stick x=0.0595238 y=0` with nobody
at the cab — the physical stick sitting just outside the 5% deadband. NB when the stick is
centred this reads `x=0`, which is indistinguishable from no data: a centred-stick zero proves
nothing either way. ⚠ **Not yet checked by hand:** that stick/throttle/pedal travel and the
physical buttons drive the mech correctly, and whether the cab's pots match the `Ranger`
calibration baked into `RIO::RIO` (`JoystickX -96..96`, `JoystickY -97..108`, `Throttle 0..800`,
pedals `0..470`). A mismatch shows up as drift or short travel, not as an error.
- The boot banner names the resolved device — `GLASS (hardware RIO; plasma off [L4PLASMA])`. It
used to hardcode "PadRIO", which is a lie on a wired cab and exactly the line you read to check.
- ⚠ **THE COUPLING BUG this exposed (fixed 2026-08-06) [T2].** `BTGlassPanels_Create()` was called
ONLY from the end of the **PadRIO constructor** — the panels began life as the frames around the
on-screen RIO button banks, so "the buttons ride the device". Selecting the hardware RIO means
PadRIO is never constructed, so **every MFD window silently failed to appear**: the mission came
up on the main view with all the pod glass dark, and not one `[glasswin]` line in the log. The
MFD panels are a DISPLAY concern, so creation moved to `LBE4ControlsManager` after the
`L4CONTROLS` parse, where every device branch converges — plus a symmetric `BTGlassPanels_Destroy()`
in its destructor, because `~PadRIO` tore them down and `~RIO` knows nothing about them (windows
outliving the surfaces they blit would crash on the next mission cycle). Both calls are
idempotent (`Create` returns on `gWinCount != 0`), so the PadRIO path is unchanged and simply
arrives first.
**Working on the cart, remotely.** SSH over Tailscale lands in **session 0**, which has a dummy
"WinDisc" display and CANNOT see or enumerate session 1's windows — GUI work must go through
`schtasks /run /tn BT411Run` (task registered `/IT`), and window enumeration over SSH silently
returns nothing rather than failing. Kill `btl4.exe` BEFORE scp'ing a new exe or the file is
locked. The receipts in `podrun.log` are the remote eyes; a missing `DEBUG_STREAM` line in an
otherwise-logging run is real evidence that code path did not execute.
### The remote runbook — READ THIS BEFORE IMPROVISING (2026-08-08)
Every line below cost real time to rediscover on a live stream. Follow it in order.
**1. Connect.** `ssh bt411-pod` (alias in `~/.ssh/config`) → `bt411-pod.tail840fa4.ts.net`, user
`user`, key `~/.ssh/bt411_pod`.
**The key has existed since 2026-08-06 but ssh will NOT offer it without the config entry** — it
only tries default names (`id_rsa`/`id_ed25519`). Without it you get
`Permission denied (publickey,password,keyboard-interactive)`, which reads exactly like "auth was
never set up". It was. Also: the Tailscale **node** KeyExpiry (2027-02-02) is what keeps the cab on
the tailnet — it is **not** an SSH credential, and Tailscale SSH is **not** enabled on the pod (the
peer advertises no `sshHostKeys`), so port 22 is the pod's own Windows OpenSSH.
**2. Absolute paths for System32 tools.** Over this SSH+cmd session, bare `taskkill` / `setx`
return `The system cannot find the path specified.` and silently do nothing. Use
`C:\Windows\System32\taskkill.exe`. (`tasklist`, `schtasks`, `dir`, `copy` happen to resolve.)
**3. Deploy — the SAME procedure a tester uses.** No pod-special exe drops.
```
python tools/mkdist.py # -> dist/BT411_4.11.NNN.zip
scp dist/BT411_4.11.NNN.zip bt411-pod:C:/bt411/
ssh bt411-pod "powershell -NoProfile -Command \"Expand-Archive C:\bt411\BT411_4.11.NNN.zip -DestinationPath C:\bt411 -Force\""
ssh bt411-pod "powershell -NoProfile -ExecutionPolicy Bypass -File C:\bt411\podkit.ps1 -Content C:\bt411\BT411_4.11.NNN\content"
```
`runpod.bat` auto-resolves the **newest** `BT411_*` by date, so nothing else needs pointing.
Local config is **never clobbered**: `mkdist.py` packs git-TRACKED content only, and
`bindings.txt`/`environ.ini`/`glass_layout.cfg` are all gitignored. `podkit.ps1` pushes the frozen
rig masters (`podprofile.ini`, `glass_layout.cfg`, `PODTEST.EGG`) from `C:\bt411\` into the install.
**`PODTEST.EGG` is not in the repo** — only podkit carries it. If podkit fails, the launcher runs
and *nothing appears*, with no error.
**4. Launch.** `schtasks /run /tn BT411Run` → runs `C:\bt411\runpod.bat` as `user`,
LogonType=Interactive, so it lands in **session 1** and is visible on the panels. Log:
`<install>\content\podrun.log`.
**5. ⚠ THE PODKIT INFINITE LOOP — the trap that ate an evening.** `podkit.ps1` line 30 was
```powershell
while ($keep.Count -gt 0 -and $keep[-1].Trim() -eq '') { $keep = $keep[0..($keep.Count-2)] }
```
When `$keep` trims to ONE blank line, `$keep.Count-2` is `-1` and PowerShell's `$keep[0..-1]`
returns **two** elements instead of shrinking — infinite loop, RSS climbing past 60 MB. It fires
whenever everything outside `environ.ini`'s marker block is blank, i.e. **any `environ.ini` that was
already kitted** — exactly what you get carrying it forward from the previous install. Patched to
`-gt 1` on the pod 2026-08-08 (`podkit.ps1.bak` is the original); **this fix is NOT in any repo**,
so a restored/replaced podkit brings the bug back.
*Signature:* a **blank cmd console** on the pod, task stuck `Status: Running`, **no `btl4.exe`, no
`podrun.log`**. *The cascade:* each hung run holds `environ.ini`, so every later attempt blocks too —
and over SSH your client times out while the REMOTE powershell keeps running, so "it returned
instantly and did nothing" actually means "it is still hung". Recover with
`taskkill /F /PID <pid>` (absolute path) on the session-1 `cmd`+`powershell` pair, then
`schtasks /end /tn BT411Run` before re-running — a task already Running refuses `/run` with
`2147946720` (`0x800710E0`, "operator refused the request").
**6. Panel identity (measured 2026-08-08, over SSH — WMI is session-independent so this works
without a GUI):** `DISPLAY\RAR0005\…UID224795`, `DISPLAY\DEL4025\…UID249395` (DELL 1908FP),
`DISPLAY\RAR0005\…UID200195`. **The two RAR panels share one EDID code and have blank serials**, so
`monitor:id:RAR0005` is ambiguous — they must be bound by the per-connector form
(`monitor:id:UID224795` / `monitor:id:UID200195`). **RESOLVED 2026-08-10:** the full map is
UID224795 = VGA Port A (Heat+Comm+Mfd2), UID200195 = VGA Port B (Mfd1+Mfd3), `Default_Monitor`
(EDID-less USB adapter, UID256) = the colour radar LCD, DEL4025 = the PRIMARY main-view panel
(not a service monitor). The master cfg now ships the id form (see the binding section above);
`glass_layout.cfg.displaynbak` preserves the old DISPLAYn cfg.
**7. ⚠ cmd redirect trap when scripting over SSH:** `echo VAR=1>> file` appends `VAR=` — cmd
parses the digit-adjacent `1>>` as a stream-handle redirect and eats the value (and MSVC putenv
treats an EMPTY value as UNSET, so the flag silently never arms). Space it (`echo VAR=1 >>file`,
trailing-space payload) or use PowerShell `Add-Content`. Bit us arming BT_GLASS_IDS 2026-08-10.
**8. Tester-procedure deploy VERIFIED end-to-end (2026-08-10, build 876):** scp zip →
`Expand-Archive` → podkit → `schtasks /run` came up clean on the first try — rig config survived,
newest-install auto-resolve worked, no hand-edits. The cab genuinely upgrades like a tester box.
**9a. ⚠ THE RIO's "COM1" IS A USB-SERIAL ADAPTER — the axis-dropout / frozen-boot fault
(2026-08-10, minidump-proven) [T1].** `\Device\VCP0` = an FTDI adapter (FTE7PGA5A); COM2 is a
second FTDI (A995JTOYA); **two ghost Prolific PL2303 entries also claim "COM1"** from earlier
plug-ins (clean them in Device Manager). When the adapter's **USB side** drops mid-operation:
brief drop = analog polls go mute (the field "encoder dropouts", revived by hand-jostle /
re-enumeration — NOT by button protocol); drop with a write in flight = the 1995 code's
**unbounded `GetOverlappedResult` wait in `PCSerialPacket::SendPacket` parked the whole game at
0 CPU forever** (three boots on 2026-08-10; stack: NtWaitForSingleObject ← GetOverlappedResult ←
SendPacket ← RIO ctor). **Hardened in 4.11.881**: 500 ms bounded write + CancelIo + 3 s sick
window (fast-drop, game keeps frame rate, keyboard fallback lives); `txTO=` in the `[rio]` health
line counts the events — proven the same evening (`txTO=1 rRetry=4` on a boot that SURVIVED).
USB **selective suspend disabled** system-wide on the cab the same day (powercfg) — prime suspect
for the random-period idle dropouts. Hardware to-do: FTDI adapter into a REAR motherboard USB
port, reseat its USB end. ⚠ A first "print-deadlock" diagnosis of the boot wedge was WRONG
(confounded A/B) and is retracted — the dump is the authority.
**Day-end state (2026-08-10 evening) + the pick-up plan.** The 881 soak measured the fault
precisely: the board answers for **~13 s after each fresh game start** (254 replies at 20 Hz),
then goes MUTE on analog for minutes while host→board writes keep completing (`req=3846 rep=254
age=240s`, txTO frozen at 1, abandon=0). Direct hand-poll (`tools/probe_rio.ps1`, also at
`C:\bt411\probe_rio.ps1` — raw COM1 CHECK/VERSION/ANALOG with correct [cmd][sum&0x7F] framing,
DTR/RTS raised): **zero bytes**. USB re-enumeration of BOTH FTDI converters + immediate re-poll:
**still zero** — which argues AGAINST the adapter (a wedged adapter RX clears on re-enum) and AT
the board. ⚠ [T4 hypothesis for tomorrow]: 13 s ≈ when the mission render load lights up — a
**power rail sagging under the cab's full load** would also explain the original mid-combat
dropouts (explosion/load transients) and the button "revival" (load dips + harness jostle).
Tomorrow, with hands: (1) loopback plug on the FTDI (TX→RX jumper, probe echoes = adapter fully
exonerated); (2) 5 V/12 V at the RIO board while a mission loads (the 13 s correlation is the
test); (3) swap in one of the spare Prolific adapters as cheap elimination; (4) rear-mobo USB
port + ghost-COM cleanup regardless. ⚠ My STALL/RECOVER edge detector in 881 keys on
request-age, which the 50 ms poll keeps resetting — it never fires; the `age=` field carries the
real signal (fix the keying to reply-age at the next build).
**Remote minidump procedure (worked over SSH, no tools install):** 32-bit dump of the wedged pid
via `C:\Windows\SysWOW64\rundll32.exe C:\Windows\SysWOW64\comsvcs.dll, MiniDump <pid>
C:\bt411\w.dmp full` → scp home → local x86 cdb (`where.exe cdb` resolves the bt411-tools copy)
with `-y C:\git\bt411\dist` (the archived per-build PDBs) → `~*k`. A 64-bit comsvcs dump of the
WOW64 process shows only 64-bit frames — use the SysWOW64 pair.
**9. ⚠ `schtasks /end` ORPHANS the game — the dead-RIO trap (2026-08-10):** `/end` kills the
batch, but `runpod.bat` `start ""`s the exe DETACHED, so the old btl4.exe survives, keeps COM1
open, and the NEXT launch boots with a dead cockpit board: `InitSerialPort() failed
[GetLastError() = 5]` (access denied) → `RIO initialization failed!` → buttons dead while the
banner still says "hardware RIO" (the banner is the PROFILE, not the init result). Diagnosis:
`tasklist /FI "IMAGENAME eq btl4.exe"` showing ≥2 instances. FIXED at the root the same day:
`runpod.bat` now `taskkill /F /IM btl4.exe`s FIRST (original saved as `runpod.bat.bak`), making
the BT411Run task an idempotent restart — but any hand-rolled launcher on a future cab needs the
same line, and a mid-mission `/run` now RESTARTS the game rather than failing quietly.
## The 1995 player manual — alignment audit (2026-07-18) [T1, primary source]
`reference/manual/Tesla40_BT_manual.pdf` (34pp, from Nick). CONFIRMS the reconstruction on
every checked control behavior:
@@ -231,9 +605,14 @@ every checked control behavior:
"Top Speed Gimped 40" also confirms the limp-gait speed cap as an authored spec. The port
follows the BINARY (house rule); resurrecting manual-4.0 supercharge would be an opt-in
deviation for the operator to decide.
NEW LEADS (manual describes, port lacks input/UI): **CROUCH** (button by the secondary screen;
`Mech::duckState` attr 0x37 + SQUAT clips exist, nothing drives them), **EJECT** (sounds exist:
EjectButton01_z*.wav), hot-box viewscreen framing (the deferred PNAME marker chain). Per-mech
NEW LEADS (manual describes, port lacks input/UI): ~~CROUCH~~ ✅ **CROUCH COMPLETE 2026-08-06**
([[locomotion]] §CROUCH), ~~EJECT~~ **EJECT WIRED
2026-08-02** (core: `Mech::EjectPilotMessageHandler` id 0x19 @0049f854 + the crippled-mech
permission evaluator @0049fa1c; input = binding-engine "Eject" action, Backspace / pad
LeftThumb; the punch-out kills via graphicAlarm 10 ≥ 9 — KillBonus authors 0 in ALL shipped
content, so the zero charge is authentic; tails: console relay notice, RIO 0x38 panic control,
alarm-10 audio/canopy presentation, `SpecialCaseDeathPenalty` role+0x20 consumer), hot-box
viewscreen framing (the deferred PNAME marker chain). Per-mech
stat tables = a systematic cross-check source for our streamed subsystem resources.
### Coolant-loop cross-check RESULT (2026-07-18) [T1] -- STRUCTURE strongly faithful
+18 -13
View File
@@ -39,19 +39,24 @@ sections in `docs/PROGRESS_LOG.md` cite old `C:/git/nick-games/...` paths — tr
they map into this repo (reconstructed BT → `game/reconstructed/`, engine → `engine/MUNGA{,_L4}/`,
content → `content/`, raw decomp → `reference/decomp/`). [T2]
## Current state (2026-07)
`btl4.exe` boots, renders the world + a skinned mech, and runs a full **drive → animate → target →
fire → damage → destroy → respawn** single-player loop (task #52). **2-node MP is verified
end-to-end** — replication, cross-pod targeting/damage/kill, beam visuals, replicant gait (tasks
#46-#51, [[multiplayer]]). All 8 cockpit canopies are authentic + the horizontal-FOV fix (task #55,
[[cockpit-view]]); the Gyroscope is live byte-exact with hit-bounce (task #56); the **gauge system
is complete** ([[gauges-hud]]). The engine/renderer/HAL are done (WinTesla); AUDIO backend is now REAL
(the repo's OpenAL32.dll + libsndfile-1.dll were both no-op STUBS — replaced with real OpenAL Soft +
an in-tree WAV loader, 2026-07-15) and the 241-sample soundbank is cracked from AUDIO1/2.RES + loading;
the ONLY remaining audio gap is game-triggering (no AudioEntities fire PlayNote yet), so gameplay is
still silent apart from a proof-of-life hook (see [[wintesla-port]] Audio). The active work
is reconstructing each BT subsystem's authentic behavior from the binary. Remaining: pod-LAN
config, Mech-level update records, per-subsystem waves. [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 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,
ghost+skate field detectors); the Steam wire seam + build gate; the operator console/relay
lifecycle; gauges/HUD; audio (real OpenAL backend + the AUDIO_FIDELITY trigger waves — footsteps/
gait/alarms/impacts live in the field). [T2]
**Infra ✅ 2026-08-06:** the #60 export gap census AND re-export shipped — coverage 87.3% →
93.5%, dark code 90 → 41 KB, +205 functions, and the re-export independently confirmed the
week's raw-disasm reconstructions ([[source-completeness]], `phases/phase-04-gap-census.md`).
**Remaining (polish):** VehicleDead killed-by consumption (operator "X killed by Y" — the sender
already ships the fields); burning-wreck handler (mech-0x17 label mismatch); `@0x4c0904` master
BTPlayer Performance vs our `@0x4c083c` attribution; the id-0x16 type-0 curiosity; MechRIOMapper
Keypress @004d2514; marker beacon (LoadObject wrapper stub); DIV firmware intersection routine
(deep-cut); deferred ledger items in [[open-questions]]. [T2]
## Key Relationships
- Full detail: `docs/PROGRESS_LOG.md`.
+219
View File
@@ -278,6 +278,12 @@ Suspect ANY reconstructed per-frame code with narrow equality/window tests or `x
state transitions: charge/seek loops, snap comparisons, timers compared with `==`.
## 13. Verification gotchas (don't fool yourself)
- **Capture the viewpoint that can SEE the change.** The crouch "pose does not hold" and the
false "eye does not drop" residual (2026-08-05/06) were BOTH capture errors: cockpit-view
screenshots cannot show your own legs, and canopy-dominant diff crops mask eye-relative motion
(the canopy drops WITH the eye — only the through-window ground shifts). Anchor pixel-diff
crops on a region the effect MUST change, pair them with a state probe (joint values), and
READ one frame with your own eyes before declaring a visual regression.
- **Lazy gauge build:** `GaugeRenderer::BuildConfigurationFile` runs LAZILY. A too-early process
kill shows `[gskip]=0` / "not built" even though the widget is fine — **wait for the gauge
@@ -543,6 +549,34 @@ segment tables) — the same class of latent overflow.
0xFEEEFEEE=freed).
5. For exhaustive multi-function analysis: a read-only Workflow (understand), then implement hands-on.
## 20. Export-gap blindness — absence in the EXPORT is not absence in the BINARY (4 incidents)
The Ghidra export (`reference/decomp/`) has coverage gaps (#60), and BTL4.RES content never
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 "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
BTL4.RES (`squ/sqd/squi/sqdi` × 8 chassis); the search covered decomp strings + loose files,
never the RES TOC. Disproven by the CROUCH reconstruction (2026-08-06).
3. **2026-07-31 morning: the myomer drive-scale DELETION** ("the binary has NO dynamic
myomer→speed coupling") — the consumer was un-exported; restored same day by raw capstone.
4. **2026-08-05 "the myomer factor FEEDER (@004b8be3) is unreconstructed"** — it had been fully
reconstructed since 07-31 under NAMED members (`speedEffect`, `AvailableOutput`); the grep
searched raw offsets. Cost: a duplicate multiply + a dead crouch gate until 08-06.
**The rule — before claiming "X does not exist / is not reconstructed":**
- byte-scan `content/BTL4OPT.EXE` for the offset/immediate (disp32 patterns), never just the export;
- grep the port for NAMED members (check the .hpp for the offset's name) — offsets rot after promotion;
- for content claims, walk the **BTL4.RES TOC** (`tools/resscan.py` pattern), not the loose tree;
- check `reference/BT410_SOURCE_MANIFEST.md` + `game/reconstructed/CLASSMAP.md`;
- and tag the claim's tier by the WEAKEST source consulted — an export-only sweep caps at [T4].
The #60 gap census + RE-EXPORT is the systemic fix, and BOTH shipped 2026-08-06: the export
now covers 93.5% of .text (dark code 90 KB → 41 KB) and `reference/decomp/GAP_CENSUS.md` maps
what is still dark. **So the rule is cheap now: look the address up in GAP_CENSUS.md first.**
If it is NOT in a dark region, the export is authoritative and "absent" means absent. If it IS,
byte-scan before claiming anything. (Toolchain + method: `phases/phase-04-gap-census.md`.)
## Key Relationships
- Applies to: every topic that reconstructs a class ([[subsystems]], [[combat-damage]], [[gauges-hud]], [[locomotion]]).
- Uses: [[decomp-reference]] (offsets/ClassIDs), [[reconstruction-method]] (the loop).
@@ -795,3 +829,188 @@ grep its helper types for `return 0` bodies FIRST; (b) a bring-up shim must
either Fail() loudly or log its own emptiness, never silently iterate nothing;
(c) before shimming an engine-side structure, check whether the engine already
has it -- the offsets in the decomp comment ARE the lookup key.
## 27. Drawing where the design says DON'T: under-surface buttons, shared planes, and inherited D3D state (#118, 2026-08-03)
Three MFD-corruption incidents in one evening, all mine, all the same class:
adding a DRAW to a compositor whose layout invariants I had not read.
1. **The eject-wipe gauge** (BitMapInverseWipeScalar) drew into a weapon's ENG
graphics port -- but the Eng1-3 planes SHARE composite dest cells with the
weapon pages (`ckSlotOf` in L4VB16), so its mis-geometry scribbled striped
garbage OVER the weapon MFDs whenever an eject cycle redrew it (= at
overheat-jam onset, which the operator correctly correlated with "when the
coolant warning starts").
2. **The "flash overlay"** re-drew flashing buttons' full faces ON TOP of the
surfaces. But the side buttons are BIG rects deliberately tucked UNDER the
displays -- the protruding edge IS the lamp (the pod's backlit keys sat
beside the CRT; there is no in-display face). A full-face redraw is wrong
BY DESIGN: it covers gauge imagery with button faces.
3. **The same overlay, drawn after `DrawDevSurface`, inherited its bound
texture** -- an un-reset `CkFill` renders a TEXTURED quad sampling the
gauge atlas: green diagonal-striped rectangles. (With the state reset the
bug became honest: solid flashing faces covering the MFD -- which is how
the operator's "the panel buttons took over the MFD" report finally
identified the author.)
Rules: (a) before ADDING a draw to an existing render pass, read the pass's
layering comments -- "UNDER the surfaces", "masks the part inside", "shares
its sibling's dest cell" are INVARIANTS, not descriptions; (b) any D3D draw
inserted after another subsystem's draws must re-assert its own texture/color
state (CkColorState-equivalent) -- the previous draw's bindings are live;
(c) when a visual corruption tracks an EVENT ("starts with the coolant
warning"), list what STARTS DRAWING at that event -- alarm-driven redraws,
state-change repaints -- before suspecting the event's logic; (d) the
operator's screenshot is worth ten theories: the red-faces capture identified
in one frame what three log-side hypotheses missed.
## 28. HAND-COMPOSING an engine-derived transform reads a STALE CACHE — and it only bites REPLICANTS (#141, 2026-08-08)
`EntitySegment::GetSegmentToEntity()` (`SEGMENT.cpp:262`) **recomputes only when
`segmentModified` is already set** — otherwise it hands back the cached matrix,
and if the segment has no parent it can never recompute at all. The thing that
sets that flag after a joint moves is `JointedMover::GetSegmentToWorld`
(`JMOVER.cpp:136-146`): it tests `AreJointsModified()` and, when set, walks the
whole segment table marking every entry dirty, then clears the joint flag.
The binary agrees exactly. `MechWeapon::GetMuzzlePoint @004b9948` ends in
`FUN_00424da8(owner, segment, out)`, which IS `GetSegmentToWorld`
instruction-for-instruction (`GetJointSubsystem``if (AreJointsModified())`
mark all → `ModifyJoints(False)``× localToWorld`). **So in the 1995 image
every muzzle query performs the joints→segments refresh.** [T1]
Four port sites had replaced that with `mw.Multiply(seg->GetSegmentToEntity(),
mech->localToWorld)` — including one commented "the faithful FUN_004b9948".
They skip the refresh and read whatever cache is present.
**Why it hid for a year:** the local mech is refreshed every frame anyway — the
renderer and cockpit camera call `GetSegmentToWorld` on it, *after* its torso
pushes the joint. So master-side output is correct and solo testing is clean.
A **replicant** gets no such pass: its cache stays at the BIND POSE. Measured on
a 2-node bench, peer missiles left along the LEG facing with `segYaw == bodyYaw`
EXACTLY (`twistDelta` 0.0000 over 165 salvos) while that same peer's copy torso
was demonstrably writing its joint (`PushTwist COPY twist=-1.49601`) from
correctly replicated records. Twist arrived, joint moved, segment never
re-derived.
Rules:
(a) **Never hand-compose `GetSegmentToEntity() × localToWorld`.** Call
`GetSegmentToWorld` — it is the binary's own path and it does the refresh.
(b) **Do NOT "fix" a stale transform by forcing the dirty flag.** Setting
`ModifyJoints(True)` at the read site made the symptom go away and scored
identically to the faithful fix — it was a stand-in that patched ONE
consumer and left every other peer segment reader stale. The binary only
ever *tests* that flag.
(c) A cached-transform bug is **master/replicant asymmetric by construction**.
If a geometry symptom is reported "peer POV only", suspect a cache that the
local render pass refreshes for free — before suspecting replication. Here
the replication was provably fine.
(d) When a fix lands at a partial percentage, **check whether the failures are
interleaved or a PREFIX** before calling it partial. These were a clean
prefix that ended the moment the peer first had a non-zero twist to carry —
i.e. the fix was complete and the remainder was correct behaviour. Reporting
it as "64% fixed" was wrong.
(e) Related probe trap: `Torso::PushTwist` sampled ONE shared static every 30th
call. With a master torso and a copy torso ticking 1:1, every 30th call is
always the SAME instance — the probe showed only the local untwisted torso
and hid the copy's writes entirely. Sample **per instance-kind** whenever
master and replicant objects share a diagnostic. (See also §gotcha on
process-wide statics serving the player's data as the replicant's.)
## 29. A peer mech does NOT tick before RunningMission — bench-only, and it fakes a replication bug (#148, 2026-08-08)
`Entity::Execute` (`ENTITY.cpp:556`, real engine source [T0]) calls
`PerformAndWatch` **only** when
```cpp
application->GetApplicationState() == Application::RunningMission
|| application->GetApplicationState() == Application::EndingMission
|| IsPreRunnable()
```
otherwise it just does `WriteSimulationUpdate`. `Entity::DefaultFlags` is
`DynamicFlag|MasterInstance`**no `PreRunFlag`**; only `Player` and `Director`
add it in their DefaultFlags, and `Mech::Reset` sets it for a reset MASTER
("a reset master must tick"). A **replicant mech never gets it.**
So during `LoadingMission` / `WaitingForLaunch` / `LaunchingMission` a peer mech
performs **zero** subsystem ticks, no matter how much correctly-replicated data
is arriving for it. Measured on the observer node:
```
235 [perf-first] mech 3:161 master <- own mech, immediately
402 [torso-rec-rx] <- peer's torso records start arriving
2754 [perf-first] mech 2:55 REPLICANT <- peer's FIRST performance
2758 [torso] PushTwist COPY <- its torso ticks 4 lines later
2761 [ent-exec] state=5 <- RunningMission
```
This is correct engine behaviour, **but it silently corrupts any bench that acts
before the round starts.** `BT_AUTOFIRE`/`BT_GOTO` begin immediately, so early
salvos measure a peer whose torso, gait and subsystems have never run — and the
result reads exactly like a replication failure. It cost a full investigation
(filed as #148) before the app-state trace showed the peer was simply not
executing yet.
Rules:
(a) **Judge a 2-node bench by PREFIX vs INTERLEAVED, never by raw percentage.**
A clean leading run of failures that stops for good is almost always the
pre-`RunningMission` window; interleaved failures are the real thing.
(b) When a peer looks inert, check `[ent-exec] state=` before suspecting
replication. States: `2` LoadingMission, `3` WaitingForLaunch,
`4` LaunchingMission, `5` RunningMission.
(c) Prefer benches that wait for `RunningMission` before acting — or slice the
log at the transition — otherwise every peer-side metric carries this bias.
(d) The receipts that make this legible: `BT_NET_TRACE` gives `[upd-repl]`
(offered to the performer), `[ent-exec]` (state / preRun / instance) and
`[perf-first]` (one-shot per mech: entity ID + instance at its FIRST
performance). Anonymous per-frame receipts are useless in a 2-node log —
**name the mech.**
## 30. A finite-difference cache the binary re-seeds at Reset — transcribe the WHOLE seed list (#137, 2026-08-09)
The binary `Mech::Reset @0049fb74` opens with more than the obvious origin writes: its SECOND
instruction is `FUN_00408440(mech+0x58c, param_2)` — re-seeding the **previous-position memory**
of the AccelerationLastFrame ring feed (+0x81c/0x824/0x828/0x82c) to the new origin. The port
reconstructed the ring itself faithfully (ctor `part_012.c:9836`, derivative `:15169`) but its
Reset never got that one line. Result: the first post-respawn sample computed
`|newPos prevPos| / dt` = **teleport distance / dt ≈ 1e5** into the velocity ring; the
ring-mean derivative turned it into an acceleration spike (with an opposite-sign ECHO ~15 frames
later as the sample rotated out of the mean); the myomer heat integrator's
`termAccel = (1accEff)·|v|·|a|·m·dt` turned THAT into a ~3e9 one-tick heat deposit; and the
freshly-reset myomers snapped from 77 to ~9000 against failT=2000 — the #137 respawn freeze.
Rules:
(a) **When the binary's Reset writes a cell you don't recognize, that write IS the spec.** The
+0x58c re-seed looked like bookkeeping and was silently dropped; it was the only thing
standing between a teleport and a position-derivative spike. Transcribe the whole zero/seed
list, then map each cell — never the recognizable subset.
(b) **Any prev-value cell backing a finite difference must be re-seeded at every discontinuity**
(teleport, warp, respawn). If you add such a cache port-side, grep the binary's reset for its
analog before assuming none exists.
(c) **Derived state hides stale backing state.** `currentTemperature` sampled AT the reset read
77 (clean) because RTIS wrote it — while the freeze arrived one frame later through
`heatEnergy += pendingHeat` from a live producer. Probing the derived cell at the reset
instant proves nothing about the producers; trace the WINDOW after, per producer.
(d) The diagnosis chain that worked, for reuse: roster-wide state snapshot (at-death / at-reset /
post-reset) → eliminate conduction by trapping flows with full operands → trap the remaining
producer's deposits with operands → cross-reference the operand SHAPE (pure local z,
magnitude = distance/dt, echo at ring-length) against the writers. Each trap eliminated a
theory the previous data had made plausible; three plausible theories died on operands.
## §25 — A same-pass edge latch sampling DEFERRED state: synchronous in the binary, next-tick in the port
**(2026-08-11, the #162 4-node retest double-credit.)** The binary's death processing writes
`movementMode 9` **inside the damage pass itself** — its own tail gate (part_012.c:14761) reads
the mode already flipped in the same call. The port put the mode write in the per-frame
`UpdateDeathState`, one tick later — and a comment at the death tail *asserted* the write was
synchronous, so nobody looked. Consequence: TWO lethal deliveries of one salvo (direct + splash,
2 ms apart, no sim tick between) each sampled `destroyedAtEntry = (mode==2||9)` as 0 and re-ran
the whole death block — duplicate kill credit + duplicate VehicleDead. Invisible at 2 nodes
(clean kills deliver ONE lethal hit); needs crowds + overlapping splash — the 8-player field
shape. **Rule:** when a binary function's OWN later code observes state it wrote earlier in the
same pass, the port's write must be equally synchronous — a per-frame deferral silently converts
an edge latch into a race. Sibling of the alarm-vs-mode trap (#108/#162): both are "the latch
sampled a cell whose write timing differs from the binary's". Fix shape: run the once-per-death
transition inline at the destruction edge (mech.cpp death_tail → `UpdateDeathState(0)`), keep the
per-frame caller for maintenance. Verified: kd4_bench 9/9/9/9 + kd8_stress 26/26/26/26, both
SWALLOWED=0 (the receiver dedup tripwire now never fires — no duplicate is ever GENERATED).
+47 -1
View File
@@ -335,7 +335,9 @@ the limb swap ("the actual enemy mech in external view is not showing darkened a
- **`.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
`destroyed``destroyedd` video variants); the res compiler is `FUN_0041e4e0`.
`destroyed``destroyedd` video variants); the res-compiler address is UNKNOWN (the old
`FUN_0041e4e0` citation was WRONG — that is `DamageZone::TakeDamage`; corrected 2026-08-02,
swept with [[decomp-reference]]).
- Compiled into BTL4.RES and parsed by **MUNGA's own `DamageZone` stream ctor** into
`materialTable`, keyed by `EntitySegment::SkeletonType` (`engine/MUNGA/DAMAGE.cpp:311-336`,
`GetMaterialList(skl_type)`) — real engine source [T0]. **This data was loaded and unused in the
@@ -377,6 +379,50 @@ mech, brightness tracking `1 - 0.9*level` exactly, hull 0.35x vs terrain control
The `damcolor_`/`gen*gry_` arm+gun names go unmatched until the DESTROYED limb variant loads —
correct: those materials live on the `*D*.BGF` damaged meshes.
## SEARCHLIGHT -- the pod's night kit, RECONSTRUCTED 2026-08-05 [T1 decode, T2 both benches]
The subsystem (searchlight.cpp: sim @4b841c, ToggleLamp @4b838c toggles requestedOn@0x1E0 --
NO novice gate, that's ThermalSight's; lightState = requested && powered(4) && heat<2 && alive,
published as the "LightOn" attribute + replicated as subsystem record 0x14) was already complete;
the VISUALS were the gap, decoded from MakeMechRenderables @004cef28 `case 0xbd8` [T1]:
- **Cockpit = a FOG SWAP, not a light.** The @00456778/@00456814 watcher polls up to two LightOn
attrs; either-on -> `SetFogStyle(searchLightOnFogStyle)` (the authored `fog=` set), off ->
`searchLightOffFogStyle` (the `nosearchlightfog=` set). Both sets are authored per
map/time/weather page in BTDPL.INI (arena_night 40/400 lit vs 5/400 dark; des_night 100/1100
vs 30/700; arnitclear 90/1100 vs 5/1000). The 2007 engine kept the whole system under its real
names (searchLightFog*/noSearchLightFog*, the FogStyle enum); the port completed the stubbed
plane application (currentFogNear/Far now swap with the style) and transcribed the watcher into
`TickSearchlight` -- including the ctor's INVERTED cache seed, whose first-tick "change"
normalizes a night start onto the DARK set. Fidelity consequence: every prior build rendered
the lit fog permanently (nothing called SetFogStyle(3)); authentic night starts darker.
Glass key: F5 -> streamed button 0x14.
- **PERCEIVED STRENGTH [T2 field eyeball 2026-08-05]: authentically SUBTLE.** With LINEAR fog the
near-plane move barely changes close-range haze (at 30u the arena pair differs ~5%); the swap
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 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
in the release notes accordingly.
- **External = the spot.bgf beam-cone mesh** (VIDEO/GEO + per-time variants), hung as a child of
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 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),
`spotcone.sh` (2-node cone + replication). BEAM MATERIAL decoded 2026-08-06 (b75bb4a) [T1]:
SPOT.BGF is a 7-vert cone ~50u forward and ~35° DOWN (a ground-pool lamp, not an air beam),
verts tinted cyan-white; its material class `btfx:brighten.25` smuggles the ADDITIVE factor
in DIFFUSE.r (0.25) with a warm emissive on the night page — the loader draws brighten*
batches as an additive veil (dest += vertexRGB × factor, blend pass, unlit); tint compose
(vertex cyan vs night emissive warm) [T3], flagged in the L4D3D draw branch. Look pass
ACCEPTED by field eyeball 2026-08-06 (translucent beam reads correctly; solo rig
BT_SPOT_SELF=1 + BT_CAM=face). Still open ([[open-questions]]): the stubbed LoadObject
wrapper + dormant marker case, the subtle authored deltas.
## Key Relationships
- Geometry/LOD: [[bgf-format]]. Base: [[wintesla-port]] (L4D3D). Shadow/visual-conform: [[locomotion]].
- Renders on: [[pod-hardware]] (main 3D view).
+26
View File
@@ -94,6 +94,32 @@ its recorded line 400 exactly. [T1/T3 per the sidecar]
Plus `BTL4OPT.EXE` as a behavioral **oracle** (run under cdb / read the decomp). Result =
behavior-equivalent **reconstruction**, NOT original source. [T2]
## THE GAP CENSUS + RE-EXPORT (2026-08-06, #60) — BOTH HALVES DONE [T1]
**The export was REBUILT 2026-08-06** (Ghidra 12.1.2 headless + `ExportGaps.java`'s gap-fill
pass): index coverage **87.3% → 93.5%**, dark real code **90.4 KB → 40.8 KB (54.8% recovered)**,
game-side dark 53.1 → 21.1 KB, functions 6,267 → **6,472** (+205, zero decompile failures).
Every historically-bitten dark function now has pseudocode — including `@0x4c05c4` VehicleDead,
the absence that opened #60. The re-export CONFIRMED the crouch reconstruction field-for-field
and revealed one branch the raw pass missed (airborne auto-rise, fixed same day).
**The old export is archived at `reference/decomp/archive_2025export/`** — addresses are
stable across both, but `part_0NN.c:LINE` citations only resolve against the archive. Cite
`@ADDR`, not part/line. Pipeline: `tools/ghidra_reexport.sh``tools/gapcensus.py`
`tools/gapdiff.py`. Toolchain (JDK 21 + Ghidra 12.1.2) lives in `%LOCALAPPDATA%\bt411-tools`;
the runner MUST use 8.3 short paths (Ghidra's .bat expands %JAVA_HOME% unquoted and the user
profile has a space). Full log: `phases/phase-04-gap-census.md`.
### The original inventory (part 1, same day) [T1]
`tools/gapcensus.py``reference/decomp/GAP_CENSUS.md` + `gap_census.tsv` (regenerate after
any re-export; it censuses whatever dir you point it at). As found BEFORE the re-export: .text
892 KB, index 87.3%, **428 dark regions = 90 KB of real code** (indexed-but-unexported = 0 —
the gap class is purely "not indexed"), game-side dark ≈ 54 KB with 159 regions never touched.
Current numbers are in the header above. Validation: all six historically-bitten dark
addresses (VehicleDead, ToggleLamp, death tail, master-perf, myomer integrator, duck consumer)
land inside census regions. Top uncharted leads + method: `phases/phase-04-gap-census.md`.
**RULE (gotcha §20):** consult the census BEFORE any "absent from the export/binary" claim —
if the address falls in a dark region, the claim needs byte-scan evidence, not export silence.
(The second half — the re-export — landed the same day; see the header.)
## Key Relationships
- Feeds: [[reconstruction-method]] (the loop that fills the gap), [[decomp-reference]].
- Route A closed 2026-07-19 (Nick holds nothing further); gated now only on reconstruction labor + the engine-closure backfill.
+43 -1
View File
@@ -41,6 +41,9 @@ Making a base byte-exact GROWS every subclass — they must be re-based TOGETHER
- **WAVE 3** — power bus (Generator/PoweredSubsystem) + Emitter/PPC fire-path (end-to-end fire; heat
conducts to the central sink via the linked-sink roster).
- **WAVE 4** — standalone readouts: Sensor/Searchlight/ThermalSight/AmmoBin (de-shim, gate fixes).
- ✅ **Searchlight VISUALS complete 2026-08-05** — cockpit fog swap + external spot.bgf beam
cone, both MP-replicated; mountSegment@0x1DC identified (= resource segmentIndex, the cone's
mount joint — was "commandedOn, role unidentified"). Full story: [[rendering]] §SEARCHLIGHT.
- ✅ **Searchlight + ThermalSight buttons WIRED 2026-07-25 (#61)** — both classes' handler sets were
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`
@@ -99,7 +102,10 @@ 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; there is NO load model — demand never pulls the bus down), or the myomers leave
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
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,
@@ -296,3 +302,39 @@ 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.
+83 -1
View File
@@ -84,6 +84,56 @@ The vehicle sed must replace the WHOLE line (`s/^vehicle=.*/`), or you mint
`vehicle=thr1bhk1` and the spawn FATALs. Chassis codes: ava1 bhk1 lok1/2 mad1/2
own1 snd1 thr1 vul1 (own1 has NO arm zones — rack zones instead).
**Weapon/combat benches need `map=grass time=day`.** `MP.EGG` authors
`map=cavern time=night`; a bench that copies it without the map sed (e.g. via
`bt_expert_egg` alone — mp_double.sh has this gap) parks the GOTO-driven mechs
against cavern rock and they shoot terrain for the whole window. Always sed the
map/time on the COPY, single-node and two-node alike.
### THE ZONE-WALK MATRIX (`scratchpad/night10/zonewalk.sh`) — systematic
### per-panel hit-location testing, operator-watchable (built for #124)
The precision-targeting rig: **two nodes**. Node B (launched first, back
window) is the TARGET — `BT_SPIN_SELF=15` rotates its own viewpoint mech in
place (deg/s; the BT_SPAWN_AT write pattern, applied per frame) so every
aspect passes the shooter's boresight; its window shows its own paper doll
taking the hits (`BT_DMG_LOG=1` prints every `[dmghit]` with zone + level).
Node A (foreground — it owns the pick focus) runs `BT_ZONE_WALK=<secs/zone>`:
walks the target's damage zones in index order, resolves each zone's CARRIER
SEGMENT's live world position (`BTResolveSegmentWorld` — the same resolver the
attached smoke rides; zone→segment via the `BTMechZoneSegAndName` bridge),
SERVOS the torso twist + aim elevation until the centered reticle's pick ray
(`BTGetAimRay(0,0)`) points at the segment, then pulses the laser trigger 3×
and advances. `[walk] ZONE/FIRE/HOLD` lines on A pair with `[dmghit]` on B.
Launch: `bash scratchpad/night10/zonewalk.sh` (relay included; NO kill timer —
the session stays up for observation; teardown = taskkill btl4).
Node A also carries `BT_ARMOR_LOG=1` + `BT_SHOT_EVERY=300 BT_SHOT_PREFIX=zwA`
(added for #87): the armour watcher's `[armor] ... level P -> Q` lines on A are
the OBSERVER-side receipt that B's zone levels replicated (the walk is the
standing zone-replication bench — A-side peaks must match B's `[dmghit]`
finals), and the numbered captures are the pixel record of the enemy hull
darkening. Digest: `python scratchpad/night10/digest_walk.py` (per-zone hits,
amt/hit, level first→last on B; replicant peaks seen by A; zone-repl lines).
Companion scalpels: `BT_ASPECT_TEST=1` (the zero-premise frame probe — 4
world-cardinal self-impacts + one at each weapon's physical MUZZLE, the
left/right anchors that caught the #124 reflection-vs-rotation error);
`BT_AIM_SWEEP=<period>` (blind elevation sweep — superseded by the walker).
Hard-won constraints baked into the walker (do not relearn these):
- **Engage gate**: servo only when the aim ray is live AND range < 150 AND
|bearing| < 1.1 rad; otherwise RELAX the twist toward 0 and hold the zone
clock. Without it the servo winds the twist to its limit while the goto
marches (limit-clamp fight = the mech visibly SHAKES) — and the gate must
sit well above the goto's resting bearing error (~0.55 rad at STOP=90) or
it deadlocks in HOLD.
- **Yaw polarity**: the twist cell's angular sense is OPPOSITE atan2(x,z)
world yaw (operator-observed live: `+=` turned the torso AWAY — the second
witness for the #124 SelectSlice twist-sign flip). The servo starts at 1
and carries a growth WATCHDOG that self-flips if the error diverges.
- **Damped servo**: gain 0.40, step cap 0.025 rad/frame, deadband — the aim
ray lags the twist write by a frame; full-error correction oscillates.
- **Approach port**: dropzones spawn cross-map; the walker one-shot teleports
A to 100 u off the target (BT_SPAWN_AT pattern incl. the +500 y ground-snap
lift) instead of minutes of marching.
### Two-node bench skeleton (the MP pattern)
```bash
bt_assert_player_env
@@ -139,7 +189,39 @@ sleep <duration>; kill $relay; bt_kill_ours
(one line, ungated) answer questions retroactively; per-frame traces stay
gated.
## Bench-script gotchas (each has burned a session)
- **Write benches CLEAN, never sed-derive a chain** — sed-derived copies silently dropped envs
twice (the [replgimp] silence); export envs INSIDE the per-node subshell.
- **Absolute paths everywhere; `cd /c/git/bt411` first** — the cwd trap (heredoc python + `cd
scratchpad/nightN`) has broken later builds/appends with `fatal: pathspec`/`cannot stat`.
- **No apostrophes/backticks through bash heredocs** — write a `.py` file and run it (quoting has
mangled posted tracker comments and killed scripts mid-parse).
- **Drain stale watchdogs before relaunching** — a leftover waiter's `taskkill` has killed a live
bench mid-run.
- **Scripted input goes through the REAL seam**: `BT_BTNTEST`/`BT_BTNTEST2` (RIO queue → mode-mask
drain). In 2-node runs press at poll ≥900 — round-start jitter can eat earlier presses.
- **Capture the viewpoint that can see the change** (gotcha #13 bullet): cockpit view can't show
your own legs; pair screenshots with a state probe and READ a frame before concluding.
- **Build ritual first** — see [[build-and-run]] §Build ritual (stale exe = phantom results).
## The standing bench suite (scratchpad/night15/, 2026-08-11 wave — all hard PASS/FAIL)
| Bench | Asserts | Hooks it rides |
|---|---|---|
| `kd_bench.sh` (2-node) | K/D invariants I1-I5: deaths == PLAYER_DEAD == 500 costs == kill credits, no swallows | BT_AUTOFIRE + BT_AF_MISSILE, BT_GOTO, matchlog |
| `kd4_bench.sh` (4-node) | I1-I8 **receipt-derived per player** + bystander mirror convergence + no-mirror-exceeds-authority + zero `inst=R` | same + SBMIRROR records |
| `kd8_stress.sh` (8-node) | the same invariants under an all-vs-all melee (staggered salvo periods, one drop zone); `LOW STRESS` guard | same, 8 instances 1 core each |
| `proj_ttl_bench.sh` | #168 flight clocks: probe fizzles at exactly 5.0s / burn+10s; field phase: authentic ttl stamps, none early, no stale-point hits | BT_PROJ_TEST probe + BT_PROJ_LOG |
| `gen_edge_bench.sh` | #154 warning edge: 4/4 trigger fixups, trip edge FIRED, recovery + respawn silent | BT_GEN_HEAT injection + BT_AUDIO_EDGE |
| `xfire_bench.sh` | #165 destroyed-weapon gate: pre-kill FIRED, post-kill only REFUSED, survivor unaffected | BT_KILL_SUBSYS + named `[emitter] FIRED` |
| `eject_canary.sh` / `ram_bench.sh` / `relaunch_bench.sh` | eject suicide arithmetic / ram-rattle eligibility set / ordered-exit no-zombies | (night-15 fix wave) |
Multi-node rig notes: extend the egg by CLONING the 1602 pilot page (the kd4/kd8 scripts show
the python pattern); **only zone names the MAP defines are valid dropzones** (undefined names
pile everyone at a fallback — mechs spawn overlapping and the melee turns into splash-suicide
chaos, which the receipt-derived invariants still score exactly, but it isn't the scenario you
wrote); affinity masks 2-cores-each up to 4 nodes, 1-each at 8.
## Key Relationships
- Uses: [[build-and-run]] (parity, env gates, BT_SHOT capture) · [[experience-levels]] (expert vs novice gating)
- Informs: [[reconstruction-method]] (step 4 "verify honestly" — this file is the how)
- Incident sources: [[reconstruction-gotchas]] §23 (pixels), §26 (silent stubs); [[multiplayer]] (replication layers)
- Incident sources: [[reconstruction-gotchas]] §23 (pixels), §25 (same-pass latch timing — caught by the 4-node bench), §26 (silent stubs); [[multiplayer]] (replication layers)
+507
View File
@@ -0,0 +1,507 @@
# 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, Sunder V1=snd1, Vulture=vulture, Mad Dog=vul1, Mischief=lok2,
Satyr=ava1, Sunder=sunder, Denkou=snd1 (#69), 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
+1 -1
View File
@@ -74,7 +74,7 @@ Ranked by impact. **Reverse thrust `0x3F` (key LALT / pad B) is FIXED** and conf
| 3 | **pad LT / RT (turn pedals)** — and every pad-only session | Steer the legs | Not a wiring gap: the pedals are live (`L4PADRIO.cpp:710-715``L4CTRL.cpp:1438-1446``btl4mppr.cpp:1337-1340``:1399 pedalsPosition`) but only *Standard/Veteran* read them (`mechmppr.cpp:1034`, `:1061`); the mapper boots in **Basic** (`mechmppr.cpp:361`) — and **the pad has no binding for `0x18` CycleControlMode**. Verified: `content/bindings.txt:74-89` binds only `0x40 0x3F 0x47 0x42 0x44 0x43 0x41`; no `0x18`, no `0x15` | **S** | `BT_MPPR_TRACE=1`, squeeze LT on a pad-only launch: `pedals=` moves, `turn=0`. Add `pad BACK button 0x18`, press it → `[mode] control mode -> 1`, then LT yaws |
| 4 | **Fire dropout: releasing one alias of a held fire button** (`1`+`SPACE` on `0x40`; `3`+`LCTRL`+`RCTRL` on `0x47`; pad A/X on the same) | Two keys on one address = two wires to one button | Edge state is per **binding**, not per **address**: `previousKeyHeld[k]` is indexed by binding index and `EmitButton(addr, held)` fires on every per-binding edge (`engine/MUNGA_L4/L4PADRIO.cpp:580-595`; same shape in the pad loop `:665-685`). The release writes `-(addr+1)` (`L4CTRL.cpp:2635-2647`) and `ControlsUpdateManager::Update` then suppresses the redundant press, so fire stays **off** until the held key is released and re-pressed | **M** (shared plumbing — **defer**) | `BT_FIRE_LOG=1`: hold LCTRL (missiles firing), tap `3`, release `3` → fire stops with LCTRL still down |
| 5 | **`0x1A`-`0x1D`** Generator A-D ON/OFF (4 radar-rail buttons) | Take a generator off line (heat/power management) | `Generator : HeatSink` (`powersub.hpp:320-322`) and **no `Generator::GetMessageHandlers()` exists**`powersub.cpp:1014` resolves to HeatSink's set `{id 3 ToggleCooling}`, so streamed `msg 4` (`ctrlmap.log:44-47`) finds nothing. `ToggleGeneratorOnOff` @`004b1ed0` is unreconstructed (0 hits in `game/`) | **M** | Click `0x1A`: today zero log. After: generator output voltage → 0 and its ENG-page bar follows |
| 6 | **`0x13`** CROUCH (manual p8, a full section) | Duck to present a smaller target | Streamed `subsys -1 msg 0x1a` (`ctrlmap.log:40`) resolves to the Mech (`ENTITY.cpp:608-615`), but `Mech::MessageHandlerEntries` has exactly three entries — `TakeDamage`, `PlayerLink`, `BalanceCoolant` (`mech.cpp:463-467`). No id `0x1a`. Assets exist (`Mech::duckState` attr 0x37 `mech.cpp:861`, SQUAT clips, `DuckServo01.wav`) | **M** (reconstruct @`0049fa00`) | Click `0x13` on a stationary mech: expect the squat clip + `duckState 0→1` |
| 6 | **`0x13`** CROUCH (manual p8, a full section) | Duck to present a smaller target | Streamed `subsys -1 msg 0x1a` (`ctrlmap.log:40`) resolves to the Mech (`ENTITY.cpp:608-615`), but `Mech::MessageHandlerEntries` has exactly three entries — `TakeDamage`, `PlayerLink`, `BalanceCoolant` (`mech.cpp:463-467`). No id `0x1a`. Assets exist (`Mech::duckState` attr 0x37 `mech.cpp:861`, SQUAT clips, `DuckServo01.wav`) | ~~M~~**COMPLETE** (handler 07-26; consumer + clips + MP 2026-08-06 — [[locomotion]] §CROUCH) | Verified: squat clip plays, holds, rises; peers replicate it |
| 7 | **Torso recenter from the keyboard** (`0x42` is pad/mouse only) | Center a twisted torso — the manual's anti-disorientation control (p9) | `0x42` itself is **LIVE** (streamed `subsys 17 attr 14 -> +0x208` = `Torso::centerCommand`, consumed `torso.cpp:636-640`) but `content/bindings.txt:83,86` bind it only on `pad Y`/`DPAD_UP`. The port's keyboard path (`gBTTorsoRecenter`, `mech4.cpp:2875`) is fed from the zeroed `gBTInput` and its consumer sits inside the stood-down bridge (`mechmppr.cpp:854-863`, gate `:655-660`) | **S** | Twist with `E`, press `X`: `currentTwist` does not return to 0. Add `key X button 0x42` (two rows on one VK both fire — `L4PADRIO.cpp:580-629` iterates all bindings) |
| 8 | **Any clickable button latches if the mouse-up is lost** (worst on `0x3F` reverse, `0x40` trigger) | A momentary click always releases | Neither WndProc handles `WM_CAPTURECHANGED` or `WM_KILLFOCUS` — grep over `engine/` + `game/` returns **zero** hits. Alt-tab or a system dialog between press and release leaves `gCkPressed` set and `SetScreenButton(a,0)` never runs | **M** — trap: `btl4main.cpp:137` calls `ReleaseCapture()` itself, which re-enters `WM_CAPTURECHANGED`; guard on `gCkPressed != -1` (it is cleared before `ReleaseCapture`) | Click-hold `0x3F`, alt-tab, release outside the window: `BT_PAD_LOG=1` shows no release line and `rev` stays 1 |
| 9 | **Every HOTAS / flight stick / rudder** (whole `joydev/joyaxis/joybutton/joyhat` grammar) | Drive the pod channels from a DirectInput device | `content/bindings.txt` is 89 lines and has **zero** joy rows; the joy poll is gated on `joyAxisBindingCount>0 \|\| …` (`L4PADRIO.cpp:775-777`) so `BTJoyInit/BTJoyPoll` are never called. `PadBindingProfile::Load` writes the default only when the file is **absent** (`L4PADBINDINGS.cpp:708-713`), and the file is gitignored (`.gitignore:23`) — so the zip ships **this machine's stale file** | **S** now (ship without it / refresh it) · **M** later (version marker + additive merge) | `content/jstest.log`: `bindings loaded: 40 keys, 10 pad buttons, 5 pad axes, 0 joy axes…`; no `[joy]` line exists in any log in `content/` |
+478
View File
@@ -0,0 +1,478 @@
# 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.
+25
View File
@@ -404,3 +404,28 @@ design change for its own pass. `killCount`/`deathTally` are still zeroed only i
column should read, `+0x280` vs `deathCount`), §Headline-5 (last-hitter-takes-all and
ram kills), and §Headline-6's fidelity question (the wrong-column slip is still
reproduced; it is merely no longer visible).
## Addendum 2026-08-05 — the AUTHENTIC report tail lands (#45/#134)
The kill/damage report sender is no longer the BTPostDamageScore/BTPostKillScore stand-in pair:
`Mech::TakeDamageMessageHandler`'s dark-gap tail (@0x4a02f4-0x4a0890) is reconstructed as
`BTMechPostCombatReports` + `BTMechPostVehicleDead` (btplayer.cpp), dispatching the binary's three
id-0x16 reports (kill to shooter / type-0 wire-fidelity / received to victim) and the BT-extended
0x38 VehicleDead {killed-by player, kill zone} from the victim's handler at the binary's exact
positions. Consequences for this plan:
- KILLS credit path unchanged in shape (victim node -> replicant-player Dispatch reroute ->
killer's master ++killCount) — re-benched cross-node (scorekill.sh: kills=1, award=4.88).
- Suicides now dispatch and the handler negates the award (#134 panic penalty live;
scoreself.sh: type=2 award=-39.00 kills=0).
- Per-hit INFLICTED credit is REMOVED — it never existed in 1995 (@0x4c0200 is bound in no
handler-table entry; scoring = kill awards + received penalties + death costs). SCORE column
behavior changes accordingly; KILLS/DEATHS columns unaffected.
- §Headline-6's wrong-column slip (dual +0x27c increment) remains faithfully reproduced and
replication-masked. The type-1 received report also fixes the console VTVDamaged
points_transfered field (was a Now() misread; it is Round(award), raw disasm @0x4c04a7).
## 2026-08-11 -- SETTLED
The chain was audited end-to-end and fixed at the authority model (ticket #162, build 4.11.885). The definitive references now live in
`context/combat-damage.md` (curated model) and `docs/KD_CHAIN_AUDIT_20260811.md`
(complete evidence). This plan file is historical from here on.
+339
View File
@@ -0,0 +1,339 @@
# The mission-end / exit-hang audit -- full findings (2026-08-11, ticket #163)
> Four-agent audit of the round-end freeze + hang-on-exit family. VERDICT:
> the mission-end ladder is CLEAN on every machine (StopMission never even
> fires in steam fields -- each pod self-ends on its local clock); the bug
> lives in the unlogged exit window: bare ExitProcess with no SteamAPI_Shutdown
> anywhere in the codebase -> intermittent DLL-detach deadlock -> the dead
> process's fullscreen frame squats on screen while its menu child hangs in
> SteamAPI_Init against the wedged sibling (field-captured: ZEUS 22:12:38).
> Fix shipped same day: BTSteamNet_ShutdownAll + ordered exit (flush, steam
> down BEFORE child spawn, hide windows, TerminateProcess) at every exit path.
> Side-yield: #156's fade root cause (POVStartEndRenderable ctor is an empty
> stub, btstubs.cpp:339) + the complete binary end-of-mission ladder.
# #163 — Task 2: The end-of-mission ladder as built, every stall-capable site flagged
Stall-capable sites are tagged **[S#]**. Evidence tiers: [T0]=our engine source read directly; claims about runtime behavior of third-party code marked [T4].
## Stage A — the HOST marshal (game/glass/btl4console.cpp)
**Arming.** btl4main.cpp:1322-1330 — if `BT_FE_EGG` is set, `BTLocalConsole_Start(fe_egg, BT_FE_PODS, atoi(BT_FE_SECS))` runs. For Steam hosting the menu sets `BT_STEAM_NET=1`, `BT_FE_MYFAKE`, `BT_FE_STEAMMAP` and *falls into the marshal-armed default* (btl4main.cpp:1284-1296). `BTLocalConsole_Start` (btl4console.cpp:588-606) copies the args into `marshalState` and `CreateThread(MarshalThread)`; the handle is closed immediately (:604) — **the marshal thread is never joined by anything**.
**MarshalThread** (btl4console.cpp:434-586), step by step:
1. Read the egg (:440-446). Failure → `MarshalLog("cannot read egg...")` and the thread just returns — the pods sit at WaitingForEgg forever **[S0]** (not tonight's shape, but a stall).
2. Connect every pod in `BT_FE_PODS` order, **self first**, sequentially (:452-482): `ConnectWithRetry(host, port, 60)` (:388-432). The Steam branch calls `BTSteamNet_Connect` which **blocks up to 30 s per attempt** (L4STEAMNET.cpp:595-609) — worst case 60×~31 s ≈ 31 min per pod **[S1]**. Any pod that never answers → `BTFE_RelaunchSelfAndExit("")` — the whole host process aborts the start (:473-476).
3. Egg → every pod (:492-497) via `SendEggChunks` (:334-363). The Winsock branch uses **blocking** `send()` — the marshal's raw sockets are never set FIONBIO (only TCP_NODELAY, :423-425) **[S2]**. Errors surface only as a `-1` chunk count in the log; no retry.
4. `Sleep(20s)`; RunMission #1 to all; `Sleep(4s)`; RunMission #2 to all (:500-512). Send results **ignored**.
5. **The mission clock** (:519-570): `mission_end = GetTickCount() + missionSeconds*1000`; loop drains and discards pod→console traffic (select() for real sockets, a nonblocking `BTSteamNet_Recv` pass for Steam pseudo-sockets :559-569). Note: a Steam connection that dies mid-mission is **invisible** here — `closedByPeer` makes Recv return 0/err and the loop just moves on; nothing logs or repairs it.
6. **StopMission at expiry** (:572-576):
```
MarshalLog("mission clock expired -- StopMission to all pods");
for (int p = 0; p < pod_count; ++p)
SendApplicationMessage(pods[p], StopMissionMessageID);
```
`SendApplicationMessage` (:369-382) writes one 28-byte packet (header clientID=4/ApplicationClientID, msg id 6, ReliableFlag). **One shot per pod. No retry. No acknowledge. The return value is ignored. There is no per-pod success/failure log** — only the single "clock expired" line **[S3 — the silent-drop site]**. Over the Steam seam, `BTSteamNet_Send` (L4STEAMNET.cpp:424-442) returns SOCKET_ERROR if the connection is `closedByPeer` (WSAECONNRESET) or if `SendMessageToConnection` fails (WSAEWOULDBLOCK) — all swallowed. Over raw TCP the blocking `send` could also wedge the loop between pods so *later pods in podList never get their stop* **[S4]** (needs a full send buffer; unlikely for 28 bytes).
7. `Sleep(MissionEndGraceSeconds=8s)` (:577), `MarshalClose` every pod (:578-581), `BTFE_RelaunchSelfAndExit("")` (:583-584) → Stage D. During the 8 s grace the marshal **no longer drains** pod traffic (pods push an immediate console score update in EndingMission — btplayer.cpp:1361-1366).
8. **Host-side race** **[S5]**: the host's *own* pod (self, first in podList) receives StopMission over its loop link, finishes its fade in ~3-4 s, and the MAIN thread reaches the BT_FE_LOOP relaunch (btl4main.cpp:1669-1674) → `ExitProcess`*while the marshal thread is still inside its 8 s grace sleep*. Whichever thread reaches `ExitProcess(0)` first kills the other mid-flight; if the main thread is slow (long Shutdown) both can pass `CreateProcessW` → two menu children. Nothing serializes the two relaunch paths.
## Stage B — the receiving pod: StopMission → RunMissions returns
**Delivery.** The 28-byte packet is routed by `networkManager->RoutePacket()`, pumped at least once per frame from `Application::ExecuteBackgroundTask` (APP.cpp:771-781; in non-Running states it routes greedily). Steam-carried console bytes are drained lazily by the seam's recv (L4STEAMNET pump is called from the seam entries).
**Handler chain**: `BTL4Application::StopMissionMessageHandler` (game/reconstructed/btl4app.cpp:765-781, @004d3a94 — plasma display off) → `L4Application::` (L4APP.cpp:693-732 — `PilotIllumination(True)` + a LightsOut self-post at +30 s) → `Application::` (APP.cpp:1706-1778):
- `gBTMissionStoppedByConsole = 1` (:1723-1726); if state==RunningMission: `gBTRoundCompleted=1`, launch pends voided (:1733-1739).
- switch (:1746-1777): `StoppingMission` → ignore; `EndingMission`/`AbortingMission``Stop()`; **default**`SetState(EndingMission)`, `networkManager->Mode(ReliableMode)` (:1762), dispatch `Player::MissionEndingMessage` to `GetMissionPlayer()` (:1759-1769); **if the mission player is NULL → `Stop()` immediately** (:1771-1774).
**The fade.** `Player::MissionEndingMessageHandler` (engine/MUNGA/PLAYER.cpp:106-113): `fadeTimeRemaining = 3.0f; ForceUpdate(); SetSimulationState(MissionEndingState)`. **BTPlayer has NO override**`MESSAGE_ENTRY(BTPlayer, MissionEnding)` (btplayer.cpp:355) resolves to the inherited Player handler; the "fade ForceUpdate site" named in the task brief is PLAYER.cpp:110.
**EVERY condition required to progress from EndingMission to `Stop()`** — the countdown lives ONLY in `Player::ManageApplicationStatus` (PLAYER.cpp:408-476; the MissionEndingState arm :444-472 counts `fadeTimeRemaining -= time_slice` and, at ≤0 with app state EndingMission, dispatches `StopMissionMessage(NullExitCodeID)` back at the application :451-456):
1. A mission player must exist (else Stop() already ran — faster, not a stall).
2. `ManageApplicationStatus` is reached ONLY from the player's active Performance: `Player::PlayerSimulation` (PLAYER.cpp:505, called from `BTPlayer::PlayerSimulation` btplayer.cpp:1329) or `CameraShipSimulation` (PLAYER.cpp:486). **If the mission player's Performance is `HuntForDropZone` (PLAYER.cpp:768 — no MAS call) or `DoNothingOnce` (set at PLAYER.cpp:380 once a dropzone answered the initial hunt; `DoNothingOnce` = `NeverExecute()`, SIMULATE.cpp:484-488 — the entity STOPS EXECUTING ENTIRELY), the fade NEVER counts down and the app sits in EndingMission forever** **[S6 — hard in-engine stall]**. This window exists from mission start until `DropZoneReply` creates the vehicle and sets PlayerSimulation + `AlwaysExecute()` (btplayer.cpp:1771/1780/1784). After that, the reset-based respawn keeps the same Performance through death, so a dead-at-mission-end pilot still fades normally.
3. Fade 3 s of simulated time must elapse; then the re-dispatched StopMission lands in the `EndingMission` case → `Stop()` (APP.cpp:1751-1754) → `executeFrames = False` + `SetState(StoppingMission)` (APP.cpp:818-820).
- There are **no queue-drain gates, no all-entities gates, no peer gates, and no other timers** in this leg.
**Backstop worth knowing**: the solo-clock shim (APP.cpp:674-695) is **ACTIVE in Steam matches** — its only gate is `getenv("BT_RELAY") == 0`, and glass/steam mode does not set BT_RELAY; the glass egg writes `length=` (btl4fe.cpp:277) → `Mission::gameLength > 0` (MISSION.cpp:336-337). So a pod that never receives the marshal's StopMission still self-posts one when its own clock (started at RunningMission entry, APP.cpp:1559) expires. A *lost* StopMission alone therefore should not strand a pod in RunningMission [T0 code-read; runtime unverified].
**RunMissions returning** (engine/MUNGA/APPMGR.cpp:39-273): next frame `ExecuteForeground` returns False (APP.cpp:516-519) → APPMGR calls `application->Shutdown()` (APPMGR.cpp:77-91). `Application::Shutdown` (APP.cpp:828-927): gauge/video/audio `Shutdown()`, viewpoint delete, `interestManager->Shutdown()`, `hostManager->Shutdown()`, `networkManager->Shutdown()` — L4NetworkManager::Shutdown (L4NET.CPP:1296-1411) closes every non-console host via `CloseConnection` = `shutdown(SD_BOTH)` + `BTNetClose` (L4NET.CPP:4658-4659), all nonblocking (engine sockets are FIONBIO throughout, e.g. L4NET.CPP:4554-4558) — deletes the mission, and **returns False** (APP.cpp:925). App is moved to the ended list; the next loop pass finds no apps → `Terminate()` (APP.cpp:934+ — deletes renderers/managers; D3D/DirectSound teardown lives here **[S7, T4]**) → `delete application`**RunMissions returns** (APPMGR.cpp:128-138). Alternate exit: WM_QUIT (window destroyed) → Terminate + return (APPMGR.cpp:102-115).
## Stage C — after RunMissions returns (game/btl4main.cpp:1618-1684)
- :1619 `"[boot] RunMissions returned (mission loop exited)."`**the key forensic marker** separating a Stage-B stall from a Stage-C/D hang.
- :1623-1626 `BTProjectilesClearAll()` — static pool scrub, memory-only.
- :1632-1635 `BTRelayUploadMatchLog()`**relay-only**: gated on `s_relayGameAddrCached` (L4NET.CPP:707-708), a **no-op on Steam nights**. When it does run: bounded 3 s nonblocking connect (L4NET.CPP:738-753), but then an **unbounded blocking SendAll** (L4NET.CPP:770) **[S8, relay only]**.
- :1645-1660 BT_RELAY rejoin — not taken under Steam.
- :1669-1674 `BT_FE_LOOP` set (it always is on menu-launched missions, btl4main.cpp:1248) → `BTFE_RelaunchSelfAndExit("")`.
## Stage D — the exit/relaunch choke point (btl4console.cpp:156-276)
1. `gBTUserRequestedExit` (WM_CLOSE stamp, btl4main.cpp:164-172) → `ExitProcess(0)` directly (:171-181).
2. Storm damper: generation <15 s → `Sleep(5000)` (:193-202).
3. Menu relaunch clears BT_FE_EGG/PODS/SECS/LOOP from the env (:211-217).
4. `CreateProcessW` of the menu child (:259-274). **A CreateProcessW failure is completely silent — no log, no retry — and control still falls to ExitProcess: window closes, no menu appears** **[S9]**.
5. `ExitProcess(0)` (:275) — **the exit-hang site [S10]**. ExitProcess terminates all other threads (the marshal, or the main thread if the marshal got here first) then runs DLL_PROCESS_DETACH serially under the loader lock. **`SteamAPI_Shutdown()` is never called anywhere in the tree** (grep hits only the SDK header, extern/steamworks_sdk_164/public/steam/steam_api.h:107) — so steam_api64/steamclient are detached with live SDR connections and their service threads hard-terminated; a detach-time deadlock there is the standing hypothesis for "BTL4 hang on exit, hosting" [T4 — inference, matches the host being the machine that hosts the most Steam connections]. D3D/driver DLL detach is the sibling candidate [T4].
6. Because every glass-path exit is `ExitProcess`, **CRT atexit/static dtors are skipped** — the RIO dtor's bounded 5 s flush (L4RIO.cpp:1160-1184) and `PCSerialPacket::ShutdownRxThread`'s **unbounded** `WaitForSingleObject(hRxThread, INFINITE)` (L4PCSPAK.cpp:204) **[S11]** only run on the plain `return Exit_Code` path (btl4main.cpp:1683, taken only when BT_FE_LOOP is unset) — and only in serial-RIO builds; glass uses PadRIO which owns no thread (no CreateThread in L4PADRIO.cpp).
## Stage E — is there any all-pods handshake at mission end? NO.
- Start side has three gates: the egg acknowledge (`AcknowledgeEggFileMessage`, L4NET.H:276/424-433), the READY notify (APP.cpp:1466-1470), and the connection gate (L4NET.CPP:1957 comment). **The end has none**: no end-acknowledge message exists (grep across engine/), the marshal reads nothing back after StopMission, and pod teardown never waits on a peer.
- `HostDisconnected` during EndingMission: GameMachineHostType arm just decrements the count and closes (L4NET.CPP:1672-1677) — no state change, no wait. Console loss mid-mission in glass/steam (non-relay, scene presented): the ConsoleHostType arm recreates the console listener and **continues the mission** (L4NET.CPP:1686-1753) — the relay-mode 15 s graceful self-stop (RelayGameDown, L4NET.CPP:2458-2472) does NOT apply outside BT_RELAY.
## Cross-cutting observations relevant to the incidents
- **Thread-safety hole [S12]**: `BTSteamNet_Pump` (SteamAPI_RunCallbacks + unlocked `connections[].ring` writes, L4STEAMNET.cpp:244-306) is invoked from BOTH the marshal thread (via BTSteamNet_Send/Recv/Connect — btl4console.cpp:119-124, 564) and the game thread (seam entries; L4STEAMNET.h:34 documents "game thread"). No lock anywhere. Concurrent RunCallbacks + ring index races are possible on the HOST only (the only process with a marshal) [T0 for the code paths; consequences T4].
- **Frozen-view interpretation** [inference]: a stalled EndingMission does NOT freeze the picture — ExecuteForeground has no EndingMission early-out, the update manager keeps simulating and the renderers keep presenting (APP.cpp:560-565 only overlays pre-run states). A literally static final frame with no menu means the frame loop *stopped* while the window lived: i.e., RunMissions returned (or Terminate/Shutdown hung) and the process then wedged in Stage C/D — most plausibly at ExitProcess [S10] or silent CreateProcessW failure [S9]. Log triage for the frozen machines: `"[boot] RunMissions returned"` present → Stage C/D hang; absent but `StopMission`/plasma-off lines present → Stage B stall ([S6]/[S7]); no stop lines at all → the marshal's send was silently dropped ([S3]) and the solo-clock backstop should then have fired ~launch-skew seconds later — if it didn't, check `length=` in the session egg.
## Stall-site index
| # | Site | File:line | Bound |
|---|------|-----------|-------|
| S0 | egg unreadable → marshal thread exits, pods stranded | btl4console.cpp:440-446 | forever |
| S1 | ConnectWithRetry, Steam branch | btl4console.cpp:388-408 + L4STEAMNET.cpp:595-609 | ~31 min/pod |
| S2 | blocking egg/TCP sends (marshal sockets never nonblocking) | btl4console.cpp:334-363, 409-431 | OS send timeout |
| S3 | StopMission one-shot, errors swallowed, no per-pod log | btl4console.cpp:572-576, 369-382; L4STEAMNET.cpp:424-442 | n/a (silent drop) |
| S4 | blocking StopMission send can starve later pods | btl4console.cpp:573-576 | TCP buffer dependent |
| S5 | main-thread relaunch races marshal's 8 s grace; dual relaunch | btl4main.cpp:1669-1674 vs btl4console.cpp:577-584 | race |
| S6 | fade counts only in PlayerSimulation; DoNothingOnce=NeverExecute strands EndingMission | PLAYER.cpp:380, 444-472; SIMULATE.cpp:484-488 | forever |
| S7 | Terminate/Shutdown renderer teardown (D3D/DSound) | APP.cpp:838-865, 944-973 | unbounded [T4] |
| S8 | matchlog upload blocking SendAll (relay only) | L4NET.CPP:770 | TCP dependent |
| S9 | CreateProcessW failure silent, no menu | btl4console.cpp:259-274 | n/a |
| S10 | ExitProcess w/ live SteamAPI (no SteamAPI_Shutdown anywhere), DLL detach | btl4console.cpp:275; steam_api.h:107 | unbounded [T4] |
| S11 | PCSerialPacket join INFINITE (static-dtor path only, non-glass) | L4PCSPAK.cpp:204 | unbounded |
| S12 | unsynchronized two-thread Steam pump on the host | L4STEAMNET.cpp:244-306; btl4console.cpp:119-124 | corruption risk |
==============================================================================
# Task 4 — Steam console-link delivery of StopMission (ticket #163)
## 1. The exact delivery chain for StopMission under Steam
**Dial timing — at GO, once, never again.** The host's mission process arms the marshal thread at boot (`game/btl4main.cpp:1322-1329``BTLocalConsole_Start`, `game/glass/btl4console.cpp:588-606`). `MarshalThread` dials EVERY podList entry up front, serially, before anything else: `ConnectWithRetry(host, port, 60)` at `btl4console.cpp:470`. For a fake-IP entry the Steam branch (`btl4console.cpp:391-408`) calls `BTSteamNet_Connect` up to 60 times, 1 s apart; each attempt itself blocks up to 30 s in the SDR handshake wait (`engine/MUNGA_L4/L4STEAMNET.cpp:595-609`) — worst case ~31 min of near-silence before the abort at `btl4console.cpp:473-476` (which aborts the mission start for EVERYONE via relaunch). [T1 — read from our own source]
**Token resolution.** `BTSteamNet_Connect` maps the token IP to a SteamID64 via the `tokens[]` table (`L4STEAMNET.cpp:556-568`), loaded ONCE per process at `BTSteamNet_Install` from env `BT_FE_STEAMMAP` (`L4STEAMNET.cpp:354-384`); port 1501 selects P2P virtual channel 0 = console (`L4STEAMNET.cpp:569`, `TokenConsolePort` at `:67`). The connection is a `ConnectP2P` handle wrapped in a pseudo-SOCKET (`0x5EA0xxxx`, `L4STEAMNET.cpp:93-97`).
**Send path — RunMission and StopMission ride the SAME connection.** Egg chunks (`SendEggChunks`, `btl4console.cpp:334-363`), RunMission #1 (+20 s), RunMission #2 (+4 s), then the marshal holds the clock (`:519-570`) draining pod→console bytes, then StopMission at clock expiry (`:572-576`), 8 s grace, then `MarshalClose` all pods (`:577-581`) and relaunch (`:583-584`). There is **no re-dial, no ack, no health check anywhere** — StopMission depends on the connection dialed at GO still being alive ~10 min later. Wire message: 28-byte packet, clientID=4 (ApplicationClientID), messageID=6 (`StopMissionMessageID`, `btl4console.cpp:75`, per `APP.h:383`), sent via `BTSteamNet_Send``SendMessageToConnection(..., k_nSteamNetworkingSend_ReliableNoNagle)` (`L4STEAMNET.cpp:433-435`).
## 2. Every silent-failure point in that chain
1. **Send results are discarded.** `SendApplicationMessage` returns -1 on failure but the return is ignored at all three call sites (`btl4console.cpp:503, 509, 575`). A failed StopMission produces NO log line; the marshal logs only the aggregate "StopMission to all pods" (`:572`) BEFORE sending. [T1]
2. **Send-on-dead-connection is an error only if the death was already noticed.** `BTSteamNet_Send` returns SOCKET_ERROR/WSAECONNRESET only when `closedByPeer` was set by the status callback (`L4STEAMNET.cpp:428-431`), and SOCKET_ERROR/WSAEWOULDBLOCK when `SendMessageToConnection` fails (`:436-440`) — both swallowed by (1). Worse, a reliable send on a dying-but-not-yet-flagged connection returns `k_EResultOK`, buffers the bytes, and they are dropped when the connection is later closed — **undetectable even if (1) were fixed**. [T4 — SDK semantics, not exercised in a bench]
3. **The marshal never learns a console link died mid-match.** The clock-hold drain loop treats Steam recv()==0 (dead: `L4STEAMNET.cpp:459-462`) identically to -1/no-data (`while (BTSteamNet_Recv(...) > 0)`, `btl4console.cpp:564`); the real-socket path likewise ignores `recv`==0 (`:550`). No close callback reaches the marshal; the only trace is the engine-side `[steamnet] connection N closed (…)` line printed from the status callback during a pump (`L4STEAMNET.cpp:219-231`). No reconnect logic exists. [T1]
4. **Mesh/Steam pods have NO console-loss StopMission fallback.** The 15 s self-stop on console loss is RELAY-ONLY (`RelayGameDown`, `engine/MUNGA_L4/L4NET.CPP:2456-2472`). In mesh/steam mode a `ConsoleHostType` disconnect just destroys and re-listens the console host (`L4NET.CPP:1686-1752`, `CreateConsoleHost` at `:885-1023`) — a pod that loses its console link mid-match plays a **forever-mission**: peers time out and freeze as stale replicants — literally "a frozen view of how the game ended" with no menu transition. [T1 code + T2 field, below]
5. **Pump concurrency hazard (host only).** `BTSteamNet_Pump` mutates the global `connections[]`/rings and runs `SteamAPI_RunCallbacks` with zero locking (`L4STEAMNET.cpp:244-306`), and on the HOST it is called concurrently from the marshal thread (via Send/Recv/Connect, e.g. `btl4console.cpp:564`) and the main game thread (via the L4NET seam, `L4NET.CPP:48-96`). Only the host has this exposure. [T1 for the race's existence; T3 for consequences]
6. **Accept is channel-blind.** `BTSteamNet_Accept` ignores which listener polled and dequeues either channel (`L4STEAMNET.cpp:476-501`); the console-host accept path trusts "whatever connects to a console host" (`L4NET.CPP:3876-3894`). Benign so far, but a game-channel dial can be adopted as the console. [T1, no field hits]
7. **Second-round state:** the connection table is per-process (fresh each round — every round is a new process), and mission children get fresh `BT_FE_MYFAKE`/`BT_FE_STEAMMAP` at GO (`btl4main.cpp:1279-1287`). But a MENU relaunch clears only `BT_FE_EGG/PODS/SECS/LOOP` (`btl4console.cpp:211-217`) — menu children carry the PREVIOUS round's token map (visible in the field: relaunched menus log "N roster token(s) incl. self" at boot). The real cross-round asymmetry found is **zombie processes**: a round-N process wedged at exit stays Steam-online into round N+1 (see acaci's ghost lobby seat, below). [T1/T2]
**Receiving side (pod).** `CreateConsoleHost` listens on 1501 (`L4NET.CPP:962-993`) — under Steam the marshal's dial arrives via the P2P channel-0 listen socket, is accepted inside the status callback (`L4STEAMNET.cpp:189-203`), queued, and dequeued by the console host's `BTNetAccept` poll (`L4NET.CPP:3814-3821`); the console host goes OnLine (`:1593-1595`, logs "Connected to ConsoleHost at <token .1>:1501"). Marshal-side death is detected only by `BTNetRecv` returning 0 (or WSAECONNRESET→0) during `CheckBuffers` (`L4NET.CPP:3989-4019`). At mission end the pod does NOT close the console link — teardown explicitly keeps the console host (`L4NET.CPP:1367-1370`); the MARSHAL closes it 8 s after StopMission (`btl4console.cpp:577-581`), which the pod handles as a normal console disconnect + re-listen. So there is no close-while-the-other-side-needs-it on the pod side. [T1]
## 3. Field evidence (scratchpad/night15, six logs; frozen = santo/MS-FIREFLY, acaci/ZEUS; Lynx has NO log)
**StopMission WAS delivered to every logged pod in both incident rounds.** Every mission session on santo ends with the full clean tail `[boot] RunMissions returned` + `[fe] mission over -- relaunching the menu` (14/14, e.g. santo:57956-57957 for the round ending 21:40:50 — the 21:41 incident round — and santo:201693 for the round ending 22:47:21 — the 22:48 incident round). Michael (d:153225-153226) and Dave (a: tail before 218096) likewise ended the 22:47 round cleanly. **No `[steamnet]` close, no ConsoleHost disconnect, no PEER_DOWN anomaly at either incident round-end on any logged machine.** The freeze on the logged machines happened AFTER the last log line of a clean in-process mission end — i.e., in the relaunch/exit seam, NOT in Stop delivery. [T2]
**But genuine console-link death happened that night — twice — and its signature matches the reported symptom exactly.** Mid-round, all pods lost BOTH links to the host token (.1) with `[steamnet] connection N closed (Timeout; remote problem. Rx age server 12-21s ...)` followed by `Disconnected from ConsoleHost at 169.254.77.1:1501`: eleng f:22366-22372 (+ a second episode f:50374-50455) and the santo(e:35887-35922)/rajel(b:20340-20416) round (same round; rajel's clock is -3 h). After the loss the pod keeps simulating with no console and no fallback (finding 4): peers ghost out (`[ghost] replicant 5:29 has received NO update records for 601 frames`, f:~22434) and the mission never ends — each player eventually closed the window by hand (`[marshal] window closed by the user -- exiting for real`, e:36012, f:22510). The "Timeout; remote problem" reason means the HOST process went silent mid-round (crash/wedge, not graceful close) — the host is the machine with the marshal-thread pump race (finding 5). [T2; the race attribution is T3]
**The frozen machines' actual signatures:**
- **santo ~21:41:** round ended cleanly 21:40:50 (e:57957); the menu child (pid 28112, e:57959-57966) booted fully, auto-joined the lobby within 1 s, and received GO normally at 21:43:10 (mission child pid 31404, `relaunched generation`, e:57967). The 2m20s hole between a clean end and the next GO, with a healthy-but-invisible menu, fits a wedged mission-parent window sitting frozen on top of the live menu (the foreground-handoff problem this code already documents, `btl4console.cpp:262-271`). [T2 logs; interpretation T3]
- **acaci 22:48:** ZEUS's log goes SILENT at 22:12:38 — the post-round menu child (pid 29344, c:213897-213900) printed exactly 3 boot lines and never reached `[steamnet] up`, which for a relaunched child is unconditional ~1 s after boot (`BT_STEAM_NET` is inherited; boot-time install at `btl4main.cpp:1306-1316`). It hung at/before `SteamAPI_Init` (`L4STEAMNET.cpp:337`). acaci never played again (file mtime 23:18, nothing after 22:12:38) — **acaci was NOT in the 22:37-22:47 round at all**; yet acaci's SteamID (76561198064247614) still appears as token .2 in the 22:47 lobby map on santo/Michael (e:201702, d:153233) — a ghost lobby seat held by the wedged process. [T2; "hung at SteamAPI_Init" is T3 — the alternative, a user-kill inside the ~1 s boot window, is implausible given the same machine repeated it]
- The same 3-line pre-SteamAPI_Init signature ends EVERY machine's log (Dave 22:47:22 — the exact incident round end, a:218096-218099; eleng 21:47:21; rajel 20:25:37; Michael 23:25:36; santo 23:25:37) and appears mid-night followed by ~1-2 min manual-restart gaps (Dave 20:31:52→20:32:40 a:31170; acaci 21:07:11→21:09:13 c:60457). The relaunch/exit seam wedges routinely; nobody calls `SteamAPI_Shutdown` anywhere (grep: SDK headers only), and every round-end exits via `ExitProcess` from `BTFE_RelaunchSelfAndExit` (`btl4console.cpp:275`) — on the HOST from the marshal thread while the main thread is mid-teardown (`btl4console.cpp:583-584`), matching Lynx's "hang on exit, hosting". [T2 signatures; ExitProcess/DLL-detach mechanism is T4]
## 4. Bottom line for #163
The StopMission delivery chain is a single unmonitored 10-minute-old connection per pod with at least four fully silent failure modes (send-result discarded; error mapped then discarded; reliable-buffer drop; dead-vs-empty indistinguishable in the drain loop) and no mesh-mode fallback on the pod — a real design hole, and the night's two host-death rounds show exactly what its failure looks like (orphaned forever-mission, frozen peers). **However, for the two ticketed incidents the field logs exonerate delivery:** every logged pod received Stop and ended its mission in-process; the freeze and the exit hang live in the round-end relaunch/exit seam (ExitProcess without Steam shutdown; menu children hanging pre-SteamAPI_Init; ghost lobby seats from wedged processes), on which the marshal's host-side thread-kill-at-clock-expiry piles extra risk for the host machine.
==============================================================================
# #163 Field Forensics — night15 (2026-08-10, build 4.11.883)
## 0. Ground work: clock alignment and identity map (all T2 unless noted)
Wall clocks were aligned by matching identical `[lobby] join ... map [...]` strings and identical session-cadence across machines (per instructions, not by raw wall clock):
| Machine | User | Steam ID | Clock offset vs ET | Evidence |
|---|---|---|---|---|
| MS-FIREFLY | santo | 76561198049064449 | **= ET** | own token, santo log line 15 |
| SCREECH-PC | Dave | 76561198022091594 | = ET | sessions 21:30:18/21:40:54… match santo; hosted the 19:40 2-pod round (Dave 766) whose lobby shows .1=…022091594 (eleng line 9) |
| XIAOLONG | Michael | 76561198020775163 | = ET | sessions 21:43:10/21:53:46 match santo; hosted the 23:15 round (Michael 209577) whose map has .1=…020775163 (santo 255102) |
| DESKTOP-QR9VPJQ | Lynx | 76561198659597127 | = ET | matchlog B HDR (his machine) is exactly +1:00:00 vs ALIA's matchlog A of the same events |
| ALIA | eleng | 76561198147980449 | **ET 1:00:00** | glass sessions 20:30:17/20:43:10/…/21:36:48 = santo's 21:30:17/…/22:36:48 to the second |
| ZEUS | acaci | 76561198064247614 | ET 1:00:13 (≈ALIA13s) | ZEUS 21:36:35→21:47:11 pairs with ALIA 21:36:48→21:47:21 |
| GAMERSLAB | rajel | 76561197970523304 | **ET 3:00:00** | rajel 18:40:50/18:43:10/18:53:46… = ALIA 20:40:50/20:43:10/20:53:47 exactly 2h (⇒ 3h vs ET, matching the "~3h behind" note) |
| (no log) | ? | 76561197976435895, 76561197995508393 | — | extra players, day logs not collected |
**CRITICAL CORRECTION TO THE TICKET'S PREMISE [T1]:** `matchlog_20260810_213656_20476.txt` (ALIA, local 21:36) and `matchlog_20260810_223651_12120.txt` (DESKTOP-QR9VPJQ, local 22:36) are **the SAME mission** — the 22:36 ET round — recorded on two machines whose clocks differ by exactly 1h. Proof: identical MISSION run instant (A line 22: `MISSION … w=21:37:21.041 st=4 run` vs B line 22: `w=22:37:20.047`), identical terminal events (A line ~1319: `DEATH victim=2:20 … pos=-27.5,0.0,144.2` + final `SBMIRROR player=8:1 kills=4 deaths=2 wasKills=3 wasDeaths=2`; B lines 1816-1818: same death at `pos=-27.6,0.0,144.2`, same SBMIRROR). Matchlogs are written per-machine (each pod keeps one), confirmed by dev-bench pairs in `scratchpad/night15/mlbak/` (two matchlogs per bench round). There is **no matchlog for the 21:41-incident round** in the staged set. Lynx DID host the 22:36 round (host token .1=…659597127 in the round's map, ALIA line 172106) — and in fact hosted **every round from ET 21:08 through 22:47**, including BOTH incident rounds (santo maps at 36020, 46238, 57965, 68589, 101519, 124366, 156700 all show .1=…659597127).
## 1. The mission-end ladder as it appears in these logs
There are **no** 'EndingMission'/'StopMission'/state-name lines in this build's day logs. The observed healthy end shape (every clean round, every machine) is:
```
[mission] solo game clock expired (600s|1200s) -- ending the mission
[score] gauge read / ~RankAndScore: I think my score is N
[lamp] 0xNN <- 0x0 (×41, cockpit lamps off)
SVGA16::~SVGA16: pixel management statistics + 4 lines
[glasswin] destroy entry #1 windows=0
[glasswin] destroy entry #2 windows=0
[boot] RunMissions returned (mission loop exited).
[fe] mission over -- relaunching the menu <=== LAST LINE the process ever logs
===== BT411 SESSION ... ===== (the relaunched MENU process, new pid)
```
(e.g. santo 57904→57959). ~50-60 lines, wall time ≤3s (the menu header lands the same/next second as the computed 600s expiry). After `[fe] mission over` the process calls `BTFE_RelaunchSelfAndExit("")``CreateProcessW` + `ExitProcess(0)` (game/btl4main.cpp:1671-1673, game/glass/btl4console.cpp:157-275) — **everything after the `[fe]` line is unlogged by construction**.
## 2. Incident A — ~21:41 ET, santo (MS-FIREFLY) frozen
**Round:** ET 21:30→21:40:50, Lynx-hosted, 6 players (santo 46238 map: Lynx.1, eleng.2, acaci.3, santo.4, Dave.5, rajel.6), 600s clock. Michael not in it.
**santo's end sequence is textbook-clean and on time [T1]:**
- 57887-57903: normal in-mission ticks (santo idle: `speedDemand=0`, alive — targeting active; the last nearby deaths are replicants 7:4 at 54956 and 6:4 at 57832, NOT santo).
- 57904 `[mission] solo game clock expired (600s)` → 57906 `~RankAndScore: I think my score is 2686` → lamps → 57948 `SVGA16::~SVGA16` → 57954-5 glasswin destroys → 57956 `RunMissions returned` → 57957 `[fe] mission over -- relaunching the menu`**57959 new MENU session at 21:40:50** (same second as everyone else, below). The log never goes silent mid-ladder and never keeps ticking past the end. (No `[glassperf]` beats exist on this machine — the tag appears only in Dave's and Michael's logs.)
- **Recovery:** the relaunched menu was alive immediately — 57963-57965 it joined the NEXT round's lobby (token .7); santo was playing again at **21:43:10 ET** (57967), ≈2m20s after the freeze report. No manual cold restart visible.
**Same-round healthy comparison (identical ladder shapes, same wall second):**
| machine | EXPIRED line | menu-header line | menu local time (→ET) |
|---|---|---|---|
| santo (frozen) | 57904 | 57959 | 21:40:50 |
| eleng | 73366 | 73424 | 20:40:50 (21:40:50) |
| acaci | 11986 | 12045 | 20:40:37 (≈21:40:50) |
| Dave | 75044 | 75126 | 21:40:54 |
| rajel | 44983 | 45041 | 18:40:50 (21:40:50) |
**Conclusion [inference, flagged]:** the freeze left NO trace in santo's day log. Everything up to and including the menu relaunch executed on time; the only unlogged window is the old glass process's `ExitProcess(0)` path after `[fe]`. The "frozen view of how the game ended" is consistent with the OLD process hanging after spawning the menu — its last rendered frame stays on the fullscreen/plasma window on top while the new menu opens BEHIND (the build even logs `[marshal] foreground handoff denied -- the next window may open behind` for this Z-order hazard, e.g. santo 46239; emit site btl4console.cpp:267-271).
## 3. Incident B — ~22:48 ET, acaci (ZEUS) + Lynx (host) frozen
**Round:** ET 22:36:51→22:47:2x, Lynx-hosted, 8 players (map ALIA 172106: Lynx.1, acaci.2, …976435895.3, santo.4, eleng.5, Michael.6, Dave.7, rajel.8), GO at 22:37:20.0 (matchlog B line 22), 600s ⇒ nominal expiry 22:47:20 ET.
**acaci (ZEUS) end sequence [T1]** — clean, but with the night's most interesting receipt:
- 159680 `[wreck] replicant 2:20 entered wreck state … at (-27.6352,144.223)`**Lynx's mech dying at 22:47:15.6**, the same death both matchlogs end on.
- **159890 `[steamnet] connection 1 closed (closed)` + 159892 `Disconnected from GameMachineHost at 169.254.77.1:1502` — the HOST (Lynx) dropped ZEUS's game link BEFORE ZEUS's own clock expired** (ZEUS's 600s clock ran ~3-10s later than the pack because its RunMission receipt timing; it was the last machine still in-mission).
- 159894 `EXPIRED (600s)` → 159909/159911 second disconnect (`169.254.77.5:1502` = eleng, tearing down in parallel) → 159970 `RunMissions returned` → 159971 `[fe]`**159973 menu session 21:47:11 local (≈22:47:24 ET)**.
- **Recovery:** menu joined the 22:52-round lobby immediately (159979); acaci played the 22:52 ET round (159981, glass 21:52:09 local) — recovered ≤5 min; quit for the night cleanly at 23:14:36 ET (`lastrun_steam_acaci_ZEUS_8.txt` line 29: `[2026-08-10 22:14:36] clean exit: player quit from the menu`; his day log's final 3-line menu stub at 213897 is the systemic buffered-tail loss described in §6, not a wedge).
**Same-round healthy machines:** Michael 153171→153227 (menu 22:47:20), santo 201637→201695 (22:47:21), rajel 192406→192463 (19:47:21=22:47:21), eleng 216583→216639 (21:47:21=22:47:21), Dave 218029→218096 (22:47:22). All within 0-4s of nominal expiry, identical ladders. eleng then quit for the night at 22:47:48 ET (lastrun line 47) — unaffected.
**Lynx (host, no day log):** his own matchlog (B) stops at 1818 `SBMIRROR … w=22:47:15.805` — his own death (`PLAYER_DEAD … player=2:1 deaths=10` at 22:47:15.642, B line ~1813), **5.6s before expiry, respawn never recorded** — and contains no mission-end receipt. He reported "BTL4 hang on exit, hosting" + Task Manager; he is absent from both later lobbies (22:52 map santo 201701; 23:15 map santo 255102) — **never returned**. The pre-expiry link close seen on ZEUS (`(closed)`, i.e. orderly close, not timeout) proves Lynx's process DID start its teardown (its clock expired first); the hang happened later in his exit path [inference].
## 4. Did StopMission ever arrive? (Task 4)
**No — on any machine, at any round, all night [T1]:**
- `grep StopMission` over all six day logs (≈90MB): **0 hits**.
- Every mission end on every machine is the LOCAL fallback: `[mission] solo game clock expired (Ns) -- ending the mission` (14 on santo, 10 on eleng, 8 on acaci, etc.).
- The marshal's only sends all night are launches: `[marshal] RunMission #1 sent to N pod(s)` / `RunMission #2 sent -- mission running; clock Ns` (Dave 766/899, 13401/13588, 16085/16289, 23146/23304, 32151/32319, 50684/50831; rajel 19145/19269; Michael 615/733, 209577/209723). No `[marshal] StopMission` line ever.
- Consequence: rounds end as N independent local expiries skewed by RunMission-delivery jitter (0-13s spread observed), and the host tears its links down whenever ITS clock fires — peers see `Disconnected from GameMachineHost … :1502` pre-expiry when their clock is late (ZEUS 159890 in incident B; also Dave 118701 & 141778, Michael 13749, eleng 60403 & 84435 — all Lynx-hosted rounds).
- Corroborating shape from earlier the same night: when a HOST vanished mid-round (Dave closed his hosting window at 20:21:27 and 21:06:28 ET), peers did NOT transition — they sat in-mission until SteamNetworkingSockets timed out (`Timeout; remote problem. Rx age server 11-21s`: rajel 20340-20416, santo 35887-35922, eleng 22366-22440 & 50374-50455) and each user had to close the window (`RunMissions returned` + `[marshal] window closed by the user`). There is no host-loss or console-driven end path under Steam in the field [T1].
## 5. Matchlog tails vs bench baseline (Task 3)
- Field matchlogs (both machines, incident-B round) end mid-combat with NO end receipt: A ends `SBMIRROR … w=21:47:16.821`, B ends `SBMIRROR … w=22:47:15.805`. Neither contains any `st=6`/stop/`PEER_DOWN` record; the only MISSION line each is `st=4 run`.
- Dev-bench matchlogs (mlbak/, builds 874-885) normally end with **`PEER_DOWN t=… host=1 type=3`** (the console/relay link closing at teardown) — e.g. matchlog_20260811_005558_2240.txt last line, matchlog_20260810_102334_9548.txt last line.
- So both field matchlogs lost their teardown tail. Since ALIA demonstrably transitioned cleanly, the missing tail is NOT diagnostic of the hang; the likely mechanism is the `ExitProcess(0)` relaunch path discarding the matchlog's unflushed stdio buffer (btl4console.cpp:275) [T3].
## 6. Systemic log-tail artifact (don't misread it)
Every collected day log ends with a fresh MENU session header + exactly 3 `[boot]` lines and nothing more (Dave 22:47:22, eleng 21:47:21, rajel 20:25:37, Michael 23:25:36, acaci 22:12:38, santo 23:25:37). The lastrun files prove these menus ran fine and exited cleanly minutes later (ZEUS quit 22:14:36 local; ALIA quit 21:47:48 local; santo's final sessions rotated into `steam_20260810.1.log` — not collected; Dave's post-22:47 activity, including hosting the 22:52 ET round per map token .1=…022091594, is likewise missing from the collected file). The `[boot]` lines carry explicit `std::flush` (btl4main.cpp:1605-1619); the later `[steamnet]`/`[lobby]` lines evidently don't survive the quit path's buffer loss. Truncated final menu stub ≠ wedge.
## 7. Healthy baseline round (Task 1 control): ET 22:22:36 → 22:33:11, Lynx-hosted, 8 players
| machine | glass session (local) | EXPIRED line | menu line | menu local (→ET) |
|---|---|---|---|---|
| santo | 22:22:36 (124368) | 156637 | 156694 | 22:33:11 |
| Michael | 22:22:36 (74169) | 107656 | 107712 | 22:33:10 |
| Dave | 22:22:37 (141879) | 173829 | 173890 | 22:33:11 |
| eleng | 21:22:36 (140157) | 172042 | 172100 | 21:33:11 (22:33:11) |
| acaci | 21:22:23 (83362) | 115098 | 115152 | 21:32:57 (≈22:33:10) |
| rajel | 19:22:36 (113680) | 146649 | 146702 | 19:33:11 (22:33:11) |
Identical ~55-line ladders, all six menus stamped within 1s. The incident-round ladders of the frozen machines are line-for-line indistinguishable from this baseline.
## 8. Synthesis (clearly marked inference where noted)
1. [T1] The in-log mission-end ladder COMPLETED normally and on time on every frozen machine; the freeze lives entirely in the unlogged post-`[fe]` exit path (`BTFE_RelaunchSelfAndExit``ExitProcess(0)`, btl4console.cpp:275).
2. [Inference] "Frozen view of the ended mission" = the old glass process hung inside `ExitProcess` (classic DLL-detach/terminated-thread deadlock territory: SNS threads, D3D, the plasma-window thread) with its last frame still on screen, while the already-spawned menu opened BEHIND it (`AllowSetForegroundWindow` hazard logged at btl4console.cpp:269). Task Manager kill of the OLD pid clears it — exactly Lynx's report.
3. [T1] Both incidents happened in Lynx-hosted rounds; Lynx's own machine hung at exit in incident B while hosting (7 console links + relay + marshal to unwind). In incident B, Lynx's teardown began BEFORE the last peer's expiry (orderly `(closed)` on ZEUS 159890) and then hung.
4. [T1] StopMission is not part of this build's field behavior — every end is the local solo clock; the marshal only launches. Host death mid-round strands every peer in-mission until manual window close (three occurrences 20:08-21:07 ET).
5. [T2] Lynx died in-game 5.6s before the clock and his respawn never appears before the end; acaci and santo were alive at their freezes — a death-at-end race is NOT the common factor across the three frozen instances.
6. Recovery: santo ≈2m20s (auto-relaunch worked; next round 21:43:10 ET); acaci ≈5 min (next round 22:52 ET, quit cleanly 23:14:36 ET); Lynx: killed via Task Manager, never rejoined.
==============================================================================
# Ticket #163 / #156 — TASK 3: The binary's end-of-mission truth
Scope: the 1995 `BTL4OPT.EXE` end ladder, read from `reference/decomp/all/part_*.c` + the T0 WinTesla MUNGA source the port compiles against, with constants byte-read from the image (`scratchpad/rdva.py`) and one raw disasm (`tools/disas2.py`). Everything is [T1] (decompiled + cross-checked against T0/raw bytes) unless tagged.
## 0. Headline answer
**The 1995 end sequence is EVENT → TIME → EVENT, and the only timed leg runs inside the mission player's own simulation tick.** Console `StopMission` (event) puts the app in `EndingMission` (state 6) and dispatches `MissionEnding` to the mission player; the player then counts a **3.0-second** `fadeTimeRemaining` down **in player-Performance time** (`ManageApplicationStatus` @0x42df8c); at ≤0 it self-dispatches a **second `StopMissionMessage`** (event) which, arriving at state 6, calls `Application::Stop()` @0x44e6c0`executeFrames=0` → the mission loop unwinds. **Nothing in the ladder waits on the visual fade** — the fog fade renderable (@0x45447c) is a passive video watcher. **The ladder CAN stall in exactly two binary-grounded ways:** (a) StopMission #1 never arrives — the mission runs forever (1995's console owned the clock; the pod never stops itself — `context/multiplayer.md:639-641`); (b) the mission player's Performance never executes while in `MissionEndingState` — then `fadeTimeRemaining` never decrements, StopMission #2 is never dispatched, and the app parks in `EndingMission` FOREVER. Once the countdown completes, StopMission #2 is a **direct synchronous `application->Dispatch`** (PLAYER.cpp:454-455; @0x42df8c line 740) — it cannot be lost in transit.
## 1. The Application state machine's EndingMission arm (binary addresses)
State enum (T0 `engine/MUNGA/APP.h:188-202`): Initializing=0, WaitingForEgg=1, LoadingMission=2, WaitingForLaunch=3, LaunchingMission=4, RunningMission=5, **EndingMission=6**, StoppingMission=7, Suspending=8, Resuming=9, Aborting=10, CreatingMission=11. App state read as `*(app+0x88)`; the `StateIndicator` sits at app+0x74 with current state at indicator+0x14, and `SetState` = `FUN_0041bbd8` @0x41bbd8 (part_002.c:5510) — which also **pings three watcher chains** (indicator+0x18/+0x2c/+0x40, vtbl+0x14 notify) — this is how the fade renderable learns of state flips.
Binary Application TU (part_007.c:3090-3810):
- **`FUN_0044eeb4` @0x44eeb4 = `Application::StopMissionMessageHandler`** (part_007.c:3698-3737): state 7 → ignore; state 6 or 10 → `Stop()` (@0x44e6c0) — this is the SECOND arrival; default → `SetState(6)`, and if `missionPlayer(app+0x14)` non-null: `networkManager->Mode(0=ReliableMode)` (vtbl+0x30 on app+0x20, part_007.c:3721 == T0 APP.cpp:1762) then dispatch **`Player::MissionEndingMessage {size 0x1c, id 0x19}`** to the player; if NO mission player → `Stop()` directly. Matches T0 APP.cpp:1705-1778 exactly.
- **`FUN_0044ef4c` @0x44ef4c = AbortMissionMessageHandler** — same shape, `SetState(10)`, no ReliableMode switch.
- **`FUN_0044e6c0` @0x44e6c0 = `Application::Stop()`**: `executeFrames(app+0x70)=0`; flush DEBUG stream; `SetState(7)`. (T0 APP.cpp:797-821.)
- **`FUN_0044e488` @0x44e488 = `Application::ExecuteForeground`** (part_007.c:3138-3176): returns 0 immediately when `executeFrames==0`; at state 5 recomputes `secondsRemainingInGame(app+0x58) = mission->length(+0xe0) (NowgameStarted(+0x5c))/tps`; final return is `executeFrames && !Exit_Code` where **Exit_Code = `DAT_004efc98`**. **There is NO pod-side action when the clock hits 0** — app+0x58 is display-only; the stop was the console's job.
- **`FUN_0044e6e8` @0x44e6e8 = base `Application::Shutdown`** (part_007.c:3239-3278): gauge/video/audio renderer `Shutdown+UnlinkFromEntity` (+0x4c/+0x48/+0x44), delete viewpointEntity (+0x6c), interestManager/hostManager/networkManager shutdown, delete `currentMission(+0xC8)`, then **`executeFrames=1; SetState(1=WaitingForEgg); return 0`**.
- Other rungs for contrast: CheckLoad `@0x44ebec` (2→3, and if **no console host** (`FUN_00429078(app+0x2c)`) self-launches RunMission — the console-less path), RunMission `@0x44ecdc` (3→4 + dispatch **MissionStarting id 0x18** to player; 4→5 + `gameStarted=Now()`; else Fail @APP.CPP:0x609 — the port's launch/load-race crash site), Suspend `@0x44ed88` (5→8 + dispatch id 0x19 — suspend ALSO rides the player MissionEnding fade), Resume `@0x44ee10`, KeyCommand `@0x44efd8` ('&'=0x26 → `Exit_Code=1` + `Stop()`).
**There is NO app-side timer in the end arm.** The only end-sequence time constant anywhere is the player's 3.0 s fade (plus the fade renderable's cosmetic 1.0 s, §3). No mission-review display period exists at the app level; the ranking window is a separate per-player display driver (§below).
**Ranking-window behavior at end** — `FUN_0042eb38` @0x42eb38 (part_004.c:1387-1426, called from PlayerSimulation/CameraShipSimulation): at states **6/7 the window flag (player+0x224) is forced 0 (hidden)**; while `secondsRemainingInGame > 30.0` (`_DAT_0042ec2c`=30.0, byte-read) it runs a periodic show/hide machine (+0x228/+0x22c/+0x230/+0x234); at ≤30 s it pins the flag to 1 (solid on). So the authentic end was: standings solid for the final 30 s, then StopMission → **ranking hidden + 1 s fade to black**. (The camera-seat DIRECTOR.cpp:107 additionally shows its ranking during EndingMission — different display, camera seat only.)
**Crucial executability fact [T0]:** `Entity::Execute` (ENTITY.cpp:556-558, quoted in `context/reconstruction-gotchas.md §29:920-934`) calls `PerformAndWatch` only in `RunningMission || EndingMission || IsPreRunnable()` — so the world (and the player's fade countdown) KEEPS TICKING during state 6, and stops at state 7. A screen showing a *frozen last frame* therefore means the app got PAST state 6 (Stop() ran, `executeFrames=0`, renderers no longer execute) but the process never finished Shutdown/relaunch — whereas a player *stuck in state 6* would see a live, still-simulating world that never returns to menu. (Relevance flag for #163 triage, [T4 inference]: post-`RunMissions` in the modern port comes the matchlog-upload dial + relaunch (`context/multiplayer.md:110`, `:541-545`) — a blocking network call there matches "frozen final frame + hang on exit".)
## 2. The Player handlers @0x4bfc20 / @0x4bfbe8
Base handlers (part_004.c:409-430):
- **`FUN_0042d9c0` @0x42d9c0 = base `Player::MissionStartingMessageHandler`**: `fadeTimeRemaining(+0x1F4) = 3.0f`; `SetSimulationState(3=MissionStartingState)`.
- **`FUN_0042d9e0` @0x42d9e0 = base `Player::MissionEndingMessageHandler`**: `fadeTimeRemaining = 3.0f`; **`ForceUpdate` (`+0x18 |= 1`)** — this ships the final player update record (score) under the just-switched reliable mode; `SetSimulationState(4=MissionEndingState)`. Neither touches the Performance pointer or `simulationFlags`.
**BT overrides** (part_013.c:18556-18588; raw disasm of @0x4bfc20 via `tools/disas2.py` this session):
- **`FUN_004bfbe8` @0x4bfbe8 = BTPlayer::MissionStarting**: base @0x42d9c0, then `if (app->state==4 /*LaunchingMission*/ && !(this+0x29 & 0x40 /*NonScoringPlayerBit*/)) currentScore(+0x1c8) = 1000.0f` — the manual's "+1000 starting the game" seed (port: `game/reconstructed/btplayer.cpp:788-806`).
- **`FUN_004bfc20` @0x4bfc20 = BTPlayer::MissionEnding** — full body:
1. base @0x42d9e0 (fade 3.0 + ForceUpdate + state 4);
2. **`this+0x288 = Round(15.0f × DAT_0052140c + 0.5)`** — recovered from raw bytes @0x4bfc3b-0x4bfc52 (`fld 15.0f @0x4bfca4; fmul [0x52140c]; fadd 0.5f @0x4bfca8; call __ftol@0x4dcd94; sub [ebx+0x288],eax`): it **rewinds the 15-second console score-total clock by exactly one period**, so the next PlayerSimulation tick immediately pushes the FINAL total score to the console (see §5). The old export dropped the x87 operand (`FUN_004dcd94()` rendered argless — the §19 gotcha); now pinned.
3. `if (app->state == 8 /*SuspendingMission*/ && playerVehicle(+0x1fc) IsDerivedFrom Mech (tag 0x50bdb4))``FUN_0049fb74(mech, &DAT_00524b38, 1)` (= `Mech::Reset`, per the KB respawn chain — `context/decomp-reference.md` "Respawn SIM path"; the argument blob `DAT_00524b38` semantics unverified [T4]) + `ForceUpdate`. So the operator SUSPEND path resets the vehicle; the normal state-6 path does NOT.
- **No score finalization, no fade rendering, no waiting happens in this handler** — it is fire-and-forget bookkeeping; the actual fade/countdown/exit all run elsewhere.
**The countdown** — **`FUN_0042df8c` @0x42df8c = `Player::ManageApplicationStatus`** (part_004.c:706-752; == T0 PLAYER.cpp:408-476): state 3: `fade = dt`; at ≤0 (`_DAT_0042e0dc`=0.0) → state 2 (VehicleTranslocated) + dispatch RunMission (app state 4) / Resume (state 9). **State 4 (MissionEnding): `fade = dt`; at ≤0 → app state 6 → dispatch `StopMissionMessage(exit 0)` (ctor @0x44f5d0); state 8 → SuspendMission; state 10 → AbortMission.** Called every tick from `Player::PlayerSimulation` @0x42e100 and `Player::CameraShipSimulation` @0x42e0e0 (part_004.c:756-781) — both flavors complete the ladder. (KB nit: `btplayer.cpp:1329` cites base PlayerSimulation as `FUN_0042e168`; @0x42e168 is only the vehicle-position-copy tail — the full base is @0x42e100.)
**When can the countdown not run?** `Simulation::DoNothingOnce` = `NeverExecute()` = `simulationFlags |= DontExecuteFlag` (SIMULATE.cpp:484-488, SIMULATE.h:167-206). In the 1995 binary the master player is parked on DoNothingOnce only **pre-first-spawn** (`Player::HuntForDropZone` @0x42ddcc sets the pmf `PTR_FUN_004e67fc` at part_004.c:669 once the drop zone is found, until DropZoneReply re-arms). The BT death cycle does NOT park it: **`FUN_004c05c4` @0x4c05c4 = BTPlayer::VehicleDead** (part_013.c:19064-19156) contains **no SetPerformance/simulationFlags writes** — a mid-mission-dead player still ticks PlayerSimulation, so a StopMission arriving during a death window still completes the fade in 1995. Its first line is the state-4 gate (`this+0x40==4 → return`): once MissionEnding, all death traffic is swallowed. Also in the -1 arm: deaths dual-increment (+0x280), console **MechKilled id 8** via ctor @0x4c18cc gated `suppressConsole(+0x258)==0`, objectiveMech(+0x284)=killer's vehicle, lives check `role(+0x208)->returnFromDeath(+0x28) < 1` → post Mech id 0x18 (ClearBurningState) to the wreck at +10 s and NO respawn re-post; else re-post VehicleDead to self at +5.0 s (`@0x4c0830`=5.0f, byte-read); then the straight-line death cost gated `advancedDamageOn(+0x264)` via direct call to base ScoreMessageHandler @0x42da20.
## 3. THE FADE (ticket #156)
**Mechanism: a fog-color/fog-range animation on the main view — `POVStartEndRenderable`** (T0 `engine/MUNGA_L4/L4VIDRND.cpp:2126-2394`, binary ctor **`FUN_00454394` @0x454394**, Execute **`FUN_0045447c` @0x45447c**, part_007.c:8468-8606). Not a palette fade, not a gauge overlay, not the warp machinery.
- **Built for the LOCAL (master) player only** by the video builder **`FUN_004d0774` @0x4d0774** (part_014.c:10673-10693): classID 0xBDA (BTPlayer): replicant (`(entity+0x28 & 0xc)==4`) → translocation-warp renderable @0x458d2c; **master → alloc 0x50 → `FUN_00454394(entity, Watcher=2, mainView, mainZone, deathZone, trigger, fogRGB, fogNear/Far, 3, 4)`**. The trigger = the player's **SimulationState attribute** (`FUN_0041bf44(entity,1)`), i.e. the same `StateIndicator` the MissionStarting/Ending handlers write; start state **3** = MissionStartingState, end state **4** = MissionEndingState.
- **State machine** (@0x45447c; myState at +0x1c, timer +0x3c, trigger state read at `trigger+0x14`): 0 WaitForStart → on trigger==3: fog snapped to WHITE (1,1,1) with near/far 0.01/0.05 (screen fully fogged = white flash), AddDynamicRenderable; 1 FlashScreen: hold **0.1 s** (`@0x454740`); 2 FadeIn: lerp fog color/ranges back over **1.0 s** (`@0x454744`); 3 MissionRunning: wait for trigger==4 → 4 FadeOut: fog color × percent-left → black, ranges collapse to 0.01/0.05 (`@0x45474c/0x454750`), over **1.0 s in the binary** — note the WinTesla source's `FADE_OUT_TIME (0.5f)` (L4VIDRND.cpp:2150) is a later edit; the shipped 1995 constant is 1.0 (the same `@0x454744` is reused as the fade-out duration at part_007.c:8584); at expiry → back to state 0 + RemoveDynamicRenderable.
- **What advances it:** the renderer's frame clock (`GetCurrentFrameTime`), NOT the player sim — once state 4 is set it completes even if the player sim is dormant. **What would happen if it never completed: nothing.** The ladder never reads the renderable; `ManageApplicationStatus` is the sole gatekeeper. The fade-out (1.0 s) simply fits inside the 3.0 s window; the pod then sat on a black screen ~2 s before teardown.
- **Why the port has no fade (#156 root cause):** the builder call exists (`game/reconstructed/btl4vid.cpp:311-315`, states 3/4 passed), but **`BTPOVStartEndRenderable`'s ctor is an empty stub — `game/reconstructed/btstubs.cpp:339-342`** — it never registers with the video renderer or the state dial. The white mission-start flash is missing for the same reason. All the plumbing it needs (SetState watcher ping @0x41bbd8, fog style/limits on the D3D renderer) already exists in the port.
## 4. RunMissions exit + what the 1995 pod did next
- **Exit condition** (T0 `engine/MUNGA/APPMGR.cpp:39-161`): `RunMissions` loops foreground/packet-route/background passes; when `application->ExecuteForeground(...)` returns False (binary: `executeFrames==0 || Exit_Code(DAT_004efc98)`@0x44e488) it calls `application->Shutdown(n)`; **Shutdown returning False removes the app from `runningApplications`, and with no apps left RunMissions Terminates them and RETURNS.**
- **1995: the pod did NOT exit** — the L4 (pod) application overrides Shutdown: **`FUN_0047c560` @0x47c560** (part_010.c:2805-2820) = base Shutdown @0x44e6e8 (which re-arms `executeFrames=1` + `SetState(WaitingForEgg)`) then **`return (Exit_Code==0 && app+0xd4==0) ? 1 : 0`** — i.e. the app STAYS in the manager and the pod parks in WaitingForEgg for the console's next egg (the between-rounds attract wait). RunMissions only returned on the '&' keystroke (Exit_Code, @0x44efd8) or the `app+0xd4` shutdown latch (set by the two-phase handler **@0x47c2c4** — first receipt latches +0xd4 and re-posts, second receipt tears down; a console-initiated pod shutdown [T3 on its exact message id]).
- The modern build diverges deliberately: T0 APP.cpp:922-926 replaced the original `return !Exit_Code` with a hard `return False` (`#if 0` preserved in-source), and `engine/MUNGA_L4/L4APP.cpp` has **no** Shutdown override — so every mission end exits RunMissions, then btl4main uploads the matchlog and BT_FE_LOOP relaunches the menu (`context/multiplayer.md:110,541-545`).
## 5. The end-of-mission pod↔console handshake
All pod→console, fire-and-forget over the console link (clientID 5 = ConsoleClientID); **the ladder never waits for any console reply**. Names from the surviving original `game/original/BT/BTCNSL.CPP` (cnslmsgs.cpp) + T0 `engine/MUNGA/CONSOLE.h:11-18`:
| When | Message {size, id} | Binary ctor / site |
|---|---|---|
| Every 10 s AND immediately once app state == **6** | score **DELTA** flush: self-dispatch ScoreUpdate {0x20, 0x1a} → the +0x278 delta cell is sent then **zeroed** | **`FUN_004c083c` @0x4c083c** (part_013.c:19160-19192): `10.0(@0x4c08fc) <= (this+0x10 this+0x28c)/tps \|\| app+0x88==6`; port analog `btplayer.cpp:1361-1427` |
| Every **15 s** while state 5 or 6; forced immediately at MissionEnding by the @0x4bfc20 clock rewind | **ConsolePlayerMechScoreUpdate** (running TOTAL, from +0x1c8) {0x14, id 9} | ctor **@0x4c18f4**; send site part_013.c:19269-19279 (`_DAT_004c0bc0`=15.0, clock at +0x288) |
| Once, when `fadeTimeRemaining ≤ 1.5` in MissionEndingState (i.e. **halfway through the 3 s fade**, while the link is guaranteed still up), one-shot latch +0x254 | state 6: **ConsoleApplicationEndMissionMessage {0x14, id 7} {host, Round(score)}** (added 10/05/95 per BTCNSL.CPP:10); otherwise (abort/suspend): **ConsoleApplicationAbortMissionMessage {0x10, id 0xb}** | **BTPlayer::PlayerSimulation `FUN_004c0904` @0x4c0904**, block part_013.c:19251-19268, Tell string `"Sending EndMission..."` @0x5132d7; ctors @0x42998c / @0x4299b4 |
| On death (any time) | **ConsolePlayerMechKilled** {0x14, id 8} {victim, killer} | ctor @0x4c18cc, sent from VehicleDead @0x4c05c4 (part_013.c:19110-19114), suppressed after eject via +0x258 |
| On eject | DeathWithoutHonor (id 5) | `FUN_004c198c` (KB: decomp-reference EJECT cluster) |
| (team variants) | TeamScoreUpdate {0x14, 0xd} @0x4c191c; TeamEndMission {0x1c, 0xe} @0x4299d8 | |
Plus two non-console end actions: **`networkManager->Mode(ReliableMode)`** at StopMission #1 (@0x44eeb4/T0 APP.cpp:1762) and the base MissionEnding **ForceUpdate** shipping the final player record to peers. Also note part_013.c:19280-19282: while state 5 **or 6**, the player record is re-dirtied (`+0x18 |= 1`) whenever none has shipped for 1.0 s (`_DAT_004c0bc4`) — a 1995 1 Hz player-record keepalive that keeps running through the ending window.
## 6. Stall matrix (the ticket's question, binary-grounded)
| Leg | Driven by | Can it stall? |
|---|---|---|
| StopMission #1 → state 6 | console event over the console link | YES — if it never arrives the mission runs forever (1995 design; modern relay adds the +15 s RelayGameDown fallback, multiplayer.md:519-521). World stays LIVE (not frozen). |
| state 6 → StopMission #2 | **3.0 s of MISSION-PLAYER SIM TIME** (@0x42df8c) | YES — iff the mission player's Performance stops executing while in MissionEndingState (DontExecuteFlag latched, or its Performance never calls ManageApplicationStatus). In 1995 the only such window is pre-first-spawn (HuntForDropZone→DoNothingOnce, part_004.c:669). Result: app parks in state 6 forever — world keeps ticking (ENTITY.cpp:556-558), ranking window hidden (@0x42eb38), never returns to menu. |
| StopMission #2 → Stop() | direct synchronous Dispatch | NO (cannot be lost). |
| Stop() → RunMissions return | next foreground pass: ExecuteForeground→0, then Shutdown (synchronous renderer/network/mission teardown @0x44e6e8) | Only if a Shutdown callee BLOCKS (e.g. a network shutdown on a dead transport). From Stop() onward the screen is a FROZEN LAST FRAME (renderers no longer execute) — the shape #163 describes. |
| visual fade | renderer frame clock, passive watcher | irrelevant — nothing waits on it; in the port it's a stub anyway (btstubs.cpp:339-342). |
+135
View File
@@ -0,0 +1,135 @@
=============================================================================
BT411 -- ALL OPEN TRACKER ISSUES (59), SORTED FOR PLAYTESTERS
Updated 2026-08-10 (evening) -- after Oracle's full annotated sweep, which
closed 35 issues. Thank you Oracle: that is what this list is for.
=============================================================================
HOW TO REPORT: drop a line in Discord with the issue number and PASS /
FAIL / what you saw. Screenshots and rough times help; logs get collected
as usual.
=============================================================================
-----------------------------------------------------------------------------
A. FIXED -- PLEASE VERIFY (headliners first: new fixes fielded THIS build)
-----------------------------------------------------------------------------
#149 Glass cockpit stutter / hitching ** HEADLINER **
First fielded build with the fix. Glass users -- especially Oracle:
mission load and play should feel smooth again (repaints now skip
windows whose gauges did not change).
#150 Double kill credit (one kill counted twice) ** HEADLINER **
First fielded build with the fix (868's double-kills were pre-fix).
TEST: kill someone with a missile volley -- scoreboard +1, not +2.
#69 "Sunder V1" renamed to DENKOU
TEST: vehicle picker shows Denkou -- era witnesses, confirm spelling.
#71 Closing the Plasma window lost it until restart
TEST: X the plasma window -- it minimizes to the taskbar; click restores.
#25 Gamepad turn profile (stick X could not turn the mech)
TEST: RajelAran -- bind an axis to "Turn" in content\bindings.txt.
#27 Autocannon/Gauss dealt the wrong damage type
Oracle asked for a RETEST before closing: ballistics should feel/score
like solid shot (no blast splash).
#38 Mech colors reverted on respawn
TEST: custom paint, die, respawn -- colors stay.
#39 Controls were live before the game screen appeared
TEST: mash fire during load -- nothing happens until the scene shows.
#41 (David/Auric) launcher killed before it could run -- forensics armed
TEST: if join.bat ever fails again, send content\lastrun_join.txt.
#56 MFD buttons drift away from where they are drawn
TEST: alt-tab / focus changes mid-round -- buttons stay aligned.
#147 Range caret vanished for the session (ticks stayed)
Monitoring: if it happens, the log traps the culprit -- just report it.
#158 (Sauron) could not move after respawn until throttle cycled
TEST when it happens: does cycling the throttle free you?
YES -> the #146 release mechanic (expected). NO -> map + spawn spot.
-----------------------------------------------------------------------------
B. OPEN -- HELP US REPRODUCE / NARROW (what to note when it happens)
-----------------------------------------------------------------------------
#24 Controller hat/look view sticks (recovers after wiggling)
#37 Mech torso appears BACKWARDS to other players (transient)
#74 8BitDo gamepad works solo, dead in Steam MP (VexUbiquity)
#102 Owens: full-throttle run -> hold reverse = animation/audio hang
REPRO known (exterior cam, savannah); self-clears on a torso twist.
#125 Peers do not see the FIRST destroyed-zone graphic (2nd hit shows it)
#126 Peer weapon fire invisible AND silent from some positions
#128 Ramming terrain = INSTANT coolant leak -- Oracle re-confirms STILL LIVE
#130 Vulture "skating" (gliding without walk animation)
#132 (Conn Man) persistent yellow line on the HUD in every mech
#135 Coolant leak with NO flashing annunciators -- Oracle re-confirms LIVE
#136 A SHUT leaking loop starts/stops leaking with COMPONENT toggles
(Oracle marked "Clarify" -- one more description pass requested.)
#143 One panel hit -> smoke erupts from MULTIPLE places at once
#153 Basic-mode elevation limit only applies after cycling modes (fix pending)
#154 Generator Out warning fires on the WRONG EDGE (at recovery, not trip)
Refined by Oracle: the stuck-forever gen did NOT reproduce; era rule =
gens return at HALF-cooled (red bar portion gone). Threshold audit due.
#155 PANIC lit: Eng + Weapon MFD banks dead; Score/Radar/Coolant stay live
(Oracle mapped the exact bank split; #50 closed into this ticket.
Flicker half = authentic. Next: is the bank capture authentic too?)
#159 (Conn Man) lag during drops -- missiles / fire exploding in empty space
NOTE: shooter + target + rough time; match receipts measure the rest.
#160 Pod cab: RIO axis dropouts + boot wedges (hardware investigation)
Cab-side only. Bounded-write hardening is in this build; the cage
hardware work continues with Nick.
-----------------------------------------------------------------------------
C. STILL-OPEN ERA QUESTIONS
-----------------------------------------------------------------------------
#103 Mech-to-mech RAM: armor damage or internals only? (Oracle: don't know;
Lynx / Ronin / Draco -- your call. Binary does internals-only.)
#133 Generator X-out on the MFD: Oracle recalls an X "under certain
circumstances" -- WHICH circumstances? (shutdown / overheat / destroyed)
ALSO (#152 rider): did Mid/Adv auto-recentre the torso in the pod, or only
Basic? (Oracle: don't know. Binary says: no auto-recentre.)
-----------------------------------------------------------------------------
D. DESIGN CALLS -- group opinions wanted
-----------------------------------------------------------------------------
#151 Panic eject costs -499: verified REAL 1995 scoring (your death scored
as suicide + death penalty). Keep authentic, or house-rule a flat cost?
#23 Non-glass screen layout: Oracle proposes widescreen-aware sizing --
world view wider + taller, moved up, allowed to overlap the radar.
Opinions welcome before anyone prototypes it.
#77 Resizable Secondary/Radar window (touch-panel cockpits). Want it?
-----------------------------------------------------------------------------
E. KNOWN GAPS -- confirmed and on the list, no need to re-report
-----------------------------------------------------------------------------
#49 Radar/map does not draw the arena WALL geometry
#76 Glass layout save/restore rework (some windows not saved)
#83 INCOMING MISSILE alarm not implemented yet
#90 Flames/smoke from serious damage: less than the original (tuning)
#104 Mad Dog "NARC 15" label: NARC postdates our 4.10 binary (Oracle: added
in a late 4.x release; zero NARC in our decomp) -- content rename due.
#105 Sensor damage effects: Oracle's era spec captured (HUD flicker/loss,
radar loss) -- implementation pending.
#111 Peers see a dying mech's blue warp bubble (should see explosion only)
#112 AFC rounds shotgun across zones (should land as one projectile)
#113 LRM burn/hit state lasts too long
#115 Hot box / target select not connected (Oracle era note: hotbox was a
STANDARD-mode feature; absent in Expert -- gating to reconcile)
#121 Sensor HEAT has no consequences yet
#123 Searchlight: the visible LIGHT + night-fog swap still to come
#145 IR "predator vision": decode done, display not implemented
#156 Fade-to-black at mission end not happening
-----------------------------------------------------------------------------
F. INTERNAL ENGINEERING BACKLOG (nothing to test)
-----------------------------------------------------------------------------
#14 Glass panel unreconstructed button handler backlog (census)
#17 Cavern upper-butte halves lack a collision stream (authenticity TBD)
#21 Weapon recharge arc dark after eng-page use (symptom trail -> #62)
#28 Confirm the vital-subsystem-crit death path can fire (machinery in)
#30 Weapon "unavailable" (level-7) display verification + accessor cleanup
#63 PlayerStatus reads raw 1995 offsets (databinding trap, dormant)
#64 damageZone shadow field (half-fixed; consequences disarmed)
#65 HandleMessage non-virtual across 10 classes (latent dispatch trap)
#72 BTLoadPump swallows WM_QUIT during load (latent)
#107 Destroyed HeatSink annunciates nothing (no lamp for condition 0)
#117 KB note: per-chassis cockpit bounce was deliberate (Ronin) -- FYI
=============================================================================
End of list. 59 open issues total:
12 verify (2 headliners), 17 reproduce, 3 era questions, 3 design calls,
14 known gaps, 11 internal.
35 issues closed 2026-08-10 on Oracle's annotated sweep.
=============================================================================
+204
View File
@@ -0,0 +1,204 @@
# 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.
+9 -2
View File
@@ -14,8 +14,15 @@ 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/mission-registry resolution: `GetMissionRegistry()` / `GetRoleRegistry()->Lookup()` /
`GetMission()->GetGameModel()` have no WinTesla analog → stubbed; base-set `scenarioRole` stands.
- ~~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.
### Link-time externs to be PROVIDED by owning modules (Stage 3)
- `ToggleVoiceAssist`, `Is_Destroyed` (mechmppr)
+24 -1
View File
@@ -254,4 +254,27 @@ Keep all three unconditional (matchlog only arms on `-net`, `matchlog.cpp:66-76`
7. **Do not remove the entity.** Nothing in this plan may issue `DestroyEntityMessage` on death — the wreck stays (P5 teardown crash).
8. **Don't reconstruct dtor glue** while touching the heat/power chain terminus; the trailing base-dtor calls run the chain twice.
9. **`AmmoBin +0x228`** is written to 0 by `@004bd26c` alongside `feedTimer`/`cookOffArmed` and is the object's last dword (`sizeof == 0x22C`); our `ammobin.hpp:270` calls it `reserved // unused`. It is a live per-feed latch — identify it from `AmmoBinSimulation @004bd394` / `FeedAmmo @004bd4f4` before shipping the refill move, or copy the write blind and flag it [T3].
10. **Roster-order sensitivity is a non-risk.** `PoweredSubsystem::RTIS @004b0e6c` gates the electrical restore on the source Generator's `stateAlarm == 2`, but `Generator @004b215c` sets level 2 **unconditionally** (`0x4b2194`) and the death sweep already ran `DeathReset(0)` over the whole roster with no index skip — so no authored segment order can produce a partial re-arm. Do not add ordering logic to `Mech::Reset`.
10. **Roster-order sensitivity is a non-risk.** `PoweredSubsystem::RTIS @004b0e6c` gates the electrical restore on the source Generator's `stateAlarm == 2`, but `Generator @004b215c` sets level 2 **unconditionally** (`0x4b2194`) and the death sweep already ran `DeathReset(0)` over the whole roster with no index skip — so no authored segment order can produce a partial re-arm. Do not add ordering logic to `Mech::Reset`.
---
# ADDENDUM 2026-08-04 — the full-reset audit (operator request): valves, gensel, MFD modes, spawn smoke
Steps 0-5 above all landed via #55 and are byte-consistent (re-audited today).
Four operator/field reports were then grounded and dispositioned:
| report | binary ground truth | disposition |
|---|---|---|
| coolant valves survive respawn | `@004ae534` (MISSING from the Ghidra export -- raw disasm): chains **HeatSink** `0x4ad760` (coolant refill RUNS), then arg-gated `valveState=1` + `massScale=refrigerationFactor`; `Mech::Reset` tail (`call 0x49f788`) re-runs RecomputeCondenserValves | **PORT GAP -> FIXED** (heat.cpp authentic body + mech4.cpp Reset tail call). Bench: detent 5 -> death -> `[respawn] Condenser1 valve detent 5 -> 1` |
| generator switching survives | `@004b215c` sets `generatorOn=1` (landed, #55); `@004b0e6c` only RESOLVES the source link -- **the weapon->generator taps are never rewritten** | gens re-light = already correct; **taps persisting = AUTHENTIC** |
| MFD display mode / control mode survive | mapper vtables `0050f45c` + `0051e440` slots 8-11 read from the shipped exe = plain root bodies -- **displayMode/controlMode have NO reset anywhere** | **AUTHENTIC persist** -- do not "fix" |
| #129 fresh spawns smoke briefly | the pod's respawn ran the per-entity effect cleanup `@004d0c14` | **PORT GAP -> FIXED**: only the master's Mech::Reset ran the cleanup; the REPLICANT un-wreck edge (mechdmg.cpp MechDeathHandler::Tick) rebuilt without it, so the observer's last 10s plume window rode the teleport (peers-only symptom). BTStopEntityPfx now runs on the un-wreck edge |
Bench: `scratchpad/night11/respawnreset.sh` (BT_VALVE_TEST press -> BT_MP_FORCE_DMG
kill -> read `[valve]`/`[respawn]` on A, plume/un-wreck ordering on B).
## Addendum 2026-08-05 (#45 report tail): the VehicleDead SENDER moved
The death-transition dispatch site (mech4) is retired: the decoded sender is the TakeDamage
handler's death tail (@0x4a07d4-0x4a0890), now BTMechPostVehicleDead (btplayer.cpp) with the
BT 0x38-byte extension {killed-by player, kill zone}. The #55 NULL-playerLink fallback + the
DEAD_NOTIFY forensics moved into it intact; the #81 single-dispatch rule is unchanged. The
flow diagram above predates this -- read BTPostKillScore rows as historical (retired 08-05).
+41 -4
View File
@@ -1,4 +1,5 @@
#include <cstdlib>
#include <cstring>
#include "munga.h"
#pragma hdrstop
@@ -978,6 +979,31 @@ 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()
@@ -1076,10 +1102,21 @@ void
//
// Check for the trigger state
//
if (
(!inverseTrigger && new_state == triggerState) ||
(inverseTrigger && old_state == triggerState)
)
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)
{
Check(&audioComponentSocket);
Check(audioComponentSocket.GetCurrent());
+24
View File
@@ -117,6 +117,16 @@ 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(
@@ -125,6 +135,13 @@ 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);
@@ -135,6 +152,7 @@ void
//
if (joints->AreJointsModified())
{
++gBTSegWDirty;
EntitySegment::SegmentTableIterator iterator(segmentTable);
EntitySegment *current_segment;
while( (current_segment = iterator.ReadAndNext() ) != NULL)
@@ -153,6 +171,12 @@ 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,6 +53,17 @@ 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();
@@ -248,6 +259,7 @@ Lamp::Lamp(
previousState = LampStateUndefined;
manager = lamp_manager;
alertActive = 0;
staleAlertNotify = False; // #135
manager->AddLamp(this);
Check_Fpu();
@@ -281,6 +293,10 @@ void
{
NotifyOfStateChange();
}
else
{
staleAlertNotify = True; // #135: deliver at next in-mode Update
}
}
Check_Fpu();
}
@@ -311,6 +327,10 @@ void
{
NotifyOfStateChange();
}
else
{
staleAlertNotify = True; // #135: deliver at next in-mode Update
}
Check_Fpu();
}
+8
View File
@@ -78,6 +78,14 @@ 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;
};
//#########################################################################
+39
View File
@@ -48,15 +48,39 @@ ScenarioRole::ScenarioRole(const CString &role_name, const CString &model_file)
resourceAddress;
killBonus = player_data->killBonus;
specialCaseDeathPenalty = player_data->specialCaseDeathPenalty; // BT 4.10 (restored)
returnFromDeath = player_data->returnFromDeath;
damageReceivedModifier = player_data->damageReceivedModifier;
damageInflictedModifier = player_data->damageInflictedModifier;
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;
@@ -64,6 +88,7 @@ ScenarioRole::ScenarioRole(const CString &role_name, const CString &model_file)
damageBias = 0.0f;
friendlyFirePenalty = 0.0f;
killBonus = 0.0f;
specialCaseDeathPenalty = 0.0f; // BT 4.10 (restored)
returnFromDeath = 0;
}
}
@@ -151,6 +176,20 @@ Dump_And_Die:
return -1;
}
// BT 4.10: read right after KillBonus, fail-hard like every other key
// (binary role reader @00429bec, dest = record slot [1]).
if(
!model_file->GetEntry(
"gamedata",
"SpecialCaseDeathPenalty",
&local_model->specialCaseDeathPenalty
)
)
{
std::cerr << model_name << "Missing SpecialCaseDeathPenalty" << std::endl;
goto Dump_And_Die;
}
if(
!model_file->GetEntry(
"gamedata",
+13 -1
View File
@@ -9,7 +9,12 @@ class NotationFile;
struct ScenarioRole__ModelResource
{
Scalar killBonus, damageReceivedModifier, damageInflictedModifier, damageBias, friendlyFirePenalty;
// BT 4.10 carries specialCaseDeathPenalty as record slot [1], right after
// killBonus (role reader @00429bec dest offsets; the 2007 WinTesla source
// dropped the BT-only field -- restored 2026-08-02, the DefaultRendererRate
// pattern). Consumed by the BT death handler tail (@004c07cd): every death
// with advancedDamageOn costs -specialCaseDeathPenalty score.
Scalar killBonus, specialCaseDeathPenalty, damageReceivedModifier, damageInflictedModifier, damageBias, friendlyFirePenalty;
int returnFromDeath;
};
@@ -20,6 +25,7 @@ public:
protected:
Scalar damageInflictedModifier, damageReceivedModifier, friendlyFirePenalty, damageBias, killBonus;
Scalar specialCaseDeathPenalty; // BT 4.10 role+0x20 (restored 2026-08-02)
CString roleName;
int returnFromDeath;
@@ -61,6 +67,12 @@ public:
return killBonus;
}
Scalar GetSpecialCaseDeathPenalty() const // BT 4.10 role+0x20
{
Check(this);
return specialCaseDeathPenalty;
}
CString GetRoleName() const
{
Check(this);
+13
View File
@@ -96,6 +96,19 @@ 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,6 +23,7 @@ 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
+75
View File
@@ -9,6 +9,10 @@
#include "dxutils.h"
#ifdef BT_GLASS
#include "l4padrio.h"
#ifdef BT_GLASS
#include "l4glasswin.h" // the per-display MFD windows -- created below for
// EITHER RIO (they used to ride the PadRIO ctor)
#endif
#endif
//
@@ -865,6 +869,29 @@ LBE4ControlsManager::LBE4ControlsManager():
while (temp[0] != '\0');
}
//--------------------------------------------------
// The per-display MFD windows (2026-08-06)
//--------------------------------------------------
// These used to be created ONLY at the end of the PadRIO constructor,
// because they began life as the frames around the on-screen RIO button
// banks -- "the buttons ride the device". On a real cab that coupling is
// backwards: selecting the HARDWARE RIO means PadRIO is never built, so
// the MFD panels never appeared at all. Found on the crash cart, where
// the mission came up on the main view with every MFD dark.
//
// The windows are a DISPLAY concern, so create them here -- after the
// L4CONTROLS parse, where every device branch converges -- and let them
// exist whichever RIO won. In pod surface mode there are no on-screen
// buttons on them at all. Create() is idempotent (`gWinCount != 0`
// returns), so the PadRIO call site stays valid and simply arrives first.
//
#ifdef BT_GLASS
if (rioPointer != NULL && BTGlassPanelsActive())
{
BTGlassPanels_Create();
}
#endif
//--------------------------------------------------
// Clear all lamps (and reset analog I/O)
//--------------------------------------------------
@@ -1089,6 +1116,28 @@ LBE4ControlsManager::~LBE4ControlsManager()
rioPointer = NULL;
}
#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.
//
BTGlassPanels_Destroy();
#endif
Check_Fpu();
}
@@ -2624,12 +2673,28 @@ void
Verify(rio_event.Data.Unit < ButtonCount);
temp = rio_event.Data.Unit + 1;
// DIAG (BT_DUCK_LOG): the mode mask a press dispatches under -- the
// crouch-button (0x13) MP-delivery hunt (mode-gated streamed rows).
if (getenv("BT_DUCK_LOG"))
DEBUG_STREAM << "[duck] RIO press unit=0x" << std::hex
<< (int)rio_event.Data.Unit << " modeMask=0x"
<< (unsigned)mode_mask << std::dec << "\n" << std::flush;
Check(&buttonGroup[rio_event.Data.Unit]);
buttonGroup[rio_event.Data.Unit].Update(&temp, mode_mask);
//----------------------------------------
// Save 'pressed' mode mask for 'release'
//----------------------------------------
buttonActivateModeMask[rio_event.Data.Unit] = mode_mask;
// (NO eng-page eject-key hook: REMOVED 2026-08-03 for strict binary
// fidelity. The soft keys 0x0B/0x23/0x03 never pilot-eject in the
// binary -- every page routes them to authored functions (weapon eng
// pages: EjectAmmo 0xB -- a hook here HIJACKED the unjam while armed,
// self-destructing a pilot trying to clear a jam). The flashing
// engEject lamp is the INVITE; the authored press is the pilot KEYPAD
// bank / PANIC key, both live on the desktop.)
break;
case RIO::ButtonReleasedEvent:
@@ -2670,6 +2735,16 @@ void
);
Check(&keyboardGroup[rio_event.Data.Keyboard.Unit]);
{
static int s_klog = -1;
if (s_klog < 0) s_klog = (getenv("BT_PAD_LOG") != 0) ? 1 : 0;
if (s_klog)
DEBUG_STREAM << "[lbe4key] unit "
<< (int)rio_event.Data.Keyboard.Unit
<< " key " << (int)new_key
<< " mode 0x" << std::hex << (unsigned)mode_mask
<< std::dec << "\n" << std::flush;
}
keyboardGroup[rio_event.Data.Keyboard.Unit].
ForceUpdate(&new_key, mode_mask);
+32 -3
View File
@@ -443,8 +443,13 @@ d3d_OBJECT* d3d_OBJECT::LoadObjectBGF(LPDIRECT3DDEVICE9 device, char *fileName)
// drawAsSky) and vanishes -- DECLOUDS carries vertex alpha but must
// stay in the sky pass.
const bool vtxAlphaOp = data.batches[i].vertexAlpha && !isSkyObj;
object->mDrawOps[i].alphaTest = (object->mIsShadow != 0) || vtxAlphaOp;
// BTFX brighten veil (spot cone): route to the blend pass like the
// vertex-alpha cards; the draw-state branch in DrawMesh adds
// dest += emissive x factor instead of SRCALPHA/INVSRCALPHA.
const bool brightenOp = data.batches[i].brightenFactor > 0.0f && !isSkyObj;
object->mDrawOps[i].alphaTest = (object->mIsShadow != 0) || vtxAlphaOp || brightenOp;
object->mDrawOps[i].vertexAlphaBlend = vtxAlphaOp;
object->mDrawOps[i].brightenAlpha = brightenOp ? data.batches[i].brightenFactor : 0.0f;
object->mDrawOps[i].drawAsDecal = false;
object->mDrawOps[i].drawAsSky = isSkyObj; // sky dome -> PASS_SKY (fullbright)
// PUNCH (dpl_Punchize; BT_PUNCH=0 disables): cutout batch -- black texels
@@ -1279,7 +1284,9 @@ void d3d_OBJECT::DrawMesh(int pass, const D3DXMATRIX *viewTransform, Time target
// (SRCALPHA/INVSRCALPHA). Runs only in the blend pass (the op filter
// above routed it there; ALPHABLEND is on and z-write off pass-wide).
DWORD sVLight = 0, sVSrc = 0, sVDst = 0, sVCop = 0, sVCa1 = 0, sVCa2 = 0, sVAop = 0, sVAa2 = 0;
const bool vtxAlphaCard = (drawOp->vertexAlphaBlend && pass == PASS_ALPHABLEND);
const bool brightenCard = (drawOp->brightenAlpha > 0.0f && pass == PASS_ALPHABLEND);
const bool vtxAlphaCard =
((drawOp->vertexAlphaBlend && pass == PASS_ALPHABLEND) || brightenCard);
if (vtxAlphaCard)
{
mDevice->GetRenderState(D3DRS_LIGHTING, &sVLight);
@@ -1292,13 +1299,35 @@ void d3d_OBJECT::DrawMesh(int pass, const D3DXMATRIX *viewTransform, Time target
mDevice->GetTextureStageState(0, D3DTSS_ALPHAARG2, &sVAa2);
mDevice->SetRenderState(D3DRS_LIGHTING, FALSE);
mDevice->SetRenderState(D3DRS_SRCBLEND, D3DBLEND_SRCALPHA);
mDevice->SetRenderState(D3DRS_DESTBLEND, D3DBLEND_INVSRCALPHA);
mDevice->SetRenderState(D3DRS_DESTBLEND,
brightenCard ? D3DBLEND_ONE : D3DBLEND_INVSRCALPHA);
if (brightenCard)
{
// BTFX brighten veil: dest += vertexRGB x factor. SPOT.BGF's
// verts author the beam tint (cyan-white 0.12/0.98/1.0, alpha
// 1.0 -- no fade gradient); the factor rides TFACTOR alpha.
// [T3 tint compose]: the night material ALSO authors emissive
// (0.9,0.4,0.2 warm) -- whether the DIV board tinted the veil
// by vertex, emissive, or their product is unconfirmed; vertex
// (a white-blue searchlight) matches the authored geometry
// gradient model. Flip here if era-look evidence says warm.
mDevice->SetRenderState(D3DRS_TEXTUREFACTOR,
D3DCOLOR_COLORVALUE(1.0f, 1.0f, 1.0f, drawOp->brightenAlpha));
mDevice->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_SELECTARG1);
mDevice->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_DIFFUSE);
mDevice->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_TFACTOR);
mDevice->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_SELECTARG2);
mDevice->SetTextureStageState(0, D3DTSS_ALPHAARG2, D3DTA_TFACTOR);
}
else
{
mDevice->SetTextureStageState(0, D3DTSS_COLOROP,
drawOp->texture.texture != NULL ? D3DTOP_MODULATE : D3DTOP_SELECTARG2);
mDevice->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE);
mDevice->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_DIFFUSE);
mDevice->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_SELECTARG2);
mDevice->SetTextureStageState(0, D3DTSS_ALPHAARG2, D3DTA_DIFFUSE);
}
}
const bool copCut = (drawOp->copRole == 2 && iOp > 0
File diff suppressed because it is too large Load Diff
+17
View File
@@ -90,6 +90,23 @@ L4GaugeRenderer::L4GaugeRenderer(bool windowed, int *secondaryIndex, int *aux1In
char
*plasma_string = getenv("L4PLASMA");
//
// L4PLASMA=NONE / OFF / 0 -- no plasma marquee at all (2026-08-06).
// A real cab drives the 128x32 marquee over serial; a rebuilt cart may
// not have one wired, and the GLASS profile force-defaults L4PLASMA to
// SCREEN, which puts a desktop plasma window on the pod's glass where
// nothing should be. Leaving the variable UNSET is not an option
// there (the profile fills it in), so give "off" an explicit spelling.
//
if (plasma_string != NULL
&& (_stricmp(plasma_string, "NONE") == 0
|| _stricmp(plasma_string, "OFF") == 0
|| strcmp(plasma_string, "0") == 0))
{
Tell("Plasma display disabled (L4PLASMA=none)\n");
plasma_string = NULL;
}
if (plasma_string != NULL)
{
//Win32 Serial support: ADB 06/30/07
+57
View File
@@ -36,6 +36,7 @@ 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
@@ -458,6 +459,17 @@ 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;
@@ -466,6 +478,21 @@ void
<< (pressed ? "HELD" : "released") << "\n" << std::flush;
}
// PANIC (0x3D) -> pilot-keypad key. The binary's ONLY pilot-eject input
// is keyboardGroup[KeyboardPilot].Add(mode 0x200000, mech, msg 0x19)
// (FUN_004d266c; exhaustive scan: no other 0x200000 consumer). The
// guarded PANIC key has no button-space consumer anywhere in the image,
// so on the pod it reported through the pilot keypad matrix -- this is
// that wire.
if (address == 0x3D && pressed)
EmitKeypad(LBE4ControlsManager::KeyboardPilot, 0);
// (The ENG-PAGE EJECT KEY intent-completion lives in the game-thread RIO
// drain -- LBE4ControlsManager::ProcessRIOEvent, ButtonPressedEvent --
// where mode_mask is safely in hand. A first cut queried the mode
// manager HERE, on the window-click thread, and crashed on the unbound
// TU global (field-caught 2026-08-03).)
RIOEvent event;
event.Type = pressed ? ButtonPressedEvent : ButtonReleasedEvent;
event.Data.Unit = address;
@@ -479,6 +506,9 @@ void
event.Type = KeyEvent;
event.Data.Keyboard.Unit = unit;
event.Data.Keyboard.Key = key;
if (getenv("BT_PAD_LOG"))
DEBUG_STREAM << "[padkey] unit " << unit << " key " << key
<< " queued\n" << std::flush;
PushEvent(event);
}
@@ -524,6 +554,33 @@ void
DEBUG_STREAM << "[btntest] RELEASE 0x" << std::hex << s_btnAddr
<< std::dec << " at poll " << s_poll << "\n" << std::flush;
}
// BT_BTNTEST2="addr,pressPoll,releasePoll": an optional SECOND
// scripted cycle (e.g. crouch then rise) on the same poll clock.
static int s_bt2Addr = -2, s_bt2On = 0, s_bt2Off = 0, s_bt2State = 0;
if (s_bt2Addr == -2)
{
s_bt2Addr = -1;
const char *e2 = getenv("BT_BTNTEST2");
if (e2 != NULL)
sscanf(e2, "%i,%i,%i", &s_bt2Addr, &s_bt2On, &s_bt2Off);
}
if (s_bt2Addr >= 0)
{
if (s_bt2State == 0 && s_poll >= s_bt2On)
{
s_bt2State = 1;
EmitButton(s_bt2Addr, 1);
DEBUG_STREAM << "[btntest] PRESS2 0x" << std::hex << s_bt2Addr
<< std::dec << " at poll " << s_poll << "\n" << std::flush;
}
else if (s_bt2State == 1 && s_poll >= s_bt2Off)
{
s_bt2State = 2;
EmitButton(s_bt2Addr, 0);
DEBUG_STREAM << "[btntest] RELEASE2 0x" << std::hex << s_bt2Addr
<< std::dec << " at poll " << s_poll << "\n" << std::flush;
}
}
}
}
+49 -4
View File
@@ -410,6 +410,20 @@ 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
{
//--------------------------------------------------------------
@@ -418,6 +432,11 @@ 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
@@ -459,11 +478,37 @@ void PCSerialPacket::SendPacket(BYTE *srcPtr) //sends packet
return;
}
}
DWORD bytesWritten;
GetOverlappedResult(hComm, &overlapSend, &bytesWritten, TRUE);
if(bytesWritten != length + 1)
//
// 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)
{
DEBUG_STREAM << "Not all data was written to serial port!\n" << std::flush;
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;
}
delete buffer;
}
+21 -1
View File
@@ -25,7 +25,11 @@ static LRESULT CALLBACK
return 0;
case WM_CLOSE:
ShowWindow(window, SW_HIDE); // hide only; the renderer owns it
// 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);
return 0;
}
return DefWindowProcW(window, message, wparam, lparam);
@@ -50,6 +54,22 @@ 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,6 +1240,105 @@ 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
{
@@ -1264,6 +1363,44 @@ 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;
@@ -1377,6 +1514,20 @@ 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.
@@ -1423,11 +1574,13 @@ 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;
@@ -1568,6 +1721,9 @@ void
if (operational && !TestModeActive)
{
SendPacket((Byte *) request_analog_string);
++gRioReqCount;
gRioReqPending = 1;
gRioLastReqTick = GetTickCount();
}
Check_Fpu();
}
+46
View File
@@ -632,3 +632,49 @@ 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,6 +32,10 @@ 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).
+60 -1
View File
@@ -692,6 +692,28 @@ 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
@@ -739,6 +761,34 @@ void SVGA16::ExpandPlaneToBGRA(int mask, int paletteID, int monoTint, int rotate
return;
}
// 180 degrees (rotate 2): dimensions UNCHANGED -- the case a landscape
// replacement panel needs when the 1995 glass was a portrait CRT mounted
// the other way up. Added 2026-08-06 from the crash cart, whose radar is a
// landscape LCD: 90 CW left the picture portrait and overhanging its
// monitor, and a further 90 CW is this.
if (rotateQuadrant == 2)
{
for (int oy = 0; oy < h; oy++)
{
unsigned long *d = dst + oy * w;
for (int ox = 0; ox < w; ox++)
{
Word s = base[(h - 1 - oy) * w + (w - 1 - ox)];
if (monoTint < 0)
{
PaletteTriplet *pe = &(pal->paletteData.Color[s & mask]);
d[ox] = ((unsigned long)pe->Red << 16) |
((unsigned long)pe->Green << 8) | (unsigned long)pe->Blue;
}
else
d[ox] = (s & mask) ? (unsigned long)monoTint : 0UL;
}
}
if (outW) *outW = w;
if (outH) *outH = h;
return;
}
// 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).
int ow = h, oh = w;
@@ -1180,6 +1230,15 @@ void BTDrawCockpitPanels(LPDIRECT3DDEVICE9 device)
}
}
// (NO full-face "flash overlay" here -- twice field-broken 2026-08-03.
// The side buttons are BIG rects deliberately tucked UNDER the surfaces;
// step 2 already animates their lamp shades, and the protruding edge IS
// the port's lamp light (the pod's backlit keys sat beside the CRT --
// there is no in-display face to light). An on-top redraw either covers
// the MFD imagery with flashing faces, or -- drawn without resetting the
// texture state DrawDevSurface leaves bound -- smears the gauge atlas as
// striped garbage (operator captures: green stripes, then red faces).)
// 4) Flight-block labels (on top; the blue faces aren't covered by surfaces).
for (int i = 0; i < L.buttonCount; i++)
{
@@ -1293,7 +1352,7 @@ void BTCockpitMouseUp(void)
// that turn fires only ONCE PER FULL GAUGE CYCLE (foreground -> background -> copy).
// The cycle only advances to the next foreground turn after the THROTTLED background
// gauge sweep drains the whole active-instrument list -- and that background task
// starves under load (issue #45: "every cockpit instrument freezes at its last paint
// starves under load (field reports: "every cockpit instrument freezes at its last paint
// while the underlying values AND fps stay perfectly healthy"). So on a busy MP
// mission the lamp sweep ran ~1x/second: the lit buttons froze / flashes stalled while
// the 3D view (a separate per-frame foreground render) stayed smooth -- exactly the
+6
View File
@@ -419,6 +419,12 @@ 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;
};
//########################################################################
+123
View File
@@ -4361,6 +4361,12 @@ void
fogFar = searchLightFogFar;
if(fogUpdating)
{
// BT (searchlight, 2026-08-05): the stubbed dpl_SetViewFog below also
// carried the PLANES; the per-frame FOGSTART/FOGEND application reads
// currentFogNear/Far, so the swap must land there too (the color line
// alone left the fog DISTANCES stuck at load values).
currentFogNear = fogNear;
currentFogFar = fogFar;
mDevice->SetRenderState(D3DRS_FOGCOLOR, D3DCOLOR_XRGB((int)(255 * fogRed), (int)(255 * fogGreen), (int)(255 * fogBlue)));
// dpl_SetViewFog(
// dplMainView,
@@ -4381,6 +4387,8 @@ void
fogFar = noSearchLightFogFar;
if(fogUpdating)
{
currentFogNear = fogNear; // see the On case
currentFogFar = fogFar;
mDevice->SetRenderState(D3DRS_FOGCOLOR, D3DCOLOR_XRGB((int)(255 * fogRed), (int)(255 * fogGreen), (int)(255 * fogBlue)));
// dpl_SetViewFog(
// dplMainView,
@@ -8433,6 +8441,107 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
lastFrameTime = mTargetRenderTime;
currentFrameTime = Now();
// #156 -- the MISSION START/END FADE (POVStartEndRenderable, ctor @00454394,
// Execute @0045447c; the port class is armed by MakeEntityRenderables'
// BTPlayer master case via BTMissionFadeArm). The 1995 renderable drives
// the POV zone fog through a 5-phase machine keyed on the local player's
// SimulationState; constants byte-read from BTL4OPT.EXE @0x454740-0x454750:
// flash hold 0.1s, fades 1.0s, plane floors near 0.01 / far 0.05. Phases:
// 0 armed -> (MissionStarting) 1 WHITE FLASH (fog 1,1,1 @ 0.01/0.05) ->
// 2 FADE-IN (white -> live style; planes floor -> authored) -> 3 running ->
// (MissionEnding) 4 FADE-OUT (color x local -> BLACK, planes close) ->
// re-arm. The binary re-reads the LIVE fog each frame mid-fade (style
// swaps track); we read the STYLE members (fogRed/G/B, fogNear/Far), never
// currentFogNear/Far which we are overriding. Placed at the frame head:
// the Clear below samples FOGCOLOR, so the fade owns the WHOLE frame
// including sky/void. Receipts: [fade] phase lines under BT_FADE_LOG or
// BT_FOG_LOG.
{
extern void *gBTFadeIndicator; // BTMissionFadeArm (btl4vid.cpp)
extern int gBTFadeStart, gBTFadeEnd;
static int s_phase = 0;
static float s_T = 0.0f;
if (gBTFadeIndicator != 0)
{
const float kFlash = 0.1f, kFade = 1.0f; // @0x454740 / @0x454744
const float kNearFloor = 0.01f, kFarFloor = 0.05f; // @0x454750 / @0x45474c
int st = ((StateIndicator *)gBTFadeIndicator)->GetState();
float fdt = (float)dT;
if (fdt < 0.0f) fdt = 0.0f; else if (fdt > 0.1f) fdt = 0.1f;
float r = -1.0f, g = 0.0f, b = 0.0f, fn = 0.0f, ff = 0.0f;
switch (s_phase)
{
case 0:
if (st == gBTFadeStart) { s_phase = 1; s_T = 0.0f; }
break;
case 1:
r = 1.0f; g = 1.0f; b = 1.0f; fn = kNearFloor; ff = kFarFloor;
s_T += fdt;
if (s_T >= kFlash) { s_phase = 2; s_T = 0.0f; }
break;
case 2:
{
float local = kFade - s_T; if (local < 0.0f) local = 0.0f;
float el = kFade - local;
r = (1.0f - fogRed) * local + fogRed;
g = (1.0f - fogGreen) * local + fogGreen;
b = (1.0f - fogBlue) * local + fogBlue;
fn = fogNear * el + kNearFloor;
ff = fogFar * el + kFarFloor;
s_T += fdt;
if (local <= 0.0f) { s_phase = 3; r = -1.0f; } // fog back to the style
break;
}
case 3:
if (st == gBTFadeEnd) { s_phase = 4; s_T = 0.0f; }
break;
case 4:
{
float local = kFade - s_T; if (local < 0.0f) local = 0.0f;
r = fogRed * local;
g = fogGreen * local;
b = fogBlue * local;
fn = fogNear * local + kNearFloor;
ff = fogFar * local + kFarFloor;
s_T += fdt;
if (local <= 0.0f) { s_phase = 0; } // re-armed; final frame stays black
break;
}
}
if (r >= 0.0f)
{
currentFogNear = fn;
currentFogFar = ff;
mDevice->SetRenderState(D3DRS_FOGCOLOR,
D3DCOLOR_XRGB((int)(255 * r), (int)(255 * g), (int)(255 * b)));
mDevice->SetRenderState(D3DRS_FOGENABLE, TRUE);
}
else if (s_phase == 3 || s_phase == 0)
{
// Out of the fade: restore the style planes once so a fade that
// ended mid-style leaves the world exactly on its authored fog.
static int s_prev = -1;
if (s_prev != s_phase)
{
currentFogNear = fogNear;
currentFogFar = fogFar;
mDevice->SetRenderState(D3DRS_FOGCOLOR,
D3DCOLOR_XRGB((int)(255 * fogRed), (int)(255 * fogGreen),
(int)(255 * fogBlue)));
}
s_prev = s_phase;
}
static int s_lastLogged = -1;
if (s_phase != s_lastLogged)
{
s_lastLogged = s_phase;
if (getenv("BT_FADE_LOG") || getenv("BT_FOG_LOG"))
DEBUG_STREAM << "[fade] phase " << s_phase
<< " (simState=" << st << ")\n" << std::flush;
}
}
}
// DIAG (turn-hitch hunt): time the render phases -- draw CPU vs Present
// (GPU-queue block). Logged on slow frames + 1 Hz stats.
LARGE_INTEGER _rt0; QueryPerformanceCounter(&_rt0);
@@ -9026,6 +9135,20 @@ 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;
}
}
+55 -2
View File
@@ -350,6 +350,12 @@ struct MatInfo {
// EMISSIVE (tag 0x26): pure-emissive materials (diffuse black) render as an
// unlit glow -- tex x emissive (btpolar:pintBIceEmit_mtl, the polar ice).
bool hasEmissive = false;
// BTFX "brighten" class (SPOT.BGF searchlight beam cone): the material
// smuggles its ADDITIVE factor in DIFFUSE.r (brighten.25 authors diffuse
// {0.25,0,0}) and its glow colour in EMISSIVE ({0.9,0.4,0.2} on the night
// page). 0 = not a brighten material; >0 = draw as an additive veil,
// dest += emissive x factor (the 1995 DIV brighten semantics).
float brightenFactor = 0.0f;
float emissive[3] = {0,0,0};
};
@@ -495,12 +501,14 @@ struct MaterialResolver {
std::string name, texName, rampName;
MatInfo info;
bool haveColor = false;
float rawDiffuseR = -1.0f; // brighten-class factor carrier
for (const Chunk& ch : c.children) {
if (ch.id == TAG_NAME) name = chunkStr(ch);
else if (ch.id == TAG_MATERIAL_TEXTURE && ch.len > 1) texName = chunkStr(ch, 1);
else if (ch.id == TAG_RAMP_REF) rampName = chunkStr(ch);
else if (ch.id == TAG_DIFFUSE && ch.len >= 12) {
info.color = packColor(rdF32(ch.data), rdF32(ch.data + 4), rdF32(ch.data + 8));
rawDiffuseR = rdF32(ch.data);
haveColor = true;
info.hasDiffuse = true;
} else if (ch.id == TAG_AMBIENT && ch.len >= 12 && !haveColor
@@ -520,6 +528,12 @@ struct MaterialResolver {
info.hasEmissive = (info.emissive[0] + info.emissive[1] + info.emissive[2]) > 0.001f;
}
}
// BTFX brighten class: name-gated so ordinary materials keep
// their diffuse semantics untouched (the factor rides only in
// "brighten*" records -- brighten.25/.5/.75 in btfx.bmf).
if (name.compare(0, 8, "brighten") == 0 && rawDiffuseR > 0.0f)
info.brightenFactor = rawDiffuseR;
// Resolve the ramp reference to its low/high colours: this
// file's own definitions first, then the cross-library
// registry (mech skins reference 'softer' defined elsewhere).
@@ -640,10 +654,12 @@ struct Builder {
bool currentTSphere = false; // material is tsphere_mtl (translocation warp): ramp it despite normals
bool meshIsTSphere = false; // this OBJECT is the translocation warp -> smooth-tessellate the cone
bool meshIsCop = false; // this OBJECT is a *_cop cockpit canopy shell (task #55)
std::string meshStem; // lowercased load stem (BT_MAT_LOG diag)
std::map<const void*, int> copRoleMap; // punch-kit roles per PMESH chunk: 1=mask 2=hull 3=skip (see TAG_PATCH)
int currentCopRole = 0; // role of the pmesh being built (consumed by buildPmesh)
bool currentHasEmissive = false;
float currentEmissive[3] = {0,0,0};
float currentBrighten = 0.0f; // btfx brighten-class additive factor
// per-vertex scratch (normals accumulated from forward triangles only)
std::vector<float> px, py, pz, nx, ny, nz, uu, vv;
@@ -778,7 +794,19 @@ struct Builder {
// value is not recoverable from the code (texture-less ramp = no texel
// luminance to index); default (0.13,0.12,0.15) [T3] matches the dark
// near-black frame in pod gameplay footage. BT_COP_FRAME="r g b" overrides.
if (useRamp && currentTex.empty() && meshIsCop) {
// The same interior-structure treatment applies to the blakskn SKIN
// family on ANY mesh, not just *_cop shells: the inside skeletons
// author pilot-facing surfaces of the OWN-BODY meshes (thor thx_msl
// missile pod, owens owx_* legs -- issue #91) with the identical
// "<pfx>skin:blakskn_dz_*" material the canopy frame uses, and the
// board shaded them all the same way. Keying on the filename alone
// left those surfaces vcol=pure-black -- the reported "black
// rectangle" was the thor pod's blakskn mount plate at RGB(0,0,0)
// instead of the frame constant. mechfx:blakskn_mtl (the tshd
// shadow quads) must NOT match -- key on the skin-lib "_dz_" form.
const bool blaksknSkin =
currentMatName.find("skin:blakskn_dz_") != std::string::npos;
if (useRamp && currentTex.empty() && (meshIsCop || blaksknSkin)) {
auto CB = [](float ff){ int v=(int)(ff*255.0f+0.5f); return (uint32_t)(v<0?0:v>255?255:v); };
vcol = 0xFF000000u | (CB(copFrameRGB(0))<<16) | (CB(copFrameRGB(1))<<8) | CB(copFrameRGB(2));
}
@@ -913,6 +941,7 @@ struct Builder {
batch.shadowMat = currentShadowMat;
batch.hasEmissive = currentHasEmissive;
for (int i = 0; i < 3; ++i) batch.emissive[i] = currentEmissive[i];
batch.brightenFactor = currentBrighten; // btfx additive veil (spot cone)
// SUBMISSION-ORDER DEPTH BIAS (additive objects): the content layers
// EXACTLY-COPLANAR shells (AR02 LOD2: solid concrete + a coplanar
// PUNCH-cutout overlay + an inner shell, plane separations
@@ -939,6 +968,28 @@ struct Builder {
else
batch.lodBias = 0.0f;
mesh->batches.push_back(batch);
// #91 diag: BT_MAT_LOG=<substr> dumps each batch of a matching mesh
// stem -- the material routing decision (ramp vs lit vs emissive,
// resolved colour, texture) for the inside-view black-mesh hunt.
{
static const char* s_matLog = getenv("BT_MAT_LOG");
if (s_matLog && *s_matLog
&& meshStem.find(s_matLog) != std::string::npos)
fprintf(stderr, "[matlog] %s mat=%s color=%08X hasDiff=%d "
"tex='%s' ch=%d hasRamp=%d useRamp=%d norms=%d "
"emis=%d vcol=%08X tris=%u\n",
meshStem.c_str(), currentMatName.c_str(), currentColor,
(int)currentHasDiffuse, currentTex.c_str(),
currentTexChannel, (int)currentHasRamp, (int)useRamp,
(int)hasNormals(vtag), (int)pureEmissive, vcol,
idxCount / 3);
if (s_matLog && *s_matLog
&& meshStem.find(s_matLog) != std::string::npos)
fprintf(stderr, "[matlog] ramp lo=(%.3f,%.3f,%.3f) "
"hi=(%.3f,%.3f,%.3f)\n",
currentRampLo[0], currentRampLo[1], currentRampLo[2],
currentRampHi[0], currentRampHi[1], currentRampHi[2]);
}
if (getenv("BT_COP_DUMP") && currentHasRamp && currentTex.empty()) {
float lo[3]={1e9f,1e9f,1e9f}, hi[3]={-1e9f,-1e9f,-1e9f}, cen[3]={0,0,0}; int nv=0;
for (uint32_t i = idxStart; i < idxStart + idxCount; ++i) {
@@ -1216,8 +1267,9 @@ struct Builder {
for (int i = 0; i < 3; ++i) { currentRampLo[i] = info.rampLo[i]; currentRampHi[i] = info.rampHi[i]; }
currentHasEmissive = info.hasEmissive;
for (int i = 0; i < 3; ++i) currentEmissive[i] = info.emissive[i];
currentBrighten = info.brightenFactor;
}
else { currentColor = colorForMaterial(full); currentTex.clear(); currentHasDiffuse = false; currentTexChannel = 0; currentTexScroll = false; currentTexScrollU = currentTexScrollV = 0.0f; }
else { currentColor = colorForMaterial(full); currentTex.clear(); currentHasDiffuse = false; currentTexChannel = 0; currentTexScroll = false; currentTexScrollU = currentTexScrollV = 0.0f; currentBrighten = 0.0f; }
// TRANSLOCATION WARP: override the material's coarse "sky" ramp with the
// NARROW LAVENDER ramp that matched the original (capture.png). The swirl
// is the bintA cloud coloured lo->hi by luminance; a wide sky ramp
@@ -1386,6 +1438,7 @@ bool LoadBgfFile(const std::string& name, BgfData& out) {
// single-siding + dark-frame ramp reconstruction in emitTri/buildPmesh applies to
// these meshes only.
b.meshIsCop = stemLower(name).find("_cop") != std::string::npos;
b.meshStem = stemLower(name);
b.res = &res;
for (const Chunk& c : roots) b.collect(c);
b.finish();
+6
View File
@@ -44,6 +44,12 @@ struct BgfDrawBatch {
// alpha). The verts keep their authored RGBA gradient and the draw routes
// to the alpha-blend pass, unlit, colour = texture x vertex gradient.
bool vertexAlpha = false;
// BTFX "brighten" ADDITIVE VEIL (the searchlight SPOT.BGF beam cone): the
// material class authors its factor in DIFFUSE.r and its glow colour in
// EMISSIVE; the batch draws in the blend pass as dest += emissive x factor
// (1995 DIV brighten semantics; the record also authors fog IMMUNE +
// DRAWLAST, both satisfied by the blend pass). 0 = not a brighten batch.
float brightenFactor = 0.0f;
// TEXTURE ALPHA CUTOUT (issue #3, the wreck scorch splat): an RGBA4444 BSL
// slice (texChannel >= 8) carries an AUTHORED alpha channel -- in the shipped
// content it is a BINARY 0/240 cutout mask (bexp9 = the scorch splat
+5
View File
@@ -109,6 +109,11 @@ struct L4DRAWOP
// UNLIT, colour = texture x the authored per-vertex gradient, alpha = the
// per-vertex fade (SRCALPHA/INVSRCALPHA).
bool vertexAlphaBlend;
// BTFX BRIGHTEN VEIL (the searchlight SPOT.BGF cone): additive brighten of
// the framebuffer, dest += material.Emissive x this factor (the class
// authors the factor in DIFFUSE.r, the glow colour in EMISSIVE). Drawn in
// the alpha-blend pass, unlit, fog-immune by authoring. 0 = off.
float brightenAlpha;
// COCKPIT PUNCH STENCIL-CUT (task #55, i860-firmware-decoded): 0=normal,
// 1=aperture MASK (drawn stencil-only, paired with the following hull op),
// 2=HULL (drawn stencil-rejected under the mask = window cutouts).
+6
View File
@@ -31,6 +31,12 @@ 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
+84 -1
View File
@@ -234,6 +234,36 @@ 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
@@ -407,6 +437,32 @@ static const char *gBTCwdFixNote = NULL;
//
int gBTFitDisplay = 0;
//
// L4PLASMA=NONE/OFF/0 means "no marquee at all" (L4GREND.cpp reads the same
// spellings). Only used to keep the boot banner honest.
//
static bool BTPlasmaDisabled(void)
{
const char *p = getenv("L4PLASMA");
return p != NULL && (_stricmp(p, "NONE") == 0 ||
_stricmp(p, "OFF") == 0 ||
strcmp(p, "0") == 0);
}
//
// Which control device the GLASS profile actually resolved to. The banner
// used to hardcode "PadRIO", which is a lie on a cab wired to the real board
// -- and precisely the log line someone reads to check that (2026-08-06).
//
static const char *BTControlsSummary(void)
{
const char *c = getenv("L4CONTROLS");
if (c == NULL) return "PadRIO";
if (_strnicmp(c, "RIO", 3) == 0) return "hardware RIO";
if (_strnicmp(c, "PAD", 3) == 0) return "PadRIO";
return c;
}
static void BTEnsureContentDirectory(void)
{
if (GetFileAttributesA("BTL4.RES") != INVALID_FILE_ATTRIBUTES)
@@ -807,6 +863,7 @@ 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())
{
@@ -848,6 +905,18 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
{
gBTFitDisplay = 1;
}
//
// BT_FIT=1 -- the env spelling of -fit, so a POD can freeze it in
// content\environ.ini alongside the rest of its rig config instead of
// depending on every launcher passing the flag (2026-08-06, crash cart:
// the main view has to fill its 800x600 panel on every boot, however the
// game gets started -- shortcut, autostart or scheduled task).
//
{
const char *fitEnv = getenv("BT_FIT");
if (fitEnv != NULL && fitEnv[0] != '\0' && fitEnv[0] != '0')
gBTFitDisplay = 1;
}
// BT_JOYCONFIG=1: the generic-joystick capture wizard (flight sticks /
// HOTAS / pedals -- L4JOY.h). Console prompts detect which device/axis
@@ -1145,8 +1214,11 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
std::cout << "[boot] platform profile: "
<< (fe_menu_mode ? "MENU (front end -- no profile applied)"
: gBTPlatformPod ? "POD (RIO cockpit input; multi-surface gauges/MFDs via pod hardware or explicit L4GAUGE)"
: gBTPlatformGlass ? "GLASS (PadRIO + plasma window)"
: gBTPlatformGlass ? "GLASS"
: "DEV (single window + keyboard)");
if (gBTPlatformGlass && !fe_menu_mode)
std::cout << " (" << BTControlsSummary()
<< (BTPlasmaDisabled() ? "; plasma off [L4PLASMA])" : " + plasma window)");
if (!fe_menu_mode)
std::cout << " [secondary displays: " << kGlassLayoutName[glassLayout] << "]";
std::cout << std::endl << std::flush;
@@ -1168,6 +1240,17 @@ 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];
+71 -2
View File
@@ -94,6 +94,7 @@ 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);
@@ -153,6 +154,48 @@ 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)
{
@@ -174,7 +217,9 @@ void
{
MarshalLog("window closed by the user -- exiting for real "
"(no relaunch)");
ExitProcess(0);
extern void BTLastrunNote(const char *);
BTLastrunNote("clean exit: player closed the game window");
BTOrderlyDie(); // #163: never bare ExitProcess
}
}
@@ -249,6 +294,18 @@ 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));
@@ -257,10 +314,22 @@ 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);
}
ExitProcess(0);
// #163 step (b): vanish, then die without touching DLL detach.
EnumWindows(HideProcessWindow, (LPARAM)GetCurrentProcessId());
TerminateProcess(GetCurrentProcess(), 0);
}
//###########################################################################
+18 -1
View File
@@ -412,7 +412,7 @@ static const CatalogEntry kVehicles[] =
{ "avatar", "Avatar" },
{ "ava1", "Satyr" },
{ "sunder", "Sunder" },
{ "snd1", "Sunder V1" },
{ "snd1", "Denkou" }, // #69: the 4.x-era name (two era witnesses)
{ "vulture", "Vulture" },
{ "vul1", "Mad Dog" },
{ "lok2", "Mischief" },
@@ -1200,6 +1200,7 @@ int
{
BTLobbyRoster roster;
if (BTLobby_HostAndRoom(self.name, self.vehicle, self.color, self.experience,
self.badge, self.patch,
&roster, spec->steamMyToken, sizeof(spec->steamMyToken),
spec->steamMap, sizeof(spec->steamMap)) != 0 ||
roster.memberCount == 0)
@@ -1207,6 +1208,15 @@ int
return 1; // cancelled / Steam unavailable
}
spec->mode = BTFeLaunchHostSteam;
// #162: capture the host's ADV. DAMAGE menu choice BEFORE the roster
// loop -- `self` aliases mission.pilots[0], which the loop overwrites
// with the first lobby member. advancedDamage is a MISSION-level
// setting (every stock 1995 egg stamps the same value on every pilot
// page); leaving it unset here shipped advancedDamage=0 for EVERY
// pilot of EVERY steam match, which silently disabled the -500 death
// cost and the death splash all night (night-15 [exp] receipts:
// advDamage=0 on 24 of 26 group-mission spawns).
const int host_adv_damage = self.advancedDamage;
mission.pilotCount = 0;
sprintf(spec->podList, "127.0.0.1:%d", console_port);
for (int m = 0; m < roster.memberCount && m < 8; ++m)
@@ -1226,6 +1236,12 @@ int
// but now only for clients that never published a choice.
strncpy(pilot.experience, member.experience,
sizeof(pilot.experience) - 1);
// #38: badge + patch ride the lobby the same way (absent = the
// writer's VGL/Red fallback, which is exactly what every non-host
// player got all night while these fields were never carried).
strncpy(pilot.badge, member.badge, sizeof(pilot.badge) - 1);
strncpy(pilot.patch, member.patch, sizeof(pilot.patch) - 1);
pilot.advancedDamage = host_adv_damage; // #162: mission-level
sprintf(pilot.address, "%s:%d",
member.fakeAddress, member.gamePort);
if (!member.isSelf)
@@ -1245,6 +1261,7 @@ int
if (menu.steamAction == 2)
{
if (BTLobby_JoinAndWait(self.name, self.vehicle, self.color, self.experience,
self.badge, self.patch,
spec->steamMyToken, sizeof(spec->steamMyToken),
spec->steamMap, sizeof(spec->steamMap)) != 0)
{
+182 -13
View File
@@ -13,6 +13,7 @@
#pragma pack(push, 8)
#include "steam/steam_api.h"
#include "steam/isteammatchmaking.h"
#include <btversion.h> // BUILD GATE (#108 confound): exact-build lobbies
#pragma pack(pop)
//
@@ -48,6 +49,50 @@ static void
}
#include <stdarg.h>
//
// A rejection the PLAYER must see goes through LobbyNotice: the same text
// lands in the day log (newlines flattened so the line stays greppable)
// AND in a blocking message box. These paths are synchronous FE flows --
// a bare return here QUITS the exe, and #68 taught us a player bounced
// with only a log line reports "the game just closed".
//
static void
LobbyNotice(const char *format, ...)
{
char text[512];
va_list arguments;
va_start(arguments, format);
_vsnprintf(text, sizeof(text) - 1, format, arguments);
text[sizeof(text) - 1] = 0;
va_end(arguments);
{
char flat[512];
int n = 0;
for (const char *s = text; *s && n < 510; ++s)
{
if (*s == '\n')
{
if (n > 0 && flat[n - 1] == ' ')
continue;
flat[n++] = ' ';
}
else
{
flat[n++] = *s;
}
}
flat[n] = 0;
LobbyLog("NOTICE: %s", flat);
}
WCHAR wide[512];
int n = 0;
for (const char *s = text; *s && n < 511; ++s)
wide[n++] = (WCHAR)*s;
wide[n] = 0;
MessageBoxW(NULL, wide, L"BATTLETECH -- STEAM LOBBY",
MB_OK | MB_ICONWARNING | MB_SETFOREGROUND);
}
//###########################################################################
// Synchronous Steam call-result helper (manual polling -- no callback
// template machinery in this C-style TU).
@@ -84,7 +129,7 @@ static CSteamID currentLobby;
static void
PublishSelf(const char *pilot_name, const char *vehicle, const char *color,
const char *experience)
const char *experience, const char *badge, const char *patch)
{
//
// Identity is implicit (the member's SteamID); the roster TOKENS are
@@ -95,6 +140,15 @@ 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
// they never set.
matchmaking->SetLobbyMemberData(currentLobby, "bv", BT_VERSION_STRING);
}
static int
@@ -128,6 +182,16 @@ static int
if (xp != NULL)
strncpy(out->experience, xp, sizeof(out->experience) - 1);
}
{
// #38: badge + patch, same tolerance -- an older client that never
// published them keeps the old VGL/Red fallback, nobody else does.
const char *bd = matchmaking->GetLobbyMemberData(lobby, member, "bd");
if (bd != NULL)
strncpy(out->badge, bd, sizeof(out->badge) - 1);
const char *pt = matchmaking->GetLobbyMemberData(lobby, member, "pt");
if (pt != NULL)
strncpy(out->patch, pt, sizeof(out->patch) - 1);
}
out->isSelf = (member == SteamUser()->GetSteamID());
if (out->name[0] == 0)
{
@@ -143,8 +207,15 @@ static int
static int roomIsHost = 0;
static int roomResult = 0; // 0 pending, 1 go, -1 cancel
static RECT roomLaunchRect = { 140, 250, 420, 296 };
static RECT roomLeaveRect = { 140, 306, 300, 336 };
// #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 void
PaintRoom(HWND window)
@@ -186,10 +257,19 @@ static void
int n = 0;
for (const char *s = member.name; *s && n < 60; ++s) name[n++] = (WCHAR)*s;
name[n] = 0;
wsprintfW(line, L" %d. %-16s %hs, %hs%s", i + 1, name,
// BUILD GATE: flag mismatched members in the roster so the host
// sees WHO won't launch before pressing GO (the log alone left
// the operator guessing on field nights).
const char *bv = matchmaking->GetLobbyMemberData(currentLobby,
matchmaking->GetLobbyMemberByIndex(currentLobby, i), "bv");
int build_ok = (bv != NULL && strcmp(bv, BT_VERSION_STRING) == 0);
wsprintfW(line, L" %d. %-16s %hs, %hs%s%s", i + 1, name,
member.vehicle[0] ? member.vehicle : "mech",
member.color[0] ? member.color : "-",
member.isSelf ? L" (you)" : L"");
member.isSelf ? L" (you)" : L"",
// #169: keep the tag short -- the long "-- WILL NOT LAUNCH"
// tail clipped at the 536px row edge on long name+vehicle rows.
build_ok ? L"" : L" [WRONG BUILD]");
}
else
{
@@ -291,10 +371,12 @@ 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, 560, 360,
160, 160, outer.right - outer.left, outer.bottom - outer.top,
NULL, NULL, GetModuleHandleW(NULL), NULL);
if (window == NULL)
{
@@ -322,7 +404,7 @@ static int
int
BTLobby_HostAndRoom(
const char *pilot_name, const char *vehicle, const char *color,
const char *experience,
const char *experience, const char *badge, const char *patch,
BTLobbyRoster *roster_out,
char *my_token_out, int my_token_capacity,
char *steam_map_out, int steam_map_capacity)
@@ -346,7 +428,13 @@ int
}
currentLobby = created.m_ulSteamIDLobby;
SteamMatchmaking()->SetLobbyData(currentLobby, "btl4", "1");
PublishSelf(pilot_name, vehicle, color, experience);
// BUILD GATE: stamp the host's exact build on the lobby. Joiners filter
// on equality (below) and verify after entry -- a mixed-build lobby
// silently corrupts raw-struct replication (the night-9 ghost confound:
// 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);
LobbyLog("host: lobby up (%llu)", (unsigned long long)created.m_ulSteamIDLobby);
if (RunRoom(1) != 0)
@@ -371,6 +459,25 @@ int
{
continue;
}
if (!member.isSelf)
{
// BUILD GATE, host half: a member on a different zip (or an
// exe old enough to have no "bv" key at all) gets NO token.
// Omitted from btl4map, their client -- old builds included,
// the code predates the gate -- hits its own "the host's map
// is missing us" path and fails the join cleanly instead of
// desyncing the session (#108's stale-zip confound).
const char *bv = matchmaking->GetLobbyMemberData(
currentLobby, CSteamID(member.steamID), "bv");
if (bv == NULL || strcmp(bv, BT_VERSION_STRING) != 0)
{
LobbyLog("host: REJECT %s at GO -- their build [%s], "
"ours %s (no token minted; their join will fail)",
member.name, (bv && *bv) ? bv : "pre-gate/unknown",
BT_VERSION_STRING);
continue;
}
}
if ((pass == 0) == (member.isSelf != 0))
{
roster_out->members[roster_out->memberCount++] = member;
@@ -414,19 +521,26 @@ int
int
BTLobby_JoinAndWait(
const char *pilot_name, const char *vehicle, const char *color,
const char *experience,
const char *experience, const char *badge, const char *patch,
char *my_token_out, int my_token_capacity,
char *steam_map_out, int steam_map_capacity)
{
if (BTSteamNet_Install() != 0 || !BTSteamNet_Active())
{
LobbyLog("join: Steam transport unavailable");
LobbyNotice("STEAM UNAVAILABLE\n\n"
"Could not reach Steam.\n"
"Is Steam running and logged in?");
return -1;
}
ISteamMatchmaking *matchmaking = SteamMatchmaking();
matchmaking->AddRequestLobbyListStringFilter(
"btl4", "1", k_ELobbyComparisonEqual);
// BUILD GATE: only lobbies stamped with OUR exact build are visible. A
// stale-zip player finds nothing -- and the log names the build so the
// "no lobby found" report is self-diagnosing (#68's silent-exit lesson).
matchmaking->AddRequestLobbyListStringFilter(
"btl4ver", BT_VERSION_STRING, k_ELobbyComparisonEqual);
SteamAPICall_t call = matchmaking->RequestLobbyList();
LobbyMatchList_t match_list;
memset(&match_list, 0, sizeof(match_list));
@@ -434,7 +548,36 @@ int
LobbyMatchList_t::k_iCallback, 15000) != 0 ||
match_list.m_nLobbiesMatching == 0)
{
LobbyLog("join: no btl4 lobby found");
//
// Nothing on OUR build. Probe once WITHOUT the version filter so
// the rejection is specific: "the host is on a different zip"
// beats "no lobby" when one is in fact up. Lobby data rides back
// with the list result, readable without joining. (Filters only
// apply to the next request, so the probe re-adds the game key.)
//
matchmaking->AddRequestLobbyListStringFilter(
"btl4", "1", k_ELobbyComparisonEqual);
call = matchmaking->RequestLobbyList();
memset(&match_list, 0, sizeof(match_list));
if (WaitApiCall(call, &match_list, sizeof(match_list),
LobbyMatchList_t::k_iCallback, 8000) == 0 &&
match_list.m_nLobbiesMatching > 0)
{
const char *host_ver = matchmaking->GetLobbyData(
matchmaking->GetLobbyByIndex(0), "btl4ver");
LobbyNotice("BUILD MISMATCH\n\n"
"A lobby is up, but the host runs build %s\n"
"and this machine runs build %s.\n\n"
"Everyone must run the same zip to play together.",
(host_ver && *host_ver) ? host_ver : "(an older build)",
BT_VERSION_STRING);
}
else
{
LobbyNotice("NO LOBBY FOUND\n\n"
"No BattleTech lobby is up right now.\n"
"(This machine runs build %s.)", BT_VERSION_STRING);
}
return -1;
}
@@ -450,7 +593,25 @@ int
return -1;
}
currentLobby = lobby;
PublishSelf(pilot_name, vehicle, color, experience);
// BUILD GATE belt-and-suspenders: the list filter covers discovery, but
// verify the entered lobby too (covers invite/direct joins and any
// filter drift). Mismatch = leave loudly, never play version-skewed.
{
const char *host_ver = matchmaking->GetLobbyData(currentLobby, "btl4ver");
if (host_ver == NULL || strcmp(host_ver, BT_VERSION_STRING) != 0)
{
matchmaking->LeaveLobby(currentLobby);
currentLobby = CSteamID();
LobbyNotice("BUILD MISMATCH\n\n"
"The host runs build %s\n"
"and this machine runs build %s.\n\n"
"Everyone must run the same zip to play together.",
(host_ver && *host_ver) ? host_ver : "(an older build)",
BT_VERSION_STRING);
return -1;
}
}
PublishSelf(pilot_name, vehicle, color, experience, badge, patch);
LobbyLog("join: in lobby, waiting for GO");
int result = RunRoom(0);
@@ -487,7 +648,15 @@ int
LobbyLog("join: my token [%s], map [%s]", my_token_out, steam_map_out);
if (my_token_out[0] == 0)
{
result = -1; // the host's map is missing us
// The host launched without us -- no seat in the mission map.
// (Same-build clients can only hit this via a full lobby or a
// publish race now; version skew is gated before entry.)
LobbyNotice("LEFT BEHIND\n\n"
"The host launched the mission without this machine\n"
"(no seat in the mission map).\n\n"
"Rejoin on the next launch. If this repeats, compare\n"
"builds: this machine runs %s.", BT_VERSION_STRING);
result = -1;
}
}
SteamMatchmaking()->LeaveLobby(currentLobby);
+5 -2
View File
@@ -34,6 +34,9 @@ struct BTLobbyMember
// without it on the wire, the host's
// egg authored every JOINER as the
// WriteEgg "veteran" fallback
char badge[16]; // #38: same class as experience --
char patch[16]; // absent on the wire, every joiner
// spawned VGL-badged with a Red patch
int isSelf;
unsigned long long steamID;
};
@@ -51,7 +54,7 @@ struct BTLobbyRoster
int
BTLobby_HostAndRoom(
const char *pilot_name, const char *vehicle, const char *color,
const char *experience,
const char *experience, const char *badge, const char *patch,
BTLobbyRoster *roster_out,
char *my_token_out, int my_token_capacity,
char *steam_map_out, int steam_map_capacity);
@@ -63,6 +66,6 @@ int
int
BTLobby_JoinAndWait(
const char *pilot_name, const char *vehicle, const char *color,
const char *experience,
const char *experience, const char *badge, const char *patch,
char *my_token_out, int my_token_capacity,
char *steam_map_out, int steam_map_capacity);
+20 -1
View File
@@ -398,10 +398,29 @@ 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 PTR_LAB_005129e8(authoritative)/005129f4(ghost); base MoveAndCollide body past window — live integrator is Missile override @4bef78.
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).
└─ 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
View File
@@ -82,6 +82,7 @@ enum BTActionID
BTActMfd1Cycle, // Gitea #9: cycle the lower-left MFD preset page
BTActMfd2Cycle, // Gitea #9: cycle the upper-center MFD preset page
BTActMfd3Cycle, // Gitea #9: cycle the lower-right MFD preset page
BTActEject, // PANIC/EJECT punch-out -> Mech msg 0x19 (@0049f854)
BTActCount
};
@@ -204,6 +205,7 @@ static const BTName sActionNames[] =
{"Reconnect", BTActReconnect},
{"Mfd1Cycle", BTActMfd1Cycle}, {"Mfd2Cycle", BTActMfd2Cycle},
{"Mfd3Cycle", BTActMfd3Cycle},
{"Eject", BTActEject},
{0, 0}
};
@@ -288,6 +290,8 @@ static const char *sDefaultProfile =
"key L action Mfd3Cycle\n"
"key V action ViewToggle\n"
"key B action LookBehind\n"
"key Back action Eject\n"
"pad LeftThumb action Eject\n"
"key F5 action Generator1\n"
"key F6 action Generator2\n"
"key F7 action Generator3\n"
@@ -991,6 +995,7 @@ void
case BTActMfd1Cycle: next.mfdCycle[0] = 1; break;
case BTActMfd2Cycle: next.mfdCycle[1] = 1; break;
case BTActMfd3Cycle: next.mfdCycle[2] = 1; break;
case BTActEject: next.eject = 1; break;
case BTActGenerator1: next.genSel = 4; break;
case BTActGenerator2: next.genSel = 5; break;
case BTActGenerator3: next.genSel = 6; break;
+1
View File
@@ -55,6 +55,7 @@ struct BTInputState
int valve;
int flush; // Gitea #7: coolant flush HELD (InjectCoolant)
int configHold;
int eject; // PANIC/EJECT punch-out (level; mech4 edge-dispatches Mech 0x19)
int genSel; // 0 = none, 4..7 = Generator A..D, 8 = reconnect
// Gitea #9: per-MFD preset-page cycle (desktop senders for SetPresetMode;
// index 0/1/2 = Mfd1 lower-left / Mfd2 upper-center / Mfd3 lower-right)
+24 -5
View File
@@ -170,7 +170,13 @@ 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 };
static const int kBTPlacementLamp[5] = { 0x29, 0x1A,0x1B,0x1C,0x1D }; // [auxScreenPlacement]
// #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 };
//
// @004cc148 -- the fixed (<0x80) map: coolingLoop1..6 -> the heat-display bank.
@@ -276,6 +282,14 @@ void
mem_stream->ReadBytes(&item_condition, sizeof(item_condition)); // (*stream+0x1c)
mem_stream->ReadBytes(&lamp_code, sizeof(lamp_code));
if (BTLampLog())
DEBUG_STREAM << "[galm-item] sub='"
<< (the_subsystem ? (const char *)the_subsystem->GetName() : "?")
<< "' cond=" << (int)the_condition
<< " itemCond=" << item_condition
<< " lampCode=0x" << std::hex << lamp_code << std::dec
<< "\n" << std::flush;
if ((int)the_condition != item_condition)
{
return;
@@ -327,10 +341,15 @@ void
{
if (the_condition == 2) // CoolantLeaking
{
int screen = 0, placement = -1;
BTGetSubsystemAuxScreen(the_subsystem, &screen, &placement, 0);
if (placement >= 0 && placement < 5)
lamp_id = kBTPlacementLamp[placement];
// #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];
}
}
else if (lamp_code < 0x80)
+17 -5
View File
@@ -2020,11 +2020,23 @@ BallisticWeaponCluster::BallisticWeaponCluster(
destroyedLamp = new TwoState(ChildRate(), eng_mode, renderer_in, owner_ID,
engPort, 0xc2, 0x85, "edestryd.pcc", 0, 0xff, (int *)&failedState, "TwoState"); // the destroyed X: bright when FAILED
// ejectWipe (BitMapInverseWipeScalar @004c61c8, eject-timer wipe reading
// subsys+0x3f8). BRING-UP: the BitMapInverseWipeScalar class is not yet
// declared/reconstructed in btl4gaug (only the non-Scalar BitMapInverseWipe);
// tracked NULL until it lands.
ejectWipe = NULL; // @0x44
// ejectWipe (BitMapInverseWipeScalar @004c61c8): the round-EJECT progress
// sweep -- bteejtm.pcc as the weapon's PercentOfEject (@0x3F8) runs 0..1.
// Binary call (part_014.c:2312): FUN_004c61c8(., rate, eng_mode,
// renderer, ownerID, engPort, 0xF, 0, "bteejtm.pcc", 0, 0xFF,
// weapon+0x3f8, name). RE-WIRED 2026-08-03 after the draw op was
// properly settled: vtbl+0x58 = DrawBitMapOpaque, NOT DrawBitMap -- the
// mis-guess was the striped-MFD corruption (gotcha #27; the wipe's
// Execute in btl4gaug now carries the byte-exact two-span transcription).
{
extern Scalar *BTWeaponPercentOfEjectPtr(void *weapon);
Scalar *ejectPct = BTWeaponPercentOfEjectPtr(subsystem_in);
ejectWipe = (ejectPct != NULL)
? new BitMapInverseWipeScalar(ChildRate(), eng_mode, renderer_in,
owner_ID, engPort, 0xF, 0, "bteejtm.pcc", 0, 0xFF,
ejectPct, "BitMapInverseWipeScalar")
: NULL; // @0x44
}
}
BallisticWeaponCluster::~BallisticWeaponCluster()
+126 -1
View File
@@ -1744,10 +1744,27 @@ Logical
L4Warehouse *warehouse = (L4Warehouse *)gauge_renderer->warehousePointer;
if (warehouse->pixelMap8Bin.Get(p[2].data.string) == NULL) // FUN_00442d2b
{
DebugStream << "OneOfSeveralPixInt: Missing image '" << p[2].data.string << "'\n";
// 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;
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;
}
@@ -2152,6 +2169,114 @@ void BitMapInverseWipe::Execute()
}
//###########################################################################
// BitMapInverseWipeScalar @004c61c8 ctor / @004c5fb8 Execute
//###########################################################################
//
// The weapon-eng round-EJECT progress sweep (bteejtm.pcc <- PercentOfEject).
// Base ctor @004c5e84 interns the image and captures colours + the bitmap
// geometry; the @004c61c8 tail adds the Scalar value-watcher (FUN_00474855
// into this+0xA8) -- our GaugeConnectionDirectOf<Scalar> is that watcher.
//
BitMapInverseWipeScalar::BitMapInverseWipeScalar(
GaugeRate rate, ModeMask mode_mask, L4GaugeRenderer *renderer_in,
int owner_ID, int graphics_port_number, int x, int y, const char *image,
int color_a, int color_b, Scalar *value_pointer,
const char *identification_string
):
GraphicGauge(rate, mode_mask, renderer_in, owner_ID, graphics_port_number,
identification_string)
{
localView.SetOrigin(x, y);
imageName = new char[strlen(image) + 1];
strcpy(imageName, image);
colorA = color_a;
colorB = color_b;
value = 0.0f;
previousLevel = -1;
L4Warehouse *warehouse = (L4Warehouse *)renderer_in->warehousePointer;
BitMap *bmp = warehouse->bitMapBin.Get(imageName);
if (bmp == NULL)
{
width = 0;
height = 0;
}
else
{
width = bmp->Data.Size.x;
height = bmp->Data.Size.y;
}
AddConnection(new GaugeConnectionDirectOf<Scalar>(0, &value, value_pointer));
}
BitMapInverseWipeScalar::~BitMapInverseWipeScalar()
{
L4Warehouse *warehouse = (L4Warehouse *)renderer->warehousePointer;
warehouse->bitMapBin.Release(imageName);
delete[] imageName;
imageName = NULL;
}
Logical BitMapInverseWipeScalar::TestInstance() const { return GraphicGauge::TestInstance(); }
void BitMapInverseWipeScalar::BecameActive() // @004c5fa4
{
previousLevel = -1;
}
//
// @004c5fb8 -- Execute. level = round(value x width) clamped to [0, width]
// (the x87 form Ghidra drops -- fild width / fmul value, gotcha #19), and on
// a change draw the two source-column spans OPAQUE with INVERTED colour
// pairs -- the literal "inverse wipe".
//
// SETTLED 2026-08-03 (the MFD-corruption arc): the binary's draw call is
// vtbl+0x58 = GraphicsView::DrawBitMapOpaque(background, rotation, bitmap,
// sx1, sy1, sx2, sy2) [T0 GRAPH2D.h slot 22 -- the NEXT virtual after
// DrawBitMap at +0x54; verified by counting the declaration order:
// +0x18 SetColor, +0x24 MoveToAbsolute both land exactly]. The earlier
// reconstruction guessed DrawBitMap and fed it a colour as "rotation" --
// the striped-garbage smear the operator captured. Byte-exact spans:
// level != 0: MoveTo(0,0); SetColor(colorA);
// DrawBitMapOpaque(colorB, 0, bmp, 0, 0, level, height);
// ++level; // inclusive-bounds step
// level < w: MoveTo(level,0); SetColor(colorB);
// DrawBitMapOpaque(colorA, 0, bmp, level, 0, width, height);
//
void BitMapInverseWipeScalar::Execute()
{
Scalar scaled = value * (Scalar)width;
int level = (int)(scaled + (scaled < 0.0f ? -0.5f : 0.5f)); // FUN_004dcd94 round
if (level < 0) level = 0;
if (level > width) level = width;
if (level != previousLevel)
{
previousLevel = level;
L4Warehouse *warehouse = (L4Warehouse *)renderer->warehousePointer;
BitMap *bmp = warehouse->bitMapBin.Get(imageName);
if (bmp != NULL)
{
int split = level;
if (split != 0)
{
localView.MoveToAbsolute(0, 0);
localView.SetColor(colorA);
localView.DrawBitMapOpaque(colorB, 0, bmp, 0, 0, split, height);
++split; // the binary's iVar2++
}
if (split < width)
{
localView.MoveToAbsolute(split, 0);
localView.SetColor(colorB);
localView.DrawBitMapOpaque(colorA, 0, bmp, split, 0, width, height);
}
warehouse->bitMapBin.Release(imageName);
}
}
}
//###########################################################################
//###########################################################################
// HeadingPointer @004c554c Make / @004c562c ctor
+33
View File
@@ -541,6 +541,39 @@
Scalar value; // @0xB4 this[0x2D] (connection)
};
//#######################################################################
// BitMapInverseWipeScalar @004c61c8 (vtable 0x518a14; base ctor @004c5e84,
// Execute @004c5fb8, BecameActive @004c5fa4). A bitmap COLUMN SWEEP whose
// level tracks a live Scalar 0..1 (the ctor's trailing Scalar* rides a
// value-watcher into this+0xA8): columns [0..level] draw fg colorA over
// bg colorB, the remainder draws the INVERSE -- the weapon-engineering
// panel's round-EJECT progress graphic (bteejtm.pcc, watching the
// weapon's PercentOfEject @0x3F8). Reconstructed 2026-08-03 (#118: the
// operator saw the eject slot flash with NO graphic -- this was the
// tracked-NULL bring-up stub in BallisticWeaponCluster).
//#######################################################################
class BitMapInverseWipeScalar :
public GraphicGauge
{
public:
BitMapInverseWipeScalar( // @004c61c8
GaugeRate, ModeMask, L4GaugeRenderer *, int owner_ID,
int graphics_port_number, int x, int y, const char *image,
int color_a, int color_b, Scalar *value_pointer, const char *);
~BitMapInverseWipeScalar();
Logical TestInstance() const;
void BecameActive(); // @004c5fa4 (previousLevel = -1)
void Execute(); // @004c5fb8 (two-span inverse sweep)
protected:
char *imageName; // @0x90
int colorA; // @0x94
int colorB; // @0x98
int width; // @0x9C bitmap width (columns)
int height; // @0xA0 bitmap height
int previousLevel; // @0xA4
Scalar value; // @0xA8 (connection: the 0..1 progress)
};
//#######################################################################
// SegmentArc gauges -- needle / dial over a 270-degree arc. Derived from
// the MUNGA L4 arc primitives (FUN_004745e0 / FUN_00473f44).
+21 -11
View File
@@ -28,7 +28,7 @@
// Function -> method map:
// L4MechControlsMapper ----------------------------- vtable @0051e440
// @004d17ac ctor @004d1814 dtor
// @004d196c InterpretControls (target-range ramp + review-mode watch)
// @004d196c InterpretControls (target-range ramp + panic-arm watch)
// @004d1b64 ZoomTargetRangeIn @004d1b9c ZoomTargetRangeOut
// @004d1acc NotifyOfControlModeChange (+0x48; forwards to base no-op @004b048c)
// @004d1ae4 NotifyOfDisplayModeChange (+0x4C; the secondary-view mask swap
@@ -91,7 +91,7 @@
// DAT_0051dcd0[8] = {0x37,0x36,0x35,0x34,0x33,0x32,0x31,0x30} hotbox buttons
//
// ModeManager (app+0x50): +0x4 currentMode mask, +0x8 savedMode mask.
// Mech (mapper owner @this+0xd0): +0x404 targetRange, +0x414 missionReviewMode.
// Mech (mapper owner @this+0xd0): +0x404 targetRange, +0x414 ejectPermitted.
//
#include <bt.hpp>
@@ -329,7 +329,7 @@ L4MechControlsMapper::MessageHandlerSet&
//
// Chains to MechControlsMapper (FUN_004b02f0); stamps vtable &0051e440 and
// initialises the target-range zoom (exponent 2.0 == 250*2^2 == 1000m) and the
// review-mode watcher.
// panic-arm watcher (ejectPermitted edge).
//
L4MechControlsMapper::L4MechControlsMapper(
Mech *owner,
@@ -349,7 +349,7 @@ L4MechControlsMapper::MessageHandlerSet&
)
{
(void)class_ID;
previousMissionReviewMode = 0; // this[0x6a] @0x1a8
previousEjectPermitted = 0; // this[0x6a] @0x1a8
targetRangeExponentDemand = 2.0f; // this[0x68] @0x1a0
targetRangeExponent = 2.0f; // this[0x69] @0x1a4
Check_Fpu();
@@ -374,8 +374,14 @@ L4MechControlsMapper::MessageHandlerSet&
// The L4-layer performance, run every frame ahead of the in-Mech mapper:
// 1. snap the throttle to a full-throttle detent,
// 2. (re)build the pilot roster,
// 3. watch the Mech's mission-review flag and toggle the corresponding mode
// bit (0x200000) on the application mode manager,
// 3. watch the Mech's EJECT-PERMISSION flag (ejectPermitted @0x414 --
// refreshed each frame by the master performance, FUN_004a9b5c+0x10 ->
// @0049fa1c) and toggle the PANIC-ARMED mode bit (0x200000) on the
// application mode manager. That mode bit is what lights the pod's
// physical PANIC button (MakeLinkedLamp, btl4mppr RIO ctor) and any
// gauge elements carrying the mode in their ModeMask. [Corrected
// 2026-08-03: this cell was mislabeled "mission-review mode" -- the
// real review mode is the GLOBAL DAT_004fd550 (btl4pb), not mech+0x414.]
// 4. slew the smoothed target-range exponent toward the panel demand and
// push the resulting range (250 * 2^exponent) onto the Mech,
// 5. delegate to MechControlsMapper::InterpretControls for the actual
@@ -405,19 +411,19 @@ L4MechControlsMapper::MessageHandlerSet&
BuildPilotArray(); // FUN_004b0600
//
// (3) Mission-review mode change watcher.
// (3) Panic-arm watcher: edge-detect ejectPermitted -> mode 0x200000.
//
Mech *mech = GetMech();
int review_mode = mech->GetMissionReviewMode(); // (mech)+0x414
if (review_mode != previousMissionReviewMode) // @0x1a8
int eject_armed = mech->GetEjectPermitted(); // (mech)+0x414
if (eject_armed != previousEjectPermitted) // @0x1a8
{
previousMissionReviewMode = review_mode;
previousEjectPermitted = eject_armed;
BTL4ModeManager *mode_manager =
(BTL4ModeManager*)application->GetModeManager(); // DAT_004efc94+0x50
// ModeManager API: Add/RemoveModeMask save the old mask into
// previousModeMask (the recovered "savedMode = currentMode" step).
if (review_mode == 0)
if (eject_armed == 0)
{
mode_manager->RemoveModeMask(0x200000);
}
@@ -425,6 +431,10 @@ L4MechControlsMapper::MessageHandlerSet&
{
mode_manager->AddModeMask(0x200000);
}
if (getenv("BT_EJECT_LOG"))
DEBUG_STREAM << "[eject] panic-arm mode "
<< (eject_armed ? "ON" : "off") << " ("
<< mech->GetEntityID() << ")\n" << std::flush;
}
//
+20 -5
View File
@@ -74,8 +74,17 @@
//
public:
enum {
KeypressMessageID = MechControlsMapper::NextMessageID,
StringMatchMessageID,
// PINNED to the binary literals (FUN_004d266c passes 0x19/0x1a
// straight into the group Adds; corrected 2026-08-03). The chain
// arithmetic (MechControlsMapper::NextMessageID) lands on 0x17 --
// TWO mapper ids between 0x16 ToggleVoiceAssist and 0x19 are
// unreconstructed -- and the mis-numbered Keypress send was
// SILENTLY swallowed by the Mech (no 0x17 handler), which is why
// the armed pilot-keypad press never ejected. Mech::EjectPilot is
// id 0x19 on the MECH's receiver: the panic-mode keypad binding
// sends THIS id to the OWNER, and the collision is the design.
KeypressMessageID = 0x19,
StringMatchMessageID, // 0x1a (unregistered; stringManager route)
NextMessageID
};
@@ -203,7 +212,9 @@
targetRangeExponentDemand, // @0x1a0 panel "zoom" demand (init 2.0, range 0..5)
targetRangeExponent; // @0x1a4 smoothed value -> mech target range (2^x)
int
previousMissionReviewMode; // @0x1a8 last-seen mech mission-review flag
previousEjectPermitted; // @0x1a8 last-seen mech ejectPermitted (@0x414) --
// the panic-arm edge detector (was mislabeled
// "mission-review flag"; corrected 2026-08-03)
//
// Per-platform input staging slots (filled by the derived ctor's
@@ -305,8 +316,12 @@
// was previously self-consistent at the wrong value (0x2a), so it
// worked by accident -- now it matches the binary.
// (Faithful-fidelity note: the binary RIO table also carries its OWN
// Keypress id 0x19 -> @004d2514; our ctor still registers the shared
// L4 Keypress (0x17, @004d1bf0) -- see the Gitea backlog issue.)
// Keypress id: FIXED 2026-08-03 -- KeypressMessageID is now pinned to
// the binary's 0x19 (the old chain arithmetic landed on 0x17, and the
// panic-mode pilot-keypad send was silently swallowed by the Mech --
// no 0x17 handler -- so the armed eject press never fired). Remaining
// refinement: the ctor registers the shared L4 Keypress BODY
// (@004d1bf0); the RIO-specific body is @004d2514.)
//
enum {
Aux1QuadMessageID = MechControlsMapper::Aux1QuadMessageID, // 3
+6 -1
View File
@@ -369,8 +369,13 @@ BTL4PlaybackApplication::SharedData
//
// Controls only matter in interactive ("scrub") review mode.
// The binary reads the GLOBAL review-mode cell DAT_004fd550 here --
// NOT mech+0x414 (that cell is ejectPermitted; the old
// GetMissionReviewMode() call was a mislabel, corrected 2026-08-03).
// The port never enters scrub mode, so the global stays 0.
//
if (GetMissionReviewMode() == 2) // DAT_004fd550
extern int gMissionReviewMode; // DAT_004fd550 (btstubs)
if (gMissionReviewMode == 2)
{
Check(controlsManager);
controlsManager->Execute(); // vtbl+0x18
+652 -16
View File
@@ -170,6 +170,104 @@ void
// localToWorld in its ctor -- no explicit AddDynamicRenderable here
// (unlike the 1996 VideoComponent path).
(void)mech_root;
//
// SEARCHLIGHT pass (2026-08-05) -- the roster walk's `case 0xbd8`
// (@004cef28, raw pseudocode part_014). The binary, per searchlight
// subsystem: resolve the "LightOn" attribute (error print
// BTL4VID.CPP:0x941 when absent); COCKPIT build (view 1) -> stash up
// to TWO attrs for the fog-swap watcher (@00456778); EXTERNAL build
// -> load "spot.bgf" (warn if missing), hang it as a child of the
// mount joint and bind visibility to the attribute (@0045612c). Our
// tree builder does not walk the roster, so the probe bridge
// (searchlight.cpp) supplies {attr, mount segment} and the two
// watcher Execute bodies live in TickSearchlight.
//
{
extern int BTMechSearchlightProbe(void *, int **, int *);
int *attrs[2];
int segs[2];
int count = BTMechSearchlightProbe((void *)entity, attrs, segs);
std::map<Entity*, MechRenderTree>::iterator ti =
mMechRenderTrees.find(entity);
if (count > 0 && ti != mMechRenderTrees.end())
{
MechRenderTree &tree = ti->second;
tree.searchLightCount = 0;
tree.searchIsCockpit = (view_type == 1);
for (int sl = 0; sl < count; ++sl)
{
MechRenderTree::SearchLight &light =
tree.searchLight[tree.searchLightCount];
light.cone = NULL;
light.coneObj = NULL;
light.lightOn = attrs[sl];
light.shown = 0;
light.mountSeg = segs[sl];
// the binary's inverted seed (@00456778: [9]/[10] =
// (*attr == 0)) -- guarantees the FIRST tick "changes"
// and normalizes the fog to the real lamp state, which
// is how a night mission starts on nosearchlightfog.
tree.searchFogCache[tree.searchLightCount] =
(*attrs[sl] == 0);
// BT_SPOT_SELF=1 (bench-only): build the cone on the
// OWN-cockpit tree too, so the V-chase camera can
// eyeball it solo. Authentic builds never cone the
// cockpit tree (the 1995 view-1 branch stashes attrs
// only) -- keep this env off outside look benches.
if (!tree.searchIsCockpit || getenv("BT_SPOT_SELF"))
{
// NB: the renderer-level LoadObject wrapper (@00498448) is
// still a btstubs no-op; load through the d3d route the
// tree builder itself uses (extension REQUIRED there).
d3d_OBJECT *spot = d3d_OBJECT::LoadObject(
GetDevice(), (char *)"spot.bgf"); // @0051d6c0
if (spot == NULL)
{
DEBUG_STREAM << "[spot] Couldn't locate spot.bgf "
"for mech\n" << std::flush; // @0051d6c9
}
else
{
std::map<int, HierarchicalDrawComponent*>::iterator si =
tree.segRenderable.find(segs[sl]);
HierarchicalDrawComponent *parent =
(si != tree.segRenderable.end()) ? si->second : NULL;
if (parent == NULL)
{
// slot-space mismatch would land here -- keep the
// receipt loud (see the bench).
DEBUG_STREAM << "[spot] mount segment " << segs[sl]
<< " has no joint renderable -- cone skipped; slots:";
for (std::map<int, HierarchicalDrawComponent*>::iterator di =
tree.segRenderable.begin();
di != tree.segRenderable.end(); ++di)
DEBUG_STREAM << " " << di->first;
DEBUG_STREAM << "\n" << std::flush;
}
else
{
LinearMatrix identity(True);
dpl_ISECT_MODE gSpotIsectMode; // stub type (binary: mode 1)
light.cone = new DPLStaticChildRenderable(
entity, false /* main zone */, spot,
gSpotIsectMode, 0 /* mask 0: never pickable */,
identity, parent);
light.cone->SetDrawObj(NULL); // hidden until LightOn
light.coneObj = spot;
}
}
}
++tree.searchLightCount;
}
DEBUG_STREAM << "[spot] searchlight pass: " << count
<< " light(s), view=" << (int)view_type
<< " mountSeg=" << segs[0]
<< (tree.searchIsCockpit ? " (cockpit: fog watcher)"
: " (external: spot cone)")
<< "\n" << std::flush;
}
}
break;
}
@@ -401,7 +499,8 @@ HierarchicalDrawComponent*
// bring-up diagnostics (counts geometry actually loaded vs. requested)
int dbg_obj_requested = 0, dbg_obj_loaded = 0, dbg_eye = 0;
DEBUG_STREAM << "[BTrender] MakeMechRenderables: " << segment_count
<< " segments, view=" << (int)type << "\n" << std::flush;
<< " segments, view=" << (int)type
<< " entity=" << entity->GetEntityID() << "\n" << std::flush;
JointSubsystem *joint_subsystem = jointed_mover->GetJointSubsystem(); // [0x31c]
@@ -459,6 +558,11 @@ HierarchicalDrawComponent*
//
if (segment->IsSiteSegment() /* [0x10] */ != 0)
{
if (getenv("BT_TREE_LOG"))
DEBUG_STREAM << "[tree] SITE seg=" << segment->GetIndex()
<< " name=" << (segment->GetName() ? (const char *)segment->GetName() : "?")
<< " parent=" << (segment->GetParent() ? segment->GetParentIndex() : -1)
<< "\n" << std::flush;
// The authentic COCKPIT EYEPOINT. Built for the true inside view
// AND for the player's chase build (buildDebugChaseCamera), so the
// V-key toggle can switch to it -- the pod's only view was this
@@ -491,6 +595,22 @@ HierarchicalDrawComponent*
mCamera = mEyeCockpit;
dbg_eye = 1;
}
//
// SEARCHLIGHT (2026-08-05): sites are ATTACHMENT joints, and the
// 1995 render graph gave every one a DCS node -- that node is what
// the subsystem-visual walk parents to (the spot cone hangs on the
// searchlight site, @004cef28 case 0xbd8). Build the geometry-less
// child so site slots are posed + parentable; nothing draws for the
// site itself.
//
{
dpl_ISECT_MODE site_isect;
HierarchicalDrawComponent *site_child = new DPLStaticChildRenderable(
entity, inDeathZone, NULL /* no geometry */,
site_isect, 0, offset_matrix, parent_DCS);
dcs_array[segment->GetIndex()] = site_child;
render_tree.segRenderable[segment->GetIndex()] = site_child;
}
continue;
}
@@ -563,6 +683,14 @@ HierarchicalDrawComponent*
// the JointSubsystem's joint table.
//
int segment_slot = segment->GetIndex() /* [0xcc] */;
// DIAG (BT_TREE_LOG): the built joint topology -- segment slot, name,
// joint index/type, parent -- the crouch root-drop chain hunt.
if (getenv("BT_TREE_LOG"))
DEBUG_STREAM << "[tree] seg=" << segment_slot
<< " name=" << (segment->GetName() ? (const char *)segment->GetName() : "?")
<< " joint=" << segment->GetJointIndex()
<< " parent=" << (segment->GetParent() ? segment->GetParentIndex() : -1)
<< "\n" << std::flush;
Joint *this_joint = NULL;
Joint::JointType joint_type;
if (segment->GetJointIndex() /* [0xc0] */ == -1)
@@ -884,7 +1012,8 @@ void
if (swapped != 0 || getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[BTrender] RemakeEntity: " << swapped
<< " mesh(es) swapped (" << mapped << " body segs mapped of "
<< checked << " checked)\n" << std::flush;
<< checked << " checked) entity="
<< entity->GetEntityID() << "\n" << std::flush;
}
@@ -1171,13 +1300,15 @@ void
if (hulk != NULL && hulk->GetVertCount() == 0)
{
DEBUG_STREAM << "[BTrender] wreck: '" << hulk_name
<< "' is an EMPTY placeholder -> gendbr.bgf fallback\n" << std::flush;
<< "' is an EMPTY placeholder -> gendbr.bgf fallback (entity="
<< victim->GetEntityID() << ")\n" << std::flush;
hulk = NULL;
}
if (hulk == NULL)
{
DEBUG_STREAM << "[BTrender] wreck: '" << hulk_name
<< "' missing -> gendbr.bgf fallback\n" << std::flush;
<< "' missing -> gendbr.bgf fallback (entity="
<< victim->GetEntityID() << ")\n" << std::flush;
hulk = d3d_OBJECT::LoadObject(GetDevice(), "gendbr.bgf");
}
@@ -1451,9 +1582,36 @@ void
}
if (log)
{
DEBUG_STREAM << "[armor] entity " << (void *)entity << " skel="
<< tree.skeletonType << ": " << tree.dmgBinds.size()
<< " material binding(s)\n" << std::flush;
// ORPHAN inventory: drawn ops NO zone claimed. These panels never
// darken -- if the big hull materials land here (a .DZM name the mesh
// doesn't carry, or per-zone material instancing collapsed at load),
// the mech reads "no darkening" live even while every bound op logs
// perfectly (the #87 visibility gap).
for (std::map<int, MechRenderTree::SegPick>::iterator si = tree.segPick.begin();
si != tree.segPick.end(); ++si)
{
d3d_OBJECT *obj = si->second.obj;
if (obj == NULL)
continue;
for (int op = 0; op < obj->GetDrawOpCount(); ++op)
{
L4DRAWOP *draw_op = obj->GetDrawOp(op);
if (draw_op->dzMatName[0] == '\0')
continue;
int claimed = 0;
for (size_t b = 0; b < tree.dmgBinds.size() && !claimed; ++b)
if (tree.dmgBinds[b].obj == obj && tree.dmgBinds[b].op == op)
claimed = 1;
if (!claimed)
DEBUG_STREAM << "[armor] ORPHAN op seg=" << si->first
<< " '" << draw_op->dzMatName << "'\n" << std::flush;
}
}
}
}
@@ -1514,6 +1672,63 @@ void
}
//
//#############################################################################
// TickSearchlight -- the two 1995 searchlight watchers, transcribed
//#############################################################################
//
// External cone (@0045612c, Execute @004561d8): poll the subsystem's LightOn
// attribute; on change, set the spot instance visible iff the value matches
// (match value 1) and flush. Our equivalent of SetInstanceOn/Flush is the
// SetDrawObj object swap (the wreck-reveal mechanism).
//
// Cockpit fog (@00456778, Execute @00456814): poll BOTH stashed attrs; when
// either changed, either-on -> SetFogStyle(searchLightOnFogStyle) else
// SetFogStyle(searchLightOffFogStyle). The ctor's INVERTED caches make the
// first poll always fire, normalizing a night start onto nosearchlightfog --
// the authentic dark (fog= is the LIT set; see BTDPL.INI per-page authoring).
//
void
BTL4VideoRenderer::TickSearchlight(Entity *mech)
{
std::map<Entity*, MechRenderTree>::iterator ti = mMechRenderTrees.find(mech);
if (ti == mMechRenderTrees.end() || ti->second.searchLightCount <= 0)
return;
MechRenderTree &tree = ti->second;
int any_on = 0, changed = 0;
for (int sl = 0; sl < tree.searchLightCount; ++sl)
{
MechRenderTree::SearchLight &light = tree.searchLight[sl];
if (light.lightOn == NULL)
continue;
const int cur = (*light.lightOn != 0);
if (light.cone != NULL && cur != light.shown) // @004561d8
{
light.cone->SetDrawObj(cur ? light.coneObj : NULL);
light.shown = cur;
if (getenv("BT_FIRE_LOG") || getenv("BT_FOG_LOG"))
DEBUG_STREAM << "[spot] cone " << (cur ? "SHOWN" : "HIDDEN")
<< " (seg " << light.mountSeg << ")\n" << std::flush;
}
if (cur != tree.searchFogCache[sl]) // @00456814
changed = 1;
tree.searchFogCache[sl] = cur;
any_on |= cur;
}
if (tree.searchIsCockpit && changed)
{
SetFogStyle(any_on ? searchLightOnFogStyle : searchLightOffFogStyle);
if (getenv("BT_FIRE_LOG") || getenv("BT_FOG_LOG"))
DEBUG_STREAM << "[spot] cockpit fog -> "
<< (any_on ? "searchLightOn" : "searchLightOff") << "\n" << std::flush;
}
}
//
// Sim-side bridge (per-mech, every frame, from Mech::PerformAndWatch).
//
@@ -1526,6 +1741,7 @@ void BTArmourDamageTick(Entity *mech)
if (renderer == NULL)
return;
renderer->TickArmourDamage(mech);
renderer->TickSearchlight(mech); // the searchlight watchers ride the same beat
}
@@ -1545,10 +1761,106 @@ int BTWreckSinkTick(Entity *victim, float dt)
//
// #73 -- the aimed PER-PART pick (see the header note). Ray-vs-sphere over
// the per-segment draw objects recorded at tree build; world centers come
// through the draw-cached mLocalToWorld (updated every drawn frame -- the
// target being aimed at is on screen, so at most one frame stale).
// #124 -- the pick's TRIANGLE cache. The 1995 pick was a dpl scene
// intersection against the DRAWN GEOMETRY (the division card cast from the
// view); the port's sphere approximation measurably mis-picked (the zone-walk
// matrix: aim dead-on dtorso -> picked rgun/ruleg -- gun/limb spheres thread
// the ray before the torso from most angles, and its own comments admitted "a
// foot can be unhittable behind its own knee"). Restore the authentic
// semantic: nearest RAY-TRIANGLE hit across the candidate segments' posed
// meshes. Positions are read ONCE per d3d_OBJECT from its own BGF buffers
// (managed pool, lockable) and cached CPU-side; the per-frame cost is a
// sphere pre-filter + Moller-Trumbore over the few threaded segments.
//
struct BTPickMesh
{
std::vector<float> pos; // xyz per vertex
std::vector<unsigned int> idx; // triangle list
int ok;
};
static std::map<d3d_OBJECT *, BTPickMesh> gBTPickMeshes;
static BTPickMesh *
BTGetPickMesh(d3d_OBJECT *obj)
{
std::map<d3d_OBJECT *, BTPickMesh>::iterator mi = gBTPickMeshes.find(obj);
if (mi != gBTPickMeshes.end())
return mi->second.ok ? &mi->second : 0;
BTPickMesh &pm = gBTPickMeshes[obj];
pm.ok = 0;
if (obj->mBgfVB == 0 || obj->mBgfIB == 0 || obj->mBgfStride < 12)
return 0;
D3DINDEXBUFFER_DESC ibd;
if (FAILED(obj->mBgfIB->GetDesc(&ibd)))
return 0;
int idx32 = (ibd.Format == D3DFMT_INDEX32);
unsigned int nIdx = ibd.Size / (idx32 ? 4 : 2);
void *vp = 0, *ip = 0;
if (FAILED(obj->mBgfVB->Lock(0, 0, &vp, D3DLOCK_READONLY)))
return 0;
if (FAILED(obj->mBgfIB->Lock(0, 0, &ip, D3DLOCK_READONLY)))
{
obj->mBgfVB->Unlock();
return 0;
}
pm.pos.resize((size_t)obj->mBgfNumVerts * 3);
const unsigned char *vb = (const unsigned char *)vp;
for (UINT v = 0; v < obj->mBgfNumVerts; ++v)
{
const float *p = (const float *)(vb + (size_t)v * obj->mBgfStride);
pm.pos[v*3+0] = p[0]; // position-first vertex layout (the BGF
pm.pos[v*3+1] = p[1]; // loader's own decl; the cull sphere was
pm.pos[v*3+2] = p[2]; // computed from these same floats at load)
}
pm.idx.resize(nIdx);
if (idx32)
{
const unsigned int *s = (const unsigned int *)ip;
for (unsigned int k = 0; k < nIdx; ++k) pm.idx[k] = s[k];
}
else
{
const unsigned short *s = (const unsigned short *)ip;
for (unsigned int k = 0; k < nIdx; ++k) pm.idx[k] = s[k];
}
obj->mBgfIB->Unlock();
obj->mBgfVB->Unlock();
pm.ok = (pm.idx.size() >= 3 && pm.pos.size() >= 9);
return pm.ok ? &pm : 0;
}
// Moller-Trumbore, both-sided (the pod's dpl geometry has no consistent
// winding guarantee across ported BGF pieces). Returns t >= 0 or -1.
static float
BTRayTri(const float o[3], const float d[3],
const float *a, const float *b, const float *c)
{
float e1[3] = { b[0]-a[0], b[1]-a[1], b[2]-a[2] };
float e2[3] = { c[0]-a[0], c[1]-a[1], c[2]-a[2] };
float pv[3] = { d[1]*e2[2]-d[2]*e2[1], d[2]*e2[0]-d[0]*e2[2], d[0]*e2[1]-d[1]*e2[0] };
float det = e1[0]*pv[0] + e1[1]*pv[1] + e1[2]*pv[2];
if (det > -1e-8f && det < 1e-8f) return -1.0f;
float inv = 1.0f / det;
float tv[3] = { o[0]-a[0], o[1]-a[1], o[2]-a[2] };
float u = (tv[0]*pv[0] + tv[1]*pv[1] + tv[2]*pv[2]) * inv;
if (u < 0.0f || u > 1.0f) return -1.0f;
float qv[3] = { tv[1]*e1[2]-tv[2]*e1[1], tv[2]*e1[0]-tv[0]*e1[2], tv[0]*e1[1]-tv[1]*e1[0] };
float v = (d[0]*qv[0] + d[1]*qv[1] + d[2]*qv[2]) * inv;
if (v < 0.0f || u + v > 1.0f) return -1.0f;
float t = (e2[0]*qv[0] + e2[1]*qv[1] + e2[2]*qv[2]) * inv;
return (t >= 0.0f) ? t : -1.0f;
}
//
// #73 -- the aimed PER-PART pick (see the header note). #124: now a true
// DRAWN-GEOMETRY intersection -- sphere pre-filter, then nearest ray-triangle
// hit across the threaded segments' posed meshes (the 1995 division-card
// semantic). The old smallest-sphere selection survives only as the fallback
// when no triangle anywhere is struck (grazing edge shots). World transforms
// come through the draw-cached mLocalToWorld (at most one frame stale).
//
int
BTL4VideoRenderer::MechSegmentPick(
@@ -1561,7 +1873,8 @@ int
{
std::map<Entity*, MechRenderTree>::iterator it = mMechRenderTrees.find(mech);
if (it == mMechRenderTrees.end() || it->second.wrecked)
return 0;
return -1; // #131: NO TREE (unbuilt replicant / wreck) -- the caller may
// box-test; distinct from 0 = a true drawn-geometry MISS.
// Selection is SPECIFICITY-FIRST: among the spheres the ray pierces, the
// SMALLEST radius wins (normalized-distance tie-break). Neither nearest-
@@ -1573,12 +1886,35 @@ int
// envelope; smallest-pierced picks the most specific part on the aim line,
// and the torso wins only when no limb is threaded -- which is the per-part
// semantic the 1995 mesh intersection produced.
float bestR = 1e30f; // primary key: sphere radius (ascending)
float bestScore = 1.0f; // tie-break: normalized perpendicular d2/r2
float bestR = 1e30f; // sphere-FALLBACK key: radius (ascending)
float bestScore = 1.0f; // sphere tie-break: normalized d2/r2
float bestT = max_range;
int bestZone = -1;
int hitAny = 0;
// triangle-accurate primary: nearest surface hit across all segments
float triBestT = max_range;
int triBestZone = -1;
int triBestSeg = -1;
int triHit = 0;
d3d_OBJECT *triBestObj = 0; // #124-PATCH: winning object + tri
size_t triBestK = 0; // (triangle ordinal in its IB)
int dbgTriObjs = 0, dbgInvFail = 0, dbgNoTri = 0; // #131 telemetry
// #124 CORRECTED (2026-08-04, field pushback vindicated): the pick's zone
// is the struck PATCH's authored dz_* tag, with the segment's SKL dzone as
// the fallback for untagged patches. The earlier "struck segment's dzone,
// ALWAYS" model collapsed the hull to dtorso -- but the art zone-tags the
// hull PER PANEL (MAD_TOR carries dz_utorso/ltorso/rtorso/dtorso + all four
// rear panels + searchlight across its patches), the dpl hit result kept
// GEOGROUP granularity (dplHitInstance/DCS/GeoGroup/Geometry), and the
// binary's segment->zone map (@49db20) has NO runtime caller (sole caller =
// CreateStreamedDamageZone, load-time) -- there was nothing to do the
// segment-level collapse with. Era players' "I stripped the left torso in
// the pod" stands. The triangle -> patch attribution below reuses the .DZM
// zone->material bindings (#87's armour darkening -- the same authored
// mapping that already paints those panels per-zone).
std::map<int, MechRenderTree::SegPick>::iterator sp;
for (sp = it->second.segPick.begin(); sp != it->second.segPick.end(); ++sp)
{
@@ -1609,6 +1945,77 @@ int
if (t < 0.0f || t >= max_range)
continue;
// ---- TRIANGLE TEST (#124): the sphere only nominates ----
BTPickMesh *pm = BTGetPickMesh(obj);
if (pm == 0 && getenv("BT_PICK_LOG"))
{
static int s_nt = 0;
if (s_nt++ < 40)
DEBUG_STREAM << "[picktri] seg=" << sp->first << " zone="
<< sp->second.zone << " NO TRIS (vb=" << (void *)obj->mBgfVB
<< " ib=" << (void *)obj->mBgfIB
<< " stride=" << obj->mBgfStride << ")\n" << std::flush;
}
if (pm == 0)
++dbgNoTri;
if (pm != 0)
{
++dbgTriObjs;
// ray into object-local space (affine inverse; segment poses are
// rigid, so local t == world t after direction normalization is
// preserved by construction below)
D3DXMATRIX w2l;
if (D3DXMatrixInverse(&w2l, 0, &l2w) == 0)
++dbgInvFail;
if (D3DXMatrixInverse(&w2l, 0, &l2w) != 0)
{
D3DXVECTOR3 lo, ld;
D3DXVECTOR3 wo(ray_start[0], ray_start[1], ray_start[2]);
D3DXVECTOR3 wd(ray_dir[0], ray_dir[1], ray_dir[2]);
D3DXVec3TransformCoord(&lo, &wo, &w2l);
D3DXVec3TransformNormal(&ld, &wd, &w2l);
float o3[3] = { lo.x, lo.y, lo.z };
float d3[3] = { ld.x, ld.y, ld.z };
const float *P = &pm->pos[0];
size_t nv = pm->pos.size() / 3;
for (size_t k = 0; k + 2 < pm->idx.size(); k += 3)
{
unsigned int i0 = pm->idx[k], i1 = pm->idx[k+1], i2 = pm->idx[k+2];
if (i0 >= nv || i1 >= nv || i2 >= nv)
continue;
float tt = BTRayTri(o3, d3, P + i0*3, P + i1*3, P + i2*3);
if (tt >= 0.0f && tt < triBestT)
{
// world-space t of the local hit (handles any scale)
D3DXVECTOR3 lh(o3[0]+d3[0]*tt, o3[1]+d3[1]*tt, o3[2]+d3[2]*tt);
D3DXVECTOR3 wh;
D3DXVec3TransformCoord(&wh, &lh, &l2w);
float wt = (wh.x - ray_start[0]) * ray_dir[0]
+ (wh.y - ray_start[1]) * ray_dir[1]
+ (wh.z - ray_start[2]) * ray_dir[2];
if (wt >= 0.0f && wt < triBestT)
{
triBestT = wt;
triBestZone = sp->second.zone; // segment dzone (fallback)
triBestSeg = sp->first;
triBestObj = obj;
triBestK = k; // first index of the tri
triHit = 1;
}
}
}
}
}
// ---- sphere FALLBACK bookkeeping -- #131 MISS-MEANS-MISS: only an
// object whose drawn mesh is UNREADABLE (pm == 0) may answer by
// sphere. A readable mesh the ray misses is a MISS. The old
// any-object sphere halo produced lock-without-mech: level rays over
// the squat blackhawk read tri=0 / sphereFB=168 on the sweep bench,
// and the operator watched the reticle pass over its head with the
// ring lit. The 1995 card cast against the DRAWN geometry -- no halo.
if (pm != 0)
continue;
float score = d2 / r2; // 0 = dead-center thread
if (r > bestR
|| (r == bestR && score >= bestScore))
@@ -1621,6 +2028,58 @@ int
hitAny = 1;
}
// #131 telemetry: how did this pick answer? (TRI = drawn-geometry hit,
// the authentic semantic; SPHERE = the fallback answered with no triangle
// struck -- the false-lock candidate.) Throttled under BT_PICK_LOG.
// objs/inv/noTri localize a dead triangle pass: objs = sphere-nominated
// objects entering the tri test, inv = SILENT skips from a failed
// world-matrix inverse (degenerate l2w), noTri = pick-mesh read failures.
if (getenv("BT_PICK_LOG"))
{
static int s_src[3] = {0,0,0}; // [0]=tri [1]=sphere [2]=miss
static int s_objs = 0, s_inv = 0, s_noTri = 0;
static int s_rep = 0;
s_objs += dbgTriObjs; s_inv += dbgInvFail; s_noTri += dbgNoTri;
++s_src[triHit ? 0 : (hitAny ? 1 : 2)];
if ((++s_rep % 240) == 0)
DEBUG_STREAM << "[picksrc] tri=" << s_src[0]
<< " sphereFB=" << s_src[1] << " miss=" << s_src[2]
<< " objs=" << s_objs << " invFail=" << s_inv
<< " noTri=" << s_noTri
<< "\n" << std::flush;
}
if (triHit)
{
bestT = triBestT;
bestZone = triBestZone; // segment dzone unless a patch tags it
hitAny = 1;
// #124-PATCH: attribute the struck triangle to its DRAW OP (each op is
// an index RANGE in the same IB the pick mesh copied) and take the
// op's .DZM-bound zone -- the authored dz_* panel. dmgBinds is the
// #87 armour mapping: {obj, op, zone} for every material a zone paints.
if (triBestObj != 0)
{
const std::vector<MechRenderTree::DmgBind> &binds = it->second.dmgBinds;
for (size_t b = 0; b < binds.size(); ++b)
{
if (binds[b].obj != triBestObj)
continue;
L4DRAWOP *op = triBestObj->GetDrawOp(binds[b].op);
if (op == 0 || op->bgfPrimCount <= 0)
continue;
size_t first = (size_t)op->bgfStartIndex;
size_t count = (size_t)op->bgfPrimCount * 3;
if (triBestK >= first && triBestK < first + count)
{
bestZone = binds[b].zone; // the struck PANEL's zone
break;
}
}
}
(void)triBestSeg;
}
// #92 probe: which spheres did the ray actually THREAD, and which won?
// "smallest radius wins" means a big sphere can never beat a small one that
// the ray also grazes -- so a foot can be unhittable behind its own knee.
@@ -1681,7 +2140,9 @@ int
static int s_pl = 0;
if ((s_pl++ % 60) == 0)
DEBUG_STREAM << "[pickwin] zone=" << bestZone
<< " score=" << bestScore << " t=" << bestT << "\n" << std::flush;
<< " score=" << bestScore << " t=" << bestT
<< " tri=" << triHit << " triSeg=" << triBestSeg
<< " triZone=" << triBestZone << "\n" << std::flush;
}
hit_out[0] = ray_start[0] + bestT * ray_dir[0];
@@ -1692,11 +2153,102 @@ int
}
//
// Game-side bridge (mech4.cpp's per-frame target pick; same access pattern as
// the wreck swap below).
// #124 zone walker: the zone's visual aim anchor (see the hpp note).
//
int BTMechSegmentPick(void *mech, const float ray_start[3], const float ray_dir[3],
float max_range, float hit_out[3], int *zone_out)
int
BTL4VideoRenderer::ZoneAimPoint(Entity *mech, int zone, float out3[3])
{
std::map<Entity*, MechRenderTree>::iterator it = mMechRenderTrees.find(mech);
if (it == mMechRenderTrees.end() || it->second.wrecked)
return 0;
// #124-PATCH: a zone with .DZM-bound panel patches aims at the PATCH
// CENTROID (the authored dz_* panel -- upper/left/right/rear torso are
// distinct patch clusters on ONE torso object, so the old cull-center
// answer aimed every hull zone at the same chest point). Centroid =
// average of the bound ops' triangle vertices, world-transformed.
{
double acc[3] = { 0, 0, 0 };
long nAcc = 0;
const std::vector<MechRenderTree::DmgBind> &binds = it->second.dmgBinds;
for (size_t b = 0; b < binds.size(); ++b)
{
if (binds[b].zone != zone || binds[b].obj == NULL)
continue;
d3d_OBJECT *obj = binds[b].obj;
L4DRAWOP *op = obj->GetDrawOp(binds[b].op);
if (op == 0 || op->bgfPrimCount <= 0)
continue;
BTPickMesh *pm = BTGetPickMesh(obj);
if (pm == 0)
continue;
D3DXMATRIX l2w = obj->GetLocalToWorld();
size_t first = (size_t)op->bgfStartIndex;
size_t last = first + (size_t)op->bgfPrimCount * 3;
if (last > pm->idx.size())
continue;
for (size_t k = first; k < last; ++k)
{
unsigned int vi = pm->idx[k];
if ((size_t)vi * 3 + 2 >= pm->pos.size())
continue;
D3DXVECTOR3 lp(pm->pos[vi*3], pm->pos[vi*3+1], pm->pos[vi*3+2]);
D3DXVECTOR3 wp;
D3DXVec3TransformCoord(&wp, &lp, &l2w);
acc[0] += wp.x; acc[1] += wp.y; acc[2] += wp.z;
++nAcc;
}
}
if (nAcc > 0)
{
out3[0] = (float)(acc[0] / nAcc);
out3[1] = (float)(acc[1] / nAcc);
out3[2] = (float)(acc[2] / nAcc);
return 1;
}
}
d3d_OBJECT *best = 0;
std::map<int, MechRenderTree::SegPick>::iterator sp;
for (sp = it->second.segPick.begin(); sp != it->second.segPick.end(); ++sp)
{
if (sp->second.zone != zone || sp->second.obj == NULL)
continue;
if (best == 0 || sp->second.obj->mCullRadius > best->mCullRadius)
best = sp->second.obj;
}
if (best == 0 || best->mCullRadius <= 0.0f)
return 0;
D3DXMATRIX l2w = best->GetLocalToWorld();
D3DXVECTOR3 cw;
D3DXVec3TransformCoord(&cw, &best->mCullCenter, &l2w);
out3[0] = cw.x; out3[1] = cw.y; out3[2] = cw.z;
return 1;
}
// The carrier-SEGMENT variant: anchor at segPick[seg]'s cull center (the
// piece's visual middle). For hull-family zones no object claims the zone,
// and the zone table's carrier is the hull joint whose ORIGIN sits in the
// crotch gap -- rays aimed there sail between the legs to the terrain
// sentinel. The cull center is the chest.
int
BTL4VideoRenderer::SegAimPoint(Entity *mech, int seg, float out3[3])
{
std::map<Entity*, MechRenderTree>::iterator it = mMechRenderTrees.find(mech);
if (it == mMechRenderTrees.end() || it->second.wrecked)
return 0;
std::map<int, MechRenderTree::SegPick>::iterator sp = it->second.segPick.find(seg);
if (sp == it->second.segPick.end() || sp->second.obj == NULL
|| sp->second.obj->mCullRadius <= 0.0f)
return 0;
D3DXMATRIX l2w = sp->second.obj->GetLocalToWorld();
D3DXVECTOR3 cw;
D3DXVec3TransformCoord(&cw, &sp->second.obj->mCullCenter, &l2w);
out3[0] = cw.x; out3[1] = cw.y; out3[2] = cw.z;
return 1;
}
int BTMechSegAimPoint(void *mech, int seg, float out3[3])
{
if (mech == NULL || application == NULL)
return 0;
@@ -1704,6 +2256,35 @@ int BTMechSegmentPick(void *mech, const float ray_start[3], const float ray_dir[
(BTL4VideoRenderer *)application->GetVideoRenderer();
if (renderer == NULL)
return 0;
return renderer->SegAimPoint((Entity *)mech, seg, out3);
}
int BTMechZoneAimPoint(void *mech, int zone, float out3[3])
{
if (mech == NULL || application == NULL)
return 0;
BTL4VideoRenderer *renderer =
(BTL4VideoRenderer *)application->GetVideoRenderer();
if (renderer == NULL)
return 0;
return renderer->ZoneAimPoint((Entity *)mech, zone, out3);
}
//
// Game-side bridge (mech4.cpp's per-frame target pick; same access pattern as
// the wreck swap below).
//
int BTMechSegmentPick(void *mech, const float ray_start[3], const float ray_dir[3],
float max_range, float hit_out[3], int *zone_out)
{
// #131 contract: 1 = drawn-geometry hit, 0 = TRUE MISS (mesh tested),
// -1 = no renderer/tree (caller may box-test as pre-tree grace).
if (mech == NULL || application == NULL)
return -1;
BTL4VideoRenderer *renderer =
(BTL4VideoRenderer *)application->GetVideoRenderer();
if (renderer == NULL)
return -1;
return renderer->MechSegmentPick((Entity *)mech, ray_start, ray_dir,
max_range, hit_out, zone_out);
}
@@ -2041,6 +2622,14 @@ 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);
@@ -2916,6 +3505,16 @@ int
if (inside && nm != NULL && strstr((const char *)*nm, "_cop") != NULL
&& getenv("BT_HIDE_COCKPIT"))
nm = NULL;
// #91 attribution diag: BT_HIDE_INSIDE_SEG=<substr> hides any OTHER
// inside-view (type-A) mesh whose name contains the substring -- the
// thor authors THREE type-A meshes (cop + thx_tor + thx_msl) and one
// of the extras is the reported black rectangle.
{
const char *hide = getenv("BT_HIDE_INSIDE_SEG");
if (inside && nm != NULL && hide && *hide
&& strstr((const char *)*nm, hide) != NULL)
nm = NULL;
}
d3d_OBJECT *obj = NULL;
if (nm != NULL)
{
@@ -2947,6 +3546,14 @@ int
else
render_tree.segPick.erase(slot);
if (obj) ++shown; else ++hidden;
// Inside view: NAME what renders. The KB says the inside view is the
// _cop alone; any OTHER type-A mesh here is a black-material stowaway
// (the #91 rectangle investigation) -- keep the roster visible.
if (inside && obj != NULL)
DEBUG_STREAM << "[view] shown seg " << slot << ": "
<< (const char *)segment->GetName()
<< " mesh " << (nm ? (const char *)*nm : "?")
<< "\n" << std::flush;
}
// This reloaded every segment mesh, so every armour-damage binding is stale.
BindArmourDamage(viewpoint, render_tree);
@@ -3080,6 +3687,35 @@ 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.
+36
View File
@@ -721,6 +721,24 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device);
struct DmgBind { d3d_OBJECT *obj; int op; int zone; };
std::vector<DmgBind> dmgBinds;
std::map<int, int> segGState; // slot -> last applied graphic state
// SEARCHLIGHT (2026-08-05, MakeMechRenderables case 0xbd8 @004cef28):
// external view = a spot.bgf cone child on the mount joint, shown/
// hidden from the subsystem's "LightOn" attribute (the @0045612c
// watcher, transcribed into TickSearchlight); cockpit view = the fog
// swap (@00456778/@00456814 -> SetFogStyle searchLightOn/Off).
struct SearchLight
{
DPLStaticChildRenderable *cone; // external only; NULL on cockpit builds
d3d_OBJECT *coneObj; // spot.bgf (held while hidden)
int *lightOn; // the subsystem's LightOn attribute
int shown; // cone visibility last applied
int mountSeg; // resource segmentIndex (subsys +0x1DC)
};
SearchLight searchLight[2]; // the binary keeps 2 slots
int searchLightCount;
int searchIsCockpit; // built with view_type 1
int searchFogCache[2]; // seeded INVERTED (@00456778
// [9]/[10]) -> first tick syncs
char paintSerno; // the %serno% this mech was BUILT with
// (0 = none) -- ApplyViewSkeleton re-parses
// segment BGFs, so the paint substitutions
@@ -740,6 +758,15 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device);
int
TickWreck(Entity *victim, float dt);
//
// SEARCHLIGHT per-frame drive (2026-08-05): the transcription of the two
// 1995 watcher renderables -- cone visibility from "LightOn" (@0045612c
// Execute @004561d8) and the cockpit fog swap (@00456778 Execute
// @00456814). Rides the same sim bridge as TickArmourDamage.
//
void
TickSearchlight(Entity *mech);
//
// ARMOUR DARKENING (issue #87). BindArmourDamage resolves the mech's
// .DZM zone->material lists (DamageZone::GetMaterialList, already parsed
@@ -797,6 +824,15 @@ extern void BTDrawReticle(struct IDirect3DDevice9 *device);
const float ray_dir[3], float max_range,
float hit_out[3], int *zone_out);
// #124 zone walker: a zone's VISUAL aim point -- its largest pick
// object's cull-center in world (what a player aims at; the segment
// ORIGIN sits at the joint and makes lower segments strike the part
// above). 0 when the zone has no pick geometry.
int
ZoneAimPoint(Entity *mech, int zone, float out3[3]);
int
SegAimPoint(Entity *mech, int seg, float out3[3]);
protected:
//
// Renderer-manager overrides
File diff suppressed because it is too large Load Diff
+121 -22
View File
@@ -57,14 +57,26 @@ class DropZone__ReplyMessage;
// generated the points. Parallels RPPlayer__ScoreMessage, but the BT
// score types describe mech combat instead of score zones.
//
// Observed message layout (param_2 in the handlers):
// +0x1c scoreAward (Scalar, base Player::ScoreMessage)
// Message layout, sizeof 0x3C (field meanings CORRECTED 2026-08-05 from the
// raw disasm of the three sender builds in the Mech::TakeDamageMessageHandler
// report tail @0x4a04da/@0x4a05d9/@0x4a06c0 -- the old "pointSender" names
// were guesses from RP's smaller message, which stops at +0x28):
// +0x1c scoreAward (Scalar, base) -- the sender puts the APPLIED
// damage tally here; the receiving handler
// overwrites it with the computed award before
// delegating to Player::ScoreMessageHandler
// +0x20 scoreType (int)
// +0x24 damageAmount (Scalar) raw damage / point quantity
// +0x28 pointSenderHi (EntityID word)
// +0x2c pointSenderLo (EntityID word)
// +0x30 auxID (EntityID / host word)
// +0x34 senderMechID (EntityID -- resolved to the inflicting Mech)
// +0x24 damageAmount (Scalar) the scoring BASIS: type 0 = tally,
// type 1 = intended (burstCount x amount),
// type 2 = the VICTIM role's killBonus (role+0x1c)
// +0x28 vitalHit (int) a zone reached BurningState during
// this message's applications (loop local_2c)
// +0x2c zoneIndex (int) the struck damage zone (msg+0x24,
// the INITIAL resolve -- not the last burst's)
// +0x30 subsysID (int) inflictingSubsystemID passed through
// from the TakeDamageMessage (+0x5c)
// +0x34 senderMechID (EntityID) types 0/2: the VICTIM mech;
// type 1: the INFLICTING mech
//
class BTPlayer__ScoreMessage:
public Player::ScoreMessage
@@ -72,11 +84,24 @@ class DropZone__ReplyMessage;
public:
//
// Kind of scoring event. Recovered from the branch selector at
// @004c02e4 (this->scoreType, message+0x20) and the dedicated
// inflicted-damage handler at @004c0200.
// @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".
//
enum ScoreType {
DamageInflictedScore = 0, // to ScoreInflictedMessageHandler
DamageInflictedScore = 0, // per-hit inflicted credit (LIVE, see above)
DamageReceivedScore = 1, // I took damage
KillScore = 2 // I destroyed / was destroyed
};
@@ -85,21 +110,19 @@ class DropZone__ReplyMessage;
scoreType; // +0x20
Scalar
damageAmount; // +0x24
damageAmount; // +0x24 scoring basis (see layout table)
//
// Point-source / auxiliary handles carried alongside the damage record
// (read by ScoreMessageHandler when building the console feed message).
//
int
pointSenderHi; // +0x28
vitalHit; // +0x28 zone entered BurningState this message
int
pointSenderLo; // +0x2c
zoneIndex; // +0x2c struck damage zone
int
auxID; // +0x30
subsysID; // +0x30 inflicting weapon subsystem
EntityID
senderMechID; // +0x34 inflicting mech (point sender)
senderMechID; // +0x34 victim (types 0/2) / inflictor (type 1)
BTPlayer__ScoreMessage(
Receiver::MessageID message_ID,
@@ -107,15 +130,63 @@ class DropZone__ReplyMessage;
int score_type,
Scalar score_award,
Scalar damage_amount,
const EntityID &sender_mech_ID
const EntityID &sender_mech_ID,
int vital_hit = 0,
int zone_index = 0,
int subsys_ID = 0
):
Player::ScoreMessage(message_ID, length, score_award),
scoreType(score_type),
damageAmount(damage_amount),
vitalHit(vital_hit),
zoneIndex(zone_index),
subsysID(subsys_ID),
senderMechID(sender_mech_ID)
{}
};
//###########################################################################
//################# BTPlayer::VehicleDeadMessage ######################
//###########################################################################
//
// BT extension of the engine Player__VehicleDeadMessage (PLAYER.h:
// deathCount @0x1c, dropZoneID @0x20). Decoded 2026-08-05 from the death
// tail of Mech::TakeDamageMessageHandler (@0x4a07d4-0x4a0890, raw disasm):
// the binary builds a 0x38-byte message {size 0x38, id 0x17 ==
// Player::VehicleDeadMessageID, pri 1, deathCount -1 (the engine ctor
// default), dropZoneID Null, +0x28 = 0, +0x2c = the KILLER PLAYER's own
// EntityID (shooterPlayer+0x184), +0x34 = the killing zone (msg+0x24)} and
// dispatches it to the dying mech's own player -- the respawn-cycle
// trigger, now carrying the killed-by attribution. Whether the @004c05c4
// handler consumes the two extension fields is still undecoded (the
// function is not in the export); the sender carries them faithfully.
//
class BTPlayer__VehicleDeadMessage:
public Player::VehicleDeadMessage
{
public:
int
reserved28; // +0x28 zeroed by the binary (@0x4a0856)
EntityID
killedByPlayerID; // +0x2c the killer PLAYER's EntityID
int
killZone; // +0x34 zone the killing damage struck
BTPlayer__VehicleDeadMessage(
Receiver::MessageID message_ID,
size_t length,
const EntityID &killed_by_player_ID,
int kill_zone
):
Player::VehicleDeadMessage(message_ID, length), // deathCount -1, dropZone Null
reserved28(0),
killedByPlayerID(killed_by_player_ID),
killZone(kill_zone)
{}
};
//###########################################################################
//##################### BTPlayer::MakeMessage #########################
//###########################################################################
@@ -202,8 +273,9 @@ class DropZone__ReplyMessage;
// Player::ScoreMessage.)
//
public:
typedef BTPlayer__ScoreMessage ScoreMessage;
typedef BTPlayer__MakeMessage MakeMessage;
typedef BTPlayer__ScoreMessage ScoreMessage;
typedef BTPlayer__MakeMessage MakeMessage;
typedef BTPlayer__VehicleDeadMessage VehicleDeadMessage; // BT 0x38 ext (killed-by)
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Message Support
@@ -224,6 +296,23 @@ 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();
@@ -238,6 +327,14 @@ 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
@@ -437,6 +534,8 @@ class DropZone__ReplyMessage;
friend int BTPlayerExperienceSimLive(void *); // +0x25c reader (issue #2): jams / lights / powersub short path
friend int BTPlayerExperienceHeatModelOn(void *); // +0x260 reader (issue #2): FUN_004ad7d4 heat-model gate
friend int BTPlayerAdvancedDamageOn(void *); // +0x268 reader (#83): FUN_0049ffcc collision-rattle gate
friend Scalar BTPlayerEjectBookkeeping(void *); // +0x258 latch + role killBonus (Mech::EjectPilot @0049f854)
friend int BTPlayerConsoleSuppressed(void *); // +0x258 reader (#89): death-blast eject gate @0x4a0ace
// TARGET DESIGNATION (Gitea #48/#57): the mapper used to write the target
// RAW at `pilot + 0x284` -- the BINARY's objectiveMech offset, which on our
// compiled object is `deathPending` (the respawn latch). These bridges
+9 -10
View File
@@ -10,7 +10,7 @@
// body recovered from BTL4OPT.EXE (the binary oracle) / the RP analogue. //
// //
// == RUNTIME BRING-UP WORKLIST (replace these) == //
// Mech::GetMissionReviewMode / IsAirborne / SetTargetRange / //
// Mech::IsAirborne / SetTargetRange / //
// SetMappingSubsystem / RaiseStatusAlarm //
// Mech__DamageZone::LoadCriticalSubsystems //
// MechSubsystem::TakeDamage / OnAlarmChanged //
@@ -97,11 +97,11 @@ void Notify_Objective_Reached(int * /*objective_subsystem*/, Mech * /*mech*/)
// Mech method stubs.
//===========================================================================//
// TODO(bring-up): reads mech+0x414 (mission-review playback flag).
int Mech::GetMissionReviewMode()
{
return 0;
}
// (GetMissionReviewMode stub RETIRED 2026-08-03: it was a mislabel of
// mech+0x414 = ejectPermitted, now served by the inline GetEjectPermitted in
// mech.hpp.) The REAL mission-review mode is this GLOBAL (DAT_004fd550);
// the port never enters scrub/review, so it stays 0.
int gMissionReviewMode = 0;
// TODO(bring-up): true while the mech is off the ground (jump-jet / fall state).
int Mech::IsAirborne()
@@ -336,10 +336,9 @@ BTMarkerWatcherRenderable::BTMarkerWatcherRenderable(
// BTTranslocationRenderable (the "blue warp" translocation sphere) is now a real
// reconstruction in btl4vid.cpp (task #52) -- no longer a stub here.
BTPOVStartEndRenderable::BTPOVStartEndRenderable(
Entity *entity, int, dpl_VIEW *, dpl_ZONE *, dpl_ZONE *, StateIndicator *,
float, float, float, float, float, int, int)
: BTRenderableBase(entity) {}
// 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.
BTTracerEffectRenderable::BTTracerEffectRenderable(
Entity *entity, int, void *, int, dpl_ZONE *, dpl_DCS *, LinearMatrix *)
+49 -3
View File
@@ -175,9 +175,14 @@ DamageZonePercentTable *
{
if (sliceCount <= 0) return 0;
Scalar thetaIn = theta;
if (rotateWithTorso) // binary this[3] != 0
{
theta += owner->TorsoHeading(); // torso+0x1d8
// #124 frame adapter, angular half: theta arrives in the 1995 frame
// (ResolveHit's z-reflection), so the twist -- measured in OUR frame
// -- enters with the OPPOSITE sign (a reflection reverses angular
// direction). Binary form: theta += torso+0x1d8.
theta -= owner->TorsoHeading(); // torso+0x1d8 (sign: see adapter)
if (theta >= TwoPi) theta -= TwoPi;
if (theta < 0.0f) theta += TwoPi;
}
@@ -185,6 +190,19 @@ DamageZonePercentTable *
int index = (int)floorf(theta * (Scalar)sliceCount * (1.0f / TwoPi));
if (index < 0) index = 0;
if (index > sliceCount - 1) index = sliceCount - 1;
// #124 twist-sign bench: expose the twist term itself -- the leaf is a
// weighted roll, so the SLICE choice (not the rolled zone) is the signal.
if (getenv("BT_DMGTABLE_LOG"))
{
static int s_sl = 0;
if (s_sl++ < 400)
DEBUG_STREAM << "[slice] rot=" << (int)rotateWithTorso
<< " twist=" << (float)owner->TorsoHeading()
<< " thetaIn=" << (float)thetaIn
<< " thetaAdj=" << (float)theta
<< " -> slice " << index << "/" << sliceCount << std::endl;
}
return slices[index];
}
@@ -277,6 +295,24 @@ int
Scalar heightRef = owner->CylinderReferenceHeight(); // owner+0x2ec[+0xc]
if (heightRef <= 0.0f) return -1;
// PORT FRAME ADAPTER (#124, corrected 2026-08-03 by the MUZZLE-ANCHOR
// probe). The 1995 resolver frame: FORWARD = +Z (the authored ring puts
// Front* cells in the +Z arc) and RIGHT = +X (W0/W7 = "Right*") [T1
// slice-name data]. Our engine frame, measured with the mech's own
// asymmetric geometry as the anchor (BT_ASPECT_TEST muzzle probes -- the
// LEFT missile pod LRM15_1 sits at raw local x=-2.32, the RIGHT pod
// LRM15_2 at x=+2.32): RIGHT = +X (SAME as 1995) but FORWARD = -Z
// (drive-code convention, "forward = -Z at heading 0"). The frames
// therefore differ by a Z-NEGATION ONLY -- a REFLECTION, not the pi
// rotation first committed (that crossed the pods: left muzzle resolved
// rtorso). y (the band axis) untouched.
//
// A reflection reverses the angular walk direction, so the OTHER angular
// input -- the torso-twist term SelectSlice adds -- must flip sign with
// it (applied there, same adapter). Twist was 0 in every probe; the
// twisted-torso verify is the follow-up on #124.
local.z = -local.z;
// --- height -> layer (penetration depth) ---
Scalar depth = local.y; // binary local_2c
int layerIndex = (int)floorf((Scalar)layerCount * (depth / heightRef));
@@ -305,12 +341,22 @@ int
{
static int s_rh = 0;
if (s_rh++ < 400)
DEBUG_STREAM << "[dmgresolve] localY=" << local.y
{
extern int BTMechZoneSegAndName(void *mech_v, int zone_idx,
int *seg_out, const char **name_out);
int seg = -1; const char *zname = 0;
if (resolved >= 0)
BTMechZoneSegAndName((void *)owner, resolved, &seg, &zname);
DEBUG_STREAM << "[dmgresolve] impact=(" << impact.x << ","
<< impact.y << "," << impact.z << ")"
<< " local=(" << local.x << "," << local.y << "," << local.z << ")"
<< " heightRef=" << heightRef
<< " frac=" << (depth / heightRef)
<< " layer=" << layerIndex << "/" << layerCount
<< " theta=" << theta
<< " -> zone " << resolved << std::endl;
<< " -> zone " << resolved
<< " '" << (zname ? zname : "?") << "'" << std::endl;
}
}
return resolved;
}
+8 -1
View File
@@ -304,7 +304,14 @@ 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;
if ((s_fireLog++ % 19) == 0) // 19 prime: rotates across the 5 emitters (20 aliased to one)
// #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)
DEBUG_STREAM << "[emitter] FIRED #" << s_fireLog << " damage=" << damagePortion
<< " heat=" << heatPortion << " pendingHeat=" << pendingHeat << "\n" << std::flush;
+226 -19
View File
@@ -359,22 +359,40 @@ Condenser::~Condenser()
#endif
//###########################################################################
// ResetToInitialState -- Condenser (HEAT.TCP)
// ResetToInitialState -- Condenser
//
// RE-TRANSCRIBED FROM THE BINARY (respawn-reset audit 2026-08-04; the function
// is MISSING from the Ghidra export -- disassembled raw, tools/disas2.py
// 0x4ae534 0x60). The old body chained HeatableSubsystem (temp+heatLoad only,
// per the stale HEAT.TCP shard) -- the binary chains HEATSINK (call 0x4ad760),
// so the coolant refill DOES run for a condenser; then, RESPAWN-side only:
//
// test esi, esi ; je ... -> if (powered) {
// [this+0x1d0] = 1 -> valveState = 1 (default detent --
// the pod RESETS every coolant valve)
// eax = [this+0x1d8]; [0x160] = eax -> massScale = refrigerationFactor
// (restore the refrigeration output) }
//
// Mech::Reset's tail then re-runs BTRecomputeCondenserValves (@0049f788), so
// the flow fractions rebuild from the reset detents (mech4.cpp). This is the
// operator-reported "coolant valves survive a respawn" gap [was T3, now T1].
//
void
Condenser::ResetToInitialState(Logical /*powered*/)
Condenser::ResetToInitialState(Logical powered)
{
HeatableSubsystem::ResetToInitialState(True);
HeatSink::ResetToInitialState(powered); // call 0x4ad760
if (powered)
{
if (getenv("BT_DEATH_LOG") && valveState != 1)
DEBUG_STREAM << "[respawn] " << GetName() << " valve detent "
<< valveState << " -> 1 (authentic @004ae534 restore)\n" << std::flush;
valveState = 1; // @0x1D0 = 1
massScale = refrigerationFactor; // @0x160 = @0x1D8
}
}
//
// DeathReset (Gitea #55): the respawn sweep's entry for a Condenser -- a coolant
// loop's valve. NOTE the body above chains HeatableSubsystem's (temperature +
// heatLoad) and so does NOT run HeatSink's coolant refill, even though Condenser
// derives from HeatSink. That mirrors the existing reconstruction; whether the
// binary's Condenser reset also restores the VALVE SETTING is not established from
// the decomp yet, so valve detents may still persist across a respawn.
// [T3 -- do NOT claim valves are fixed; tracked on the issue.]
// DeathReset (Gitea #55): the respawn sweep's entry for a Condenser.
//
void
Condenser::DeathReset(int reset_command)
@@ -777,7 +795,7 @@ void
{
heatEnergy += pendingHeat;
currentTemperature = heatEnergy / thermalMass;
UpdateHeatLoad();
UpdateHeatLoad(time_slice); // 28 Hz filter cadence (#119)
// DIAG census (BT_HEAT_LOG, viewpoint mech): PER-INSTANCE 5-s timers --
// the old shared static timer aliased to whichever instance crossed the
@@ -850,8 +868,22 @@ void
// @004ad7f0 -- recompute the radiated/instantaneous heat and feed it through
// the running-average filter to produce the smoothed heatLoad reading.
//
// POD-CADENCE SAMPLING (#119, 2026-08-02). The binary adds ONE sample per
// Perform -- a dt-less per-frame term (the myomer-kinetic class, FUN_0041c018
// proves Performs ride the frame rate). 15 samples at the pod's 28 Hz = a
// 0.536 s smoothing window; sampled at the port's ~59 fps the window halves
// and heatLoad turns 2x as twitchy -- which doubles the cadence of the
// ReportLeak relaxation oscillation at a drained tank (draw = damage x
// heatLoad hunting across the authored 0.0025/0.003 hysteresis band), and
// THAT is the rapidly-clipping "warning coo-- war--" leak voice Oracle
// reproduced twice in 716. Sim calls pass their slice and samples accrue at
// 28 Hz on any machine; ctor/reset prime calls (negative dt) sample
// unconditionally. Per-instance clock lives in a static map (the census
// precedent above) because the heat-family layouts are factory-size-locked
// (sizeof(Myomers) == 0x358 exact) -- no new members.
//
void
HeatSink::UpdateHeatLoad()
HeatSink::UpdateHeatLoad(Scalar time_slice)
{
radiatedHeat = currentTemperature * coolantLevel;
@@ -865,7 +897,31 @@ void
sample = HeatLoadMaximum;
}
heatFilter.AddSample(sample); // FUN_0043ade4
if (time_slice >= 0.0f)
{
static std::map<const void *, Scalar> s_filterClock;
Scalar &clock = s_filterClock[this];
clock += time_slice;
const Scalar kPodTick = 1.0f / 28.0f; // [T1] pod Perform cadence
if (clock < kPodTick)
{
return; // heatLoad holds the last average
}
int catchUp = 0;
while (clock >= kPodTick && ++catchUp <= 15) // >15 samples saturate the window
{
clock -= kPodTick;
heatFilter.AddSample(sample); // FUN_0043ade4
}
if (catchUp > 15)
{
clock = 0.0f; // hitch: window already saturated
}
heatLoad = heatFilter.Average(); // FUN_0043ae0b
return;
}
heatFilter.AddSample(sample); // FUN_0043ade4 (prime call)
heatLoad = heatFilter.Average(); // FUN_0043ae0b
}
@@ -891,6 +947,26 @@ 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
@@ -1363,12 +1439,143 @@ int BTCondenserNumber(::Subsystem *sub)
//
// #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.
// 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.
//
Derivation *
BTHeatSinkFamily()
//===========================================================================//
// BTHeatSinkBankCoolantFraction -- complete-type bridge for the EJECT-
// permission evaluator (Mech::EvaluateEjectPermission @0049fa1c). The binary
// clause: classID 0xBBE (the AGGREGATE heat-sink bank -- the factory case
// carries the known "Sensor" mislabel; ctor-address truth per CLASSMAP rule),
// fraction = coolantLevel(@0x12C) / thermalCapacity(@0x128). Eject unlocks
// below 0.05 -- i.e. the mech has LEAKED nearly dry. Returns 0 = not the
// bank, else 1 with *out filled.
//===========================================================================//
int BTHeatSinkBankCoolantFraction(Subsystem *sub, Scalar *out)
{
return HeatSink::GetClassDerivations();
if (sub == 0 || (int)sub->GetClassID() != 0xBBE)
return 0;
HeatSink *bank = (HeatSink *)sub;
*out = bank->CoolantFractionOf();
return 1;
}
//===========================================================================//
// BTReportHeatAtReset -- #137 forensic (ungated when BT_HEAT_LOG is set).
//
// The [heat-t] census runs on a 5-second per-instance timer, which is far too
// coarse to answer the question #137 actually poses: "respawn came back with
// MYOMERS heat MAXED". Is the temperature high BECAUSE the reset did not
// clear it, or because it climbs again within the first second? Those need a
// sample taken AT the reset, which is what this is. Called from Mech::Reset
// immediately after the subsystem sweep, so every heat-bearing subsystem
// reports the temperature the reset actually left it at.
//===========================================================================//
void BTReportHeatAtReset(void *mech_v)
{
if (mech_v == 0 || getenv("BT_HEAT_LOG") == 0)
return;
Entity *mech = (Entity *)mech_v;
const int count = mech->GetSubsystemCount();
for (int i = 0; i < count; ++i)
{
Subsystem *s = mech->GetSubsystem(i);
if (s == 0)
continue;
// UNFILTERED first: the earlier pass filtered on IsDerivedFrom(HeatSink)
// and reported no Condensers. That test rides a hand-built Derivation
// chain, so a false negative there is indistinguishable from "not in the
// roster" -- name every roster entry and say whether the test passed.
if (!s->IsDerivedFrom(*HeatableSubsystem::GetClassDerivations()))
{
DEBUG_STREAM << "[heat-reset] roster[" << i << "] "
<< (s->GetName() ? s->GetName() : "?")
<< " (not HeatSink-derived)" << "\n" << std::flush;
continue;
}
HeatableSubsystem *sink = (HeatableSubsystem *)s;
DEBUG_STREAM << "[heat-reset] " << (s->GetName() ? s->GetName() : "?")
<< " T=" << (float)sink->currentTemperature
<< "\n" << std::flush;
}
}
//===========================================================================//
// BTReportMyomerFreeze -- #137 forensic (BT_HEAT_LOG), the POST-reset trace.
//
// BTReportHeatAtReset samples AT the reset and showed every subsystem at
// T == startingTemperature, which is what made the reset path look innocent and
// got #137 wrongly closed. The field then proved the freeze is real (5 of 61
// respawns: throttle up, speedDemand pinned at 0) -- so the interesting window
// is the frames immediately AFTER the reset, which nothing was sampling.
//
// Three explanations survive the decomp read and only data separates them:
// (a) RESET DIDN'T TAKE -> temp is high right after the reset
// (b) STALE CACHE -> temp is at start but speedEffect is still 0
// (HeatSink::RTIS @004ad760 writes only bytes
// 0x114/0x12C/0x130/0x134/0x138/0x158/0x15C --
// it does NOT touch Myomers::speedEffect @0x31C,
// and Myomers::RTIS @004b8aa4 only chains to the
// PoweredSubsystem one, so the pre-death value
// survives until the myomers next ticks)
// (c) INSTANT RE-HEAT -> temp starts at start and climbs back at once
//
// Prints per myomers: temperature, its own speedEffect, and the mech-level MAX
// the mover actually multiplies by. Freeze == that MAX at 0.
//===========================================================================//
void BTReportMyomerFreeze(void *mech_v, const char *when)
{
if (mech_v == 0 || getenv("BT_HEAT_LOG") == 0)
return;
Entity *mech = (Entity *)mech_v;
extern Scalar BTMyomersSpeedEffectOf(void *subsystem);
const int count = mech->GetSubsystemCount();
Scalar best = -1.0f;
for (int i = 0; i < count; ++i)
{
Subsystem *s = mech->GetSubsystem(i);
if (s == 0)
continue;
// ROSTER-WIDE now (2026-08-09): the myomers-only version proved the
// myomers is being COOKED FROM OUTSIDE -- +2.3e9 of energy arrives in
// <=30 frames while its own integrator reads near-zero v (our Reset
// zeroes localVelocity, matching the binary's +0x1c4 zero-fill, so
// termKinetic/termAccel are dead at spawn). ConductHeat flow is
// bounded by deltaT, so an e9 slug through it demands a NEIGHBOUR at
// extreme temperature. Print EVERY heat-bearing subsystem's T plus
// the two cells T is actually derived from (heatEnergy @0x158,
// pendingHeat @0x1C8) to NAME that neighbour.
if (!s->IsDerivedFrom(*HeatableSubsystem::GetClassDerivations()))
continue;
HeatableSubsystem *h = (HeatableSubsystem *)s;
// Every Heatable-positive roster member in this game IS a HeatSink
// (the Watcher branch -- Torso/HUD/Gyro -- rides HeatWatcher, which is
// not HeatableSubsystem-derived), so the downcast for E/pend is safe.
// Deliberately NOT IsDerivedFrom(HeatSink): that hand-built chain
// returns false for Condenser (the night-13 trap).
HeatSink *hs = (HeatSink *)s;
Scalar f = BTMyomersSpeedEffectOf(s); // >= 0 only for a Myomers
if (f > best) best = f;
DEBUG_STREAM << "[myofreeze] " << when << " " << (s->GetName() ? s->GetName() : "?")
<< " T=" << h->currentTemperature
<< " E=" << hs->heatEnergy
<< " pend=" << hs->pendingHeat
<< " fail=" << h->failureTemperature;
if (f >= 0.0f)
{
DEBUG_STREAM << " speedEffect=" << f
<< ((f <= 1.0e-4f && h->currentTemperature < h->failureTemperature)
? " <<<< STALE (cold but zero)" : "");
}
DEBUG_STREAM << "\n" << std::flush;
}
if (best >= 0.0f)
DEBUG_STREAM << "[myofreeze] " << when << " CHAIN MAX=" << best
<< (best <= 1.0e-4f ? " <<<< FROZEN" : "") << "\n" << std::flush;
}
+9 -1
View File
@@ -512,8 +512,11 @@ inline int
// Internal model helpers
//
public:
// time_slice >= 0: sample the filter at the POD's 28 Hz cadence (#119
// -- the per-Perform filter is frame-rate-dependent, the myomer-kinetic
// class); negative (the ctor/reset prime calls) samples unconditionally.
void
UpdateHeatLoad(); // @004ad7f0
UpdateHeatLoad(Scalar time_slice = -1.0f); // @004ad7f0
void
ClearHeatFilter(); // @004ad884
void
@@ -567,6 +570,11 @@ inline int
Scalar filterDecay; // @0x150 init 0.15f (@004b8fec) -- ALSO the
// leak gauge's full-scale divisor (#97)
Scalar LeakGaugeFullScale() const { return filterDecay; }
// Eject evaluator (@0049fa1c): the bank's remaining-coolant fraction
// (@0x12C / @0x128). Capacity is authored nonzero (ctor init 1.0);
// the guard only shields a malformed stream from a NaN.
Scalar CoolantFractionOf() const
{ return (thermalCapacity > 0.0f) ? (coolantLevel / thermalCapacity) : 0.0f; }
Scalar thermalMass; // @0x154 resource +0xF4
Scalar heatEnergy; // @0x158 init = thermalMass * startingTemperature
Scalar coolantFlowScale; // @0x15C init 1.0f (== "word57")
+1 -1
View File
@@ -1233,7 +1233,7 @@ void
}
heatEnergy += pendingHeat; // [0x56] += [0x72]
currentTemperature = heatEnergy / thermalMass; // [0x45] = [0x56]/[0x55]
UpdateHeatLoad(); // FUN_004ad7f0
UpdateHeatLoad(time_slice); // FUN_004ad7f0 (28 Hz cadence, #119)
pendingHeat = 0.0f;
Scalar target = 300.0f
+57 -10
View File
@@ -52,15 +52,41 @@ struct HUDLayoutCheck
};
//
// Tuning constants observed as read-only float globals adjacent to the
// HudSimulation body (.rdata, recovered from section_dump.txt).
// 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.
//
static const Point3D HudZeroVector(0.0f, 0.0f, 0.0f); // DAT_004e0f74/78/7c
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
// 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
//
// Cross-family helper (definition lives in the mech game layer; declared here
@@ -322,9 +348,13 @@ 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 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.
// 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.)
//
void
HUD::HudSimulation(Scalar time_slice)
@@ -475,3 +505,20 @@ 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,6 +196,12 @@
// 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);
+365 -23
View File
@@ -466,6 +466,7 @@ const Receiver::HandlerEntry
MESSAGE_ENTRY(Mech, PlayerLink),
MESSAGE_ENTRY(Mech, BalanceCoolant), // id 0x16 @0049f728 (issue #20)
MESSAGE_ENTRY(Mech, DuckRequest), // id 0x1a @0049fa00 (CROUCH, 2026-07-26)
MESSAGE_ENTRY(Mech, EjectPilot), // id 0x19 @0049f854 (PANIC/EJECT, 2026-08-02)
};
//
@@ -509,8 +510,156 @@ void
{
return;
}
duckState = 1;
DEBUG_STREAM << "[duck] DuckRequest: duckState -> 1" << std::endl << std::flush;
// #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;
}
//
// @0049fa1c -- the EJECT-PERMISSION evaluator (writes ejectPermitted @0x414).
// Raw decomp, transcribed clause for clause: walk the roster from index 2 --
// classID 0xBBE (HeatSinkBank): coolant fraction = coolantLevel/thermalCapacity
// classID 0xBC1 (Generator): count live (not destroyed && state != 4)
// MechWeapon-derived: count live (not destroyed; ammo-fed weapons
// additionally need weaponAlarm != 7 = NoAmmo)
// then permitted = weapons < ejectMinWeapons || generators == 0
// || coolant < 0.05 (@0049fb50)
// || (MovementMode 3/4 [leg-gimped] && player simLive == 0).
// The binary refreshes this per frame from an UNEXPORTED caller; the port
// evaluates on demand (the handler + any future panic-lamp consumer).
//
int
Mech::EvaluateEjectPermission()
{
extern int BTWeaponCountsForEject(Subsystem *sub); // projweap.cpp (-1 = not a weapon)
extern int BTGeneratorCountsForEject(Subsystem *sub); // powersub.cpp (-1 = not a generator)
extern int BTHeatSinkBankCoolantFraction(Subsystem *sub, Scalar *out); // heat.cpp
int liveWeapons = 0;
int liveGenerators = 0;
Scalar coolantFrac = 0.0f; // local_10 (0 when no bank streams)
for (int i = 2; i < subsystemCount; ++i) // binary: from index 2
{
Subsystem *s = (Subsystem *)subsystemArray[i];
if (s == 0)
{
continue;
}
Scalar frac;
if (BTHeatSinkBankCoolantFraction(s, &frac))
{
coolantFrac = frac;
}
else
{
int g = BTGeneratorCountsForEject(s);
if (g >= 0)
{
liveGenerators += g;
}
else
{
int w = BTWeaponCountsForEject(s);
if (w >= 0)
{
liveWeapons += w;
}
}
}
}
int gimped = (MovementMode() == 3 || MovementMode() == 4); // mech+0x40 - 3U < 2
extern int BTPlayerExperienceSimLive(void *owner_mech); // btplayer.cpp (+0x25c;
int noviceSim = (BTPlayerExperienceSimLive(this) == 0); // NULL-player reads LIVE)
// (A short-lived coolant-clause HYSTERESIS was tried and REMOVED
// 2026-08-03, same night, for strict fidelity: the binary's clause is
// this plain `< 0.05` compare, evaluated per frame by the master
// performance (FUN_004a9b5c+0x10) -- the ARCADE ran exactly this, so a
// bank fraction hovering at the threshold flapped the panic-arm mode at
// frame rate there too. The flap is cosmetic churn (mode mask + lamp;
// hundreds of transitions ran crash-free on the bench) and was NOT the
// operator's audio tick -- that was the autofire scalpel spamming the
// per-pull jam click, no game bug. Keep the authentic compare.)
ejectPermitted =
(liveWeapons < ejectMinWeapons) // @0x448 floor (inert at 0)
|| (liveGenerators == 0)
|| (coolantFrac < 0.05f) // _DAT_0049fb50
|| (gimped && noviceSim);
return ejectPermitted;
}
//
// @0049f854 -- EjectPilot (id 0x19): the cockpit PANIC/EJECT punch-out.
// See the header comment (mech.hpp) for the byte-level story.
//
void
Mech::EjectPilotMessageHandler(ReceiverDataMessageOf<int> *message)
{
if (message->dataContents <= 0) // press only (msg+0xc)
{
return;
}
if (EvaluateEjectPermission() == 0) // @0x414 gate: healthy mechs refuse
{
DEBUG_STREAM << "[eject] " << GetEntityID()
<< " REFUSED (mech not crippled enough)" << std::endl << std::flush;
return;
}
if (IsDisabled()) // @0049fb54 -- no ejecting from a wreck
{
DEBUG_STREAM << "[eject] " << GetEntityID()
<< " REFUSED (disabled: movementMode "
<< MovementMode() << ")" << std::endl << std::flush;
return;
}
// Binary @0049f88e-0x49f8cf: build the console eject notice (FUN_004c198c
// from ownerID @0x18c) and send it to the console host. CORE: forensic
// log; the relay wire is the tracked tail. The suppressConsole latch set
// below is the authentic other half (the following DEATH must not also
// notify the console).
extern Scalar BTPlayerEjectBookkeeping(void *player); // btplayer.cpp:
void *player = GetPlayerLink(); // suppressConsole=1,
Scalar charge = (player != 0) // returns role killBonus
? BTPlayerEjectBookkeeping(player) : 0.0f;
DEBUG_STREAM << "[eject] " << GetEntityID()
<< " PUNCH-OUT: charge=" << charge
<< " (role killBonus)" << std::endl << std::flush;
graphicAlarm.SetLevel(10); // FUN_0041bbd8(this+0x2C, 0xA) -- EJECT state
Damage dmg; // FUN_0041db7c (Damage::Damage)
dmg.damageType = (Enumeration)2; // Explosive
dmg.damageAmount = charge; // role+0x1c (killBonus)
dmg.impactPoint = localOrigin.linearPosition; // binary copies this+0x100
dmg.burstCount = 1; // binary writes 0; our victim-side
// guard clamps 0->1 -- same outcome
Entity::TakeDamageMessage take_damage(
Entity::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage),
GetEntityID(), // inflicting = SELF (this+0x184)
-1, // unaimed -> cylinder resolves
dmg,
-1); // inflictingSubsystemID = -1
Dispatch(&take_damage); // vtbl+0xC self-dispatch
}
Receiver::MessageHandlerSet
@@ -852,7 +1001,18 @@ void
<< " pts in " << n << " sub-hits (scale=" << scale << ")"
<< std::endl << std::flush;
extern Derivation *BTHeatSinkFamily(void); // heat.cpp (0x50e590)
// #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 *BTGyroscopeFamily(void); // gyro.cpp (0x50fdc0)
extern Derivation *BTTorsoFamily(void); // torso.cpp (0x510b08)
for (int i = 0; i < n; ++i) // @0x4a006b
@@ -864,7 +1024,7 @@ void
Subsystem *s = GetSubsystem(b); // roster @+0x128
if (s == 0)
continue;
if (!s->IsDerivedFrom(*BTHeatSinkFamily()) // @0x4a0092
if (!s->IsDerivedFrom("HeatSinkBank") // @0x4a0092 (0x50e590, the BANK)
&& !s->IsDerivedFrom(*BTGyroscopeFamily()) // @0x4a00b2
&& !s->IsDerivedFrom(*BTTorsoFamily())) // @0x4a00d2
continue;
@@ -883,6 +1043,30 @@ 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"))
@@ -931,24 +1115,64 @@ void
(float)(message->damageData.impactPoint.z - localOrigin.linearPosition.z));
}
//
// Death-edge latch, MOVED 2026-08-05 to the authentic position (binary
// @0x4a0303: captured at handler ENTRY, before the collision divert). It
// arms the whole death tail -- kill report, VehicleDead, death blast.
// 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)
//
// The zone the reports + VehicleDead carry: msg+0x24 as of loop entry.
// The binary never rewrites msg+0x24 after the initial cylinder resolve;
// our loop-exit matchlog write (damageZone = LAST burst's zone) must not
// leak into them.
//
int reportZone = message->damageZone;
DamageLookupTable *table = (DamageLookupTable *)damageLookupTable; // named member (Wword absorbs!)
//
// @0x4a0368 -- the COLLISION DIVERT (#83, closing the #82 chain). Damage
// type 0 == the mover's collision damage (what the crash response policy
// forwards). It NEVER reaches the zone/armor loop below: it is priced +
// distributed as INTERNAL RATTLE by DistributeCollisionDamage (@0049ffcc)
// and the handler is done. This was the missing half of the crash
// self-damage reconstruction -- without the divert, the raw kinetic-energy
// amounts (a 60-ton mech prices ~1000+ per wall tap) fell through into the
// WEAPON loop and a single bump killed the mech (run-14 field report).
// distributed as INTERNAL RATTLE by DistributeCollisionDamage (@0049ffcc).
// CORRECTED 2026-08-05: the binary does NOT return here -- it JUMPS TO THE
// DEATH TAIL (raw disasm @0x4a0375: jmp 0x4a07b5), so a mech that dies of
// the rattle still posts VehicleDead (the respawn trigger) and still
// blasts. The early `return` this block shipped with was a latent
// "wall-death strands the pilot" hazard. No score reports on this path --
// the jump bypasses them; a collision death credits no one. Authentic.
//
if (message->damageData.damageType == 0)
{
DistributeCollisionDamage(&message->damageData);
Check_Fpu();
return;
goto death_tail;
}
DamageLookupTable *table = (DamageLookupTable *)damageLookupTable; // named member (Wword absorbs!)
// #92: when this gate fails the zone keeps whatever the message carried --
// which for an unaimed weapon hit is 0, i.e. EVERY shot lands on zone 0.
if (getenv("BT_DMGTABLE_LOG"))
@@ -973,6 +1197,7 @@ void
<< message->damageData.impactPoint.z << ")\n" << std::flush;
}
}
reportZone = message->damageZone; // post-resolve (@0x4a0396 write)
//
// #80 -- the faithful application loop (binary @0x4a0423-0x4a04d8), which
// REPLACES the engine-base single application. Three things the base
@@ -1044,14 +1269,40 @@ void
}
message->damageZone = zoneIndex; // matchlog sees the LAST zone
// Binary tail (@0x4a04da-0x4a07b2, decoded + deferred): builds id-0x16
// damage/kill REPORT messages -- {tally, zone, zoneDestroyed flag,
// inflicting subsystem, victim name} -- to the shooter's player (with a
// kill-flagged variant when this damage NEWLY disabled the mech) and to
// the victim's player. That is the authentic stats plumbing (#45); the
// port's matchlog + BTPostDamageScore cover the bookkeeping today [T3].
(void)zoneDestroyed;
(void)damageTally;
//
// THE REPORT TAIL (@0x4a04da-0x4a07b2), reconstructed 2026-08-05 [T1]:
// the kill / inflicted / received score reports to the shooter's and
// victim's players. Sender lives in btplayer.cpp (complete-BTPlayer
// TU, per the databinding rule); it receives the APPLIED tally, the
// 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());
}
}
// MP MATCH FORENSICS (matchlog.hpp): the victim-side authoritative damage
@@ -1080,6 +1331,81 @@ void
(float)message->damageData.impactPoint.y,
(float)message->damageData.impactPoint.z);
}
// THE DEATH TAIL (@0x4a07b5-0x4a0bda, raw disasm -- the un-exported tail
// of THIS handler). Gate [T1]: mech was ALIVE at entry ([ebp-0x10]) and
// is dead(9)/eject(10) now. Sequence:
// 1. Player::VehicleDeadMessage (id 0x17; the BT 0x38-byte extension
// carrying killed-by player + kill zone) -> the mech's OWN player:
// the respawn-cycle trigger. CORRECTED 2026-08-05: the #89 banner
// previously read this build as "SetBurningState (mech id 0x17)" --
// it is a PLAYER-table id: the binary's BTPlayer handler table (file
// 0x112dxx) binds 0x17 -> @0x4c05c4 VehicleDeadMessageHandler; the
// mech-table 0x17 (@0x49f674) is unrelated. Dispatched from HERE --
// this is the decoded sender the mech4 transition site's
// [T3 sender-undecoded] flag was waiting for.
// 2. the death Explosion (id 3 MakeMessage, 0x5C bytes, model 0x31 at
// the mech origin, @0x4a08bd) -- the port fires the authored per-mech
// death list from the death transition instead (#42 history): same
// once-per-death edge, not double-spawned.
// 3. SplashDamage (#89), gated advancedDamageOn (+0x264) AND NOT
// suppressConsole (+0x258 -- ejected pilots' mechs never blast):
// Damage{type=2 Explosive, amount=deathSplashDamage@0x520,
// impact=mech origin,
// burstCount=round(0.001 * moverMass@0x20c * 15.0)}
// radius=deathSplashRadius@0x524, excluded=the dying mech;
// 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.
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)
{
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);
}
}
//
@@ -1249,6 +1575,8 @@ Mech::Mech(
mechNameFilter.Initialize(); // FUN_00435a7c(this+0xdb)
masterAlarm = AlarmIndicator(0x21); // FUN_0041b9ec(this+0xe7,0x21)
rearFiring = 0; // (task #68) ORed from the weapons below
ejectPermitted = 0; // @0x414 (refreshed by EvaluateEjectPermission)
ejectMinWeapons = 0; // @0x448 (no exported writer -- zero, clause inert [T3])
// (F7 correction) the binary's 0x400 = FLT_MAX init is DistanceToMissile's
// "no missile" far default (attr id 56), NOT a maxSpeed -- the old member
// is retired; distanceToMissile (init below) owns the slot's meaning.
@@ -1348,6 +1676,7 @@ 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;
@@ -1764,8 +2093,11 @@ Mech::Mech(
Wword(0x68) = model->cameraOffset; // FUN_00408440(this+0x1a0, rec+0xA8)
Wword(0x130) = model->deathEffectResourceID; // rec+0x74 -> mech+0x4c0
Wword(0x148) = model->deathSplashDamage; // rec+0x78 -> mech+0x520
Wword(0x149) = model->deathSplashRadius; // rec+0x7C -> mech+0x524
deathSplashDamage = model->deathSplashDamage; // rec+0x78 -> mech+0x520 (#89:
deathSplashRadius = model->deathSplashRadius; // rec+0x7C -> mech+0x524 named
// members -- the Wword bank is
// GLOBAL and clobbered per-mech
// values chassis-to-chassis)
Wword(0x195) = Wword(0x196) = 0;
Wword(0xfd) = 0;
masterAlarm.SetLevel(0); // FUN_0041bbd8(this+0xe7,0)
@@ -1919,6 +2251,9 @@ Mech::Mech(
ramLastVictim = 0; // ram contact-edge state
ramContactLinger = 0.0f;
lastInflictingDamage = 0.0f; // task #60: killing-blow magnitude (set on hit)
mapPosture = 0; // binary @0x3f8 (the ctor zero-block @0x4b3d18 zeroes the band)
myomerEffectiveness = 1.0f; // binary @0x79c: 0-init + per-frame MAX; the port holds the
// healthy 1.0 until the Myomers Performance feeder lands [T3]
if (GroundReal() && GetCollisionVolumeCount() > 0
&& collisionTemplate != 0 && collisionVolume != 0)
{
@@ -2290,6 +2625,13 @@ Logical
void
Mech::ReadUpdateRecord(Simulation::UpdateRecord *message)
{
// #108 ghost forensics: stamp the staleness detector on every applied
// record (replicants only -- the detector lives on the death handler).
if (GetInstance() == ReplicantInstance)
{
extern void BTMechNoteUpdateApplied(void *mech_v);
BTMechNoteUpdateApplied((void *)this);
}
// RECORD-CADENCE probe (BT_RXJIT): quantify how EVENLY records arrive (wall-clock
// ms between arrivals) -- the jitter that makes the peer's corrections random.
if (getenv("BT_RXJIT") && GetInstance() == ReplicantInstance)
+66 -2
View File
@@ -357,7 +357,11 @@ struct ShotDescriptor
enum ResetMode { MissionReviewReset = 0 }; // reset-mode selector (Reset arg, value 0)
void SetMappingSubsystem(Subsystem *mapper); // btl4app.cpp:567
Logical GetMissionReviewMode(); // reads this+0x414 (btl4mppr.cpp:366)
// ejectPermitted (@0x414) accessor -- the panic-arm watcher's read
// (btl4mppr InterpretControls @004d196c). [The old GetMissionReviewMode
// here was a mislabel of the same cell; the real review mode is the
// GLOBAL DAT_004fd550 (btl4pb.cpp). Corrected 2026-08-03.]
int GetEjectPermitted() const { return ejectPermitted; }
void SetTargetRange(Scalar range); // writes this+0x404 (btl4mppr.cpp:407)
void Reset(const Origin &origin, int mode); // btl4pb.cpp:555 (FUN_0049fb74)
@@ -650,6 +654,12 @@ public:
// binary part_012.c:9938-9940 + 9974-9975). By-name access only; declared
// after the layout-locked fields so nothing shifts.
Scalar standingTemplateMaxY; // binary @0x518 collisionTemplate->maxY at ctor
// #89 DEATH BLAST (raw disasm @0x4a07b8-0x4a0bda): the authored death-
// explosion pair, model "gamedata" keys -> ctor. PROMOTED from the Wword
// scratch bank (the bank is one GLOBAL array -- per-mech authored values
// were silently clobbered to the last-loaded chassis).
Scalar deathSplashDamage; // binary @0x520 <- model rec+0x78 "DeathSplashDamage"
Scalar deathSplashRadius; // binary @0x524 <- model rec+0x7C "DeathSplashRadius"
Scalar duckedTemplateMaxY; // binary @0x51c 0.6 x standing (duck preset)
Scalar templateBottomLift; // binary @0x4b8 0.05 x (volume maxX-minX)
@@ -739,6 +749,15 @@ protected:
// `stateFlags`): OR of every mounted weapon's rearFiring flag -- "this
// mech carries a rear arsenal". The look-back view arms those weapons.
int rearFiring; // @0x410 this[0x104]
int ejectPermitted; // @0x414 EvaluateEjectPermission (@0049fa1c) result:
// 1 = crippled enough that the PANIC/EJECT
// punch-out is allowed (binary refreshes per
// frame from an unexported caller; the port
// evaluates on demand in the 0x19 handler)
int ejectMinWeapons; // @0x448 live-weapon floor for the evaluator's first
// clause. NO writer in the exported decomp --
// zero-filled in the binary, so the clause is
// inert there too [T3]; init 0 to match.
// (task #68) the authored look-view angles (model record +0x50..0x5c,
// ctor converts degrees->radians; binary @0x564..0x570 -- previously
// parked in the Wword scratch bank). Consumed by the look-state
@@ -945,7 +964,15 @@ protected:
Scalar radarRange; // 0x2f RadarRange (scale/max)
Point3D *radarLinearPosition; // 0x30 RadarLinearPosition
Quaternion *radarAngularPosition; // 0x31 RadarAngularPosition
int duckState; // 0x37 DuckState (crouch posture)
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;
// (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
@@ -1013,6 +1040,17 @@ protected:
Scalar gimpStrideLength; // @0x350
AlarmIndicator legStateAlarm; // @0x39c
int legAnimationState; // @0x3b0
int mapPosture; // binary @0x3f8 -- the map-legend/duck posture arbiter
// (0 none / 1 may-duck / 2 holding-squat), computed per
// master frame (FUN_004a9b5c @0x4a9f61-0x4aa007; the
// CROUCH reconstruction, 2026-08-05)
Scalar myomerEffectiveness; // binary @0x79c -- the master-perf myomer chain MAX
// (speedEffect@0x31C over the myomer subsystems),
// published LIVE each mapper update by mechmppr.cpp's
// drive-scale block (the 2026-07-31 seek-audit recon of
// the same @0x4a9cf2-0x4a9da4 bytes). Consumed by the
// crouch posture gate (mech4). Ctor 1.0 = pre-first-
// update default only.
Scalar gimpSpeedMax; // @0x52c
Scalar standSpeed; // @0x530
Scalar walkStrideLength; // @0x534
@@ -1106,6 +1144,9 @@ protected:
// from its exported consumers + the RP VTV::DeathShutdown analog) ---
Logical IsMechDestroyed(); // damage-side death flag: graphicAlarm level >= 9
void UpdateDeathState(Scalar dt); // death freeze + subsystem DeathShutdown + wreck smoke
// --- #108 ghost-forensics accessors (PORT; the fields are protected) ---
void *DeathHandlerPtr() { return (void *)deathHandler; }
int WreckBuried() const { return collisionVolumeCount == 0; }
// --- clip loaders (mech3) -------------------------------------------
ResourceDescription::ResourceID *
@@ -1237,6 +1278,29 @@ protected:
enum { DuckRequestMessageID = 0x1a };
void DuckRequestMessageHandler(ReceiverDataMessageOf<int> *message);
// @0049f854 -- "EjectPilot" (Mech table id 0x19): the cockpit PANIC/
// EJECT punch-out (raw disasm 2026-08-02 -- the handler sat in the
// 0x49f854-0x49fa00 export gap). Press-only (msg+0xc > 0), gated on
// ejectPermitted (@0x414) and !IsDisabled(). Body: notify the operator
// console of the eject (FUN_004c198c ctor -> network sender; port core
// LOGS it, the relay wire is the tail) and latch the player's
// suppressConsole (+0x258) so the death that follows doesn't double-
// notify; graphicAlarm -> 10 (the EJECT state, one past death's 9);
// then SELF-dispatch a TakeDamageMessage {inflicting = SELF, zone -1,
// Explosive, amount = ScenarioRole::killBonus (role+0x1c -- field map
// byte-verified via the role reader @00429bec dest offsets + the ctor
// record copy), burst 0 (binary; our victim guard clamps to 1 --
// identical outcome: the charge is lethal)}. The punch-out IS a
// death: wreck, respawn cycle and replication all ride the normal
// damage chain.
enum { EjectPilotMessageID = 0x19 };
void EjectPilotMessageHandler(ReceiverDataMessageOf<int> *message);
// @0049fa1c -- the eject-permission evaluator (ejectPermitted @0x414):
// roster walk -- live weapons < ejectMinWeapons(@0x448) | zero live
// generators | HeatSinkBank coolant fraction < 0.05 (@0049fb50) |
// (leg-gimped state 3/4 AND novice, player simLive == 0).
int EvaluateEjectPermission();
// --- damage-routing support (mechdmg / mech4) -----------------------
// Typed access to the inherited Entity::damageZones[] (engine stores DamageZone*;
// our entries are Mech__DamageZone, populated by the Mech ctor). Defined in
+63 -1
View File
@@ -220,6 +220,16 @@ enum MechAnimationState
void
Mech::SetLegAnimation(int state)
{
// DIAG (BT_DUCK_LOG): every leg re-arm with the caller's return address --
// the crouch-clobber hunt (who re-arms after the squat parks?).
if (getenv("BT_DUCK_LOG"))
{
char dbuf[96];
sprintf(dbuf, "[duck] SetLegAnimation(%d) ra=btl4+0x%lx", state,
(unsigned long)_ReturnAddress()
- (unsigned long)GetModuleHandleA(0));
DEBUG_STREAM << dbuf << std::endl << std::flush;
}
legAnimation.SelectSequence( // FUN_004277a8(this+0x65c, ...)
animationClips[state], // *(this+0x5cc + state*4)
// The real leg finished-callback PTR_LAB_0050d6f0 == FUN_004a6928
@@ -520,6 +530,8 @@ Scalar
return 0.0f;
// slot10 state 2 (0x4a6b37): SetLevel(1).
case 2:
if (getenv("BT_DUCK_LOG"))
DEBUG_STREAM << "[duck] squat clip parked (case 2 -> leg alarm 1)\n" << std::flush;
m->legStateAlarm.SetLevel(1); return 0.0f;
// slot2 (0x4a6b21) + slot1 state 32 (0x4a6b4d) + slot9 state 4 (0x4a6d6e): SetLevel(0).
case 3: case 4: case 8: case 9: case 20: case 21:
@@ -1234,6 +1246,10 @@ 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)`
@@ -1285,7 +1301,35 @@ Scalar
}
SetBodyAnimation(0x10);
}
// FALLTHROUGH
// 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)
case 4: // TURN-IN-PLACE, LOCKSTEP twin (task #64)
// The body channel runs trn in LOCKSTEP with the leg: armed together at
@@ -1312,6 +1356,23 @@ 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;
@@ -1335,6 +1396,7 @@ 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
+1475 -116
View File
File diff suppressed because it is too large Load Diff
+204 -7
View File
@@ -1193,8 +1193,21 @@ extern void BTSpawnDamageEffect(Mech *mech, int effect_resource, int segment_ind
// unported "RemakeEntity" render state). Entity* param -> Mech* binds fine.
extern void BTRemakeMechModel(Entity *entity);
//
// #108 bridge: Mech::ReadUpdateRecord (mech.cpp, incomplete-type TU for the
// handler) stamps the ghost detector through this.
//
void
BTMechNoteUpdateApplied(void *mech_v)
{
Mech *m = (Mech *)mech_v;
if (m != 0 && m->DeathHandlerPtr() != 0)
((MechDeathHandler *)m->DeathHandlerPtr())->NoteUpdateApplied();
}
MechDeathHandler::MechDeathHandler(Mech *mech) // @0042a984
: owner(mech), prevMode(0)
: owner(mech), prevMode(0), ghostFrames(0), ghostLogged(0),
skatePrevX(0.0f), skatePrevZ(0.0f), skateFrames(0), skateLogged(0)
{
// per-zone last-damage cache, zeroed (binary this[0x10], size mech+0x11c).
int count = (mech != 0 && mech->damageZoneCount > 0) ? mech->damageZoneCount : 0;
@@ -1217,6 +1230,117 @@ void
if (owner == 0)
return;
// #52 SKATE DETECTOR (PORT, ungated -- the night-12 forensic): a REPLICANT
// whose position advances while its LEG CHANNEL is not cycling is the
// field "skating" signature. One line per episode, carrying the three
// inputs that split the failure: bodyTargetSpeed (the replicated commanded
// speed the leg SM feeds from -- ~0 means the RECORD side starved the
// gait), legCycleSpeed (~0 with a live demand means the SM is STUCK), and
// IsMechDestroyed (1 means the gait feed block was SKIPPED -- the stale
// post-respawn flag suspect). ~90 frames of sustained skate to trip; the
// threshold 0.08 u/frame ~ 2.4 u/s at 30 fps, well above reckoner jitter.
if (owner->GetInstance() == Entity::ReplicantInstance)
{
const float px = (float)owner->localOrigin.linearPosition.x;
const float pz = (float)owner->localOrigin.linearPosition.z;
const float dx = px - skatePrevX, dz = pz - skatePrevZ;
const float step = sqrtf(dx * dx + dz * dz);
skatePrevX = px; skatePrevZ = pz;
// CHANNEL-AGNOSTIC (corrected same-day): the peer's joints are posed by
// the BODY channel in the current authentic architecture (mech4
// s_peerLegCh=0 -> AdvanceBodyAnimation mj=1), so legCycleSpeed==0 is
// NORMAL there -- the first detector build false-fired on every healthy
// movement phase. Skating = moving with BOTH channels idle.
const float cyc = fabsf((float)owner->legCycleSpeed)
+ 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",
BTMatchHostOf(owner->GetEntityID()), (int)owner->GetEntityID(),
skateFrames, step, cyc,
(float)owner->bodyTargetSpeed,
(int)owner->bodyAnimationState,
(int)owner->IsMechDestroyed(), (int)owner->MovementMode(),
px, pz);
}
}
else if (skateFrames > 0)
{
if (skateLogged)
DEBUG_STREAM << "[skate] replicant " << owner->GetEntityID()
<< " recovered after " << skateFrames << " frames\n" << std::flush;
skateFrames = 0; skateLogged = 0;
}
}
// #108 GHOST DETECTOR (PORT, ungated -- the night-9 forensic): a visible
// REPLICANT that stops receiving update records is exactly what "everyone
// shoots thin air" looks like from the other side. ReadUpdateRecord
// stamps arrivals (NoteUpdateApplied); ~20s of silence on an unburied
// replicant logs ONE [ghost] line per episode with the identity + last
// position, then re-arms when records resume. Frame-counted (Tick has no
// dt); 600 frames ~ 10-20s at field rates.
if (owner->GetInstance() == Entity::ReplicantInstance)
{
++ghostFrames;
if (ghostFrames > 600 && !ghostLogged
&& !owner->WreckBuried()) // buried wrecks are expected-silent
{
ghostLogged = 1;
DEBUG_STREAM << "[ghost] replicant " << owner->GetEntityID()
<< " has received NO update records for " << ghostFrames
<< " frames (mode " << (int)owner->MovementMode()
<< ") last pos (" << owner->localOrigin.linearPosition.x
<< "," << owner->localOrigin.linearPosition.z
<< ") -- stale-replicant / ghost signature (#108)\n" << std::flush;
if (BTMatchLogActive())
BTMatchLog("GHOST", "mech=%d:%d frames=%d mode=%d x=%.1f z=%.1f",
BTMatchHostOf(owner->GetEntityID()), (int)owner->GetEntityID(),
ghostFrames, (int)owner->MovementMode(),
(float)owner->localOrigin.linearPosition.x,
(float)owner->localOrigin.linearPosition.z);
}
}
// REPLICANT UN-WRECK (#94): fire on the DEATH-STATE EXIT edge of the
// replicated SimulationState dial (MovementMode 9/2 are the death modes;
// they never revert except through Mech::Reset, whose SetMovementMode(0)
@@ -1234,20 +1358,55 @@ 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)
{
extern void BTRebuildMechModel(Entity *entity);
extern void BTStartWarpEffect(float x, float y, float z);
// #129 (fresh spawns smoking, peers' screens only): the wreck plume
// is re-armed LOCALLY on every node (entity-attached, 10s windows),
// and only the master's Mech::Reset ran the @004d0c14 effect
// cleanup -- the observer's last window rode the teleport and kept
// puffing on the fresh mech for up to ~16s. Kill the entity's
// effects on the un-wreck edge, same as the master path.
extern void BTStopEntityPfx(void *entity_v);
BTStopEntityPfx((void *)owner);
BTRebuildMechModel((Entity *)owner);
BTStartWarpEffect((float)owner->localOrigin.linearPosition.x,
(float)owner->localOrigin.linearPosition.y,
(float)owner->localOrigin.linearPosition.z);
if (getenv("BT_DEATH_LOG"))
DEBUG_STREAM << "[respawn] replicant un-wrecked + warp (mode "
<< oldMode << "->" << mode << ") at ("
<< owner->localOrigin.linearPosition.x << ","
<< owner->localOrigin.linearPosition.z << ")\n" << std::flush;
// #108 forensics: UNGATED -- one line per peer respawn; a field
// night's ghost triage needs these without asking players for envs.
DEBUG_STREAM << "[respawn] replicant " << owner->GetEntityID()
<< " un-wrecked + warp (mode "
<< oldMode << "->" << mode << ") at ("
<< owner->localOrigin.linearPosition.x << ","
<< owner->localOrigin.linearPosition.z << ")\n" << std::flush;
}
}
@@ -1275,6 +1434,20 @@ void
continue;
lastLevel[i] = level;
// AUTHENTIC (FUN_0042aa2c @0042aa2c, the binary's effect-watcher level
// branch): when the rise CROSSES a band-descriptor threshold
// (FUN_0042a5f4) and this node owns the master, raise the entity's
// damage-zone update flag (*(entity+0x18) |= 2, master-gated on
// (entity+0x28 & 0xc) == 0) -- the zone update records ship this
// zone's damageLevel to every observer, so replicant dolls/hull tint
// track the fight at band granularity. This send was MISSING from
// the port (only the graphic-state branch below raised it): masters
// consumed damage correctly while every observer's copy sat at 0.0
// until destruction (the "no discoloration in MP" report, #87).
if (zone->DescriptorCrossed(prev, level)
&& owner->GetInstance() == Entity::MasterInstance)
owner->ForceUpdate(Entity::DamageZoneUpdateModelFlag);
// AUTHENTIC GATE (decomp re-verified 2026-07-12, workflow): the binary
// fires the CURRENT band descriptor's effect whenever the zone's
// damage-CHANGED flag is set (+0xb8 & 4, set by DamageZone::TakeDamage,
@@ -1326,7 +1499,12 @@ void
// The single-threaded frame loop makes the immediate swap safe.
if (d->graphicState != DamageZone::ExistsGraphicState)
{
owner->ForceUpdate(Entity::DamageZoneUpdateModelFlag);
// Master-gated like the binary's gstate branch (@0042aa2c:
// (entity+0x28 & 0xc) == 0 guards the |= 2); the mesh swap
// runs on EVERY instance -- an observer node executes this
// same path when the replicated level/gstate lands.
if (owner->GetInstance() == Entity::MasterInstance)
owner->ForceUpdate(Entity::DamageZoneUpdateModelFlag);
BTRemakeMechModel(owner); // RemakeEntity: swap in the destroyed mesh
}
if (getenv("BT_DEATH_LOG"))
@@ -1438,3 +1616,22 @@ void BTAmmoExplosionFanOut(
((Mech *)owner_mech)->AmmoExplosionFanOut(
(::Subsystem *)inflicting_subsystem, inflicting_subsystem_id, total_damage);
}
//
// #124 ZONE WALKER bridge: a zone's carrier SEGMENT index + name, complete-
// type TU (Mech__DamageZone). Replicant-safe -- the zone table streams on
// every instance, so the SHOOTER can enumerate its target's zones locally.
// Returns 0 past the end (the walker wraps on that).
//
int BTMechZoneSegAndName(void *mech_v, int zone_idx, int *seg_out, const char **name_out)
{
Mech *m = (Mech *)mech_v;
if (m == 0 || zone_idx < 0 || zone_idx >= m->damageZoneCount)
return 0;
Mech__DamageZone *dz = m->Zone(zone_idx);
if (dz == 0)
return 0;
*seg_out = dz->EffectSegmentIndex(); // public accessor (@0x194)
*name_out = (const char *)dz->damageZoneName; // engine base, public
return 1;
}
+13
View File
@@ -363,6 +363,11 @@ class MechDeathHandler
MechDeathHandler(Mech *mech); // @0042a984
~MechDeathHandler(); // @0042a9f4
void Tick(); // @0042aa2c (the Performance)
// #108 forensics (PORT, ungated): the replicant staleness detector.
// Mech::ReadUpdateRecord stamps every applied record; Tick counts
// frames since -- a visible replicant starved of records IS the ghost
// signature, logged once per episode with id + age + last position.
void NoteUpdateApplied() { ghostFrames = 0; ghostLogged = 0; }
private:
Mech *owner; // @0x14
@@ -378,6 +383,14 @@ class MechDeathHandler
// authentic un-wreck rides the type-0 graphic-state hook
// (Mech::ReadUpdateRecord case 0), which our port has not reconstructed.
int prevMode;
// #108 ghost detector state (PORT)
int ghostFrames;
int ghostLogged;
// #52 skate detector state (PORT): position advancing while the leg
// channel is not cycling = the field "skating" signature.
float skatePrevX, skatePrevZ;
int skateFrames;
int skateLogged;
};
#endif
+142 -27
View File
@@ -508,25 +508,69 @@ void
}
NotifyOfControlModeChange(controlMode); // vtable+0x48
// 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).
// 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.
Mech *mech = GetMech();
Torso *torso = (mech != 0) ? (Torso *)mech->GetTorsoSubsystem() : 0;
if (torso != 0)
{
if (controlMode == BasicMode)
{
torso->SetAnalogTwistAxis(0.0f);
torso->SetAnalogElevationAxis(0.0f);
torso->CommandRecenter();
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
}
// 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;
@@ -660,6 +704,58 @@ 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 /
@@ -840,6 +936,15 @@ void
extern float gBTElevAxis;
stickPosition.y = gBTElevAxis;
}
// #124 twist-sign bench probe: is the axis routed, and in what mode?
if (getenv("BT_TORSO_LOG"))
{
static int s_mp = 0;
if ((s_mp++ % 60) == 0 && s_mp < 1200)
DEBUG_STREAM << "[mppr] mode=" << (int)controlMode
<< " twistAxis=" << gBTTwistAxis
<< " keyTurn=" << key_turn << "\n" << std::flush;
}
if (controlMode == BasicMode)
{
stickPosition.x = key_turn;
@@ -862,21 +967,12 @@ 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).
{
Torso *rcTorso = (Torso *)mech->GetTorsoSubsystem();
if (rcTorso != 0)
{
if (gBTTorsoRecenter)
{
gBTTorsoRecenter = 0;
rcTorso->CommandRecenter();
}
else
{
rcTorso->ClearRecenterCommand();
}
}
}
// (#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.
}
}
// (stickPosition.y no longer zeroed here -- the bridge above
@@ -1009,6 +1105,25 @@ void
if (myomers > 0)
{
speedDemand *= drive; // @0x4a9d63: in-place demand scale
// The binary keeps the chain MAX in mech+0x79C (@0x4a9d00-0x4a9d39)
// -- publish it so the CROUCH gate (mech4 posture block, 2026-08-06)
// 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
}
+8
View File
@@ -287,6 +287,14 @@ void MechSubsystem::ForceCriticalFailure()
{
((DamageZone *)damageZone)->SetDamageZoneState(1); // zone+0x10
}
// (A MarkGeneratorOut call lived here 2026-08-03 for a few hours --
// REMOVED same night when the alarm dig completed: the gauge-alarm
// conditions are STATUS-FLAG BITS scanned by MechTech, not stateAlarm
// levels. Generator death raises bit 6 (!HasVoltage) AUTHENTICALLY ->
// the gotoEngineering + engBusMode invite; conditions 4/5 (engEject)
// are the WEAPON's AmmoBurning/Jammed bits -- the ammo-purge invite,
// never a pilot-eject one. stateAlarm 4's real producer remains
// unfound (export holes) and nothing observable needs it.)
}
// issue #22 (PORT): the fresh-mech respawn repair -- the exact inverse of the
+7
View File
@@ -341,6 +341,13 @@ class Damage;
public:
static Logical
LookupStatusType(const char *name, int *out_value);
// EJECT-evaluator support (@0049fa1c bridges): the both-cells
// destroyed test (gotcha #22 -- the binary's ONE cell @0x40 is split
// into statusAlarm + simulationState here, so every cross-family
// destroyed check must read BOTH).
int SimulationStateCell() const { return simulationState; }
int StatusAlarmLevel() { return statusAlarm.GetLevel(); }
protected:
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+9
View File
@@ -197,6 +197,15 @@ MechTech::MechTech(
wooHooMinimumDuration = model->wooHooMinimumDuration; // res +0x34 -> this+0xF4
wooHooDurationRange = model->wooHooDurationRange; // res +0x38 -> this+0xF8
wooHooChance = model->wooHooChance; // res +0x3C -> this+0xFC
// DIAG (#119 leak-voice dig): the AUTHORED anti-spam latch tuning.
// No armer for the wooHoo latch exists anywhere in the flat export --
// if the authored chance is 0 the latch was dormant in 4.10 too and
// the repeating warning voice under multiple leaks is AUTHENTIC.
if (getenv("BT_LAMP_LOG"))
DEBUG_STREAM << "[techstat] wooHoo authored: minDur="
<< wooHooMinimumDuration << " range=" << wooHooDurationRange
<< " chance=" << wooHooChance
<< " alarmModel=" << alarmModel << "\n" << std::flush;
//
// Walk the owner's subsystem roster and attach a monitor to every
+16 -9
View File
@@ -740,15 +740,12 @@ void
target->Dispatch(&message); // no manager: direct (bring-up)
}
// PORT bookkeeping (score lived in the retired mech4 fire block): credit
// the delivered amount to the shooter-side score exactly as before.
extern void BTPostDamageScore(Entity *victim, Scalar damage);
extern Logical BTIsRegisteredMech(Entity *e);
if (BTIsRegisteredMech(target)
&& !((Mech *)target)->IsMechDestroyed())
{
BTPostDamageScore(target, damageData.damageAmount);
}
// SCORE: handled by the victim's own TakeDamage handler now -- the
// dispatch above lands in the authentic report tail (#45), which posts
// the kill/received reports itself. The shooter-side BTPostDamageScore
// call that lived here was RETIRED 2026-08-05 (per-hit inflicted credit
// never existed in the 1995 pod; kills + received-damage penalties only).
extern Logical BTIsRegisteredMech(Entity *e); // still read by the matchlog below
// MP MATCH FORENSICS (matchlog.hpp): the shooter-side per-shot damage
// submission -- pairs with the victim's DMG application line. mech=1
@@ -994,3 +991,13 @@ void BTWeaponSetViewFireEnable(Subsystem *sub, int enable)
if (sub != 0 && sub->IsDerivedFrom(MechWeapon::ClassDerivations))
((MechWeapon *)sub)->SetViewFireEnable(enable);
}
// #124 aspect probe: a weapon's physical muzzle WORLD position (the pod's own
// asymmetric geometry as a left/right anchor). -1-family returns 0.
int BTWeaponMuzzleWorld(Subsystem *sub, Point3D *out)
{
if (sub == 0 || out == 0 || !sub->IsDerivedFrom(MechWeapon::ClassDerivations))
return 0;
((MechWeapon *)sub)->MuzzlePoint(*out); // the public beam-render alias
return 1;
}
+1
View File
@@ -360,6 +360,7 @@ class CockpitHud;
int PipExtendedRange() const { return pipExtendedRange; }
void *WithinRangePtr() { return &targetWithinRange; }
void *WeaponAlarmPtr() { return &weaponAlarm; }
int WeaponAlarmLevel() { return weaponAlarm.GetLevel(); } // @0x364 (eject evaluator: 7 = NoAmmo)
void *SimulationStatePtr() { return &simulationState; } // attr 1 ("SimulationState" -- damage state; 1 = Destroyed)
Scalar *RechargeLevelPtr() { return &rechargeLevel; } // the recharge-dial source (1.0 == fully charged); LIVE on BOTH families
// (projectile weapons write it via slot 17 @004b9c9c — Gitea #12)
+25 -9
View File
@@ -20,9 +20,23 @@
// excluded : the Projectile base ctor @004be1bc and the 0x41xxxx/0x42xxxx
// Entity engine vtable slots
//
// 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
// 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.
//
// Helper-function name mapping:
// FUN_004be1bc Projectile base constructor (vtable @00512a5c)
@@ -53,12 +67,14 @@
//
// MoveAndCollide tuning constants (read-only globals in the decomp).
//
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
// ⚠ 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)
//###########################################################################
//###########################################################################
+54 -28
View File
@@ -762,35 +762,36 @@ void Myomers::MyomersDriveHeat(Scalar time_slice)
// exists because the binary's per-tick value depends on the pod's frame
// rate, which our port does not run at.
//
// THE CLOCK ORIGIN, traced 2026-08-02 (was the open question here):
// the DOS binary keeps its engine frame rate in the global DAT_0052140c,
// set once at startup --
// 0x401ace: mov [0x52140c], 0x41E00000 = 28.0f (nominal)
// 0x401ada: mov [0x52140c], 0x4191A6E0 = 18.2065f (BIOS-tick fallback)
// -- and consumed by ~90 fmul/fdiv sites across the image as THE
// per-frame<->per-second conversion. The DOS main passes it straight into
// the ApplicationManager ctor (0x401189: push [0x52140c]). So the pod's
// nominal Performance cadence was 28 Hz, byte-proven. [T1]
// THE CLOCK -- CLOSED 2026-08-02, all [T1] from bytes:
// * DAT_0052140c = SystemClock ticksPerSecond, 28.0 nominal (0x401ace) /
// 18.2065 BIOS fallback (0x401ada).
// * The arcade Simulation::PerformAndWatch is FUN_0041c018:
// slice = (till.ticks - lastPerformance.ticks) / DAT_0052140c;
// lastPerformance = till; (*performance)(this, slice);
// -- VARIABLE-STEP, slice in real SECONDS, line-for-line the WinTesla
// SIMULATE.cpp body. Every dt-carrying term is frame-rate independent
// in BOTH the binary and the port; no unit conversion is missing.
// * This term's ONE caller is the unexported MyomersSimulation body
// (0x4b8b9a-0x4b8d0d, raw disasm 2026-08-02 -- the export gap hid it):
// it calls 0x4b8d18 once per Perform with the RAW slice, gated only on
// measuredVoltage > 0. So the kinetic energy pulse fires once per
// FRAME, and the pod's frame cadence is its tick rate: 28 Hz when
// holding the deadline, sagging under load (fewer pulses/s -- an
// EMERGENT hardware mercy, not a constant in the code).
//
// CALIBRATION FACTOR 0.5 -- the pod's EFFECTIVE rate under load, now
// corroborated from three directions (2026-08-02):
// * Oracle (pod veteran), asked if the frame rate was rock solid:
// "I think I could do as well with a flipbook"; heavy fights "would
// turn to a slide show".
// * Lynx (original-era): "RP had a target frame rate of 30FPS. It
// wasn't hard coded or locked. BT never had a locked frame rate in
// 1st release testing... coming from 3.0, where 20FPS was a miracle."
// * The engine's own pacer DESIGN: end_of_frame = Now() + frameDuration
// with background fill and NO overrun catch-up (APPMGR.cpp, T0) --
// "target 28 but allowed to slip" is the construction, and the
// 18.2065 BIOS fallback anticipated degraded timing.
// Corollary: a choking 486 generated LESS heat/s exactly during the
// biggest fights -- veteran memory of seek-4 endurance encodes the LOADED
// rate, so bracketing by their consensus is the correct instrument.
// Effective default 28 * 0.5 = 14 Hz [T2 multi-witness]; BT_MYO_HZ=<hz>
// overrides absolutely for bracketing.
// The faithful reference is therefore the binary's own nominal cadence,
// 28 Hz, full stop. The old 0.5 "loaded-rate" calibration [T3 witness
// bracket] is RETIRED: it modeled the sag as a constant, which is wrong in
// exactly the reported scenario (open-field cruising is a LIGHT scene --
// the pod held ~28 there). Measured at 28 Hz (cruise benches 2026-08-02):
// recommended gear equilibrates ~1450 (soft 20% output governor inside the
// authored degradation band, weapons unaffected, zero jams); supercharge
// ~1770 (deep governor, the manual's own warning). That IS the authored
// system: degradeT=1000 is a governor onset, failT=2000 the cliff, and the
// pod's coolant-loop controls (flush/balance, Myomers on loop 5) are the
// player's lever. BT_MYO_HZ=<hz> remains as the bracketing override.
static const Scalar kPodFrameHz = 28.0f; // byte-proven nominal [T1]
static const Scalar kSeekHeatCalib = 0.5f; // Oracle bracket 2026-08-02 [T3]
static const Scalar kSeekHeatCalib = 1.0f; // faithful (0.5 bracket retired 2026-08-02)
static Scalar s_hz = -1.0f;
if (s_hz < 0.0f)
{
@@ -800,7 +801,7 @@ void Myomers::MyomersDriveHeat(Scalar time_slice)
if (getenv("BT_MYO_LOG"))
DEBUG_STREAM << "[myoheat] kinetic-term reference rate: " << s_hz
<< " Hz (pod nominal 28 [T1] x calib " << kSeekHeatCalib
<< " [T3]" << (hv ? ", ENV OVERRIDE" : "") << ")" << std::endl;
<< (hv ? ", ENV OVERRIDE" : "") << ")" << std::endl;
}
Scalar termClimb = velComplement * vy * mass * gravity * time_slice;
@@ -808,6 +809,31 @@ 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"))
+84 -3
View File
@@ -1295,6 +1295,49 @@ 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
@@ -1305,10 +1348,10 @@ void
{
// The THERMAL BREAKER: output collapses to 0 until the generator cools
// past NormalHeat and re-spins (task #11 diag below shows the duty cycle).
if (stateAlarm.GetLevel() != GeneratorRecovered && getenv("BT_HEAT_LOG"))
if (stateAlarm.GetLevel() != GeneratorOut && getenv("BT_HEAT_LOG"))
DEBUG_STREAM << "[gen] " << GetName() << " TRIPPED (T="
<< currentTemperature << ")" << std::endl;
stateAlarm.SetLevel(GeneratorRecovered); // SetLevel 4
stateAlarm.SetLevel(GeneratorOut); // SetLevel 4
}
switch (stateAlarm.GetLevel()) // this[0x84] @0x210
@@ -1337,7 +1380,7 @@ void
}
break;
case GeneratorRecovered: // 4
case GeneratorOut: // 4
outputVoltage = 0.0f;
if (heatAlarm.GetLevel() == HeatSink::NormalHeat) // this+0x184 == 0
{
@@ -1778,3 +1821,41 @@ 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
// fires and the eject evaluator's state clause never sees it. Called from
// MechSubsystem::ForceCriticalFailure (the one crit-death chokepoint).
void BTGeneratorMarkOut(::Subsystem *sub)
{
if (sub != 0 && sub->IsDerivedFrom(*Generator::GetClassDerivations()))
((Generator *)sub)->MarkGeneratorOut();
}
//===========================================================================//
// BTGeneratorCountsForEject -- complete-type bridge for the EJECT-permission
// evaluator (Mech::EvaluateEjectPermission @0049fa1c). Binary clause: classID
// 0xBC1, not destroyed (+0x40 != 1; both-cells here per gotcha #22), and
// stateAlarm level (@0x210) != 4. Returns -1 = not a generator, else 0/1.
//===========================================================================//
int BTGeneratorCountsForEject(Subsystem *sub)
{
if (sub == 0 || sub->GetClassID() != RegisteredClass::GeneratorClassID) // +4 == 0xbc1
return -1;
Generator *g = (Generator *)sub;
if (g->SimulationStateCell() == 1 || g->StatusAlarmLevel() == 1)
return 0; // destroyed
return (g->GeneratorStateOf() != 4) ? 1 : 0; // stateAlarm @0x210 != 4
}
+20 -1
View File
@@ -379,7 +379,12 @@ class Generator;
GeneratorIdle = 1, // off / no output
GeneratorReady = 2, // producing RatedVoltage
GeneratorShorted = 3, // shorted; shortTimer -> shortRecoveryTime
GeneratorRecovered = 4 // TODO: confirm 4th/5th level names
GeneratorOut = 4 // generator DEAD/OUT: the eject evaluator counts
// stateAlarm==4 as not-live [T1 @0049fa1c], and the
// authored gauge-alarm streams flash gotoEngineering +
// engEject on conditions 4/5 [T1 content dump
// 2026-08-03] -- the "punch out now" invite.
// (Old name "GeneratorRecovered" was a placeholder.)
};
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -462,6 +467,20 @@ class Generator;
// Source-state queries used by PoweredSubsystem / PowerWatcher.
int
GeneratorStateOf() { return stateAlarm.GetLevel(); } // source+0x210
// UNUSED -- and now PROVEN unnecessary (2026-08-04 dig): stateAlarm 4
// (GeneratorOut) is the THERMAL BREAKER state, produced by
// GeneratorSimulation itself (heatAlarm==FailureHeat -> SetLevel 4,
// byte-matched vs FUN_004b1f7c) -- never by destruction. The gauge
// alarms scan STATUS-FLAG BITS via MechTech (bit 6 = !HasVoltage);
// a generator destroyed IN PLACE keeps its stale Ready voltage in
// the BINARY TOO (the crit distributor @0049c9a8 touches nothing
// electrical) until any transition recomputes output through the
// (1 - damage) formula. A short-lived ForceCriticalFailure bridge
// to this method was removed as unfounded; keep the method only as
// documentation of the state's meaning.
void
MarkGeneratorOut() { stateAlarm.SetLevel(GeneratorOut); }
Scalar
MeasuredVoltage() { return outputVoltage; } // source+0x1dc
Scalar
+45
View File
@@ -268,6 +268,17 @@ void *
? (void *)((ProjectileWeapon *)weapon)->ammoBinLink.Resolve() : 0;
}
// Eject-wipe gauge bridge (#118): the BallisticWeaponCluster's
// BitMapInverseWipeScalar watches the weapon's PercentOfEject (@0x3F8, the
// binary passes param_8+0x3f8 straight into the @004c61c8 ctor). Complete-
// type TU per the databinding rule -- never raw-offset a compiled object.
Scalar *
BTWeaponPercentOfEjectPtr(void *weapon)
{
return (weapon != 0)
? &((ProjectileWeapon *)weapon)->percentOfEject : 0;
}
//#############################################################################
// Construction / Destruction
@@ -568,6 +579,18 @@ 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)
@@ -1242,3 +1265,25 @@ Subsystem *CreateProjectileWeaponSubsystem(Mech *owner, int id, void *seg)
return (Subsystem *) new (Memory::Allocate(0x448))
ProjectileWeapon(owner, id, (ProjectileWeapon::SubsystemResource *)seg, ProjectileWeapon::DefaultData);
}
//===========================================================================//
// BTWeaponCountsForEject -- complete-type bridge for the EJECT-permission
// evaluator (Mech::EvaluateEjectPermission, @0049fa1c; issue: eject/panic).
// The binary's weapon clause: MechWeapon-derived (0x511830), not destroyed,
// and -- for the ProjectileWeapon family only (0x5121a8) -- weaponAlarm
// (@0x364) != 7 (NoAmmo): a dry ballistic rack is not a fighting weapon.
// Destroyed is the BOTH-CELLS test (gotcha #22: statusAlarm level + the
// port's split simulationState). Returns -1 = not a weapon, else 0/1.
//===========================================================================//
int BTWeaponCountsForEject(Subsystem *sub)
{
if (sub == 0 || !sub->IsDerivedFrom(MechWeapon::ClassDerivations))
return -1;
MechWeapon *w = (MechWeapon *)sub;
if (w->SimulationStateCell() == 1 || w->StatusAlarmLevel() == 1)
return 0; // destroyed (both cells)
if (sub->IsDerivedFrom(ProjectileWeapon::ClassDerivations)
&& ((ProjectileWeapon *)sub)->WeaponAlarmLevel() == 7)
return 0; // NoAmmo (@0x364 == 7)
return 1;
}

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